app

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

commit 1b38bcb73ddc704114141754a44bc0179ff65b12
parent 834af4b874f3995d9bd3222485c703b12d43afd5
Author: triesap <tyson@radroots.org>
Date:   Tue, 26 May 2026 01:08:47 +0000

orders: add declined order projection

Diffstat:
Mcrates/launchers/desktop/src/window.rs | 6++++--
Mcrates/shared/i18n/src/keys.rs | 2++
Mcrates/shared/i18n/src/lib.rs | 5+++++
Mcrates/shared/models/src/lib.rs | 17++++++++++++++++-
Acrates/shared/sqlite/migrations/0020_declined_order_status.sql | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/shared/sqlite/src/buyer.rs | 1+
Mcrates/shared/sqlite/src/lib.rs | 147++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/shared/sqlite/src/local_interop.rs | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/shared/sqlite/src/migrations.rs | 4++++
Mcrates/shared/sqlite/src/orders.rs | 5+++--
Mi18n/locales/en/messages.json | 2++
11 files changed, 513 insertions(+), 11 deletions(-)

diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -8859,6 +8859,7 @@ fn buyer_orders_status_key(status: BuyerOrderStatus) -> AppTextKey { BuyerOrderStatus::Scheduled => AppTextKey::PersonalOrdersStatusScheduled, BuyerOrderStatus::Ready => AppTextKey::PersonalOrdersStatusReady, BuyerOrderStatus::Completed => AppTextKey::PersonalOrdersStatusCompleted, + BuyerOrderStatus::Declined => AppTextKey::PersonalOrdersStatusDeclined, BuyerOrderStatus::Refunded => AppTextKey::PersonalOrdersStatusRefunded, } } @@ -8869,7 +8870,7 @@ fn buyer_orders_status_color(status: BuyerOrderStatus) -> u32 { BuyerOrderStatus::Scheduled | BuyerOrderStatus::Ready => { APP_UI_THEME.components.app_status_indicator.online } - BuyerOrderStatus::Completed | BuyerOrderStatus::Refunded => { + BuyerOrderStatus::Completed | BuyerOrderStatus::Declined | BuyerOrderStatus::Refunded => { APP_UI_THEME.components.app_status_indicator.offline } } @@ -10143,6 +10144,7 @@ fn orders_status_key(status: OrderStatus) -> AppTextKey { OrderStatus::Scheduled => AppTextKey::OrdersStatusScheduled, OrderStatus::Packed => AppTextKey::OrdersStatusPacked, OrderStatus::Completed => AppTextKey::OrdersStatusCompleted, + OrderStatus::Declined => AppTextKey::OrdersStatusDeclined, OrderStatus::Refunded => AppTextKey::OrdersStatusRefunded, } } @@ -10153,7 +10155,7 @@ fn orders_status_color(status: OrderStatus) -> u32 { OrderStatus::Scheduled | OrderStatus::Packed => { APP_UI_THEME.components.app_status_indicator.online } - OrderStatus::Completed | OrderStatus::Refunded => { + OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => { APP_UI_THEME.components.app_status_indicator.offline } } diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -151,6 +151,7 @@ define_app_text_keys! { PersonalOrdersStatusScheduled => "personal.orders.status.scheduled", PersonalOrdersStatusReady => "personal.orders.status.ready", PersonalOrdersStatusCompleted => "personal.orders.status.completed", + PersonalOrdersStatusDeclined => "personal.orders.status.declined", PersonalOrdersStatusRefunded => "personal.orders.status.refunded", PersonalCartSurfaceBody => "personal.cart.surface.body", PersonalOrderSummaryTitle => "personal.order_summary.title", @@ -187,6 +188,7 @@ define_app_text_keys! { OrdersStatusScheduled => "orders.status.scheduled", OrdersStatusPacked => "orders.status.packed", OrdersStatusCompleted => "orders.status.completed", + OrdersStatusDeclined => "orders.status.declined", OrdersStatusRefunded => "orders.status.refunded", OrdersTableTitle => "orders.table.title", OrdersColumnOrder => "orders.column.order", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -297,6 +297,7 @@ mod tests { app_text(AppTextKey::OrdersStatusNeedsAction), "Needs action" ); + assert_eq!(app_text(AppTextKey::OrdersStatusDeclined), "Declined"); assert_eq!(app_text(AppTextKey::OrdersActionMarkPacked), "Mark packed"); assert_eq!( app_text(AppTextKey::OrdersActionMarkCompleted), @@ -397,6 +398,10 @@ mod tests { "Completed" ); assert_eq!( + app_text(AppTextKey::PersonalOrdersStatusDeclined), + "Declined" + ); + assert_eq!( app_text(AppTextKey::PersonalOrdersStatusRefunded), "Refunded" ); diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -1361,6 +1361,7 @@ pub enum OrderStatus { Scheduled, Packed, Completed, + Declined, Refunded, } @@ -1371,6 +1372,7 @@ impl OrderStatus { Self::Scheduled => "scheduled", Self::Packed => "packed", Self::Completed => "completed", + Self::Declined => "declined", Self::Refunded => "refunded", } } @@ -1383,6 +1385,7 @@ pub enum BuyerOrderStatus { Scheduled, Ready, Completed, + Declined, Refunded, } @@ -1393,6 +1396,7 @@ impl BuyerOrderStatus { Self::Scheduled => "scheduled", Self::Ready => "ready", Self::Completed => "completed", + Self::Declined => "declined", Self::Refunded => "refunded", } } @@ -1405,6 +1409,7 @@ impl From<OrderStatus> for BuyerOrderStatus { OrderStatus::Scheduled => Self::Scheduled, OrderStatus::Packed => Self::Ready, OrderStatus::Completed => Self::Completed, + OrderStatus::Declined => Self::Declined, OrderStatus::Refunded => Self::Refunded, } } @@ -1891,7 +1896,7 @@ impl PackDayOutputOrderState { OrderStatus::NeedsAction => Some(Self::NeedsAction), OrderStatus::Scheduled => Some(Self::Scheduled), OrderStatus::Packed => Some(Self::Packed), - OrderStatus::Completed | OrderStatus::Refunded => None, + OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => None, } } } @@ -3135,11 +3140,13 @@ mod tests { assert_eq!(OrderStatus::Scheduled.storage_key(), "scheduled"); assert_eq!(OrderStatus::Packed.storage_key(), "packed"); assert_eq!(OrderStatus::Completed.storage_key(), "completed"); + assert_eq!(OrderStatus::Declined.storage_key(), "declined"); assert_eq!(OrderStatus::Refunded.storage_key(), "refunded"); 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::from(OrderStatus::NeedsAction), @@ -3149,6 +3156,10 @@ mod tests { BuyerOrderStatus::from(OrderStatus::Packed), BuyerOrderStatus::Ready ); + assert_eq!( + BuyerOrderStatus::from(OrderStatus::Declined), + BuyerOrderStatus::Declined + ); assert_eq!(OrdersFilter::default(), OrdersFilter::NeedsAction); assert_eq!(OrdersFilter::All.storage_key(), "all"); @@ -3398,6 +3409,10 @@ mod tests { None ); assert_eq!( + PackDayOutputOrderState::from_order_status(OrderStatus::Declined), + None + ); + assert_eq!( PackDayOutputOrderState::from_order_status(OrderStatus::Refunded), None ); diff --git a/crates/shared/sqlite/migrations/0020_declined_order_status.sql b/crates/shared/sqlite/migrations/0020_declined_order_status.sql @@ -0,0 +1,163 @@ +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_legacy; +ALTER TABLE buyer_order_coordination_records RENAME TO buyer_order_coordination_records_legacy; +ALTER TABLE orders RENAME TO orders_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') + ), + 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 '' +); + +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 +) +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 +FROM orders_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_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_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_legacy; +DROP TABLE buyer_order_coordination_records_legacy; +DROP TABLE orders_legacy; diff --git a/crates/shared/sqlite/src/buyer.rs b/crates/shared/sqlite/src/buyer.rs @@ -2580,6 +2580,7 @@ fn parse_order_status(field: &'static str, value: String) -> Result<OrderStatus, "scheduled" => Ok(OrderStatus::Scheduled), "packed" => Ok(OrderStatus::Packed), "completed" => Ok(OrderStatus::Completed), + "declined" => Ok(OrderStatus::Declined), "refunded" => Ok(OrderStatus::Refunded), _ => Err(AppSqliteError::DecodeEnum { field, value }), } diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -802,7 +802,7 @@ fn apply_migrations(connection: &mut Connection) -> Result<(), AppSqliteError> { #[cfg(test)] mod tests { use super::{AppSqliteStore, DatabaseTarget, latest_schema_version, migrations}; - use rusqlite::Connection; + use rusqlite::{Connection, params}; use std::{ env, fs, path::PathBuf, @@ -1062,6 +1062,135 @@ mod tests { remove_database_artifacts(&path); } + #[test] + fn legacy_orders_status_migration_preserves_child_rows_and_accepts_declined() { + let path = temp_database_path("legacy-declined-orders"); + fs::create_dir_all(path.parent().expect("temp database should have a parent")) + .expect("legacy database parent should exist"); + let connection = Connection::open(&path).expect("legacy database should open"); + connection + .execute_batch("PRAGMA foreign_keys = ON") + .expect("foreign keys should enable"); + + for (version, sql) in migrations::pending_migrations(0).filter(|(version, _)| *version < 20) + { + connection + .execute_batch(sql) + .expect("legacy migration should apply"); + connection + .pragma_update(None, "user_version", version) + .expect("legacy schema version should record"); + } + + connection + .execute( + "INSERT INTO farms (id, display_name, readiness, created_at, updated_at) + VALUES (?1, 'Legacy Farm', 'ready', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", + params!["farm_legacy"], + ) + .expect("legacy farm should insert"); + connection + .execute( + "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 + ) VALUES ( + 'order_legacy', + 'farm_legacy', + NULL, + 'R-900', + 'Legacy Buyer', + 'needs_action', + '2026-01-01T00:00:00Z', + 'account:buyer', + '', + '', + '' + )", + [], + ) + .expect("legacy order should insert"); + connection + .execute( + "INSERT INTO order_lines ( + id, + order_id, + title, + quantity_value, + quantity_display + ) VALUES ( + 'line_legacy', + 'order_legacy', + 'Legacy Eggs', + 2, + '2 each' + )", + [], + ) + .expect("legacy order line should insert"); + connection + .execute( + "INSERT INTO buyer_order_coordination_records ( + order_id, + buyer_context_key, + state, + created_at, + updated_at + ) VALUES ( + 'order_legacy', + 'account:buyer', + 'pending', + '2026-01-01T00:00:00Z', + '2026-01-01T00:00:00Z' + )", + [], + ) + .expect("legacy buyer coordination should insert"); + + drop(connection); + + let store = + AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("store should open"); + let connection = store.connection(); + + assert_eq!( + store.schema_version().expect("schema version"), + latest_schema_version() + ); + assert_eq!(row_count(connection, "orders"), 1); + assert_eq!(row_count(connection, "order_lines"), 1); + assert_eq!(row_count(connection, "buyer_order_coordination_records"), 1); + assert_eq!(foreign_key_violation_count(connection), 0); + + connection + .execute( + "UPDATE orders SET status = 'declined' WHERE id = 'order_legacy'", + [], + ) + .expect("declined status should satisfy migrated check"); + + let status: String = connection + .query_row( + "SELECT status FROM orders WHERE id = 'order_legacy'", + [], + |row| row.get(0), + ) + .expect("status should load"); + assert_eq!(status, "declined"); + + drop(store); + remove_database_artifacts(&path); + } + fn table_exists(connection: &Connection, table_name: &str) -> bool { connection .query_row( @@ -1102,6 +1231,22 @@ mod tests { false } + fn foreign_key_violation_count(connection: &Connection) -> usize { + let mut statement = connection + .prepare("PRAGMA foreign_key_check") + .expect("foreign key check should prepare"); + let mut rows = statement.query([]).expect("foreign key check should run"); + let mut count = 0; + while rows + .next() + .expect("foreign key check row should load") + .is_some() + { + count += 1; + } + count + } + fn pragma_i64(connection: &Connection, pragma_name: &str) -> i64 { let sql = format!("PRAGMA {pragma_name}"); connection diff --git a/crates/shared/sqlite/src/local_interop.rs b/crates/shared/sqlite/src/local_interop.rs @@ -720,7 +720,7 @@ impl<'a> AppLocalInteropRepository<'a> { order_number = excluded.order_number, customer_display_name = excluded.customer_display_name, status = CASE - WHEN orders.status IN ('scheduled', 'packed', 'completed', 'refunded') + WHEN orders.status IN ('scheduled', 'packed', 'completed', 'declined', 'refunded') THEN orders.status ELSE excluded.status END, @@ -753,7 +753,7 @@ impl<'a> AppLocalInteropRepository<'a> { .execute( "UPDATE orders SET status = CASE - WHEN status IN ('packed', 'completed', 'refunded') THEN status + WHEN status IN ('packed', 'completed', 'declined', 'refunded') THEN status ELSE 'scheduled' END, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') @@ -765,7 +765,23 @@ impl<'a> AppLocalInteropRepository<'a> { source, })?; } - RadrootsTradeOrderDecision::Declined { .. } => {} + RadrootsTradeOrderDecision::Declined { .. } => { + self.connection + .execute( + "UPDATE orders + SET status = CASE + WHEN status IN ('packed', 'completed', 'refunded') THEN status + ELSE 'declined' + END, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ?1", + params![order_id.to_string()], + ) + .map_err(|source| AppSqliteError::Query { + operation: "apply local interop order decision", + source, + })?; + } } Ok(()) } @@ -2449,7 +2465,7 @@ mod tests { } } - fn order_decision_payload( + fn accepted_order_decision_payload( order_id: &str, listing_addr: &str, buyer_pubkey: &str, @@ -2469,6 +2485,23 @@ mod tests { } } + fn declined_order_decision_payload( + order_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + ) -> RadrootsTradeOrderDecisionEvent { + RadrootsTradeOrderDecisionEvent { + order_id: order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + decision: RadrootsTradeOrderDecision::Declined { + reason: "not available for this pickup".to_owned(), + }, + } + } + fn event_from_parts(event_id: &str, author: &str, parts: WireEventParts) -> RadrootsNostrEvent { RadrootsNostrEvent { id: event_id.to_owned(), @@ -2695,7 +2728,7 @@ mod tests { assert_eq!(buyer_orders.rows[0].order_id, order_id); assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Placed); - let decision_payload = order_decision_payload( + let decision_payload = accepted_order_decision_payload( order_id_raw, listing_addr.as_str(), buyer_pubkey, @@ -2747,6 +2780,135 @@ mod tests { } #[test] + fn app_origin_signed_order_request_and_decline_project_to_buyer_orders() { + let app_store = + AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); + let events = local_events_store(); + let farm_key = "CCCCCCCCCCCCCCCCCCCCCC"; + let listing_key = "AAAAAAAAAAAAAAAAAAAAAg"; + let seller_pubkey = "seller-pubkey"; + let buyer_pubkey = "app-buyer-pubkey"; + let order_id_raw = "app-relay-order-declined-1"; + let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); + events + .append_record(&signed_market_listing_record( + "buyer-order-decline-listing", + seller_pubkey, + farm_key, + listing_key, + "Buyer Order 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, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let request_parts = active_trade_order_request_event_build( + &listing_event_ptr("buyer-order-decline-listing-event"), + &request_payload, + ) + .expect("build order request event"); + let request_event = event_from_parts( + "buyer-order-decline-request-event", + buyer_pubkey, + request_parts, + ); + events + .append_record(&signed_order_event_record( + "app:signed_event:order-request:buyer-declined", + &request_event, + listing_addr.as_str(), + SourceRuntime::App, + Some("acct_buyer"), + )) + .expect("append app order request"); + + let request_report = app_store + .import_shared_local_events_from_store(&events) + .expect("import app order request"); + let buyer_context = BuyerContext::account("acct_buyer"); + let order_id = projected_order_id(order_id_raw, buyer_pubkey); + let buyer_orders = app_store + .load_buyer_orders(&buyer_context) + .expect("load buyer orders after request"); + + assert_eq!(request_report.imported_records, 1); + assert_eq!(buyer_orders.rows.len(), 1); + assert_eq!(buyer_orders.rows[0].order_id, order_id); + assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Placed); + + let decision_payload = declined_order_decision_payload( + order_id_raw, + listing_addr.as_str(), + buyer_pubkey, + seller_pubkey, + ); + let decision_parts = active_trade_order_decision_event_build( + request_event.id.as_str(), + request_event.id.as_str(), + &decision_payload, + ) + .expect("build declined order decision event"); + let decision_event = event_from_parts( + "buyer-order-decline-decision-event", + seller_pubkey, + decision_parts, + ); + events + .append_record(&signed_order_event_record( + "cli:signed_event:order-decision:buyer-declined", + &decision_event, + listing_addr.as_str(), + SourceRuntime::Cli, + None, + )) + .expect("append declined order decision"); + + let decision_report = app_store + .import_shared_local_events_from_store(&events) + .expect("import declined order decision"); + let buyer_orders = app_store + .load_buyer_orders(&buyer_context) + .expect("load buyer orders after declined decision"); + let buyer_detail = app_store + .load_buyer_order_detail(&buyer_context, order_id) + .expect("load buyer order detail") + .expect("buyer order detail"); + let seller_orders = app_store + .load_orders_list( + deterministic_farm_id(Some(seller_pubkey), farm_key), + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load seller orders after declined decision"); + + assert_eq!(decision_report.imported_records, 1); + assert_eq!(buyer_orders.rows.len(), 1); + assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Declined); + assert_eq!(buyer_detail.status, BuyerOrderStatus::Declined); + assert_eq!(seller_orders.rows[0].status, OrderStatus::Declined); + assert_eq!(seller_orders.summary.needs_action_orders, 0); + assert_eq!(seller_orders.summary.scheduled_orders, 0); + assert_eq!(seller_orders.summary.packed_orders, 0); + assert!(seller_orders.rows[0].primary_action.is_none()); + } + + #[test] fn malformed_order_event_remains_signed_event_evidence_without_projection() { let app_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("open app sqlite store"); diff --git a/crates/shared/sqlite/src/migrations.rs b/crates/shared/sqlite/src/migrations.rs @@ -80,6 +80,10 @@ const MIGRATIONS: &[Migration] = &[ version: 19, sql: include_str!("../migrations/0019_relay_ingest_freshness.sql"), }, + Migration { + version: 20, + sql: include_str!("../migrations/0020_declined_order_status.sql"), + }, ]; pub fn latest_schema_version() -> u32 { diff --git a/crates/shared/sqlite/src/orders.rs b/crates/shared/sqlite/src/orders.rs @@ -920,7 +920,7 @@ fn summarize_orders(records: &[OrderRecord]) -> OrdersListSummary { OrderStatus::NeedsAction => summary.needs_action_orders += 1, OrderStatus::Scheduled => summary.scheduled_orders += 1, OrderStatus::Packed => summary.packed_orders += 1, - OrderStatus::Completed | OrderStatus::Refunded => {} + OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => {} } } @@ -932,7 +932,7 @@ fn primary_action_for_status(status: OrderStatus) -> Option<OrderPrimaryAction> OrderStatus::NeedsAction => Some(OrderPrimaryAction::Review), OrderStatus::Scheduled => Some(OrderPrimaryAction::MarkPacked), OrderStatus::Packed => Some(OrderPrimaryAction::MarkCompleted), - OrderStatus::Completed | OrderStatus::Refunded => None, + OrderStatus::Completed | OrderStatus::Declined | OrderStatus::Refunded => None, } } @@ -969,6 +969,7 @@ fn parse_order_status(field: &'static str, value: String) -> Result<OrderStatus, "scheduled" => Ok(OrderStatus::Scheduled), "packed" => Ok(OrderStatus::Packed), "completed" => Ok(OrderStatus::Completed), + "declined" => Ok(OrderStatus::Declined), "refunded" => Ok(OrderStatus::Refunded), _ => Err(AppSqliteError::DecodeEnum { field, value }), } diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -131,6 +131,7 @@ "personal.orders.status.scheduled": "Scheduled", "personal.orders.status.ready": "Ready", "personal.orders.status.completed": "Completed", + "personal.orders.status.declined": "Declined", "personal.orders.status.refunded": "Refunded", "personal.cart.surface.body": "Review items from one farm and continue to checkout when you're ready.", "personal.order_summary.title": "Order summary", @@ -167,6 +168,7 @@ "orders.status.scheduled": "Scheduled", "orders.status.packed": "Packed", "orders.status.completed": "Completed", + "orders.status.declined": "Declined", "orders.status.refunded": "Refunded", "orders.table.title": "Order queue", "orders.column.order": "Order",