commit 079170d50a8a36cb9a801858bedeea8658a31cf3
parent 0dbc924a4c885cf60de932b72d19a725483acccb
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Apr 2026 01:59:26 +0000
cli: add lifecycle command surfaces
Diffstat:
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(