app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 6a9358fbc09d3255ea861045d26395a9b65f7d01
parent 4802b340e599152e8d22a23ffe4eeba8f00e6279
Author: triesap <tyson@radroots.org>
Date:   Wed,  3 Jun 2026 00:50:39 -0700

app: publish active trade lifecycle events

- add typed lifecycle publish payloads for cancellation, fulfillment, and receipt

- publish app lifecycle work through canonical SDK trade methods

- route seller and buyer order actions through localized lifecycle controls

- cover lifecycle payload validation and relay publishing

Diffstat:
Mcrates/desktop/src/runtime.rs | 1052++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/desktop/src/source_guards.rs | 17+++++++++++++----
Mcrates/desktop/src/window.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/i18n/src/keys.rs | 2++
Mcrates/i18n/src/lib.rs | 10+++++++++-
Mcrates/sync/src/lib.rs | 11++++++-----
Mcrates/sync/src/publish.rs | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mi18n/locales/en/messages.json | 4+++-
8 files changed, 1401 insertions(+), 56 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -32,28 +32,30 @@ use radroots_app_state::{ derive_sync_projection, }; use radroots_app_sync::{ - AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderDecisionInventoryCommitment, - AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderRequestItemPayload, - AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload, - AppPublishedOperationReceipt, AppRelayIngestScopeFreshness, AppSyncProjection, AppSyncRequest, - AppSyncResult, AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, + AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, + AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, + AppOrderFulfillmentPublishPayload, AppOrderFulfillmentPublishStatus, + AppOrderReceiptPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, + AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, + AppRelayIngestScopeFreshness, AppSyncProjection, AppSyncRequest, AppSyncResult, + AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, SyncAggregateRef, SyncCheckpointStatus, SyncConflictSeverity, SyncOperationKind, SyncTrigger, }; use radroots_app_view::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, - BuyerCheckoutDraft, BuyerContext, BuyerOrderDetailProjection, BuyerProductDetailProjection, - FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmRulesProjection, FarmSetupDraft, - FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, - LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrderRecoveryProjection, - OrderStatus, OrdersFilter, OrdersListProjection, OrdersScreenQueryState, - PackDayBatchPrintStatus, PackDayExportBundle, PackDayExportInstanceId, PackDayExportStatus, - PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPrintKind, PackDayPrintStatus, - PackDayProjection, PackDayScreenQueryState, PersonalSection, PickupLocationRecord, - ProductEditorDraft, ProductId, ProductStatus, ProductsFilter, ProductsListProjection, - ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, RecoveryState, - ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId, - ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, + BuyerCheckoutDraft, BuyerContext, BuyerOrderDetailProjection, BuyerOrderStatus, + BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, + FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, + FulfillmentWindowId, LoggedOutStartupProjection, OrderDetailProjection, OrderId, + OrderRecoveryProjection, OrderStatus, OrdersFilter, OrdersListProjection, + OrdersScreenQueryState, PackDayBatchPrintStatus, PackDayExportBundle, PackDayExportInstanceId, + PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPrintKind, + PackDayPrintStatus, PackDayProjection, PackDayScreenQueryState, PersonalSection, + PickupLocationRecord, ProductEditorDraft, ProductId, ProductStatus, ProductsFilter, + ProductsListProjection, ProductsSort, RecoveryKind, RecoveryQueueProjection, RecoveryRecordId, + RecoveryState, ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, + ReminderId, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, }; @@ -82,8 +84,9 @@ use radroots_sdk::listing::{ RadrootsListingStatus, }; use radroots_sdk::trade::{ - RadrootsTradeInventoryCommitment, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, - RadrootsTradeOrderRequested, + RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt, + RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled, + RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, }; use radroots_sdk::{ RadrootsNostrEventPtr, RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment, @@ -178,6 +181,26 @@ struct ResolvedAppSellerOrderRequest { payload: RadrootsTradeOrderRequested, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResolvedAppOrderDecisionEvidence { + event_id: String, + payload: RadrootsTradeOrderDecisionEvent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResolvedAppOrderFulfillmentEvidence { + event_id: String, + status: RadrootsActiveTradeFulfillmentState, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct ResolvedAppOrderLifecycleEvidence { + decision: Option<ResolvedAppOrderDecisionEvidence>, + latest_fulfillment: Option<ResolvedAppOrderFulfillmentEvidence>, + cancellation_event_id: Option<String>, + receipt_event_id: Option<String>, +} + #[derive(Debug, Default)] struct AppDirectRelayIngestReport { local_import: AppLocalInteropImportReport, @@ -340,6 +363,9 @@ fn publish_payload_context(publish_payload: &AppPublishPayload) -> &AppPublishCo AppPublishPayload::Listing(payload) => &payload.context, AppPublishPayload::OrderRequest(payload) => &payload.context, AppPublishPayload::OrderDecision(payload) => &payload.context, + AppPublishPayload::OrderCancellation(payload) => &payload.context, + AppPublishPayload::OrderFulfillment(payload) => &payload.context, + AppPublishPayload::OrderReceipt(payload) => &payload.context, } } @@ -719,6 +745,30 @@ impl DesktopAppRuntime { ) } + pub fn publish_order_ready_for_pickup( + &self, + order_id: OrderId, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut().publish_seller_order_fulfillment( + order_id, + AppOrderFulfillmentPublishStatus::ReadyForPickup, + ) + } + + pub fn publish_order_delivered(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .publish_seller_order_fulfillment(order_id, AppOrderFulfillmentPublishStatus::Delivered) + } + + pub fn publish_buyer_order_cancel(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .publish_buyer_order_cancellation(order_id) + } + + pub fn publish_buyer_order_receipt(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { + self.lock_state_mut().publish_buyer_order_receipt(order_id) + } + pub fn start_order_recovery( &self, order_id: OrderId, @@ -2304,7 +2354,7 @@ impl DesktopAppRuntimeState { reason: "seller order decision requires configured relays", }); } - self.refresh_configured_relay_state_before_seller_order_decision()?; + self.refresh_configured_relay_state_before_order_lifecycle()?; let Some(sqlite_store) = self.sqlite_store.as_ref() else { return Err(AppSqliteError::InvalidProjection { reason: "seller order decision requires local state", @@ -2397,7 +2447,7 @@ impl DesktopAppRuntimeState { Ok(payload) } - fn refresh_configured_relay_state_before_seller_order_decision( + fn refresh_configured_relay_state_before_order_lifecycle( &mut self, ) -> Result<(), AppSqliteError> { match self.ingest_configured_relay_events() { @@ -2413,7 +2463,7 @@ impl DesktopAppRuntimeState { Err(AppDirectRelayIngestError::Sqlite(error)) => Err(error), Err(AppDirectRelayIngestError::Transport(_)) => { Err(AppSqliteError::InvalidProjection { - reason: "seller order decision requires fresh configured relay state", + reason: "order lifecycle publish requires fresh configured relay state", }) } } @@ -2426,11 +2476,405 @@ impl DesktopAppRuntimeState { ) -> Result<bool, AppSqliteError> { let payload = self.prepare_seller_order_decision(order_id, command)?; let operation = PendingSyncOperation::from_publish_payload( - AppPublishPayload::OrderDecision(payload), + AppPublishPayload::OrderDecision(payload), + current_utc_timestamp(), + ) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "seller order decision publish payload must serialize", + })?; + let _ = self.enqueue_selected_account_sync_operation_once(operation)?; + self.attempt_sync(SyncTrigger::ManualRefresh) + } + + fn prepare_seller_order_fulfillment( + &mut self, + order_id: OrderId, + status: AppOrderFulfillmentPublishStatus, + ) -> Result<AppOrderFulfillmentPublishPayload, AppSqliteError> { + let _ = self.import_shared_local_events()?; + let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires valid configured relays", + } + })?; + if relay_urls.is_empty() { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires configured relays", + }); + } + self.refresh_configured_relay_state_before_order_lifecycle()?; + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires local state", + }); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires a selected farm", + }); + }; + let Some(selected_account) = self + .state_store + .identity_projection() + .selected_account + .as_ref() + else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires a selected seller account", + }); + }; + let account_id = selected_account.account.account_id.clone(); + let seller_pubkey = self.local_events_owner_pubkey(selected_account).ok_or( + AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires a selected seller public key", + }, + )?; + let request = self.resolve_seller_order_request_evidence(order_id)?; + if request.payload.seller_pubkey.trim() != seller_pubkey.as_str() { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment seller account does not match order seller", + }); + } + let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; + let Some(decision) = lifecycle.decision.as_ref() else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires accepted order decision evidence", + }); + }; + if !matches!( + decision.payload.decision, + RadrootsTradeOrderDecision::Accepted { .. } + ) { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires accepted order decision evidence", + }); + } + if lifecycle.cancellation_event_id.is_some() || lifecycle.receipt_event_id.is_some() { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires an active order", + }); + } + if lifecycle + .latest_fulfillment + .as_ref() + .is_some_and(|fulfillment| { + matches!( + fulfillment.status, + RadrootsActiveTradeFulfillmentState::Delivered + | RadrootsActiveTradeFulfillmentState::SellerCancelled + ) + }) + { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment is already terminal", + }); + } + let Some(order_detail) = sqlite_store.load_order_detail(farm_id, order_id)? else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires a visible seller order", + }); + }; + let prev_event_id = match status { + AppOrderFulfillmentPublishStatus::ReadyForPickup + | AppOrderFulfillmentPublishStatus::Preparing + | AppOrderFulfillmentPublishStatus::OutForDelivery => { + if order_detail.status != OrderStatus::Scheduled { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order fulfillment requires a scheduled order", + }); + } + lifecycle + .latest_fulfillment + .as_ref() + .map(|fulfillment| fulfillment.event_id.clone()) + .unwrap_or_else(|| decision.event_id.clone()) + } + AppOrderFulfillmentPublishStatus::Delivered => { + if order_detail.status != OrderStatus::Packed { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order delivery requires a ready order", + }); + } + lifecycle + .latest_fulfillment + .as_ref() + .map(|fulfillment| fulfillment.event_id.clone()) + .ok_or(AppSqliteError::InvalidProjection { + reason: "seller order delivery requires fulfillment evidence", + })? + } + AppOrderFulfillmentPublishStatus::SellerCancelled => lifecycle + .latest_fulfillment + .as_ref() + .map(|fulfillment| fulfillment.event_id.clone()) + .unwrap_or_else(|| decision.event_id.clone()), + }; + let payload = AppOrderFulfillmentPublishPayload { + context: AppPublishContext::new(account_id, "seller_order_fulfillment"), + app_order_id: order_id, + farm_id, + trade_order_id: request.payload.order_id, + request_event_id: request.request_event_id, + prev_event_id, + listing_addr: request.payload.listing_addr, + buyer_pubkey: request.payload.buyer_pubkey, + seller_pubkey: request.payload.seller_pubkey, + status, + }; + AppPublishPayload::OrderFulfillment(payload.clone()) + .validate() + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "seller order fulfillment publish payload is invalid", + })?; + Ok(payload) + } + + fn publish_seller_order_fulfillment( + &mut self, + order_id: OrderId, + status: AppOrderFulfillmentPublishStatus, + ) -> Result<bool, AppSqliteError> { + let payload = self.prepare_seller_order_fulfillment(order_id, status)?; + let operation = PendingSyncOperation::from_publish_payload( + AppPublishPayload::OrderFulfillment(payload), + current_utc_timestamp(), + ) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "seller order fulfillment publish payload must serialize", + })?; + let _ = self.enqueue_selected_account_sync_operation_once(operation)?; + self.attempt_sync(SyncTrigger::ManualRefresh) + } + + fn prepare_buyer_order_cancellation( + &mut self, + order_id: OrderId, + ) -> Result<AppOrderCancellationPublishPayload, AppSqliteError> { + let _ = self.import_shared_local_events()?; + let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires valid configured relays", + } + })?; + if relay_urls.is_empty() { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires configured relays", + }); + } + self.refresh_configured_relay_state_before_order_lifecycle()?; + let buyer_context = self.state_store.identity_projection().buyer_context(); + let BuyerContext::Account(account_id) = &buyer_context else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires a selected buyer account", + }); + }; + let Some(selected_account) = self.selected_buyer_account(&buyer_context) else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires a selected buyer account", + }); + }; + let buyer_pubkey = self.local_events_owner_pubkey(selected_account).ok_or( + AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires a selected buyer public key", + }, + )?; + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires local state", + }); + }; + let Some(detail) = sqlite_store.load_buyer_order_detail(&buyer_context, order_id)? else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires a visible buyer order", + }); + }; + if !matches!( + detail.status, + BuyerOrderStatus::Placed | BuyerOrderStatus::Scheduled + ) { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires an open order", + }); + } + let request = self.resolve_seller_order_request_evidence(order_id)?; + if request.payload.buyer_pubkey.trim() != buyer_pubkey.as_str() { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation buyer account does not match order buyer", + }); + } + let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; + if lifecycle.cancellation_event_id.is_some() + || lifecycle.receipt_event_id.is_some() + || lifecycle.latest_fulfillment.is_some() + { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires an unfulfilled order", + }); + } + let prev_event_id = match detail.status { + BuyerOrderStatus::Placed => request.request_event_id.clone(), + BuyerOrderStatus::Scheduled => lifecycle + .decision + .as_ref() + .map(|decision| decision.event_id.clone()) + .ok_or(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires order decision evidence", + })?, + BuyerOrderStatus::Ready + | BuyerOrderStatus::Completed + | BuyerOrderStatus::Declined + | BuyerOrderStatus::Refunded => { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order cancellation requires an open order", + }); + } + }; + let payload = AppOrderCancellationPublishPayload { + context: AppPublishContext::new(account_id.clone(), "buyer_order_cancellation"), + app_order_id: order_id, + farm_id: detail.farm_id, + trade_order_id: request.payload.order_id, + request_event_id: request.request_event_id, + prev_event_id, + listing_addr: request.payload.listing_addr, + buyer_pubkey: request.payload.buyer_pubkey, + seller_pubkey: request.payload.seller_pubkey, + reason: "buyer cancelled order".to_owned(), + }; + AppPublishPayload::OrderCancellation(payload.clone()) + .validate() + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "buyer order cancellation publish payload is invalid", + })?; + Ok(payload) + } + + fn publish_buyer_order_cancellation( + &mut self, + order_id: OrderId, + ) -> Result<bool, AppSqliteError> { + let payload = self.prepare_buyer_order_cancellation(order_id)?; + let operation = PendingSyncOperation::from_publish_payload( + AppPublishPayload::OrderCancellation(payload), + current_utc_timestamp(), + ) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "buyer order cancellation publish payload must serialize", + })?; + let _ = self.enqueue_selected_account_sync_operation_once(operation)?; + self.attempt_sync(SyncTrigger::ManualRefresh) + } + + fn prepare_buyer_order_receipt( + &mut self, + order_id: OrderId, + ) -> Result<AppOrderReceiptPublishPayload, AppSqliteError> { + let _ = self.import_shared_local_events()?; + let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires valid configured relays", + } + })?; + if relay_urls.is_empty() { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires configured relays", + }); + } + self.refresh_configured_relay_state_before_order_lifecycle()?; + let buyer_context = self.state_store.identity_projection().buyer_context(); + let BuyerContext::Account(account_id) = &buyer_context else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires a selected buyer account", + }); + }; + let Some(selected_account) = self.selected_buyer_account(&buyer_context) else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires a selected buyer account", + }); + }; + let buyer_pubkey = self.local_events_owner_pubkey(selected_account).ok_or( + AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires a selected buyer public key", + }, + )?; + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires local state", + }); + }; + let Some(detail) = sqlite_store.load_buyer_order_detail(&buyer_context, order_id)? else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires a visible buyer order", + }); + }; + if detail.status != BuyerOrderStatus::Ready { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires a ready order", + }); + } + let request = self.resolve_seller_order_request_evidence(order_id)?; + if request.payload.buyer_pubkey.trim() != buyer_pubkey.as_str() { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order receipt buyer account does not match order buyer", + }); + } + let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; + if lifecycle.cancellation_event_id.is_some() || lifecycle.receipt_event_id.is_some() { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires an active ready order", + }); + } + let fulfillment = + lifecycle + .latest_fulfillment + .as_ref() + .ok_or(AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires fulfillment evidence", + })?; + if !matches!( + fulfillment.status, + RadrootsActiveTradeFulfillmentState::ReadyForPickup + | RadrootsActiveTradeFulfillmentState::Delivered + ) { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order receipt requires ready fulfillment evidence", + }); + } + let received_at = u64::try_from(current_runtime_time_seconds()?).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "buyer order receipt timestamp must be non-negative", + } + })?; + let payload = AppOrderReceiptPublishPayload { + context: AppPublishContext::new(account_id.clone(), "buyer_order_receipt"), + app_order_id: order_id, + farm_id: detail.farm_id, + trade_order_id: request.payload.order_id, + request_event_id: request.request_event_id, + prev_event_id: fulfillment.event_id.clone(), + listing_addr: request.payload.listing_addr, + buyer_pubkey: request.payload.buyer_pubkey, + seller_pubkey: request.payload.seller_pubkey, + received: true, + issue: None, + received_at, + }; + AppPublishPayload::OrderReceipt(payload.clone()) + .validate() + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "buyer order receipt publish payload is invalid", + })?; + Ok(payload) + } + + fn publish_buyer_order_receipt(&mut self, order_id: OrderId) -> Result<bool, AppSqliteError> { + let payload = self.prepare_buyer_order_receipt(order_id)?; + let operation = PendingSyncOperation::from_publish_payload( + AppPublishPayload::OrderReceipt(payload), current_utc_timestamp(), ) .map_err(|_| AppSqliteError::InvalidProjection { - reason: "seller order decision publish payload must serialize", + reason: "buyer order receipt publish payload must serialize", })?; let _ = self.enqueue_selected_account_sync_operation_once(operation)?; self.attempt_sync(SyncTrigger::ManualRefresh) @@ -4618,6 +5062,149 @@ impl DesktopAppRuntimeState { Ok(()) } + fn resolve_order_lifecycle_evidence( + &self, + request: &ResolvedAppSellerOrderRequest, + ) -> Result<ResolvedAppOrderLifecycleEvidence, AppSqliteError> { + let mut events = self.collect_order_lifecycle_signed_events()?; + events.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + let mut evidence = ResolvedAppOrderLifecycleEvidence::default(); + for event in events { + if trade_chain_tag_value(&event, "e_root").as_deref() + != Some(request.request_event_id.as_str()) + { + continue; + } + match event.kind { + 3423 => { + let Ok(envelope) = radroots_sdk::trade::parse_order_decision(&event) else { + continue; + }; + if !trade_decision_matches_request(&envelope.payload, &request.payload) { + continue; + } + evidence.decision = Some(ResolvedAppOrderDecisionEvidence { + event_id: event.id, + payload: envelope.payload, + }); + } + 3432 => { + let Ok(envelope) = radroots_sdk::trade::parse_order_cancellation(&event) else { + continue; + }; + if !trade_cancellation_matches_request(&envelope.payload, &request.payload) { + continue; + } + evidence.cancellation_event_id = Some(event.id); + } + 3433 => { + let Ok(envelope) = radroots_sdk::trade::parse_fulfillment_update(&event) else { + continue; + }; + if !trade_fulfillment_matches_request(&envelope.payload, &request.payload) { + continue; + } + evidence.latest_fulfillment = Some(ResolvedAppOrderFulfillmentEvidence { + event_id: event.id, + status: envelope.payload.status, + }); + } + 3434 => { + let Ok(envelope) = radroots_sdk::trade::parse_buyer_receipt(&event) else { + continue; + }; + if !trade_receipt_matches_request(&envelope.payload, &request.payload) { + continue; + } + evidence.receipt_event_id = Some(event.id); + } + _ => {} + } + } + + Ok(evidence) + } + + fn collect_order_lifecycle_signed_events( + &self, + ) -> Result<Vec<radroots_sdk::RadrootsNostrEvent>, AppSqliteError> { + let mut events = Vec::new(); + let mut seen_event_ids = BTreeSet::new(); + let kinds = [3423_u32, 3432, 3433, 3434]; + + if let Some(sqlite_store) = self.sqlite_store.as_ref() { + for kind in kinds { + for event in + sqlite_store.load_local_interop_signed_events_by_kind(i64::from(kind))? + { + if seen_event_ids.insert(event.id.clone()) { + events.push(event); + } + } + } + } + + let Some(store) = self.open_shared_local_events_store()? else { + return Ok(events); + }; + let mut before = None; + loop { + let records = match before { + Some((before_change_seq, before_seq)) => store + .list_records_changed_before( + before_change_seq, + before_seq, + APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE, + ) + .map_err(|source| AppSqliteError::LocalEvents { + operation: "load shared order lifecycle evidence", + source, + })?, + None => store + .list_records_changed_latest(APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE) + .map_err(|source| AppSqliteError::LocalEvents { + operation: "load shared order lifecycle evidence", + source, + })?, + }; + if records.is_empty() { + break; + } + let is_last_page = + records.len() < APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE as usize; + before = records.last().map(|record| (record.change_seq, record.seq)); + + for record in records { + let Some(kind) = record.event_kind else { + continue; + }; + if record.family != LocalRecordFamily::SignedEvent + || !kinds.contains(&u32::try_from(kind).unwrap_or_default()) + || !signed_order_request_evidence_record_is_usable(&record) + { + continue; + } + let Some(event) = signed_event_from_local_record(&record)? else { + continue; + }; + if seen_event_ids.insert(event.id.clone()) { + events.push(event); + } + } + + if before.is_none() || is_last_page { + break; + } + } + + Ok(events) + } + fn open_shared_local_events_store( &self, ) -> Result<Option<LocalEventsStore<SqliteExecutor>>, AppSqliteError> { @@ -6085,6 +6672,45 @@ async fn publish_app_payload( .await .map_err(|error| AppSyncTransportError::failed(error.to_string())) } + AppPublishPayload::OrderCancellation(payload) => { + let cancellation = order_cancellation_publish_payload_to_sdk_cancellation(payload); + client + .trade() + .publish_order_cancellation_with_identity( + identity, + payload.request_event_id.as_str(), + payload.prev_event_id.as_str(), + &cancellation, + ) + .await + .map_err(|error| AppSyncTransportError::failed(error.to_string())) + } + AppPublishPayload::OrderFulfillment(payload) => { + let fulfillment = order_fulfillment_publish_payload_to_sdk_fulfillment(payload); + client + .trade() + .publish_fulfillment_update_with_identity( + identity, + payload.request_event_id.as_str(), + payload.prev_event_id.as_str(), + &fulfillment, + ) + .await + .map_err(|error| AppSyncTransportError::failed(error.to_string())) + } + AppPublishPayload::OrderReceipt(payload) => { + let receipt = order_receipt_publish_payload_to_sdk_receipt(payload); + client + .trade() + .publish_buyer_receipt_with_identity( + identity, + payload.request_event_id.as_str(), + payload.prev_event_id.as_str(), + &receipt, + ) + .await + .map_err(|error| AppSyncTransportError::failed(error.to_string())) + } } } @@ -6373,6 +6999,21 @@ fn published_operation_receipt( payload.context.source_local_event_id.clone(), Some(payload.listing_addr.clone()), ), + AppPublishPayload::OrderCancellation(payload) => ( + payload.context.account_id.clone(), + payload.context.source_local_event_id.clone(), + Some(payload.listing_addr.clone()), + ), + AppPublishPayload::OrderFulfillment(payload) => ( + payload.context.account_id.clone(), + payload.context.source_local_event_id.clone(), + Some(payload.listing_addr.clone()), + ), + AppPublishPayload::OrderReceipt(payload) => ( + payload.context.account_id.clone(), + payload.context.source_local_event_id.clone(), + Some(payload.listing_addr.clone()), + ), }; let failed_relays = relay_receipt .failed_relays @@ -8136,6 +8777,60 @@ fn event_tags_from_value( .collect() } +fn trade_chain_tag_value(event: &radroots_sdk::RadrootsNostrEvent, key: &str) -> Option<String> { + event.tags.iter().find_map(|tag| { + if tag.first().map(String::as_str) == Some(key) { + tag.get(1) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + } else { + None + } + }) +} + +fn trade_decision_matches_request( + decision: &RadrootsTradeOrderDecisionEvent, + request: &RadrootsTradeOrderRequested, +) -> bool { + decision.order_id == request.order_id + && decision.listing_addr == request.listing_addr + && decision.buyer_pubkey == request.buyer_pubkey + && decision.seller_pubkey == request.seller_pubkey +} + +fn trade_fulfillment_matches_request( + fulfillment: &RadrootsTradeFulfillmentUpdated, + request: &RadrootsTradeOrderRequested, +) -> bool { + fulfillment.order_id == request.order_id + && fulfillment.listing_addr == request.listing_addr + && fulfillment.buyer_pubkey == request.buyer_pubkey + && fulfillment.seller_pubkey == request.seller_pubkey +} + +fn trade_cancellation_matches_request( + cancellation: &RadrootsTradeOrderCancelled, + request: &RadrootsTradeOrderRequested, +) -> bool { + cancellation.order_id == request.order_id + && cancellation.listing_addr == request.listing_addr + && cancellation.buyer_pubkey == request.buyer_pubkey + && cancellation.seller_pubkey == request.seller_pubkey +} + +fn trade_receipt_matches_request( + receipt: &RadrootsTradeBuyerReceipt, + request: &RadrootsTradeOrderRequested, +) -> bool { + receipt.order_id == request.order_id + && receipt.listing_addr == request.listing_addr + && receipt.buyer_pubkey == request.buyer_pubkey + && receipt.seller_pubkey == request.seller_pubkey +} + fn insert_seller_order_request_evidence( order_id: &OrderId, event: &radroots_sdk::RadrootsNostrEvent, @@ -8240,6 +8935,60 @@ fn order_decision_publish_payload_to_sdk_decision( } } +fn order_fulfillment_publish_payload_to_sdk_fulfillment( + payload: &AppOrderFulfillmentPublishPayload, +) -> RadrootsTradeFulfillmentUpdated { + RadrootsTradeFulfillmentUpdated { + order_id: payload.trade_order_id.clone(), + listing_addr: payload.listing_addr.clone(), + buyer_pubkey: payload.buyer_pubkey.clone(), + seller_pubkey: payload.seller_pubkey.clone(), + status: match payload.status { + AppOrderFulfillmentPublishStatus::Preparing => { + RadrootsActiveTradeFulfillmentState::Preparing + } + AppOrderFulfillmentPublishStatus::ReadyForPickup => { + RadrootsActiveTradeFulfillmentState::ReadyForPickup + } + AppOrderFulfillmentPublishStatus::OutForDelivery => { + RadrootsActiveTradeFulfillmentState::OutForDelivery + } + AppOrderFulfillmentPublishStatus::Delivered => { + RadrootsActiveTradeFulfillmentState::Delivered + } + AppOrderFulfillmentPublishStatus::SellerCancelled => { + RadrootsActiveTradeFulfillmentState::SellerCancelled + } + }, + } +} + +fn order_cancellation_publish_payload_to_sdk_cancellation( + payload: &AppOrderCancellationPublishPayload, +) -> RadrootsTradeOrderCancelled { + RadrootsTradeOrderCancelled { + order_id: payload.trade_order_id.clone(), + listing_addr: payload.listing_addr.clone(), + buyer_pubkey: payload.buyer_pubkey.clone(), + seller_pubkey: payload.seller_pubkey.clone(), + reason: payload.reason.clone(), + } +} + +fn order_receipt_publish_payload_to_sdk_receipt( + payload: &AppOrderReceiptPublishPayload, +) -> RadrootsTradeBuyerReceipt { + RadrootsTradeBuyerReceipt { + order_id: payload.trade_order_id.clone(), + listing_addr: payload.listing_addr.clone(), + buyer_pubkey: payload.buyer_pubkey.clone(), + seller_pubkey: payload.seller_pubkey.clone(), + received: payload.received, + issue: payload.issue.clone(), + received_at: payload.received_at, + } +} + fn pending_sync_upsert(aggregate: SyncAggregateRef, payload_json: String) -> PendingSyncOperation { let created_at = current_utc_timestamp(); @@ -8336,15 +9085,17 @@ mod tests { HomeRoute, }; use radroots_app_sync::{ - AppFarmProfilePublishPayload, AppListingPublishPayload, + AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, - AppOrderDecisionPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, - AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, - AppRelayIngestScopeFreshness, AppRelayIngestScopeStatus, AppSyncRequest, AppSyncResult, - AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, - PendingSyncOperationState, RecordedAppSyncTransport, SyncAggregateRef, SyncCheckpointState, - SyncCheckpointStatus, SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, - SyncConflictSeverity, SyncOperationKind, SyncTrigger, + AppOrderDecisionPublishPayload, AppOrderFulfillmentPublishPayload, + AppOrderFulfillmentPublishStatus, AppOrderReceiptPublishPayload, + AppOrderRequestItemPayload, AppOrderRequestPublishPayload, AppPublishContext, + AppPublishPayload, AppPublishedOperationReceipt, AppRelayIngestScopeFreshness, + AppRelayIngestScopeStatus, AppSyncRequest, AppSyncResult, AppSyncRunStatus, + AppSyncTransport, AppSyncTransportError, PendingSyncOperation, PendingSyncOperationState, + RecordedAppSyncTransport, SyncAggregateRef, SyncCheckpointState, SyncCheckpointStatus, + SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, + SyncOperationKind, SyncTrigger, }; use radroots_app_view::{ AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, @@ -8381,8 +9132,9 @@ mod tests { }; use radroots_sdk::RadrootsNostrEventPtr; use radroots_sdk::trade::{ - RadrootsTradeOrderDecision, RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, - RadrootsTradeOrderItem, RadrootsTradeOrderRequested, RadrootsTradePricingBasis, + RadrootsActiveTradeFulfillmentState, RadrootsTradeOrderDecision, + RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, + RadrootsTradeOrderRequested, RadrootsTradePricingBasis, }; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::json; @@ -8779,7 +9531,11 @@ mod tests { let product_id = ProductId::new(); let order_id = OrderId::new(); let listing_event_id = "1".repeat(64); - let listing_addr = format!("30402:{}:listing-key", seller_identity.public_key_hex()); + let listing_addr = format!( + "30402:{}:{}", + seller_identity.public_key_hex(), + super::d_tag_from_uuid(ProductId::new().as_uuid()) + ); let order_document = RadrootsTradeOrderRequested { order_id: order_id.to_string(), listing_addr: listing_addr.clone(), @@ -8920,6 +9676,138 @@ mod tests { } #[test] + fn runtime_direct_relay_transport_publishes_typed_order_lifecycle_work() { + let relay = ThreadedAckRelay::spawn(); + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let buyer_account_id = manager + .generate_identity(Some("Buyer".to_owned()), true) + .expect("buyer account should generate"); + let seller_account_id = manager + .generate_identity(Some("Seller".to_owned()), true) + .expect("seller account should generate"); + let buyer_identity = manager + .get_signing_identity(&buyer_account_id) + .expect("buyer signer lookup should succeed") + .expect("buyer account should have local signer"); + let seller_identity = manager + .get_signing_identity(&seller_account_id) + .expect("seller signer lookup should succeed") + .expect("seller account should have local signer"); + let app_order_id = OrderId::new(); + let farm_id = FarmId::new(); + let listing_addr = format!( + "30402:{}:AAAAAAAAAAAAAAAAAAAAAg", + seller_identity.public_key_hex() + ); + let common = ( + app_order_id, + farm_id, + "order-1".to_owned(), + "order-request-event-1".to_owned(), + listing_addr, + buyer_identity.public_key_hex(), + seller_identity.public_key_hex(), + ); + let cancellation = + AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload { + context: AppPublishContext::new( + buyer_account_id.to_string(), + "buyer_order_cancellation", + ), + app_order_id: common.0, + farm_id: common.1, + trade_order_id: common.2.clone(), + request_event_id: common.3.clone(), + prev_event_id: common.3.clone(), + listing_addr: common.4.clone(), + buyer_pubkey: common.5.clone(), + seller_pubkey: common.6.clone(), + reason: "buyer cancelled order".to_owned(), + }); + let fulfillment = AppPublishPayload::OrderFulfillment(AppOrderFulfillmentPublishPayload { + context: AppPublishContext::new( + seller_account_id.to_string(), + "seller_order_fulfillment", + ), + app_order_id: common.0, + farm_id: common.1, + trade_order_id: common.2.clone(), + request_event_id: common.3.clone(), + prev_event_id: "order-decision-event-1".to_owned(), + listing_addr: common.4.clone(), + buyer_pubkey: common.5.clone(), + seller_pubkey: common.6.clone(), + status: AppOrderFulfillmentPublishStatus::ReadyForPickup, + }); + let receipt = AppPublishPayload::OrderReceipt(AppOrderReceiptPublishPayload { + context: AppPublishContext::new(buyer_account_id.to_string(), "buyer_order_receipt"), + app_order_id: common.0, + farm_id: common.1, + trade_order_id: common.2.clone(), + request_event_id: common.3.clone(), + prev_event_id: "fulfillment-event-1".to_owned(), + listing_addr: common.4.clone(), + buyer_pubkey: common.5.clone(), + seller_pubkey: common.6.clone(), + received: true, + issue: None, + received_at: 1_785_000_000, + }); + let operations = [cancellation, fulfillment, receipt] + .into_iter() + .map(|payload| { + PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") + .expect("typed lifecycle publish work should serialize") + }) + .collect::<Vec<_>>(); + let mut transport = + SdkDirectRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); + + let result = transport + .sync(AppSyncRequest { + trigger: SyncTrigger::ManualRefresh, + checkpoint: SyncCheckpointStatus::never_synced(), + pending_operations: operations, + known_conflicts: Vec::new(), + }) + .expect("direct relay lifecycle publish should succeed"); + + assert_eq!(result.run_status, AppSyncRunStatus::Succeeded); + assert_eq!(result.pushed_operation_count, 3); + assert_eq!(relay.event_count(), 3); + let kinds = result + .published_receipts + .iter() + .map(|receipt| receipt.event_kind) + .collect::<Vec<_>>(); + assert_eq!(kinds, vec![3432, 3433, 3434]); + for receipt in &result.published_receipts { + let event = published_receipt_event(receipt); + match receipt.event_kind { + 3432 => { + let envelope = radroots_sdk::trade::parse_order_cancellation(&event) + .expect("order cancellation should parse"); + assert_eq!(envelope.payload.reason, "buyer cancelled order"); + } + 3433 => { + let envelope = radroots_sdk::trade::parse_fulfillment_update(&event) + .expect("fulfillment update should parse"); + assert_eq!( + envelope.payload.status, + RadrootsActiveTradeFulfillmentState::ReadyForPickup + ); + } + 3434 => { + let envelope = radroots_sdk::trade::parse_buyer_receipt(&event) + .expect("buyer receipt should parse"); + assert!(envelope.payload.received); + } + _ => panic!("unexpected lifecycle event kind"), + } + } + } + + #[test] fn runtime_configured_relay_sync_triggers_ingest_listing_into_fresh_buyer_projection() { let relay = ThreadedAckRelay::spawn(); let projected_product_id = publish_relay_ingest_listing_fixture(&relay); @@ -13250,7 +14138,7 @@ mod tests { assert!(matches!( error, AppSqliteError::InvalidProjection { - reason: "seller order decision requires fresh configured relay state" + reason: "order lifecycle publish requires fresh configured relay state" } )); assert_eq!(persisted_order_status(&runtime, order_id), "needs_action"); @@ -13380,6 +14268,64 @@ mod tests { } #[test] + fn runtime_publishes_seller_fulfillment_updates_and_projects_signed_evidence() { + let relay = ThreadedAckRelay::spawn(); + let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) = + seller_order_decision_runtime("seller_order_fulfillment_publish", 6, 2); + install_direct_relay_sync_transport(&runtime, &relay); + publish_prior_relay_seller_order_accept( + &runtime, + &relay, + order_id, + product_id, + seller_pubkey.as_str(), + buyer_pubkey.as_str(), + ); + + assert!( + runtime + .publish_order_ready_for_pickup(order_id) + .expect("seller order ready update should publish") + ); + + assert_eq!(persisted_order_status(&runtime, order_id), "packed"); + assert_eq!(relay.event_count(), 2); + let fulfillment_events = shared_order_events_by_kind(&paths, 3433, seller_pubkey.as_str()); + assert_eq!(fulfillment_events.len(), 1); + let ready_event = fulfillment_events.first().expect("ready event"); + let ready_envelope = radroots_sdk::trade::parse_fulfillment_update(ready_event) + .expect("ready fulfillment should parse"); + assert_eq!( + ready_envelope.payload.status, + RadrootsActiveTradeFulfillmentState::ReadyForPickup + ); + assert!(event_has_tag( + ready_event, + "e_root", + "event-app:signed_event:order-request:seller-order-decision-1" + )); + + assert!( + runtime + .publish_order_delivered(order_id) + .expect("seller order delivered update should publish") + ); + + assert_eq!(relay.event_count(), 3); + let fulfillment_events = shared_order_events_by_kind(&paths, 3433, seller_pubkey.as_str()); + assert_eq!(fulfillment_events.len(), 2); + assert!(fulfillment_events.iter().any(|event| { + radroots_sdk::trade::parse_fulfillment_update(event) + .map(|envelope| { + envelope.payload.status == RadrootsActiveTradeFulfillmentState::Delivered + }) + .unwrap_or(false) + })); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] fn runtime_places_supported_buyer_order_into_shared_local_events() { let (runtime, paths) = bootstrapped_runtime("buyer_order_local_event"); assert!( @@ -17752,6 +18698,21 @@ mod tests { } } + fn published_receipt_event( + receipt: &AppPublishedOperationReceipt, + ) -> radroots_sdk::RadrootsNostrEvent { + radroots_sdk::RadrootsNostrEvent { + id: receipt.event_id.clone(), + author: receipt.event_pubkey.clone(), + created_at: receipt.event_created_at, + kind: receipt.event_kind, + tags: serde_json::from_value(receipt.event_tags_json.clone()) + .expect("receipt event tags should decode"), + content: receipt.event_content.clone(), + sig: receipt.event_sig.clone(), + } + } + fn shared_local_event_records(paths: &AppDesktopRuntimePaths) -> Vec<LocalEventRecord> { let database_path = paths .shared_local_events_database_path() @@ -17781,6 +18742,25 @@ mod tests { .expect("shared seller order decision record should contain signed event") } + fn shared_order_events_by_kind( + paths: &AppDesktopRuntimePaths, + kind: i64, + pubkey: &str, + ) -> Vec<radroots_sdk::RadrootsNostrEvent> { + shared_local_event_records(paths) + .into_iter() + .filter(|record| { + record.family == LocalRecordFamily::SignedEvent + && record.event_kind == Some(kind) + && record.event_pubkey.as_deref() == Some(pubkey) + }) + .filter_map(|record| { + signed_event_from_local_record(&record) + .expect("shared signed event record should decode") + }) + .collect() + } + fn event_has_tag(event: &radroots_sdk::RadrootsNostrEvent, key: &str, value: &str) -> bool { event.tags.iter().any(|tag| { tag.first().map(String::as_str) == Some(key) diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -51,16 +51,21 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "buyer-checkout-place-order", "buyer-listing-open", "buyer-order-confirm-replace", + "buyer-order-cancel", "buyer-order-keep-current", + "buyer-order-mark-received", "buyer-order-repeat-demand", "buyer-orders-retry-coordination", + "personal_orders", "buyer.add_to_cart_failed", "buyer.cart_remove_failed", "buyer.checkout_place_failed", "buyer.checkout_save_failed", "buyer.detail_open_failed", "buyer.order_open_failed", + "buyer.order_cancel_failed", "buyer.order_coordination_retry_failed", + "buyer.order_receipt_failed", "buyer.repeat_demand_failed", "buyer.section_select_failed", "buyer_notice", @@ -86,8 +91,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "failed to update buyer search query", "failed to add relay `{relay_url}`: {error}", "failed to load farm settings projection", - "failed to mark order completed", - "failed to mark order packed", + "failed to cancel buyer order", + "failed to mark buyer order received", + "failed to mark order delivered", + "failed to mark order ready", "failed to open existing product editor", "failed to open new product editor", "failed to acknowledge reminder", @@ -199,8 +206,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "orders-row-open", "orders.detail_open_failed", "orders.filter_update_failed", - "orders.mark_completed_failed", - "orders.mark_packed_failed", + "orders.mark_delivered_failed", + "orders.ready_for_pickup_failed", "orders.recovery_reopen_failed", "orders.recovery_resolve_failed", "orders.recovery_review_failed", @@ -396,6 +403,8 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalOrdersDetailFulfillmentLabel", "AppTextKey::PersonalOrdersDetailNoteLabel", "AppTextKey::PersonalOrdersDetailItemsTitle", + "AppTextKey::PersonalOrdersActionCancel", + "AppTextKey::PersonalOrdersActionMarkReceived", "AppTextKey::PersonalOrdersRepeatDemandTitle", "AppTextKey::PersonalOrdersRepeatDemandActionEligible", "AppTextKey::PersonalOrdersRepeatDemandActionPartial", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -2096,32 +2096,64 @@ impl HomeView { } fn mark_order_packed(&mut self, order_id: OrderId, cx: &mut Context<Self>) { - match self.runtime.mark_order_packed(order_id) { + match self.runtime.publish_order_ready_for_pickup(order_id) { Ok(true) => cx.notify(), Ok(false) => {} Err(runtime_error) => { error!( target: "orders", - event = "orders.mark_packed_failed", + event = "orders.ready_for_pickup_failed", error = %runtime_error, order_id = %order_id, - "failed to mark order packed" + "failed to mark order ready" ); } } } fn mark_order_completed(&mut self, order_id: OrderId, cx: &mut Context<Self>) { - match self.runtime.mark_order_completed(order_id) { + match self.runtime.publish_order_delivered(order_id) { Ok(true) => cx.notify(), Ok(false) => {} Err(runtime_error) => { error!( target: "orders", - event = "orders.mark_completed_failed", + event = "orders.mark_delivered_failed", error = %runtime_error, order_id = %order_id, - "failed to mark order completed" + "failed to mark order delivered" + ); + } + } + } + + fn cancel_buyer_order(&mut self, order_id: OrderId, cx: &mut Context<Self>) { + match self.runtime.publish_buyer_order_cancel(order_id) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "personal_orders", + event = "buyer.order_cancel_failed", + error = %runtime_error, + order_id = %order_id, + "failed to cancel buyer order" + ); + } + } + } + + fn mark_buyer_order_received(&mut self, order_id: OrderId, cx: &mut Context<Self>) { + match self.runtime.publish_buyer_order_receipt(order_id) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "personal_orders", + event = "buyer.order_receipt_failed", + error = %runtime_error, + order_id = %order_id, + "failed to mark buyer order received" ); } } @@ -8906,6 +8938,34 @@ fn buyer_order_detail_card( this.child(home_body_text(app_shared_text(AppTextKey::ValueNone))) }), )) + .when( + matches!( + detail.status, + BuyerOrderStatus::Placed | BuyerOrderStatus::Scheduled + ), + |this| { + this.child(action_button_compact( + "buyer-order-cancel", + app_shared_text(AppTextKey::PersonalOrdersActionCancel), + cx.listener({ + let order_id = detail.order_id; + move |this, _, _, cx| this.cancel_buyer_order(order_id, cx) + }), + cx, + )) + }, + ) + .when(detail.status == BuyerOrderStatus::Ready, |this| { + this.child(action_button_primary( + "buyer-order-mark-received", + app_shared_text(AppTextKey::PersonalOrdersActionMarkReceived), + cx.listener({ + let order_id = detail.order_id; + move |this, _, _, cx| this.mark_buyer_order_received(order_id, cx) + }), + cx, + )) + }) .when_some(detail.repeat_demand.as_ref(), |this, repeat_demand| { this.child(app_form_section( app_shared_text(AppTextKey::PersonalOrdersRepeatDemandTitle), diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -140,6 +140,8 @@ define_app_text_keys! { PersonalOrdersDetailPaymentLabel => "personal.orders.detail.payment.label", PersonalOrdersDetailNoteLabel => "personal.orders.detail.note.label", PersonalOrdersDetailItemsTitle => "personal.orders.detail.items.title", + PersonalOrdersActionCancel => "personal.orders.action.cancel", + PersonalOrdersActionMarkReceived => "personal.orders.action.mark_received", PersonalOrdersRepeatDemandTitle => "personal.orders.repeat_demand.title", PersonalOrdersRepeatDemandActionEligible => "personal.orders.repeat_demand.action.eligible", PersonalOrdersRepeatDemandActionPartial => "personal.orders.repeat_demand.action.partial", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -301,7 +301,7 @@ mod tests { assert_eq!(app_text(AppTextKey::OrdersActionMarkPacked), "Mark packed"); assert_eq!( app_text(AppTextKey::OrdersActionMarkCompleted), - "Mark completed" + "Mark delivered" ); assert_eq!(app_text(AppTextKey::OrdersDetailTitle), "Order detail"); assert_eq!(app_text(AppTextKey::OrdersRecoverySectionTitle), "Recovery"); @@ -481,6 +481,14 @@ mod tests { "Order note" ); assert_eq!( + app_text(AppTextKey::PersonalOrdersActionCancel), + "Cancel order" + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersActionMarkReceived), + "Mark received" + ); + assert_eq!( app_text(AppTextKey::PersonalOrdersRepeatDemandTitle), "Reorder" ); diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs @@ -3,11 +3,12 @@ mod publish; pub use publish::{ - AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderDecisionInventoryCommitment, - AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderRequestItemPayload, - AppOrderRequestPublishPayload, AppPublishContext, AppPublishPayload, - AppPublishPayloadJsonError, AppPublishValidationFailure, AppPublishValidationFailureSet, - AppPublishWorkKind, + AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, + AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, + AppOrderFulfillmentPublishPayload, AppOrderFulfillmentPublishStatus, + AppOrderReceiptPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, + AppPublishContext, AppPublishPayload, AppPublishPayloadJsonError, AppPublishValidationFailure, + AppPublishValidationFailureSet, AppPublishWorkKind, }; use radroots_app_view::{FarmId, FulfillmentWindowId, OrderId, ProductId}; diff --git a/crates/sync/src/publish.rs b/crates/sync/src/publish.rs @@ -14,6 +14,9 @@ pub enum AppPublishWorkKind { Listing, OrderRequest, OrderDecision, + OrderCancellation, + OrderFulfillment, + OrderReceipt, } impl AppPublishWorkKind { @@ -23,6 +26,9 @@ impl AppPublishWorkKind { Self::Listing => "listing", Self::OrderRequest => "order_request", Self::OrderDecision => "order_decision", + Self::OrderCancellation => "order_cancellation", + Self::OrderFulfillment => "order_fulfillment", + Self::OrderReceipt => "order_receipt", } } @@ -32,6 +38,9 @@ impl AppPublishWorkKind { Self::Listing => "listing.publish_draft_with_identity", Self::OrderRequest => "trade.publish_order_request_with_identity", Self::OrderDecision => "trade.publish_order_decision_with_identity", + Self::OrderCancellation => "trade.publish_order_cancellation_with_identity", + Self::OrderFulfillment => "trade.publish_fulfillment_update_with_identity", + Self::OrderReceipt => "trade.publish_buyer_receipt_with_identity", } } } @@ -163,6 +172,60 @@ pub struct AppOrderDecisionPublishPayload { pub decision: AppOrderDecisionPayload, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppOrderFulfillmentPublishStatus { + Preparing, + ReadyForPickup, + OutForDelivery, + Delivered, + SellerCancelled, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppOrderFulfillmentPublishPayload { + pub context: AppPublishContext, + pub app_order_id: OrderId, + pub farm_id: FarmId, + pub trade_order_id: String, + pub request_event_id: String, + pub prev_event_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub status: AppOrderFulfillmentPublishStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppOrderCancellationPublishPayload { + pub context: AppPublishContext, + pub app_order_id: OrderId, + pub farm_id: FarmId, + pub trade_order_id: String, + pub request_event_id: String, + pub prev_event_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub reason: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppOrderReceiptPublishPayload { + pub context: AppPublishContext, + pub app_order_id: OrderId, + pub farm_id: FarmId, + pub trade_order_id: String, + pub request_event_id: String, + pub prev_event_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub received: bool, + pub issue: Option<String>, + pub received_at: u64, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(tag = "publish_kind", content = "payload", rename_all = "snake_case")] pub enum AppPublishPayload { @@ -170,6 +233,9 @@ pub enum AppPublishPayload { Listing(AppListingPublishPayload), OrderRequest(AppOrderRequestPublishPayload), OrderDecision(AppOrderDecisionPublishPayload), + OrderCancellation(AppOrderCancellationPublishPayload), + OrderFulfillment(AppOrderFulfillmentPublishPayload), + OrderReceipt(AppOrderReceiptPublishPayload), } impl AppPublishPayload { @@ -179,6 +245,9 @@ impl AppPublishPayload { Self::Listing(_) => AppPublishWorkKind::Listing, Self::OrderRequest(_) => AppPublishWorkKind::OrderRequest, Self::OrderDecision(_) => AppPublishWorkKind::OrderDecision, + Self::OrderCancellation(_) => AppPublishWorkKind::OrderCancellation, + Self::OrderFulfillment(_) => AppPublishWorkKind::OrderFulfillment, + Self::OrderReceipt(_) => AppPublishWorkKind::OrderReceipt, } } @@ -196,6 +265,9 @@ impl AppPublishPayload { Self::Listing(payload) => SyncAggregateRef::Product(payload.product_id), Self::OrderRequest(payload) => SyncAggregateRef::Order(payload.order_id), Self::OrderDecision(payload) => SyncAggregateRef::Order(payload.app_order_id), + Self::OrderCancellation(payload) => SyncAggregateRef::Order(payload.app_order_id), + Self::OrderFulfillment(payload) => SyncAggregateRef::Order(payload.app_order_id), + Self::OrderReceipt(payload) => SyncAggregateRef::Order(payload.app_order_id), } } @@ -361,6 +433,54 @@ impl AppPublishPayload { } } } + Self::OrderCancellation(payload) => { + validate_lifecycle_order_fields( + &payload.context, + payload.trade_order_id.as_str(), + payload.request_event_id.as_str(), + payload.prev_event_id.as_str(), + payload.listing_addr.as_str(), + payload.buyer_pubkey.as_str(), + payload.seller_pubkey.as_str(), + &mut failures, + ); + if payload.reason.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderCancellationReason); + } + } + Self::OrderFulfillment(payload) => validate_lifecycle_order_fields( + &payload.context, + payload.trade_order_id.as_str(), + payload.request_event_id.as_str(), + payload.prev_event_id.as_str(), + payload.listing_addr.as_str(), + payload.buyer_pubkey.as_str(), + payload.seller_pubkey.as_str(), + &mut failures, + ), + Self::OrderReceipt(payload) => { + validate_lifecycle_order_fields( + &payload.context, + payload.trade_order_id.as_str(), + payload.request_event_id.as_str(), + payload.prev_event_id.as_str(), + payload.listing_addr.as_str(), + payload.buyer_pubkey.as_str(), + payload.seller_pubkey.as_str(), + &mut failures, + ); + if payload.received { + if payload.issue.is_some() { + failures.push(AppPublishValidationFailure::UnexpectedOrderReceiptIssue); + } + } else if payload + .issue + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + failures.push(AppPublishValidationFailure::MissingOrderReceiptIssue); + } + } } failures @@ -382,6 +502,37 @@ impl AppPublishPayload { } } +fn validate_lifecycle_order_fields( + context: &AppPublishContext, + trade_order_id: &str, + request_event_id: &str, + prev_event_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + failures: &mut Vec<AppPublishValidationFailure>, +) { + context.validation_failures(failures); + if trade_order_id.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderTradeOrderId); + } + if request_event_id.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderRequestEventId); + } + if prev_event_id.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderPreviousEventId); + } + if listing_addr.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderListingAddress); + } + if buyer_pubkey.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderBuyerPubkey); + } + if seller_pubkey.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderSellerPubkey); + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AppPublishValidationFailure { @@ -410,8 +561,12 @@ pub enum AppPublishValidationFailure { MissingOrderTotal, MissingOrderTradeOrderId, MissingOrderRequestEventId, + MissingOrderPreviousEventId, MissingOrderDecisionInventory, MissingOrderDeclineReason, + MissingOrderCancellationReason, + MissingOrderReceiptIssue, + UnexpectedOrderReceiptIssue, } impl AppPublishValidationFailure { @@ -442,8 +597,12 @@ impl AppPublishValidationFailure { Self::MissingOrderTotal => "missing_order_total", Self::MissingOrderTradeOrderId => "missing_order_trade_order_id", Self::MissingOrderRequestEventId => "missing_order_request_event_id", + Self::MissingOrderPreviousEventId => "missing_order_previous_event_id", Self::MissingOrderDecisionInventory => "missing_order_decision_inventory", Self::MissingOrderDeclineReason => "missing_order_decline_reason", + Self::MissingOrderCancellationReason => "missing_order_cancellation_reason", + Self::MissingOrderReceiptIssue => "missing_order_receipt_issue", + Self::UnexpectedOrderReceiptIssue => "unexpected_order_receipt_issue", } } } @@ -495,9 +654,11 @@ impl PendingSyncOperation { #[cfg(test)] mod tests { use super::{ - AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderDecisionPayload, - AppOrderDecisionPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, - AppPublishContext, AppPublishPayload, AppPublishValidationFailure, + AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, + AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderFulfillmentPublishPayload, + AppOrderFulfillmentPublishStatus, AppOrderReceiptPublishPayload, + AppOrderRequestItemPayload, AppOrderRequestPublishPayload, AppPublishContext, + AppPublishPayload, AppPublishValidationFailure, }; use crate::{ PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind, @@ -680,6 +841,128 @@ mod tests { } #[test] + fn lifecycle_publish_payloads_report_stable_validation_reason_codes() { + let order_id = OrderId::new(); + let farm_id = FarmId::new(); + let cancellation = + AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload { + context: AppPublishContext::new("", ""), + app_order_id: order_id, + farm_id, + trade_order_id: " ".to_owned(), + request_event_id: String::new(), + prev_event_id: String::new(), + listing_addr: String::new(), + buyer_pubkey: String::new(), + seller_pubkey: String::new(), + reason: " ".to_owned(), + }); + let fulfillment = AppPublishPayload::OrderFulfillment(AppOrderFulfillmentPublishPayload { + context: AppPublishContext::new("acct_local", "seller_order_fulfillment"), + app_order_id: order_id, + farm_id, + trade_order_id: "order-1".to_owned(), + request_event_id: "request-event-1".to_owned(), + prev_event_id: "decision-event-1".to_owned(), + listing_addr: "30402:seller:listing".to_owned(), + buyer_pubkey: "buyer".to_owned(), + seller_pubkey: "seller".to_owned(), + status: AppOrderFulfillmentPublishStatus::ReadyForPickup, + }); + let receipt = AppPublishPayload::OrderReceipt(AppOrderReceiptPublishPayload { + context: AppPublishContext::new("acct_local", "buyer_order_receipt"), + app_order_id: order_id, + farm_id, + trade_order_id: "order-1".to_owned(), + request_event_id: "request-event-1".to_owned(), + prev_event_id: "fulfillment-event-1".to_owned(), + listing_addr: "30402:seller:listing".to_owned(), + buyer_pubkey: "buyer".to_owned(), + seller_pubkey: "seller".to_owned(), + received: true, + issue: Some("late".to_owned()), + received_at: 1_785_000_000, + }); + + assert_eq!( + cancellation.work_kind().sdk_operation(), + "trade.publish_order_cancellation_with_identity" + ); + assert_eq!( + fulfillment.work_kind().sdk_operation(), + "trade.publish_fulfillment_update_with_identity" + ); + assert_eq!( + receipt.work_kind().sdk_operation(), + "trade.publish_buyer_receipt_with_identity" + ); + assert_eq!(fulfillment.validation_failures(), Vec::new()); + + let cancellation_reason_codes: Vec<&str> = cancellation + .validation_failures() + .into_iter() + .map(AppPublishValidationFailure::storage_key) + .collect(); + let receipt_reason_codes: Vec<&str> = receipt + .validation_failures() + .into_iter() + .map(AppPublishValidationFailure::storage_key) + .collect(); + + assert_eq!( + cancellation_reason_codes, + vec![ + "missing_account_id", + "missing_source", + "missing_order_trade_order_id", + "missing_order_request_event_id", + "missing_order_previous_event_id", + "missing_order_listing_address", + "missing_order_buyer_pubkey", + "missing_order_seller_pubkey", + "missing_order_cancellation_reason", + ] + ); + assert_eq!(receipt_reason_codes, vec!["unexpected_order_receipt_issue"]); + + let operation = PendingSyncOperation::from_publish_payload( + AppPublishPayload::OrderFulfillment(AppOrderFulfillmentPublishPayload { + context: AppPublishContext::new("acct_local", "seller_order_fulfillment"), + app_order_id: order_id, + farm_id, + trade_order_id: "order-1".to_owned(), + request_event_id: "request-event-1".to_owned(), + prev_event_id: "decision-event-1".to_owned(), + listing_addr: "30402:seller:listing".to_owned(), + buyer_pubkey: "buyer".to_owned(), + seller_pubkey: "seller".to_owned(), + status: AppOrderFulfillmentPublishStatus::Delivered, + }), + "2026-04-20T18:00:00Z", + ) + .expect("typed lifecycle payload should serialize"); + + assert_eq!(operation.aggregate, SyncAggregateRef::Order(order_id)); + assert_eq!(operation.operation_key, format!("order:{order_id}:upsert")); + assert_eq!(operation.operation, SyncOperationKind::Upsert); + assert_eq!( + operation.publish_payload().expect("payload should parse"), + AppPublishPayload::OrderFulfillment(AppOrderFulfillmentPublishPayload { + context: AppPublishContext::new("acct_local", "seller_order_fulfillment"), + app_order_id: order_id, + farm_id, + trade_order_id: "order-1".to_owned(), + request_event_id: "request-event-1".to_owned(), + prev_event_id: "decision-event-1".to_owned(), + listing_addr: "30402:seller:listing".to_owned(), + buyer_pubkey: "buyer".to_owned(), + seller_pubkey: "seller".to_owned(), + status: AppOrderFulfillmentPublishStatus::Delivered, + }) + ); + } + + #[test] fn existing_raw_payload_outbox_work_remains_local_save_compatible() { let pending_operation = PendingSyncOperation { operation_key: "product:greens:upsert".to_owned(), diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -120,6 +120,8 @@ "personal.orders.detail.payment.label": "Payment", "personal.orders.detail.note.label": "Order note", "personal.orders.detail.items.title": "Items", + "personal.orders.action.cancel": "Cancel order", + "personal.orders.action.mark_received": "Mark received", "personal.orders.repeat_demand.title": "Reorder", "personal.orders.repeat_demand.action.eligible": "Reorder", "personal.orders.repeat_demand.action.partial": "Reorder available items", @@ -180,7 +182,7 @@ "orders.column.action": "Action", "orders.action.review": "Review", "orders.action.mark_packed": "Mark packed", - "orders.action.mark_completed": "Mark completed", + "orders.action.mark_completed": "Mark delivered", "orders.empty.title": "No orders yet", "orders.empty.body": "Orders will appear here when customers place them.", "orders.empty.needs_action.title": "Nothing needs action",