commit 1fe750c28141106e940127cdac05bf2360d73fd6
parent 61bc212e4f208ab8d5c83280973322f650691d8f
Author: triesap <tyson@radroots.org>
Date: Wed, 6 May 2026 15:08:18 +0000
cli: harden deferred payment precedence
Diffstat:
6 files changed, 84 insertions(+), 49 deletions(-)
diff --git a/src/deferred_payment.rs b/src/deferred_payment.rs
@@ -0,0 +1,12 @@
+pub const DEFERRED_PAYMENT_MESSAGE: &str = "payments and settlement are not implemented in this Radroots release; order coordination is available now, and payment support is planned for a future phase";
+
+pub fn is_deferred_payment_operation(operation_id: &str) -> bool {
+ matches!(
+ operation_id,
+ "order.payment.record" | "order.settlement.accept" | "order.settlement.reject"
+ )
+}
+
+pub fn deferred_payment_message() -> String {
+ DEFERRED_PAYMENT_MESSAGE.to_owned()
+}
diff --git a/src/main.rs b/src/main.rs
@@ -1,5 +1,6 @@
#![forbid(unsafe_code)]
+mod deferred_payment;
mod domain;
mod operation_adapter;
mod operation_basket;
@@ -22,6 +23,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use clap::Parser;
+use crate::deferred_payment::{deferred_payment_message, is_deferred_payment_operation};
use crate::operation_adapter::{
OperationAdapter, OperationAdapterError, OperationNetworkMode, OperationOutputFormat,
OperationRequest, OperationRequestPayload, OperationResultPayload, OperationService,
@@ -56,10 +58,15 @@ fn run() -> Result<ExitCode, runtime::RuntimeError> {
debug_assert!(operation_registry::registry_linkage_is_valid());
debug_assert!(operation_adapter::adapter_registry_linkage_is_valid());
let args = TargetCliArgs::parse();
- let config = RuntimeConfig::from_system(&runtime_args_from_target(&args))?;
- let logging = initialize_logging(&config.logging)?;
let request =
TargetOperationRequest::from_target_args(&args).map_err(operation_config_error)?;
+ if let Err(error) = validate_pre_runtime_request_contract(&request) {
+ let envelope = failure_envelope(&request, error);
+ render_envelope(&envelope, args.format)?;
+ return Ok(envelope_exit_code(&envelope));
+ }
+ let config = RuntimeConfig::from_system(&runtime_args_from_target(&args))?;
+ let logging = initialize_logging(&config.logging)?;
let envelope = match validate_request_contract(&request, &config) {
Ok(()) => execute_request(request, &config, &logging),
Err(error) => failure_envelope(&request, error),
@@ -336,6 +343,15 @@ fn validate_request_contract(
request: &TargetOperationRequest,
config: &RuntimeConfig,
) -> Result<(), OperationAdapterError> {
+ validate_pre_runtime_request_contract(request)?;
+ validate_signer_mode_contract(request, config)?;
+ validate_network_contract(request, config)?;
+ Ok(())
+}
+
+fn validate_pre_runtime_request_contract(
+ request: &TargetOperationRequest,
+) -> Result<(), OperationAdapterError> {
let spec = request.spec();
if matches!(
request.context().output_format,
@@ -353,14 +369,12 @@ fn validate_request_contract(
message: format!("`{}` does not support --dry-run", spec.cli_path),
});
}
- if deferred_payment_operation(spec.operation_id) {
+ if is_deferred_payment_operation(spec.operation_id) {
return Err(OperationAdapterError::not_implemented(
spec.operation_id,
deferred_payment_message(),
));
}
- validate_signer_mode_contract(request, config)?;
- validate_network_contract(request, config)?;
Ok(())
}
@@ -460,17 +474,6 @@ fn external_network_operation(operation_id: &str) -> bool {
)
}
-fn deferred_payment_operation(operation_id: &str) -> bool {
- matches!(
- operation_id,
- "order.payment.record" | "order.settlement.accept" | "order.settlement.reject"
- )
-}
-
-fn deferred_payment_message() -> String {
- "payments and settlement are not implemented in this Radroots release; order coordination is available now, and payment support is planned for a future phase".to_owned()
-}
-
fn failure_envelope(
request: &TargetOperationRequest,
error: OperationAdapterError,
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -1287,12 +1287,7 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
insert_string(&mut input, "order_id", &args.order_id);
insert_string(&mut input, "amount", &args.amount);
insert_string(&mut input, "currency", &args.currency);
- if let Some(method) = args.method {
- input.insert(
- "method".to_owned(),
- Value::String(method.as_protocol_method().to_owned()),
- );
- }
+ insert_string(&mut input, "method", &args.method);
insert_string(&mut input, "reference", &args.reference);
if let Some(paid_at) = args.paid_at {
input.insert(
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -1,6 +1,7 @@
use serde::Serialize;
use serde_json::{Value, json};
+use crate::deferred_payment::deferred_payment_message;
use crate::domain::runtime::{
CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView,
OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, OrderStatusView,
@@ -1267,10 +1268,7 @@ fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapter
}
fn deferred_payment_error(operation_id: &str) -> OperationAdapterError {
- OperationAdapterError::not_implemented(
- operation_id,
- "payments and settlement are not implemented in this Radroots release; order coordination is available now, and payment support is planned for a future phase".to_owned(),
- )
+ OperationAdapterError::not_implemented(operation_id, deferred_payment_message())
}
fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError {
diff --git a/src/target_cli.rs b/src/target_cli.rs
@@ -917,32 +917,14 @@ pub struct OrderPaymentRecordArgs {
pub amount: Option<String>,
#[arg(long)]
pub currency: Option<String>,
- #[arg(long, value_enum)]
- pub method: Option<OrderPaymentMethodArg>,
+ #[arg(long)]
+ pub method: Option<String>,
#[arg(long)]
pub reference: Option<String>,
#[arg(long)]
pub paid_at: Option<u64>,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
-#[value(rename_all = "snake_case")]
-pub enum OrderPaymentMethodArg {
- Cash,
- ManualTransfer,
- Other,
-}
-
-impl OrderPaymentMethodArg {
- pub const fn as_protocol_method(self) -> &'static str {
- match self {
- Self::Cash => "cash",
- Self::ManualTransfer => "manual_transfer",
- Self::Other => "other",
- }
- }
-}
-
#[derive(Debug, Clone, Args)]
pub struct OrderSettlementArgs {
#[command(subcommand)]
@@ -1008,8 +990,8 @@ mod tests {
use super::{
OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, OrderPaymentCommand,
- OrderPaymentMethodArg, OrderReceiptCommand, OrderRevisionCommand, OrderSettlementCommand,
- TargetCliArgs, TargetOutputFormat,
+ OrderReceiptCommand, OrderRevisionCommand, OrderSettlementCommand, TargetCliArgs,
+ TargetOutputFormat,
};
use crate::operation_registry::OPERATION_REGISTRY;
@@ -1311,9 +1293,22 @@ mod tests {
assert_eq!(args.order_id.as_deref(), Some("ord_test"));
assert_eq!(args.amount.as_deref(), Some("12"));
assert_eq!(args.currency.as_deref(), Some("USD"));
- assert_eq!(args.method, Some(OrderPaymentMethodArg::ManualTransfer));
+ assert_eq!(args.method.as_deref(), Some("manual_transfer"));
assert_eq!(args.reference.as_deref(), Some("memo-1"));
assert_eq!(args.paid_at, Some(1_777_666_000));
+
+ let future_method = TargetCliArgs::try_parse_from([
+ "radroots", "order", "payment", "record", "ord_test", "--method", "card",
+ ])
+ .expect("target args parse");
+ let crate::target_cli::TargetCommand::Order(order) = future_method.command else {
+ panic!("expected order command")
+ };
+ let OrderCommand::Payment(payment) = order.command else {
+ panic!("expected order payment command")
+ };
+ let OrderPaymentCommand::Record(args) = payment.command;
+ assert_eq!(args.method.as_deref(), Some("card"));
}
#[test]
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -283,6 +283,22 @@ fn payment_commands_return_not_implemented_before_mutation_preflight() {
[
"--format",
"json",
+ "--relay",
+ "not-a-url",
+ "order",
+ "payment",
+ "record",
+ "ord_pending",
+ "--method",
+ "card",
+ ]
+ .as_slice(),
+ ),
+ (
+ "order.payment.record",
+ [
+ "--format",
+ "json",
"--offline",
"order",
"payment",
@@ -308,7 +324,23 @@ fn payment_commands_return_not_implemented_before_mutation_preflight() {
[
"--format",
"json",
+ "--relay",
+ "not-a-url",
+ "order",
+ "settlement",
+ "accept",
+ "ord_pending",
+ ]
+ .as_slice(),
+ ),
+ (
+ "order.settlement.accept",
+ [
+ "--format",
+ "json",
"--online",
+ "--relay",
+ "not-a-url",
"order",
"settlement",
"accept",