app

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

commit 94b45416d2a2a57f4e462ee1145be62819047a4e
parent d474f24f7f7c357d065bea2e3acd78e70c423692
Author: triesap <tyson@radroots.org>
Date:   Thu,  4 Jun 2026 21:53:17 -0700

orders: align fulfillment gates with workflow evidence

Diffstat:
Mcrates/desktop/src/runtime.rs | 218++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/desktop/src/source_guards.rs | 52+++++++++++++++++++++++++++++++++++++++++++---------
2 files changed, 233 insertions(+), 37 deletions(-)

diff --git a/crates/desktop/src/runtime.rs b/crates/desktop/src/runtime.rs @@ -2558,48 +2558,54 @@ impl DesktopAppRuntimeState { reason: "seller order fulfillment is already terminal", }); } - let Some(order_detail) = sqlite_store.load_order_detail(farm_id, order_id)? else { + if sqlite_store.load_order_detail(farm_id, order_id)?.is_none() { return Err(AppSqliteError::InvalidProjection { reason: "seller order fulfillment requires a visible seller order", }); }; + let latest_fulfillment = lifecycle.latest_fulfillment.as_ref(); let prev_event_id = match status { - RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled => { + RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled + | RadrootsActiveTradeFulfillmentState::Preparing + | RadrootsActiveTradeFulfillmentState::OutForDelivery => { return Err(AppSqliteError::InvalidProjection { reason: "seller order fulfillment status must be publishable", }); } - RadrootsActiveTradeFulfillmentState::ReadyForPickup - | RadrootsActiveTradeFulfillmentState::Preparing - | RadrootsActiveTradeFulfillmentState::OutForDelivery => { - if order_detail.status != OrderStatus::Scheduled { + RadrootsActiveTradeFulfillmentState::ReadyForPickup => match latest_fulfillment { + None => decision.event_id.clone(), + Some(fulfillment) + if matches!( + fulfillment.status, + RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled + | RadrootsActiveTradeFulfillmentState::Preparing + ) => + { + fulfillment.event_id.clone() + } + Some(_) => { return Err(AppSqliteError::InvalidProjection { - reason: "seller order fulfillment requires a scheduled order", + reason: "seller order ready for pickup requires accepted or preparing fulfillment evidence", }); } - lifecycle - .latest_fulfillment - .as_ref() - .map(|fulfillment| fulfillment.event_id.clone()) - .unwrap_or_else(|| decision.event_id.clone()) - } - RadrootsActiveTradeFulfillmentState::Delivered => { - if order_detail.status != OrderStatus::Packed { + }, + RadrootsActiveTradeFulfillmentState::Delivered => match latest_fulfillment { + Some(fulfillment) + if matches!( + fulfillment.status, + RadrootsActiveTradeFulfillmentState::ReadyForPickup + | RadrootsActiveTradeFulfillmentState::OutForDelivery + ) => + { + fulfillment.event_id.clone() + } + Some(_) | None => { return Err(AppSqliteError::InvalidProjection { - reason: "seller order delivery requires a ready order", + reason: "seller order delivery requires ready fulfillment evidence", }); } - lifecycle - .latest_fulfillment - .as_ref() - .map(|fulfillment| fulfillment.event_id.clone()) - .ok_or(AppSqliteError::InvalidProjection { - reason: "seller order delivery requires fulfillment evidence", - })? - } - RadrootsActiveTradeFulfillmentState::SellerCancelled => lifecycle - .latest_fulfillment - .as_ref() + }, + RadrootsActiveTradeFulfillmentState::SellerCancelled => latest_fulfillment .map(|fulfillment| fulfillment.event_id.clone()) .unwrap_or_else(|| decision.event_id.clone()), }; @@ -14914,6 +14920,125 @@ mod tests { } #[test] + fn runtime_publishes_seller_order_fulfillment_delivered_when_coarse_status_lags() { + let relay = ThreadedAckRelay::spawn(); + let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) = + seller_order_decision_runtime("seller_order_fulfillment_delivery_status_lag", 6, 2); + install_direct_relay_sync_transport(&runtime, &relay); + let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + let request_event_id = "event-app:signed_event:order-request:seller-order-decision-1"; + let decision_event_id = append_signed_order_decision_record( + &paths, + "seller-order-decision-1", + request_event_id, + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + let ready_event_id = append_signed_order_fulfillment_record_with_status( + &paths, + "seller-order-decision-1", + request_event_id, + decision_event_id.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + RadrootsActiveTradeFulfillmentState::ReadyForPickup, + ); + runtime + .refresh_shared_local_events() + .expect("seller ready fulfillment should import"); + assert_eq!(persisted_order_status(&runtime, order_id), "packed"); + set_persisted_order_status(&runtime, order_id, "scheduled"); + + assert!( + runtime + .publish_order_delivered(order_id) + .expect("seller delivered fulfillment should publish from workflow evidence") + ); + + assert_eq!(persisted_order_status(&runtime, order_id), "packed"); + assert_eq!(relay.event_count(), 1); + let fulfillment_events = shared_order_events_by_kind(&paths, 3433, seller_pubkey.as_str()); + assert_eq!(fulfillment_events.len(), 2); + let delivered_event = fulfillment_events + .iter() + .find(|event| { + radroots_sdk::trade::parse_fulfillment_update(event) + .map(|envelope| { + envelope.payload.status == RadrootsActiveTradeFulfillmentState::Delivered + }) + .unwrap_or(false) + }) + .expect("delivered fulfillment event should exist"); + assert!(event_has_tag( + delivered_event, + "e_prev", + ready_event_id.as_str() + )); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn runtime_rejects_seller_order_fulfillment_delivered_without_ready_evidence() { + for (label, latest_fulfillment) in [ + ("seller_order_fulfillment_delivery_missing_ready", None), + ( + "seller_order_fulfillment_delivery_preparing", + Some(RadrootsActiveTradeFulfillmentState::Preparing), + ), + ] { + let relay = ThreadedAckRelay::spawn(); + let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) = + seller_order_decision_runtime(label, 6, 2); + install_direct_relay_sync_transport(&runtime, &relay); + let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + let request_event_id = "event-app:signed_event:order-request:seller-order-decision-1"; + let decision_event_id = append_signed_order_decision_record( + &paths, + "seller-order-decision-1", + request_event_id, + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + 2, + ); + if let Some(status) = latest_fulfillment { + append_signed_order_fulfillment_record_with_status( + &paths, + "seller-order-decision-1", + request_event_id, + decision_event_id.as_str(), + listing_addr.as_str(), + buyer_pubkey.as_str(), + seller_pubkey.as_str(), + status, + ); + } + runtime + .refresh_shared_local_events() + .expect("seller fulfillment fixture should import"); + + let error = runtime + .publish_order_delivered(order_id) + .expect_err("seller delivered fulfillment should require ready evidence"); + + assert!(matches!( + error, + AppSqliteError::InvalidProjection { + reason: "seller order delivery requires ready fulfillment evidence" + } + )); + assert_eq!(relay.event_count(), 0); + cleanup_bootstrapped_runtime_paths(&paths); + } + } + + #[test] fn runtime_places_supported_buyer_order_into_shared_local_events() { let (runtime, paths) = bootstrapped_runtime("buyer_order_local_event"); assert!( @@ -19449,12 +19574,34 @@ mod tests { buyer_pubkey: &str, seller_pubkey: &str, ) -> String { + append_signed_order_fulfillment_record_with_status( + paths, + trade_order_id, + request_event_id, + decision_event_id, + listing_addr, + buyer_pubkey, + seller_pubkey, + RadrootsActiveTradeFulfillmentState::ReadyForPickup, + ) + } + + fn append_signed_order_fulfillment_record_with_status( + paths: &AppDesktopRuntimePaths, + trade_order_id: &str, + request_event_id: &str, + decision_event_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + status: RadrootsActiveTradeFulfillmentState, + ) -> String { let payload = RadrootsTradeFulfillmentUpdated { order_id: trade_order_id.to_owned(), listing_addr: listing_addr.to_owned(), buyer_pubkey: buyer_pubkey.to_owned(), seller_pubkey: seller_pubkey.to_owned(), - status: RadrootsActiveTradeFulfillmentState::ReadyForPickup, + status, }; let parts = radroots_sdk::trade::build_fulfillment_update_draft( request_event_id, @@ -19829,6 +19976,21 @@ mod tests { .expect("order status should load") } + fn set_persisted_order_status(runtime: &DesktopAppRuntime, order_id: OrderId, status: &str) { + let order_id = order_id.to_string(); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute( + "update orders set status = ?1 where id = ?2", + [status, order_id.as_str()], + ) + .expect("order status should update"); + } + fn pending_order_sync_payloads( runtime: &DesktopAppRuntime, account_id: &str, diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -810,7 +810,7 @@ const FORBIDDEN_HARDCODED_WORKFLOW_UI_LITERALS: &[&str] = &[ "Unknown", ]; -const FORBIDDEN_STALE_SELLER_LIFECYCLE_WINDOW_PATTERNS: &[&str] = &[ +const FORBIDDEN_STALE_SELLER_LIFECYCLE_PATTERNS: &[&str] = &[ concat!("orders-detail-", "mark-packed"), concat!("orders-detail-", "mark-completed"), concat!("orders-row-action-", "mark-packed"), @@ -818,9 +818,14 @@ const FORBIDDEN_STALE_SELLER_LIFECYCLE_WINDOW_PATTERNS: &[&str] = &[ concat!("orders.", "mark_delivered_failed"), concat!("OrderPrimaryAction::", "MarkPacked"), concat!("OrderPrimaryAction::", "MarkCompleted"), + concat!("mark", "_packed"), + concat!("mark", "_completed"), concat!("AppTextKey::", "OrdersStatus", "Packed"), concat!("AppTextKey::", "OrdersAction", "MarkPacked"), concat!("AppTextKey::", "OrdersAction", "MarkCompleted"), + concat!("orders.status.", "packed"), + concat!("orders.action.", "mark_packed"), + concat!("orders.action.", "mark_completed"), ]; const FORBIDDEN_PAYMENT_DEFERRAL_COPY_PATTERNS: &[&str] = &[ @@ -913,14 +918,15 @@ fn desktop_window_source_does_not_reintroduce_removed_ui_helper_families() { } #[test] -fn desktop_window_source_uses_publish_lifecycle_action_identifiers() { - let source = include_str!("window.rs"); - - for pattern in FORBIDDEN_STALE_SELLER_LIFECYCLE_WINDOW_PATTERNS { - assert!( - !source.contains(pattern), - "window.rs still contains stale seller lifecycle action pattern `{pattern}`" - ); +fn app_sources_use_publish_lifecycle_action_identifiers() { + for (path, source) in seller_lifecycle_action_owner_sources() { + for pattern in FORBIDDEN_STALE_SELLER_LIFECYCLE_PATTERNS { + assert!( + !source.contains(pattern), + "{} still contains stale seller lifecycle action pattern `{pattern}`", + path.display() + ); + } } } @@ -1047,6 +1053,34 @@ fn launcher_source_files() -> Vec<(PathBuf, String)> { .collect() } +fn seller_lifecycle_action_owner_sources() -> Vec<(PathBuf, String)> { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let app_root = manifest_dir + .parent() + .and_then(|path| path.parent()) + .expect("desktop crate should live under app crates directory"); + [ + manifest_dir.join("src/window.rs"), + manifest_dir.join("src/runtime.rs"), + app_root.join("crates/view/src/lib.rs"), + app_root.join("crates/store/src/repo/orders.rs"), + app_root.join("crates/i18n/src/keys.rs"), + app_root.join("crates/i18n/src/lib.rs"), + app_root.join("i18n/locales/en/messages.json"), + ] + .into_iter() + .map(|path| { + let source = fs::read_to_string(&path).unwrap_or_else(|error| { + panic!( + "failed to read seller lifecycle source {}: {error}", + path.display() + ) + }); + (path, source) + }) + .collect() +} + fn collect_rust_source_files(root: &Path, paths: &mut Vec<PathBuf>) { let entries = fs::read_dir(root).unwrap_or_else(|error| { panic!(