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