commit 1b38bcb73ddc704114141754a44bc0179ff65b12
parent 834af4b874f3995d9bd3222485c703b12d43afd5
Author: triesap <tyson@radroots.org>
Date: Tue, 26 May 2026 01:08:47 +0000
orders: add declined order projection
Diffstat:
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",