app

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

commit 0d2cee1d8a8ab6f42fd5b2f1ce67fbdc4e01e139
parent dc340809c9c216dc0f32318e6c9a9308f5c5b694
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 22:34:07 -0700

app: add buyer issue receipt parity

- add typed buyer receipt outcomes for clean and issue receipts
- persist reducer-backed receipt projection across buyer and seller views
- add localized receipt badges and buyer issue authoring controls
- prove clean, delivered, issue, migration, and source guard behavior

Diffstat:
Mcrates/desktop/src/runtime.rs | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/desktop/src/source_guards.rs | 17+++++++++++++++++
Mcrates/desktop/src/window.rs | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/i18n/src/keys.rs | 11+++++++++++
Mcrates/i18n/src/lib.rs | 33+++++++++++++++++++++++++++++++++
Acrates/store/migrations/0025_order_receipt_display_projection.sql | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/store/src/interop.rs | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/store/src/lib.rs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/store/src/migrations.rs | 4++++
Mcrates/store/src/repo/buyer.rs | 57+++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/store/src/repo/orders.rs | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/store/src/repo/workflow.rs | 46+++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sync/src/lib.rs | 10+++++-----
Mcrates/sync/src/publish.rs | 46+++++++++++++++++++++++++++++++++++++++++++---
Mcrates/types/src/lib.rs | 10+++++++++-
Mcrates/view/src/lib.rs | 51++++++++++++++++++++++++++++++++++++++++++++-------
Mi18n/locales/en/messages.json | 11+++++++++++
17 files changed, 1364 insertions(+), 98 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -35,11 +35,12 @@ use radroots_app_state::{ use radroots_app_sync::{ AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, - AppOrderFulfillmentPublishPayload, AppOrderReceiptPublishPayload, AppOrderRequestItemPayload, - AppOrderRequestPublishPayload, AppOrderRevisionDecisionPublishPayload, - AppOrderRevisionProposalPublishPayload, AppPublishContext, AppPublishPayload, - AppPublishedOperationReceipt, AppRelayIngestScopeFreshness, AppSyncProjection, AppSyncRequest, - AppSyncResult, AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, + AppOrderFulfillmentPublishPayload, AppOrderReceiptOutcome, AppOrderReceiptPublishPayload, + AppOrderRequestItemPayload, AppOrderRequestPublishPayload, + AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, + AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, + AppRelayIngestScopeFreshness, AppSyncProjection, AppSyncRequest, AppSyncResult, + AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, SyncAggregateRef, SyncCheckpointStatus, SyncConflictSeverity, SyncOperationKind, SyncTrigger, }; use radroots_app_view::{ @@ -846,8 +847,13 @@ impl DesktopAppRuntime { .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) + pub fn publish_buyer_order_receipt( + &self, + order_id: OrderId, + outcome: AppOrderReceiptOutcome, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .publish_buyer_order_receipt(order_id, outcome) } pub fn start_order_recovery( @@ -3077,6 +3083,7 @@ impl DesktopAppRuntimeState { fn prepare_buyer_order_receipt( &mut self, order_id: OrderId, + outcome: AppOrderReceiptOutcome, ) -> Result<AppOrderReceiptPublishPayload, AppSqliteError> { let _ = self.import_shared_local_events()?; let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { @@ -3119,11 +3126,6 @@ impl DesktopAppRuntimeState { reason: "buyer order receipt requires a visible buyer order", }); }; - if detail.status != BuyerOrderStatus::Ready { - return Err(AppSqliteError::InvalidProjection { - reason: "buyer order receipt requires a ready order", - }); - } let request = self.resolve_seller_order_request_evidence(order_id)?; if request.payload.buyer_pubkey.trim() != buyer_pubkey.as_str() { return Err(AppSqliteError::InvalidProjection { @@ -3157,6 +3159,7 @@ impl DesktopAppRuntimeState { reason: "buyer order receipt timestamp must be non-negative", } })?; + let received = outcome.received(); let payload = AppOrderReceiptPublishPayload { context: AppPublishContext::new(account_id.clone(), "buyer_order_receipt"), app_order_id: order_id, @@ -3167,8 +3170,8 @@ impl DesktopAppRuntimeState { listing_addr: request.payload.listing_addr, buyer_pubkey: request.payload.buyer_pubkey, seller_pubkey: request.payload.seller_pubkey, - received: true, - issue: None, + received, + issue: outcome.issue_text(), received_at, }; AppPublishPayload::OrderReceipt(payload.clone()) @@ -3179,8 +3182,12 @@ impl DesktopAppRuntimeState { Ok(payload) } - fn publish_buyer_order_receipt(&mut self, order_id: OrderId) -> Result<bool, AppSqliteError> { - let payload = self.prepare_buyer_order_receipt(order_id)?; + fn publish_buyer_order_receipt( + &mut self, + order_id: OrderId, + outcome: AppOrderReceiptOutcome, + ) -> Result<bool, AppSqliteError> { + let payload = self.prepare_buyer_order_receipt(order_id, outcome)?; let operation = PendingSyncOperation::from_publish_payload( AppPublishPayload::OrderReceipt(payload), current_utc_timestamp(), @@ -9721,7 +9728,7 @@ mod tests { use radroots_app_sync::{ AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, - AppOrderDecisionPublishPayload, AppOrderFulfillmentPublishPayload, + AppOrderDecisionPublishPayload, AppOrderFulfillmentPublishPayload, AppOrderReceiptOutcome, AppOrderReceiptPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, @@ -16732,7 +16739,7 @@ mod tests { assert!( fixture .runtime - .publish_buyer_order_receipt(fixture.order_id) + .publish_buyer_order_receipt(fixture.order_id, AppOrderReceiptOutcome::Received) .expect("linked buyer receipt should publish") ); @@ -16766,6 +16773,126 @@ mod tests { } #[test] + fn runtime_publishes_linked_buyer_receipt_after_direct_delivered_fulfillment() { + let relay = ThreadedAckRelay::spawn(); + let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_receipt_delivered", false); + let fulfillment_event_id = append_signed_order_fulfillment_record_with_status( + &fixture.paths, + fixture.trade_order_id.as_str(), + fixture.request_event_id.as_str(), + fixture.decision_event_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsActiveTradeFulfillmentState::Delivered, + ); + install_direct_relay_sync_transport(&fixture.runtime, &relay); + fixture + .runtime + .refresh_shared_local_events() + .expect("linked buyer delivered local events should import"); + assert!( + fixture + .runtime + .open_personal_order_detail(fixture.order_id) + .expect("linked delivered buyer order detail should open") + ); + + assert!( + fixture + .runtime + .publish_buyer_order_receipt(fixture.order_id, AppOrderReceiptOutcome::Received) + .expect("linked delivered buyer receipt should publish") + ); + + assert_eq!( + persisted_order_status(&fixture.runtime, fixture.order_id), + "completed" + ); + assert_eq!(relay.event_count(), 1); + let receipt_events = + shared_order_events_by_kind(&fixture.paths, 3434, fixture.buyer_pubkey.as_str()); + assert_eq!(receipt_events.len(), 1); + let receipt_event = receipt_events.first().expect("linked buyer receipt event"); + let receipt = radroots_sdk::trade::parse_buyer_receipt(receipt_event) + .expect("linked buyer delivered receipt should parse"); + assert!(receipt.payload.received); + assert!(event_has_tag( + receipt_event, + "e_prev", + fulfillment_event_id.as_str() + )); + + cleanup_bootstrapped_runtime_paths(&fixture.paths); + } + + #[test] + fn runtime_publishes_linked_buyer_issue_receipt_from_selected_account_nostr_scope() { + let relay = ThreadedAckRelay::spawn(); + let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_issue_receipt", true); + install_direct_relay_sync_transport(&fixture.runtime, &relay); + fixture + .runtime + .refresh_shared_local_events() + .expect("linked buyer local events should import"); + assert!( + fixture + .runtime + .open_personal_order_detail(fixture.order_id) + .expect("linked ready buyer order detail should open") + ); + + assert!( + fixture + .runtime + .publish_buyer_order_receipt( + fixture.order_id, + AppOrderReceiptOutcome::issue("items need review") + .expect("issue receipt text should be accepted"), + ) + .expect("linked buyer issue receipt should publish") + ); + + assert_eq!( + persisted_order_status(&fixture.runtime, fixture.order_id), + "needs_review" + ); + assert_eq!(relay.event_count(), 1); + let receipt_events = + shared_order_events_by_kind(&fixture.paths, 3434, fixture.buyer_pubkey.as_str()); + assert_eq!(receipt_events.len(), 1); + let receipt_event = receipt_events + .first() + .expect("linked buyer issue receipt event"); + let receipt = radroots_sdk::trade::parse_buyer_receipt(receipt_event) + .expect("linked buyer issue receipt should parse"); + assert!(!receipt.payload.received); + assert_eq!(receipt.payload.issue.as_deref(), Some("items need review")); + let buyer_detail = fixture + .runtime + .summary() + .personal_projection + .orders + .detail + .as_ref() + .expect("linked buyer issue receipt detail") + .clone(); + assert_eq!(buyer_detail.status, BuyerOrderStatus::NeedsReview); + let projected_receipt = buyer_detail + .workflow + .receipt + .as_ref() + .expect("receipt projection should be present"); + assert!(!projected_receipt.received); + assert_eq!( + projected_receipt.issue.as_deref(), + Some("items need review") + ); + + cleanup_bootstrapped_runtime_paths(&fixture.paths); + } + + #[test] fn runtime_rejects_linked_buyer_cancellation_with_reducer_invalid_evidence() { let relay = ThreadedAckRelay::spawn(); let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_cancel_invalid", false); @@ -16850,7 +16977,7 @@ mod tests { let error = fixture .runtime - .publish_buyer_order_receipt(fixture.order_id) + .publish_buyer_order_receipt(fixture.order_id, AppOrderReceiptOutcome::Received) .expect_err("linked buyer receipt should reject reducer-invalid fulfillment evidence"); assert!( @@ -21126,7 +21253,7 @@ mod tests { buyer_pubkey: buyer_pubkey.to_owned(), seller_pubkey: seller_pubkey.to_owned(), received, - issue: None, + issue: (!received).then(|| "items need review".to_owned()), received_at: 1_774_000_030, }; let parts = radroots_sdk::trade::build_buyer_receipt_draft( diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -55,8 +55,11 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "buyer-order-cancel", "buyer-order-keep-current", "buyer-order-keep-order", + "buyer-order-close-issue", "buyer-order-mark-received", + "buyer-order-report-issue", "buyer-order-repeat-demand", + "buyer-order-send-issue", "buyer-orders-retry-coordination", "personal_orders", "buyer.add_to_cart_failed", @@ -67,6 +70,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "buyer.order_open_failed", "buyer.order_cancel_failed", "buyer.order_coordination_retry_failed", + "buyer.order_issue_receipt_failed", "buyer.order_receipt_failed", "buyer.order_revision_accept_failed", "buyer.order_revision_decline_failed", @@ -99,6 +103,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "failed to cancel buyer order", "failed to keep buyer order", "failed to mark buyer order received", + "failed to report buyer order issue", "failed to publish order fulfillment update", "failed to open existing product editor", "failed to open new product editor", @@ -169,7 +174,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "today-reminder-chip", "https://auth.example/challenge", "identity", + "items need review", "npub1", + "receipt-clean", + "receipt-issue", "guest", "finder unavailable", "orders", @@ -409,11 +417,17 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalOrdersDetailFulfillmentLabel", "AppTextKey::PersonalOrdersDetailTotalLabel", "AppTextKey::PersonalOrdersDetailNoteLabel", + "AppTextKey::PersonalOrdersDetailReceiptLabel", "AppTextKey::PersonalOrdersDetailItemsTitle", "AppTextKey::PersonalOrdersActionCancel", "AppTextKey::PersonalOrdersActionAcceptChange", "AppTextKey::PersonalOrdersActionKeepOrder", "AppTextKey::PersonalOrdersActionMarkReceived", + "AppTextKey::PersonalOrdersActionReportIssue", + "AppTextKey::PersonalOrdersActionSendReceiptIssue", + "AppTextKey::PersonalOrdersActionCloseReceiptIssue", + "AppTextKey::PersonalOrdersReceiptIssueLabel", + "AppTextKey::PersonalOrdersReceiptIssuePlaceholder", "AppTextKey::PersonalOrdersRepeatDemandTitle", "AppTextKey::PersonalOrdersRepeatDemandActionEligible", "AppTextKey::PersonalOrdersRepeatDemandActionPartial", @@ -487,6 +501,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::TradeWorkflowAxisFulfillment", "AppTextKey::TradeWorkflowAxisInventory", "AppTextKey::TradeWorkflowAxisPayment", + "AppTextKey::TradeWorkflowAxisReceipt", "AppTextKey::TradeWorkflowAxisSource", "AppTextKey::TradeWorkflowAgreementOrdered", "AppTextKey::TradeWorkflowAgreementConfirmed", @@ -513,6 +528,8 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::TradeWorkflowPaymentRecorded", "AppTextKey::TradeWorkflowPaymentSettled", "AppTextKey::TradeWorkflowPaymentNeedsReview", + "AppTextKey::TradeWorkflowReceiptReceived", + "AppTextKey::TradeWorkflowReceiptNeedsReview", "AppTextKey::TradeWorkflowProvenanceApp", "AppTextKey::TradeWorkflowProvenanceCli", "AppTextKey::TradeWorkflowProvenanceRelay", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -23,8 +23,8 @@ use radroots_app_state::{ PackDayPrintRequest, derive_product_publish_blockers, }; use radroots_app_sync::{ - AppSyncRunStatus, SyncAggregateRef, SyncCheckpointState, SyncConflict, SyncConflictKind, - SyncConflictResolutionStatus, SyncConflictSeverity, + AppOrderReceiptOutcome, AppSyncRunStatus, SyncAggregateRef, SyncCheckpointState, SyncConflict, + SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, }; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, AppFormFieldSpec, AppIconButtonSpec, @@ -66,8 +66,8 @@ use radroots_app_view::{ ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, TodayAgendaProjection, TodaySetupTaskKind, TradeAgreementStatus, TradeEconomicsProjection, TradeFulfillmentStatus, - TradeInventoryStatus, TradePaymentDisplayStatus, TradeRevisionStatus, TradeWorkflowProjection, - TradeWorkflowSource, + TradeInventoryStatus, TradePaymentDisplayStatus, TradeReceiptProjection, TradeRevisionStatus, + TradeWorkflowProjection, TradeWorkflowSource, }; use radroots_nostr::prelude::RadrootsNostrClient; use std::{ @@ -221,6 +221,7 @@ pub struct HomeView { farm_setup_form: Option<FarmSetupFormState>, personal_search: Option<PersonalSearchState>, buyer_order_review_form: Option<BuyerOrderReviewFormState>, + buyer_receipt_issue_form: Option<BuyerReceiptIssueFormState>, products_search: Option<ProductsSearchState>, products_stock_editor: Option<ProductsStockEditorState>, product_editor_form: Option<ProductEditorFormState>, @@ -264,6 +265,7 @@ struct HomeAutoFocusState { has_farm_setup_form: bool, has_personal_search_input: bool, has_buyer_order_review_form: bool, + has_buyer_receipt_issue_form: bool, has_products_search_input: bool, has_products_stock_editor: bool, has_product_editor_form: bool, @@ -280,6 +282,7 @@ enum HomeAutoFocusTarget { BuyerDetailBack, BuyerCartOpenOrderReview, BuyerOrderReviewNameInput, + BuyerReceiptIssueInput, BuyerOrderOpenFirst, BuyerOrderConfirmReplace, BuyerOrderRepeatDemand, @@ -336,6 +339,7 @@ impl HomeView { farm_setup_form: None, personal_search: None, buyer_order_review_form: None, + buyer_receipt_issue_form: None, products_search: None, products_stock_editor: None, product_editor_form: None, @@ -353,6 +357,7 @@ impl HomeView { has_farm_setup_form: self.farm_setup_form.is_some(), has_personal_search_input: self.personal_search.is_some(), has_buyer_order_review_form: self.buyer_order_review_form.is_some(), + has_buyer_receipt_issue_form: self.buyer_receipt_issue_form.is_some(), has_products_search_input: self.products_search.is_some(), has_products_stock_editor: self.products_stock_editor.is_some(), has_product_editor_form: self.product_editor_form.is_some(), @@ -412,6 +417,12 @@ impl HomeView { .update(cx, |input, cx| input.focus(window, cx)); } } + HomeAutoFocusTarget::BuyerReceiptIssueInput => { + if let Some(form) = self.buyer_receipt_issue_form.as_ref() { + form.issue_input + .update(cx, |input, cx| input.focus(window, cx)); + } + } HomeAutoFocusTarget::BuyerOrderOpenFirst => { focus_button(window, ("buyer-order-open", 0_usize), cx); } @@ -1067,6 +1078,28 @@ impl HomeView { } } + fn sync_buyer_receipt_issue_form(&mut self, runtime_summary: &DesktopAppRuntimeSummary) { + let Some(form) = self.buyer_receipt_issue_form.as_ref() else { + return; + }; + + if home_stage(runtime_summary) != HomeStage::BuyerWorkspace + || selected_personal_section(runtime_summary) != PersonalSection::Orders + { + self.buyer_receipt_issue_form = None; + return; + } + + let Some(detail) = runtime_summary.personal_projection.orders.detail.as_ref() else { + self.buyer_receipt_issue_form = None; + return; + }; + + if detail.order_id != form.order_id || !buyer_receipt_actions_available(detail) { + self.buyer_receipt_issue_form = None; + } + } + fn sync_products_stock_editor(&mut self, runtime_summary: &DesktopAppRuntimeSummary) { let Some(editor) = self.products_stock_editor.as_ref() else { return; @@ -1362,6 +1395,25 @@ impl HomeView { } } + fn handle_buyer_receipt_issue_input_event( + &mut self, + state: &Entity<InputState>, + event: &InputEvent, + _: &mut Window, + cx: &mut Context<Self>, + ) { + if !matches!(event, InputEvent::Change) { + return; + } + + let Some(form) = self.buyer_receipt_issue_form.as_ref() else { + return; + }; + if form.issue_input == *state { + cx.notify(); + } + } + fn toggle_personal_search_fulfillment_method( &mut self, method: FarmOrderMethod, @@ -1608,7 +1660,16 @@ impl HomeView { fn open_personal_order_detail(&mut self, order_id: OrderId, cx: &mut Context<Self>) { match self.runtime.open_personal_order_detail(order_id) { - Ok(true) => cx.notify(), + Ok(true) => { + if self + .buyer_receipt_issue_form + .as_ref() + .is_some_and(|form| form.order_id != order_id) + { + self.buyer_receipt_issue_form = None; + } + cx.notify(); + } Ok(false) => {} Err(runtime_error) => { error!( @@ -2168,9 +2229,37 @@ impl HomeView { } } + fn open_buyer_receipt_issue_form( + &mut self, + order_id: OrderId, + window: &mut Window, + cx: &mut Context<Self>, + ) { + self.buyer_receipt_issue_form = Some(BuyerReceiptIssueFormState::new(order_id, window, cx)); + cx.notify(); + } + + fn close_buyer_receipt_issue_form(&mut self, cx: &mut Context<Self>) { + if self.buyer_receipt_issue_form.take().is_some() { + cx.notify(); + } + } + 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(), + match self + .runtime + .publish_buyer_order_receipt(order_id, AppOrderReceiptOutcome::Received) + { + Ok(true) => { + if self + .buyer_receipt_issue_form + .as_ref() + .is_some_and(|form| form.order_id == order_id) + { + self.buyer_receipt_issue_form = None; + } + cx.notify(); + } Ok(false) => {} Err(runtime_error) => { error!( @@ -2184,6 +2273,34 @@ impl HomeView { } } + fn submit_buyer_order_issue_receipt(&mut self, order_id: OrderId, cx: &mut Context<Self>) { + let Some(issue) = self + .buyer_receipt_issue_form + .as_ref() + .filter(|form| form.order_id == order_id) + .and_then(|form| AppOrderReceiptOutcome::issue(form.issue_text(cx))) + else { + return; + }; + + match self.runtime.publish_buyer_order_receipt(order_id, issue) { + Ok(true) => { + self.buyer_receipt_issue_form = None; + cx.notify(); + } + Ok(false) => {} + Err(runtime_error) => { + error!( + target: "personal_orders", + event = "buyer.order_issue_receipt_failed", + error = %runtime_error, + order_id = %order_id, + "failed to report buyer order issue" + ); + } + } + } + fn start_order_recovery( &mut self, order_id: OrderId, @@ -3259,8 +3376,14 @@ impl HomeView { .detail .as_ref() .map(|detail| { + let issue_form = + self.buyer_receipt_issue_form.as_ref().filter(|form| { + form.order_id == detail.order_id + && buyer_receipt_actions_available(detail) + }); buyer_order_detail_card( detail, + issue_form, runtime .personal_projection .cart @@ -4569,6 +4692,7 @@ impl Render for HomeView { self.sync_farm_setup_form(&runtime_summary, window, cx); self.sync_personal_search(&runtime_summary, window, cx); self.sync_buyer_order_review_form(&runtime_summary, window, cx); + self.sync_buyer_receipt_issue_form(&runtime_summary); self.sync_products_search(&runtime_summary, window, cx); self.sync_products_stock_editor(&runtime_summary); self.sync_product_editor_form(&runtime_summary, window, cx); @@ -4795,6 +4919,43 @@ fn sync_order_review_input( }); } +struct BuyerReceiptIssueFormState { + order_id: OrderId, + issue_input: Entity<InputState>, + _issue_subscription: Subscription, +} + +impl BuyerReceiptIssueFormState { + fn new(order_id: OrderId, window: &mut Window, cx: &mut Context<HomeView>) -> Self { + let issue_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(app_shared_text( + AppTextKey::PersonalOrdersReceiptIssuePlaceholder, + )) + .default_value(String::new()) + }); + let issue_subscription = cx.subscribe_in( + &issue_input, + window, + HomeView::handle_buyer_receipt_issue_input_event, + ); + + Self { + order_id, + issue_input, + _issue_subscription: issue_subscription, + } + } + + fn issue_text(&self, cx: &App) -> String { + self.issue_input.read(cx).value().trim().to_owned() + } + + fn can_submit(&self, cx: &App) -> bool { + !self.issue_text(cx).is_empty() + } +} + struct ProductsSearchState { account_id: String, input: Entity<InputState>, @@ -7621,7 +7782,9 @@ fn buyer_auto_focus_target( .is_some_and(|confirmation| { confirmation.incoming_farm_display_name == detail.farm_display_name }); - if replace_confirmation { + if state.has_buyer_receipt_issue_form { + Some(HomeAutoFocusTarget::BuyerReceiptIssueInput) + } else if replace_confirmation { Some(HomeAutoFocusTarget::BuyerOrderConfirmReplace) } else if detail.repeat_demand.as_ref().is_some_and(|repeat_demand| { repeat_demand.eligibility != RepeatDemandEligibility::Unavailable @@ -8662,6 +8825,12 @@ fn trade_workflow_detail_badge_strip(workflow: &TradeWorkflowProjection) -> AnyE trade_fulfillment_status_key(fulfillment), )); } + if let Some(receipt) = workflow.receipt.as_ref() { + badges.push(trade_workflow_labeled_key_badge( + AppTextKey::TradeWorkflowAxisReceipt, + buyer_receipt_status_key(receipt), + )); + } badges.push(trade_workflow_labeled_key_badge( AppTextKey::TradeWorkflowAxisInventory, @@ -8700,6 +8869,11 @@ fn trade_workflow_list_badge_strip(workflow: &TradeWorkflowProjection) -> AnyEle fulfillment, ))); } + if let Some(receipt) = workflow.receipt.as_ref() { + badges.push(trade_workflow_value_badge(buyer_receipt_status_key( + receipt, + ))); + } badges.push(trade_workflow_labeled_key_badge( AppTextKey::TradeWorkflowAxisPayment, @@ -8723,6 +8897,11 @@ fn trade_workflow_status_stack(workflow: &TradeWorkflowProjection) -> AnyElement fulfillment, ))) }) + .when_some(workflow.receipt.as_ref(), |this, receipt| { + this.child(trade_workflow_value_badge(buyer_receipt_status_key( + receipt, + ))) + }) .into_any_element() } @@ -8914,6 +9093,7 @@ fn buyer_orders_list_entry( fn buyer_order_detail_card( detail: &BuyerOrderDetailProjection, + issue_form: Option<&BuyerReceiptIssueFormState>, replace_confirmation: Option<&BuyerCartReplaceConfirmationProjection>, cx: &mut Context<HomeView>, ) -> AnyElement { @@ -8945,6 +9125,9 @@ fn buyer_order_detail_card( order_optional_text(detail.order_note.as_deref()), ), ])) + .when_some(detail.workflow.receipt.as_ref(), |this, receipt| { + this.child(buyer_receipt_summary_section(receipt)) + }) .child(app_form_section( app_shared_text(AppTextKey::PersonalOrdersDetailItemsTitle), div() @@ -9012,16 +9195,40 @@ fn buyer_order_detail_card( )) }, ) - .when(detail.status == BuyerOrderStatus::Ready, |this| { - this.child(action_button_primary( - "buyer-order-mark-received", - app_shared_text(AppTextKey::PersonalOrdersActionMarkReceived), - cx.listener({ - let order_id = detail.order_id; - move |this, _, _, cx| this.mark_buyer_order_received(order_id, cx) - }), - cx, - )) + .when(buyer_receipt_actions_available(detail), |this| { + this.child( + app_stack_v(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child( + app_cluster(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child(action_button_primary( + "buyer-order-mark-received", + app_shared_text(AppTextKey::PersonalOrdersActionMarkReceived), + cx.listener({ + let order_id = detail.order_id; + move |this, _, _, cx| { + this.mark_buyer_order_received(order_id, cx) + } + }), + cx, + )) + .child(action_button_compact( + "buyer-order-report-issue", + app_shared_text(AppTextKey::PersonalOrdersActionReportIssue), + cx.listener({ + let order_id = detail.order_id; + move |this, _, window, cx| { + this.open_buyer_receipt_issue_form(order_id, window, cx) + } + }), + cx, + )), + ) + .when_some(issue_form, |this, form| { + this.child(buyer_receipt_issue_form_section(form, cx)) + }), + ) }) .when_some(detail.repeat_demand.as_ref(), |this, repeat_demand| { this.child(app_form_section( @@ -9102,6 +9309,87 @@ fn buyer_order_detail_card( .into_any_element() } +fn buyer_receipt_summary_section(receipt: &TradeReceiptProjection) -> AnyElement { + app_form_section( + app_shared_text(AppTextKey::PersonalOrdersDetailReceiptLabel), + app_stack_v(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child(trade_workflow_value_badge(buyer_receipt_status_key( + receipt, + ))) + .when_some(receipt.issue.as_ref(), |this, issue| { + this.child(home_body_text(issue.clone())) + }), + ) + .into_any_element() +} + +fn buyer_receipt_issue_form_section( + form: &BuyerReceiptIssueFormState, + cx: &mut Context<HomeView>, +) -> AnyElement { + let order_id = form.order_id; + let submit_action = if form.can_submit(cx) { + action_button_primary( + "buyer-order-send-issue", + app_shared_text(AppTextKey::PersonalOrdersActionSendReceiptIssue), + cx.listener(move |this, _, _, cx| this.submit_buyer_order_issue_receipt(order_id, cx)), + cx, + ) + .into_any_element() + } else { + action_button_primary_disabled( + "buyer-order-send-issue", + app_shared_text(AppTextKey::PersonalOrdersActionSendReceiptIssue), + cx, + ) + .into_any_element() + }; + + app_form_section( + app_shared_text(AppTextKey::PersonalOrdersDetailReceiptLabel), + app_stack_v(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child(app_form_input_text( + AppFormFieldSpec::new( + app_shared_text(AppTextKey::PersonalOrdersReceiptIssueLabel), + Option::<SharedString>::None, + ), + &form.issue_input, + false, + )) + .child( + app_cluster(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .child(submit_action) + .child(action_button_compact( + "buyer-order-close-issue", + app_shared_text(AppTextKey::PersonalOrdersActionCloseReceiptIssue), + cx.listener(|this, _, _, cx| this.close_buyer_receipt_issue_form(cx)), + cx, + )), + ), + ) + .into_any_element() +} + +fn buyer_receipt_actions_available(detail: &BuyerOrderDetailProjection) -> bool { + detail.workflow.receipt.is_none() + && detail.workflow.agreement == TradeAgreementStatus::Confirmed + && matches!( + detail.workflow.fulfillment, + Some(TradeFulfillmentStatus::ReadyForPickup | TradeFulfillmentStatus::Delivered) + ) +} + +fn buyer_receipt_status_key(receipt: &TradeReceiptProjection) -> AppTextKey { + if receipt.received { + AppTextKey::TradeWorkflowReceiptReceived + } else { + AppTextKey::TradeWorkflowReceiptNeedsReview + } +} + fn buyer_repeat_demand_action_label(repeat_demand: &RepeatDemandHandoffProjection) -> SharedString { match repeat_demand.eligibility { RepeatDemandEligibility::Eligible => { @@ -9144,9 +9432,10 @@ fn buyer_orders_status_color(status: BuyerOrderStatus) -> u32 { BuyerOrderStatus::Scheduled | BuyerOrderStatus::Ready => { APP_UI_THEME.components.app_status_indicator.online } - BuyerOrderStatus::Completed | BuyerOrderStatus::Declined | BuyerOrderStatus::Refunded => { - APP_UI_THEME.components.app_status_indicator.offline - } + BuyerOrderStatus::Completed + | BuyerOrderStatus::Declined + | BuyerOrderStatus::Refunded + | BuyerOrderStatus::NeedsReview => APP_UI_THEME.components.app_status_indicator.offline, } } @@ -10452,9 +10741,10 @@ fn orders_status_color(status: OrderStatus) -> u32 { OrderStatus::Scheduled | OrderStatus::Packed => { APP_UI_THEME.components.app_status_indicator.online } - OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => { - APP_UI_THEME.components.app_status_indicator.offline - } + OrderStatus::Completed + | OrderStatus::Declined + | OrderStatus::Refunded + | OrderStatus::NeedsReview => APP_UI_THEME.components.app_status_indicator.offline, } } @@ -13356,8 +13646,8 @@ mod tests { about_conflict_detail_rows, about_conflict_review_body_key, about_manual_refresh_enabled, about_runtime_rows, about_status_rows, app_text, buyer_order_coordination_notice_forces_redraw, buyer_orders_retry_action_visible, - farm_setup_onboarding_card_spec, farmer_home_farm_state, farmer_pack_day_available, - home_auto_focus_target, home_content_scroll_id, home_saved_farm, + buyer_receipt_status_key, farm_setup_onboarding_card_spec, farmer_home_farm_state, + farmer_pack_day_available, home_auto_focus_target, home_content_scroll_id, home_saved_farm, home_sidebar_navigation_sections, home_stage, home_window_launch_size_px, home_window_minimum_size_px, pack_day_batch_print_action_presentation, pack_day_batch_print_status_presentation, pack_day_export_action_enabled, @@ -13411,8 +13701,8 @@ mod tests { ReminderKind, ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TradeAgreementStatus, TradeEconomicsProjection, TradeFulfillmentStatus, - TradeInventoryStatus, TradePaymentDisplayStatus, TradeRevisionStatus, - TradeWorkflowProjection, TradeWorkflowSource, + TradeInventoryStatus, TradePaymentDisplayStatus, TradeReceiptProjection, + TradeRevisionStatus, TradeWorkflowProjection, TradeWorkflowSource, }; use radroots_identity::RadrootsIdentity; use std::{ @@ -13842,6 +14132,30 @@ mod tests { assert!(!app_text(key).is_empty()); } + for (receipt, key) in [ + ( + TradeReceiptProjection { + event_id: "receipt-clean".to_owned(), + received: true, + issue: None, + received_at: 1_774_000_030, + }, + AppTextKey::TradeWorkflowReceiptReceived, + ), + ( + TradeReceiptProjection { + event_id: "receipt-issue".to_owned(), + received: false, + issue: Some("items need review".to_owned()), + received_at: 1_774_000_031, + }, + AppTextKey::TradeWorkflowReceiptNeedsReview, + ), + ] { + assert_eq!(buyer_receipt_status_key(&receipt), key); + assert!(!app_text(key).is_empty()); + } + for (source, key) in [ ( TradeWorkflowSource::App, @@ -14099,6 +14413,16 @@ mod tests { home_auto_focus_target(&buyer_orders, HomeAutoFocusState::default()), Some(HomeAutoFocusTarget::BuyerOrderRepeatDemand) ); + assert_eq!( + home_auto_focus_target( + &buyer_orders, + HomeAutoFocusState { + has_buyer_receipt_issue_form: true, + ..HomeAutoFocusState::default() + }, + ), + Some(HomeAutoFocusTarget::BuyerReceiptIssueInput) + ); } #[test] diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -139,11 +139,17 @@ define_app_text_keys! { PersonalOrdersDetailTotalLabel => "personal.orders.detail.total.label", PersonalOrdersDetailPaymentLabel => "personal.orders.detail.payment.label", PersonalOrdersDetailNoteLabel => "personal.orders.detail.note.label", + PersonalOrdersDetailReceiptLabel => "personal.orders.detail.receipt.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", + PersonalOrdersActionReportIssue => "personal.orders.action.report_issue", + PersonalOrdersActionSendReceiptIssue => "personal.orders.action.send_receipt_issue", + PersonalOrdersActionCloseReceiptIssue => "personal.orders.action.close_receipt_issue", + PersonalOrdersReceiptIssueLabel => "personal.orders.receipt.issue.label", + PersonalOrdersReceiptIssuePlaceholder => "personal.orders.receipt.issue.placeholder", PersonalOrdersRepeatDemandTitle => "personal.orders.repeat_demand.title", PersonalOrdersRepeatDemandActionEligible => "personal.orders.repeat_demand.action.eligible", PersonalOrdersRepeatDemandActionPartial => "personal.orders.repeat_demand.action.partial", @@ -159,6 +165,7 @@ define_app_text_keys! { PersonalOrdersStatusCompleted => "personal.orders.status.completed", PersonalOrdersStatusDeclined => "personal.orders.status.declined", PersonalOrdersStatusRefunded => "personal.orders.status.refunded", + PersonalOrdersStatusNeedsReview => "personal.orders.status.needs_review", PersonalCartSurfaceBody => "personal.cart.surface.body", PersonalOrderSummaryTitle => "personal.order_summary.title", PersonalFulfillmentTitle => "personal.fulfillment.title", @@ -196,6 +203,7 @@ define_app_text_keys! { OrdersStatusCompleted => "orders.status.completed", OrdersStatusDeclined => "orders.status.declined", OrdersStatusRefunded => "orders.status.refunded", + OrdersStatusNeedsReview => "orders.status.needs_review", OrdersTableTitle => "orders.table.title", OrdersColumnOrder => "orders.column.order", OrdersColumnStatus => "orders.column.status", @@ -240,6 +248,7 @@ define_app_text_keys! { TradeWorkflowAxisFulfillment => "trade.workflow.axis.fulfillment", TradeWorkflowAxisInventory => "trade.workflow.axis.inventory", TradeWorkflowAxisPayment => "trade.workflow.axis.payment", + TradeWorkflowAxisReceipt => "trade.workflow.axis.receipt", TradeWorkflowAxisSource => "trade.workflow.axis.source", TradeWorkflowAgreementOrdered => "trade.workflow.agreement.ordered", TradeWorkflowAgreementConfirmed => "trade.workflow.agreement.confirmed", @@ -266,6 +275,8 @@ define_app_text_keys! { TradeWorkflowPaymentRecorded => "trade.workflow.payment.recorded", TradeWorkflowPaymentSettled => "trade.workflow.payment.settled", TradeWorkflowPaymentNeedsReview => "trade.workflow.payment.needs_review", + TradeWorkflowReceiptReceived => "trade.workflow.receipt.received", + TradeWorkflowReceiptNeedsReview => "trade.workflow.receipt.needs_review", TradeWorkflowProvenanceApp => "trade.workflow.provenance.app", TradeWorkflowProvenanceCli => "trade.workflow.provenance.cli", TradeWorkflowProvenanceRelay => "trade.workflow.provenance.relay", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -341,6 +341,10 @@ mod tests { assert_eq!(app_text(AppTextKey::OrdersStatusDeclined), "Declined"); assert_eq!(app_text(AppTextKey::OrdersStatusInHandoff), "In handoff"); assert_eq!( + app_text(AppTextKey::OrdersStatusNeedsReview), + "Needs review" + ); + assert_eq!( app_text(AppTextKey::OrdersActionReadyForPickup), "Ready for pickup" ); @@ -527,6 +531,7 @@ mod tests { "Fulfillment" ); assert_eq!(app_text(AppTextKey::TradeWorkflowAxisPayment), "Payment"); + assert_eq!(app_text(AppTextKey::TradeWorkflowAxisReceipt), "Receipt"); assert_eq!(app_text(AppTextKey::TradeWorkflowAxisSource), "Source"); assert_eq!( app_text(AppTextKey::TradeWorkflowAgreementOrdered), @@ -566,6 +571,14 @@ mod tests { "Recorded" ); assert_eq!(app_text(AppTextKey::TradeWorkflowPaymentSettled), "Settled"); + assert_eq!( + app_text(AppTextKey::TradeWorkflowReceiptReceived), + "Received" + ); + assert_eq!( + app_text(AppTextKey::TradeWorkflowReceiptNeedsReview), + "Needs review" + ); assert_eq!(app_text(AppTextKey::TradeWorkflowProvenanceCli), "CLI"); assert_eq!( app_text(AppTextKey::TradeWorkflowProvenanceLocalEvents), @@ -619,6 +632,10 @@ mod tests { "Refunded" ); assert_eq!( + app_text(AppTextKey::PersonalOrdersStatusNeedsReview), + "Needs review" + ); + assert_eq!( app_text(AppTextKey::PersonalOrdersDetailTitle), "Order detail" ); @@ -635,6 +652,10 @@ mod tests { "Order note" ); assert_eq!( + app_text(AppTextKey::PersonalOrdersDetailReceiptLabel), + "Receipt" + ); + assert_eq!( app_text(AppTextKey::PersonalOrdersActionCancel), "Cancel order" ); @@ -651,6 +672,18 @@ mod tests { "Mark received" ); assert_eq!( + app_text(AppTextKey::PersonalOrdersActionReportIssue), + "Report issue" + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersActionSendReceiptIssue), + "Send update" + ); + assert_eq!( + app_text(AppTextKey::PersonalOrdersReceiptIssuePlaceholder), + "What needs review" + ); + assert_eq!( app_text(AppTextKey::PersonalOrdersRepeatDemandTitle), "Reorder" ); diff --git a/crates/store/migrations/0025_order_receipt_display_projection.sql b/crates/store/migrations/0025_order_receipt_display_projection.sql @@ -0,0 +1,204 @@ +DROP INDEX IF EXISTS idx_order_lines_order_sort; +DROP INDEX IF EXISTS idx_buyer_order_coordination_context_state_updated_at; +DROP INDEX IF EXISTS idx_buyer_order_coordination_state_updated_at; +DROP INDEX IF EXISTS idx_orders_farm_status; +DROP INDEX IF EXISTS idx_orders_farm_window_status_updated_at; +DROP INDEX IF EXISTS idx_orders_buyer_context_updated_at; + +ALTER TABLE order_lines RENAME TO order_lines_receipt_display_legacy; +ALTER TABLE buyer_order_coordination_records RENAME TO buyer_order_coordination_records_receipt_display_legacy; +ALTER TABLE orders RENAME TO orders_receipt_display_legacy; + +CREATE TABLE orders ( + id TEXT PRIMARY KEY NOT NULL, + farm_id TEXT NOT NULL REFERENCES farms(id) ON DELETE CASCADE, + fulfillment_window_id TEXT REFERENCES fulfillment_windows(id) ON DELETE SET NULL, + order_number TEXT NOT NULL, + customer_display_name TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('needs_action', 'scheduled', 'packed', 'completed', 'declined', 'refunded', 'needs_review') + ), + updated_at TEXT NOT NULL, + buyer_context_key TEXT, + buyer_email TEXT NOT NULL DEFAULT '', + buyer_phone TEXT NOT NULL DEFAULT '', + buyer_order_note TEXT NOT NULL DEFAULT '', + workflow_revision TEXT NOT NULL DEFAULT 'none' CHECK ( + workflow_revision IN ('none', 'change_proposed', 'updated', 'kept_as_placed') + ), + workflow_agreement TEXT NOT NULL DEFAULT 'ordered' CHECK ( + workflow_agreement IN ('ordered', 'confirmed', 'declined', 'cancelled', 'completed', 'needs_review') + ), + workflow_fulfillment TEXT CHECK ( + workflow_fulfillment IS NULL OR workflow_fulfillment IN ('confirmed', 'preparing', 'ready_for_pickup', 'out_for_delivery', 'delivered', 'cancelled') + ), + workflow_receipt_event_id TEXT, + workflow_receipt_received INTEGER CHECK ( + workflow_receipt_received IS NULL OR workflow_receipt_received IN (0, 1) + ), + workflow_receipt_issue TEXT, + workflow_receipt_received_at INTEGER CHECK ( + workflow_receipt_received_at IS NULL OR workflow_receipt_received_at >= 0 + ), + workflow_inventory TEXT NOT NULL DEFAULT 'needs_review' CHECK ( + workflow_inventory IN ('available', 'reserved', 'sold_out', 'needs_review') + ), + workflow_payment TEXT NOT NULL DEFAULT 'not_recorded' CHECK ( + workflow_payment IN ('not_recorded', 'pending', 'recorded', 'settled', 'needs_review') + ), + workflow_provenance_source TEXT NOT NULL DEFAULT 'unknown' CHECK ( + workflow_provenance_source IN ('app', 'cli', 'relay', 'local_events', 'unknown') + ), + workflow_provenance_last_event_id TEXT +); + +INSERT INTO orders ( + id, + farm_id, + fulfillment_window_id, + order_number, + customer_display_name, + status, + updated_at, + buyer_context_key, + buyer_email, + buyer_phone, + buyer_order_note, + workflow_revision, + workflow_agreement, + workflow_fulfillment, + workflow_inventory, + workflow_payment, + workflow_provenance_source, + workflow_provenance_last_event_id +) +SELECT + id, + farm_id, + fulfillment_window_id, + order_number, + customer_display_name, + status, + updated_at, + buyer_context_key, + buyer_email, + buyer_phone, + buyer_order_note, + workflow_revision, + workflow_agreement, + workflow_fulfillment, + workflow_inventory, + workflow_payment, + workflow_provenance_source, + workflow_provenance_last_event_id +FROM orders_receipt_display_legacy; + +CREATE TABLE order_lines ( + id TEXT PRIMARY KEY NOT NULL, + order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + title TEXT NOT NULL, + quantity_value INTEGER NOT NULL CHECK (quantity_value >= 0), + quantity_unit_label TEXT NOT NULL DEFAULT '', + quantity_display TEXT NOT NULL, + sort_index INTEGER NOT NULL DEFAULT 0, + listing_bin_id TEXT, + unit_price_minor_units INTEGER CHECK ( + unit_price_minor_units IS NULL OR unit_price_minor_units >= 0 + ), + price_currency TEXT NOT NULL DEFAULT 'USD', + farm_key TEXT, + listing_addr TEXT, + listing_event_id TEXT, + seller_pubkey TEXT, + listing_relays_json TEXT +); + +INSERT INTO order_lines ( + id, + order_id, + title, + quantity_value, + quantity_unit_label, + quantity_display, + sort_index, + listing_bin_id, + unit_price_minor_units, + price_currency, + farm_key, + listing_addr, + listing_event_id, + seller_pubkey, + listing_relays_json +) +SELECT + id, + order_id, + title, + quantity_value, + quantity_unit_label, + quantity_display, + sort_index, + listing_bin_id, + unit_price_minor_units, + price_currency, + farm_key, + listing_addr, + listing_event_id, + seller_pubkey, + listing_relays_json +FROM order_lines_receipt_display_legacy; + +CREATE TABLE buyer_order_coordination_records ( + order_id TEXT PRIMARY KEY NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + buyer_context_key TEXT NOT NULL, + record_id TEXT, + state TEXT NOT NULL CHECK (state IN ('pending', 'synced', 'failed')), + payload_json TEXT, + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + last_error_message TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + synced_at TEXT +); + +INSERT INTO buyer_order_coordination_records ( + order_id, + buyer_context_key, + record_id, + state, + payload_json, + attempt_count, + last_error_message, + created_at, + updated_at, + synced_at +) +SELECT + order_id, + buyer_context_key, + record_id, + state, + payload_json, + attempt_count, + last_error_message, + created_at, + updated_at, + synced_at +FROM buyer_order_coordination_records_receipt_display_legacy; + +CREATE INDEX idx_orders_farm_status ON orders(farm_id, status); +CREATE INDEX idx_orders_farm_window_status_updated_at + ON orders(farm_id, fulfillment_window_id, status, updated_at DESC, id DESC); +CREATE INDEX idx_orders_buyer_context_updated_at + ON orders(buyer_context_key, updated_at DESC, id DESC) + WHERE buyer_context_key IS NOT NULL AND trim(buyer_context_key) <> ''; +CREATE INDEX idx_order_lines_order_sort + ON order_lines(order_id, sort_index, id); +CREATE INDEX idx_buyer_order_coordination_context_state_updated_at + ON buyer_order_coordination_records(buyer_context_key, state, updated_at); +CREATE INDEX idx_buyer_order_coordination_state_updated_at + ON buyer_order_coordination_records(state, updated_at); + +DROP TABLE order_lines_receipt_display_legacy; +DROP TABLE buyer_order_coordination_records_receipt_display_legacy; +DROP TABLE orders_receipt_display_legacy; diff --git a/crates/store/src/interop.rs b/crates/store/src/interop.rs @@ -1206,10 +1206,14 @@ impl<'a> AppLocalInteropRepository<'a> { workflow_revision = ?3, workflow_agreement = ?4, workflow_fulfillment = ?5, - workflow_inventory = ?6, - workflow_payment = ?7, - workflow_provenance_source = ?8, - workflow_provenance_last_event_id = ?9, + workflow_receipt_event_id = ?6, + workflow_receipt_received = ?7, + workflow_receipt_issue = ?8, + workflow_receipt_received_at = ?9, + workflow_inventory = ?10, + workflow_payment = ?11, + workflow_provenance_source = ?12, + workflow_provenance_last_event_id = ?13, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?1", params![ @@ -1220,6 +1224,26 @@ impl<'a> AppLocalInteropRepository<'a> { workflow .fulfillment .map(|fulfillment| fulfillment.storage_key()), + workflow + .receipt + .as_ref() + .map(|receipt| receipt.event_id.as_str()), + workflow + .receipt + .as_ref() + .map(|receipt| if receipt.received { 1_i64 } else { 0_i64 }), + workflow + .receipt + .as_ref() + .and_then(|receipt| receipt.issue.as_deref()), + workflow + .receipt + .as_ref() + .map(|receipt| i64::try_from(receipt.received_at)) + .transpose() + .map_err(|_| AppSqliteError::InvalidProjection { + reason: "receipt timestamp must fit sqlite integer", + })?, workflow.inventory.storage_key(), workflow.payment.storage_key(), workflow.provenance.primary_source.storage_key(), @@ -3633,7 +3657,7 @@ mod tests { use std::collections::BTreeSet; use radroots_app_view::{ - BuyerContext, BuyerOrderStatus, FarmId, FarmOrderMethod, OrderFulfillmentAction, + BuyerContext, BuyerOrderStatus, FarmId, FarmOrderMethod, OrderFulfillmentAction, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersScreenQueryState, ProductAvailabilityState, ProductId, TradeFulfillmentStatus, TradeInventoryStatus, TradePaymentDisplayStatus, TradeRevisionStatus, TradeWorkflowSource, @@ -4320,6 +4344,159 @@ mod tests { } } + struct ActiveOrderReadyFixture { + app_store: AppSqliteStore, + events: LocalEventsStore<SqliteExecutor>, + buyer_context: BuyerContext, + seller_farm_id: FarmId, + order_id: OrderId, + order_id_raw: String, + listing_addr: String, + buyer_pubkey: String, + seller_pubkey: String, + request_event_id: String, + fulfillment_event_id: String, + } + + fn active_order_ready_fixture(label: &str) -> ActiveOrderReadyFixture { + let app_store = + AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); + let events = local_events_store(); + let farm_key = "DDDDDDDDDDDDDDDDDDDDDD"; + let listing_key = "AAAAAAAAAAAAAAAAAAAAAw"; + let seller_pubkey = format!("{label}-seller"); + let buyer_pubkey = format!("{label}-buyer"); + let order_id_raw = format!("{label}-order"); + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + events + .append_record(&signed_market_listing_record( + format!("{label}-listing-record").as_str(), + seller_pubkey.as_str(), + farm_key, + listing_key, + "Lifecycle Eggs", + "9", + "active", + "pickup", + "North barn pickup", + 4_102_444_800, + 4_102_531_200, + LocalRecordStatus::Published, + PublishOutboxStatus::Acknowledged, + )) + .expect("append signed listing"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import signed listing"); + + let request_payload = order_request_payload( + order_id_raw.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + ); + let request_parts = active_trade_order_request_event_build( + &listing_event_ptr(format!("{label}-listing-event").as_str()), + &request_payload, + ) + .expect("build lifecycle order request"); + let request_event = event_from_parts( + format!("{label}-request-event").as_str(), + buyer_pubkey.as_str(), + request_parts, + ); + events + .append_record(&signed_order_event_record( + format!("app:signed_event:{label}:request").as_str(), + &request_event, + listing_addr.as_str(), + SourceRuntime::App, + Some("acct_lifecycle"), + )) + .expect("append lifecycle order request"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import lifecycle order request"); + + let order_id = projected_order_id(order_id_raw.as_str(), buyer_pubkey.as_str()); + let buyer_context = BuyerContext::account("acct_lifecycle"); + let seller_farm_id = deterministic_farm_id(Some(seller_pubkey.as_str()), farm_key); + let decision_payload = accepted_order_decision_payload( + order_id_raw.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + ); + let decision_parts = active_trade_order_decision_event_build( + request_event.id.as_str(), + request_event.id.as_str(), + &decision_payload, + ) + .expect("build lifecycle order decision"); + let decision_event = event_from_parts( + format!("{label}-decision-event").as_str(), + seller_pubkey.as_str(), + decision_parts, + ); + events + .append_record(&signed_order_event_record( + format!("cli:signed_event:{label}:decision").as_str(), + &decision_event, + listing_addr.as_str(), + SourceRuntime::Cli, + None, + )) + .expect("append lifecycle order decision"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import lifecycle order decision"); + + let fulfillment_payload = fulfillment_update_payload( + order_id_raw.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + RadrootsActiveTradeFulfillmentState::ReadyForPickup, + ); + let fulfillment_parts = active_trade_fulfillment_update_event_build( + request_event.id.as_str(), + decision_event.id.as_str(), + &fulfillment_payload, + ) + .expect("build lifecycle fulfillment update"); + let fulfillment_event = event_from_parts( + format!("{label}-fulfillment-event").as_str(), + seller_pubkey.as_str(), + fulfillment_parts, + ); + events + .append_record(&signed_order_event_record( + format!("cli:signed_event:{label}:fulfillment").as_str(), + &fulfillment_event, + listing_addr.as_str(), + SourceRuntime::Cli, + None, + )) + .expect("append lifecycle fulfillment"); + app_store + .import_shared_local_events_from_store(&events) + .expect("import lifecycle fulfillment"); + + ActiveOrderReadyFixture { + app_store, + events, + buyer_context, + seller_farm_id, + order_id, + order_id_raw, + listing_addr, + buyer_pubkey, + seller_pubkey, + request_event_id: request_event.id, + fulfillment_event_id: fulfillment_event.id, + } + } + fn payment_recorded_payload( request: &RadrootsTradeOrderRequested, root_event_id: &str, @@ -5299,7 +5476,101 @@ mod tests { ) .expect("load lifecycle seller orders after receipt"); assert_eq!(buyer_detail.status, BuyerOrderStatus::Completed); + let buyer_receipt = buyer_detail + .workflow + .receipt + .as_ref() + .expect("buyer receipt projection"); + assert_eq!(buyer_receipt.event_id, receipt_event.id); + assert!(buyer_receipt.received); + assert!(buyer_receipt.issue.is_none()); + assert_eq!(buyer_receipt.received_at, receipt_payload.received_at); assert_eq!(seller_orders.rows[0].status, OrderStatus::Completed); + let seller_receipt = seller_orders.rows[0] + .workflow + .receipt + .as_ref() + .expect("seller receipt projection"); + assert_eq!(seller_receipt.event_id, receipt_event.id); + assert!(seller_receipt.received); + assert!(seller_receipt.issue.is_none()); + assert_eq!(seller_receipt.received_at, receipt_payload.received_at); + assert_eq!(seller_orders.rows[0].primary_action, None); + assert_eq!(seller_orders.rows[0].fulfillment_actions, Vec::new()); + } + + #[test] + fn active_order_issue_receipt_projects_through_cli_reducer_state() { + let fixture = active_order_ready_fixture("active-lifecycle-issue-receipt"); + let receipt_payload = buyer_receipt_payload( + fixture.order_id_raw.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + false, + ); + let receipt_parts = active_trade_buyer_receipt_event_build( + fixture.request_event_id.as_str(), + fixture.fulfillment_event_id.as_str(), + &receipt_payload, + ) + .expect("build lifecycle buyer issue receipt"); + let receipt_event = event_from_parts( + "active-lifecycle-issue-receipt-event", + fixture.buyer_pubkey.as_str(), + receipt_parts, + ); + fixture + .events + .append_record(&signed_order_event_record( + "app:signed_event:active-lifecycle:issue-receipt", + &receipt_event, + fixture.listing_addr.as_str(), + SourceRuntime::App, + Some("acct_lifecycle"), + )) + .expect("append lifecycle buyer issue receipt"); + fixture + .app_store + .import_shared_local_events_from_store(&fixture.events) + .expect("import lifecycle buyer issue receipt"); + + let buyer_detail = fixture + .app_store + .load_buyer_order_detail(&fixture.buyer_context, fixture.order_id) + .expect("load lifecycle buyer issue detail") + .expect("lifecycle buyer issue detail"); + let seller_orders = fixture + .app_store + .load_orders_list( + fixture.seller_farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load lifecycle seller orders after issue receipt"); + + assert_eq!(buyer_detail.status, BuyerOrderStatus::NeedsReview); + let buyer_receipt = buyer_detail + .workflow + .receipt + .as_ref() + .expect("buyer issue receipt projection"); + assert_eq!(buyer_receipt.event_id, receipt_event.id); + assert!(!buyer_receipt.received); + assert_eq!(buyer_receipt.issue.as_deref(), Some("items need review")); + assert_eq!(buyer_receipt.received_at, receipt_payload.received_at); + assert_eq!(seller_orders.rows[0].status, OrderStatus::NeedsReview); + let seller_receipt = seller_orders.rows[0] + .workflow + .receipt + .as_ref() + .expect("seller issue receipt projection"); + assert_eq!(seller_receipt.event_id, receipt_event.id); + assert!(!seller_receipt.received); + assert_eq!(seller_receipt.issue.as_deref(), Some("items need review")); + assert_eq!(seller_receipt.received_at, receipt_payload.received_at); assert_eq!(seller_orders.rows[0].primary_action, None); assert_eq!(seller_orders.rows[0].fulfillment_actions, Vec::new()); } diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs @@ -1058,6 +1058,35 @@ mod tests { .expect("expanded workflow payment state should insert"); } + connection + .execute( + "INSERT INTO orders ( + id, + farm_id, + order_number, + customer_display_name, + status, + updated_at, + workflow_receipt_event_id, + workflow_receipt_received, + workflow_receipt_issue, + workflow_receipt_received_at + ) VALUES ( + 'order_issue_receipt', + 'farm_schema', + 'issue receipt', + 'Buyer', + 'needs_review', + '2026-01-01T00:00:00Z', + 'receipt-event-1', + 0, + 'items need review', + 1777665700 + )", + [], + ) + .expect("receipt projection should insert"); + let invalid_result = connection.execute( "INSERT INTO orders ( id, @@ -1071,6 +1100,32 @@ mod tests { [], ); assert!(invalid_result.is_err()); + + let invalid_receipt_result = connection.execute( + "INSERT INTO orders ( + id, + farm_id, + order_number, + customer_display_name, + status, + updated_at, + workflow_receipt_event_id, + workflow_receipt_received, + workflow_receipt_received_at + ) VALUES ( + 'order_receipt_invalid', + 'farm_schema', + 'invalid receipt', + 'Buyer', + 'needs_review', + '2026-01-01T00:00:00Z', + 'receipt-event-invalid', + 2, + 1777665700 + )", + [], + ); + assert!(invalid_receipt_result.is_err()); } #[test] @@ -1233,6 +1288,12 @@ mod tests { [], ) .expect("declined status should satisfy migrated check"); + connection + .execute( + "UPDATE orders SET status = 'needs_review' WHERE id = 'order_legacy'", + [], + ) + .expect("needs review status should satisfy migrated check"); let status: String = connection .query_row( @@ -1241,7 +1302,7 @@ mod tests { |row| row.get(0), ) .expect("status should load"); - assert_eq!(status, "declined"); + assert_eq!(status, "needs_review"); drop(store); remove_database_artifacts(&path); diff --git a/crates/store/src/migrations.rs b/crates/store/src/migrations.rs @@ -100,6 +100,10 @@ const MIGRATIONS: &[Migration] = &[ version: 24, sql: include_str!("../migrations/0024_order_workflow_payment_display_states.sql"), }, + Migration { + version: 25, + sql: include_str!("../migrations/0025_order_receipt_display_projection.sql"), + }, ]; pub fn latest_schema_version() -> u32 { diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs @@ -809,6 +809,10 @@ impl<'a> AppBuyerRepository<'a> { o.workflow_revision, o.workflow_agreement, o.workflow_fulfillment, + o.workflow_receipt_event_id, + o.workflow_receipt_received, + o.workflow_receipt_issue, + o.workflow_receipt_received_at, o.workflow_inventory, o.workflow_payment, o.workflow_provenance_source, @@ -840,14 +844,18 @@ impl<'a> AppBuyerRepository<'a> { row.get::<_, String>(4)?, row.get::<_, String>(5)?, row.get::<_, Option<String>>(6)?, - row.get::<_, String>(7)?, - row.get::<_, String>(8)?, - row.get::<_, String>(9)?, - row.get::<_, Option<String>>(10)?, + row.get::<_, Option<String>>(7)?, + row.get::<_, Option<i64>>(8)?, + row.get::<_, Option<String>>(9)?, + row.get::<_, Option<i64>>(10)?, row.get::<_, String>(11)?, - row.get::<_, Option<String>>(12)?, - row.get::<_, Option<String>>(13)?, + row.get::<_, String>(12)?, + row.get::<_, String>(13)?, row.get::<_, Option<String>>(14)?, + row.get::<_, String>(15)?, + row.get::<_, Option<String>>(16)?, + row.get::<_, Option<String>>(17)?, + row.get::<_, Option<String>>(18)?, )) }) .map_err(|source| AppSqliteError::Query { @@ -865,6 +873,10 @@ impl<'a> AppBuyerRepository<'a> { workflow_revision, workflow_agreement, workflow_fulfillment, + workflow_receipt_event_id, + workflow_receipt_received, + workflow_receipt_issue, + workflow_receipt_received_at, workflow_inventory, workflow_payment, workflow_provenance_source, @@ -890,6 +902,10 @@ impl<'a> AppBuyerRepository<'a> { economics, agreement: workflow_agreement, fulfillment: workflow_fulfillment, + receipt_event_id: workflow_receipt_event_id, + receipt_received: workflow_receipt_received, + receipt_issue: workflow_receipt_issue, + receipt_received_at: workflow_receipt_received_at, inventory: workflow_inventory, payment: workflow_payment, provenance_source: workflow_provenance_source, @@ -953,6 +969,10 @@ impl<'a> AppBuyerRepository<'a> { o.workflow_revision, o.workflow_agreement, o.workflow_fulfillment, + o.workflow_receipt_event_id, + o.workflow_receipt_received, + o.workflow_receipt_issue, + o.workflow_receipt_received_at, o.workflow_inventory, o.workflow_payment, o.workflow_provenance_source, @@ -981,14 +1001,18 @@ impl<'a> AppBuyerRepository<'a> { row.get::<_, String>(5)?, row.get::<_, String>(6)?, row.get::<_, Option<String>>(7)?, - row.get::<_, String>(8)?, - row.get::<_, String>(9)?, - row.get::<_, String>(10)?, - row.get::<_, Option<String>>(11)?, + row.get::<_, Option<String>>(8)?, + row.get::<_, Option<i64>>(9)?, + row.get::<_, Option<String>>(10)?, + row.get::<_, Option<i64>>(11)?, row.get::<_, String>(12)?, - row.get::<_, Option<String>>(13)?, - row.get::<_, Option<String>>(14)?, + row.get::<_, String>(13)?, + row.get::<_, String>(14)?, row.get::<_, Option<String>>(15)?, + row.get::<_, String>(16)?, + row.get::<_, Option<String>>(17)?, + row.get::<_, Option<String>>(18)?, + row.get::<_, Option<String>>(19)?, )) }) .optional() @@ -1008,6 +1032,10 @@ impl<'a> AppBuyerRepository<'a> { workflow_revision, workflow_agreement, workflow_fulfillment, + workflow_receipt_event_id, + workflow_receipt_received, + workflow_receipt_issue, + workflow_receipt_received_at, workflow_inventory, workflow_payment, workflow_provenance_source, @@ -1032,6 +1060,10 @@ impl<'a> AppBuyerRepository<'a> { economics: economics.clone(), agreement: workflow_agreement, fulfillment: workflow_fulfillment, + receipt_event_id: workflow_receipt_event_id, + receipt_received: workflow_receipt_received, + receipt_issue: workflow_receipt_issue, + receipt_received_at: workflow_receipt_received_at, inventory: workflow_inventory, payment: workflow_payment, provenance_source: workflow_provenance_source, @@ -2880,6 +2912,7 @@ fn parse_order_status(field: &'static str, value: String) -> Result<OrderStatus, "completed" => Ok(OrderStatus::Completed), "declined" => Ok(OrderStatus::Declined), "refunded" => Ok(OrderStatus::Refunded), + "needs_review" => Ok(OrderStatus::NeedsReview), _ => Err(AppSqliteError::DecodeEnum { field, value }), } } diff --git a/crates/store/src/repo/orders.rs b/crates/store/src/repo/orders.rs @@ -81,6 +81,10 @@ impl<'a> AppOrdersRepository<'a> { o.workflow_revision, o.workflow_agreement, o.workflow_fulfillment, + o.workflow_receipt_event_id, + o.workflow_receipt_received, + o.workflow_receipt_issue, + o.workflow_receipt_received_at, o.workflow_inventory, o.workflow_payment, o.workflow_provenance_source, @@ -104,12 +108,16 @@ impl<'a> AppOrdersRepository<'a> { row.get::<_, String>(6)?, row.get::<_, String>(7)?, row.get::<_, Option<String>>(8)?, - row.get::<_, String>(9)?, - row.get::<_, String>(10)?, - row.get::<_, String>(11)?, - row.get::<_, Option<String>>(12)?, - row.get::<_, Option<String>>(13)?, - row.get::<_, Option<String>>(14)?, + row.get::<_, Option<String>>(9)?, + row.get::<_, Option<i64>>(10)?, + row.get::<_, Option<String>>(11)?, + row.get::<_, Option<i64>>(12)?, + row.get::<_, String>(13)?, + row.get::<_, String>(14)?, + row.get::<_, String>(15)?, + row.get::<_, Option<String>>(16)?, + row.get::<_, Option<String>>(17)?, + row.get::<_, Option<String>>(18)?, )) }, ) @@ -131,6 +139,10 @@ impl<'a> AppOrdersRepository<'a> { workflow_revision, workflow_agreement, workflow_fulfillment, + workflow_receipt_event_id, + workflow_receipt_received, + workflow_receipt_issue, + workflow_receipt_received_at, workflow_inventory, workflow_payment, workflow_provenance_source, @@ -152,6 +164,10 @@ impl<'a> AppOrdersRepository<'a> { economics: economics.clone(), agreement: workflow_agreement, fulfillment: workflow_fulfillment, + receipt_event_id: workflow_receipt_event_id, + receipt_received: workflow_receipt_received, + receipt_issue: workflow_receipt_issue, + receipt_received_at: workflow_receipt_received_at, inventory: workflow_inventory, payment: workflow_payment, provenance_source: workflow_provenance_source, @@ -298,6 +314,10 @@ impl<'a> AppOrdersRepository<'a> { o.workflow_revision, o.workflow_agreement, o.workflow_fulfillment, + o.workflow_receipt_event_id, + o.workflow_receipt_received, + o.workflow_receipt_issue, + o.workflow_receipt_received_at, o.workflow_inventory, o.workflow_payment, o.workflow_provenance_source, @@ -332,12 +352,16 @@ impl<'a> AppOrdersRepository<'a> { row.get::<_, String>(6)?, row.get::<_, String>(7)?, row.get::<_, Option<String>>(8)?, - row.get::<_, String>(9)?, - row.get::<_, String>(10)?, - row.get::<_, String>(11)?, - row.get::<_, Option<String>>(12)?, - row.get::<_, Option<String>>(13)?, - row.get::<_, Option<String>>(14)?, + row.get::<_, Option<String>>(9)?, + row.get::<_, Option<i64>>(10)?, + row.get::<_, Option<String>>(11)?, + row.get::<_, Option<i64>>(12)?, + row.get::<_, String>(13)?, + row.get::<_, String>(14)?, + row.get::<_, String>(15)?, + row.get::<_, Option<String>>(16)?, + row.get::<_, Option<String>>(17)?, + row.get::<_, Option<String>>(18)?, )) }, ) @@ -358,6 +382,10 @@ impl<'a> AppOrdersRepository<'a> { workflow_revision, workflow_agreement, workflow_fulfillment, + workflow_receipt_event_id, + workflow_receipt_received, + workflow_receipt_issue, + workflow_receipt_received_at, workflow_inventory, workflow_payment, workflow_provenance_source, @@ -381,6 +409,10 @@ impl<'a> AppOrdersRepository<'a> { economics, agreement: workflow_agreement, fulfillment: workflow_fulfillment, + receipt_event_id: workflow_receipt_event_id, + receipt_received: workflow_receipt_received, + receipt_issue: workflow_receipt_issue, + receipt_received_at: workflow_receipt_received_at, inventory: workflow_inventory, payment: workflow_payment, provenance_source: workflow_provenance_source, @@ -1144,7 +1176,12 @@ impl OrderRecord { fn matches_filter(&self, filter: OrdersFilter) -> bool { match filter { OrdersFilter::All => true, - OrdersFilter::NeedsAction => self.status == OrderStatus::NeedsAction, + OrdersFilter::NeedsAction => { + matches!( + self.status, + OrderStatus::NeedsAction | OrderStatus::NeedsReview + ) + } OrdersFilter::Scheduled => self.status == OrderStatus::Scheduled, OrdersFilter::Packed => self.status == OrderStatus::Packed, OrdersFilter::Completed => self.status == OrderStatus::Completed, @@ -1177,7 +1214,7 @@ fn summarize_orders(records: &[OrderRecord]) -> OrdersListSummary { for record in records { match record.status { - OrderStatus::NeedsAction => summary.needs_action_orders += 1, + OrderStatus::NeedsAction | OrderStatus::NeedsReview => summary.needs_action_orders += 1, OrderStatus::Scheduled => summary.scheduled_orders += 1, OrderStatus::Packed => summary.packed_orders += 1, OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => {} @@ -1202,7 +1239,10 @@ fn primary_action_for_order( ) => Some(OrderPrimaryAction::PublishDelivered), Some(TradeFulfillmentStatus::Delivered | TradeFulfillmentStatus::Cancelled) => None, }, - OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => None, + OrderStatus::Completed + | OrderStatus::Declined + | OrderStatus::Refunded + | OrderStatus::NeedsReview => None, } } @@ -1280,6 +1320,7 @@ fn parse_order_status(field: &'static str, value: String) -> Result<OrderStatus, "completed" => Ok(OrderStatus::Completed), "declined" => Ok(OrderStatus::Declined), "refunded" => Ok(OrderStatus::Refunded), + "needs_review" => Ok(OrderStatus::NeedsReview), _ => Err(AppSqliteError::DecodeEnum { field, value }), } } diff --git a/crates/store/src/repo/workflow.rs b/crates/store/src/repo/workflow.rs @@ -1,7 +1,7 @@ use radroots_app_view::{ OrderId, TradeAgreementStatus, TradeEconomicsProjection, TradeFulfillmentStatus, TradeInventoryStatus, TradePaymentDisplayStatus, TradeProvenanceProjection, - TradeRevisionStatus, TradeWorkflowProjection, TradeWorkflowSource, + TradeReceiptProjection, TradeRevisionStatus, TradeWorkflowProjection, TradeWorkflowSource, }; use crate::AppSqliteError; @@ -13,6 +13,10 @@ pub(super) struct StoredTradeWorkflowSnapshot { pub economics: TradeEconomicsProjection, pub agreement: String, pub fulfillment: Option<String>, + pub receipt_event_id: Option<String>, + pub receipt_received: Option<i64>, + pub receipt_issue: Option<String>, + pub receipt_received_at: Option<i64>, pub inventory: String, pub payment: String, pub provenance_source: String, @@ -30,6 +34,12 @@ pub(super) fn trade_workflow_projection_from_storage( .fulfillment .map(|value| parse_trade_fulfillment_status("orders.workflow_fulfillment", value)) .transpose()?, + receipt: trade_receipt_projection_from_storage( + snapshot.receipt_event_id, + snapshot.receipt_received, + snapshot.receipt_issue, + snapshot.receipt_received_at, + )?, economics: snapshot.economics, inventory: parse_trade_inventory_status("orders.workflow_inventory", snapshot.inventory)?, payment: parse_trade_payment_display_status("orders.workflow_payment", snapshot.payment)?, @@ -41,6 +51,40 @@ pub(super) fn trade_workflow_projection_from_storage( }) } +fn trade_receipt_projection_from_storage( + event_id: Option<String>, + received: Option<i64>, + issue: Option<String>, + received_at: Option<i64>, +) -> Result<Option<TradeReceiptProjection>, AppSqliteError> { + match (event_id, received, received_at) { + (None, None, None) => Ok(None), + (Some(event_id), Some(received), Some(received_at)) => Ok(Some(TradeReceiptProjection { + event_id, + received: parse_workflow_receipt_received(received)?, + issue, + received_at: u64::try_from(received_at).map_err(|_| { + AppSqliteError::InvalidProjection { + reason: "orders.workflow_receipt_received_at must be non-negative", + } + })?, + })), + _ => Err(AppSqliteError::InvalidProjection { + reason: "orders.workflow_receipt projection is incomplete", + }), + } +} + +fn parse_workflow_receipt_received(value: i64) -> Result<bool, AppSqliteError> { + match value { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(AppSqliteError::InvalidProjection { + reason: "orders.workflow_receipt_received must be 0 or 1", + }), + } +} + fn parse_trade_agreement_status( field: &'static str, value: String, diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs @@ -5,11 +5,11 @@ mod publish; pub use publish::{ AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, - AppOrderFulfillmentPublishPayload, AppOrderReceiptPublishPayload, AppOrderRequestItemPayload, - AppOrderRequestPublishPayload, AppOrderRevisionDecisionPublishPayload, - AppOrderRevisionProposalPublishPayload, AppPublishContext, AppPublishPayload, - AppPublishPayloadJsonError, AppPublishValidationFailure, AppPublishValidationFailureSet, - AppPublishWorkKind, + AppOrderFulfillmentPublishPayload, AppOrderReceiptOutcome, AppOrderReceiptPublishPayload, + AppOrderRequestItemPayload, AppOrderRequestPublishPayload, + AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, + AppPublishContext, AppPublishPayload, AppPublishPayloadJsonError, AppPublishValidationFailure, + AppPublishValidationFailureSet, AppPublishWorkKind, }; use radroots_app_view::{FarmId, FulfillmentWindowId, OrderId, ProductId}; diff --git a/crates/sync/src/publish.rs b/crates/sync/src/publish.rs @@ -258,6 +258,34 @@ pub struct AppOrderReceiptPublishPayload { pub received_at: u64, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AppOrderReceiptOutcome { + Received, + Issue { issue: String }, +} + +impl AppOrderReceiptOutcome { + pub fn issue(issue: impl Into<String>) -> Option<Self> { + let issue = issue.into().trim().to_owned(); + if issue.is_empty() { + None + } else { + Some(Self::Issue { issue }) + } + } + + pub const fn received(&self) -> bool { + matches!(self, Self::Received) + } + + pub fn issue_text(self) -> Option<String> { + match self { + Self::Received => None, + Self::Issue { issue } => Some(issue), + } + } +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(tag = "publish_kind", content = "payload", rename_all = "snake_case")] pub enum AppPublishPayload { @@ -758,9 +786,10 @@ mod tests { use super::{ AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, AppOrderFulfillmentPublishPayload, - AppOrderReceiptPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, - AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, - AppPublishContext, AppPublishPayload, AppPublishValidationFailure, AppPublishWorkKind, + AppOrderReceiptOutcome, AppOrderReceiptPublishPayload, AppOrderRequestItemPayload, + AppOrderRequestPublishPayload, AppOrderRevisionDecisionPublishPayload, + AppOrderRevisionProposalPublishPayload, AppPublishContext, AppPublishPayload, + AppPublishValidationFailure, AppPublishWorkKind, }; use crate::{ PendingSyncOperation, PendingSyncOperationState, SyncAggregateRef, SyncOperationKind, @@ -811,6 +840,17 @@ mod tests { } #[test] + fn order_receipt_outcome_requires_non_empty_issue_text() { + assert_eq!(AppOrderReceiptOutcome::issue(" "), None); + assert!(AppOrderReceiptOutcome::Received.received()); + + let issue = AppOrderReceiptOutcome::issue(" items need review ") + .expect("issue text should normalize"); + assert!(!issue.received()); + assert_eq!(issue.issue_text().as_deref(), Some("items need review")); + } + + #[test] fn publish_work_kinds_keep_payment_and_settlement_events_reserved() { let work_kinds = [ AppPublishWorkKind::FarmProfile, diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs @@ -342,6 +342,7 @@ pub enum OrderStatus { Completed, Declined, Refunded, + NeedsReview, } impl OrderStatus { @@ -353,6 +354,7 @@ impl OrderStatus { Self::Completed => "completed", Self::Declined => "declined", Self::Refunded => "refunded", + Self::NeedsReview => "needs_review", } } } @@ -366,6 +368,7 @@ pub enum BuyerOrderStatus { Completed, Declined, Refunded, + NeedsReview, } impl BuyerOrderStatus { @@ -377,6 +380,7 @@ impl BuyerOrderStatus { Self::Completed => "completed", Self::Declined => "declined", Self::Refunded => "refunded", + Self::NeedsReview => "needs_review", } } } @@ -390,6 +394,7 @@ impl From<OrderStatus> for BuyerOrderStatus { OrderStatus::Completed => Self::Completed, OrderStatus::Declined => Self::Declined, OrderStatus::Refunded => Self::Refunded, + OrderStatus::NeedsReview => Self::NeedsReview, } } } @@ -690,7 +695,10 @@ impl PackDayOutputOrderState { OrderStatus::NeedsAction => Some(Self::NeedsAction), OrderStatus::Scheduled => Some(Self::Scheduled), OrderStatus::Packed => Some(Self::Packed), - OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => None, + OrderStatus::Completed + | OrderStatus::Declined + | OrderStatus::Refunded + | OrderStatus::NeedsReview => None, } } } diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -1469,11 +1469,33 @@ impl Default for TradeProvenanceProjection { } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TradeReceiptProjection { + pub event_id: String, + pub received: bool, + pub issue: Option<String>, + pub received_at: u64, +} + +impl TradeReceiptProjection { + pub fn from_active_order_projection( + projection: &RadrootsActiveOrderProjection, + ) -> Option<Self> { + Some(Self { + event_id: projection.receipt_event_id.clone()?, + received: projection.receipt_received?, + issue: projection.receipt_issue.clone(), + received_at: projection.receipt_received_at?, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct TradeWorkflowProjection { pub order_id: OrderId, pub agreement: TradeAgreementStatus, pub revision: TradeRevisionStatus, pub fulfillment: Option<TradeFulfillmentStatus>, + pub receipt: Option<TradeReceiptProjection>, pub economics: TradeEconomicsProjection, pub inventory: TradeInventoryStatus, pub payment: TradePaymentDisplayStatus, @@ -1487,6 +1509,7 @@ impl TradeWorkflowProjection { agreement, revision: TradeRevisionStatus::None, fulfillment: None, + receipt: None, economics: TradeEconomicsProjection::default(), inventory: TradeInventoryStatus::NeedsReview, payment: TradePaymentDisplayStatus::NotRecorded, @@ -1509,6 +1532,7 @@ impl TradeWorkflowProjection { .fulfillment_status .as_ref() .map(TradeFulfillmentStatus::from_active_fulfillment_status); + workflow.receipt = TradeReceiptProjection::from_active_order_projection(projection); workflow.economics = projection .economics .as_ref() @@ -1528,7 +1552,9 @@ impl TradeWorkflowProjection { OrderStatus::Packed => Self::new(order_id, TradeAgreementStatus::Confirmed), OrderStatus::Completed => Self::new(order_id, TradeAgreementStatus::Completed), OrderStatus::Declined => Self::new(order_id, TradeAgreementStatus::Declined), - OrderStatus::Refunded => Self::new(order_id, TradeAgreementStatus::NeedsReview), + OrderStatus::Refunded | OrderStatus::NeedsReview => { + Self::new(order_id, TradeAgreementStatus::NeedsReview) + } }; match status { @@ -1549,7 +1575,7 @@ impl TradeWorkflowProjection { projection.fulfillment = Some(TradeFulfillmentStatus::Cancelled); projection.inventory = TradeInventoryStatus::Available; } - OrderStatus::Refunded => { + OrderStatus::Refunded | OrderStatus::NeedsReview => { projection.payment = TradePaymentDisplayStatus::NeedsReview; } } @@ -1564,7 +1590,9 @@ impl TradeWorkflowProjection { BuyerOrderStatus::Ready => Self::new(order_id, TradeAgreementStatus::Confirmed), BuyerOrderStatus::Completed => Self::new(order_id, TradeAgreementStatus::Completed), BuyerOrderStatus::Declined => Self::new(order_id, TradeAgreementStatus::Declined), - BuyerOrderStatus::Refunded => Self::new(order_id, TradeAgreementStatus::NeedsReview), + BuyerOrderStatus::Refunded | BuyerOrderStatus::NeedsReview => { + Self::new(order_id, TradeAgreementStatus::NeedsReview) + } }; match status { @@ -1585,7 +1613,7 @@ impl TradeWorkflowProjection { projection.fulfillment = Some(TradeFulfillmentStatus::Cancelled); projection.inventory = TradeInventoryStatus::Available; } - BuyerOrderStatus::Refunded => { + BuyerOrderStatus::Refunded | BuyerOrderStatus::NeedsReview => { projection.payment = TradePaymentDisplayStatus::NeedsReview; } } @@ -1647,9 +1675,8 @@ pub fn order_status_from_active_order_projection( Some(OrderStatus::Declined) } (RadrootsActiveOrderStatus::Completed, _) => Some(OrderStatus::Completed), - (RadrootsActiveOrderStatus::Disputed | RadrootsActiveOrderStatus::Invalid, _) => { - Some(OrderStatus::NeedsAction) - } + (RadrootsActiveOrderStatus::Disputed, _) => Some(OrderStatus::NeedsReview), + (RadrootsActiveOrderStatus::Invalid, _) => Some(OrderStatus::NeedsAction), } } @@ -2908,12 +2935,14 @@ mod tests { assert_eq!(OrderStatus::Completed.storage_key(), "completed"); assert_eq!(OrderStatus::Declined.storage_key(), "declined"); assert_eq!(OrderStatus::Refunded.storage_key(), "refunded"); + assert_eq!(OrderStatus::NeedsReview.storage_key(), "needs_review"); assert_eq!(BuyerOrderStatus::Placed.storage_key(), "placed"); assert_eq!(BuyerOrderStatus::Scheduled.storage_key(), "scheduled"); assert_eq!(BuyerOrderStatus::Ready.storage_key(), "ready"); assert_eq!(BuyerOrderStatus::Completed.storage_key(), "completed"); assert_eq!(BuyerOrderStatus::Declined.storage_key(), "declined"); assert_eq!(BuyerOrderStatus::Refunded.storage_key(), "refunded"); + assert_eq!(BuyerOrderStatus::NeedsReview.storage_key(), "needs_review"); assert_eq!( BuyerOrderStatus::from(OrderStatus::NeedsAction), BuyerOrderStatus::Placed @@ -2926,6 +2955,10 @@ mod tests { BuyerOrderStatus::from(OrderStatus::Declined), BuyerOrderStatus::Declined ); + assert_eq!( + BuyerOrderStatus::from(OrderStatus::NeedsReview), + BuyerOrderStatus::NeedsReview + ); assert_eq!(OrdersFilter::default(), OrdersFilter::NeedsAction); assert_eq!(OrdersFilter::All.storage_key(), "all"); @@ -3588,6 +3621,10 @@ mod tests { None ); assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::NeedsReview), + None + ); + assert_eq!( OrderStatus::from(PackDayOutputOrderState::Packed), OrderStatus::Packed ); diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -119,11 +119,17 @@ "personal.orders.detail.total.label": "Total", "personal.orders.detail.payment.label": "Payment", "personal.orders.detail.note.label": "Order note", + "personal.orders.detail.receipt.label": "Receipt", "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.action.report_issue": "Report issue", + "personal.orders.action.send_receipt_issue": "Send update", + "personal.orders.action.close_receipt_issue": "Close", + "personal.orders.receipt.issue.label": "Note", + "personal.orders.receipt.issue.placeholder": "What needs review", "personal.orders.repeat_demand.title": "Reorder", "personal.orders.repeat_demand.action.eligible": "Reorder", "personal.orders.repeat_demand.action.partial": "Reorder available items", @@ -139,6 +145,7 @@ "personal.orders.status.completed": "Completed", "personal.orders.status.declined": "Declined", "personal.orders.status.refunded": "Refunded", + "personal.orders.status.needs_review": "Needs review", "personal.cart.surface.body": "Review items from one farm before placing the order.", "personal.order_summary.title": "Order summary", "personal.fulfillment.title": "Fulfillment", @@ -176,6 +183,7 @@ "orders.status.completed": "Completed", "orders.status.declined": "Declined", "orders.status.refunded": "Refunded", + "orders.status.needs_review": "Needs review", "orders.table.title": "Order queue", "orders.column.order": "Order", "orders.column.status": "Status", @@ -220,6 +228,7 @@ "trade.workflow.axis.fulfillment": "Fulfillment", "trade.workflow.axis.inventory": "Stock", "trade.workflow.axis.payment": "Payment", + "trade.workflow.axis.receipt": "Receipt", "trade.workflow.axis.source": "Source", "trade.workflow.agreement.ordered": "Ordered", "trade.workflow.agreement.confirmed": "Confirmed", @@ -246,6 +255,8 @@ "trade.workflow.payment.recorded": "Recorded", "trade.workflow.payment.settled": "Settled", "trade.workflow.payment.needs_review": "Needs review", + "trade.workflow.receipt.received": "Received", + "trade.workflow.receipt.needs_review": "Needs review", "trade.workflow.provenance.app": "App", "trade.workflow.provenance.cli": "CLI", "trade.workflow.provenance.relay": "Relay",