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