cli

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

commit 079170d50a8a36cb9a801858bedeea8658a31cf3
parent 0dbc924a4c885cf60de932b72d19a725483acccb
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Apr 2026 01:59:26 +0000

cli: add lifecycle command surfaces

Diffstat:
Msrc/domain/runtime.rs | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 14+++++++++++++-
Msrc/operation_adapter.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/operation_order.rs | 366++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/operation_registry.rs | 38+++++++++++++++++++++++++++++++++++++-
Msrc/runtime/order.rs | 416++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/runtime_args.rs | 15+++++++++++++++
Msrc/target_cli.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/target_cli.rs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1309 insertions(+), 28 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1339,6 +1339,147 @@ impl OrderFulfillmentView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderCancellationView { + 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 decision_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 cancellation_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(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 OrderCancellationView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "missing" => CommandDisposition::NotFound, + "invalid" | "declined" | "fulfilled" | "terminal" | "forked" => { + CommandDisposition::ValidationFailed + } + "unconfigured" => CommandDisposition::Unconfigured, + "unavailable" => CommandDisposition::ExternalUnavailable, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderReceiptView { + 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 decision_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub fulfillment_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>, + pub received: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub issue: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub received_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 OrderReceiptView { + 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, @@ -1361,6 +1502,8 @@ pub struct OrderStatusView { pub inventory: Option<OrderInventoryView>, #[serde(skip_serializing_if = "Option::is_none")] pub fulfillment: Option<OrderStatusFulfillmentView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub lifecycle: Option<OrderStatusLifecycleView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reducer_issues: Vec<OrderIssueView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -1399,6 +1542,54 @@ pub struct OrderStatusFulfillmentView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderStatusLifecycleView { + pub phase: String, + #[serde(default)] + pub terminal: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub root_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cancellation: Option<OrderStatusLifecycleCancellationView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub receipt: Option<OrderStatusLifecycleReceiptView>, + #[serde(default)] + pub settlement_required: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub settlement_reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<OrderIssueView>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderStatusLifecycleCancellationView { + pub event_id: 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 reason: Option<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OrderStatusLifecycleReceiptView { + pub event_id: 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>, + pub received: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub issue: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub received_at: Option<u64>, +} + +#[derive(Debug, Clone, Serialize)] pub struct OrderInventoryView { pub state: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/main.rs b/src/main.rs @@ -266,9 +266,15 @@ fn execute_request( TargetOperationRequest::OrderDecline(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::OrderCancel(request) => { + execute_with(OrderOperationService::new(config), request) + } TargetOperationRequest::OrderFulfillmentUpdate(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::OrderReceiptRecord(request) => { + execute_with(OrderOperationService::new(config), request) + } TargetOperationRequest::OrderStatusGet(request) => { execute_with(OrderOperationService::new(config), request) } @@ -385,7 +391,11 @@ fn validate_network_contract( fn dry_run_requires_network(operation_id: &str) -> bool { matches!( operation_id, - "order.accept" | "order.decline" | "order.fulfillment.update" + "order.accept" + | "order.decline" + | "order.cancel" + | "order.fulfillment.update" + | "order.receipt.record" ) } @@ -402,7 +412,9 @@ fn external_network_operation(operation_id: &str) -> bool { | "order.submit" | "order.accept" | "order.decline" + | "order.cancel" | "order.fulfillment.update" + | "order.receipt.record" | "order.status.get" | "order.event.list" | "order.event.watch" diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1055,7 +1055,7 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, OrderFulfillmentCommand, - OrderStatusCommand, TargetCommand, + OrderReceiptCommand, OrderStatusCommand, TargetCommand, }; let mut input = OperationData::new(); @@ -1188,6 +1188,10 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "order_id", &args.order_id); insert_string(&mut input, "reason", &args.reason); } + OrderCommand::Cancel(args) => { + insert_string(&mut input, "order_id", &args.order_id); + insert_string(&mut input, "reason", &args.reason); + } OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command { OrderFulfillmentCommand::Update(args) => { insert_string(&mut input, "order_id", &args.order_id); @@ -1199,6 +1203,15 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati } } }, + OrderCommand::Receipt(receipt) => match &receipt.command { + OrderReceiptCommand::Record(args) => { + insert_string(&mut input, "order_id", &args.order_id); + if args.received { + input.insert("received".to_owned(), Value::Bool(true)); + } + insert_string(&mut input, "issue", &args.issue); + } + }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(args) => { insert_string(&mut input, "order_id", &args.order_id) @@ -1302,7 +1315,9 @@ target_operation_contracts! { OrderList => (OrderListRequest, OrderListResult, "order.list"), OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"), OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"), + OrderCancel => (OrderCancelRequest, OrderCancelResult, "order.cancel"), OrderFulfillmentUpdate => (OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, "order.fulfillment.update"), + OrderReceiptRecord => (OrderReceiptRecordRequest, OrderReceiptRecordResult, "order.receipt.record"), OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"), OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"), OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"), @@ -1443,6 +1458,65 @@ mod tests { } #[test] + fn adapter_maps_order_lifecycle_inputs() { + let cancel = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "cancel", + "ord_test", + "--reason", + "changed plans", + ]) + .expect("target args parse"); + let request = TargetOperationRequest::from_target_args(&cancel).expect("operation request"); + let TargetOperationRequest::OrderCancel(request) = request else { + panic!("expected order cancel request") + }; + assert_eq!(request.operation_id(), "order.cancel"); + assert_eq!( + request + .payload + .input + .get("order_id") + .and_then(Value::as_str), + Some("ord_test") + ); + assert_eq!( + request.payload.input.get("reason").and_then(Value::as_str), + Some("changed plans") + ); + + let receipt = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "receipt", + "record", + "ord_test", + "--issue", + "damaged items", + ]) + .expect("target args parse"); + let request = + TargetOperationRequest::from_target_args(&receipt).expect("operation request"); + let TargetOperationRequest::OrderReceiptRecord(request) = request else { + panic!("expected order receipt record request") + }; + assert_eq!(request.operation_id(), "order.receipt.record"); + assert_eq!( + request + .payload + .input + .get("order_id") + .and_then(Value::as_str), + Some("ord_test") + ); + assert_eq!( + request.payload.input.get("issue").and_then(Value::as_str), + Some("damaged items") + ); + } + + #[test] fn typed_service_boundary_returns_enveloped_result() { struct WorkspaceService; diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -2,22 +2,23 @@ use serde::Serialize; use serde_json::{Value, json}; use crate::domain::runtime::{ - CommandDisposition, OrderDecisionView, OrderFulfillmentView, OrderStatusView, OrderSubmitView, + CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView, + OrderReceiptView, OrderStatusView, OrderSubmitView, }; use crate::operation_adapter::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData, OperationService, OrderAcceptRequest, OrderAcceptResult, - OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventListResult, - OrderEventWatchRequest, OrderEventWatchResult, OrderFulfillmentUpdateRequest, - OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult, OrderListRequest, - OrderListResult, OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, - OrderSubmitResult, + OrderCancelRequest, OrderCancelResult, OrderDeclineRequest, OrderDeclineResult, + OrderEventListRequest, OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult, + OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult, + OrderListRequest, OrderListResult, OrderReceiptRecordRequest, OrderReceiptRecordResult, + OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime_args::{ - OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderStatusArgs, OrderSubmitArgs, - OrderWatchArgs, RecordLookupArgs, + OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderReceiptArgs, + OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, }; pub struct OrderOperationService<'a> { @@ -167,6 +168,48 @@ impl OperationService<OrderDeclineRequest> for OrderOperationService<'_> { } } +impl OperationService<OrderCancelRequest> for OrderOperationService<'_> { + type Result = OrderCancelResult; + + fn execute( + &self, + request: OperationRequest<OrderCancelRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + 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 `reason` input".to_owned(), + ) + })?; + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let args = OrderCancelArgs { + key: required_order_key(&request)?, + 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::cancel(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + cancellation_result::<OrderCancelResult>(request.operation_id(), &view) + } +} + impl OperationService<OrderFulfillmentUpdateRequest> for OrderOperationService<'_> { type Result = OrderFulfillmentUpdateResult; @@ -210,6 +253,56 @@ impl OperationService<OrderFulfillmentUpdateRequest> for OrderOperationService<' } } +impl OperationService<OrderReceiptRecordRequest> for OrderOperationService<'_> { + type Result = OrderReceiptRecordResult; + + fn execute( + &self, + request: OperationRequest<OrderReceiptRecordRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let received = bool_input(&request, "received").unwrap_or(false); + let issue = string_input(&request, "issue") + .map(|issue| issue.trim().to_owned()) + .filter(|issue| !issue.is_empty()); + if received && issue.is_some() { + return Err(invalid_input( + request.operation_id(), + "`received` and `issue` cannot both be set".to_owned(), + )); + } + if !received && issue.is_none() { + return Err(invalid_input( + request.operation_id(), + "missing required receipt outcome input".to_owned(), + )); + } + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let args = OrderReceiptArgs { + key: required_order_key(&request)?, + received, + issue, + 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::receipt_record(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + receipt_result::<OrderReceiptRecordResult>(request.operation_id(), &view) + } +} + impl OperationService<OrderStatusGetRequest> for OrderOperationService<'_> { type Result = OrderStatusGetResult; @@ -433,6 +526,181 @@ fn order_fulfillment_error_detail(view: &OrderFulfillmentView) -> Value { }) } +fn cancellation_result<R>( + operation_id: &str, + view: &OrderCancellationView, +) -> 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 cancel failed validation with state `{}`", view.state) + }); + Err(OperationAdapterError::validation_failed_with_detail( + operation_id, + message, + order_cancellation_error_detail(view), + )) + } + disposition => { + let message = view + .reason + .clone() + .unwrap_or_else(|| format!("order cancel finished with state `{}`", view.state)); + if disposition == CommandDisposition::ExternalUnavailable { + let detail = order_cancellation_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_cancellation_error_detail(view), + )) + } else { + Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + message, + )) + } + } + } +} + +fn order_cancellation_error_detail(view: &OrderCancellationView) -> Value { + json!({ + "state": &view.state, + "order_id": &view.order_id, + "listing_addr": &view.listing_addr, + "request_event_id": &view.request_event_id, + "decision_event_id": &view.decision_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, + "cancellation_reason": &view.cancellation_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 receipt_result<R>( + operation_id: &str, + view: &OrderReceiptView, +) -> 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 receipt record failed validation with state `{}`", + view.state + ) + }); + Err(OperationAdapterError::validation_failed_with_detail( + operation_id, + message, + order_receipt_error_detail(view), + )) + } + disposition => { + let message = view.reason.clone().unwrap_or_else(|| { + format!("order receipt record finished with state `{}`", view.state) + }); + if disposition == CommandDisposition::ExternalUnavailable { + let detail = order_receipt_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_receipt_error_detail(view), + )) + } else { + Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + message, + )) + } + } + } +} + +fn order_receipt_error_detail(view: &OrderReceiptView) -> Value { + json!({ + "state": &view.state, + "order_id": &view.order_id, + "listing_addr": &view.listing_addr, + "request_event_id": &view.request_event_id, + "decision_event_id": &view.decision_event_id, + "fulfillment_event_id": &view.fulfillment_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, + "received": view.received, + "issue": &view.issue, + "received_at": &view.received_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 status_result<R>( operation_id: &str, view: &OrderStatusView, @@ -582,6 +850,13 @@ where .map(str::to_owned) } +fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> +where + P: OperationRequestPayload + OperationRequestData, +{ + request.payload.input().get(key).and_then(Value::as_bool) +} + fn usize_input<P>(request: &OperationRequest<P>, key: &str) -> Option<usize> where P: OperationRequestPayload + OperationRequestData, @@ -625,9 +900,9 @@ mod tests { use crate::domain::runtime::OrderDecisionView; use crate::operation_adapter::{ OperationAdapter, OperationContext, OperationData, OperationRequest, OrderAcceptRequest, - OrderAcceptResult, OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, - OrderEventWatchRequest, OrderGetRequest, OrderListRequest, OrderStatusGetRequest, - OrderSubmitRequest, + OrderAcceptResult, OrderCancelRequest, OrderDeclineRequest, OrderDeclineResult, + OrderEventListRequest, OrderEventWatchRequest, OrderGetRequest, OrderListRequest, + OrderReceiptRecordRequest, OrderStatusGetRequest, OrderSubmitRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -803,6 +1078,75 @@ mod tests { } #[test] + fn order_cancel_requires_reason_before_approval() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let cancel = OperationRequest::new( + OperationContext::default(), + OrderCancelRequest::from_data(data(&[("order_id", "ord_pending")])), + ) + .expect("order cancel request"); + let error = service.execute(cancel).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_cancel_requires_approval_token() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let cancel = OperationRequest::new( + OperationContext::default(), + OrderCancelRequest::from_data(data(&[ + ("order_id", "ord_pending"), + ("reason", "changed plans"), + ])), + ) + .expect("order cancel request"); + let error = service.execute(cancel).expect_err("approval required"); + + assert_eq!(error.to_output_error().code, "approval_required"); + } + + #[test] + fn order_receipt_record_requires_outcome_before_approval() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let receipt = OperationRequest::new( + OperationContext::default(), + OrderReceiptRecordRequest::from_data(data(&[("order_id", "ord_pending")])), + ) + .expect("order receipt request"); + let error = service.execute(receipt).expect_err("outcome required"); + let output_error = error.to_output_error(); + + assert_eq!(output_error.code, "invalid_input"); + assert!(output_error.message.contains("outcome")); + } + + #[test] + fn order_receipt_record_requires_approval_token() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(OrderOperationService::new(&config)); + let mut input = data(&[("order_id", "ord_pending")]); + input.insert("received".to_owned(), Value::Bool(true)); + let receipt = OperationRequest::new( + OperationContext::default(), + OrderReceiptRecordRequest::from_data(input), + ) + .expect("order receipt request"); + let error = service.execute(receipt).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 @@ -872,6 +872,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ true ), operation!( + "order.cancel", + "radroots order cancel", + "order", + "order_cancel", + "OrderCancelRequest", + "OrderCancelResult", + "Cancel a buyer order before fulfillment.", + Buyer, + true, + Required, + High, + false, + true + ), + operation!( "order.fulfillment.update", "radroots order fulfillment update", "order", @@ -887,6 +902,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ true ), operation!( + "order.receipt.record", + "radroots order receipt record", + "order", + "order_receipt_record", + "OrderReceiptRecordRequest", + "OrderReceiptRecordResult", + "Record buyer receipt outcome.", + Buyer, + true, + Required, + High, + false, + true + ), + operation!( "order.status.get", "radroots order status get", "order", @@ -1008,7 +1038,9 @@ mod tests { "order.list", "order.accept", "order.decline", + "order.cancel", "order.fulfillment.update", + "order.receipt.record", "order.status.get", "order.event.list", "order.event.watch", @@ -1043,7 +1075,9 @@ mod tests { "order.submit", "order.accept", "order.decline", + "order.cancel", "order.fulfillment.update", + "order.receipt.record", ]; const INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[]; @@ -1056,7 +1090,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 57); + assert_eq!(OPERATION_REGISTRY.len(), 59); } #[test] @@ -1105,7 +1139,9 @@ mod tests { "order.submit", "order.accept", "order.decline", + "order.cancel", "order.fulfillment.update", + "order.receipt.record", ] .into_iter() .collect::<BTreeSet<_>>(); diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -36,7 +36,8 @@ use radroots_replica_db_schema::nostr_event_state::{ use radroots_replica_db_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany}; use radroots_sql_core::SqliteExecutor; use radroots_trade::order::{ - RadrootsActiveOrderDecisionRecord, RadrootsActiveOrderFulfillmentRecord, + RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord, + RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderReceiptRecord, RadrootsActiveOrderReducerIssue, RadrootsActiveOrderRequestRecord, RadrootsActiveOrderStatus, RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryAccountingProjection, RadrootsListingInventoryBinAvailability, canonicalize_active_order_decision_for_signer, @@ -46,10 +47,12 @@ use radroots_trade::order::{ use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ - OrderDecisionView, OrderDraftItemView, OrderFulfillmentView, OrderGetView, - OrderHistoryEntryView, OrderHistoryView, OrderInventoryBinView, OrderInventoryView, - OrderIssueView, OrderListView, OrderNewView, OrderStatusFulfillmentView, OrderStatusView, - OrderSubmitView, OrderSummaryView, OrderWatchView, RelayFailureView, + OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderFulfillmentView, + OrderGetView, OrderHistoryEntryView, OrderHistoryView, OrderInventoryBinView, + OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderReceiptView, + OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView, + OrderStatusLifecycleReceiptView, OrderStatusLifecycleView, OrderStatusView, OrderSubmitView, + OrderSummaryView, OrderWatchView, RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -60,8 +63,9 @@ use crate::runtime::direct_relay::{ }; use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ - OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs, OrderFulfillmentArgs, - OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, + OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs, + OrderFulfillmentArgs, OrderReceiptArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, + RecordLookupArgs, }; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; @@ -69,6 +73,8 @@ const ORDER_SOURCE: &str = "local order drafts · local first"; const ORDER_SUBMIT_SOURCE: &str = "direct Nostr relay publish · local key"; const ORDER_DECISION_SOURCE: &str = "direct Nostr relay decision publish · local key"; 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_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 = @@ -947,6 +953,77 @@ pub fn fulfillment_update( publish_order_fulfillment(config, args, status_view, signing, payload) } +pub fn cancel( + config: &RuntimeConfig, + args: &OrderCancelArgs, +) -> Result<OrderCancellationView, RuntimeError> { + Ok(OrderCancellationView { + state: "unavailable".to_owned(), + source: ORDER_CANCELLATION_SOURCE.to_owned(), + order_id: args.key.clone(), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + request_event_id: None, + decision_event_id: None, + root_event_id: None, + prev_event_id: None, + event_id: None, + event_kind: None, + cancellation_reason: Some(args.reason.clone()), + dry_run: config.output.dry_run, + target_relays: config.relay.urls.clone(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 0, + decoded_count: 0, + skipped_count: 0, + idempotency_key: args.idempotency_key.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + reason: Some("order cancel runtime is pending lifecycle preflight wiring".to_owned()), + issues: Vec::new(), + actions: vec![format!("radroots order status get {}", args.key)], + }) +} + +pub fn receipt_record( + config: &RuntimeConfig, + args: &OrderReceiptArgs, +) -> Result<OrderReceiptView, RuntimeError> { + Ok(OrderReceiptView { + state: "unavailable".to_owned(), + source: ORDER_RECEIPT_SOURCE.to_owned(), + order_id: args.key.clone(), + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + request_event_id: None, + decision_event_id: None, + fulfillment_event_id: None, + root_event_id: None, + prev_event_id: None, + event_id: None, + event_kind: None, + received: args.received, + issue: args.issue.clone(), + received_at: None, + dry_run: config.output.dry_run, + target_relays: config.relay.urls.clone(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 0, + decoded_count: 0, + skipped_count: 0, + idempotency_key: args.idempotency_key.clone(), + signer_mode: Some(config.signer.backend.as_str().to_owned()), + reason: Some("order receipt runtime is pending lifecycle preflight wiring".to_owned()), + issues: Vec::new(), + actions: vec![format!("radroots order status get {}", args.key)], + }) +} + pub fn status( config: &RuntimeConfig, args: &OrderStatusArgs, @@ -965,6 +1042,7 @@ pub fn status( last_event_id: None, inventory: None, fulfillment: None, + lifecycle: None, reducer_issues: Vec::new(), target_relays: Vec::new(), connected_relays: Vec::new(), @@ -998,6 +1076,7 @@ pub fn status( last_event_id: None, inventory: None, fulfillment: None, + lifecycle: None, reducer_issues: Vec::new(), target_relays, connected_relays: Vec::new(), @@ -1137,8 +1216,14 @@ fn order_status_reduction_from_receipt_with_context( let order_id = context.order_id; let fulfillment_records = fulfillments.clone(); - let projection = - reduce_active_order_events(order_id, requests, decisions.clone(), fulfillments); + let projection = reduce_active_order_events( + order_id, + requests, + decisions.clone(), + fulfillments, + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), + ); let fulfillment_event_id = projection.fulfillment_event_id.clone(); let fulfillment_status = projection.fulfillment_status; let fulfillment_root_event_id = fulfillment_event_id.as_ref().and_then(|event_id| { @@ -1193,6 +1278,22 @@ fn order_status_reduction_from_receipt_with_context( fulfillment_status, reducer_issues.as_slice(), ); + let lifecycle = order_status_lifecycle_view( + &projection.status, + projection.request_event_id.clone(), + projection.last_event_id.clone(), + projection.fulfillment_status, + projection.cancellation_event_id.clone(), + None, + None, + projection.settlement_pending, + projection.settlement_reason.clone(), + None, + None, + None, + None, + reducer_issues.as_slice(), + ); let view = OrderStatusView { state, @@ -1207,6 +1308,7 @@ fn order_status_reduction_from_receipt_with_context( last_event_id: projection.last_event_id, inventory, fulfillment, + lifecycle: Some(lifecycle), reducer_issues, target_relays, connected_relays, @@ -1291,6 +1393,8 @@ fn enrich_order_status_inventory( requests, decisions, fulfillments, + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); let relevant_issues = projection .issues @@ -1305,11 +1409,15 @@ fn enrich_order_status_inventory( .cloned() .collect::<Vec<_>>(); if relevant_issues.is_empty() { - if view.state == "accepted" { + if matches!( + view.state.as_str(), + "accepted" | "cancelled" | "completed" | "disputed" + ) { let inventory_state = if view .fulfillment .as_ref() .is_some_and(|fulfillment| fulfillment.inventory_released) + || view.state == "cancelled" { "released" } else { @@ -1578,6 +1686,9 @@ fn active_order_status_state(status: &RadrootsActiveOrderStatus) -> &'static str RadrootsActiveOrderStatus::Requested => "requested", RadrootsActiveOrderStatus::Accepted => "accepted", RadrootsActiveOrderStatus::Declined => "declined", + RadrootsActiveOrderStatus::Cancelled => "cancelled", + RadrootsActiveOrderStatus::Completed => "completed", + RadrootsActiveOrderStatus::Disputed => "disputed", RadrootsActiveOrderStatus::Invalid => "invalid", } } @@ -1622,7 +1733,9 @@ fn order_status_inventory_view( .collect::<Vec<_>>(); match status { - RadrootsActiveOrderStatus::Accepted => { + RadrootsActiveOrderStatus::Accepted + | RadrootsActiveOrderStatus::Completed + | RadrootsActiveOrderStatus::Disputed => { let bins = decision_event_id .and_then(|event_id| { decisions @@ -1643,6 +1756,13 @@ fn order_status_inventory_view( issues: inventory_issues, }) } + RadrootsActiveOrderStatus::Cancelled => Some(OrderInventoryView { + state: "released".to_owned(), + listing_event_id, + commitment_valid: inventory_issues.is_empty(), + bins: Vec::new(), + issues: inventory_issues, + }), RadrootsActiveOrderStatus::Declined => Some(OrderInventoryView { state: "not_reserved".to_owned(), listing_event_id, @@ -1720,6 +1840,97 @@ fn order_status_fulfillment_view( }) } +fn order_status_lifecycle_view( + status: &RadrootsActiveOrderStatus, + request_event_id: Option<String>, + last_event_id: Option<String>, + fulfillment_status: Option<RadrootsActiveTradeFulfillmentState>, + cancellation_event_id: Option<String>, + cancellation_root_event_id: Option<String>, + cancellation_prev_event_id: Option<String>, + settlement_required: bool, + settlement_reason: Option<String>, + receipt_event_id: Option<String>, + receipt_root_event_id: Option<String>, + receipt_prev_event_id: Option<String>, + receipt: Option<(bool, Option<String>, Option<u64>)>, + reducer_issues: &[OrderIssueView], +) -> OrderStatusLifecycleView { + let phase = order_status_lifecycle_phase(status, fulfillment_status).to_owned(); + let terminal = matches!( + status, + RadrootsActiveOrderStatus::Cancelled + | RadrootsActiveOrderStatus::Completed + | RadrootsActiveOrderStatus::Disputed + | RadrootsActiveOrderStatus::Invalid + ); + let cancellation = + cancellation_event_id + .as_ref() + .map(|event_id| OrderStatusLifecycleCancellationView { + event_id: event_id.clone(), + root_event_id: cancellation_root_event_id + .clone() + .or(request_event_id.clone()), + prev_event_id: cancellation_prev_event_id.clone(), + reason: settlement_reason.clone(), + }); + let receipt_view = receipt_event_id.as_ref().map(|event_id| { + let (received, issue, received_at) = receipt.clone().unwrap_or((false, None, None)); + OrderStatusLifecycleReceiptView { + event_id: event_id.clone(), + root_event_id: receipt_root_event_id.clone().or(request_event_id.clone()), + prev_event_id: receipt_prev_event_id.clone(), + received, + issue, + received_at, + } + }); + let event_id = receipt_event_id.or(cancellation_event_id); + let prev_event_id = receipt_prev_event_id + .or(cancellation_prev_event_id) + .or(last_event_id); + OrderStatusLifecycleView { + phase, + terminal, + event_id, + root_event_id: request_event_id, + prev_event_id, + cancellation, + receipt: receipt_view, + settlement_required, + settlement_reason, + issues: reducer_issues.to_vec(), + } +} + +fn order_status_lifecycle_phase( + status: &RadrootsActiveOrderStatus, + fulfillment_status: Option<RadrootsActiveTradeFulfillmentState>, +) -> &'static str { + match status { + RadrootsActiveOrderStatus::Missing => "missing", + RadrootsActiveOrderStatus::Requested => "requested", + RadrootsActiveOrderStatus::Accepted => match fulfillment_status { + Some(RadrootsActiveTradeFulfillmentState::Preparing) + | Some(RadrootsActiveTradeFulfillmentState::OutForDelivery) => { + "fulfillment_in_progress" + } + Some( + RadrootsActiveTradeFulfillmentState::ReadyForPickup + | RadrootsActiveTradeFulfillmentState::Delivered + | RadrootsActiveTradeFulfillmentState::SellerCancelled, + ) => "fulfilled", + Some(RadrootsActiveTradeFulfillmentState::AcceptedNotFulfilled) | None => "accepted", + }, + RadrootsActiveOrderStatus::Declined => "declined", + RadrootsActiveOrderStatus::Cancelled => "cancelled", + RadrootsActiveOrderStatus::Completed => "completed", + RadrootsActiveOrderStatus::Disputed => "disputed", + RadrootsActiveOrderStatus::Invalid => "invalid", + } +} + fn fulfillment_issue_code(code: &str) -> bool { matches!( code, @@ -2013,6 +2224,180 @@ fn active_order_reducer_issue_view(issue_value: RadrootsActiveOrderReducerIssue) "active order reducer reported forked fulfillment updates", event_ids, ), + RadrootsActiveOrderReducerIssue::CancellationWithoutCancellableOrder { event_id } => { + issue_with_events( + "cancellation_without_cancellable_order", + "cancellation_event_id", + "active order reducer reported cancellation without cancellable order", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationPayloadInvalid { event_id } => { + issue_with_events( + "invalid_cancellation_payload", + "cancellation_payload", + "active order reducer reported invalid cancellation payload", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationOrderIdMismatch { event_id } => { + issue_with_events( + "cancellation_order_id_mismatch", + "order_id", + "active order reducer reported cancellation order id mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationAuthorMismatch { event_id } => { + issue_with_events( + "cancellation_author_mismatch", + "buyer_pubkey", + "active order reducer reported cancellation author mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationCounterpartyMismatch { event_id } => { + issue_with_events( + "cancellation_counterparty_mismatch", + "seller_pubkey", + "active order reducer reported cancellation counterparty mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationBuyerMismatch { event_id } => { + issue_with_events( + "cancellation_buyer_mismatch", + "buyer_pubkey", + "active order reducer reported cancellation buyer mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationSellerMismatch { event_id } => { + issue_with_events( + "cancellation_seller_mismatch", + "seller_pubkey", + "active order reducer reported cancellation seller mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationListingAddressInvalid { event_id } => { + issue_with_events( + "invalid_cancellation_listing_address", + "listing_addr", + "active order reducer reported invalid cancellation listing address", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationListingMismatch { event_id } => { + issue_with_events( + "cancellation_listing_mismatch", + "listing_addr", + "active order reducer reported cancellation listing mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationRootMismatch { event_id } => { + issue_with_events( + "cancellation_root_mismatch", + "root_event_id", + "active order reducer reported cancellation root mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationPreviousMismatch { event_id } => { + issue_with_events( + "cancellation_previous_mismatch", + "prev_event_id", + "active order reducer reported cancellation previous mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::CancellationAfterFulfillment { event_id } => { + issue_with_events( + "cancellation_after_fulfillment", + "fulfillment_event_id", + "active order reducer reported cancellation after fulfillment", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::ReceiptWithoutEligibleFulfillment { event_id } => { + issue_with_events( + "receipt_without_eligible_fulfillment", + "receipt_event_id", + "active order reducer reported receipt without eligible fulfillment", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::ReceiptPayloadInvalid { event_id } => issue_with_events( + "invalid_receipt_payload", + "receipt_payload", + "active order reducer reported invalid receipt payload", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::ReceiptOrderIdMismatch { event_id } => issue_with_events( + "receipt_order_id_mismatch", + "order_id", + "active order reducer reported receipt order id mismatch", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::ReceiptAuthorMismatch { event_id } => issue_with_events( + "receipt_author_mismatch", + "buyer_pubkey", + "active order reducer reported receipt author mismatch", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::ReceiptCounterpartyMismatch { event_id } => { + issue_with_events( + "receipt_counterparty_mismatch", + "seller_pubkey", + "active order reducer reported receipt counterparty mismatch", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::ReceiptBuyerMismatch { event_id } => issue_with_events( + "receipt_buyer_mismatch", + "buyer_pubkey", + "active order reducer reported receipt buyer mismatch", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::ReceiptSellerMismatch { event_id } => issue_with_events( + "receipt_seller_mismatch", + "seller_pubkey", + "active order reducer reported receipt seller mismatch", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::ReceiptListingAddressInvalid { event_id } => { + issue_with_events( + "invalid_receipt_listing_address", + "listing_addr", + "active order reducer reported invalid receipt listing address", + vec![event_id], + ) + } + RadrootsActiveOrderReducerIssue::ReceiptListingMismatch { event_id } => issue_with_events( + "receipt_listing_mismatch", + "listing_addr", + "active order reducer reported receipt listing mismatch", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::ReceiptRootMismatch { event_id } => issue_with_events( + "receipt_root_mismatch", + "root_event_id", + "active order reducer reported receipt root mismatch", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::ReceiptPreviousMismatch { event_id } => issue_with_events( + "receipt_previous_mismatch", + "prev_event_id", + "active order reducer reported receipt previous mismatch", + vec![event_id], + ), + RadrootsActiveOrderReducerIssue::ForkedLifecycle { event_ids } => issue_with_events( + "forked_lifecycle", + "event_id", + "active order reducer reported forked lifecycle events", + event_ids, + ), } } @@ -2549,6 +2934,8 @@ fn order_accept_inventory_preflight_view( requests, decisions, fulfillments, + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); Ok(order_accept_inventory_preflight_view_from_projection( config, args, request, resolution, status, projection, @@ -5038,7 +5425,8 @@ mod tests { use radroots_runtime_paths::RadrootsMigrationReport; use radroots_secret_vault::RadrootsSecretBackend; use radroots_trade::order::{ - RadrootsActiveOrderDecisionRecord, RadrootsActiveOrderFulfillmentRecord, + RadrootsActiveOrderCancellationRecord, RadrootsActiveOrderDecisionRecord, + RadrootsActiveOrderFulfillmentRecord, RadrootsActiveOrderReceiptRecord, RadrootsListingInventoryBinAvailability, canonicalize_active_order_decision_for_signer, reduce_listing_inventory_accounting, }; @@ -6807,6 +7195,8 @@ mod tests { proposed_accept_decision_record(&request).expect("proposed accept decision"), ], [], + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); let args = OrderDecisionArgs { key: fixture.order_id.clone(), @@ -6914,6 +7304,8 @@ mod tests { status: RadrootsActiveTradeFulfillmentState::SellerCancelled, }, }], + Vec::<RadrootsActiveOrderCancellationRecord>::new(), + Vec::<RadrootsActiveOrderReceiptRecord>::new(), ); let args = OrderDecisionArgs { key: fixture.order_id.clone(), diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -207,6 +207,13 @@ pub struct OrderDecisionArgs { } #[derive(Debug, Clone)] +pub struct OrderCancelArgs { + pub key: String, + pub reason: String, + pub idempotency_key: Option<String>, +} + +#[derive(Debug, Clone)] pub struct OrderFulfillmentArgs { pub key: String, pub state: String, @@ -214,6 +221,14 @@ pub struct OrderFulfillmentArgs { } #[derive(Debug, Clone)] +pub struct OrderReceiptArgs { + pub key: String, + pub received: bool, + pub issue: 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 @@ -174,9 +174,13 @@ impl TargetCommand { OrderCommand::List => "order.list", OrderCommand::Accept(_) => "order.accept", OrderCommand::Decline(_) => "order.decline", + OrderCommand::Cancel(_) => "order.cancel", OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command { OrderFulfillmentCommand::Update(_) => "order.fulfillment.update", }, + OrderCommand::Receipt(receipt) => match &receipt.command { + OrderReceiptCommand::Record(_) => "order.receipt.record", + }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(_) => "order.status.get", }, @@ -688,7 +692,9 @@ pub enum OrderCommand { List, Accept(OrderKeyArgs), Decline(OrderDeclineArgs), + Cancel(OrderCancelArgs), Fulfillment(OrderFulfillmentArgs), + Receipt(OrderReceiptArgs), Status(OrderStatusArgs), Event(OrderEventArgs), } @@ -711,6 +717,13 @@ pub struct OrderDeclineArgs { } #[derive(Debug, Clone, Args)] +pub struct OrderCancelArgs { + pub order_id: Option<String>, + #[arg(long)] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct OrderFulfillmentArgs { #[command(subcommand)] pub command: OrderFulfillmentCommand, @@ -751,6 +764,26 @@ impl OrderFulfillmentStateArg { } #[derive(Debug, Clone, Args)] +pub struct OrderReceiptArgs { + #[command(subcommand)] + pub command: OrderReceiptCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum OrderReceiptCommand { + Record(OrderReceiptRecordArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct OrderReceiptRecordArgs { + pub order_id: Option<String>, + #[arg(long, action = ArgAction::SetTrue, conflicts_with = "issue")] + pub received: bool, + #[arg(long)] + pub issue: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct OrderStatusArgs { #[command(subcommand)] pub command: OrderStatusCommand, @@ -786,8 +819,8 @@ mod tests { use clap::{CommandFactory, Parser}; use super::{ - OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, TargetCliArgs, - TargetOutputFormat, + OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, OrderReceiptCommand, + TargetCliArgs, TargetOutputFormat, }; use crate::operation_registry::OPERATION_REGISTRY; @@ -901,6 +934,65 @@ mod tests { } #[test] + fn target_parser_accepts_order_cancel_reason() { + let parsed = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "cancel", + "ord_test", + "--reason", + "changed plans", + ]) + .expect("target args parse"); + + assert_eq!(parsed.command.operation_id(), "order.cancel"); + let crate::target_cli::TargetCommand::Order(order) = parsed.command else { + panic!("expected order command") + }; + let OrderCommand::Cancel(args) = order.command else { + panic!("expected order cancel command") + }; + assert_eq!(args.order_id.as_deref(), Some("ord_test")); + assert_eq!(args.reason.as_deref(), Some("changed plans")); + } + + #[test] + fn target_parser_accepts_order_receipt_record_outcomes() { + let received = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "receipt", + "record", + "ord_test", + "--received", + ]) + .expect("target args parse"); + assert_eq!(received.command.operation_id(), "order.receipt.record"); + let crate::target_cli::TargetCommand::Order(order) = received.command else { + panic!("expected order command") + }; + let OrderCommand::Receipt(receipt) = order.command else { + panic!("expected order receipt command") + }; + let OrderReceiptCommand::Record(args) = receipt.command; + assert_eq!(args.order_id.as_deref(), Some("ord_test")); + assert!(args.received); + assert_eq!(args.issue, None); + + let issue = TargetCliArgs::try_parse_from([ + "radroots", + "order", + "receipt", + "record", + "ord_test", + "--issue", + "damaged items", + ]) + .expect("target args parse"); + assert_eq!(issue.command.operation_id(), "order.receipt.record"); + } + + #[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 @@ -140,6 +140,20 @@ fn seller_order_decision_and_status_commands_are_public() { .as_slice(), ), ( + "order.cancel", + [ + "--format", + "json", + "--dry-run", + "order", + "cancel", + "ord_public", + "--reason", + "changed plans", + ] + .as_slice(), + ), + ( "order.status.get", ["--format", "json", "order", "status", "get", "ord_public"].as_slice(), ), @@ -158,6 +172,20 @@ fn seller_order_decision_and_status_commands_are_public() { ] .as_slice(), ), + ( + "order.receipt.record", + [ + "--format", + "json", + "--dry-run", + "order", + "receipt", + "record", + "ord_public", + "--received", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -477,6 +505,20 @@ fn offline_forbids_external_network_operations() { ["--format", "json", "--offline", "order", "submit"].as_slice(), ), ( + "order.cancel", + [ + "--format", + "json", + "--offline", + "order", + "cancel", + "ord_offline_cancel", + "--reason", + "changed plans", + ] + .as_slice(), + ), + ( "order.fulfillment.update", [ "--format", @@ -491,6 +533,20 @@ fn offline_forbids_external_network_operations() { ] .as_slice(), ), + ( + "order.receipt.record", + [ + "--format", + "json", + "--offline", + "order", + "receipt", + "record", + "ord_offline_receipt", + "--received", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -560,6 +616,21 @@ fn offline_rejects_order_decision_dry_run() { .as_slice(), ), ( + "order.cancel", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "cancel", + "ord_offline_decision", + "--reason", + "changed plans", + ] + .as_slice(), + ), + ( "order.fulfillment.update", [ "--format", @@ -575,6 +646,22 @@ fn offline_rejects_order_decision_dry_run() { ] .as_slice(), ), + ( + "order.receipt.record", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "receipt", + "record", + "ord_offline_decision", + "--issue", + "damaged items", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -675,6 +762,20 @@ fn online_requires_relay_for_external_network_operations() { .as_slice(), ), ( + "order.cancel", + [ + "--format", + "json", + "--online", + "order", + "cancel", + "ord_missing", + "--reason", + "changed plans", + ] + .as_slice(), + ), + ( "order.fulfillment.update", [ "--format", @@ -689,6 +790,20 @@ fn online_requires_relay_for_external_network_operations() { ] .as_slice(), ), + ( + "order.receipt.record", + [ + "--format", + "json", + "--online", + "order", + "receipt", + "record", + "ord_missing", + "--received", + ] + .as_slice(), + ), ] { let output = radroots() .args(args) @@ -1147,6 +1262,11 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { ); assert_required_approval_token_rejected( &sandbox, + "order.cancel", + &["order", "cancel", "--reason", "changed plans"], + ); + assert_required_approval_token_rejected( + &sandbox, "order.fulfillment.update", &[ "order", @@ -1157,6 +1277,11 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { "ready_for_pickup", ], ); + assert_required_approval_token_rejected( + &sandbox, + "order.receipt.record", + &["order", "receipt", "record", "ord_pending", "--received"], + ); } fn assert_required_approval_token_rejected(