cli

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

commit a7f7011a62c84a630d368b201b8032d4642fc009
parent 0893d2b072d83056745191e9ac9c8450b5ede2ac
Author: triesap <tyson@radroots.org>
Date:   Tue,  5 May 2026 19:40:54 +0000

cli: add seller settlement commands

- add settlement accept and reject target operation surfaces
- sign seller settlement decisions against the current recorded payment
- reject wrong actor, stale payment, duplicate decision, reason, offline, and relay gaps
- cover settlement parser, adapter, runtime preflight, dry-run, status, and cli posture tests

Diffstat:
Msrc/domain/runtime.rs | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main.rs | 10++++++++++
Msrc/operation_adapter.rs | 54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/operation_order.rs | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/operation_registry.rs | 38+++++++++++++++++++++++++++++++++++++-
Msrc/runtime/order.rs | 1259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/runtime_args.rs | 15+++++++++++++++
Msrc/target_cli.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/target_cli.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1888 insertions(+), 100 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -6,7 +6,9 @@ use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal}; use radroots_events::farm::RadrootsFarm; use radroots_events::listing::RadrootsListingLocation; use radroots_events::profile::RadrootsProfile; -use radroots_events::trade::{RadrootsTradeOrderEconomics, RadrootsTradePaymentMethod}; +use radroots_events::trade::{ + RadrootsTradeOrderEconomics, RadrootsTradePaymentMethod, RadrootsTradeSettlementDecision, +}; use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; use serde::Serialize; @@ -1718,6 +1720,87 @@ impl OrderPaymentView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderSettlementView { + pub state: String, + pub source: String, + pub order_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub agreement_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(skip_serializing_if = "Option::is_none")] + pub payment_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_kind: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub quote_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub quote_version: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub economics_digest: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option<RadrootsCoreDecimal>, + #[serde(skip_serializing_if = "Option::is_none")] + pub currency: Option<RadrootsCoreCurrency>, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option<RadrootsTradeSettlementDecision>, + #[serde(skip_serializing_if = "Option::is_none")] + pub settlement_reason: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default)] + pub dry_run: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub connected_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub acknowledged_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failed_relays: Vec<RelayFailureView>, + #[serde(default)] + pub fetched_count: usize, + #[serde(default)] + pub decoded_count: usize, + #[serde(default)] + pub skipped_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub signer_mode: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderSettlementView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "missing" => CommandDisposition::NotFound, + "invalid" | "requested" | "declined" | "cancelled" | "not_recorded" | "settled" + | "already_decided" => CommandDisposition::ValidationFailed, + "unconfigured" => CommandDisposition::Unconfigured, + "unavailable" => CommandDisposition::ExternalUnavailable, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct OrderStatusView { pub state: String, pub source: String, diff --git a/src/main.rs b/src/main.rs @@ -293,6 +293,12 @@ fn execute_request( TargetOperationRequest::OrderPaymentRecord(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::OrderSettlementAccept(request) => { + execute_with(OrderOperationService::new(config), request) + } + TargetOperationRequest::OrderSettlementReject(request) => { + execute_with(OrderOperationService::new(config), request) + } TargetOperationRequest::OrderStatusGet(request) => { execute_with(OrderOperationService::new(config), request) } @@ -421,6 +427,8 @@ fn dry_run_requires_network(operation_id: &str) -> bool { | "order.fulfillment.update" | "order.receipt.record" | "order.payment.record" + | "order.settlement.accept" + | "order.settlement.reject" ) } @@ -444,6 +452,8 @@ fn external_network_operation(operation_id: &str) -> bool { | "order.fulfillment.update" | "order.receipt.record" | "order.payment.record" + | "order.settlement.accept" + | "order.settlement.reject" | "order.status.get" | "order.event.list" | "order.event.watch" diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1056,7 +1056,7 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, - OrderStatusCommand, TargetCommand, + OrderSettlementCommand, OrderStatusCommand, TargetCommand, }; let mut input = OperationData::new(); @@ -1280,6 +1280,17 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati } } }, + OrderCommand::Settlement(settlement) => match &settlement.command { + OrderSettlementCommand::Accept(args) => { + insert_string(&mut input, "order_id", &args.order_id); + insert_string(&mut input, "payment_event_id", &args.payment_event_id); + } + OrderSettlementCommand::Reject(args) => { + insert_string(&mut input, "order_id", &args.order_id); + insert_string(&mut input, "payment_event_id", &args.payment_event_id); + insert_string(&mut input, "reason", &args.reason); + } + }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(args) => { insert_string(&mut input, "order_id", &args.order_id) @@ -1392,6 +1403,8 @@ target_operation_contracts! { OrderFulfillmentUpdate => (OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, "order.fulfillment.update"), OrderReceiptRecord => (OrderReceiptRecordRequest, OrderReceiptRecordResult, "order.receipt.record"), OrderPaymentRecord => (OrderPaymentRecordRequest, OrderPaymentRecordResult, "order.payment.record"), + OrderSettlementAccept => (OrderSettlementAcceptRequest, OrderSettlementAcceptResult, "order.settlement.accept"), + OrderSettlementReject => (OrderSettlementRejectRequest, OrderSettlementRejectResult, "order.settlement.reject"), OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"), OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"), OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"), @@ -1816,6 +1829,45 @@ mod tests { request.payload.input.get("paid_at").and_then(Value::as_u64), Some(1_777_666_000) ); + + let settlement = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "settlement", + "reject", + "ord_test", + "--payment-event-id", + "pay_event", + "--reason", + "reference mismatch", + ]) + .expect("target args parse"); + let request = + TargetOperationRequest::from_target_args(&settlement).expect("operation request"); + let TargetOperationRequest::OrderSettlementReject(request) = request else { + panic!("expected order settlement reject request") + }; + assert_eq!(request.operation_id(), "order.settlement.reject"); + assert_eq!( + request + .payload + .input + .get("order_id") + .and_then(Value::as_str), + Some("ord_test") + ); + assert_eq!( + request + .payload + .input + .get("payment_event_id") + .and_then(Value::as_str), + Some("pay_event") + ); + assert_eq!( + request.payload.input.get("reason").and_then(Value::as_str), + Some("reference mismatch") + ); } #[test] diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::domain::runtime::{ CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView, OrderPaymentView, OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, - OrderStatusView, OrderSubmitView, + OrderSettlementView, OrderStatusView, OrderSubmitView, }; use crate::operation_adapter::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, @@ -15,15 +15,17 @@ use crate::operation_adapter::{ OrderListRequest, OrderListResult, OrderPaymentRecordRequest, OrderPaymentRecordResult, OrderReceiptRecordRequest, OrderReceiptRecordResult, OrderRevisionAcceptRequest, OrderRevisionAcceptResult, OrderRevisionDeclineRequest, OrderRevisionDeclineResult, - OrderRevisionProposeRequest, OrderRevisionProposeResult, OrderStatusGetRequest, - OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult, + OrderRevisionProposeRequest, OrderRevisionProposeResult, OrderSettlementAcceptRequest, + OrderSettlementAcceptResult, OrderSettlementRejectRequest, OrderSettlementRejectResult, + OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderPaymentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, - OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, + OrderRevisionProposeArgs, OrderSettlementArgs, OrderSettlementDecisionArg, OrderStatusArgs, + OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, }; pub struct OrderOperationService<'a> { @@ -504,6 +506,87 @@ impl OperationService<OrderPaymentRecordRequest> for OrderOperationService<'_> { } } +impl OperationService<OrderSettlementAcceptRequest> for OrderOperationService<'_> { + type Result = OrderSettlementAcceptResult; + + fn execute( + &self, + request: OperationRequest<OrderSettlementAcceptRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let payment_event_id = required_payment_event_id(&request)?; + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let args = OrderSettlementArgs { + key: required_order_key(&request)?, + payment_event_id, + decision: OrderSettlementDecisionArg::Accept, + reason: None, + idempotency_key: request + .context + .idempotency_key + .clone() + .or_else(|| string_input(&request, "idempotency_key")), + }; + let mut config = self.config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + let view = crate::runtime::order::settlement_decision(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + settlement_result::<OrderSettlementAcceptResult>(request.operation_id(), &view) + } +} + +impl OperationService<OrderSettlementRejectRequest> for OrderOperationService<'_> { + type Result = OrderSettlementRejectResult; + + fn execute( + &self, + request: OperationRequest<OrderSettlementRejectRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let payment_event_id = required_payment_event_id(&request)?; + let reason = string_input(&request, "reason") + .map(|reason| reason.trim().to_owned()) + .filter(|reason| !reason.is_empty()) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + "missing required settlement rejection reason input".to_owned(), + ) + })?; + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let args = OrderSettlementArgs { + key: required_order_key(&request)?, + payment_event_id, + decision: OrderSettlementDecisionArg::Reject, + reason: Some(reason), + idempotency_key: request + .context + .idempotency_key + .clone() + .or_else(|| string_input(&request, "idempotency_key")), + }; + let mut config = self.config.clone(); + if request.context.dry_run { + config.output.dry_run = true; + } + let view = crate::runtime::order::settlement_decision(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + settlement_result::<OrderSettlementRejectResult>(request.operation_id(), &view) + } +} + impl OperationService<OrderStatusGetRequest> for OrderOperationService<'_> { type Result = OrderStatusGetResult; @@ -1117,6 +1200,67 @@ where } } +fn settlement_result<R>( + operation_id: &str, + view: &OrderSettlementView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_target_result::<R, _>(view), + CommandDisposition::ValidationFailed => { + let message = view.reason.clone().unwrap_or_else(|| { + format!( + "order settlement decision failed validation with state `{}`", + view.state + ) + }); + Err(OperationAdapterError::validation_failed_with_detail( + operation_id, + message, + order_settlement_error_detail(view), + )) + } + disposition => { + let message = view.reason.clone().unwrap_or_else(|| { + format!( + "order settlement decision finished with state `{}`", + view.state + ) + }); + if disposition == CommandDisposition::ExternalUnavailable { + let detail = order_settlement_error_detail(view); + if !view.failed_relays.is_empty() && view.connected_relays.is_empty() { + Err(OperationAdapterError::network_unavailable_with_detail( + operation_id, + message, + detail, + )) + } else { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + detail, + )) + } + } else if disposition == CommandDisposition::Unconfigured { + Err(OperationAdapterError::operation_unavailable_with_detail( + operation_id, + message, + order_settlement_error_detail(view), + )) + } else { + Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + message, + )) + } + } + } +} + fn order_payment_error_detail(view: &OrderPaymentView) -> Value { json!({ "state": &view.state, @@ -1153,6 +1297,43 @@ fn order_payment_error_detail(view: &OrderPaymentView) -> Value { }) } +fn order_settlement_error_detail(view: &OrderSettlementView) -> Value { + json!({ + "state": &view.state, + "order_id": &view.order_id, + "listing_addr": &view.listing_addr, + "request_event_id": &view.request_event_id, + "agreement_event_id": &view.agreement_event_id, + "root_event_id": &view.root_event_id, + "prev_event_id": &view.prev_event_id, + "payment_event_id": &view.payment_event_id, + "event_id": &view.event_id, + "event_kind": view.event_kind, + "buyer_pubkey": &view.buyer_pubkey, + "seller_pubkey": &view.seller_pubkey, + "quote_id": &view.quote_id, + "quote_version": view.quote_version, + "economics_digest": &view.economics_digest, + "amount": &view.amount, + "currency": &view.currency, + "decision": &view.decision, + "settlement_reason": &view.settlement_reason, + "reason": &view.reason, + "dry_run": view.dry_run, + "target_relays": &view.target_relays, + "connected_relays": &view.connected_relays, + "acknowledged_relays": &view.acknowledged_relays, + "failed_relays": &view.failed_relays, + "fetched_count": view.fetched_count, + "decoded_count": view.decoded_count, + "skipped_count": view.skipped_count, + "idempotency_key": &view.idempotency_key, + "signer_mode": &view.signer_mode, + "issues": &view.issues, + "actions": &view.actions, + }) +} + fn order_receipt_error_detail(view: &OrderReceiptView) -> Value { json!({ "state": &view.state, @@ -1322,6 +1503,15 @@ where }) } +fn required_payment_event_id<P>( + request: &OperationRequest<P>, +) -> Result<String, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + required_string_input(request, "payment_event_id") +} + fn required_string_input<P>( request: &OperationRequest<P>, key: &str, @@ -1417,8 +1607,8 @@ mod tests { OrderAcceptResult, OrderCancelRequest, OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventWatchRequest, OrderGetRequest, OrderListRequest, OrderPaymentRecordRequest, OrderReceiptRecordRequest, OrderRevisionAcceptRequest, - OrderRevisionDeclineRequest, OrderRevisionProposeRequest, OrderStatusGetRequest, - OrderSubmitRequest, + OrderRevisionDeclineRequest, OrderRevisionProposeRequest, OrderSettlementAcceptRequest, + OrderSettlementRejectRequest, OrderStatusGetRequest, OrderSubmitRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -1820,6 +2010,82 @@ mod tests { } #[test] + fn order_settlement_accept_requires_payment_event_before_approval() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let settlement = OperationRequest::new( + OperationContext::default(), + OrderSettlementAcceptRequest::from_data(data(&[("order_id", "ord_pending")])), + ) + .expect("order settlement accept request"); + let error = service + .execute(settlement) + .expect_err("payment event required"); + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "invalid_input"); + assert!(output_error.message.contains("payment_event_id")); + } + + #[test] + fn order_settlement_accept_requires_approval_token() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let settlement = OperationRequest::new( + OperationContext::default(), + OrderSettlementAcceptRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("payment_event_id", "pay_pending"), + ])), + ) + .expect("order settlement accept request"); + let error = service.execute(settlement).expect_err("approval required"); + + assert_eq!(error.to_output_error().code, "approval_required"); + } + + #[test] + fn order_settlement_reject_requires_reason_before_approval() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let settlement = OperationRequest::new( + OperationContext::default(), + OrderSettlementRejectRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("payment_event_id", "pay_pending"), + ])), + ) + .expect("order settlement reject request"); + let error = service.execute(settlement).expect_err("reason required"); + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "invalid_input"); + assert!(output_error.message.contains("reason")); + } + + #[test] + fn order_settlement_reject_requires_approval_token() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let settlement = OperationRequest::new( + OperationContext::default(), + OrderSettlementRejectRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("payment_event_id", "pay_pending"), + ("reason", "reference mismatch"), + ])), + ) + .expect("order settlement reject request"); + let error = service.execute(settlement).expect_err("approval required"); + + assert_eq!(error.to_output_error().code, "approval_required"); + } + + #[test] fn order_status_get_requires_relay_configuration() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path()); diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -1007,6 +1007,36 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ true ), operation!( + "order.settlement.accept", + "radroots order settlement accept", + "order", + "order_settlement_accept", + "OrderSettlementAcceptRequest", + "OrderSettlementAcceptResult", + "Accept seller settlement of a recorded payment.", + Seller, + true, + Required, + High, + false, + true + ), + operation!( + "order.settlement.reject", + "radroots order settlement reject", + "order", + "order_settlement_reject", + "OrderSettlementRejectRequest", + "OrderSettlementRejectResult", + "Reject seller settlement of a recorded payment.", + Seller, + true, + Required, + High, + false, + true + ), + operation!( "order.status.get", "radroots order status get", "order", @@ -1137,6 +1167,8 @@ mod tests { "order.fulfillment.update", "order.receipt.record", "order.payment.record", + "order.settlement.accept", + "order.settlement.reject", "order.status.get", "order.event.list", "order.event.watch", @@ -1180,6 +1212,8 @@ mod tests { "order.fulfillment.update", "order.receipt.record", "order.payment.record", + "order.settlement.accept", + "order.settlement.reject", ]; const INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[]; @@ -1192,7 +1226,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 65); + assert_eq!(OPERATION_REGISTRY.len(), 67); } #[test] @@ -1248,6 +1282,8 @@ mod tests { "order.fulfillment.update", "order.receipt.record", "order.payment.record", + "order.settlement.accept", + "order.settlement.reject", ] .into_iter() .collect::<BTreeSet<_>>(); diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -26,6 +26,7 @@ use radroots_events::trade::{ RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, RadrootsTradePaymentMethod, RadrootsTradePaymentRecorded, RadrootsTradePricingBasis, + RadrootsTradeSettlementDecision, RadrootsTradeSettlementDecisionEvent, }; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::decode::listing_from_event; @@ -40,7 +41,8 @@ use radroots_events_codec::trade::{ active_trade_order_revision_decision_from_event, active_trade_order_revision_proposal_event_build, active_trade_order_revision_proposal_from_event, active_trade_payment_recorded_event_build, - active_trade_payment_recorded_from_event, active_trade_settlement_decision_from_event, + active_trade_payment_recorded_from_event, active_trade_settlement_decision_event_build, + active_trade_settlement_decision_from_event, }; use radroots_events_codec::wire::WireEventParts; use radroots_nostr::prelude::{ @@ -76,7 +78,7 @@ use crate::domain::runtime::{ OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderFulfillmentView, OrderGetView, OrderHistoryEntryView, OrderHistoryView, OrderInventoryBinView, OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderPaymentView, - OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, + OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, OrderSettlementView, OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView, OrderStatusLifecycleReceiptView, OrderStatusLifecycleView, OrderStatusPaymentView, OrderStatusRevisionView, OrderStatusView, OrderSubmitView, OrderSummaryView, OrderWatchView, @@ -93,8 +95,8 @@ use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs, OrderFulfillmentArgs, OrderPaymentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, - OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, - OrderWatchArgs, RecordLookupArgs, + OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderSettlementArgs, + OrderSettlementDecisionArg, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, }; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; @@ -109,6 +111,7 @@ const ORDER_FULFILLMENT_SOURCE: &str = "direct Nostr relay fulfillment publish const ORDER_CANCELLATION_SOURCE: &str = "direct Nostr relay cancellation publish · local key"; const ORDER_RECEIPT_SOURCE: &str = "direct Nostr relay receipt publish · local key"; const ORDER_PAYMENT_SOURCE: &str = "direct Nostr relay payment publish · local key"; +const ORDER_SETTLEMENT_SOURCE: &str = "direct Nostr relay settlement publish · local key"; const ORDER_EVENT_LIST_SOURCE: &str = "direct Nostr relay fetch · selected seller identity"; const ORDER_STATUS_SOURCE: &str = "direct Nostr relay status fetch · active order reducer"; const ORDER_EVENT_WATCH_UNAVAILABLE_REASON: &str = @@ -1575,6 +1578,96 @@ pub fn payment_record( publish_order_payment(config, args, status_view, signing, payload) } +pub fn settlement_decision( + config: &RuntimeConfig, + args: &OrderSettlementArgs, +) -> Result<OrderSettlementView, RuntimeError> { + if let Some(view) = order_settlement_args_preflight_view(config, args) { + return Ok(view); + } + if config.relay.urls.is_empty() { + let mut view = + order_settlement_base_view(config, args, "unconfigured", config.output.dry_run); + view.reason = + Some("order settlement decision 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_settlement_base_view(config, args, "unconfigured", config.output.dry_run); + view.reason = + Some("order settlement decision requires a selected seller 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_settlement_base_view(config, args, "unavailable", config.output.dry_run); + view.seller_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_settlement_preflight_view_from_status( + config, + args, + &status_view, + selected_pubkey.as_str(), + ) { + return Ok(view); + } + + let seller_pubkey = status_view.seller_pubkey.as_deref().ok_or_else(|| { + RuntimeError::Config("settleable order is missing seller_pubkey".to_owned()) + })?; + let signing = match resolve_local_order_settlement_signing_identity(config, seller_pubkey) { + Ok(signing) => signing, + Err(error) => { + return Ok(order_settlement_binding_error_view( + config, + args, + &status_view, + error, + )); + } + }; + let payload = order_settlement_payload_from_status(args, &status_view)?; + let _ = order_settlement_event_parts(&status_view, &payload)?; + if config.output.dry_run { + return Ok(order_settlement_dry_run_view( + config, + args, + &status_view, + &payload, + )); + } + publish_order_settlement(config, args, status_view, signing, payload) +} + pub fn status( config: &RuntimeConfig, args: &OrderStatusArgs, @@ -4342,6 +4435,65 @@ fn order_payment_base_view( } } +fn order_settlement_base_view( + config: &RuntimeConfig, + args: &OrderSettlementArgs, + state: &str, + dry_run: bool, +) -> OrderSettlementView { + OrderSettlementView { + state: state.to_owned(), + source: ORDER_SETTLEMENT_SOURCE.to_owned(), + order_id: args.key.clone(), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + request_event_id: None, + agreement_event_id: None, + root_event_id: None, + prev_event_id: None, + payment_event_id: non_empty_ref(args.payment_event_id.as_str()).map(str::to_owned), + event_id: None, + event_kind: None, + quote_id: None, + quote_version: None, + economics_digest: None, + amount: None, + currency: None, + decision: Some(settlement_decision_protocol(args.decision)), + settlement_reason: args.reason.as_ref().map(|reason| reason.trim().to_owned()), + reason: 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()), + issues: Vec::new(), + actions: Vec::new(), + } +} + +const fn settlement_decision_protocol( + decision: OrderSettlementDecisionArg, +) -> RadrootsTradeSettlementDecision { + match decision { + OrderSettlementDecisionArg::Accept => RadrootsTradeSettlementDecision::Accepted, + OrderSettlementDecisionArg::Reject => RadrootsTradeSettlementDecision::Rejected, + } +} + +const fn settlement_decision_state(decision: OrderSettlementDecisionArg) -> &'static str { + match decision { + OrderSettlementDecisionArg::Accept => "accepted", + OrderSettlementDecisionArg::Reject => "rejected", + } +} + fn apply_order_fulfillment_status(view: &mut OrderFulfillmentView, status: &OrderStatusView) { view.order_id = status.order_id.clone(); view.listing_addr = status.listing_addr.clone(); @@ -4426,6 +4578,41 @@ fn apply_order_payment_status(view: &mut OrderPaymentView, status: &OrderStatusV view.issues = status.reducer_issues.clone(); } +fn apply_order_settlement_status(view: &mut OrderSettlementView, 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.root_event_id = status.request_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(); + if let Some(payment) = status.payment.as_ref() { + view.payment_event_id = payment + .payment_event_id + .clone() + .or_else(|| view.payment_event_id.clone()); + view.event_id = payment.settlement_event_id.clone(); + view.event_kind = payment + .settlement_event_id + .as_ref() + .map(|_| KIND_TRADE_SETTLEMENT_DECISION); + view.agreement_event_id = payment.agreement_event_id.clone(); + view.prev_event_id = payment.payment_event_id.clone(); + view.quote_id = payment.quote_id.clone(); + view.quote_version = payment.quote_version; + view.economics_digest = payment.economics_digest.clone(); + view.amount = payment.amount; + view.currency = payment.currency; + view.settlement_reason = payment.reason.clone().or(view.settlement_reason.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") { @@ -4859,6 +5046,151 @@ fn order_payment_terms_preflight_view_from_status( None } +fn order_settlement_args_preflight_view( + config: &RuntimeConfig, + args: &OrderSettlementArgs, +) -> Option<OrderSettlementView> { + let (reason, issue) = if args.payment_event_id.trim().is_empty() { + ( + "order settlement decision requires --payment-event-id".to_owned(), + issue_with_code( + "missing_payment_event_id", + "payment_event_id", + "payment event id is required", + ), + ) + } else if matches!(args.decision, OrderSettlementDecisionArg::Reject) + && args.reason.as_deref().and_then(non_empty_ref).is_none() + { + ( + "order settlement reject requires --reason".to_owned(), + issue_with_code( + "missing_settlement_reason", + "reason", + "settlement rejection reason is required", + ), + ) + } else if matches!(args.decision, OrderSettlementDecisionArg::Accept) + && args.reason.as_deref().and_then(non_empty_ref).is_some() + { + ( + "order settlement accept does not accept --reason".to_owned(), + issue_with_code( + "unexpected_settlement_reason", + "reason", + "settlement acceptance must not carry a reason", + ), + ) + } else { + return None; + }; + let mut view = order_settlement_base_view(config, args, "invalid", config.output.dry_run); + view.reason = Some(reason); + view.issues = vec![issue]; + Some(view) +} + +fn order_settlement_preflight_view_from_status( + config: &RuntimeConfig, + args: &OrderSettlementArgs, + status: &OrderStatusView, + selected_pubkey: &str, +) -> Option<OrderSettlementView> { + let seller_matches = status + .seller_pubkey + .as_deref() + .is_some_and(|seller| seller.eq_ignore_ascii_case(selected_pubkey)); + let payment = status.payment.as_ref(); + let current_payment_id = payment.and_then(|payment| payment.payment_event_id.as_deref()); + if matches!(status.state.as_str(), "accepted" | "completed" | "disputed") + && seller_matches + && payment.is_some_and(|payment| { + payment.state == "recorded" && payment.settlement_state == "pending" + }) + && current_payment_id == Some(args.payment_event_id.as_str()) + { + return None; + } + + let state = match status.state.as_str() { + "missing" | "requested" | "declined" | "cancelled" | "invalid" | "unavailable" + | "unconfigured" => status.state.as_str(), + "accepted" | "completed" | "disputed" if !seller_matches => "invalid", + "accepted" | "completed" | "disputed" => match payment { + None => "not_recorded", + Some(payment) => { + if payment.payment_event_id.as_deref() != Some(args.payment_event_id.as_str()) { + "invalid" + } else if matches!(payment.settlement_state.as_str(), "accepted" | "rejected") { + "already_decided" + } else if payment.state != "recorded" { + payment.state.as_str() + } else { + payment.settlement_state.as_str() + } + } + }, + _ => "invalid", + }; + let mut view = order_settlement_base_view(config, args, state, config.output.dry_run); + apply_order_settlement_status(&mut view, status); + view.reason = Some(match state { + "missing" => format!("no active order events matched `{}`", args.key), + "requested" => format!( + "order settlement decision refused because order `{}` has no accepted seller decision", + args.key + ), + "declined" => format!( + "order settlement decision refused because order `{}` was declined", + args.key + ), + "cancelled" => format!( + "order settlement decision refused because order `{}` was cancelled", + args.key + ), + "not_recorded" => format!( + "order settlement decision refused because order `{}` has no recorded payment", + args.key + ), + "already_decided" => format!( + "order settlement decision skipped because payment `{}` already has settlement state `{}`", + args.payment_event_id, + payment + .map(|payment| payment.settlement_state.as_str()) + .unwrap_or("unknown") + ), + "invalid" if !seller_matches && status.seller_pubkey.is_some() => format!( + "order settlement decision refused because selected account is not seller for order `{}`", + args.key + ), + "invalid" if current_payment_id.is_some() => format!( + "order settlement decision refused because payment event `{}` is not the current recorded payment", + args.payment_event_id + ), + "invalid" => status.reason.clone().unwrap_or_else(|| { + format!( + "order settlement decision refused because active order events for `{}` are invalid", + args.key + ) + }), + _ => status.reason.clone().unwrap_or_else(|| { + format!( + "order settlement decision status preflight failed with state `{}`", + status.state + ) + }), + }); + if state == "invalid" && current_payment_id.is_some() && seller_matches { + view.issues = vec![issue_with_code( + "stale_payment_event", + "payment_event_id", + "settlement payment event id must match the current recorded payment", + )]; + } + view.actions = vec![format!("radroots order status get {}", args.key)]; + Some(view) +} + fn order_fulfillment_preflight_view_from_status( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -6722,45 +7054,150 @@ fn order_payment_event_parts( .map_err(|error| RuntimeError::Config(format!("encode payment recorded event: {error}"))) } -fn apply_order_payment_payload( - view: &mut OrderPaymentView, - payload: &RadrootsTradePaymentRecorded, -) { - view.root_event_id = Some(payload.root_event_id.clone()); - view.prev_event_id = Some(payload.previous_event_id.clone()); - view.agreement_event_id = Some(payload.agreement_event_id.clone()); - view.quote_id = Some(payload.quote_id.clone()); - view.quote_version = Some(payload.quote_version); - view.economics_digest = Some(payload.economics_digest.clone()); - view.amount = Some(payload.amount); - view.currency = Some(payload.currency); - view.method = Some(payload.method); - view.reference = payload.reference.clone(); - view.paid_at = payload.paid_at; -} - -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, +fn order_settlement_payload_from_status( + args: &OrderSettlementArgs, 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; +) -> Result<RadrootsTradeSettlementDecisionEvent, RuntimeError> { + let payment = status + .payment + .as_ref() + .ok_or_else(|| RuntimeError::Config("settleable order is missing payment".to_owned()))?; + let payment_event_id = payment.payment_event_id.clone().ok_or_else(|| { + RuntimeError::Config("settleable order is missing payment_event_id".to_owned()) + })?; + if payment_event_id != args.payment_event_id { + return Err(RuntimeError::Config( + "settlement payment event id must match current recorded payment".to_owned(), + )); + } + if payment.state != "recorded" || payment.settlement_state != "pending" { + return Err(RuntimeError::Config( + "settlement requires a recorded payment with pending settlement".to_owned(), + )); + } + let decision = settlement_decision_protocol(args.decision); + Ok(RadrootsTradeSettlementDecisionEvent { + order_id: status.order_id.clone(), + listing_addr: status.listing_addr.clone().ok_or_else(|| { + RuntimeError::Config("settleable order is missing listing_addr".to_owned()) + })?, + seller_pubkey: status.seller_pubkey.clone().ok_or_else(|| { + RuntimeError::Config("settleable order is missing seller_pubkey".to_owned()) + })?, + buyer_pubkey: status.buyer_pubkey.clone().ok_or_else(|| { + RuntimeError::Config("settleable order is missing buyer_pubkey".to_owned()) + })?, + root_event_id: status.request_event_id.clone().ok_or_else(|| { + RuntimeError::Config("settleable order is missing request_event_id".to_owned()) + })?, + previous_event_id: payment_event_id.clone(), + agreement_event_id: payment.agreement_event_id.clone().ok_or_else(|| { + RuntimeError::Config("settleable order is missing agreement_event_id".to_owned()) + })?, + payment_event_id, + quote_id: payment.quote_id.clone().ok_or_else(|| { + RuntimeError::Config("settleable order is missing quote_id".to_owned()) + })?, + quote_version: payment.quote_version.ok_or_else(|| { + RuntimeError::Config("settleable order is missing quote_version".to_owned()) + })?, + economics_digest: payment.economics_digest.clone().ok_or_else(|| { + RuntimeError::Config("settleable order is missing economics_digest".to_owned()) + })?, + amount: payment + .amount + .ok_or_else(|| RuntimeError::Config("settleable order is missing amount".to_owned()))?, + currency: payment.currency.ok_or_else(|| { + RuntimeError::Config("settleable order is missing currency".to_owned()) + })?, + decision, + reason: if matches!(args.decision, OrderSettlementDecisionArg::Reject) { + Some( + args.reason + .as_deref() + .and_then(non_empty_ref) + .ok_or_else(|| { + RuntimeError::Config("settlement rejection reason is required".to_owned()) + })? + .to_owned(), + ) + } else { + None + }, + }) +} + +fn order_settlement_event_parts( + status: &OrderStatusView, + payload: &RadrootsTradeSettlementDecisionEvent, +) -> Result<WireEventParts, RuntimeError> { + let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { + RuntimeError::Config("settleable order is missing request_event_id".to_owned()) + })?; + active_trade_settlement_decision_event_build( + root_event_id, + payload.payment_event_id.as_str(), + payload, + ) + .map_err(|error| RuntimeError::Config(format!("encode settlement decision event: {error}"))) +} + +fn apply_order_payment_payload( + view: &mut OrderPaymentView, + payload: &RadrootsTradePaymentRecorded, +) { + view.root_event_id = Some(payload.root_event_id.clone()); + view.prev_event_id = Some(payload.previous_event_id.clone()); + view.agreement_event_id = Some(payload.agreement_event_id.clone()); + view.quote_id = Some(payload.quote_id.clone()); + view.quote_version = Some(payload.quote_version); + view.economics_digest = Some(payload.economics_digest.clone()); + view.amount = Some(payload.amount); + view.currency = Some(payload.currency); + view.method = Some(payload.method); + view.reference = payload.reference.clone(); + view.paid_at = payload.paid_at; +} + +fn apply_order_settlement_payload( + view: &mut OrderSettlementView, + payload: &RadrootsTradeSettlementDecisionEvent, +) { + view.root_event_id = Some(payload.root_event_id.clone()); + view.prev_event_id = Some(payload.previous_event_id.clone()); + view.payment_event_id = Some(payload.payment_event_id.clone()); + view.agreement_event_id = Some(payload.agreement_event_id.clone()); + view.quote_id = Some(payload.quote_id.clone()); + view.quote_version = Some(payload.quote_version); + view.economics_digest = Some(payload.economics_digest.clone()); + view.amount = Some(payload.amount); + view.currency = Some(payload.currency); + view.decision = Some(payload.decision); + view.settlement_reason = payload.reason.clone(); +} + +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()); @@ -6782,6 +7219,20 @@ fn order_payment_dry_run_view( view } +fn order_settlement_dry_run_view( + config: &RuntimeConfig, + args: &OrderSettlementArgs, + status: &OrderStatusView, + payload: &RadrootsTradeSettlementDecisionEvent, +) -> OrderSettlementView { + let mut view = order_settlement_base_view(config, args, "dry_run", true); + apply_order_settlement_status(&mut view, status); + apply_order_settlement_payload(&mut view, payload); + view.reason = Some("dry run requested; seller settlement publication skipped".to_owned()); + view.actions = vec![format!("radroots order status get {}", status.order_id)]; + view +} + fn publish_order_revision( config: &RuntimeConfig, args: &OrderRevisionProposeArgs, @@ -6952,6 +7403,22 @@ fn publish_order_payment( )) } +fn publish_order_settlement( + config: &RuntimeConfig, + args: &OrderSettlementArgs, + status: OrderStatusView, + signing: accounts::AccountSigningIdentity, + payload: RadrootsTradeSettlementDecisionEvent, +) -> Result<OrderSettlementView, RuntimeError> { + let parts = order_settlement_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_settlement_view( + config, args, &status, &payload, event_kind, receipt, + )) +} + fn published_order_fulfillment_view( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -7066,6 +7533,38 @@ fn published_order_payment_view( view } +fn published_order_settlement_view( + config: &RuntimeConfig, + args: &OrderSettlementArgs, + status: &OrderStatusView, + payload: &RadrootsTradeSettlementDecisionEvent, + event_kind: u32, + receipt: DirectRelayPublishReceipt, +) -> OrderSettlementView { + let DirectRelayPublishReceipt { + event_id, + created_at: _, + signature: _, + target_relays, + acknowledged_relays, + failed_relays, + } = receipt; + let mut view = order_settlement_base_view( + config, + args, + settlement_decision_state(args.decision), + false, + ); + apply_order_settlement_status(&mut view, status); + apply_order_settlement_payload(&mut view, payload); + 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, @@ -7188,6 +7687,26 @@ fn order_payment_binding_error_view( view } +fn order_settlement_binding_error_view( + config: &RuntimeConfig, + args: &OrderSettlementArgs, + status: &OrderStatusView, + error: ActorWriteBindingError, +) -> OrderSettlementView { + 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_settlement_base_view(config, args, state.as_str(), config.output.dry_run); + apply_order_settlement_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, @@ -9314,6 +9833,31 @@ fn resolve_local_order_payment_signing_identity( Ok(signing) } +fn resolve_local_order_settlement_signing_identity( + config: &RuntimeConfig, + seller_pubkey: &str, +) -> Result<accounts::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured( + "order settlement decision 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(seller_pubkey) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "selected local account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" + ))); + } + Ok(signing) +} + fn resolve_local_order_revision_decision_signing_identity( config: &RuntimeConfig, buyer_pubkey: &str, @@ -9626,7 +10170,7 @@ mod tests { use radroots_events::kinds::{ KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, KIND_TRADE_PAYMENT_RECORDED, - KIND_TRADE_RECEIPT, + KIND_TRADE_RECEIPT, KIND_TRADE_SETTLEMENT_DECISION, }; use radroots_events::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType, @@ -9636,7 +10180,8 @@ mod tests { RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, RadrootsTradePaymentMethod, - RadrootsTradePaymentRecorded, RadrootsTradePricingBasis, + RadrootsTradePaymentRecorded, RadrootsTradePricingBasis, RadrootsTradeSettlementDecision, + RadrootsTradeSettlementDecisionEvent, }; use radroots_events_codec::trade::{ active_trade_buyer_receipt_event_build, active_trade_event_context_from_tags, @@ -9644,7 +10189,7 @@ mod tests { active_trade_order_decision_event_build, active_trade_order_decision_from_event, active_trade_order_request_event_build, active_trade_order_revision_decision_event_build, active_trade_order_revision_proposal_event_build, - active_trade_payment_recorded_event_build, + active_trade_payment_recorded_event_build, active_trade_settlement_decision_event_build, }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event}; @@ -9680,6 +10225,8 @@ mod tests { order_revision_decision_preflight_view_from_status, order_revision_event_parts, order_revision_inventory_preflight_view, order_revision_payload_from_status, order_revision_preflight_view_from_status, order_revision_proposals_from_events, + order_settlement_dry_run_view, order_settlement_event_parts, + order_settlement_payload_from_status, order_settlement_preflight_view_from_status, order_status_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, @@ -9698,7 +10245,8 @@ mod tests { use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftAdjustmentArgs, OrderFulfillmentArgs, OrderPaymentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, - OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderSubmitArgs, + OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderSettlementArgs, + OrderSettlementDecisionArg, OrderSubmitArgs, }; #[test] @@ -12872,11 +13420,10 @@ mod tests { } #[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()]; + fn order_settlement_event_parts_bind_recorded_payment_terms() { 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, @@ -12891,16 +13438,17 @@ mod tests { }], }, ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, + let payment_event = signed_payment_recorded_event( + &fixture.buyer, &fixture.request_event, &decision_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 payment_event_id = payment_event.id.to_string(); let status_view = order_status_from_receipt( fixture.order_id.as_str(), DirectRelayFetchReceipt { @@ -12909,35 +13457,60 @@ mod tests { failed_relays: Vec::new(), events: vec![ fixture.request_event.clone(), - decision_event, - fulfillment_event, + decision_event.clone(), + payment_event, ], }, ); - let args = receipt_args_for_fixture(&fixture, true, None); + let args = settlement_args_for_fixture( + &fixture, + payment_event_id.as_str(), + OrderSettlementDecisionArg::Accept, + ); - let view = order_receipt_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), + assert!( + order_settlement_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.seller_pubkey.as_str() + ) + .is_none() + ); + let payload = + order_settlement_payload_from_status(&args, &status_view).expect("settlement payload"); + let parts = order_settlement_event_parts(&status_view, &payload).expect("settlement parts"); + let request_event_id = fixture.request_event.id.to_string(); + let decision_event_id = decision_event.id.to_string(); + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeSettlementDecision, + &parts.tags, ) - .expect("ineligible receipt preflight"); + .expect("settlement context"); - assert_eq!(view.state, "invalid"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("no eligible seller fulfillment") + assert_eq!(parts.kind, KIND_TRADE_SETTLEMENT_DECISION); + assert_eq!( + context.root_event_id.as_deref(), + Some(request_event_id.as_str()) ); - assert!(view.event_id.is_none()); + assert_eq!( + context.prev_event_id.as_deref(), + Some(payment_event_id.as_str()) + ); + assert_eq!(payload.previous_event_id, payment_event_id); + assert_eq!(payload.agreement_event_id, decision_event_id); + assert_eq!(payload.payment_event_id, payload.previous_event_id); + assert_eq!(payload.amount, RadrootsCoreDecimal::from(12u32)); + assert_eq!(payload.currency, RadrootsCoreCurrency::USD); + assert_eq!(payload.decision, RadrootsTradeSettlementDecision::Accepted); + assert_eq!(payload.reason, None); } #[test] - fn order_receipt_preflight_rejects_selected_non_buyer_account() { + fn order_settlement_dry_run_view_preserves_rejection_payload_without_event_id() { 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( @@ -12954,42 +13527,492 @@ mod tests { }], }, ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, + let payment_event = signed_payment_recorded_event( + &fixture.buyer, &fixture.request_event, &decision_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 payment_event_id = payment_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, - ], + events: vec![fixture.request_event.clone(), decision_event, payment_event], }, ); - let args = receipt_args_for_fixture(&fixture, true, None); + let mut args = settlement_args_for_fixture( + &fixture, + payment_event_id.as_str(), + OrderSettlementDecisionArg::Reject, + ); + args.idempotency_key = Some("idem_settlement".to_owned()); + let payload = + order_settlement_payload_from_status(&args, &status_view).expect("settlement payload"); - let view = order_receipt_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - ) - .expect("non buyer receipt preflight"); + let view = order_settlement_dry_run_view(&config, &args, &status_view, &payload); - assert_eq!(view.state, "invalid"); - assert!( - view.reason + assert_eq!(view.state, "dry_run"); + assert_eq!(view.dry_run, true); + assert_eq!( + view.prev_event_id.as_deref(), + Some(payment_event_id.as_str()) + ); + assert_eq!( + view.payment_event_id.as_deref(), + Some(payment_event_id.as_str()) + ); + assert_eq!(view.event_id, None); + assert_eq!(view.event_kind, None); + assert_eq!(view.amount, Some(RadrootsCoreDecimal::from(12u32))); + assert_eq!(view.currency, Some(RadrootsCoreCurrency::USD)); + assert_eq!( + view.decision, + Some(RadrootsTradeSettlementDecision::Rejected) + ); + assert_eq!( + view.settlement_reason.as_deref(), + Some("reference mismatch") + ); + 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_settlement")); + } + + #[test] + fn order_settlement_preflight_rejects_selected_non_seller_account() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + 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 payment_event = signed_payment_recorded_event( + &fixture.buyer, + &fixture.request_event, + &decision_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + ); + let payment_event_id = payment_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, payment_event], + }, + ); + let args = settlement_args_for_fixture( + &fixture, + payment_event_id.as_str(), + OrderSettlementDecisionArg::Accept, + ); + + let view = order_settlement_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str(), + ) + .expect("non seller settlement preflight"); + + assert_eq!(view.state, "invalid"); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("selected account is not seller") + ); + } + + #[test] + fn order_settlement_preflight_rejects_stale_payment_event_id() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + 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 payment_event = signed_payment_recorded_event( + &fixture.buyer, + &fixture.request_event, + &decision_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + ); + 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, payment_event], + }, + ); + let args = settlement_args_for_fixture( + &fixture, + "2".repeat(64).as_str(), + OrderSettlementDecisionArg::Accept, + ); + + let view = order_settlement_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.seller_pubkey.as_str(), + ) + .expect("stale settlement preflight"); + + assert_eq!(view.state, "invalid"); + assert_eq!(view.issues.len(), 1); + assert_eq!(view.issues[0].code, "stale_payment_event"); + } + + #[test] + fn order_settlement_preflight_rejects_duplicate_decision() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + 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 payment_event = signed_payment_recorded_event( + &fixture.buyer, + &fixture.request_event, + &decision_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + ); + let settlement_event = signed_settlement_decision_event( + &fixture.seller, + &fixture.request_event, + &payment_event, + RadrootsTradeSettlementDecision::Accepted, + ); + let payment_event_id = payment_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, + payment_event, + settlement_event, + ], + }, + ); + let args = settlement_args_for_fixture( + &fixture, + payment_event_id.as_str(), + OrderSettlementDecisionArg::Accept, + ); + + let view = order_settlement_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.seller_pubkey.as_str(), + ) + .expect("duplicate settlement preflight"); + + assert_eq!(view.state, "already_decided"); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("already has settlement state") + ); + } + + #[test] + fn order_status_from_receipt_reports_accepted_settlement_axis() { + 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 payment_event = signed_payment_recorded_event( + &fixture.buyer, + &fixture.request_event, + &decision_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + ); + let settlement_event = signed_settlement_decision_event( + &fixture.seller, + &fixture.request_event, + &payment_event, + RadrootsTradeSettlementDecision::Accepted, + ); + let settlement_event_id = settlement_event.id.to_string(); + 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, + payment_event, + settlement_event, + ], + }, + ); + let payment = view.payment.as_ref().expect("payment view"); + + assert_eq!(payment.state, "settled"); + assert_eq!(payment.settlement_state, "accepted"); + assert_eq!( + payment.settlement_event_id.as_deref(), + Some(settlement_event_id.as_str()) + ); + assert_eq!(payment.reason, None); + assert!(view.reducer_issues.is_empty()); + } + + #[test] + fn order_status_from_receipt_reports_rejected_settlement_axis() { + 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 payment_event = signed_payment_recorded_event( + &fixture.buyer, + &fixture.request_event, + &decision_event, + &decision_event, + fixture.order_id.as_str(), + fixture.listing_addr.as_str(), + fixture.buyer_pubkey.as_str(), + fixture.seller_pubkey.as_str(), + ); + let settlement_event = signed_settlement_decision_event( + &fixture.seller, + &fixture.request_event, + &payment_event, + RadrootsTradeSettlementDecision::Rejected, + ); + 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, + payment_event, + settlement_event, + ], + }, + ); + let payment = view.payment.as_ref().expect("payment view"); + + assert_eq!(payment.state, "rejected"); + assert_eq!(payment.settlement_state, "rejected"); + assert_eq!(payment.reason.as_deref(), Some("reference mismatch")); + assert!(view.reducer_issues.is_empty()); + } + + #[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") @@ -14519,6 +15542,24 @@ mod tests { } } + fn settlement_args_for_fixture( + fixture: &OrderStatusFixture, + payment_event_id: &str, + decision: OrderSettlementDecisionArg, + ) -> OrderSettlementArgs { + OrderSettlementArgs { + key: fixture.order_id.clone(), + payment_event_id: payment_event_id.to_owned(), + decision, + reason: if decision == OrderSettlementDecisionArg::Reject { + Some("reference mismatch".to_owned()) + } else { + None + }, + idempotency_key: None, + } + } + fn revision_args_for_fixture( fixture: &OrderStatusFixture, bin_count: u32, @@ -14920,6 +15961,46 @@ mod tests { .expect("signed payment recorded") } + fn signed_settlement_decision_event( + seller: &RadrootsIdentity, + request_event: &radroots_nostr::prelude::RadrootsNostrEvent, + payment_event: &radroots_nostr::prelude::RadrootsNostrEvent, + decision: RadrootsTradeSettlementDecision, + ) -> radroots_nostr::prelude::RadrootsNostrEvent { + let payment = radroots_event_from_nostr(payment_event); + let envelope = + radroots_events_codec::trade::active_trade_payment_recorded_from_event(&payment) + .expect("decoded payment"); + let payload = RadrootsTradeSettlementDecisionEvent { + order_id: envelope.payload.order_id.clone(), + listing_addr: envelope.payload.listing_addr.clone(), + seller_pubkey: envelope.payload.seller_pubkey.clone(), + buyer_pubkey: envelope.payload.buyer_pubkey.clone(), + root_event_id: request_event.id.to_string(), + previous_event_id: payment_event.id.to_string(), + agreement_event_id: envelope.payload.agreement_event_id.clone(), + payment_event_id: payment_event.id.to_string(), + quote_id: envelope.payload.quote_id.clone(), + quote_version: envelope.payload.quote_version, + economics_digest: envelope.payload.economics_digest.clone(), + amount: envelope.payload.amount, + currency: envelope.payload.currency, + decision, + reason: (decision == RadrootsTradeSettlementDecision::Rejected) + .then(|| "reference mismatch".to_owned()), + }; + let parts = active_trade_settlement_decision_event_build( + payload.root_event_id.as_str(), + payload.previous_event_id.as_str(), + &payload, + ) + .expect("settlement decision parts"); + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("nostr event builder") + .sign_with_keys(seller.keys()) + .expect("signed settlement decision") + } + fn signed_malformed_order_request_event( buyer: &RadrootsIdentity, order_id: &str, diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -300,6 +300,21 @@ pub struct OrderRevisionDecisionArgs { pub idempotency_key: Option<String>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrderSettlementDecisionArg { + Accept, + Reject, +} + +#[derive(Debug, Clone)] +pub struct OrderSettlementArgs { + pub key: String, + pub payment_event_id: String, + pub decision: OrderSettlementDecisionArg, + pub reason: Option<String>, + pub idempotency_key: Option<String>, +} + #[derive(Debug, Clone)] pub struct OrderStatusArgs { pub key: String, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -193,6 +193,10 @@ impl TargetCommand { OrderCommand::Payment(payment) => match &payment.command { OrderPaymentCommand::Record(_) => "order.payment.record", }, + OrderCommand::Settlement(settlement) => match &settlement.command { + OrderSettlementCommand::Accept(_) => "order.settlement.accept", + OrderSettlementCommand::Reject(_) => "order.settlement.reject", + }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(_) => "order.status.get", }, @@ -756,6 +760,7 @@ pub enum OrderCommand { Fulfillment(OrderFulfillmentArgs), Receipt(OrderReceiptArgs), Payment(OrderPaymentArgs), + Settlement(OrderSettlementArgs), Status(OrderStatusArgs), Event(OrderEventArgs), } @@ -939,6 +944,34 @@ impl OrderPaymentMethodArg { } #[derive(Debug, Clone, Args)] +pub struct OrderSettlementArgs { + #[command(subcommand)] + pub command: OrderSettlementCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum OrderSettlementCommand { + Accept(OrderSettlementAcceptArgs), + Reject(OrderSettlementRejectArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct OrderSettlementAcceptArgs { + pub order_id: Option<String>, + #[arg(long)] + pub payment_event_id: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct OrderSettlementRejectArgs { + pub order_id: Option<String>, + #[arg(long)] + pub payment_event_id: Option<String>, + #[arg(long)] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct OrderStatusArgs { #[command(subcommand)] pub command: OrderStatusCommand, @@ -975,8 +1008,8 @@ mod tests { use super::{ OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, OrderPaymentCommand, - OrderPaymentMethodArg, OrderReceiptCommand, OrderRevisionCommand, TargetCliArgs, - TargetOutputFormat, + OrderPaymentMethodArg, OrderReceiptCommand, OrderRevisionCommand, OrderSettlementCommand, + TargetCliArgs, TargetOutputFormat, }; use crate::operation_registry::OPERATION_REGISTRY; @@ -1284,6 +1317,46 @@ mod tests { } #[test] + fn target_parser_accepts_order_settlement_decisions() { + let accept = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "settlement", + "accept", + "ord_test", + "--payment-event-id", + "pay_event", + ]) + .expect("target args parse"); + assert_eq!(accept.command.operation_id(), "order.settlement.accept"); + let crate::target_cli::TargetCommand::Order(order) = accept.command else { + panic!("expected order command") + }; + let OrderCommand::Settlement(settlement) = order.command else { + panic!("expected order settlement command") + }; + let OrderSettlementCommand::Accept(args) = settlement.command else { + panic!("expected settlement accept command") + }; + assert_eq!(args.order_id.as_deref(), Some("ord_test")); + assert_eq!(args.payment_event_id.as_deref(), Some("pay_event")); + + let reject = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "settlement", + "reject", + "ord_test", + "--payment-event-id", + "pay_event", + "--reason", + "reference mismatch", + ]) + .expect("target args parse"); + assert_eq!(reject.command.operation_id(), "order.settlement.reject"); + } + + #[test] fn target_parser_rejects_removed_global_flags() { let rejected = [ vec!["radroots", "--output", "json", "config", "get"], diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -205,6 +205,38 @@ fn seller_order_decision_and_status_commands_are_public() { ] .as_slice(), ), + ( + "order.settlement.accept", + [ + "--format", + "json", + "--dry-run", + "order", + "settlement", + "accept", + "ord_public", + "--payment-event-id", + "1", + ] + .as_slice(), + ), + ( + "order.settlement.reject", + [ + "--format", + "json", + "--dry-run", + "order", + "settlement", + "reject", + "ord_public", + "--payment-event-id", + "1", + "--reason", + "reference mismatch", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -642,6 +674,38 @@ fn offline_forbids_external_network_operations() { ] .as_slice(), ), + ( + "order.settlement.accept", + [ + "--format", + "json", + "--offline", + "order", + "settlement", + "accept", + "ord_offline_settlement", + "--payment-event-id", + "pay_event", + ] + .as_slice(), + ), + ( + "order.settlement.reject", + [ + "--format", + "json", + "--offline", + "order", + "settlement", + "reject", + "ord_offline_settlement", + "--payment-event-id", + "pay_event", + "--reason", + "reference mismatch", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -831,6 +895,40 @@ fn offline_rejects_order_decision_dry_run() { ] .as_slice(), ), + ( + "order.settlement.accept", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "settlement", + "accept", + "ord_offline_decision", + "--payment-event-id", + "pay_event", + ] + .as_slice(), + ), + ( + "order.settlement.reject", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "settlement", + "reject", + "ord_offline_decision", + "--payment-event-id", + "pay_event", + "--reason", + "reference mismatch", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -1047,6 +1145,38 @@ fn online_requires_relay_for_external_network_operations() { ] .as_slice(), ), + ( + "order.settlement.accept", + [ + "--format", + "json", + "--online", + "order", + "settlement", + "accept", + "ord_missing", + "--payment-event-id", + "pay_event", + ] + .as_slice(), + ), + ( + "order.settlement.reject", + [ + "--format", + "json", + "--online", + "order", + "settlement", + "reject", + "ord_missing", + "--payment-event-id", + "pay_event", + "--reason", + "reference mismatch", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -1551,6 +1681,48 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { "order.receipt.record", &["order", "receipt", "record", "ord_pending", "--received"], ); + assert_required_approval_token_rejected( + &sandbox, + "order.payment.record", + &[ + "order", + "payment", + "record", + "ord_pending", + "--amount", + "12", + "--currency", + "USD", + "--method", + "cash", + ], + ); + assert_required_approval_token_rejected( + &sandbox, + "order.settlement.accept", + &[ + "order", + "settlement", + "accept", + "ord_pending", + "--payment-event-id", + "pay_pending", + ], + ); + assert_required_approval_token_rejected( + &sandbox, + "order.settlement.reject", + &[ + "order", + "settlement", + "reject", + "ord_pending", + "--payment-event-id", + "pay_pending", + "--reason", + "reference mismatch", + ], + ); } fn assert_required_approval_token_rejected(