cli

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

commit 0893d2b072d83056745191e9ac9c8450b5ede2ac
parent bac1ea987aaf4aa2a46c14fe26b433fdb63a612c
Author: triesap <tyson@radroots.org>
Date:   Tue,  5 May 2026 19:07:20 +0000

cli: add buyer payment record command

- add target operation and parser support for order payment record
- require entered payment amount and currency to match accepted economics
- reject missing input, wrong actor, terminal, duplicate, offline, and relay gaps
- cover payment parser, adapter, preflight, dry-run, and status projection tests

Diffstat:
Msrc/domain/runtime.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 5+++++
Msrc/operation_adapter.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/operation_order.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/operation_registry.rs | 20+++++++++++++++++++-
Msrc/runtime/order.rs | 1336+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/runtime_args.rs | 11+++++++++++
Msrc/target_cli.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/target_cli.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1847 insertions(+), 107 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1636,6 +1636,88 @@ impl OrderRevisionDecisionView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderPaymentView { + 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 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 method: Option<RadrootsTradePaymentMethod>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reference: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub paid_at: Option<u64>, + #[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(skip_serializing_if = "Option::is_none")] + pub reason: 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 OrderPaymentView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "missing" => CommandDisposition::NotFound, + "invalid" | "requested" | "declined" | "cancelled" | "terminal" | "forked" => { + 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 @@ -290,6 +290,9 @@ fn execute_request( TargetOperationRequest::OrderReceiptRecord(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::OrderPaymentRecord(request) => { + execute_with(OrderOperationService::new(config), request) + } TargetOperationRequest::OrderStatusGet(request) => { execute_with(OrderOperationService::new(config), request) } @@ -417,6 +420,7 @@ fn dry_run_requires_network(operation_id: &str) -> bool { | "order.revision.decline" | "order.fulfillment.update" | "order.receipt.record" + | "order.payment.record" ) } @@ -439,6 +443,7 @@ fn external_network_operation(operation_id: &str) -> bool { | "order.revision.decline" | "order.fulfillment.update" | "order.receipt.record" + | "order.payment.record" | "order.status.get" | "order.event.list" | "order.event.watch" diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1055,8 +1055,8 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, - OrderFulfillmentCommand, OrderReceiptCommand, OrderRevisionCommand, OrderStatusCommand, - TargetCommand, + OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, + OrderStatusCommand, TargetCommand, }; let mut input = OperationData::new(); @@ -1260,6 +1260,26 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "issue", &args.issue); } }, + OrderCommand::Payment(payment) => match &payment.command { + OrderPaymentCommand::Record(args) => { + insert_string(&mut input, "order_id", &args.order_id); + insert_string(&mut input, "amount", &args.amount); + insert_string(&mut input, "currency", &args.currency); + if let Some(method) = args.method { + input.insert( + "method".to_owned(), + Value::String(method.as_protocol_method().to_owned()), + ); + } + insert_string(&mut input, "reference", &args.reference); + if let Some(paid_at) = args.paid_at { + input.insert( + "paid_at".to_owned(), + Value::Number(serde_json::Number::from(paid_at)), + ); + } + } + }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(args) => { insert_string(&mut input, "order_id", &args.order_id) @@ -1371,6 +1391,7 @@ target_operation_contracts! { OrderRevisionDecline => (OrderRevisionDeclineRequest, OrderRevisionDeclineResult, "order.revision.decline"), OrderFulfillmentUpdate => (OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, "order.fulfillment.update"), OrderReceiptRecord => (OrderReceiptRecordRequest, OrderReceiptRecordResult, "order.receipt.record"), + OrderPaymentRecord => (OrderPaymentRecordRequest, OrderPaymentRecordResult, "order.payment.record"), OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"), OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"), OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"), @@ -1734,6 +1755,67 @@ mod tests { request.payload.input.get("issue").and_then(Value::as_str), Some("damaged items") ); + + let payment = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "payment", + "record", + "ord_test", + "--amount", + "12", + "--currency", + "USD", + "--method", + "manual_transfer", + "--reference", + "memo-1", + "--paid-at", + "1777666000", + ]) + .expect("target args parse"); + let request = + TargetOperationRequest::from_target_args(&payment).expect("operation request"); + let TargetOperationRequest::OrderPaymentRecord(request) = request else { + panic!("expected order payment record request") + }; + assert_eq!(request.operation_id(), "order.payment.record"); + assert_eq!( + request + .payload + .input + .get("order_id") + .and_then(Value::as_str), + Some("ord_test") + ); + assert_eq!( + request.payload.input.get("amount").and_then(Value::as_str), + Some("12") + ); + assert_eq!( + request + .payload + .input + .get("currency") + .and_then(Value::as_str), + Some("USD") + ); + assert_eq!( + request.payload.input.get("method").and_then(Value::as_str), + Some("manual_transfer") + ); + assert_eq!( + request + .payload + .input + .get("reference") + .and_then(Value::as_str), + Some("memo-1") + ); + assert_eq!( + request.payload.input.get("paid_at").and_then(Value::as_u64), + Some(1_777_666_000) + ); } #[test] diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -3,8 +3,8 @@ use serde_json::{Value, json}; use crate::domain::runtime::{ CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView, - OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, OrderStatusView, - OrderSubmitView, + OrderPaymentView, OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, + OrderStatusView, OrderSubmitView, }; use crate::operation_adapter::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, @@ -12,17 +12,18 @@ use crate::operation_adapter::{ OrderCancelRequest, OrderCancelResult, OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult, OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult, - OrderListRequest, OrderListResult, OrderReceiptRecordRequest, OrderReceiptRecordResult, - OrderRevisionAcceptRequest, OrderRevisionAcceptResult, OrderRevisionDeclineRequest, - OrderRevisionDeclineResult, OrderRevisionProposeRequest, OrderRevisionProposeResult, - OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult, + OrderListRequest, OrderListResult, OrderPaymentRecordRequest, OrderPaymentRecordResult, + OrderReceiptRecordRequest, OrderReceiptRecordResult, OrderRevisionAcceptRequest, + OrderRevisionAcceptResult, OrderRevisionDeclineRequest, OrderRevisionDeclineResult, + OrderRevisionProposeRequest, OrderRevisionProposeResult, OrderStatusGetRequest, + OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime_args::{ - OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderReceiptArgs, - OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs, - OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, + OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderPaymentArgs, + OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, + OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, }; pub struct OrderOperationService<'a> { @@ -435,6 +436,74 @@ impl OperationService<OrderReceiptRecordRequest> for OrderOperationService<'_> { } } +impl OperationService<OrderPaymentRecordRequest> for OrderOperationService<'_> { + type Result = OrderPaymentRecordResult; + + fn execute( + &self, + request: OperationRequest<OrderPaymentRecordRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let amount = string_input(&request, "amount") + .map(|amount| amount.trim().to_owned()) + .filter(|amount| !amount.is_empty()) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + "missing required payment amount input".to_owned(), + ) + })?; + let currency = string_input(&request, "currency") + .map(|currency| currency.trim().to_owned()) + .filter(|currency| !currency.is_empty()) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + "missing required payment currency input".to_owned(), + ) + })?; + let method = string_input(&request, "method") + .map(|method| method.trim().to_owned()) + .filter(|method| !method.is_empty()) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + "missing required payment method input".to_owned(), + ) + })?; + let reference = string_input(&request, "reference") + .map(|reference| reference.trim().to_owned()) + .filter(|reference| !reference.is_empty()); + let paid_at = u64_input(&request, "paid_at"); + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let args = OrderPaymentArgs { + key: required_order_key(&request)?, + amount, + currency, + method, + reference, + paid_at, + 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::payment_record(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + payment_result::<OrderPaymentRecordResult>(request.operation_id(), &view) + } +} + impl OperationService<OrderStatusGetRequest> for OrderOperationService<'_> { type Result = OrderStatusGetResult; @@ -990,6 +1059,100 @@ where } } +fn payment_result<R>( + operation_id: &str, + view: &OrderPaymentView, +) -> 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 payment record failed validation with state `{}`", + view.state + ) + }); + Err(OperationAdapterError::validation_failed_with_detail( + operation_id, + message, + order_payment_error_detail(view), + )) + } + disposition => { + let message = view.reason.clone().unwrap_or_else(|| { + format!("order payment record finished with state `{}`", view.state) + }); + if disposition == CommandDisposition::ExternalUnavailable { + let detail = order_payment_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_payment_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, + "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, + "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, + "method": &view.method, + "reference": &view.reference, + "paid_at": &view.paid_at, + "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, @@ -1253,8 +1416,9 @@ mod tests { OperationAdapter, OperationContext, OperationData, OperationRequest, OrderAcceptRequest, OrderAcceptResult, OrderCancelRequest, OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventWatchRequest, OrderGetRequest, OrderListRequest, - OrderReceiptRecordRequest, OrderRevisionAcceptRequest, OrderRevisionDeclineRequest, - OrderRevisionProposeRequest, OrderStatusGetRequest, OrderSubmitRequest, + OrderPaymentRecordRequest, OrderReceiptRecordRequest, OrderRevisionAcceptRequest, + OrderRevisionDeclineRequest, OrderRevisionProposeRequest, OrderStatusGetRequest, + OrderSubmitRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -1594,6 +1758,68 @@ mod tests { } #[test] + fn order_payment_record_requires_amount_before_approval() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let payment = OperationRequest::new( + OperationContext::default(), + OrderPaymentRecordRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("currency", "USD"), + ("method", "cash"), + ])), + ) + .expect("order payment request"); + let error = service.execute(payment).expect_err("amount required"); + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "invalid_input"); + assert!(output_error.message.contains("amount")); + } + + #[test] + fn order_payment_record_requires_method_before_approval() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let payment = OperationRequest::new( + OperationContext::default(), + OrderPaymentRecordRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("amount", "12"), + ("currency", "USD"), + ])), + ) + .expect("order payment request"); + let error = service.execute(payment).expect_err("method required"); + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "invalid_input"); + assert!(output_error.message.contains("method")); + } + + #[test] + fn order_payment_record_requires_approval_token() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let payment = OperationRequest::new( + OperationContext::default(), + OrderPaymentRecordRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("amount", "12"), + ("currency", "USD"), + ("method", "cash"), + ])), + ) + .expect("order payment request"); + let error = service.execute(payment).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 @@ -992,6 +992,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ true ), operation!( + "order.payment.record", + "radroots order payment record", + "order", + "order_payment_record", + "OrderPaymentRecordRequest", + "OrderPaymentRecordResult", + "Record buyer manual payment.", + Buyer, + true, + Required, + High, + false, + true + ), + operation!( "order.status.get", "radroots order status get", "order", @@ -1121,6 +1136,7 @@ mod tests { "order.revision.decline", "order.fulfillment.update", "order.receipt.record", + "order.payment.record", "order.status.get", "order.event.list", "order.event.watch", @@ -1163,6 +1179,7 @@ mod tests { "order.revision.decline", "order.fulfillment.update", "order.receipt.record", + "order.payment.record", ]; const INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[]; @@ -1175,7 +1192,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 64); + assert_eq!(OPERATION_REGISTRY.len(), 65); } #[test] @@ -1230,6 +1247,7 @@ mod tests { "order.revision.decline", "order.fulfillment.update", "order.receipt.record", + "order.payment.record", ] .into_iter() .collect::<BTreeSet<_>>(); diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -12,7 +12,7 @@ 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_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, - KIND_TRADE_RECEIPT, + KIND_TRADE_PAYMENT_RECORDED, KIND_TRADE_RECEIPT, KIND_TRADE_SETTLEMENT_DECISION, }; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingStatus, @@ -25,7 +25,7 @@ use radroots_events::trade::{ RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, RadrootsTradeOrderRevisionProposed, - RadrootsTradePricingBasis, + RadrootsTradePaymentMethod, RadrootsTradePaymentRecorded, RadrootsTradePricingBasis, }; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::decode::listing_from_event; @@ -39,7 +39,8 @@ use radroots_events_codec::trade::{ active_trade_order_revision_decision_event_build, active_trade_order_revision_decision_from_event, active_trade_order_revision_proposal_event_build, - active_trade_order_revision_proposal_from_event, + 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, }; use radroots_events_codec::wire::WireEventParts; use radroots_nostr::prelude::{ @@ -66,19 +67,20 @@ use radroots_trade::order::{ RadrootsActiveOrderSettlementState, RadrootsActiveOrderStatus, RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryAccountingProjection, RadrootsListingInventoryBinAvailability, canonicalize_active_order_decision_for_signer, - canonicalize_active_order_request_for_signer, reduce_active_order_events, - reduce_listing_inventory_accounting, + canonicalize_active_order_request_for_signer, radroots_trade_order_economics_digest, + reduce_active_order_events, reduce_listing_inventory_accounting, }; use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderFulfillmentView, OrderGetView, OrderHistoryEntryView, OrderHistoryView, OrderInventoryBinView, - OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderReceiptView, - OrderRevisionDecisionView, OrderRevisionProposalView, OrderStatusFulfillmentView, - OrderStatusLifecycleCancellationView, OrderStatusLifecycleReceiptView, - OrderStatusLifecycleView, OrderStatusPaymentView, OrderStatusRevisionView, OrderStatusView, - OrderSubmitView, OrderSummaryView, OrderWatchView, RelayFailureView, + OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderPaymentView, + OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, + OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView, + OrderStatusLifecycleReceiptView, OrderStatusLifecycleView, OrderStatusPaymentView, + OrderStatusRevisionView, OrderStatusView, OrderSubmitView, OrderSummaryView, OrderWatchView, + RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -90,8 +92,9 @@ use crate::runtime::direct_relay::{ use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs, - OrderFulfillmentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, - OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, + OrderFulfillmentArgs, OrderPaymentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, + OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, + OrderWatchArgs, RecordLookupArgs, }; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; @@ -105,6 +108,7 @@ const ORDER_REVISION_DECISION_SOURCE: &str = const ORDER_FULFILLMENT_SOURCE: &str = "direct Nostr relay fulfillment publish · local key"; 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_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 = @@ -1482,6 +1486,95 @@ pub fn receipt_record( publish_order_receipt(config, args, status_view, signing, payload) } +pub fn payment_record( + config: &RuntimeConfig, + args: &OrderPaymentArgs, +) -> Result<OrderPaymentView, RuntimeError> { + if let Some(view) = order_payment_args_preflight_view(config, args) { + return Ok(view); + } + if config.relay.urls.is_empty() { + let mut view = order_payment_base_view(config, args, "unconfigured", config.output.dry_run); + view.reason = + Some("order payment 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_payment_base_view(config, args, "unconfigured", config.output.dry_run); + view.reason = Some("order payment 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_payment_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_payment_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("payable order is missing buyer_pubkey".to_owned()))?; + let signing = match resolve_local_order_payment_signing_identity(config, buyer_pubkey) { + Ok(signing) => signing, + Err(error) => { + return Ok(order_payment_binding_error_view( + config, + args, + &status_view, + error, + )); + } + }; + let payload = order_payment_payload_from_status(args, &status_view)?; + let _ = order_payment_event_parts(&status_view, &payload)?; + if config.output.dry_run { + return Ok(order_payment_dry_run_view( + config, + args, + &status_view, + &payload, + )); + } + publish_order_payment(config, args, status_view, signing, payload) +} + pub fn status( config: &RuntimeConfig, args: &OrderStatusArgs, @@ -1583,6 +1676,8 @@ enum OrderStatusRecord { Fulfillment(RadrootsActiveOrderFulfillmentRecord), Cancellation(RadrootsActiveOrderCancellationRecord), Receipt(RadrootsActiveOrderReceiptRecord), + Payment(RadrootsActiveOrderPaymentRecord), + Settlement(RadrootsActiveOrderSettlementRecord), } type OrderRevisionProposalRecord = RadrootsActiveOrderRevisionProposalRecord; @@ -1651,6 +1746,8 @@ fn order_status_reduction_from_receipt_with_context( let mut fulfillments = Vec::new(); let mut cancellations = Vec::new(); let mut receipts = Vec::new(); + let mut payments = Vec::new(); + let mut settlements = Vec::new(); let mut request_listing_events = Vec::new(); let mut candidate_issues = Vec::new(); @@ -1692,6 +1789,14 @@ fn order_status_reduction_from_receipt_with_context( decoded_count += 1; receipts.push(record); } + Ok(OrderStatusRecord::Payment(record)) => { + decoded_count += 1; + payments.push(record); + } + Ok(OrderStatusRecord::Settlement(record)) => { + decoded_count += 1; + settlements.push(record); + } Err(error) => { skipped_count += 1; if order_status_request_candidate(&event, context) { @@ -1729,8 +1834,8 @@ fn order_status_reduction_from_receipt_with_context( fulfillments, cancellations, receipts, - Vec::<RadrootsActiveOrderPaymentRecord>::new(), - Vec::<RadrootsActiveOrderSettlementRecord>::new(), + payments, + settlements, ); let fulfillment_event_id = projection.fulfillment_event_id.clone(); let fulfillment_status = projection.fulfillment_status; @@ -2444,6 +2549,55 @@ fn order_status_record_from_event( }, )) } + KIND_TRADE_PAYMENT_RECORDED => { + let event = radroots_event_from_nostr(event); + let envelope = active_trade_payment_recorded_from_event(&event).map_err(|error| { + RuntimeError::Config(format!("decode active payment recorded event: {error}")) + })?; + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradePaymentRecorded, + &event.tags, + ) + .map_err(|error| { + RuntimeError::Config(format!("decode active payment recorded tags: {error}")) + })?; + Ok(OrderStatusRecord::Payment( + RadrootsActiveOrderPaymentRecord { + 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, + }, + )) + } + KIND_TRADE_SETTLEMENT_DECISION => { + let event = radroots_event_from_nostr(event); + let envelope = + active_trade_settlement_decision_from_event(&event).map_err(|error| { + RuntimeError::Config(format!( + "decode active settlement decision event: {error}" + )) + })?; + let context = active_trade_event_context_from_tags( + RadrootsActiveTradeMessageType::TradeSettlementDecision, + &event.tags, + ) + .map_err(|error| { + RuntimeError::Config(format!("decode active settlement decision tags: {error}")) + })?; + Ok(OrderStatusRecord::Settlement( + RadrootsActiveOrderSettlementRecord { + 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}`" ))), @@ -2517,6 +2671,36 @@ fn active_order_settlement_state(status: &RadrootsActiveOrderSettlementState) -> } } +fn parse_payment_method(value: &str) -> Result<RadrootsTradePaymentMethod, RuntimeError> { + match value.trim() { + "cash" => Ok(RadrootsTradePaymentMethod::Cash), + "manual_transfer" => Ok(RadrootsTradePaymentMethod::ManualTransfer), + "other" => Ok(RadrootsTradePaymentMethod::Other), + other => Err(RuntimeError::Config(format!( + "unsupported payment method `{other}`" + ))), + } +} + +fn parse_payment_amount(value: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { + let parsed = value + .trim() + .parse::<RadrootsCoreDecimal>() + .map_err(|error| RuntimeError::Config(format!("payment amount is invalid: {error}")))?; + if parsed.is_zero() || parsed.is_sign_negative() { + return Err(RuntimeError::Config( + "payment amount must be greater than zero".to_owned(), + )); + } + Ok(parsed) +} + +fn parse_payment_currency(value: &str) -> Result<RadrootsCoreCurrency, RuntimeError> { + value + .parse::<RadrootsCoreCurrency>() + .map_err(|error| RuntimeError::Config(format!("payment currency is invalid: {error}"))) +} + fn active_order_status_reason( status: &RadrootsActiveOrderStatus, order_id: &str, @@ -4112,6 +4296,52 @@ fn order_receipt_base_view( } } +fn order_payment_base_view( + config: &RuntimeConfig, + args: &OrderPaymentArgs, + state: &str, + dry_run: bool, +) -> OrderPaymentView { + OrderPaymentView { + state: state.to_owned(), + source: ORDER_PAYMENT_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, + event_id: None, + event_kind: None, + quote_id: None, + quote_version: None, + economics_digest: None, + amount: parse_payment_amount(args.amount.as_str()).ok(), + currency: parse_payment_currency(args.currency.as_str()).ok(), + method: parse_payment_method(args.method.as_str()).ok(), + reference: args + .reference + .as_ref() + .map(|reference| reference.trim().to_owned()), + paid_at: args.paid_at, + 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(); @@ -4171,6 +4401,31 @@ fn apply_order_receipt_status(view: &mut OrderReceiptView, status: &OrderStatusV view.issues = status.reducer_issues.clone(); } +fn apply_order_payment_status(view: &mut OrderPaymentView, 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.agreement_event_id = status.agreement_event_id.clone(); + view.root_event_id = status.request_event_id.clone(); + view.prev_event_id = order_payment_prev_event_id(status); + if let Some(economics) = status.economics.as_ref() { + view.quote_id = Some(economics.quote_id.clone()); + view.quote_version = Some(economics.quote_version); + view.economics_digest = radroots_trade_order_economics_digest(economics).ok(); + view.amount = Some(economics.total.amount); + view.currency = Some(economics.total.currency); + } + 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") { @@ -4181,6 +4436,19 @@ fn order_receipt_prev_event_id(status: &OrderStatusView) -> Option<String> { }) } +fn order_payment_prev_event_id(status: &OrderStatusView) -> Option<String> { + status.payment.as_ref().and_then(|payment| { + if payment.state == "rejected" { + payment + .settlement_event_id + .clone() + .or_else(|| status.agreement_event_id.clone()) + } else { + status.agreement_event_id.clone() + } + }) +} + fn order_cancellation_prev_event_id(status: &OrderStatusView) -> Option<String> { match status.state.as_str() { "requested" => status.request_event_id.clone(), @@ -4371,52 +4639,272 @@ fn order_receipt_preflight_view_from_status( Some(view) } -fn order_fulfillment_preflight_view_from_status( +fn order_payment_args_preflight_view( config: &RuntimeConfig, - args: &OrderFulfillmentArgs, + args: &OrderPaymentArgs, +) -> Option<OrderPaymentView> { + let (reason, issue) = if args.amount.trim().is_empty() { + ( + "order payment record requires --amount".to_owned(), + issue_with_code( + "missing_payment_amount", + "amount", + "payment amount is required", + ), + ) + } else if parse_payment_amount(args.amount.as_str()).is_err() { + ( + format!( + "order payment record received invalid amount `{}`", + args.amount + ), + issue_with_code( + "invalid_payment_amount", + "amount", + "payment amount must be greater than zero", + ), + ) + } else if args.currency.trim().is_empty() { + ( + "order payment record requires --currency".to_owned(), + issue_with_code( + "missing_payment_currency", + "currency", + "payment currency is required", + ), + ) + } else if parse_payment_currency(args.currency.as_str()).is_err() { + ( + format!( + "order payment record received invalid currency `{}`", + args.currency + ), + issue_with_code( + "invalid_payment_currency", + "currency", + "payment currency must be a 3-letter code", + ), + ) + } else if args.method.trim().is_empty() { + ( + "order payment record requires --method".to_owned(), + issue_with_code( + "missing_payment_method", + "method", + "payment method is required", + ), + ) + } else if parse_payment_method(args.method.as_str()).is_err() { + ( + format!( + "order payment record received unsupported method `{}`", + args.method + ), + issue_with_code( + "invalid_payment_method", + "method", + "payment method must be cash, manual_transfer, or other", + ), + ) + } else { + return None; + }; + let mut view = order_payment_base_view(config, args, "invalid", config.output.dry_run); + view.reason = Some(reason); + view.issues = vec![issue]; + Some(view) +} + +fn order_payment_preflight_view_from_status( + config: &RuntimeConfig, + args: &OrderPaymentArgs, status: &OrderStatusView, - current_fulfillment_status: Option<RadrootsActiveTradeFulfillmentState>, - current_fulfillment_event_id: Option<&str>, -) -> Option<OrderFulfillmentView> { + selected_pubkey: &str, +) -> Option<OrderPaymentView> { + let buyer_matches = status + .buyer_pubkey + .as_deref() + .is_some_and(|buyer| buyer.eq_ignore_ascii_case(selected_pubkey)); + let payment_state = status + .payment + .as_ref() + .map(|payment| payment.state.as_str()) + .unwrap_or("not_recorded"); + let payment_open = matches!(payment_state, "not_recorded" | "rejected"); let state = match status.state.as_str() { - "accepted" => { - if matches!( - current_fulfillment_status, - Some( - RadrootsActiveTradeFulfillmentState::Delivered - | RadrootsActiveTradeFulfillmentState::SellerCancelled - ) - ) { - "invalid" - } else { - return None; + "accepted" | "completed" | "disputed" if buyer_matches && payment_open => { + if let Some(view) = order_payment_terms_preflight_view_from_status(config, args, status) + { + return Some(view); } + return None; } - "missing" | "requested" | "declined" | "invalid" | "unavailable" | "unconfigured" => { - status.state.as_str() - } - _ => return None, + "accepted" | "completed" | "disputed" if buyer_matches => payment_state, + "missing" | "requested" | "declined" | "cancelled" | "invalid" | "unavailable" + | "unconfigured" => status.state.as_str(), + _ => "invalid", }; - let mut view = order_fulfillment_base_view(config, args, state, config.output.dry_run); - apply_order_fulfillment_status(&mut view, status); + let mut view = order_payment_base_view(config, args, state, config.output.dry_run); + apply_order_payment_status(&mut view, status); + if let Some(payment) = status.payment.as_ref() { + view.event_id = payment.payment_event_id.clone(); + view.event_kind = payment + .payment_event_id + .as_ref() + .map(|_| KIND_TRADE_PAYMENT_RECORDED); + view.quote_id = payment.quote_id.clone().or(view.quote_id); + view.quote_version = payment.quote_version.or(view.quote_version); + view.economics_digest = payment.economics_digest.clone().or(view.economics_digest); + view.amount = payment.amount.or(view.amount); + view.currency = payment.currency.or(view.currency); + view.method = payment.method.or(view.method); + view.reference = payment.reference.clone().or(view.reference); + view.paid_at = payment.paid_at.or(view.paid_at); + } view.reason = Some(match state { "missing" => format!("no active order events matched `{}`", args.key), "requested" => format!( - "order fulfillment update refused because order `{}` has no accepted seller decision", + "order payment record refused because order `{}` has no accepted seller decision", args.key ), "declined" => format!( - "order fulfillment update refused because order `{}` was declined", + "order payment record refused because order `{}` was declined", args.key ), - "invalid" - if matches!( - current_fulfillment_status, - Some( - RadrootsActiveTradeFulfillmentState::Delivered - | RadrootsActiveTradeFulfillmentState::SellerCancelled - ) - ) => + "cancelled" => format!( + "order payment record refused because order `{}` was cancelled", + args.key + ), + "recorded" | "settled" => format!( + "order payment record skipped because order `{}` already has payment state `{state}`", + args.key + ), + "invalid" if !buyer_matches && status.buyer_pubkey.is_some() => format!( + "order payment record refused because selected account is not buyer for order `{}`", + args.key + ), + "invalid" => status.reason.clone().unwrap_or_else(|| { + format!( + "order payment record refused because active order events for `{}` are invalid", + args.key + ) + }), + _ => status.reason.clone().unwrap_or_else(|| { + format!( + "order payment record status preflight failed with state `{}`", + status.state + ) + }), + }); + view.actions = vec![format!("radroots order status get {}", args.key)]; + Some(view) +} + +fn order_payment_terms_preflight_view_from_status( + config: &RuntimeConfig, + args: &OrderPaymentArgs, + status: &OrderStatusView, +) -> Option<OrderPaymentView> { + let requested_amount = parse_payment_amount(args.amount.as_str()).ok()?; + let requested_currency = parse_payment_currency(args.currency.as_str()).ok()?; + let Some(economics) = status.economics.as_ref() else { + let mut view = order_payment_base_view(config, args, "invalid", config.output.dry_run); + apply_order_payment_status(&mut view, status); + view.reason = Some(format!( + "order payment record refused because order `{}` has no accepted economics", + args.key + )); + view.issues = vec![issue_with_code( + "missing_payment_economics", + "amount", + "active order has no accepted economics for payment comparison", + )]; + view.actions = vec![format!("radroots order status get {}", args.key)]; + return Some(view); + }; + if requested_amount != economics.total.amount { + let mut view = order_payment_base_view(config, args, "invalid", config.output.dry_run); + apply_order_payment_status(&mut view, status); + view.amount = Some(requested_amount); + view.currency = Some(requested_currency); + view.reason = Some(format!( + "order payment record refused because amount `{}` does not match current agreement total `{}`", + args.amount, economics.total.amount + )); + view.issues = vec![issue_with_code( + "payment_amount_mismatch", + "amount", + "payment amount must match the current accepted agreement total", + )]; + view.actions = vec![format!("radroots order status get {}", args.key)]; + return Some(view); + } + if requested_currency != economics.total.currency { + let mut view = order_payment_base_view(config, args, "invalid", config.output.dry_run); + apply_order_payment_status(&mut view, status); + view.amount = Some(requested_amount); + view.currency = Some(requested_currency); + view.reason = Some(format!( + "order payment record refused because currency `{}` does not match current agreement currency `{}`", + args.currency, economics.total.currency + )); + view.issues = vec![issue_with_code( + "payment_currency_mismatch", + "currency", + "payment currency must match the current accepted agreement currency", + )]; + view.actions = vec![format!("radroots order status get {}", args.key)]; + return Some(view); + } + None +} + +fn order_fulfillment_preflight_view_from_status( + config: &RuntimeConfig, + args: &OrderFulfillmentArgs, + status: &OrderStatusView, + current_fulfillment_status: Option<RadrootsActiveTradeFulfillmentState>, + current_fulfillment_event_id: Option<&str>, +) -> Option<OrderFulfillmentView> { + let state = match status.state.as_str() { + "accepted" => { + if matches!( + current_fulfillment_status, + Some( + RadrootsActiveTradeFulfillmentState::Delivered + | RadrootsActiveTradeFulfillmentState::SellerCancelled + ) + ) { + "invalid" + } else { + return None; + } + } + "missing" | "requested" | "declined" | "invalid" | "unavailable" | "unconfigured" => { + status.state.as_str() + } + _ => return None, + }; + let mut view = order_fulfillment_base_view(config, args, state, config.output.dry_run); + apply_order_fulfillment_status(&mut view, status); + view.reason = Some(match state { + "missing" => format!("no active order events matched `{}`", args.key), + "requested" => format!( + "order fulfillment update refused because order `{}` has no accepted seller decision", + args.key + ), + "declined" => format!( + "order fulfillment update refused because order `{}` was declined", + args.key + ), + "invalid" + if matches!( + current_fulfillment_status, + Some( + RadrootsActiveTradeFulfillmentState::Delivered + | RadrootsActiveTradeFulfillmentState::SellerCancelled + ) + ) => { let current = current_fulfillment_status .map(fulfillment_state_name) @@ -6162,6 +6650,95 @@ fn order_receipt_event_parts( .map_err(|error| RuntimeError::Config(format!("encode buyer receipt event: {error}"))) } +fn order_payment_payload_from_status( + args: &OrderPaymentArgs, + status: &OrderStatusView, +) -> Result<RadrootsTradePaymentRecorded, RuntimeError> { + let economics = status + .economics + .as_ref() + .ok_or_else(|| RuntimeError::Config("payable order is missing economics".to_owned()))?; + let agreement_event_id = status.agreement_event_id.clone().ok_or_else(|| { + RuntimeError::Config("payable order is missing agreement_event_id".to_owned()) + })?; + let amount = parse_payment_amount(args.amount.as_str())?; + let currency = parse_payment_currency(args.currency.as_str())?; + if amount != economics.total.amount { + return Err(RuntimeError::Config( + "payment amount must match accepted agreement total".to_owned(), + )); + } + if currency != economics.total.currency { + return Err(RuntimeError::Config( + "payment currency must match accepted agreement currency".to_owned(), + )); + } + Ok(RadrootsTradePaymentRecorded { + order_id: status.order_id.clone(), + listing_addr: status.listing_addr.clone().ok_or_else(|| { + RuntimeError::Config("payable order is missing listing_addr".to_owned()) + })?, + buyer_pubkey: status.buyer_pubkey.clone().ok_or_else(|| { + RuntimeError::Config("payable order is missing buyer_pubkey".to_owned()) + })?, + seller_pubkey: status.seller_pubkey.clone().ok_or_else(|| { + RuntimeError::Config("payable order is missing seller_pubkey".to_owned()) + })?, + root_event_id: status.request_event_id.clone().ok_or_else(|| { + RuntimeError::Config("payable order is missing request_event_id".to_owned()) + })?, + previous_event_id: order_payment_prev_event_id(status).ok_or_else(|| { + RuntimeError::Config("payable order is missing payment previous event id".to_owned()) + })?, + agreement_event_id, + quote_id: economics.quote_id.clone(), + quote_version: economics.quote_version, + economics_digest: radroots_trade_order_economics_digest(economics) + .map_err(|error| RuntimeError::Config(error.to_string()))?, + amount, + currency, + method: parse_payment_method(args.method.as_str())?, + reference: args + .reference + .as_deref() + .map(str::trim) + .filter(|reference| !reference.is_empty()) + .map(str::to_owned), + paid_at: args.paid_at, + }) +} + +fn order_payment_event_parts( + status: &OrderStatusView, + payload: &RadrootsTradePaymentRecorded, +) -> Result<WireEventParts, RuntimeError> { + let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { + RuntimeError::Config("payable order is missing request_event_id".to_owned()) + })?; + let prev_event_id = order_payment_prev_event_id(status).ok_or_else(|| { + RuntimeError::Config("payable order is missing payment previous event id".to_owned()) + })?; + active_trade_payment_recorded_event_build(root_event_id, prev_event_id.as_str(), payload) + .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, @@ -6191,6 +6768,20 @@ fn order_receipt_dry_run_view( view } +fn order_payment_dry_run_view( + config: &RuntimeConfig, + args: &OrderPaymentArgs, + status: &OrderStatusView, + payload: &RadrootsTradePaymentRecorded, +) -> OrderPaymentView { + let mut view = order_payment_base_view(config, args, "dry_run", true); + apply_order_payment_status(&mut view, status); + apply_order_payment_payload(&mut view, payload); + view.reason = Some("dry run requested; buyer payment publication skipped".to_owned()); + view.actions = vec![format!("radroots order status get {}", status.order_id)]; + view +} + fn publish_order_revision( config: &RuntimeConfig, args: &OrderRevisionProposeArgs, @@ -6345,6 +6936,22 @@ fn publish_order_receipt( )) } +fn publish_order_payment( + config: &RuntimeConfig, + args: &OrderPaymentArgs, + status: OrderStatusView, + signing: accounts::AccountSigningIdentity, + payload: RadrootsTradePaymentRecorded, +) -> Result<OrderPaymentView, RuntimeError> { + let parts = order_payment_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_payment_view( + config, args, &status, &payload, event_kind, receipt, + )) +} + fn published_order_fulfillment_view( config: &RuntimeConfig, args: &OrderFulfillmentArgs, @@ -6432,6 +7039,33 @@ fn published_order_receipt_view( view } +fn published_order_payment_view( + config: &RuntimeConfig, + args: &OrderPaymentArgs, + status: &OrderStatusView, + payload: &RadrootsTradePaymentRecorded, + event_kind: u32, + receipt: DirectRelayPublishReceipt, +) -> OrderPaymentView { + let DirectRelayPublishReceipt { + event_id, + created_at: _, + signature: _, + target_relays, + acknowledged_relays, + failed_relays, + } = receipt; + let mut view = order_payment_base_view(config, args, "recorded", false); + apply_order_payment_status(&mut view, status); + apply_order_payment_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, @@ -6534,6 +7168,26 @@ fn order_receipt_binding_error_view( view } +fn order_payment_binding_error_view( + config: &RuntimeConfig, + args: &OrderPaymentArgs, + status: &OrderStatusView, + error: ActorWriteBindingError, +) -> OrderPaymentView { + 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_payment_base_view(config, args, state.as_str(), config.output.dry_run); + apply_order_payment_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, @@ -6954,6 +7608,8 @@ fn order_status_filter(order_id: &str) -> Result<RadrootsNostrFilter, RuntimeErr 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), + radroots_nostr_kind(KIND_TRADE_PAYMENT_RECORDED as u16), + radroots_nostr_kind(KIND_TRADE_SETTLEMENT_DECISION as u16), ]) .limit(1_000); radroots_nostr_filter_tag(filter, "d", vec![order_id.to_owned()]) @@ -8633,6 +9289,31 @@ fn resolve_local_order_receipt_signing_identity( Ok(signing) } +fn resolve_local_order_payment_signing_identity( + config: &RuntimeConfig, + buyer_pubkey: &str, +) -> Result<accounts::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured( + "order payment 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 resolve_local_order_revision_decision_signing_identity( config: &RuntimeConfig, buyer_pubkey: &str, @@ -8944,7 +9625,8 @@ mod tests { use radroots_events::RadrootsNostrEventPtr; 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_RECEIPT, + KIND_TRADE_ORDER_REVISION, KIND_TRADE_ORDER_REVISION_RESPONSE, KIND_TRADE_PAYMENT_RECORDED, + KIND_TRADE_RECEIPT, }; use radroots_events::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType, @@ -8953,7 +9635,8 @@ mod tests { RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionDecision, RadrootsTradeOrderRevisionDecisionEvent, - RadrootsTradeOrderRevisionProposed, RadrootsTradePricingBasis, + RadrootsTradeOrderRevisionProposed, RadrootsTradePaymentMethod, + RadrootsTradePaymentRecorded, RadrootsTradePricingBasis, }; use radroots_events_codec::trade::{ active_trade_buyer_receipt_event_build, active_trade_event_context_from_tags, @@ -8961,6 +9644,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, }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event}; @@ -8988,10 +9672,11 @@ mod tests { order_decision_preflight_view_from_status, order_decision_view_from_resolution, order_economics_from_resolved_listing, order_fulfillment_dry_run_view, order_fulfillment_preflight_view_from_status, order_history_entry_from_event, - 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_revision_decision_event_parts, - order_revision_decision_payload_from_proposal, + order_history_from_receipt, order_payment_dry_run_view, order_payment_event_parts, + order_payment_payload_from_status, order_payment_preflight_view_from_status, + order_receipt_dry_run_view, order_receipt_event_parts, order_receipt_payload_from_status, + order_receipt_preflight_view_from_status, order_request_filter, + order_revision_decision_event_parts, order_revision_decision_payload_from_proposal, 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, @@ -9012,7 +9697,7 @@ mod tests { use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftAdjustmentArgs, - OrderFulfillmentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, + OrderFulfillmentArgs, OrderPaymentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderSubmitArgs, }; @@ -9312,6 +9997,8 @@ mod tests { assert!(kinds.contains(&serde_json::json!(3433))); assert!(kinds.contains(&serde_json::json!(3432))); assert!(kinds.contains(&serde_json::json!(3434))); + assert!(kinds.contains(&serde_json::json!(3435))); + assert!(kinds.contains(&serde_json::json!(3436))); assert_eq!(value["#d"][0], "ord_AAAAAAAAAAAAAAAAAAAAAg"); } @@ -11773,11 +12460,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_payment_event_parts_bind_current_agreement_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, @@ -11792,52 +12478,465 @@ mod tests { }], }, ); - 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, - ], + events: vec![fixture.request_event.clone(), decision_event.clone()], }, ); - 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"); + let args = payment_args_for_fixture(&fixture); - assert_eq!(view.state, "invalid"); assert!( - view.reason - .as_deref() - .expect("reason") - .contains("no eligible seller fulfillment") + order_payment_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str() + ) + .is_none() ); - assert!(view.event_id.is_none()); - } + let payload = + order_payment_payload_from_status(&args, &status_view).expect("payment payload"); + let parts = order_payment_event_parts(&status_view, &payload).expect("payment 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::TradePaymentRecorded, + &parts.tags, + ) + .expect("payment context"); - #[test] - fn order_receipt_preflight_rejects_selected_non_buyer_account() { - let dir = tempdir().expect("tempdir"); + assert_eq!(parts.kind, KIND_TRADE_PAYMENT_RECORDED); + assert_eq!( + context.root_event_id.as_deref(), + Some(request_event_id.as_str()) + ); + assert_eq!( + context.prev_event_id.as_deref(), + Some(decision_event_id.as_str()) + ); + assert_eq!(payload.agreement_event_id, decision_event_id); + assert_eq!(payload.quote_id, format!("quote_{}", fixture.order_id)); + assert_eq!(payload.quote_version, 1); + assert_eq!(payload.amount, RadrootsCoreDecimal::from(12u32)); + assert_eq!(payload.currency, RadrootsCoreCurrency::USD); + assert_eq!(payload.method, RadrootsTradePaymentMethod::ManualTransfer); + assert_eq!(payload.reference.as_deref(), Some("memo-1")); + assert_eq!(payload.paid_at, Some(1_777_666_000)); + } + + #[test] + fn order_payment_dry_run_view_preserves_payment_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( + &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 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.clone()], + }, + ); + let mut args = payment_args_for_fixture(&fixture); + args.idempotency_key = Some("idem_payment".to_owned()); + let payload = + order_payment_payload_from_status(&args, &status_view).expect("payment payload"); + + let view = order_payment_dry_run_view(&config, &args, &status_view, &payload); + let request_event_id = fixture.request_event.id.to_string(); + let decision_event_id = decision_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(decision_event_id.as_str()) + ); + assert_eq!( + view.agreement_event_id.as_deref(), + Some(decision_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.method, + Some(RadrootsTradePaymentMethod::ManualTransfer) + ); + assert_eq!(view.reference.as_deref(), Some("memo-1")); + assert_eq!(view.paid_at, Some(1_777_666_000)); + assert_eq!(view.target_relays, vec!["ws://relay.test"]); + assert_eq!(view.connected_relays, vec!["ws://relay.test"]); + assert_eq!(view.fetched_count, 2); + assert_eq!(view.decoded_count, 2); + assert_eq!(view.idempotency_key.as_deref(), Some("idem_payment")); + } + + #[test] + fn order_payment_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 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], + }, + ); + let args = payment_args_for_fixture(&fixture); + + let view = order_payment_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.seller_pubkey.as_str(), + ) + .expect("non buyer payment preflight"); + + assert_eq!(view.state, "invalid"); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("selected account is not buyer") + ); + } + + #[test] + fn order_payment_preflight_rejects_amount_mismatch() { + 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 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], + }, + ); + let mut args = payment_args_for_fixture(&fixture); + args.amount = "11.99".to_owned(); + + let view = order_payment_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str(), + ) + .expect("amount mismatch preflight"); + + assert_eq!(view.state, "invalid"); + assert_eq!(view.amount, Some("11.99".parse().expect("decimal"))); + assert_eq!(view.issues.len(), 1); + assert_eq!(view.issues[0].code, "payment_amount_mismatch"); + } + + #[test] + fn order_payment_preflight_rejects_cancelled_order() { + 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 cancel_event = signed_order_cancellation_event( + &fixture.buyer, + &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(), + "changed plans", + ); + 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, cancel_event], + }, + ); + let args = payment_args_for_fixture(&fixture); + + let view = order_payment_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str(), + ) + .expect("cancelled payment preflight"); + + assert_eq!(view.state, "cancelled"); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("was cancelled") + ); + } + + #[test] + fn order_payment_preflight_skips_existing_recorded_payment() { + 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 = payment_args_for_fixture(&fixture); + + let view = order_payment_preflight_view_from_status( + &config, + &args, + &status_view, + fixture.buyer_pubkey.as_str(), + ) + .expect("recorded payment preflight"); + + assert_eq!(view.state, "recorded"); + assert_eq!(view.event_id.as_deref(), Some(payment_event_id.as_str())); + assert!( + view.reason + .as_deref() + .expect("reason") + .contains("already has payment state") + ); + } + + #[test] + fn order_status_from_receipt_reports_recorded_payment_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 payment_event_id = payment_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.clone(), + payment_event, + ], + }, + ); + let payment = view.payment.as_ref().expect("payment view"); + + assert_eq!(view.state, "accepted"); + assert_eq!(payment.state, "recorded"); + assert_eq!(payment.settlement_state, "pending"); + assert_eq!( + payment.payment_event_id.as_deref(), + Some(payment_event_id.as_str()) + ); + assert_eq!( + payment.agreement_event_id.as_deref(), + Some(decision_event.id.to_string().as_str()) + ); + assert_eq!(payment.amount, Some(RadrootsCoreDecimal::from(12u32))); + assert_eq!(payment.currency, Some(RadrootsCoreCurrency::USD)); + assert_eq!( + payment.method, + Some(RadrootsTradePaymentMethod::ManualTransfer) + ); + assert!(payment.issues.is_empty()); + 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(); @@ -13408,6 +14507,18 @@ mod tests { } } + fn payment_args_for_fixture(fixture: &OrderStatusFixture) -> OrderPaymentArgs { + OrderPaymentArgs { + key: fixture.order_id.clone(), + amount: "12".to_owned(), + currency: "USD".to_owned(), + method: "manual_transfer".to_owned(), + reference: Some("memo-1".to_owned()), + paid_at: Some(1_777_666_000), + idempotency_key: None, + } + } + fn revision_args_for_fixture( fixture: &OrderStatusFixture, bin_count: u32, @@ -13766,6 +14877,49 @@ mod tests { .expect("signed buyer receipt") } + fn signed_payment_recorded_event( + buyer: &RadrootsIdentity, + request_event: &radroots_nostr::prelude::RadrootsNostrEvent, + prev_event: &radroots_nostr::prelude::RadrootsNostrEvent, + agreement_event: &radroots_nostr::prelude::RadrootsNostrEvent, + order_id: &str, + listing_addr: &str, + buyer_pubkey: &str, + seller_pubkey: &str, + ) -> radroots_nostr::prelude::RadrootsNostrEvent { + let economics = sample_order_economics(order_id, "bin-1", 2); + let payload = RadrootsTradePaymentRecorded { + order_id: order_id.to_owned(), + listing_addr: listing_addr.to_owned(), + buyer_pubkey: buyer_pubkey.to_owned(), + seller_pubkey: seller_pubkey.to_owned(), + root_event_id: request_event.id.to_string(), + previous_event_id: prev_event.id.to_string(), + agreement_event_id: agreement_event.id.to_string(), + quote_id: economics.quote_id.clone(), + quote_version: economics.quote_version, + economics_digest: radroots_trade::order::radroots_trade_order_economics_digest( + &economics, + ) + .expect("economics digest"), + amount: economics.total.amount, + currency: economics.total.currency, + method: RadrootsTradePaymentMethod::ManualTransfer, + reference: Some("memo-1".to_owned()), + paid_at: Some(1_777_666_000), + }; + let parts = active_trade_payment_recorded_event_build( + payload.root_event_id.as_str(), + payload.previous_event_id.as_str(), + &payload, + ) + .expect("payment recorded parts"); + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("nostr event builder") + .sign_with_keys(buyer.keys()) + .expect("signed payment recorded") + } + fn signed_malformed_order_request_event( buyer: &RadrootsIdentity, order_id: &str, diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -245,6 +245,17 @@ pub struct OrderReceiptArgs { } #[derive(Debug, Clone)] +pub struct OrderPaymentArgs { + pub key: String, + pub amount: String, + pub currency: String, + pub method: String, + pub reference: Option<String>, + pub paid_at: Option<u64>, + pub idempotency_key: Option<String>, +} + +#[derive(Debug, Clone)] pub struct OrderRevisionProposeArgs { pub key: String, pub reason: String, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -190,6 +190,9 @@ impl TargetCommand { OrderCommand::Receipt(receipt) => match &receipt.command { OrderReceiptCommand::Record(_) => "order.receipt.record", }, + OrderCommand::Payment(payment) => match &payment.command { + OrderPaymentCommand::Record(_) => "order.payment.record", + }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(_) => "order.status.get", }, @@ -752,6 +755,7 @@ pub enum OrderCommand { Revision(OrderRevisionArgs), Fulfillment(OrderFulfillmentArgs), Receipt(OrderReceiptArgs), + Payment(OrderPaymentArgs), Status(OrderStatusArgs), Event(OrderEventArgs), } @@ -891,6 +895,50 @@ pub struct OrderReceiptRecordArgs { } #[derive(Debug, Clone, Args)] +pub struct OrderPaymentArgs { + #[command(subcommand)] + pub command: OrderPaymentCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum OrderPaymentCommand { + Record(OrderPaymentRecordArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct OrderPaymentRecordArgs { + pub order_id: Option<String>, + #[arg(long)] + pub amount: Option<String>, + #[arg(long)] + pub currency: Option<String>, + #[arg(long, value_enum)] + pub method: Option<OrderPaymentMethodArg>, + #[arg(long)] + pub reference: Option<String>, + #[arg(long)] + pub paid_at: Option<u64>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[value(rename_all = "snake_case")] +pub enum OrderPaymentMethodArg { + Cash, + ManualTransfer, + Other, +} + +impl OrderPaymentMethodArg { + pub const fn as_protocol_method(self) -> &'static str { + match self { + Self::Cash => "cash", + Self::ManualTransfer => "manual_transfer", + Self::Other => "other", + } + } +} + +#[derive(Debug, Clone, Args)] pub struct OrderStatusArgs { #[command(subcommand)] pub command: OrderStatusCommand, @@ -926,8 +974,9 @@ mod tests { use clap::{CommandFactory, Parser}; use super::{ - OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, OrderReceiptCommand, - OrderRevisionCommand, TargetCliArgs, TargetOutputFormat, + OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, OrderPaymentCommand, + OrderPaymentMethodArg, OrderReceiptCommand, OrderRevisionCommand, TargetCliArgs, + TargetOutputFormat, }; use crate::operation_registry::OPERATION_REGISTRY; @@ -1199,6 +1248,42 @@ mod tests { } #[test] + fn target_parser_accepts_order_payment_record_methods() { + let parsed = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "payment", + "record", + "ord_test", + "--amount", + "12", + "--currency", + "USD", + "--method", + "manual_transfer", + "--reference", + "memo-1", + "--paid-at", + "1777666000", + ]) + .expect("target args parse"); + assert_eq!(parsed.command.operation_id(), "order.payment.record"); + let crate::target_cli::TargetCommand::Order(order) = parsed.command else { + panic!("expected order command") + }; + let OrderCommand::Payment(payment) = order.command else { + panic!("expected order payment command") + }; + let OrderPaymentCommand::Record(args) = payment.command; + assert_eq!(args.order_id.as_deref(), Some("ord_test")); + assert_eq!(args.amount.as_deref(), Some("12")); + assert_eq!(args.currency.as_deref(), Some("USD")); + assert_eq!(args.method, Some(OrderPaymentMethodArg::ManualTransfer)); + assert_eq!(args.reference.as_deref(), Some("memo-1")); + assert_eq!(args.paid_at, Some(1_777_666_000)); + } + + #[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 @@ -186,6 +186,25 @@ fn seller_order_decision_and_status_commands_are_public() { ] .as_slice(), ), + ( + "order.payment.record", + [ + "--format", + "json", + "--dry-run", + "order", + "payment", + "record", + "ord_public", + "--amount", + "12", + "--currency", + "USD", + "--method", + "cash", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -604,6 +623,25 @@ fn offline_forbids_external_network_operations() { ] .as_slice(), ), + ( + "order.payment.record", + [ + "--format", + "json", + "--offline", + "order", + "payment", + "record", + "ord_offline_payment", + "--amount", + "12", + "--currency", + "USD", + "--method", + "cash", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -773,6 +811,26 @@ fn offline_rejects_order_decision_dry_run() { ] .as_slice(), ), + ( + "order.payment.record", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "payment", + "record", + "ord_offline_decision", + "--amount", + "12", + "--currency", + "USD", + "--method", + "manual_transfer", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -970,6 +1028,25 @@ fn online_requires_relay_for_external_network_operations() { ] .as_slice(), ), + ( + "order.payment.record", + [ + "--format", + "json", + "--online", + "order", + "payment", + "record", + "ord_missing", + "--amount", + "12", + "--currency", + "USD", + "--method", + "cash", + ] + .as_slice(), + ), ] { let output = radroots() .args(args)