app

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

commit d6f0edf47f21a09e85319a11c8d3f38704ec8b6c
parent 6a9358fbc09d3255ea861045d26395a9b65f7d01
Author: triesap <tyson@radroots.org>
Date:   Wed,  3 Jun 2026 01:19:29 -0700

app: add active trade revision workflow

- add typed app publish payloads for order revision proposals and decisions
- project revision badges and accepted revision economics from active trade events
- wire buyer revision accept and keep actions through localized UI copy
- validate sync, store, i18n, direct relay runtime, view, and desktop tests

Diffstat:
Mcrates/desktop/src/runtime.rs | 646++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/desktop/src/source_guards.rs | 8++++++++
Mcrates/desktop/src/window.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/i18n/src/keys.rs | 2++
Mcrates/i18n/src/lib.rs | 8++++++++
Acrates/store/migrations/0022_order_workflow_revision.sql | 4++++
Mcrates/store/src/interop.rs | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/store/src/migrations.rs | 4++++
Mcrates/store/src/repo/buyer.rs | 20++++++++++++++++----
Mcrates/store/src/repo/orders.rs | 21+++++++++++++++++----
Mcrates/sync/src/lib.rs | 1+
Mcrates/sync/src/publish.rs | 264++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/view/src/lib.rs | 14++++++++++++++
Mi18n/locales/en/messages.json | 2++
14 files changed, 1355 insertions(+), 38 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -36,6 +36,7 @@ use radroots_app_sync::{ AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderFulfillmentPublishPayload, AppOrderFulfillmentPublishStatus, AppOrderReceiptPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, + AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, AppRelayIngestScopeFreshness, AppSyncProjection, AppSyncRequest, AppSyncResult, AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, @@ -86,7 +87,9 @@ use radroots_sdk::listing::{ use radroots_sdk::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsTradeBuyerReceipt, RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled, - RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderRequested, + RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomics, + RadrootsTradeOrderItem, RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, + RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, }; use radroots_sdk::{ RadrootsNostrEventPtr, RadrootsSdkClient, RadrootsSdkConfig, RelayConfig, SdkEnvironment, @@ -188,6 +191,18 @@ struct ResolvedAppOrderDecisionEvidence { } #[derive(Clone, Debug, Eq, PartialEq)] +struct ResolvedAppOrderRevisionProposalEvidence { + event_id: String, + payload: RadrootsTradeOrderRevisionProposed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResolvedAppOrderRevisionDecisionEvidence { + event_id: String, + payload: RadrootsTradeOrderRevisionDecisionEvent, +} + +#[derive(Clone, Debug, Eq, PartialEq)] struct ResolvedAppOrderFulfillmentEvidence { event_id: String, status: RadrootsActiveTradeFulfillmentState, @@ -196,6 +211,8 @@ struct ResolvedAppOrderFulfillmentEvidence { #[derive(Clone, Debug, Default, Eq, PartialEq)] struct ResolvedAppOrderLifecycleEvidence { decision: Option<ResolvedAppOrderDecisionEvidence>, + revision_proposals: Vec<ResolvedAppOrderRevisionProposalEvidence>, + revision_decisions: Vec<ResolvedAppOrderRevisionDecisionEvidence>, latest_fulfillment: Option<ResolvedAppOrderFulfillmentEvidence>, cancellation_event_id: Option<String>, receipt_event_id: Option<String>, @@ -363,6 +380,8 @@ fn publish_payload_context(publish_payload: &AppPublishPayload) -> &AppPublishCo AppPublishPayload::Listing(payload) => &payload.context, AppPublishPayload::OrderRequest(payload) => &payload.context, AppPublishPayload::OrderDecision(payload) => &payload.context, + AppPublishPayload::OrderRevisionProposal(payload) => &payload.context, + AppPublishPayload::OrderRevisionDecision(payload) => &payload.context, AppPublishPayload::OrderCancellation(payload) => &payload.context, AppPublishPayload::OrderFulfillment(payload) => &payload.context, AppPublishPayload::OrderReceipt(payload) => &payload.context, @@ -760,11 +779,38 @@ impl DesktopAppRuntime { .publish_seller_order_fulfillment(order_id, AppOrderFulfillmentPublishStatus::Delivered) } + pub fn publish_order_revision_proposal( + &self, + order_id: OrderId, + items: Vec<RadrootsTradeOrderItem>, + economics: RadrootsTradeOrderEconomics, + reason: &str, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .publish_seller_order_revision_proposal(order_id, items, economics, reason) + } + 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_revision_accept( + &self, + order_id: OrderId, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .publish_buyer_order_revision_accept(order_id) + } + + pub fn publish_buyer_order_revision_decline( + &self, + order_id: OrderId, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .publish_buyer_order_revision_decline(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) } @@ -2646,6 +2692,294 @@ impl DesktopAppRuntimeState { self.attempt_sync(SyncTrigger::ManualRefresh) } + fn prepare_seller_order_revision_proposal( + &mut self, + order_id: OrderId, + items: Vec<RadrootsTradeOrderItem>, + economics: RadrootsTradeOrderEconomics, + reason: &str, + ) -> Result<AppOrderRevisionProposalPublishPayload, 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 revision requires valid configured relays", + } + })?; + if relay_urls.is_empty() { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision 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 revision requires local state", + }); + }; + let Some(farm_id) = self.selected_farm_id() else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision 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 revision 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 revision 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 revision seller account does not match order seller", + }); + } + let listing_address = + radroots_sdk::trade::parse_listing_address(request.payload.listing_addr.as_str()) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "seller order revision listing address is invalid", + })?; + if listing_address.seller_pubkey != seller_pubkey { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision listing address is outside seller authority", + }); + } + let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; + let Some(decision) = lifecycle.decision.as_ref() else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision requires accepted order decision evidence", + }); + }; + if !matches!( + decision.payload.decision, + RadrootsTradeOrderDecision::Accepted { .. } + ) { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision requires accepted order decision evidence", + }); + } + if lifecycle.cancellation_event_id.is_some() + || lifecycle.receipt_event_id.is_some() + || lifecycle.latest_fulfillment.is_some() + { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision requires an unfulfilled active order", + }); + } + let Some(order_detail) = sqlite_store.load_order_detail(farm_id, order_id)? else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision requires a visible seller order", + }); + }; + if order_detail.status != OrderStatus::Scheduled { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision requires a scheduled order", + }); + } + let Some(prev_event_id) = active_order_revision_parent_event_id(&lifecycle) else { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision requires no pending revision proposal", + }); + }; + let reason = reason.trim(); + if reason.is_empty() { + return Err(AppSqliteError::InvalidProjection { + reason: "seller order revision requires a non-empty reason", + }); + } + let payload = AppOrderRevisionProposalPublishPayload { + context: AppPublishContext::new(account_id, "seller_order_revision_proposal"), + app_order_id: order_id, + farm_id, + trade_order_id: request.payload.order_id, + request_event_id: request.request_event_id, + prev_event_id, + revision_id: format!("app-revision-{}", d_tag_from_uuid(Uuid::now_v7())), + listing_addr: request.payload.listing_addr, + buyer_pubkey: request.payload.buyer_pubkey, + seller_pubkey: request.payload.seller_pubkey, + items, + economics, + reason: reason.to_owned(), + }; + AppPublishPayload::OrderRevisionProposal(payload.clone()) + .validate() + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "seller order revision publish payload is invalid", + })?; + Ok(payload) + } + + fn publish_seller_order_revision_proposal( + &mut self, + order_id: OrderId, + items: Vec<RadrootsTradeOrderItem>, + economics: RadrootsTradeOrderEconomics, + reason: &str, + ) -> Result<bool, AppSqliteError> { + let payload = + self.prepare_seller_order_revision_proposal(order_id, items, economics, reason)?; + let operation = PendingSyncOperation::from_publish_payload( + AppPublishPayload::OrderRevisionProposal(payload), + current_utc_timestamp(), + ) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "seller order revision publish payload must serialize", + })?; + let _ = self.enqueue_selected_account_sync_operation_once(operation)?; + self.attempt_sync(SyncTrigger::ManualRefresh) + } + + fn prepare_buyer_order_revision_decision( + &mut self, + order_id: OrderId, + decision: RadrootsTradeOrderRevisionDecision, + ) -> Result<AppOrderRevisionDecisionPublishPayload, 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 revision requires valid configured relays", + } + })?; + if relay_urls.is_empty() { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order revision 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 revision requires a selected buyer account", + }); + }; + let Some(selected_account) = self.selected_buyer_account(&buyer_context) else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order revision requires a selected buyer account", + }); + }; + let buyer_pubkey = self.local_events_owner_pubkey(selected_account).ok_or( + AppSqliteError::InvalidProjection { + reason: "buyer order revision requires a selected buyer public key", + }, + )?; + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order revision requires local state", + }); + }; + let Some(detail) = sqlite_store.load_buyer_order_detail(&buyer_context, order_id)? else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order revision requires a visible buyer order", + }); + }; + if detail.status != BuyerOrderStatus::Scheduled { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order revision requires a scheduled 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 revision buyer account does not match order buyer", + }); + } + let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; + let Some(order_decision) = lifecycle.decision.as_ref() else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order revision requires accepted order decision evidence", + }); + }; + if !matches!( + order_decision.payload.decision, + RadrootsTradeOrderDecision::Accepted { .. } + ) { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order revision requires accepted order decision evidence", + }); + } + 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 revision requires an unfulfilled active order", + }); + } + let Some(proposal) = active_order_pending_revision_proposal(&lifecycle) else { + return Err(AppSqliteError::InvalidProjection { + reason: "buyer order revision requires a pending seller proposal", + }); + }; + let payload = AppOrderRevisionDecisionPublishPayload { + context: AppPublishContext::new(account_id.clone(), "buyer_order_revision_decision"), + 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: proposal.event_id.clone(), + revision_id: proposal.payload.revision_id.clone(), + listing_addr: request.payload.listing_addr, + buyer_pubkey: request.payload.buyer_pubkey, + seller_pubkey: request.payload.seller_pubkey, + decision, + }; + AppPublishPayload::OrderRevisionDecision(payload.clone()) + .validate() + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "buyer order revision publish payload is invalid", + })?; + Ok(payload) + } + + fn publish_buyer_order_revision_accept( + &mut self, + order_id: OrderId, + ) -> Result<bool, AppSqliteError> { + self.publish_buyer_order_revision_decision( + order_id, + RadrootsTradeOrderRevisionDecision::Accepted, + ) + } + + fn publish_buyer_order_revision_decline( + &mut self, + order_id: OrderId, + ) -> Result<bool, AppSqliteError> { + self.publish_buyer_order_revision_decision( + order_id, + RadrootsTradeOrderRevisionDecision::Declined { + reason: "buyer kept order as placed".to_owned(), + }, + ) + } + + fn publish_buyer_order_revision_decision( + &mut self, + order_id: OrderId, + decision: RadrootsTradeOrderRevisionDecision, + ) -> Result<bool, AppSqliteError> { + let payload = self.prepare_buyer_order_revision_decision(order_id, decision)?; + let operation = PendingSyncOperation::from_publish_payload( + AppPublishPayload::OrderRevisionDecision(payload), + current_utc_timestamp(), + ) + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "buyer order revision 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, @@ -5093,6 +5427,38 @@ impl DesktopAppRuntimeState { payload: envelope.payload, }); } + 3424 => { + let Ok(envelope) = radroots_sdk::trade::parse_order_revision_proposal(&event) + else { + continue; + }; + if !trade_revision_proposal_matches_request(&envelope.payload, &request.payload) + { + continue; + } + evidence + .revision_proposals + .push(ResolvedAppOrderRevisionProposalEvidence { + event_id: event.id, + payload: envelope.payload, + }); + } + 3425 => { + let Ok(envelope) = radroots_sdk::trade::parse_order_revision_decision(&event) + else { + continue; + }; + if !trade_revision_decision_matches_request(&envelope.payload, &request.payload) + { + continue; + } + evidence + .revision_decisions + .push(ResolvedAppOrderRevisionDecisionEvidence { + event_id: event.id, + payload: envelope.payload, + }); + } 3432 => { let Ok(envelope) = radroots_sdk::trade::parse_order_cancellation(&event) else { continue; @@ -5135,7 +5501,7 @@ impl DesktopAppRuntimeState { ) -> 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]; + let kinds = [3423_u32, 3424, 3425, 3432, 3433, 3434]; if let Some(sqlite_store) = self.sqlite_store.as_ref() { for kind in kinds { @@ -6672,6 +7038,33 @@ async fn publish_app_payload( .await .map_err(|error| AppSyncTransportError::failed(error.to_string())) } + AppPublishPayload::OrderRevisionProposal(payload) => { + let proposal = order_revision_proposal_publish_payload_to_sdk_revision(payload); + client + .trade() + .publish_order_revision_proposal_with_identity( + identity, + payload.request_event_id.as_str(), + payload.prev_event_id.as_str(), + &proposal, + ) + .await + .map_err(|error| AppSyncTransportError::failed(error.to_string())) + } + AppPublishPayload::OrderRevisionDecision(payload) => { + let decision = + order_revision_decision_publish_payload_to_sdk_revision_decision(payload); + client + .trade() + .publish_order_revision_decision_with_identity( + identity, + payload.request_event_id.as_str(), + payload.prev_event_id.as_str(), + &decision, + ) + .await + .map_err(|error| AppSyncTransportError::failed(error.to_string())) + } AppPublishPayload::OrderCancellation(payload) => { let cancellation = order_cancellation_publish_payload_to_sdk_cancellation(payload); client @@ -6999,6 +7392,16 @@ fn published_operation_receipt( payload.context.source_local_event_id.clone(), Some(payload.listing_addr.clone()), ), + AppPublishPayload::OrderRevisionProposal(payload) => ( + payload.context.account_id.clone(), + payload.context.source_local_event_id.clone(), + Some(payload.listing_addr.clone()), + ), + AppPublishPayload::OrderRevisionDecision(payload) => ( + payload.context.account_id.clone(), + 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(), @@ -8801,6 +9204,26 @@ fn trade_decision_matches_request( && decision.seller_pubkey == request.seller_pubkey } +fn trade_revision_proposal_matches_request( + proposal: &RadrootsTradeOrderRevisionProposed, + request: &RadrootsTradeOrderRequested, +) -> bool { + proposal.order_id == request.order_id + && proposal.listing_addr == request.listing_addr + && proposal.buyer_pubkey == request.buyer_pubkey + && proposal.seller_pubkey == request.seller_pubkey +} + +fn trade_revision_decision_matches_request( + decision: &RadrootsTradeOrderRevisionDecisionEvent, + 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, @@ -8831,6 +9254,72 @@ fn trade_receipt_matches_request( && receipt.seller_pubkey == request.seller_pubkey } +fn active_order_revision_parent_event_id( + lifecycle: &ResolvedAppOrderLifecycleEvidence, +) -> Option<String> { + let decision = lifecycle.decision.as_ref()?; + let mut parent_event_id = decision.event_id.clone(); + loop { + let proposals = lifecycle + .revision_proposals + .iter() + .filter(|proposal| proposal.payload.prev_event_id == parent_event_id) + .collect::<Vec<_>>(); + let proposal = match proposals.as_slice() { + [] => return Some(parent_event_id), + [proposal] => *proposal, + _ => return None, + }; + let decisions = lifecycle + .revision_decisions + .iter() + .filter(|decision| { + decision.payload.prev_event_id == proposal.event_id + && decision.payload.revision_id == proposal.payload.revision_id + }) + .collect::<Vec<_>>(); + let decision = match decisions.as_slice() { + [] => return None, + [decision] => *decision, + _ => return None, + }; + parent_event_id.clone_from(&decision.event_id); + } +} + +fn active_order_pending_revision_proposal( + lifecycle: &ResolvedAppOrderLifecycleEvidence, +) -> Option<&ResolvedAppOrderRevisionProposalEvidence> { + let decision = lifecycle.decision.as_ref()?; + let mut parent_event_id = decision.event_id.as_str(); + loop { + let proposals = lifecycle + .revision_proposals + .iter() + .filter(|proposal| proposal.payload.prev_event_id == parent_event_id) + .collect::<Vec<_>>(); + let proposal = match proposals.as_slice() { + [] => return None, + [proposal] => *proposal, + _ => return None, + }; + let decisions = lifecycle + .revision_decisions + .iter() + .filter(|decision| { + decision.payload.prev_event_id == proposal.event_id + && decision.payload.revision_id == proposal.payload.revision_id + }) + .collect::<Vec<_>>(); + let decision = match decisions.as_slice() { + [] => return Some(proposal), + [decision] => *decision, + _ => return None, + }; + parent_event_id = decision.event_id.as_str(); + } +} + fn insert_seller_order_request_evidence( order_id: &OrderId, event: &radroots_sdk::RadrootsNostrEvent, @@ -8935,6 +9424,38 @@ fn order_decision_publish_payload_to_sdk_decision( } } +fn order_revision_proposal_publish_payload_to_sdk_revision( + payload: &AppOrderRevisionProposalPublishPayload, +) -> RadrootsTradeOrderRevisionProposed { + RadrootsTradeOrderRevisionProposed { + revision_id: payload.revision_id.clone(), + 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(), + root_event_id: payload.request_event_id.clone(), + prev_event_id: payload.prev_event_id.clone(), + items: payload.items.clone(), + economics: payload.economics.clone(), + reason: payload.reason.clone(), + } +} + +fn order_revision_decision_publish_payload_to_sdk_revision_decision( + payload: &AppOrderRevisionDecisionPublishPayload, +) -> RadrootsTradeOrderRevisionDecisionEvent { + RadrootsTradeOrderRevisionDecisionEvent { + revision_id: payload.revision_id.clone(), + 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(), + root_event_id: payload.request_event_id.clone(), + prev_event_id: payload.prev_event_id.clone(), + decision: payload.decision.clone(), + } +} + fn order_fulfillment_publish_payload_to_sdk_fulfillment( payload: &AppOrderFulfillmentPublishPayload, ) -> RadrootsTradeFulfillmentUpdated { @@ -9089,13 +9610,14 @@ mod tests { AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, 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, + AppOrderRequestItemPayload, AppOrderRequestPublishPayload, + AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, + 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, @@ -9134,7 +9656,7 @@ mod tests { use radroots_sdk::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsTradeOrderDecision, RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, - RadrootsTradeOrderRequested, RadrootsTradePricingBasis, + RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradePricingBasis, }; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::json; @@ -9708,6 +10230,69 @@ mod tests { buyer_identity.public_key_hex(), seller_identity.public_key_hex(), ); + let revision_economics = RadrootsTradeOrderEconomics { + quote_id: "quote-revision-1".to_owned(), + quote_version: 2, + pricing_basis: RadrootsTradePricingBasis::ListingEvent, + currency: RadrootsCoreCurrency::USD, + items: vec![RadrootsTradeOrderEconomicItem { + bin_id: "bin-1".to_owned(), + bin_count: 3, + quantity_amount: RadrootsCoreDecimal::from(1u32), + quantity_unit: RadrootsCoreUnit::Each, + unit_price_amount: RadrootsCoreDecimal::from(8u32), + unit_price_currency: RadrootsCoreCurrency::USD, + line_subtotal: RadrootsCoreMoney::from_minor_units_u32( + 2400, + RadrootsCoreCurrency::USD, + ), + }], + discounts: Vec::new(), + adjustments: Vec::new(), + subtotal: RadrootsCoreMoney::from_minor_units_u32(2400, RadrootsCoreCurrency::USD), + discount_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), + adjustment_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), + total: RadrootsCoreMoney::from_minor_units_u32(2400, RadrootsCoreCurrency::USD), + }; + let revision_proposal = + AppPublishPayload::OrderRevisionProposal(AppOrderRevisionProposalPublishPayload { + context: AppPublishContext::new( + seller_account_id.to_string(), + "seller_order_revision_proposal", + ), + 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(), + revision_id: "revision-1".to_owned(), + listing_addr: common.4.clone(), + buyer_pubkey: common.5.clone(), + seller_pubkey: common.6.clone(), + items: vec![RadrootsTradeOrderItem { + bin_id: "bin-1".to_owned(), + bin_count: 3, + }], + economics: revision_economics, + reason: "harvest count updated".to_owned(), + }); + let revision_decision = + AppPublishPayload::OrderRevisionDecision(AppOrderRevisionDecisionPublishPayload { + context: AppPublishContext::new( + buyer_account_id.to_string(), + "buyer_order_revision_decision", + ), + 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-revision-proposal-event-1".to_owned(), + revision_id: "revision-1".to_owned(), + listing_addr: common.4.clone(), + buyer_pubkey: common.5.clone(), + seller_pubkey: common.6.clone(), + decision: RadrootsTradeOrderRevisionDecision::Accepted, + }); let cancellation = AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload { context: AppPublishContext::new( @@ -9753,13 +10338,19 @@ mod tests { 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 operations = [ + revision_proposal, + revision_decision, + 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()]); @@ -9773,17 +10364,32 @@ mod tests { .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); + assert_eq!(result.pushed_operation_count, 5); + assert_eq!(relay.event_count(), 5); let kinds = result .published_receipts .iter() .map(|receipt| receipt.event_kind) .collect::<Vec<_>>(); - assert_eq!(kinds, vec![3432, 3433, 3434]); + assert_eq!(kinds, vec![3424, 3425, 3432, 3433, 3434]); for receipt in &result.published_receipts { let event = published_receipt_event(receipt); match receipt.event_kind { + 3424 => { + let envelope = radroots_sdk::trade::parse_order_revision_proposal(&event) + .expect("order revision proposal should parse"); + assert_eq!(envelope.payload.reason, "harvest count updated"); + assert_eq!(envelope.payload.items[0].bin_count, 3); + } + 3425 => { + let envelope = radroots_sdk::trade::parse_order_revision_decision(&event) + .expect("order revision decision should parse"); + assert_eq!(envelope.payload.revision_id, "revision-1"); + assert_eq!( + envelope.payload.decision, + RadrootsTradeOrderRevisionDecision::Accepted + ); + } 3432 => { let envelope = radroots_sdk::trade::parse_order_cancellation(&event) .expect("order cancellation should parse"); diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -50,9 +50,11 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "buyer-checkout-back", "buyer-checkout-place-order", "buyer-listing-open", + "buyer-order-accept-change", "buyer-order-confirm-replace", "buyer-order-cancel", "buyer-order-keep-current", + "buyer-order-keep-order", "buyer-order-mark-received", "buyer-order-repeat-demand", "buyer-orders-retry-coordination", @@ -66,6 +68,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "buyer.order_cancel_failed", "buyer.order_coordination_retry_failed", "buyer.order_receipt_failed", + "buyer.order_revision_accept_failed", + "buyer.order_revision_decline_failed", "buyer.repeat_demand_failed", "buyer.section_select_failed", "buyer_notice", @@ -91,7 +95,9 @@ 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 accept buyer order change", "failed to cancel buyer order", + "failed to keep buyer order", "failed to mark buyer order received", "failed to mark order delivered", "failed to mark order ready", @@ -404,6 +410,8 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalOrdersDetailNoteLabel", "AppTextKey::PersonalOrdersDetailItemsTitle", "AppTextKey::PersonalOrdersActionCancel", + "AppTextKey::PersonalOrdersActionAcceptChange", + "AppTextKey::PersonalOrdersActionKeepOrder", "AppTextKey::PersonalOrdersActionMarkReceived", "AppTextKey::PersonalOrdersRepeatDemandTitle", "AppTextKey::PersonalOrdersRepeatDemandActionEligible", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -2143,6 +2143,38 @@ impl HomeView { } } + fn accept_buyer_order_revision(&mut self, order_id: OrderId, cx: &mut Context<Self>) { + match self.runtime.publish_buyer_order_revision_accept(order_id) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "personal_orders", + event = "buyer.order_revision_accept_failed", + error = %runtime_error, + order_id = %order_id, + "failed to accept buyer order change" + ); + } + } + } + + fn decline_buyer_order_revision(&mut self, order_id: OrderId, cx: &mut Context<Self>) { + match self.runtime.publish_buyer_order_revision_decline(order_id) { + Ok(true) => cx.notify(), + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "personal_orders", + event = "buyer.order_revision_decline_failed", + error = %runtime_error, + order_id = %order_id, + "failed to keep 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(), @@ -8939,6 +8971,38 @@ fn buyer_order_detail_card( }), )) .when( + detail.status == BuyerOrderStatus::Scheduled + && detail.workflow.revision == TradeRevisionStatus::ChangeProposed, + |this| { + this.child( + app_stack_h(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child(action_button_primary( + "buyer-order-accept-change", + app_shared_text(AppTextKey::PersonalOrdersActionAcceptChange), + cx.listener({ + let order_id = detail.order_id; + move |this, _, _, cx| { + this.accept_buyer_order_revision(order_id, cx) + } + }), + cx, + )) + .child(action_button_compact( + "buyer-order-keep-order", + app_shared_text(AppTextKey::PersonalOrdersActionKeepOrder), + cx.listener({ + let order_id = detail.order_id; + move |this, _, _, cx| { + this.decline_buyer_order_revision(order_id, cx) + } + }), + cx, + )), + ) + }, + ) + .when( matches!( detail.status, BuyerOrderStatus::Placed | BuyerOrderStatus::Scheduled diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -141,6 +141,8 @@ define_app_text_keys! { PersonalOrdersDetailNoteLabel => "personal.orders.detail.note.label", PersonalOrdersDetailItemsTitle => "personal.orders.detail.items.title", PersonalOrdersActionCancel => "personal.orders.action.cancel", + PersonalOrdersActionAcceptChange => "personal.orders.action.accept_change", + PersonalOrdersActionKeepOrder => "personal.orders.action.keep_order", PersonalOrdersActionMarkReceived => "personal.orders.action.mark_received", PersonalOrdersRepeatDemandTitle => "personal.orders.repeat_demand.title", PersonalOrdersRepeatDemandActionEligible => "personal.orders.repeat_demand.action.eligible", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -485,6 +485,14 @@ mod tests { "Cancel order" ); assert_eq!( + app_text(AppTextKey::PersonalOrdersActionAcceptChange), + "Accept change" + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersActionKeepOrder), + "Keep order" + ); + assert_eq!( app_text(AppTextKey::PersonalOrdersActionMarkReceived), "Mark received" ); diff --git a/crates/store/migrations/0022_order_workflow_revision.sql b/crates/store/migrations/0022_order_workflow_revision.sql @@ -0,0 +1,4 @@ +ALTER TABLE orders + ADD COLUMN workflow_revision TEXT NOT NULL DEFAULT 'none' CHECK ( + workflow_revision IN ('none', 'change_proposed', 'updated', 'kept_as_placed') + ); diff --git a/crates/store/src/interop.rs b/crates/store/src/interop.rs @@ -3,6 +3,7 @@ use std::{fs, path::Path}; use radroots_app_view::{ FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FulfillmentWindowId, OrderId, OrderStatus, PickupLocationId, ProductId, ProductStatus, + TradeRevisionStatus, }; use radroots_events::{ RadrootsNostrEvent, @@ -11,7 +12,10 @@ use radroots_events::{ KIND_TRADE_ORDER_RESPONSE, KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, KIND_TRADE_RECEIPT, }, - trade::{RadrootsActiveTradeFulfillmentState, RadrootsTradeOrderRequested}, + trade::{ + RadrootsActiveTradeFulfillmentState, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, + RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, + }, }; use radroots_events_codec::trade::{ active_trade_buyer_receipt_from_event, active_trade_event_context_from_tags, @@ -1074,6 +1078,9 @@ impl<'a> AppLocalInteropRepository<'a> { let buyer_pubkey = buyer_pubkey.to_owned(); let order_id = projected_order_id(raw_order_id.as_str(), buyer_pubkey.as_str()); let buckets = ActiveOrderEvidenceBuckets::from_evidence(evidence); + let requests = buckets.requests.clone(); + let revision_proposals = buckets.revision_proposals.clone(); + let revision_decisions = buckets.revision_decisions.clone(); let projection = reduce_active_order_events( raw_order_id.as_str(), buckets.requests, @@ -1086,7 +1093,28 @@ impl<'a> AppLocalInteropRepository<'a> { [], [], ); - self.apply_active_order_projection(order_id, &projection) + let request_payload = projection.request_event_id.as_deref().and_then(|event_id| { + requests + .iter() + .find(|request| request.event_id == event_id) + .map(|request| &request.payload) + }); + let revision = + active_order_revision_status(&projection, &revision_proposals, &revision_decisions); + let agreement_source = request_payload.map(|request| { + active_order_agreement_source( + request, + &projection, + &revision_proposals, + &revision_decisions, + ) + }); + self.apply_active_order_projection( + order_id, + &projection, + revision, + agreement_source.as_ref(), + ) } fn upsert_order_request( @@ -1150,6 +1178,8 @@ impl<'a> AppLocalInteropRepository<'a> { &self, order_id: OrderId, projection: &RadrootsActiveOrderProjection, + revision: TradeRevisionStatus, + agreement_source: Option<&ActiveOrderAgreementSource>, ) -> Result<(), AppSqliteError> { let Some(status) = order_status_from_active_projection(projection) else { return Ok(()); @@ -1158,14 +1188,24 @@ impl<'a> AppLocalInteropRepository<'a> { .execute( "UPDATE orders SET status = ?2, + workflow_revision = ?3, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?1", - params![order_id.to_string(), status.storage_key()], + params![ + order_id.to_string(), + status.storage_key(), + revision.storage_key() + ], ) .map_err(|source| AppSqliteError::Query { operation: "apply local interop active order projection", source, })?; + if projection.economics.is_some() + && let Some(agreement_source) = agreement_source + { + self.replace_active_order_agreement_lines(order_id, agreement_source)?; + } Ok(()) } @@ -1270,6 +1310,139 @@ impl<'a> AppLocalInteropRepository<'a> { Ok(()) } + fn replace_active_order_agreement_lines( + &self, + order_id: OrderId, + source: &ActiveOrderAgreementSource, + ) -> Result<(), AppSqliteError> { + let existing_listing = + self.existing_listing_projection(Some(source.listing_addr.as_str()))?; + let metadata = self.existing_order_line_metadata(order_id)?; + self.connection + .execute( + "DELETE FROM order_lines WHERE order_id = ?1", + params![order_id.to_string()], + ) + .map_err(|source| AppSqliteError::Query { + operation: "replace local interop active order agreement lines", + source, + })?; + for (index, item) in source.items.iter().enumerate() { + let economics_item = source + .economics + .items + .iter() + .find(|candidate| candidate.bin_id == item.bin_id); + let unit_label = economics_item + .map(|item| item.quantity_unit.to_string()) + .or_else(|| { + existing_listing + .as_ref() + .map(|listing| listing.unit_label.clone()) + }) + .unwrap_or_else(|| "item".to_owned()); + let unit_price_minor_units = economics_item.and_then(|item| { + parse_decimal_minor_units(item.unit_price_amount.to_string().as_str()) + }); + let price_currency = economics_item + .map(|item| item.unit_price_currency.to_string()) + .unwrap_or_else(|| source.economics.currency.to_string()); + let title = existing_listing + .as_ref() + .filter(|listing| { + listing + .listing_bin_id + .as_deref() + .is_none_or(|listing_bin_id| listing_bin_id == item.bin_id) + }) + .map(|listing| listing.title.clone()) + .unwrap_or_else(|| item.bin_id.clone()); + self.connection + .execute( + "INSERT INTO order_lines ( + id, + order_id, + title, + quantity_value, + quantity_unit_label, + quantity_display, + listing_bin_id, + unit_price_minor_units, + price_currency, + farm_key, + listing_addr, + listing_event_id, + listing_relays_json, + seller_pubkey, + sort_index + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + params![ + format!( + "{}:{}", + order_id, + order_agreement_line_product_id( + source.listing_addr.as_str(), + source.seller_pubkey.as_str(), + existing_listing.as_ref(), + item, + ) + ), + order_id.to_string(), + title.as_str(), + i64::from(item.bin_count), + unit_label.as_str(), + format_quantity_display(item.bin_count, unit_label.as_str()), + item.bin_id.as_str(), + unit_price_minor_units, + price_currency.as_str(), + existing_listing + .as_ref() + .and_then(|listing| listing.farm_key.as_deref()), + source.listing_addr.as_str(), + metadata + .as_ref() + .and_then(|metadata| metadata.listing_event_id.as_deref()), + metadata + .as_ref() + .and_then(|metadata| metadata.listing_relays_json.as_deref()), + source.seller_pubkey.as_str(), + index as i64, + ], + ) + .map_err(|source| AppSqliteError::Query { + operation: "insert local interop active order agreement line", + source, + })?; + } + Ok(()) + } + + fn existing_order_line_metadata( + &self, + order_id: OrderId, + ) -> Result<Option<ExistingOrderLineMetadata>, AppSqliteError> { + self.connection + .query_row( + "SELECT listing_event_id, listing_relays_json + FROM order_lines + WHERE order_id = ?1 + ORDER BY sort_index ASC, id ASC + LIMIT 1", + params![order_id.to_string()], + |row| { + Ok(ExistingOrderLineMetadata { + listing_event_id: row.get::<_, Option<String>>(0)?, + listing_relays_json: row.get::<_, Option<String>>(1)?, + }) + }, + ) + .optional() + .map_err(|source| AppSqliteError::Query { + operation: "load existing local interop order line metadata", + source, + }) + } + fn upsert_farm_summary(&self, farm: &FarmSummary) -> Result<(), AppSqliteError> { self.connection .execute( @@ -2254,6 +2427,20 @@ struct ExistingListingProjection { } #[derive(Clone, Debug, Eq, PartialEq)] +struct ActiveOrderAgreementSource { + listing_addr: String, + seller_pubkey: String, + items: Vec<RadrootsTradeOrderItem>, + economics: RadrootsTradeOrderEconomics, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExistingOrderLineMetadata { + listing_event_id: Option<String>, + listing_relays_json: Option<String>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] enum ActiveOrderEvidence { Request(RadrootsActiveOrderRequestRecord), Decision(RadrootsActiveOrderDecisionRecord), @@ -2843,11 +3030,101 @@ fn projected_order_id(order_id: &str, buyer_pubkey: &str) -> OrderId { projected_order_id_from_trade_request(order_id, buyer_pubkey) } +fn active_order_revision_status( + projection: &RadrootsActiveOrderProjection, + revision_proposals: &[RadrootsActiveOrderRevisionProposalRecord], + revision_decisions: &[RadrootsActiveOrderRevisionDecisionRecord], +) -> TradeRevisionStatus { + let Some(mut parent_event_id) = projection.decision_event_id.clone() else { + return TradeRevisionStatus::None; + }; + let mut status = TradeRevisionStatus::None; + loop { + let matching_proposals = revision_proposals + .iter() + .filter(|proposal| proposal.prev_event_id == parent_event_id) + .collect::<Vec<_>>(); + let proposal = match matching_proposals.as_slice() { + [] => return status, + [proposal] => *proposal, + _ => return TradeRevisionStatus::None, + }; + let matching_decisions = revision_decisions + .iter() + .filter(|decision| decision.prev_event_id == proposal.event_id) + .collect::<Vec<_>>(); + let decision = match matching_decisions.as_slice() { + [] => return TradeRevisionStatus::ChangeProposed, + [decision] => *decision, + _ => return TradeRevisionStatus::None, + }; + if decision.payload.revision_id != proposal.payload.revision_id { + return TradeRevisionStatus::None; + } + status = match &decision.payload.decision { + RadrootsTradeOrderRevisionDecision::Accepted => TradeRevisionStatus::Updated, + RadrootsTradeOrderRevisionDecision::Declined { .. } => { + TradeRevisionStatus::KeptAsPlaced + } + }; + parent_event_id.clone_from(&decision.event_id); + } +} + +fn active_order_agreement_source( + request: &RadrootsTradeOrderRequested, + projection: &RadrootsActiveOrderProjection, + revision_proposals: &[RadrootsActiveOrderRevisionProposalRecord], + revision_decisions: &[RadrootsActiveOrderRevisionDecisionRecord], +) -> ActiveOrderAgreementSource { + if let Some(agreement_event_id) = projection.agreement_event_id.as_deref() + && projection.decision_event_id.as_deref() != Some(agreement_event_id) + && let Some(revision_decision) = revision_decisions.iter().find(|decision| { + decision.event_id == agreement_event_id + && matches!( + &decision.payload.decision, + RadrootsTradeOrderRevisionDecision::Accepted + ) + }) + && let Some(revision_proposal) = revision_proposals.iter().find(|proposal| { + proposal.event_id == revision_decision.prev_event_id + && proposal.payload.revision_id == revision_decision.payload.revision_id + }) + { + return ActiveOrderAgreementSource { + listing_addr: revision_proposal.payload.listing_addr.clone(), + seller_pubkey: revision_proposal.payload.seller_pubkey.clone(), + items: revision_proposal.payload.items.clone(), + economics: revision_proposal.payload.economics.clone(), + }; + } + ActiveOrderAgreementSource { + listing_addr: request.listing_addr.clone(), + seller_pubkey: request.seller_pubkey.clone(), + items: request.items.clone(), + economics: request.economics.clone(), + } +} + fn order_line_product_id( payload: &RadrootsTradeOrderRequested, existing_listing: Option<&ExistingListingProjection>, item: &radroots_events::trade::RadrootsTradeOrderItem, ) -> ProductId { + order_agreement_line_product_id( + payload.listing_addr.as_str(), + payload.seller_pubkey.as_str(), + existing_listing, + item, + ) +} + +fn order_agreement_line_product_id( + listing_addr: &str, + seller_pubkey: &str, + existing_listing: Option<&ExistingListingProjection>, + item: &RadrootsTradeOrderItem, +) -> ProductId { if let Some(existing_listing) = existing_listing && existing_listing .listing_bin_id @@ -2856,8 +3133,8 @@ fn order_line_product_id( { return existing_listing.product_id; } - let product_key = format!("{}:{}", payload.listing_addr, item.bin_id); - deterministic_product_id(Some(payload.seller_pubkey.as_str()), product_key.as_str()) + let product_key = format!("{listing_addr}:{}", item.bin_id); + deterministic_product_id(Some(seller_pubkey), product_key.as_str()) } fn deterministic_order_number(order_id: &str) -> String { @@ -3305,7 +3582,7 @@ mod tests { use radroots_app_view::{ BuyerContext, BuyerOrderStatus, FarmId, FarmOrderMethod, OrderStatus, OrdersFilter, - OrdersScreenQueryState, ProductAvailabilityState, ProductId, + OrdersScreenQueryState, ProductAvailabilityState, ProductId, TradeRevisionStatus, }; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, @@ -3891,7 +4168,15 @@ mod tests { root_event_id: &str, prev_event_id: &str, ) -> RadrootsTradeOrderRevisionProposed { - let request = order_request_payload(order_id, listing_addr, buyer_pubkey, seller_pubkey); + let mut request = + order_request_payload(order_id, listing_addr, buyer_pubkey, seller_pubkey); + request.items[0].bin_count = 3; + request.economics.quote_id = format!("quote-{order_id}-{revision_id}"); + request.economics.quote_version = 2; + request.economics.items[0].bin_count = 3; + request.economics.items[0].line_subtotal = usd("24"); + request.economics.subtotal = usd("24"); + request.economics.total = usd("24"); RadrootsTradeOrderRevisionProposed { revision_id: revision_id.to_owned(), order_id: order_id.to_owned(), @@ -4858,6 +5143,7 @@ mod tests { let seller_farm_id = deterministic_farm_id(Some(seller_pubkey), farm_key); let order_id = projected_order_id(order_id_raw, buyer_pubkey); + let buyer_context = BuyerContext::account("acct_revision"); let seller_orders = app_store .load_orders_list( seller_farm_id, @@ -4868,6 +5154,19 @@ mod tests { ) .expect("load revision seller orders after proposal"); assert_eq!(seller_orders.rows[0].status, OrderStatus::Scheduled); + assert_eq!( + seller_orders.rows[0].workflow.revision, + TradeRevisionStatus::ChangeProposed + ); + let buyer_detail = app_store + .load_buyer_order_detail(&buyer_context, order_id) + .expect("load revision buyer detail after proposal") + .expect("revision buyer detail after proposal"); + assert_eq!( + buyer_detail.workflow.revision, + TradeRevisionStatus::ChangeProposed + ); + assert_eq!(buyer_detail.economics.total_minor_units, Some(1600)); let revision_decision_payload = revision_decision_payload( "revision-1", @@ -4912,6 +5211,25 @@ mod tests { ) .expect("load revision seller orders after decision"); assert_eq!(seller_orders.rows[0].status, OrderStatus::Scheduled); + assert_eq!( + seller_orders.rows[0].workflow.revision, + TradeRevisionStatus::Updated + ); + let seller_detail = app_store + .load_order_detail(seller_farm_id, order_id) + .expect("load revision seller detail after decision") + .expect("revision seller detail after decision"); + let buyer_detail = app_store + .load_buyer_order_detail(&buyer_context, order_id) + .expect("load revision buyer detail after decision") + .expect("revision buyer detail after decision"); + assert_eq!( + seller_detail.workflow.revision, + TradeRevisionStatus::Updated + ); + assert_eq!(seller_detail.economics.total_minor_units, Some(2400)); + assert_eq!(buyer_detail.workflow.revision, TradeRevisionStatus::Updated); + assert_eq!(buyer_detail.economics.total_minor_units, Some(2400)); let cancel_payload = order_cancel_payload( order_id_raw, @@ -4940,7 +5258,7 @@ mod tests { .import_shared_local_events_from_store(&events) .expect("import revision cancellation"); let buyer_detail = app_store - .load_buyer_order_detail(&BuyerContext::account("acct_revision"), order_id) + .load_buyer_order_detail(&buyer_context, order_id) .expect("load revision buyer detail") .expect("revision buyer detail"); let seller_orders = app_store @@ -4953,6 +5271,7 @@ mod tests { ) .expect("load revision seller orders after cancellation"); assert_eq!(buyer_detail.status, BuyerOrderStatus::Declined); + assert_eq!(buyer_detail.workflow.revision, TradeRevisionStatus::Updated); assert_eq!(seller_orders.rows[0].status, OrderStatus::Declined); } diff --git a/crates/store/src/migrations.rs b/crates/store/src/migrations.rs @@ -88,6 +88,10 @@ const MIGRATIONS: &[Migration] = &[ version: 21, sql: include_str!("../migrations/0021_local_interop_signed_event_evidence.sql"), }, + Migration { + version: 22, + sql: include_str!("../migrations/0022_order_workflow_revision.sql"), + }, ]; pub fn latest_schema_version() -> u32 { diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs @@ -9,7 +9,7 @@ use radroots_app_view::{ OrderId, OrderStatus, ProductAvailabilityState, ProductAvailabilitySummary, ProductId, ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary, RepeatDemandEligibility, RepeatDemandHandoffProjection, TradePaymentDisplayStatus, - TradeWorkflowProjection, + TradeRevisionStatus, TradeWorkflowProjection, }; use rusqlite::{Connection, OptionalExtension, params}; use serde_json::Value; @@ -740,6 +740,7 @@ impl<'a> AppBuyerRepository<'a> { o.farm_id, o.order_number, o.status, + o.workflow_revision, f.display_name, fw.label, fw.starts_at, @@ -762,9 +763,10 @@ impl<'a> AppBuyerRepository<'a> { row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, - row.get::<_, Option<String>>(5)?, + row.get::<_, String>(5)?, row.get::<_, Option<String>>(6)?, row.get::<_, Option<String>>(7)?, + row.get::<_, Option<String>>(8)?, )) }) .map_err(|source| AppSqliteError::Query { @@ -779,6 +781,7 @@ impl<'a> AppBuyerRepository<'a> { farm_id, order_number, status, + workflow_revision, farm_display_name, fulfillment_label, fulfillment_starts_at, @@ -808,7 +811,10 @@ impl<'a> AppBuyerRepository<'a> { fulfillment_ends_at, ), status: buyer_status, - workflow: TradeWorkflowProjection::from_buyer_order_status(order_id, buyer_status), + workflow: TradeWorkflowProjection::from_buyer_order_status(order_id, buyer_status) + .with_revision(TradeRevisionStatus::from_storage_key( + workflow_revision.as_str(), + )), }); } @@ -832,6 +838,7 @@ impl<'a> AppBuyerRepository<'a> { o.order_number, o.status, o.buyer_order_note, + o.workflow_revision, f.display_name, fw.label, fw.starts_at, @@ -850,9 +857,10 @@ impl<'a> AppBuyerRepository<'a> { row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?, - row.get::<_, Option<String>>(6)?, + row.get::<_, String>(6)?, row.get::<_, Option<String>>(7)?, row.get::<_, Option<String>>(8)?, + row.get::<_, Option<String>>(9)?, )) }, ) @@ -870,6 +878,7 @@ impl<'a> AppBuyerRepository<'a> { order_number, status, order_note, + workflow_revision, farm_display_name, fulfillment_label, fulfillment_starts_at, @@ -884,6 +893,9 @@ impl<'a> AppBuyerRepository<'a> { let payment = TradePaymentDisplayStatus::NotRecorded; let workflow = TradeWorkflowProjection::from_buyer_order_status(order_id, status) + .with_revision(TradeRevisionStatus::from_storage_key( + workflow_revision.as_str(), + )) .with_economics_and_payment(economics.clone(), payment); Ok(BuyerOrderDetailProjection { order_id, diff --git a/crates/store/src/repo/orders.rs b/crates/store/src/repo/orders.rs @@ -7,7 +7,8 @@ use radroots_app_view::{ PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, - PackDayScreenQueryState, ProductId, TradePaymentDisplayStatus, TradeWorkflowProjection, + PackDayScreenQueryState, ProductId, TradePaymentDisplayStatus, TradeRevisionStatus, + TradeWorkflowProjection, }; use rusqlite::{Connection, OptionalExtension, params}; @@ -74,6 +75,7 @@ impl<'a> AppOrdersRepository<'a> { o.customer_display_name, o.status, o.fulfillment_window_id, + o.workflow_revision, fw.label, pl.label from orders o @@ -90,8 +92,9 @@ impl<'a> AppOrdersRepository<'a> { row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, Option<String>>(5)?, - row.get::<_, Option<String>>(6)?, + row.get::<_, String>(6)?, row.get::<_, Option<String>>(7)?, + row.get::<_, Option<String>>(8)?, )) }, ) @@ -110,6 +113,7 @@ impl<'a> AppOrdersRepository<'a> { customer_display_name, status, fulfillment_window_id, + workflow_revision, fulfillment_window_label, pickup_location_label, )| { @@ -120,6 +124,9 @@ impl<'a> AppOrdersRepository<'a> { let economics = order_detail_economics(&items)?; let payment = TradePaymentDisplayStatus::NotRecorded; let workflow = TradeWorkflowProjection::from_order_status(order_id, status) + .with_revision(TradeRevisionStatus::from_storage_key( + workflow_revision.as_str(), + )) .with_economics_and_payment(economics.clone(), payment); Ok(OrderDetailProjection { order_id, @@ -285,6 +292,7 @@ impl<'a> AppOrdersRepository<'a> { o.order_number, o.customer_display_name, o.status, + o.workflow_revision, fw.label, pl.label from orders o @@ -312,8 +320,9 @@ impl<'a> AppOrdersRepository<'a> { row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?, - row.get::<_, Option<String>>(6)?, + row.get::<_, String>(6)?, row.get::<_, Option<String>>(7)?, + row.get::<_, Option<String>>(8)?, )) }, ) @@ -331,6 +340,7 @@ impl<'a> AppOrdersRepository<'a> { order_number, customer_display_name, status, + workflow_revision, fulfillment_window_label, pickup_location_label, ) = row.map_err(|source| AppSqliteError::Query { @@ -350,6 +360,7 @@ impl<'a> AppOrdersRepository<'a> { fulfillment_window_label: empty_string_to_none(fulfillment_window_label), pickup_location_label: empty_string_to_none(pickup_location_label), status: parse_order_status("orders.status", status)?, + revision: TradeRevisionStatus::from_storage_key(workflow_revision.as_str()), }); } @@ -1117,6 +1128,7 @@ struct OrderRecord { fulfillment_window_label: Option<String>, pickup_location_label: Option<String>, status: OrderStatus, + revision: TradeRevisionStatus, } impl OrderRecord { @@ -1132,7 +1144,8 @@ impl OrderRecord { } fn into_list_row(self) -> OrdersListRow { - let workflow = TradeWorkflowProjection::from_order_status(self.order_id, self.status); + let workflow = TradeWorkflowProjection::from_order_status(self.order_id, self.status) + .with_revision(self.revision); OrdersListRow { order_id: self.order_id, diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs @@ -7,6 +7,7 @@ pub use publish::{ AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderFulfillmentPublishPayload, AppOrderFulfillmentPublishStatus, AppOrderReceiptPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, + AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, AppPublishContext, AppPublishPayload, AppPublishPayloadJsonError, AppPublishValidationFailure, AppPublishValidationFailureSet, AppPublishWorkKind, }; diff --git a/crates/sync/src/publish.rs b/crates/sync/src/publish.rs @@ -2,6 +2,9 @@ use radroots_app_view::{ FarmId, FarmReadiness, FulfillmentWindowId, OrderId, ProductId, ProductStatus, }; use radroots_sdk::SdkTransportMode; +use radroots_sdk::trade::{ + RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRevisionDecision, +}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -14,6 +17,8 @@ pub enum AppPublishWorkKind { Listing, OrderRequest, OrderDecision, + OrderRevisionProposal, + OrderRevisionDecision, OrderCancellation, OrderFulfillment, OrderReceipt, @@ -26,6 +31,8 @@ impl AppPublishWorkKind { Self::Listing => "listing", Self::OrderRequest => "order_request", Self::OrderDecision => "order_decision", + Self::OrderRevisionProposal => "order_revision_proposal", + Self::OrderRevisionDecision => "order_revision_decision", Self::OrderCancellation => "order_cancellation", Self::OrderFulfillment => "order_fulfillment", Self::OrderReceipt => "order_receipt", @@ -38,6 +45,8 @@ 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::OrderRevisionProposal => "trade.publish_order_revision_proposal_with_identity", + Self::OrderRevisionDecision => "trade.publish_order_revision_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", @@ -172,6 +181,38 @@ pub struct AppOrderDecisionPublishPayload { pub decision: AppOrderDecisionPayload, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppOrderRevisionProposalPublishPayload { + 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 revision_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub items: Vec<RadrootsTradeOrderItem>, + pub economics: RadrootsTradeOrderEconomics, + pub reason: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AppOrderRevisionDecisionPublishPayload { + 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 revision_id: String, + pub listing_addr: String, + pub buyer_pubkey: String, + pub seller_pubkey: String, + pub decision: RadrootsTradeOrderRevisionDecision, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AppOrderFulfillmentPublishStatus { @@ -233,6 +274,8 @@ pub enum AppPublishPayload { Listing(AppListingPublishPayload), OrderRequest(AppOrderRequestPublishPayload), OrderDecision(AppOrderDecisionPublishPayload), + OrderRevisionProposal(AppOrderRevisionProposalPublishPayload), + OrderRevisionDecision(AppOrderRevisionDecisionPublishPayload), OrderCancellation(AppOrderCancellationPublishPayload), OrderFulfillment(AppOrderFulfillmentPublishPayload), OrderReceipt(AppOrderReceiptPublishPayload), @@ -245,6 +288,8 @@ impl AppPublishPayload { Self::Listing(_) => AppPublishWorkKind::Listing, Self::OrderRequest(_) => AppPublishWorkKind::OrderRequest, Self::OrderDecision(_) => AppPublishWorkKind::OrderDecision, + Self::OrderRevisionProposal(_) => AppPublishWorkKind::OrderRevisionProposal, + Self::OrderRevisionDecision(_) => AppPublishWorkKind::OrderRevisionDecision, Self::OrderCancellation(_) => AppPublishWorkKind::OrderCancellation, Self::OrderFulfillment(_) => AppPublishWorkKind::OrderFulfillment, Self::OrderReceipt(_) => AppPublishWorkKind::OrderReceipt, @@ -265,6 +310,8 @@ 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::OrderRevisionProposal(payload) => SyncAggregateRef::Order(payload.app_order_id), + Self::OrderRevisionDecision(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), @@ -433,6 +480,53 @@ impl AppPublishPayload { } } } + Self::OrderRevisionProposal(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.revision_id.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderRevisionId); + } + if payload.items.is_empty() + || payload + .items + .iter() + .any(|item| item.bin_id.trim().is_empty() || item.bin_count == 0) + { + failures.push(AppPublishValidationFailure::MissingOrderRevisionItems); + } + if payload.economics.validate().is_err() { + failures.push(AppPublishValidationFailure::InvalidOrderRevisionEconomics); + } + if payload.reason.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderRevisionReason); + } + } + Self::OrderRevisionDecision(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.revision_id.trim().is_empty() { + failures.push(AppPublishValidationFailure::MissingOrderRevisionId); + } + if payload.decision.validate().is_err() { + failures.push(AppPublishValidationFailure::MissingOrderRevisionDecisionReason); + } + } Self::OrderCancellation(payload) => { validate_lifecycle_order_fields( &payload.context, @@ -564,6 +658,11 @@ pub enum AppPublishValidationFailure { MissingOrderPreviousEventId, MissingOrderDecisionInventory, MissingOrderDeclineReason, + MissingOrderRevisionId, + MissingOrderRevisionItems, + InvalidOrderRevisionEconomics, + MissingOrderRevisionReason, + MissingOrderRevisionDecisionReason, MissingOrderCancellationReason, MissingOrderReceiptIssue, UnexpectedOrderReceiptIssue, @@ -600,6 +699,11 @@ impl AppPublishValidationFailure { Self::MissingOrderPreviousEventId => "missing_order_previous_event_id", Self::MissingOrderDecisionInventory => "missing_order_decision_inventory", Self::MissingOrderDeclineReason => "missing_order_decline_reason", + Self::MissingOrderRevisionId => "missing_order_revision_id", + Self::MissingOrderRevisionItems => "missing_order_revision_items", + Self::InvalidOrderRevisionEconomics => "invalid_order_revision_economics", + Self::MissingOrderRevisionReason => "missing_order_revision_reason", + Self::MissingOrderRevisionDecisionReason => "missing_order_revision_decision_reason", Self::MissingOrderCancellationReason => "missing_order_cancellation_reason", Self::MissingOrderReceiptIssue => "missing_order_receipt_issue", Self::UnexpectedOrderReceiptIssue => "unexpected_order_receipt_issue", @@ -657,13 +761,18 @@ mod tests { AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderFulfillmentPublishPayload, AppOrderFulfillmentPublishStatus, AppOrderReceiptPublishPayload, - AppOrderRequestItemPayload, AppOrderRequestPublishPayload, AppPublishContext, - AppPublishPayload, AppPublishValidationFailure, + AppOrderRequestItemPayload, AppOrderRequestPublishPayload, + AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, + AppPublishContext, AppPublishPayload, AppPublishValidationFailure, }; use crate::{ PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind, }; use radroots_app_view::{FarmId, FarmReadiness, OrderId, ProductId, ProductStatus}; + use radroots_sdk::trade::{ + RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRevisionDecision, + }; + use serde_json::json; #[test] fn publish_payload_serializes_with_stable_kind_and_sdk_target() { @@ -963,6 +1072,117 @@ mod tests { } #[test] + fn order_revision_publish_payloads_report_stable_validation_reason_codes() { + let order_id = OrderId::new(); + let farm_id = FarmId::new(); + let economics = revision_economics(); + let valid_proposal = + AppPublishPayload::OrderRevisionProposal(AppOrderRevisionProposalPublishPayload { + context: AppPublishContext::new("acct_seller", "seller_order_revision_proposal"), + 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(), + revision_id: "revision-1".to_owned(), + listing_addr: "30402:seller:listing".to_owned(), + buyer_pubkey: "buyer".to_owned(), + seller_pubkey: "seller".to_owned(), + items: vec![RadrootsTradeOrderItem { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + economics: economics.clone(), + reason: "harvest count updated".to_owned(), + }); + let invalid_proposal = + AppPublishPayload::OrderRevisionProposal(AppOrderRevisionProposalPublishPayload { + 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(), + revision_id: String::new(), + listing_addr: String::new(), + buyer_pubkey: String::new(), + seller_pubkey: String::new(), + items: Vec::new(), + economics: economics.clone(), + reason: " ".to_owned(), + }); + let invalid_decision = + AppPublishPayload::OrderRevisionDecision(AppOrderRevisionDecisionPublishPayload { + 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(), + revision_id: String::new(), + listing_addr: String::new(), + buyer_pubkey: String::new(), + seller_pubkey: String::new(), + decision: RadrootsTradeOrderRevisionDecision::Declined { + reason: " ".to_owned(), + }, + }); + + assert_eq!( + valid_proposal.work_kind().sdk_operation(), + "trade.publish_order_revision_proposal_with_identity" + ); + assert_eq!(valid_proposal.validation_failures(), Vec::new()); + assert_eq!( + invalid_decision.work_kind().sdk_operation(), + "trade.publish_order_revision_decision_with_identity" + ); + + let proposal_reason_codes: Vec<&str> = invalid_proposal + .validation_failures() + .into_iter() + .map(AppPublishValidationFailure::storage_key) + .collect(); + let decision_reason_codes: Vec<&str> = invalid_decision + .validation_failures() + .into_iter() + .map(AppPublishValidationFailure::storage_key) + .collect(); + + assert_eq!( + proposal_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_revision_id", + "missing_order_revision_items", + "missing_order_revision_reason", + ] + ); + assert_eq!( + decision_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_revision_id", + "missing_order_revision_decision_reason", + ] + ); + } + + #[test] fn existing_raw_payload_outbox_work_remains_local_save_compatible() { let pending_operation = PendingSyncOperation { operation_key: "product:greens:upsert".to_owned(), @@ -979,4 +1199,44 @@ mod tests { assert!(!pending_operation.is_retry()); assert!(pending_operation.publish_payload().is_err()); } + + fn revision_economics() -> RadrootsTradeOrderEconomics { + serde_json::from_value(json!({ + "quote_id": "quote-revision-1", + "quote_version": 2, + "pricing_basis": "listing_event", + "currency": "USD", + "items": [{ + "bin_id": "bin-1", + "bin_count": 2, + "quantity_amount": "1", + "quantity_unit": "each", + "unit_price_amount": "8", + "unit_price_currency": "USD", + "line_subtotal": { + "amount": "16", + "currency": "USD" + } + }], + "discounts": [], + "adjustments": [], + "subtotal": { + "amount": "16", + "currency": "USD" + }, + "discount_total": { + "amount": "0", + "currency": "USD" + }, + "adjustment_total": { + "amount": "0", + "currency": "USD" + }, + "total": { + "amount": "16", + "currency": "USD" + } + })) + .expect("revision economics fixture should decode") + } } diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -1196,6 +1196,15 @@ impl TradeRevisionStatus { TradeReducerRevisionStatus::Declined => Self::KeptAsPlaced, } } + + pub fn from_storage_key(value: &str) -> Self { + match value.trim() { + "change_proposed" => Self::ChangeProposed, + "updated" => Self::Updated, + "kept_as_placed" => Self::KeptAsPlaced, + _ => Self::None, + } + } } impl From<TradeReducerRevisionStatus> for TradeRevisionStatus { @@ -1531,6 +1540,11 @@ impl TradeWorkflowProjection { self } + pub fn with_revision(mut self, revision: TradeRevisionStatus) -> Self { + self.revision = revision; + self + } + pub fn with_economics_and_payment( self, economics: TradeEconomicsProjection, diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -121,6 +121,8 @@ "personal.orders.detail.note.label": "Order note", "personal.orders.detail.items.title": "Items", "personal.orders.action.cancel": "Cancel order", + "personal.orders.action.accept_change": "Accept change", + "personal.orders.action.keep_order": "Keep order", "personal.orders.action.mark_received": "Mark received", "personal.orders.repeat_demand.title": "Reorder", "personal.orders.repeat_demand.action.eligible": "Reorder",