cli

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

commit b6d756a6c484c39aa0c1ccbc9d1b8fe5fa5106e6
parent 217c58fa35200118d49443407cb9d6668d8a4139
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Apr 2026 02:20:57 +0000

order: implement buyer receipt runtime

Diffstat:
Msrc/runtime/order.rs | 1277++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 1146 insertions(+), 131 deletions(-)

diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -6,13 +6,13 @@ use std::time::{SystemTime, UNIX_EPOCH}; use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::{ KIND_LISTING, KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, - KIND_TRADE_ORDER_REQUEST, + KIND_TRADE_ORDER_REQUEST, KIND_TRADE_RECEIPT, }; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingStatus, }; use radroots_events::trade::{ - RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType, + RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType, RadrootsTradeBuyerReceipt, RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, @@ -20,7 +20,8 @@ use radroots_events::trade::{ use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::decode::listing_from_event; use radroots_events_codec::trade::{ - RadrootsTradeListingAddress, active_trade_envelope_from_event, + RadrootsTradeListingAddress, active_trade_buyer_receipt_event_build, + active_trade_buyer_receipt_from_event, active_trade_envelope_from_event, active_trade_event_context_from_tags, active_trade_fulfillment_update_event_build, active_trade_fulfillment_update_from_event, active_trade_order_cancel_event_build, active_trade_order_cancel_from_event, active_trade_order_decision_event_build, @@ -1040,37 +1041,88 @@ pub fn receipt_record( config: &RuntimeConfig, args: &OrderReceiptArgs, ) -> Result<OrderReceiptView, RuntimeError> { - Ok(OrderReceiptView { - state: "unavailable".to_owned(), - source: ORDER_RECEIPT_SOURCE.to_owned(), - order_id: args.key.clone(), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - request_event_id: None, - decision_event_id: None, - fulfillment_event_id: None, - root_event_id: None, - prev_event_id: None, - event_id: None, - event_kind: None, - received: args.received, - issue: args.issue.clone(), - received_at: None, - dry_run: config.output.dry_run, - target_relays: config.relay.urls.clone(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: Some("order receipt runtime is pending lifecycle preflight wiring".to_owned()), - issues: Vec::new(), - actions: vec![format!("radroots order status get {}", args.key)], - }) + if let Some(view) = order_receipt_args_preflight_view(config, args) { + return Ok(view); + } + if config.relay.urls.is_empty() { + let mut view = order_receipt_base_view(config, args, "unconfigured", config.output.dry_run); + view.reason = + Some("order receipt record requires at least one configured relay".to_owned()); + return Ok(view); + } + + let selected_account = match accounts::resolve_account(config)? { + Some(account) => account, + None => { + let mut view = + order_receipt_base_view(config, args, "unconfigured", config.output.dry_run); + view.reason = Some("order receipt record requires a selected buyer account".to_owned()); + view.actions = vec!["radroots account create".to_owned()]; + return Ok(view); + } + }; + let selected_pubkey = selected_account.record.public_identity.public_key_hex; + let filter = order_status_filter(args.key.as_str())?; + let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { + Ok(receipt) => receipt, + Err(DirectRelayFetchError::Connect { + reason, + target_relays, + failed_relays, + }) => { + let mut view = + order_receipt_base_view(config, args, "unavailable", config.output.dry_run); + view.buyer_pubkey = Some(selected_pubkey); + view.target_relays = target_relays; + view.failed_relays = relay_failures(failed_relays); + view.reason = Some(format!("direct relay connection failed: {reason}")); + return Ok(view); + } + Err(error) => return Err(RuntimeError::Network(error.to_string())), + }; + + let reduction = order_status_reduction_from_receipt_with_context( + OrderStatusContext { + order_id: args.key.as_str(), + selected_account_pubkey: Some(selected_pubkey.as_str()), + }, + receipt, + ); + let status_view = reduction.view; + if let Some(view) = order_receipt_preflight_view_from_status( + config, + args, + &status_view, + selected_pubkey.as_str(), + ) { + return Ok(view); + } + + let buyer_pubkey = status_view.buyer_pubkey.as_deref().ok_or_else(|| { + RuntimeError::Config("receiptable order is missing buyer_pubkey".to_owned()) + })?; + let signing = match resolve_local_order_receipt_signing_identity(config, buyer_pubkey) { + Ok(signing) => signing, + Err(error) => { + return Ok(order_receipt_binding_error_view( + config, + args, + &status_view, + error, + )); + } + }; + let payload = order_receipt_payload_from_status(args, &status_view)?; + let _ = order_receipt_event_parts(&status_view, &payload)?; + if config.output.dry_run { + return Ok(order_receipt_dry_run_view( + config, + args, + &status_view, + &payload, + )); + } + publish_order_receipt(config, args, status_view, signing, payload) } pub fn status( @@ -1163,6 +1215,7 @@ enum OrderStatusRecord { Decision(RadrootsActiveOrderDecisionRecord), Fulfillment(RadrootsActiveOrderFulfillmentRecord), Cancellation(RadrootsActiveOrderCancellationRecord), + Receipt(RadrootsActiveOrderReceiptRecord), } #[derive(Debug, Clone)] @@ -1218,6 +1271,7 @@ fn order_status_reduction_from_receipt_with_context( let mut decisions = Vec::new(); let mut fulfillments = Vec::new(); let mut cancellations = Vec::new(); + let mut receipts = Vec::new(); let mut request_listing_events = Vec::new(); let mut candidate_issues = Vec::new(); @@ -1247,6 +1301,10 @@ fn order_status_reduction_from_receipt_with_context( decoded_count += 1; cancellations.push(record); } + Ok(OrderStatusRecord::Receipt(record)) => { + decoded_count += 1; + receipts.push(record); + } Err(error) => { skipped_count += 1; if order_status_request_candidate(&event, context) { @@ -1272,13 +1330,14 @@ fn order_status_reduction_from_receipt_with_context( let order_id = context.order_id; let fulfillment_records = fulfillments.clone(); let cancellation_records = cancellations.clone(); + let receipt_records = receipts.clone(); let projection = reduce_active_order_events( order_id, requests, decisions.clone(), fulfillments, cancellations, - Vec::<RadrootsActiveOrderReceiptRecord>::new(), + receipts, ); let fulfillment_event_id = projection.fulfillment_event_id.clone(); let fulfillment_status = projection.fulfillment_status; @@ -1314,6 +1373,18 @@ fn order_status_reduction_from_receipt_with_context( .find(|record| &record.event_id == event_id) .map(|record| record.prev_event_id.clone()) }); + let receipt_root_event_id = projection.receipt_event_id.as_ref().and_then(|event_id| { + receipt_records + .iter() + .find(|record| &record.event_id == event_id) + .map(|record| record.root_event_id.clone()) + }); + let receipt_prev_event_id = projection.receipt_event_id.as_ref().and_then(|event_id| { + receipt_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() @@ -1364,10 +1435,16 @@ fn order_status_reduction_from_receipt_with_context( cancellation_prev_event_id, projection.settlement_pending, projection.settlement_reason.clone(), - None, - None, - None, - None, + projection.receipt_event_id.clone(), + receipt_root_event_id, + receipt_prev_event_id, + projection.receipt_received.map(|received| { + ( + received, + projection.receipt_issue.clone(), + projection.receipt_received_at, + ) + }), reducer_issues.as_slice(), ); @@ -1800,6 +1877,29 @@ fn order_status_record_from_event( }, )) } + KIND_TRADE_RECEIPT => { + let event = radroots_event_from_nostr(event); + let envelope = active_trade_buyer_receipt_from_event(&event).map_err(|error| { + RuntimeError::Config(format!("decode active buyer receipt event: {error}")) + })?; + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeBuyerReceipt, + &event.tags, + ) + .map_err(|error| { + RuntimeError::Config(format!("decode active buyer receipt tags: {error}")) + })?; + Ok(OrderStatusRecord::Receipt( + RadrootsActiveOrderReceiptRecord { + event_id: event.id, + author_pubkey: event.author, + counterparty_pubkey: context.counterparty_pubkey, + root_event_id: context.root_event_id.unwrap_or_default(), + prev_event_id: context.prev_event_id.unwrap_or_default(), + payload: envelope.payload, + }, + )) + } event_kind => Err(RuntimeError::Config(format!( "order status received unexpected kind `{event_kind}`" ))), @@ -2744,6 +2844,45 @@ fn order_cancellation_base_view( } } +fn order_receipt_base_view( + config: &RuntimeConfig, + args: &OrderReceiptArgs, + state: &str, + dry_run: bool, +) -> OrderReceiptView { + OrderReceiptView { + state: state.to_owned(), + source: ORDER_RECEIPT_SOURCE.to_owned(), + order_id: args.key.clone(), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + request_event_id: None, + decision_event_id: None, + fulfillment_event_id: None, + root_event_id: None, + prev_event_id: None, + event_id: None, + event_kind: None, + received: args.received, + issue: args.issue.as_ref().map(|issue| issue.trim().to_owned()), + received_at: None, + dry_run, + target_relays: config.relay.urls.clone(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 0, + decoded_count: 0, + skipped_count: 0, + idempotency_key: args.idempotency_key.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + reason: None, + issues: Vec::new(), + actions: Vec::new(), + } +} + fn apply_order_fulfillment_status(view: &mut OrderFulfillmentView, status: &OrderStatusView) { view.order_id = status.order_id.clone(); view.listing_addr = status.listing_addr.clone(); @@ -2780,6 +2919,39 @@ fn apply_order_cancellation_status(view: &mut OrderCancellationView, status: &Or view.issues = status.reducer_issues.clone(); } +fn apply_order_receipt_status(view: &mut OrderReceiptView, status: &OrderStatusView) { + view.order_id = status.order_id.clone(); + view.listing_addr = status.listing_addr.clone(); + view.buyer_pubkey = status.buyer_pubkey.clone(); + view.seller_pubkey = status.seller_pubkey.clone(); + view.request_event_id = status.request_event_id.clone(); + view.decision_event_id = status.decision_event_id.clone(); + view.fulfillment_event_id = status + .fulfillment + .as_ref() + .and_then(|fulfillment| fulfillment.event_id.clone()); + view.root_event_id = status.request_event_id.clone(); + view.prev_event_id = + order_receipt_prev_event_id(status).or_else(|| status.last_event_id.clone()); + view.target_relays = status.target_relays.clone(); + view.connected_relays = status.connected_relays.clone(); + view.failed_relays = status.failed_relays.clone(); + view.fetched_count = status.fetched_count; + view.decoded_count = status.decoded_count; + view.skipped_count = status.skipped_count; + view.issues = status.reducer_issues.clone(); +} + +fn order_receipt_prev_event_id(status: &OrderStatusView) -> Option<String> { + status.fulfillment.as_ref().and_then(|fulfillment| { + if matches!(fulfillment.state.as_str(), "ready_for_pickup" | "delivered") { + fulfillment.event_id.clone() + } else { + None + } + }) +} + fn order_cancellation_prev_event_id(status: &OrderStatusView) -> Option<String> { match status.state.as_str() { "requested" => status.request_event_id.clone(), @@ -2811,8 +2983,10 @@ fn order_cancellation_preflight_view_from_status( return None; } "accepted" if buyer_matches => "fulfilled", - "missing" | "declined" | "cancelled" | "completed" | "disputed" | "invalid" - | "unavailable" | "unconfigured" => status.state.as_str(), + "cancelled" | "completed" | "disputed" => "terminal", + "missing" | "declined" | "invalid" | "unavailable" | "unconfigured" => { + status.state.as_str() + } _ => "invalid", }; let mut view = order_cancellation_base_view(config, args, state, config.output.dry_run); @@ -2830,7 +3004,7 @@ fn order_cancellation_preflight_view_from_status( "order cancel refused because order `{}` was declined", args.key ), - "cancelled" | "completed" | "disputed" => { + "terminal" => { format!( "order cancel refused because order `{}` is already terminal", args.key @@ -2861,6 +3035,110 @@ fn order_cancellation_preflight_view_from_status( Some(view) } +fn order_receipt_args_preflight_view( + config: &RuntimeConfig, + args: &OrderReceiptArgs, +) -> Option<OrderReceiptView> { + let issue = args + .issue + .as_deref() + .map(str::trim) + .filter(|issue| !issue.is_empty()); + let reason = if args.received && issue.is_some() { + Some("order receipt record cannot set both received and issue".to_owned()) + } else if !args.received && issue.is_none() { + Some("order receipt record requires --received or a non-empty --issue".to_owned()) + } else { + None + }?; + let mut view = order_receipt_base_view(config, args, "invalid", config.output.dry_run); + view.reason = Some(reason); + view.issues = vec![issue_with_code( + "invalid_receipt_outcome", + "receipt", + "receipt outcome must be either received or issue", + )]; + Some(view) +} + +fn order_receipt_preflight_view_from_status( + config: &RuntimeConfig, + args: &OrderReceiptArgs, + status: &OrderStatusView, + selected_pubkey: &str, +) -> Option<OrderReceiptView> { + let buyer_matches = status + .buyer_pubkey + .as_deref() + .is_some_and(|buyer| buyer.eq_ignore_ascii_case(selected_pubkey)); + let eligible_fulfillment = order_receipt_prev_event_id(status).is_some(); + let state = match status.state.as_str() { + "accepted" if buyer_matches && eligible_fulfillment => return None, + "accepted" if buyer_matches => "invalid", + "completed" | "disputed" => "terminal", + "missing" | "requested" | "declined" | "cancelled" | "invalid" | "unavailable" + | "unconfigured" => status.state.as_str(), + _ => "invalid", + }; + let mut view = order_receipt_base_view(config, args, state, config.output.dry_run); + apply_order_receipt_status(&mut view, status); + if matches!(status.state.as_str(), "completed" | "disputed") { + view.event_id = status + .lifecycle + .as_ref() + .and_then(|lifecycle| lifecycle.event_id.clone()); + view.event_kind = Some(KIND_TRADE_RECEIPT); + if let Some(receipt) = status + .lifecycle + .as_ref() + .and_then(|lifecycle| lifecycle.receipt.as_ref()) + { + view.received = receipt.received; + view.issue = receipt.issue.clone(); + view.received_at = receipt.received_at; + } + } + view.reason = Some(match state { + "missing" => format!("no active order events matched `{}`", args.key), + "requested" => format!( + "order receipt record refused because order `{}` has no accepted seller decision", + args.key + ), + "declined" => format!( + "order receipt record refused because order `{}` was declined", + args.key + ), + "cancelled" | "terminal" => { + format!( + "order receipt record refused because order `{}` is already terminal", + args.key + ) + } + "invalid" if !buyer_matches && status.buyer_pubkey.is_some() => format!( + "order receipt record refused because selected account is not buyer for order `{}`", + args.key + ), + "invalid" if status.state == "accepted" => format!( + "order receipt record refused because order `{}` has no eligible seller fulfillment", + args.key + ), + "invalid" => status.reason.clone().unwrap_or_else(|| { + format!( + "order receipt record refused because active order events for `{}` are invalid", + args.key + ) + }), + _ => status.reason.clone().unwrap_or_else(|| { + format!( + "order receipt record status preflight failed with state `{}`", + status.state + ) + }), + }); + view.actions = vec![format!("radroots order status get {}", args.key)]; + Some(view) +} + fn order_fulfillment_preflight_view_from_status( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -3751,17 +4029,85 @@ fn order_cancellation_event_parts( .map_err(|error| RuntimeError::Config(format!("encode order cancellation event: {error}"))) } -fn order_cancellation_dry_run_view( - config: &RuntimeConfig, - args: &OrderCancelArgs, +fn order_receipt_payload_from_status( + args: &OrderReceiptArgs, status: &OrderStatusView, -) -> OrderCancellationView { - let mut view = order_cancellation_base_view(config, args, "dry_run", true); - apply_order_cancellation_status(&mut view, status); - view.reason = - Some("dry run requested; buyer order cancellation publication skipped".to_owned()); - view.actions = vec![format!("radroots order status get {}", status.order_id)]; - view +) -> Result<RadrootsTradeBuyerReceipt, RuntimeError> { + Ok(RadrootsTradeBuyerReceipt { + order_id: status.order_id.clone(), + listing_addr: status.listing_addr.clone().ok_or_else(|| { + RuntimeError::Config("receiptable order is missing listing_addr".to_owned()) + })?, + buyer_pubkey: status.buyer_pubkey.clone().ok_or_else(|| { + RuntimeError::Config("receiptable order is missing buyer_pubkey".to_owned()) + })?, + seller_pubkey: status.seller_pubkey.clone().ok_or_else(|| { + RuntimeError::Config("receiptable order is missing seller_pubkey".to_owned()) + })?, + received: args.received, + issue: if args.received { + None + } else { + Some( + args.issue + .as_deref() + .map(str::trim) + .filter(|issue| !issue.is_empty()) + .ok_or_else(|| { + RuntimeError::Config( + "receipt issue is required when received is false".to_owned(), + ) + })? + .to_owned(), + ) + }, + received_at: now_unix(), + }) +} + +fn order_receipt_event_parts( + status: &OrderStatusView, + payload: &RadrootsTradeBuyerReceipt, +) -> Result<WireEventParts, RuntimeError> { + let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { + RuntimeError::Config("receiptable order is missing request_event_id".to_owned()) + })?; + let prev_event_id = order_receipt_prev_event_id(status).ok_or_else(|| { + RuntimeError::Config( + "receiptable order is missing eligible fulfillment event id".to_owned(), + ) + })?; + active_trade_buyer_receipt_event_build(root_event_id, prev_event_id.as_str(), payload) + .map_err(|error| RuntimeError::Config(format!("encode buyer receipt event: {error}"))) +} + +fn order_cancellation_dry_run_view( + config: &RuntimeConfig, + args: &OrderCancelArgs, + status: &OrderStatusView, +) -> OrderCancellationView { + let mut view = order_cancellation_base_view(config, args, "dry_run", true); + apply_order_cancellation_status(&mut view, status); + view.reason = + Some("dry run requested; buyer order cancellation publication skipped".to_owned()); + view.actions = vec![format!("radroots order status get {}", status.order_id)]; + view +} + +fn order_receipt_dry_run_view( + config: &RuntimeConfig, + args: &OrderReceiptArgs, + status: &OrderStatusView, + payload: &RadrootsTradeBuyerReceipt, +) -> OrderReceiptView { + let mut view = order_receipt_base_view(config, args, "dry_run", true); + apply_order_receipt_status(&mut view, status); + view.received = payload.received; + view.issue = payload.issue.clone(); + view.received_at = Some(payload.received_at); + view.reason = Some("dry run requested; buyer receipt publication skipped".to_owned()); + view.actions = vec![format!("radroots order status get {}", status.order_id)]; + view } fn publish_order_fulfillment( @@ -3801,6 +4147,22 @@ fn publish_order_cancellation( )) } +fn publish_order_receipt( + config: &RuntimeConfig, + args: &OrderReceiptArgs, + status: OrderStatusView, + signing: accounts::AccountSigningIdentity, + payload: RadrootsTradeBuyerReceipt, +) -> Result<OrderReceiptView, RuntimeError> { + let parts = order_receipt_event_parts(&status, &payload)?; + let event_kind = parts.kind; + let receipt = publish_parts_with_identity(&signing.identity, &config.relay.urls, parts) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + Ok(published_order_receipt_view( + config, args, &status, &payload, event_kind, receipt, + )) +} + fn published_order_fulfillment_view( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -3854,6 +4216,40 @@ fn published_order_cancellation_view( view } +fn published_order_receipt_view( + config: &RuntimeConfig, + args: &OrderReceiptArgs, + status: &OrderStatusView, + payload: &RadrootsTradeBuyerReceipt, + event_kind: u32, + receipt: DirectRelayPublishReceipt, +) -> OrderReceiptView { + let DirectRelayPublishReceipt { + event_id, + created_at: _, + signature: _, + target_relays, + acknowledged_relays, + failed_relays, + } = receipt; + let state = if payload.received { + "completed" + } else { + "disputed" + }; + let mut view = order_receipt_base_view(config, args, state, false); + apply_order_receipt_status(&mut view, status); + view.received = payload.received; + view.issue = payload.issue.clone(); + view.received_at = Some(payload.received_at); + view.event_id = Some(event_id); + view.event_kind = Some(event_kind); + view.target_relays = target_relays; + view.acknowledged_relays = acknowledged_relays; + view.failed_relays = relay_failures(failed_relays); + view +} + fn order_fulfillment_binding_error_view( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -3895,6 +4291,26 @@ fn order_cancellation_binding_error_view( view } +fn order_receipt_binding_error_view( + config: &RuntimeConfig, + args: &OrderReceiptArgs, + status: &OrderStatusView, + error: ActorWriteBindingError, +) -> OrderReceiptView { + let (state, reason, actions) = match error { + ActorWriteBindingError::Unconfigured(reason) => ( + "unconfigured".to_owned(), + reason, + vec!["run radroots signer status get".to_owned()], + ), + }; + let mut view = order_receipt_base_view(config, args, state.as_str(), config.output.dry_run); + apply_order_receipt_status(&mut view, status); + view.reason = Some(reason); + view.actions = actions; + view +} + fn seller_order_request_resolution_from_receipt( seller_pubkey: &str, order_id: &str, @@ -4289,6 +4705,7 @@ fn order_status_filter(order_id: &str) -> Result<RadrootsNostrFilter, RuntimeErr radroots_nostr_kind(KIND_TRADE_ORDER_DECISION as u16), radroots_nostr_kind(KIND_TRADE_FULFILLMENT_UPDATE as u16), radroots_nostr_kind(KIND_TRADE_CANCEL as u16), + radroots_nostr_kind(KIND_TRADE_RECEIPT as u16), ]) .limit(1_000); radroots_nostr_filter_tag(filter, "d", vec![order_id.to_owned()]) @@ -5573,6 +5990,31 @@ fn resolve_local_order_cancellation_signing_identity( Ok(signing) } +fn resolve_local_order_receipt_signing_identity( + config: &RuntimeConfig, + buyer_pubkey: &str, +) -> Result<accounts::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured( + "order receipt record requires signer mode `local`".to_owned(), + )); + } + let signing = accounts::resolve_local_signing_identity(config) + .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; + let selected_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + ))); + } + Ok(signing) +} + fn parse_fulfillment_state(state: &str) -> Result<RadrootsActiveTradeFulfillmentState, String> { match state.trim() { "accepted_not_fulfilled" => Ok(RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled), @@ -5841,17 +6283,19 @@ mod tests { use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::{ KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, + KIND_TRADE_RECEIPT, }; use radroots_events::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType, - RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, - RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, - RadrootsTradeOrderItem, RadrootsTradeOrderRequested, + RadrootsTradeBuyerReceipt, RadrootsTradeFulfillmentUpdated, + RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, + RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, }; use radroots_events_codec::trade::{ - active_trade_event_context_from_tags, active_trade_fulfillment_update_event_build, - active_trade_order_cancel_event_build, active_trade_order_decision_event_build, - active_trade_order_decision_from_event, active_trade_order_request_event_build, + active_trade_buyer_receipt_event_build, active_trade_event_context_from_tags, + active_trade_fulfillment_update_event_build, active_trade_order_cancel_event_build, + active_trade_order_decision_event_build, active_trade_order_decision_from_event, + active_trade_order_request_event_build, }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event}; @@ -5877,10 +6321,12 @@ mod tests { order_decision_dry_run_view, order_decision_preflight_view_from_status, order_decision_view_from_resolution, order_fulfillment_dry_run_view, order_fulfillment_preflight_view_from_status, order_history_entry_from_event, - order_history_from_receipt, order_request_filter, order_status_from_receipt, - order_status_from_receipt_with_context, order_status_reduction_from_receipt_with_context, - order_submit_dry_run_view, order_submit_existing_request_view_from_receipt, - proposed_accept_decision_record, resolve_local_order_fulfillment_signing_identity, + order_history_from_receipt, order_receipt_dry_run_view, order_receipt_event_parts, + order_receipt_payload_from_status, order_receipt_preflight_view_from_status, + order_request_filter, order_status_from_receipt, order_status_from_receipt_with_context, + order_status_reduction_from_receipt_with_context, order_submit_dry_run_view, + order_submit_existing_request_view_from_receipt, proposed_accept_decision_record, + resolve_local_order_fulfillment_signing_identity, seller_order_request_resolution_from_receipt, }; use crate::runtime::accounts; @@ -5893,7 +6339,8 @@ mod tests { use crate::runtime::direct_relay::DirectRelayFetchReceipt; use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ - OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderSubmitArgs, + OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, + OrderReceiptArgs, OrderSubmitArgs, }; #[test] @@ -7307,7 +7754,7 @@ mod tests { } #[test] - fn order_status_from_receipt_reports_latest_fulfillment_as_last_event() { + fn order_status_from_receipt_reports_completed_buyer_receipt() { let fixture = order_status_fixture(); let decision_event = signed_order_decision_event( &fixture.seller, @@ -7331,56 +7778,71 @@ mod tests { fixture.listing_addr.as_str(), fixture.buyer_pubkey.as_str(), fixture.seller_pubkey.as_str(), - RadrootsActiveTradeFulfillmentState::ReadyForPickup, + RadrootsActiveTradeFulfillmentState::Delivered, ); - 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.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 receipt_event = signed_buyer_receipt_event( + &fixture.buyer, + &fixture.request_event, + &fulfillment_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + true, + None, + ); + let view = order_status_from_receipt( + fixture.order_id.as_str(), + 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(), + receipt_event.clone(), + ], + }, + ); + let receipt_event_id = receipt_event.id.to_string(); let fulfillment_event_id = fulfillment_event.id.to_string(); + let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); + let receipt = lifecycle.receipt.as_ref().expect("receipt view"); + let inventory = view.inventory.as_ref().expect("inventory view"); - assert_eq!( - u32::from(fulfillment_event.kind.as_u16()), - KIND_TRADE_FULFILLMENT_UPDATE - ); - assert_eq!(view.state, "accepted"); + assert_eq!(u32::from(receipt_event.kind.as_u16()), KIND_TRADE_RECEIPT); + assert_eq!(view.state, "completed"); assert_eq!( view.last_event_id.as_deref(), - Some(fulfillment_event_id.as_str()) + Some(receipt_event_id.as_str()) ); - let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); - assert_eq!(fulfillment.state, "ready_for_pickup"); + assert_eq!(inventory.state, "reserved"); + assert_eq!(lifecycle.phase, "completed"); + assert_eq!(lifecycle.terminal, true); assert_eq!( - fulfillment.event_id.as_deref(), - Some(fulfillment_event_id.as_str()) + lifecycle.event_id.as_deref(), + Some(receipt_event_id.as_str()) ); assert_eq!( - fulfillment.root_event_id.as_deref(), - Some(request_event_id.as_str()) + lifecycle.prev_event_id.as_deref(), + Some(fulfillment_event_id.as_str()) ); + assert_eq!(lifecycle.settlement_required, false); + assert_eq!(lifecycle.settlement_reason, None); + assert_eq!(receipt.event_id, receipt_event_id); assert_eq!( - fulfillment.prev_event_id.as_deref(), - Some(decision_event_id.as_str()) + receipt.prev_event_id.as_deref(), + Some(fulfillment_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_eq!(receipt.received, true); + assert_eq!(receipt.issue, None); + assert_eq!(receipt.received_at, Some(1_777_665_600)); assert!(view.reducer_issues.is_empty()); } #[test] - fn order_status_from_receipt_reports_seller_cancelled_inventory_release_flag() { + fn order_status_from_receipt_reports_disputed_buyer_receipt() { let fixture = order_status_fixture(); let decision_event = signed_order_decision_event( &fixture.seller, @@ -7404,37 +7866,60 @@ mod tests { fixture.listing_addr.as_str(), fixture.buyer_pubkey.as_str(), fixture.seller_pubkey.as_str(), - RadrootsActiveTradeFulfillmentState::SellerCancelled, + RadrootsActiveTradeFulfillmentState::ReadyForPickup, ); - 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"); + let receipt_event = signed_buyer_receipt_event( + &fixture.buyer, + &fixture.request_event, + &fulfillment_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + false, + Some("damaged items"), + ); + let view = order_status_from_receipt( + fixture.order_id.as_str(), + 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, + receipt_event.clone(), + ], + }, + ); + let receipt_event_id = receipt_event.id.to_string(); + let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); + let receipt = lifecycle.receipt.as_ref().expect("receipt view"); - assert_eq!(view.state, "accepted"); - assert_eq!(fulfillment.state, "seller_cancelled"); + assert_eq!(view.state, "disputed"); + assert_eq!(lifecycle.phase, "disputed"); + assert_eq!(lifecycle.terminal, true); assert_eq!( - fulfillment.event_id.as_deref(), - Some(fulfillment_event_id.as_str()) + lifecycle.event_id.as_deref(), + Some(receipt_event_id.as_str()) ); - assert_eq!(fulfillment.terminal, true); - assert_eq!(fulfillment.inventory_released, true); - assert!(fulfillment.issues.is_empty()); + assert_eq!(lifecycle.settlement_required, true); + assert_eq!( + lifecycle.settlement_reason.as_deref(), + Some("damaged items") + ); + assert_eq!(receipt.received, false); + assert_eq!(receipt.issue.as_deref(), Some("damaged items")); + assert_eq!(receipt.received_at, Some(1_777_665_600)); + assert!(view.reducer_issues.is_empty()); } #[test] - fn order_status_from_receipt_exposes_forked_fulfillment_issues() { + fn order_receipt_event_parts_chain_from_latest_eligible_fulfillment() { let fixture = order_status_fixture(); + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); let decision_event = signed_order_decision_event( &fixture.seller, &fixture.request_event, @@ -7449,7 +7934,7 @@ mod tests { }], }, ); - let first_fulfillment_event = signed_fulfillment_update_event( + let fulfillment_event = signed_fulfillment_update_event( &fixture.seller, &fixture.request_event, &decision_event, @@ -7457,17 +7942,500 @@ mod tests { fixture.listing_addr.as_str(), fixture.buyer_pubkey.as_str(), fixture.seller_pubkey.as_str(), - RadrootsActiveTradeFulfillmentState::Preparing, + RadrootsActiveTradeFulfillmentState::ReadyForPickup, ); - let second_fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, + let status_view = order_status_from_receipt( fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsActiveTradeFulfillmentState::ReadyForPickup, + 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 args = receipt_args_for_fixture(&fixture, true, None); + + assert!( + order_receipt_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str() + ) + .is_none() + ); + let payload = + order_receipt_payload_from_status(&args, &status_view).expect("receipt payload"); + let parts = order_receipt_event_parts(&status_view, &payload).expect("receipt parts"); + let request_event_id = fixture.request_event.id.to_string(); + let fulfillment_event_id = fulfillment_event.id.to_string(); + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeBuyerReceipt, + &parts.tags, + ) + .expect("receipt context"); + + assert_eq!(payload.received, true); + assert!(payload.received_at > 0); + assert_eq!(parts.kind, KIND_TRADE_RECEIPT); + assert_eq!( + context.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + context.prev_event_id.as_deref(), + Some(fulfillment_event_id.as_str()) + ); + } + + #[test] + fn order_receipt_dry_run_view_preserves_preflight_without_publish_fields() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.output.dry_run = true; + config.relay.urls = vec!["ws://relay.test".to_owned()]; + 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::Delivered, + ); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + 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 args = OrderReceiptArgs { + key: fixture.order_id.clone(), + received: false, + issue: Some("damaged items".to_owned()), + idempotency_key: Some("idem_receipt".to_owned()), + }; + let payload = + order_receipt_payload_from_status(&args, &status_view).expect("receipt payload"); + + let view = order_receipt_dry_run_view(&config, &args, &status_view, &payload); + let request_event_id = fixture.request_event.id.to_string(); + let fulfillment_event_id = fulfillment_event.id.to_string(); + + assert_eq!(view.state, "dry_run"); + assert_eq!(view.dry_run, true); + assert_eq!( + view.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + view.prev_event_id.as_deref(), + Some(fulfillment_event_id.as_str()) + ); + assert_eq!(view.event_id, None); + assert_eq!(view.event_kind, None); + assert_eq!(view.received, false); + assert_eq!(view.issue.as_deref(), Some("damaged items")); + assert_eq!(view.received_at, Some(payload.received_at)); + assert_eq!(view.target_relays, vec!["ws://relay.test"]); + assert_eq!(view.connected_relays, vec!["ws://relay.test"]); + assert_eq!(view.fetched_count, 3); + assert_eq!(view.decoded_count, 3); + assert_eq!(view.idempotency_key.as_deref(), Some("idem_receipt")); + } + + #[test] + fn order_receipt_preflight_rejects_ineligible_fulfillment() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.relay.urls = vec!["ws://relay.test".to_owned()]; + 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::Preparing, + ); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + 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, + ], + }, + ); + let args = receipt_args_for_fixture(&fixture, true, None); + + let view = order_receipt_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str(), + ) + .expect("ineligible receipt preflight"); + + assert_eq!(view.state, "invalid"); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("no eligible seller fulfillment") + ); + assert!(view.event_id.is_none()); + } + + #[test] + fn order_receipt_preflight_rejects_selected_non_buyer_account() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.relay.urls = vec!["ws://relay.test".to_owned()]; + 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::Delivered, + ); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + 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, + ], + }, + ); + let args = receipt_args_for_fixture(&fixture, true, None); + + let view = order_receipt_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.seller_pubkey.as_str(), + ) + .expect("non buyer receipt preflight"); + + assert_eq!(view.state, "invalid"); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("selected account is not buyer") + ); + assert!(view.event_id.is_none()); + } + + #[test] + fn order_receipt_preflight_rejects_existing_terminal_receipt() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.relay.urls = vec!["ws://relay.test".to_owned()]; + 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::Delivered, + ); + let receipt_event = signed_buyer_receipt_event( + &fixture.buyer, + &fixture.request_event, + &fulfillment_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + true, + None, + ); + let receipt_event_id = receipt_event.id.to_string(); + let status_view = order_status_from_receipt( + fixture.order_id.as_str(), + 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, + receipt_event, + ], + }, + ); + let args = receipt_args_for_fixture(&fixture, true, None); + + let view = order_receipt_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str(), + ) + .expect("terminal receipt preflight"); + + assert_eq!(view.state, "terminal"); + assert_eq!(view.event_id.as_deref(), Some(receipt_event_id.as_str())); + assert_eq!(view.event_kind, Some(KIND_TRADE_RECEIPT)); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("already terminal") + ); + } + + #[test] + fn order_status_from_receipt_reports_latest_fulfillment_as_last_event() { + 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::ReadyForPickup, + ); + 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.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!( + u32::from(fulfillment_event.kind.as_u16()), + KIND_TRADE_FULFILLMENT_UPDATE + ); + assert_eq!(view.state, "accepted"); + assert_eq!( + 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(), @@ -8674,6 +9642,19 @@ mod tests { } } + fn receipt_args_for_fixture( + fixture: &OrderStatusFixture, + received: bool, + issue: Option<&str>, + ) -> OrderReceiptArgs { + OrderReceiptArgs { + key: fixture.order_id.clone(), + received, + issue: issue.map(str::to_owned), + idempotency_key: None, + } + } + fn sample_config(root: &Path) -> RuntimeConfig { let data = root.join("data"); let logs = root.join("logs"); @@ -8888,6 +9869,40 @@ mod tests { .expect("signed order cancellation") } + fn signed_buyer_receipt_event( + buyer: &RadrootsIdentity, + request_event: &radroots_nostr::prelude::RadrootsNostrEvent, + prev_event: &radroots_nostr::prelude::RadrootsNostrEvent, + order_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + received: bool, + issue: Option<&str>, + ) -> radroots_nostr::prelude::RadrootsNostrEvent { + let payload = RadrootsTradeBuyerReceipt { + order_id: order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + received, + issue: issue.map(str::to_owned), + received_at: 1_777_665_600, + }; + let request_event_id = request_event.id.to_string(); + let prev_event_id = prev_event.id.to_string(); + let parts = active_trade_buyer_receipt_event_build( + request_event_id.as_str(), + prev_event_id.as_str(), + &payload, + ) + .expect("buyer receipt parts"); + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("nostr event builder") + .sign_with_keys(buyer.keys()) + .expect("signed buyer receipt") + } + fn signed_malformed_order_request_event( buyer: &RadrootsIdentity, order_id: &str,