app

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

commit 806fc887e390f8d4166639421f4ed6d5db638677
parent e17ba7818e59a9e5b1f0d846d72be34a9defab25
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 21:17:29 -0700

app: add passive payment display states

Diffstat:
Mcrates/desktop/src/source_guards.rs | 4++++
Mcrates/desktop/src/window.rs | 39+++++++++++++++++++++++++++++++++++++++
Mcrates/i18n/src/keys.rs | 2++
Mcrates/i18n/src/lib.rs | 15+++++++++++++++
Acrates/store/migrations/0024_order_workflow_payment_display_states.sql | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/store/src/interop.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/store/src/lib.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/store/src/migrations.rs | 4++++
Mcrates/store/src/repo/workflow.rs | 41+++++++++++++++++++++++++++++++++++++++++
Mcrates/view/src/lib.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mi18n/locales/en/messages.json | 2++
11 files changed, 588 insertions(+), 8 deletions(-)

diff --git a/crates/desktop/src/source_guards.rs b/crates/desktop/src/source_guards.rs @@ -505,7 +505,9 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::TradeWorkflowInventorySoldOut", "AppTextKey::TradeWorkflowInventoryNeedsReview", "AppTextKey::TradeWorkflowPaymentNotRecorded", + "AppTextKey::TradeWorkflowPaymentPending", "AppTextKey::TradeWorkflowPaymentRecorded", + "AppTextKey::TradeWorkflowPaymentSettled", "AppTextKey::TradeWorkflowPaymentNeedsReview", "AppTextKey::TradeWorkflowProvenanceApp", "AppTextKey::TradeWorkflowProvenanceCli", @@ -802,7 +804,9 @@ const FORBIDDEN_HARDCODED_WORKFLOW_UI_LITERALS: &[&str] = &[ "Reserved", "Sold out", "Not recorded", + "Pending", "Recorded", + "Settled", "App", "CLI", "Relay", diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs @@ -8798,7 +8798,9 @@ fn trade_inventory_status_key(status: TradeInventoryStatus) -> AppTextKey { fn trade_payment_display_status_key(status: TradePaymentDisplayStatus) -> AppTextKey { match status { TradePaymentDisplayStatus::NotRecorded => AppTextKey::TradeWorkflowPaymentNotRecorded, + TradePaymentDisplayStatus::Pending => AppTextKey::TradeWorkflowPaymentPending, TradePaymentDisplayStatus::Recorded => AppTextKey::TradeWorkflowPaymentRecorded, + TradePaymentDisplayStatus::Settled => AppTextKey::TradeWorkflowPaymentSettled, TradePaymentDisplayStatus::NeedsReview => AppTextKey::TradeWorkflowPaymentNeedsReview, } } @@ -13818,10 +13820,18 @@ mod tests { AppTextKey::TradeWorkflowPaymentNotRecorded, ), ( + TradePaymentDisplayStatus::Pending, + AppTextKey::TradeWorkflowPaymentPending, + ), + ( TradePaymentDisplayStatus::Recorded, AppTextKey::TradeWorkflowPaymentRecorded, ), ( + TradePaymentDisplayStatus::Settled, + AppTextKey::TradeWorkflowPaymentSettled, + ), + ( TradePaymentDisplayStatus::NeedsReview, AppTextKey::TradeWorkflowPaymentNeedsReview, ), @@ -13858,6 +13868,35 @@ mod tests { } #[test] + fn trade_payment_display_status_keys_cover_passive_states() { + for (status, key) in [ + ( + TradePaymentDisplayStatus::NotRecorded, + AppTextKey::TradeWorkflowPaymentNotRecorded, + ), + ( + TradePaymentDisplayStatus::Pending, + AppTextKey::TradeWorkflowPaymentPending, + ), + ( + TradePaymentDisplayStatus::Recorded, + AppTextKey::TradeWorkflowPaymentRecorded, + ), + ( + TradePaymentDisplayStatus::Settled, + AppTextKey::TradeWorkflowPaymentSettled, + ), + ( + TradePaymentDisplayStatus::NeedsReview, + AppTextKey::TradeWorkflowPaymentNeedsReview, + ), + ] { + assert_eq!(trade_payment_display_status_key(status), key); + assert!(!app_text(key).is_empty()); + } + } + + #[test] fn today_route_has_no_setup_onboarding_card() { assert!(farm_setup_onboarding_card_spec(HomeRoute::Today).is_none()); } diff --git a/crates/i18n/src/keys.rs b/crates/i18n/src/keys.rs @@ -258,7 +258,9 @@ define_app_text_keys! { TradeWorkflowInventorySoldOut => "trade.workflow.inventory.sold_out", TradeWorkflowInventoryNeedsReview => "trade.workflow.inventory.needs_review", TradeWorkflowPaymentNotRecorded => "trade.workflow.payment.not_recorded", + TradeWorkflowPaymentPending => "trade.workflow.payment.pending", TradeWorkflowPaymentRecorded => "trade.workflow.payment.recorded", + TradeWorkflowPaymentSettled => "trade.workflow.payment.settled", TradeWorkflowPaymentNeedsReview => "trade.workflow.payment.needs_review", TradeWorkflowProvenanceApp => "trade.workflow.provenance.app", TradeWorkflowProvenanceCli => "trade.workflow.provenance.cli", diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs @@ -547,10 +547,12 @@ mod tests { app_text(AppTextKey::TradeWorkflowPaymentNotRecorded), "Not recorded" ); + assert_eq!(app_text(AppTextKey::TradeWorkflowPaymentPending), "Pending"); assert_eq!( app_text(AppTextKey::TradeWorkflowPaymentRecorded), "Recorded" ); + assert_eq!(app_text(AppTextKey::TradeWorkflowPaymentSettled), "Settled"); assert_eq!(app_text(AppTextKey::TradeWorkflowProvenanceCli), "CLI"); assert_eq!( app_text(AppTextKey::TradeWorkflowProvenanceLocalEvents), @@ -559,6 +561,19 @@ mod tests { } #[test] + fn payment_workflow_copy_covers_passive_statuses() { + for (key, expected) in [ + (AppTextKey::TradeWorkflowPaymentNotRecorded, "Not recorded"), + (AppTextKey::TradeWorkflowPaymentPending, "Pending"), + (AppTextKey::TradeWorkflowPaymentRecorded, "Recorded"), + (AppTextKey::TradeWorkflowPaymentSettled, "Settled"), + (AppTextKey::TradeWorkflowPaymentNeedsReview, "Needs review"), + ] { + assert_eq!(app_text(key), expected); + } + } + + #[test] fn english_marketplace_orders_copy_matches_the_buyer_history_contract() { assert_eq!( app_text(AppTextKey::PersonalOrdersSurfaceBody), diff --git a/crates/store/migrations/0024_order_workflow_payment_display_states.sql b/crates/store/migrations/0024_order_workflow_payment_display_states.sql @@ -0,0 +1,196 @@ +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_payment_display_legacy; +ALTER TABLE buyer_order_coordination_records RENAME TO buyer_order_coordination_records_payment_display_legacy; +ALTER TABLE orders RENAME TO orders_payment_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') + ), + 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_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_payment_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_payment_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_payment_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_payment_display_legacy; +DROP TABLE buyer_order_coordination_records_payment_display_legacy; +DROP TABLE orders_payment_display_legacy; diff --git a/crates/store/src/interop.rs b/crates/store/src/interop.rs @@ -3651,6 +3651,7 @@ mod tests { RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, RadrootsTradePaymentMethod, RadrootsTradePaymentRecorded, RadrootsTradePricingBasis, + RadrootsTradeSettlementDecision, RadrootsTradeSettlementDecisionEvent, }, }; use radroots_events_codec::{ @@ -3661,6 +3662,7 @@ mod tests { active_trade_order_revision_decision_event_build, active_trade_order_revision_proposal_event_build, active_trade_payment_recorded_event_build, + active_trade_settlement_decision_event_build, }, wire::WireEventParts, }; @@ -4343,6 +4345,36 @@ mod tests { } } + fn settlement_decision_payload( + request: &RadrootsTradeOrderRequested, + root_event_id: &str, + previous_event_id: &str, + agreement_event_id: &str, + payment_event_id: &str, + decision: RadrootsTradeSettlementDecision, + ) -> RadrootsTradeSettlementDecisionEvent { + let reason = (decision == RadrootsTradeSettlementDecision::Rejected) + .then(|| "reference mismatch".to_owned()); + RadrootsTradeSettlementDecisionEvent { + order_id: request.order_id.clone(), + listing_addr: request.listing_addr.clone(), + buyer_pubkey: request.buyer_pubkey.clone(), + seller_pubkey: request.seller_pubkey.clone(), + root_event_id: root_event_id.to_owned(), + previous_event_id: previous_event_id.to_owned(), + agreement_event_id: agreement_event_id.to_owned(), + payment_event_id: payment_event_id.to_owned(), + quote_id: request.economics.quote_id.clone(), + quote_version: request.economics.quote_version, + economics_digest: radroots_trade_order_economics_digest(&request.economics) + .expect("order economics digest should encode"), + amount: request.economics.total.amount, + currency: request.economics.total.currency, + decision, + reason, + } + } + fn event_from_parts(event_id: &str, author: &str, parts: WireEventParts) -> RadrootsNostrEvent { RadrootsNostrEvent { id: event_id.to_owned(), @@ -4716,6 +4748,7 @@ mod tests { .expect("import app order request"); let buyer_context = BuyerContext::account("acct_buyer"); let order_id = projected_order_id(order_id_raw, buyer_pubkey); + let farm_id = deterministic_farm_id(Some(seller_pubkey), farm_key); let buyer_orders = app_store .load_buyer_orders(&buyer_context) .expect("load buyer orders after request"); @@ -4761,7 +4794,7 @@ mod tests { .expect("buyer order detail"); let seller_orders = app_store .load_orders_list( - deterministic_farm_id(Some(seller_pubkey), farm_key), + farm_id, &OrdersScreenQueryState { filter: OrdersFilter::All, fulfillment_window_id: None, @@ -4813,12 +4846,25 @@ mod tests { .load_buyer_order_detail(&buyer_context, order_id) .expect("load buyer order detail after payment") .expect("buyer order detail after payment"); + let seller_orders = app_store + .load_orders_list( + farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load seller orders after payment"); + let seller_detail = app_store + .load_order_detail(farm_id, order_id) + .expect("load seller order detail after payment") + .expect("seller order detail after payment"); assert_eq!(payment_report.imported_records, 1); assert_eq!(buyer_orders.rows[0].status, BuyerOrderStatus::Scheduled); assert_eq!( buyer_orders.rows[0].workflow.payment, - TradePaymentDisplayStatus::Recorded + TradePaymentDisplayStatus::Pending ); assert_eq!( buyer_orders.rows[0].workflow.provenance.primary_source, @@ -4832,8 +4878,78 @@ mod tests { .as_deref(), Some(decision_event.id.as_str()) ); - assert_eq!(buyer_detail.payment, TradePaymentDisplayStatus::Recorded); + assert_eq!(buyer_detail.payment, TradePaymentDisplayStatus::Pending); assert_eq!(buyer_detail.workflow, buyer_orders.rows[0].workflow); + assert_eq!( + seller_orders.rows[0].workflow.payment, + TradePaymentDisplayStatus::Pending + ); + assert_eq!(seller_detail.payment, TradePaymentDisplayStatus::Pending); + + let settlement_payload = settlement_decision_payload( + &request_payload, + request_event.id.as_str(), + payment_event.id.as_str(), + decision_event.id.as_str(), + payment_event.id.as_str(), + RadrootsTradeSettlementDecision::Accepted, + ); + let settlement_parts = active_trade_settlement_decision_event_build( + request_event.id.as_str(), + payment_event.id.as_str(), + &settlement_payload, + ) + .expect("build settlement decision event"); + let settlement_event = event_from_parts( + "buyer-order-settlement-event", + seller_pubkey, + settlement_parts, + ); + events + .append_record(&signed_order_event_record( + "cli:signed_event:order-settlement:buyer", + &settlement_event, + listing_addr.as_str(), + SourceRuntime::Cli, + None, + )) + .expect("append settlement decision event"); + + let settlement_report = app_store + .import_shared_local_events_from_store(&events) + .expect("import settlement decision event"); + let buyer_orders = app_store + .load_buyer_orders(&buyer_context) + .expect("load buyer orders after settlement"); + let buyer_detail = app_store + .load_buyer_order_detail(&buyer_context, order_id) + .expect("load buyer order detail after settlement") + .expect("buyer order detail after settlement"); + let seller_orders = app_store + .load_orders_list( + farm_id, + &OrdersScreenQueryState { + filter: OrdersFilter::All, + fulfillment_window_id: None, + }, + ) + .expect("load seller orders after settlement"); + let seller_detail = app_store + .load_order_detail(farm_id, order_id) + .expect("load seller order detail after settlement") + .expect("seller order detail after settlement"); + + assert_eq!(settlement_report.imported_records, 1); + assert_eq!( + buyer_orders.rows[0].workflow.payment, + TradePaymentDisplayStatus::Settled + ); + assert_eq!(buyer_detail.payment, TradePaymentDisplayStatus::Settled); + assert_eq!( + seller_orders.rows[0].workflow.payment, + TradePaymentDisplayStatus::Settled + ); + assert_eq!(seller_detail.payment, TradePaymentDisplayStatus::Settled); } #[test] diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs @@ -1027,6 +1027,53 @@ mod tests { } #[test] + fn workflow_payment_display_schema_accepts_pending_and_settled_states() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let connection = store.connection(); + connection + .execute( + "INSERT INTO farms (id, display_name, readiness, created_at, updated_at) + VALUES (?1, 'Schema Farm', 'ready', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')", + params!["farm_schema"], + ) + .expect("farm should insert"); + + for (order_id, workflow_payment) in [ + ("order_payment_pending", "pending"), + ("order_payment_settled", "settled"), + ] { + connection + .execute( + "INSERT INTO orders ( + id, + farm_id, + order_number, + customer_display_name, + status, + updated_at, + workflow_payment + ) VALUES (?1, 'farm_schema', ?2, 'Buyer', 'scheduled', '2026-01-01T00:00:00Z', ?3)", + params![order_id, order_id, workflow_payment], + ) + .expect("expanded workflow payment state should insert"); + } + + let invalid_result = connection.execute( + "INSERT INTO orders ( + id, + farm_id, + order_number, + customer_display_name, + status, + updated_at, + workflow_payment + ) VALUES ('order_payment_invalid', 'farm_schema', 'invalid', 'Buyer', 'scheduled', '2026-01-01T00:00:00Z', 'collect')", + [], + ); + assert!(invalid_result.is_err()); + } + + #[test] fn legacy_sync_scaffolding_migrates_to_account_scoped_contract() { let path = temp_database_path("legacy-sync-contract"); fs::create_dir_all(path.parent().expect("temp database should have a parent")) diff --git a/crates/store/src/migrations.rs b/crates/store/src/migrations.rs @@ -96,6 +96,10 @@ const MIGRATIONS: &[Migration] = &[ version: 23, sql: include_str!("../migrations/0023_order_workflow_display_projection.sql"), }, + Migration { + version: 24, + sql: include_str!("../migrations/0024_order_workflow_payment_display_states.sql"), + }, ]; pub fn latest_schema_version() -> u32 { diff --git a/crates/store/src/repo/workflow.rs b/crates/store/src/repo/workflow.rs @@ -90,7 +90,9 @@ fn parse_trade_payment_display_status( ) -> Result<TradePaymentDisplayStatus, AppSqliteError> { match value.as_str() { "not_recorded" => Ok(TradePaymentDisplayStatus::NotRecorded), + "pending" => Ok(TradePaymentDisplayStatus::Pending), "recorded" => Ok(TradePaymentDisplayStatus::Recorded), + "settled" => Ok(TradePaymentDisplayStatus::Settled), "needs_review" => Ok(TradePaymentDisplayStatus::NeedsReview), _ => Err(AppSqliteError::DecodeEnum { field, value }), } @@ -109,3 +111,42 @@ fn parse_trade_workflow_source( _ => Err(AppSqliteError::DecodeEnum { field, value }), } } + +#[cfg(test)] +mod tests { + use super::parse_trade_payment_display_status; + use crate::AppSqliteError; + use radroots_app_view::TradePaymentDisplayStatus; + + #[test] + fn workflow_payment_display_parser_accepts_all_payment_states() { + for (stored, expected) in [ + ("not_recorded", TradePaymentDisplayStatus::NotRecorded), + ("pending", TradePaymentDisplayStatus::Pending), + ("recorded", TradePaymentDisplayStatus::Recorded), + ("settled", TradePaymentDisplayStatus::Settled), + ("needs_review", TradePaymentDisplayStatus::NeedsReview), + ] { + assert_eq!( + parse_trade_payment_display_status("orders.workflow_payment", stored.to_owned()) + .expect("workflow payment should parse"), + expected + ); + } + } + + #[test] + fn workflow_payment_display_parser_rejects_unknown_payment_state() { + let error = + parse_trade_payment_display_status("orders.workflow_payment", "unknown".to_owned()) + .expect_err("unknown workflow payment should reject"); + + match error { + AppSqliteError::DecodeEnum { field, value } => { + assert_eq!(field, "orders.workflow_payment"); + assert_eq!(value, "unknown"); + } + other => panic!("expected DecodeEnum error, got {other:?}"), + } + } +} diff --git a/crates/view/src/lib.rs b/crates/view/src/lib.rs @@ -5,7 +5,8 @@ pub use radroots_app_types::*; use radroots_core::RadrootsCoreMoney; use radroots_events::trade::{RadrootsActiveTradeFulfillmentState, RadrootsTradeOrderEconomics}; use radroots_trade::order::{ - RadrootsActiveOrderPaymentState, RadrootsActiveOrderProjection, RadrootsActiveOrderStatus, + RadrootsActiveOrderPaymentProjection, RadrootsActiveOrderPaymentState, + RadrootsActiveOrderProjection, RadrootsActiveOrderSettlementState, RadrootsActiveOrderStatus, }; use serde::{Deserialize, Serialize}; use std::{collections::BTreeSet, error::Error, fmt, str::FromStr}; @@ -1291,7 +1292,9 @@ impl TradeInventoryStatus { pub enum TradePaymentDisplayStatus { #[default] NotRecorded, + Pending, Recorded, + Settled, NeedsReview, } @@ -1299,7 +1302,9 @@ impl TradePaymentDisplayStatus { pub const fn storage_key(self) -> &'static str { match self { Self::NotRecorded => "not_recorded", + Self::Pending => "pending", Self::Recorded => "recorded", + Self::Settled => "settled", Self::NeedsReview => "needs_review", } } @@ -1307,7 +1312,9 @@ impl TradePaymentDisplayStatus { pub const fn label_key_id(self) -> &'static str { match self { Self::NotRecorded => "messages.trade.workflow.payment.not_recorded", + Self::Pending => "messages.trade.workflow.payment.pending", Self::Recorded => "messages.trade.workflow.payment.recorded", + Self::Settled => "messages.trade.workflow.payment.settled", Self::NeedsReview => "messages.trade.workflow.payment.needs_review", } } @@ -1319,12 +1326,45 @@ impl TradePaymentDisplayStatus { pub fn from_active_payment_state(status: &RadrootsActiveOrderPaymentState) -> Self { match status { RadrootsActiveOrderPaymentState::NotRecorded => Self::NotRecorded, - RadrootsActiveOrderPaymentState::Recorded - | RadrootsActiveOrderPaymentState::Settled => Self::Recorded, + RadrootsActiveOrderPaymentState::Recorded => Self::Recorded, + RadrootsActiveOrderPaymentState::Settled => Self::Settled, RadrootsActiveOrderPaymentState::Rejected | RadrootsActiveOrderPaymentState::Invalid => Self::NeedsReview, } } + + pub fn from_active_payment_projection(payment: &RadrootsActiveOrderPaymentProjection) -> Self { + match (&payment.state, &payment.settlement_state) { + ( + RadrootsActiveOrderPaymentState::NotRecorded, + RadrootsActiveOrderSettlementState::NotRequired, + ) => Self::NotRecorded, + (RadrootsActiveOrderPaymentState::NotRecorded, _) => Self::NeedsReview, + ( + RadrootsActiveOrderPaymentState::Recorded, + RadrootsActiveOrderSettlementState::Pending, + ) => Self::Pending, + ( + RadrootsActiveOrderPaymentState::Recorded, + RadrootsActiveOrderSettlementState::NotRequired, + ) => Self::Recorded, + ( + RadrootsActiveOrderPaymentState::Recorded, + RadrootsActiveOrderSettlementState::Accepted, + ) => Self::Settled, + ( + RadrootsActiveOrderPaymentState::Recorded, + RadrootsActiveOrderSettlementState::Rejected + | RadrootsActiveOrderSettlementState::Invalid, + ) => Self::NeedsReview, + (RadrootsActiveOrderPaymentState::Settled, _) => Self::Settled, + ( + RadrootsActiveOrderPaymentState::Rejected + | RadrootsActiveOrderPaymentState::Invalid, + _, + ) => Self::NeedsReview, + } + } } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] @@ -1476,7 +1516,7 @@ impl TradeWorkflowProjection { .unwrap_or_default(); workflow.inventory = TradeInventoryStatus::from_active_order_projection(projection); workflow.payment = - TradePaymentDisplayStatus::from_active_payment_state(&projection.payment.state); + TradePaymentDisplayStatus::from_active_payment_projection(&projection.payment); workflow.provenance = provenance.with_last_event_id(projection.last_event_id.clone()); workflow } @@ -2950,7 +2990,7 @@ mod tests { TradePaymentDisplayStatus::from_active_payment_state( &RadrootsActiveOrderPaymentState::Settled ), - TradePaymentDisplayStatus::Recorded + TradePaymentDisplayStatus::Settled ); assert_eq!( TradePaymentDisplayStatus::from_active_payment_state( @@ -3059,13 +3099,49 @@ mod tests { invalid_payment_projection.payment, TradePaymentDisplayStatus::NeedsReview ); + + let mut pending_payment_order = test_active_order_projection( + RadrootsActiveOrderStatus::Accepted, + None, + RadrootsActiveOrderPaymentState::Recorded, + ); + pending_payment_order.payment.settlement_state = + RadrootsActiveOrderSettlementState::Pending; + let pending_payment_projection = TradeWorkflowProjection::from_active_order_projection( + order_id, + &pending_payment_order, + TradeRevisionStatus::None, + TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::LocalEvents), + ); + assert_eq!( + pending_payment_projection.payment, + TradePaymentDisplayStatus::Pending + ); + + let settled_payment_order = test_active_order_projection( + RadrootsActiveOrderStatus::Completed, + None, + RadrootsActiveOrderPaymentState::Settled, + ); + let settled_payment_projection = TradeWorkflowProjection::from_active_order_projection( + order_id, + &settled_payment_order, + TradeRevisionStatus::None, + TradeProvenanceProjection::from_primary_source(TradeWorkflowSource::LocalEvents), + ); + assert_eq!( + settled_payment_projection.payment, + TradePaymentDisplayStatus::Settled + ); } #[test] fn trade_payment_display_statuses_do_not_enable_payment_actions() { for status in [ TradePaymentDisplayStatus::NotRecorded, + TradePaymentDisplayStatus::Pending, TradePaymentDisplayStatus::Recorded, + TradePaymentDisplayStatus::Settled, TradePaymentDisplayStatus::NeedsReview, ] { assert!(!status.allows_payment_action()); @@ -3073,6 +3149,34 @@ mod tests { } #[test] + fn trade_payment_display_projection_maps_reducer_payment_states() { + let mut pending = RadrootsActiveOrderPaymentProjection::not_recorded(); + pending.state = RadrootsActiveOrderPaymentState::Recorded; + pending.payment_event_id = Some("payment-event-1".to_owned()); + pending.settlement_state = RadrootsActiveOrderSettlementState::Pending; + assert_eq!( + TradePaymentDisplayStatus::from_active_payment_projection(&pending), + TradePaymentDisplayStatus::Pending + ); + + let mut settled = RadrootsActiveOrderPaymentProjection::not_recorded(); + settled.state = RadrootsActiveOrderPaymentState::Settled; + settled.payment_event_id = Some("payment-event-1".to_owned()); + settled.settlement_state = RadrootsActiveOrderSettlementState::Accepted; + assert_eq!( + TradePaymentDisplayStatus::from_active_payment_projection(&settled), + TradePaymentDisplayStatus::Settled + ); + + let mut inconsistent = RadrootsActiveOrderPaymentProjection::not_recorded(); + inconsistent.settlement_state = RadrootsActiveOrderSettlementState::Accepted; + assert_eq!( + TradePaymentDisplayStatus::from_active_payment_projection(&inconsistent), + TradePaymentDisplayStatus::NeedsReview + ); + } + + #[test] fn trade_workflow_projection_uses_localization_key_ids_for_visible_status_labels() { assert_eq!( TradeAgreementStatus::from_active_order_status(&RadrootsActiveOrderStatus::Requested) @@ -3100,6 +3204,8 @@ mod tests { TradePaymentDisplayStatus::NotRecorded.storage_key(), "not_recorded" ); + assert_eq!(TradePaymentDisplayStatus::Pending.storage_key(), "pending"); + assert_eq!(TradePaymentDisplayStatus::Settled.storage_key(), "settled"); assert_eq!( TradeWorkflowSource::LocalEvents.storage_key(), "local_events" @@ -3130,6 +3236,14 @@ mod tests { "messages.trade.workflow.payment.not_recorded" ); assert_eq!( + TradePaymentDisplayStatus::Pending.label_key_id(), + "messages.trade.workflow.payment.pending" + ); + assert_eq!( + TradePaymentDisplayStatus::Settled.label_key_id(), + "messages.trade.workflow.payment.settled" + ); + assert_eq!( TradeWorkflowSource::Cli.label_key_id(), "messages.trade.workflow.provenance.cli" ); diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -238,7 +238,9 @@ "trade.workflow.inventory.sold_out": "Sold out", "trade.workflow.inventory.needs_review": "Needs review", "trade.workflow.payment.not_recorded": "Not recorded", + "trade.workflow.payment.pending": "Pending", "trade.workflow.payment.recorded": "Recorded", + "trade.workflow.payment.settled": "Settled", "trade.workflow.payment.needs_review": "Needs review", "trade.workflow.provenance.app": "App", "trade.workflow.provenance.cli": "CLI",