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:
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",