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