cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 1bf34ff31134165995d533e9e4854a18b6417f10
parent c7101d18447013b0f197cf819fe46639512222ab
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Apr 2026 22:19:34 +0000

order: expose fulfillment status

Diffstat:
Msrc/domain/runtime.rs | 19+++++++++++++++++++
Msrc/runtime/order.rs | 320+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 334 insertions(+), 5 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1359,6 +1359,8 @@ pub struct OrderStatusView { pub last_event_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub inventory: Option<OrderInventoryView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub fulfillment: Option<OrderStatusFulfillmentView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reducer_issues: Vec<OrderIssueView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -1380,6 +1382,23 @@ pub struct OrderStatusView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderStatusFulfillmentView { + pub state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub root_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_event_id: Option<String>, + #[serde(default)] + pub terminal: bool, + #[serde(default)] + pub inventory_released: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, +} + +#[derive(Debug, Clone, Serialize)] pub struct OrderInventoryView { pub state: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -48,8 +48,8 @@ use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ OrderDecisionView, OrderDraftItemView, OrderFulfillmentView, OrderGetView, OrderHistoryEntryView, OrderHistoryView, OrderInventoryBinView, OrderInventoryView, - OrderIssueView, OrderListView, OrderNewView, OrderStatusView, OrderSubmitView, - OrderSummaryView, OrderWatchView, RelayFailureView, + OrderIssueView, OrderListView, OrderNewView, OrderStatusFulfillmentView, OrderStatusView, + OrderSubmitView, OrderSummaryView, OrderWatchView, RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -964,6 +964,7 @@ pub fn status( seller_pubkey: None, last_event_id: None, inventory: None, + fulfillment: None, reducer_issues: Vec::new(), target_relays: Vec::new(), connected_relays: Vec::new(), @@ -996,6 +997,7 @@ pub fn status( seller_pubkey: None, last_event_id: None, inventory: None, + fulfillment: None, reducer_issues: Vec::new(), target_relays, connected_relays: Vec::new(), @@ -1134,10 +1136,23 @@ fn order_status_reduction_from_receipt_with_context( }); let order_id = context.order_id; + let fulfillment_records = fulfillments.clone(); let projection = reduce_active_order_events(order_id, requests, decisions.clone(), fulfillments); let fulfillment_event_id = projection.fulfillment_event_id.clone(); let fulfillment_status = projection.fulfillment_status; + let fulfillment_root_event_id = fulfillment_event_id.as_ref().and_then(|event_id| { + fulfillment_records + .iter() + .find(|record| &record.event_id == event_id) + .map(|record| record.root_event_id.clone()) + }); + let fulfillment_prev_event_id = fulfillment_event_id.as_ref().and_then(|event_id| { + fulfillment_records + .iter() + .find(|record| &record.event_id == event_id) + .map(|record| record.prev_event_id.clone()) + }); let listing_event_id = projection .request_event_id .as_ref() @@ -1168,6 +1183,16 @@ fn order_status_reduction_from_receipt_with_context( &decisions, reducer_issues.as_slice(), ); + let fulfillment = order_status_fulfillment_view( + &projection.status, + projection.request_event_id.clone(), + projection.decision_event_id.clone(), + fulfillment_event_id.clone(), + fulfillment_root_event_id.clone(), + fulfillment_prev_event_id.clone(), + fulfillment_status, + reducer_issues.as_slice(), + ); let view = OrderStatusView { state, @@ -1181,6 +1206,7 @@ fn order_status_reduction_from_receipt_with_context( seller_pubkey: projection.seller_pubkey, last_event_id: projection.last_event_id, inventory, + fulfillment, reducer_issues, target_relays, connected_relays, @@ -1253,13 +1279,18 @@ fn enrich_order_status_inventory( .into_iter() .filter(|record| request_order_ids.contains(&record.payload.order_id)) .collect::<Vec<_>>(); + let fulfillments = + fetch_listing_accounting_fulfillments_for_status(config, listing_addr.as_str())? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); let projection = reduce_listing_inventory_accounting( listing_addr.as_str(), listing.event_id.as_str(), listing.bins, requests, decisions, - [], + fulfillments, ); let relevant_issues = projection .issues @@ -1275,9 +1306,18 @@ fn enrich_order_status_inventory( .collect::<Vec<_>>(); if relevant_issues.is_empty() { if view.state == "accepted" { + let inventory_state = if view + .fulfillment + .as_ref() + .is_some_and(|fulfillment| fulfillment.inventory_released) + { + "released" + } else { + "reserved" + }; view.inventory = Some(order_inventory_view_from_listing_projection( &projection, - "reserved", + inventory_state, true, )); } @@ -1359,6 +1399,27 @@ fn fetch_listing_accounting_decisions_for_status( Ok(records) } +fn fetch_listing_accounting_fulfillments_for_status( + config: &RuntimeConfig, + listing_addr: &str, +) -> Result<Vec<RadrootsActiveOrderFulfillmentRecord>, RuntimeError> { + let filter = order_listing_fulfillment_filter(listing_addr)?; + let receipt = fetch_events_from_relays(&config.relay.urls, filter) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + let mut records = Vec::new(); + for event in receipt.events { + if event_kind_u32(&event) != KIND_TRADE_FULFILLMENT_UPDATE + || !event_matches_tag_value(&event, "a", listing_addr) + { + continue; + } + if let Ok(OrderStatusRecord::Fulfillment(record)) = order_status_record_from_event(&event) { + records.push(record); + } + } + Ok(records) +} + fn listing_inventory_issue_involves_order( issue: &RadrootsListingInventoryAccountingIssue, order_id: &str, @@ -1602,6 +1663,83 @@ fn order_status_inventory_view( } } +fn order_status_fulfillment_view( + status: &RadrootsActiveOrderStatus, + request_event_id: Option<String>, + decision_event_id: Option<String>, + fulfillment_event_id: Option<String>, + fulfillment_root_event_id: Option<String>, + fulfillment_prev_event_id: Option<String>, + fulfillment_status: Option<RadrootsActiveTradeFulfillmentState>, + reducer_issues: &[OrderIssueView], +) -> Option<OrderStatusFulfillmentView> { + let issues = reducer_issues + .iter() + .filter(|issue| fulfillment_issue_code(issue.code.as_str())) + .cloned() + .collect::<Vec<_>>(); + if !issues.is_empty() { + return Some(OrderStatusFulfillmentView { + state: "invalid".to_owned(), + event_id: fulfillment_event_id, + root_event_id: fulfillment_root_event_id.or(request_event_id), + prev_event_id: fulfillment_prev_event_id, + terminal: false, + inventory_released: false, + issues, + }); + } + if !matches!(status, RadrootsActiveOrderStatus::Accepted) { + return None; + } + let fulfillment_status = fulfillment_status?; + let terminal = matches!( + fulfillment_status, + RadrootsActiveTradeFulfillmentState::Delivered + | RadrootsActiveTradeFulfillmentState::SellerCancelled + ); + let inventory_released = matches!( + fulfillment_status, + RadrootsActiveTradeFulfillmentState::SellerCancelled + ); + let prev_event_id = fulfillment_prev_event_id.or_else(|| { + if fulfillment_event_id.is_none() { + decision_event_id + } else { + None + } + }); + Some(OrderStatusFulfillmentView { + state: fulfillment_state_name(fulfillment_status).to_owned(), + event_id: fulfillment_event_id, + root_event_id: fulfillment_root_event_id.or(request_event_id), + prev_event_id, + terminal, + inventory_released, + issues, + }) +} + +fn fulfillment_issue_code(code: &str) -> bool { + matches!( + code, + "fulfillment_without_accepted_decision" + | "invalid_fulfillment_payload" + | "fulfillment_order_id_mismatch" + | "fulfillment_author_mismatch" + | "fulfillment_counterparty_mismatch" + | "fulfillment_buyer_mismatch" + | "fulfillment_seller_mismatch" + | "invalid_fulfillment_listing_address" + | "fulfillment_listing_mismatch" + | "fulfillment_root_mismatch" + | "fulfillment_previous_mismatch" + | "fulfillment_status_not_publishable" + | "fulfillment_unsupported_transition" + | "forked_fulfillments" + ) +} + fn inventory_bins_from_decision( decision: &RadrootsTradeOrderDecision, ) -> Vec<OrderInventoryBinView> { @@ -3317,6 +3455,16 @@ fn order_listing_decision_filter(listing_addr: &str) -> Result<RadrootsNostrFilt .map_err(|error| RuntimeError::Config(format!("build order decision filter: {error}"))) } +fn order_listing_fulfillment_filter( + listing_addr: &str, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_TRADE_FULFILLMENT_UPDATE as u16)) + .limit(1_000); + radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build fulfillment filter: {error}"))) +} + fn order_status_filter(order_id: &str) -> Result<RadrootsNostrFilter, RuntimeError> { let filter = RadrootsNostrFilter::new() .kinds([ @@ -5516,6 +5664,7 @@ mod tests { assert_eq!(view.decoded_count, 0); assert_eq!(view.skipped_count, 0); assert!(view.request_event_id.is_none()); + assert!(view.fulfillment.is_none()); assert!(view.reducer_issues.is_empty()); } @@ -5556,6 +5705,7 @@ mod tests { ); assert_eq!(view.decoded_count, 1); assert_eq!(view.skipped_count, 0); + assert!(view.fulfillment.is_none()); } #[test] @@ -5785,6 +5935,7 @@ mod tests { }; let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); + let request_event_id = fixture.request_event.id.to_string(); let decision_event_id = decision_event.id.to_string(); assert_eq!(view.state, "accepted"); @@ -5810,6 +5961,20 @@ mod tests { assert_eq!(inventory.bins.len(), 1); assert_eq!(inventory.bins[0].bin_id, "bin-1"); assert_eq!(inventory.bins[0].committed_count, 2); + let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); + assert_eq!(fulfillment.state, "accepted_not_fulfilled"); + assert_eq!(fulfillment.event_id, None); + assert_eq!( + fulfillment.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + fulfillment.prev_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert_eq!(fulfillment.terminal, false); + assert_eq!(fulfillment.inventory_released, false); + assert!(fulfillment.issues.is_empty()); assert!(view.reducer_issues.is_empty()); assert_eq!(view.decoded_count, 2); } @@ -5847,12 +6012,14 @@ mod tests { failed_relays: Vec::new(), events: vec![ fixture.request_event.clone(), - decision_event, + decision_event.clone(), fulfillment_event.clone(), ], }; let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); + let request_event_id = fixture.request_event.id.to_string(); + let decision_event_id = decision_event.id.to_string(); let fulfillment_event_id = fulfillment_event.id.to_string(); assert_eq!( @@ -5864,11 +6031,145 @@ mod tests { view.last_event_id.as_deref(), Some(fulfillment_event_id.as_str()) ); + let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); + assert_eq!(fulfillment.state, "ready_for_pickup"); + assert_eq!( + fulfillment.event_id.as_deref(), + Some(fulfillment_event_id.as_str()) + ); + assert_eq!( + fulfillment.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + fulfillment.prev_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert_eq!(fulfillment.terminal, false); + assert_eq!(fulfillment.inventory_released, false); + assert!(fulfillment.issues.is_empty()); assert_eq!(view.decoded_count, 3); assert!(view.reducer_issues.is_empty()); } #[test] + fn order_status_from_receipt_reports_seller_cancelled_inventory_release_flag() { + let fixture = order_status_fixture(); + let decision_event = signed_order_decision_event( + &fixture.seller, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + }, + ); + let fulfillment_event = signed_fulfillment_update_event( + &fixture.seller, + &fixture.request_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsActiveTradeFulfillmentState::SellerCancelled, + ); + let receipt = DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![ + fixture.request_event.clone(), + decision_event, + fulfillment_event.clone(), + ], + }; + + let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); + let fulfillment_event_id = fulfillment_event.id.to_string(); + let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); + + assert_eq!(view.state, "accepted"); + assert_eq!(fulfillment.state, "seller_cancelled"); + assert_eq!( + fulfillment.event_id.as_deref(), + Some(fulfillment_event_id.as_str()) + ); + assert_eq!(fulfillment.terminal, true); + assert_eq!(fulfillment.inventory_released, true); + assert!(fulfillment.issues.is_empty()); + } + + #[test] + fn order_status_from_receipt_exposes_forked_fulfillment_issues() { + let fixture = order_status_fixture(); + let decision_event = signed_order_decision_event( + &fixture.seller, + &fixture.request_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsTradeOrderDecision::Accepted { + inventory_commitments: vec![RadrootsTradeInventoryCommitment { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + }, + ); + let first_fulfillment_event = signed_fulfillment_update_event( + &fixture.seller, + &fixture.request_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsActiveTradeFulfillmentState::Preparing, + ); + let second_fulfillment_event = signed_fulfillment_update_event( + &fixture.seller, + &fixture.request_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + RadrootsActiveTradeFulfillmentState::ReadyForPickup, + ); + let mut expected_event_ids = vec![ + first_fulfillment_event.id.to_string(), + second_fulfillment_event.id.to_string(), + ]; + expected_event_ids.sort(); + let receipt = DirectRelayFetchReceipt { + target_relays: vec!["ws://relay.test".to_owned()], + connected_relays: vec!["ws://relay.test".to_owned()], + failed_relays: Vec::new(), + events: vec![ + fixture.request_event.clone(), + decision_event, + first_fulfillment_event, + second_fulfillment_event, + ], + }; + + let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); + let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); + + assert_eq!(view.state, "invalid"); + assert_eq!(fulfillment.state, "invalid"); + assert_eq!(fulfillment.issues.len(), 1); + assert_eq!(fulfillment.issues[0].code, "forked_fulfillments"); + assert_eq!(fulfillment.issues[0].event_ids, expected_event_ids); + } + + #[test] fn order_fulfillment_dry_run_view_chains_from_latest_visible_event() { let dir = tempdir().expect("tempdir"); let mut config = sample_config(dir.path()); @@ -6009,6 +6310,14 @@ mod tests { .expect("terminal fulfillment preflight"); assert_eq!(view.state, "invalid"); + let fulfillment = reduction + .view + .fulfillment + .as_ref() + .expect("fulfillment view"); + assert_eq!(fulfillment.state, "delivered"); + assert_eq!(fulfillment.terminal, true); + assert_eq!(fulfillment.inventory_released, false); assert_eq!(view.issues[0].code, "fulfillment_unsupported_transition"); assert_eq!(view.issues[0].event_ids, vec![fulfillment_event_id]); assert!(view.event_id.is_none()); @@ -6292,6 +6601,7 @@ mod tests { assert_eq!(inventory.state, "not_reserved"); assert_eq!(inventory.commitment_valid, true); assert!(inventory.bins.is_empty()); + assert!(view.fulfillment.is_none()); assert!(view.reducer_issues.is_empty()); assert_eq!(view.decoded_count, 2); }