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