commit 623d1c0e8d8490daf436edf6d1ec8cd56eae81cb
parent 6b6cedd8b415e53c4667d7556f64129ca912e6b3
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Apr 2026 07:07:49 +0000
order: add revision proposals
Diffstat:
8 files changed, 1613 insertions(+), 17 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1485,6 +1485,80 @@ impl OrderReceiptView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct OrderRevisionProposalView {
+ pub state: String,
+ pub source: String,
+ pub order_id: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub revision_id: Option<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(default, skip_serializing_if = "Vec::is_empty")]
+ pub items: Vec<OrderDraftItemView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub economics: Option<RadrootsTradeOrderEconomics>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub inventory: Option<OrderInventoryView>,
+ #[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 OrderRevisionProposalView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "missing" => CommandDisposition::NotFound,
+ "invalid" | "requested" | "declined" | "fulfilled" | "terminal" | "forked" => {
+ CommandDisposition::ValidationFailed
+ }
+ "unconfigured" => CommandDisposition::Unconfigured,
+ "unavailable" => CommandDisposition::ExternalUnavailable,
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct OrderStatusView {
pub state: String,
pub source: String,
diff --git a/src/main.rs b/src/main.rs
@@ -275,6 +275,9 @@ fn execute_request(
TargetOperationRequest::OrderCancel(request) => {
execute_with(OrderOperationService::new(config), request)
}
+ TargetOperationRequest::OrderRevisionPropose(request) => {
+ execute_with(OrderOperationService::new(config), request)
+ }
TargetOperationRequest::OrderFulfillmentUpdate(request) => {
execute_with(OrderOperationService::new(config), request)
}
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -1055,7 +1055,8 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand,
FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand,
MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand,
- OrderFulfillmentCommand, OrderReceiptCommand, OrderStatusCommand, TargetCommand,
+ OrderFulfillmentCommand, OrderReceiptCommand, OrderRevisionCommand, OrderStatusCommand,
+ TargetCommand,
};
let mut input = OperationData::new();
@@ -1212,6 +1213,24 @@ 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::Revision(revision) => match &revision.command {
+ OrderRevisionCommand::Propose(args) => {
+ insert_string(&mut input, "order_id", &args.order_id);
+ insert_string(&mut input, "reason", &args.reason);
+ insert_string(&mut input, "bin_id", &args.bin_id);
+ if let Some(bin_count) = args.bin_count {
+ input.insert(
+ "bin_count".to_owned(),
+ Value::Number(serde_json::Number::from(bin_count)),
+ );
+ }
+ insert_string(&mut input, "adjustment_id", &args.adjustment_id);
+ insert_string(&mut input, "adjustment_effect", &args.adjustment_effect);
+ insert_string(&mut input, "adjustment_amount", &args.adjustment_amount);
+ insert_string(&mut input, "adjustment_currency", &args.adjustment_currency);
+ insert_string(&mut input, "adjustment_reason", &args.adjustment_reason);
+ }
+ },
OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command {
OrderFulfillmentCommand::Update(args) => {
insert_string(&mut input, "order_id", &args.order_id);
@@ -1338,6 +1357,7 @@ target_operation_contracts! {
OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"),
OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"),
OrderCancel => (OrderCancelRequest, OrderCancelResult, "order.cancel"),
+ OrderRevisionPropose => (OrderRevisionProposeRequest, OrderRevisionProposeResult, "order.revision.propose"),
OrderFulfillmentUpdate => (OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, "order.fulfillment.update"),
OrderReceiptRecord => (OrderReceiptRecordRequest, OrderReceiptRecordResult, "order.receipt.record"),
OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"),
@@ -1481,6 +1501,101 @@ mod tests {
#[test]
fn adapter_maps_order_lifecycle_inputs() {
+ let revision = TargetCliArgs::try_parse_from([
+ "radroots",
+ "order",
+ "revision",
+ "propose",
+ "ord_test",
+ "--reason",
+ "update count",
+ "--bin-id",
+ "bin-1",
+ "--bin-count",
+ "3",
+ "--adjustment-id",
+ "adj-weather",
+ "--adjustment-effect",
+ "increase",
+ "--adjustment-amount",
+ "1.25",
+ "--adjustment-currency",
+ "USD",
+ "--adjustment-reason",
+ "weather delay",
+ ])
+ .expect("target args parse");
+ let request =
+ TargetOperationRequest::from_target_args(&revision).expect("operation request");
+ let TargetOperationRequest::OrderRevisionPropose(request) = request else {
+ panic!("expected order revision propose request")
+ };
+ assert_eq!(request.operation_id(), "order.revision.propose");
+ 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("update count")
+ );
+ assert_eq!(
+ request.payload.input.get("bin_id").and_then(Value::as_str),
+ Some("bin-1")
+ );
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("bin_count")
+ .and_then(Value::as_u64),
+ Some(3)
+ );
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("adjustment_id")
+ .and_then(Value::as_str),
+ Some("adj-weather")
+ );
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("adjustment_effect")
+ .and_then(Value::as_str),
+ Some("increase")
+ );
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("adjustment_amount")
+ .and_then(Value::as_str),
+ Some("1.25")
+ );
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("adjustment_currency")
+ .and_then(Value::as_str),
+ Some("USD")
+ );
+ assert_eq!(
+ request
+ .payload
+ .input
+ .get("adjustment_reason")
+ .and_then(Value::as_str),
+ Some("weather delay")
+ );
+
let cancel = TargetCliArgs::try_parse_from([
"radroots",
"order",
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
use crate::domain::runtime::{
CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView,
- OrderReceiptView, OrderStatusView, OrderSubmitView,
+ OrderReceiptView, OrderRevisionProposalView, OrderStatusView, OrderSubmitView,
};
use crate::operation_adapter::{
OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload,
@@ -12,13 +12,14 @@ use crate::operation_adapter::{
OrderEventListRequest, OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult,
OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult,
OrderListRequest, OrderListResult, OrderReceiptRecordRequest, OrderReceiptRecordResult,
- OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult,
+ OrderRevisionProposeRequest, OrderRevisionProposeResult, OrderStatusGetRequest,
+ OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
use crate::runtime_args::{
OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderReceiptArgs,
- OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs,
+ OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs,
};
pub struct OrderOperationService<'a> {
@@ -210,6 +211,55 @@ impl OperationService<OrderCancelRequest> for OrderOperationService<'_> {
}
}
+impl OperationService<OrderRevisionProposeRequest> for OrderOperationService<'_> {
+ type Result = OrderRevisionProposeResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<OrderRevisionProposeRequest>,
+ ) -> 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 = OrderRevisionProposeArgs {
+ key: required_order_key(&request)?,
+ reason,
+ bin_id: string_input(&request, "bin_id"),
+ bin_count: u32_input(&request, "bin_count"),
+ adjustment_id: string_input(&request, "adjustment_id"),
+ adjustment_effect: string_input(&request, "adjustment_effect"),
+ adjustment_amount: string_input(&request, "adjustment_amount"),
+ adjustment_currency: string_input(&request, "adjustment_currency"),
+ adjustment_reason: string_input(&request, "adjustment_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::revision_propose(&config, &args).map_err(|error| {
+ OperationAdapterError::runtime_failure(request.operation_id(), error)
+ })?;
+ revision_proposal_result::<OrderRevisionProposeResult>(request.operation_id(), &view)
+ }
+}
+
impl OperationService<OrderFulfillmentUpdateRequest> for OrderOperationService<'_> {
type Result = OrderFulfillmentUpdateResult;
@@ -611,6 +661,99 @@ fn order_cancellation_error_detail(view: &OrderCancellationView) -> Value {
})
}
+fn revision_proposal_result<R>(
+ operation_id: &str,
+ view: &OrderRevisionProposalView,
+) -> 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 revision propose failed validation with state `{}`",
+ view.state
+ )
+ });
+ Err(OperationAdapterError::validation_failed_with_detail(
+ operation_id,
+ message,
+ order_revision_proposal_error_detail(view),
+ ))
+ }
+ disposition => {
+ let message = view.reason.clone().unwrap_or_else(|| {
+ format!(
+ "order revision propose finished with state `{}`",
+ view.state
+ )
+ });
+ if disposition == CommandDisposition::ExternalUnavailable {
+ let detail = order_revision_proposal_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_revision_proposal_error_detail(view),
+ ))
+ } else {
+ Err(OperationAdapterError::from_command_disposition(
+ operation_id,
+ disposition,
+ message,
+ ))
+ }
+ }
+ }
+}
+
+fn order_revision_proposal_error_detail(view: &OrderRevisionProposalView) -> Value {
+ json!({
+ "state": &view.state,
+ "order_id": &view.order_id,
+ "revision_id": &view.revision_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,
+ "items": &view.items,
+ "economics": &view.economics,
+ "inventory": &view.inventory,
+ "buyer_pubkey": &view.buyer_pubkey,
+ "seller_pubkey": &view.seller_pubkey,
+ "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,
@@ -869,6 +1012,18 @@ where
.and_then(|value| usize::try_from(value).ok())
}
+fn u32_input<P>(request: &OperationRequest<P>, key: &str) -> Option<u32>
+where
+ P: OperationRequestPayload + OperationRequestData,
+{
+ request
+ .payload
+ .input()
+ .get(key)
+ .and_then(Value::as_u64)
+ .and_then(|value| u32::try_from(value).ok())
+}
+
fn u64_input<P>(request: &OperationRequest<P>, key: &str) -> Option<u64>
where
P: OperationRequestPayload + OperationRequestData,
@@ -902,7 +1057,8 @@ mod tests {
OperationAdapter, OperationContext, OperationData, OperationRequest, OrderAcceptRequest,
OrderAcceptResult, OrderCancelRequest, OrderDeclineRequest, OrderDeclineResult,
OrderEventListRequest, OrderEventWatchRequest, OrderGetRequest, OrderListRequest,
- OrderReceiptRecordRequest, OrderStatusGetRequest, OrderSubmitRequest,
+ OrderReceiptRecordRequest, OrderRevisionProposeRequest, OrderStatusGetRequest,
+ OrderSubmitRequest,
};
use crate::runtime::config::{
AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig,
@@ -1113,6 +1269,44 @@ mod tests {
}
#[test]
+ fn order_revision_propose_requires_reason_before_approval() {
+ let dir = tempdir().expect("tempdir");
+ let config = sample_config(dir.path());
+ let service = OperationAdapter::new(OrderOperationService::new(&config));
+ let revision = OperationRequest::new(
+ OperationContext::default(),
+ OrderRevisionProposeRequest::from_data(data(&[("order_id", "ord_pending")])),
+ )
+ .expect("order revision request");
+ let error = service.execute(revision).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_revision_propose_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"),
+ ("reason", "update count"),
+ ("bin_id", "bin-1"),
+ ]);
+ input.insert("bin_count".to_owned(), Value::from(3));
+ let revision = OperationRequest::new(
+ OperationContext::default(),
+ OrderRevisionProposeRequest::from_data(input),
+ )
+ .expect("order revision request");
+ let error = service.execute(revision).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());
diff --git a/src/operation_registry.rs b/src/operation_registry.rs
@@ -917,6 +917,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
true
),
operation!(
+ "order.revision.propose",
+ "radroots order revision propose",
+ "order",
+ "order_revision_propose",
+ "OrderRevisionProposeRequest",
+ "OrderRevisionProposeResult",
+ "Propose seller-authored order revision.",
+ Seller,
+ true,
+ Required,
+ High,
+ false,
+ true
+ ),
+ operation!(
"order.fulfillment.update",
"radroots order fulfillment update",
"order",
@@ -1071,6 +1086,7 @@ mod tests {
"order.accept",
"order.decline",
"order.cancel",
+ "order.revision.propose",
"order.fulfillment.update",
"order.receipt.record",
"order.status.get",
@@ -1110,6 +1126,7 @@ mod tests {
"order.accept",
"order.decline",
"order.cancel",
+ "order.revision.propose",
"order.fulfillment.update",
"order.receipt.record",
];
@@ -1124,7 +1141,7 @@ mod tests {
.copied()
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
- assert_eq!(OPERATION_REGISTRY.len(), 61);
+ assert_eq!(OPERATION_REGISTRY.len(), 62);
}
#[test]
@@ -1174,6 +1191,7 @@ mod tests {
"order.accept",
"order.decline",
"order.cancel",
+ "order.revision.propose",
"order.fulfillment.update",
"order.receipt.record",
]
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -11,7 +11,7 @@ use radroots_core::{
use radroots_events::RadrootsNostrEventPtr;
use radroots_events::kinds::{
KIND_LISTING, KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION,
- KIND_TRADE_ORDER_REQUEST, KIND_TRADE_RECEIPT,
+ KIND_TRADE_ORDER_REQUEST, KIND_TRADE_ORDER_REVISION, KIND_TRADE_RECEIPT,
};
use radroots_events::listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingStatus,
@@ -22,7 +22,7 @@ use radroots_events::trade::{
RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled,
RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem,
RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem,
- RadrootsTradeOrderRequested, RadrootsTradePricingBasis,
+ RadrootsTradeOrderRequested, RadrootsTradeOrderRevisionProposed, RadrootsTradePricingBasis,
};
use radroots_events_codec::d_tag::is_d_tag_base64url;
use radroots_events_codec::listing::decode::listing_from_event;
@@ -33,6 +33,8 @@ use radroots_events_codec::trade::{
active_trade_fulfillment_update_from_event, active_trade_order_cancel_event_build,
active_trade_order_cancel_from_event, active_trade_order_decision_event_build,
active_trade_order_request_event_build, active_trade_order_request_from_event,
+ active_trade_order_revision_proposal_event_build,
+ active_trade_order_revision_proposal_from_event,
};
use radroots_events_codec::wire::WireEventParts;
use radroots_nostr::prelude::{
@@ -64,7 +66,7 @@ use crate::domain::runtime::{
OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderFulfillmentView,
OrderGetView, OrderHistoryEntryView, OrderHistoryView, OrderInventoryBinView,
OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderReceiptView,
- OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView,
+ OrderRevisionProposalView, OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView,
OrderStatusLifecycleReceiptView, OrderStatusLifecycleView, OrderStatusView, OrderSubmitView,
OrderSummaryView, OrderWatchView, RelayFailureView,
};
@@ -78,14 +80,16 @@ use crate::runtime::direct_relay::{
use crate::runtime::signer::ActorWriteBindingError;
use crate::runtime_args::{
OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs,
- OrderFulfillmentArgs, OrderReceiptArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs,
- RecordLookupArgs,
+ OrderFulfillmentArgs, OrderReceiptArgs, OrderRevisionProposeArgs, OrderStatusArgs,
+ OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs,
};
const ORDER_DRAFT_KIND: &str = "order_draft_v1";
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_REVISION_PROPOSAL_SOURCE: &str =
+ "direct Nostr relay revision proposal 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";
@@ -913,6 +917,123 @@ pub fn decide(
))
}
+pub fn revision_propose(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+) -> Result<OrderRevisionProposalView, RuntimeError> {
+ if let Some(view) = order_revision_args_preflight_view(config, args) {
+ return Ok(view);
+ }
+ if config.relay.urls.is_empty() {
+ let mut view =
+ order_revision_base_view(config, args, "unconfigured", config.output.dry_run);
+ view.reason =
+ Some("order revision propose requires at least one configured relay".to_owned());
+ return Ok(view);
+ }
+
+ let seller = match accounts::resolve_account(config)? {
+ Some(account) => account,
+ None => {
+ let mut view =
+ order_revision_base_view(config, args, "unconfigured", config.output.dry_run);
+ view.reason =
+ Some("order revision propose requires a selected seller account".to_owned());
+ view.actions = vec!["radroots account create".to_owned()];
+ return Ok(view);
+ }
+ };
+ let selected_pubkey = seller.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_revision_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 revision_candidates =
+ order_revision_proposals_from_events(args.key.as_str(), receipt.events.as_slice());
+ 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 mut status_view = reduction.view;
+ enrich_order_status_inventory(config, &mut status_view)?;
+ if let Some(view) = order_revision_preflight_view_from_status(
+ config,
+ args,
+ &status_view,
+ selected_pubkey.as_str(),
+ &revision_candidates,
+ ) {
+ return Ok(view);
+ }
+
+ let seller_pubkey = status_view.seller_pubkey.as_deref().ok_or_else(|| {
+ RuntimeError::Config("accepted order is missing seller_pubkey".to_owned())
+ })?;
+ let signing = match resolve_local_order_fulfillment_signing_identity(config, seller_pubkey) {
+ Ok(signing) => signing,
+ Err(error) => {
+ return Ok(order_revision_binding_error_view(
+ config,
+ args,
+ &status_view,
+ error,
+ ));
+ }
+ };
+ let payload = match order_revision_payload_from_status(args, &status_view) {
+ Ok(payload) => payload,
+ Err(error) => {
+ return Ok(order_revision_invalid_view(
+ config,
+ args,
+ &status_view,
+ format!(
+ "order revision propose inputs for `{}` are invalid",
+ args.key
+ ),
+ vec![issue_with_code(
+ "revision_payload_invalid",
+ "revision",
+ error.to_string(),
+ )],
+ ));
+ }
+ };
+ if let Some(view) =
+ order_revision_inventory_preflight_view(config, args, &status_view, &payload)
+ {
+ return Ok(view);
+ }
+ let _ = order_revision_event_parts(&status_view, &payload)?;
+ if config.output.dry_run {
+ return Ok(order_revision_dry_run_view(
+ config,
+ args,
+ &status_view,
+ &payload,
+ ));
+ }
+ publish_order_revision(config, args, status_view, signing, payload)
+}
+
pub fn fulfillment_update(
config: &RuntimeConfig,
args: &OrderFulfillmentArgs,
@@ -1292,12 +1413,25 @@ enum OrderStatusRecord {
record: RadrootsActiveOrderRequestRecord,
},
Decision(RadrootsActiveOrderDecisionRecord),
+ RevisionProposal(OrderRevisionProposalRecord),
Fulfillment(RadrootsActiveOrderFulfillmentRecord),
Cancellation(RadrootsActiveOrderCancellationRecord),
Receipt(RadrootsActiveOrderReceiptRecord),
}
#[derive(Debug, Clone)]
+struct OrderRevisionProposalRecord {
+ event_id: String,
+ payload: RadrootsTradeOrderRevisionProposed,
+}
+
+#[derive(Debug, Clone)]
+struct OrderRevisionProposalCandidates {
+ records: Vec<OrderRevisionProposalRecord>,
+ issues: Vec<OrderIssueView>,
+}
+
+#[derive(Debug, Clone)]
struct OrderStatusReduction {
view: OrderStatusView,
fulfillment_event_id: Option<String>,
@@ -1372,6 +1506,9 @@ fn order_status_reduction_from_receipt_with_context(
decoded_count += 1;
decisions.push(record);
}
+ Ok(OrderStatusRecord::RevisionProposal(_record)) => {
+ decoded_count += 1;
+ }
Ok(OrderStatusRecord::Fulfillment(record)) => {
decoded_count += 1;
fulfillments.push(record);
@@ -1911,6 +2048,21 @@ fn order_status_record_from_event(
},
))
}
+ KIND_TRADE_ORDER_REVISION => {
+ let event = radroots_event_from_nostr(event);
+ let envelope =
+ active_trade_order_revision_proposal_from_event(&event).map_err(|error| {
+ RuntimeError::Config(format!(
+ "decode active order revision proposal event: {error}"
+ ))
+ })?;
+ Ok(OrderStatusRecord::RevisionProposal(
+ OrderRevisionProposalRecord {
+ event_id: event.id,
+ payload: envelope.payload,
+ },
+ ))
+ }
KIND_TRADE_FULFILLMENT_UPDATE => {
let event = radroots_event_from_nostr(event);
let envelope = active_trade_fulfillment_update_from_event(&event).map_err(|error| {
@@ -1986,6 +2138,40 @@ fn order_status_record_from_event(
}
}
+fn order_revision_proposals_from_events(
+ order_id: &str,
+ events: &[RadrootsNostrEvent],
+) -> OrderRevisionProposalCandidates {
+ let mut records = Vec::new();
+ let mut issues = Vec::new();
+ for event in events {
+ if event_kind_u32(event) != KIND_TRADE_ORDER_REVISION
+ || !event_matches_tag_value(event, "d", order_id)
+ {
+ continue;
+ }
+ let event_id = event.id.to_string();
+ match order_status_record_from_event(event) {
+ Ok(OrderStatusRecord::RevisionProposal(record)) => records.push(record),
+ Ok(_) => issues.push(issue_with_events(
+ "invalid_revision_candidate",
+ "revision_event_id",
+ format!("revision event `{event_id}` decoded as the wrong active record type"),
+ vec![event_id],
+ )),
+ Err(error) => issues.push(issue_with_events(
+ "invalid_revision_candidate",
+ "revision_event_id",
+ format!("revision event `{event_id}` failed proposal validation: {error}"),
+ vec![event_id],
+ )),
+ }
+ }
+ records.sort_by(|left, right| left.event_id.cmp(&right.event_id));
+ issues.sort_by(|left, right| left.event_ids.cmp(&right.event_ids));
+ OrderRevisionProposalCandidates { records, issues }
+}
+
fn active_order_status_state(status: &RadrootsActiveOrderStatus) -> &'static str {
match status {
RadrootsActiveOrderStatus::Missing => "missing",
@@ -2861,6 +3047,45 @@ fn order_decision_base_view(
}
}
+fn order_revision_base_view(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+ state: &str,
+ dry_run: bool,
+) -> OrderRevisionProposalView {
+ OrderRevisionProposalView {
+ state: state.to_owned(),
+ source: ORDER_REVISION_PROPOSAL_SOURCE.to_owned(),
+ order_id: args.key.clone(),
+ revision_id: None,
+ 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,
+ items: Vec::new(),
+ economics: None,
+ inventory: 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()),
+ reason: None,
+ issues: Vec::new(),
+ actions: Vec::new(),
+ }
+}
+
fn order_fulfillment_base_view(
config: &RuntimeConfig,
args: &OrderFulfillmentArgs,
@@ -3418,6 +3643,26 @@ fn apply_order_decision_status(view: &mut OrderDecisionView, status: &OrderStatu
view.inventory = status.inventory.clone();
}
+fn apply_order_revision_status(view: &mut OrderRevisionProposalView, 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.decision_event_id = status.decision_event_id.clone();
+ view.root_event_id = status.request_event_id.clone();
+ view.prev_event_id = status.decision_event_id.clone();
+ view.economics = status.economics.clone();
+ view.inventory = status.inventory.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();
+}
+
fn order_decision_preflight_view_from_status(
config: &RuntimeConfig,
args: &OrderDecisionArgs,
@@ -3466,6 +3711,178 @@ fn order_decision_preflight_view_from_status(
Some(view)
}
+fn order_revision_args_preflight_view(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+) -> Option<OrderRevisionProposalView> {
+ let mut issues = Vec::new();
+ let has_bin_id = args.bin_id.as_deref().and_then(non_empty_ref).is_some();
+ let has_bin_count = args.bin_count.is_some();
+ if has_bin_id != has_bin_count {
+ issues.push(issue_with_code(
+ "revision_item_change_incomplete",
+ "bin_id",
+ "`bin_id` and `bin_count` must be supplied together",
+ ));
+ }
+ if args.bin_count == Some(0) {
+ issues.push(issue_with_code(
+ "revision_bin_count_invalid",
+ "bin_count",
+ "bin_count must be greater than zero",
+ ));
+ }
+
+ let adjustment_inputs = [
+ args.adjustment_id.as_deref(),
+ args.adjustment_effect.as_deref(),
+ args.adjustment_amount.as_deref(),
+ args.adjustment_currency.as_deref(),
+ args.adjustment_reason.as_deref(),
+ ];
+ let adjustment_supplied = adjustment_inputs
+ .iter()
+ .any(|value| value.and_then(non_empty_ref).is_some());
+ let adjustment_complete = adjustment_inputs
+ .iter()
+ .all(|value| value.and_then(non_empty_ref).is_some());
+ if adjustment_supplied && !adjustment_complete {
+ issues.push(issue_with_code(
+ "revision_adjustment_incomplete",
+ "adjustment",
+ "all revision adjustment fields must be supplied together",
+ ));
+ }
+
+ if !has_bin_id && !adjustment_supplied {
+ issues.push(issue_with_code(
+ "revision_no_changes",
+ "revision",
+ "order revision propose requires a bin-count change or revision adjustment",
+ ));
+ }
+
+ if issues.is_empty() {
+ return None;
+ }
+ let mut view = order_revision_base_view(config, args, "invalid", config.output.dry_run);
+ view.reason = Some(format!(
+ "order revision propose inputs for `{}` failed validation",
+ args.key
+ ));
+ view.issues = issues;
+ Some(view)
+}
+
+fn order_revision_preflight_view_from_status(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+ status: &OrderStatusView,
+ selected_pubkey: &str,
+ candidates: &OrderRevisionProposalCandidates,
+) -> Option<OrderRevisionProposalView> {
+ let seller_matches = status
+ .seller_pubkey
+ .as_deref()
+ .is_some_and(|seller| seller.eq_ignore_ascii_case(selected_pubkey));
+ let state = match status.state.as_str() {
+ "accepted"
+ if seller_matches
+ && status
+ .fulfillment
+ .as_ref()
+ .and_then(|fulfillment| fulfillment.event_id.as_ref())
+ .is_none()
+ && candidates.issues.is_empty()
+ && candidates.records.is_empty() =>
+ {
+ return None;
+ }
+ "accepted" if !seller_matches => "invalid",
+ "accepted"
+ if status
+ .fulfillment
+ .as_ref()
+ .and_then(|fulfillment| fulfillment.event_id.as_ref())
+ .is_some() =>
+ {
+ "fulfilled"
+ }
+ "accepted" if !candidates.issues.is_empty() => "invalid",
+ "accepted" if !candidates.records.is_empty() => "forked",
+ "cancelled" | "completed" | "disputed" => "terminal",
+ "missing" | "requested" | "declined" | "invalid" | "unavailable" | "unconfigured" => {
+ status.state.as_str()
+ }
+ _ => "invalid",
+ };
+ let mut view = order_revision_base_view(config, args, state, config.output.dry_run);
+ apply_order_revision_status(&mut view, status);
+ if let Some(record) = candidates.records.first() {
+ view.event_id = Some(record.event_id.clone());
+ view.event_kind = Some(KIND_TRADE_ORDER_REVISION);
+ view.revision_id = Some(record.payload.revision_id.clone());
+ }
+ view.reason = Some(match state {
+ "missing" => format!("no active order events matched `{}`", args.key),
+ "requested" => format!(
+ "order revision propose refused because order `{}` has no accepted seller decision",
+ args.key
+ ),
+ "declined" => format!(
+ "order revision propose refused because order `{}` was declined",
+ args.key
+ ),
+ "terminal" => format!(
+ "order revision propose refused because order `{}` is already terminal",
+ args.key
+ ),
+ "fulfilled" => format!(
+ "order revision propose refused because order `{}` already has seller fulfillment",
+ args.key
+ ),
+ "forked" => format!(
+ "order revision propose refused because order `{}` already has a pending revision proposal",
+ args.key
+ ),
+ "invalid" if !seller_matches && status.seller_pubkey.is_some() => format!(
+ "order revision propose refused because selected account is not seller for order `{}`",
+ args.key
+ ),
+ "invalid" if !candidates.issues.is_empty() => format!(
+ "order revision propose refused because revision proposal candidates for `{}` are invalid",
+ args.key
+ ),
+ "invalid" => status.reason.clone().unwrap_or_else(|| {
+ format!(
+ "order revision propose refused because active order events for `{}` are invalid",
+ args.key
+ )
+ }),
+ _ => status.reason.clone().unwrap_or_else(|| {
+ format!(
+ "order revision propose status preflight failed with state `{}`",
+ status.state
+ )
+ }),
+ });
+ if state == "forked" {
+ view.issues.push(issue_with_events(
+ "pending_revision_exists",
+ "revision_id",
+ "a seller revision proposal is already visible for this accepted order",
+ candidates
+ .records
+ .iter()
+ .map(|record| record.event_id.clone())
+ .collect(),
+ ));
+ }
+ view.issues.extend(candidates.issues.clone());
+ view.actions = vec![format!("radroots order status get {}", args.key)];
+ Some(view)
+}
+
fn order_accept_inventory_preflight_view(
config: &RuntimeConfig,
args: &OrderDecisionArgs,
@@ -4034,6 +4451,36 @@ fn order_decision_dry_run_view(
view
}
+fn order_revision_invalid_view(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+ status: &OrderStatusView,
+ reason: impl Into<String>,
+ issues: Vec<OrderIssueView>,
+) -> OrderRevisionProposalView {
+ let mut view = order_revision_base_view(config, args, "invalid", config.output.dry_run);
+ apply_order_revision_status(&mut view, status);
+ view.reason = Some(reason.into());
+ view.issues.extend(issues);
+ view.actions = vec![format!("radroots order status get {}", args.key)];
+ view
+}
+
+fn order_revision_dry_run_view(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+ status: &OrderStatusView,
+ payload: &RadrootsTradeOrderRevisionProposed,
+) -> OrderRevisionProposalView {
+ let mut view = order_revision_base_view(config, args, "dry_run", true);
+ apply_order_revision_status(&mut view, status);
+ apply_order_revision_payload(&mut view, payload);
+ view.reason =
+ Some("dry run requested; seller revision proposal publication skipped".to_owned());
+ view.actions = vec![format!("radroots order status get {}", status.order_id)];
+ view
+}
+
fn order_fulfillment_dry_run_view(
config: &RuntimeConfig,
args: &OrderFulfillmentArgs,
@@ -4049,6 +4496,298 @@ fn order_fulfillment_dry_run_view(
view
}
+fn order_revision_payload_from_status(
+ args: &OrderRevisionProposeArgs,
+ status: &OrderStatusView,
+) -> Result<RadrootsTradeOrderRevisionProposed, RuntimeError> {
+ let revision_id = next_revision_id();
+ let economics = status.economics.clone().ok_or_else(|| {
+ RuntimeError::Config("accepted order is missing current agreement economics".to_owned())
+ })?;
+ let economics = revised_order_economics(args, &revision_id, &economics)?;
+ let items = economics
+ .items
+ .iter()
+ .map(|item| RadrootsTradeOrderItem {
+ bin_id: item.bin_id.clone(),
+ bin_count: item.bin_count,
+ })
+ .collect::<Vec<_>>();
+ Ok(RadrootsTradeOrderRevisionProposed {
+ revision_id,
+ order_id: status.order_id.clone(),
+ listing_addr: status.listing_addr.clone().ok_or_else(|| {
+ RuntimeError::Config("accepted order is missing listing_addr".to_owned())
+ })?,
+ buyer_pubkey: status.buyer_pubkey.clone().ok_or_else(|| {
+ RuntimeError::Config("accepted order is missing buyer_pubkey".to_owned())
+ })?,
+ seller_pubkey: status.seller_pubkey.clone().ok_or_else(|| {
+ RuntimeError::Config("accepted order is missing seller_pubkey".to_owned())
+ })?,
+ root_event_id: status.request_event_id.clone().ok_or_else(|| {
+ RuntimeError::Config("accepted order is missing request_event_id".to_owned())
+ })?,
+ prev_event_id: status.decision_event_id.clone().ok_or_else(|| {
+ RuntimeError::Config("accepted order is missing accepted decision event id".to_owned())
+ })?,
+ items,
+ economics,
+ reason: args.reason.trim().to_owned(),
+ })
+}
+
+fn revised_order_economics(
+ args: &OrderRevisionProposeArgs,
+ revision_id: &str,
+ current: &RadrootsTradeOrderEconomics,
+) -> Result<RadrootsTradeOrderEconomics, RuntimeError> {
+ let mut current_canonical = current.clone();
+ current_canonical.canonicalize();
+ let mut economics = current_canonical.clone();
+ let mut changed = false;
+ economics.quote_id = format!("revision_{revision_id}");
+ economics.quote_version = economics
+ .quote_version
+ .checked_add(1)
+ .ok_or_else(|| RuntimeError::Config("revision quote_version overflowed".to_owned()))?;
+
+ if let Some(bin_id) = args.bin_id.as_deref().and_then(non_empty_ref) {
+ let bin_count = args.bin_count.ok_or_else(|| {
+ RuntimeError::Config("revision bin_count is required with bin_id".to_owned())
+ })?;
+ let Some(item) = economics
+ .items
+ .iter_mut()
+ .find(|item| item.bin_id == bin_id)
+ else {
+ return Err(RuntimeError::Config(format!(
+ "revision bin `{bin_id}` is not part of the current agreement"
+ )));
+ };
+ if item.bin_count != bin_count {
+ changed = true;
+ }
+ item.bin_count = bin_count;
+ item.line_subtotal = RadrootsCoreMoney::new(
+ item.unit_price_amount * item.quantity_amount * RadrootsCoreDecimal::from(bin_count),
+ item.unit_price_currency,
+ );
+ }
+
+ if let Some(line) = revision_adjustment_line(args, economics.currency)? {
+ changed = true;
+ if economics
+ .adjustments
+ .iter()
+ .any(|existing| existing.id == line.id)
+ {
+ return Err(RuntimeError::Config(format!(
+ "revision adjustment id `{}` already exists in current agreement economics",
+ line.id
+ )));
+ }
+ economics.adjustments.push(line);
+ }
+
+ economics.canonicalize();
+ economics
+ .validate()
+ .map_err(|error| RuntimeError::Config(format!("build revision economics: {error}")))?;
+ if !changed {
+ return Err(RuntimeError::Config(
+ "order revision propose requires a changed item count or adjustment".to_owned(),
+ ));
+ }
+ Ok(economics)
+}
+
+fn revision_adjustment_line(
+ args: &OrderRevisionProposeArgs,
+ expected_currency: RadrootsCoreCurrency,
+) -> Result<Option<RadrootsTradeOrderEconomicLine>, RuntimeError> {
+ let Some(id) = args.adjustment_id.as_deref().and_then(non_empty_ref) else {
+ return Ok(None);
+ };
+ let effect = match args
+ .adjustment_effect
+ .as_deref()
+ .and_then(non_empty_ref)
+ .ok_or_else(|| RuntimeError::Config("revision adjustment effect is required".to_owned()))?
+ {
+ "increase" => RadrootsTradeEconomicEffect::Increase,
+ "decrease" => RadrootsTradeEconomicEffect::Decrease,
+ other => {
+ return Err(RuntimeError::Config(format!(
+ "revision adjustment effect `{other}` is invalid"
+ )));
+ }
+ };
+ let currency = parse_economics_currency(
+ args.adjustment_currency
+ .as_deref()
+ .and_then(non_empty_ref)
+ .ok_or_else(|| {
+ RuntimeError::Config("revision adjustment currency is required".to_owned())
+ })?,
+ "revision_adjustment_currency",
+ )?;
+ if currency != expected_currency {
+ return Err(RuntimeError::Config(
+ "revision adjustment currency must match current agreement currency".to_owned(),
+ ));
+ }
+ let amount = decimal_from_adjustment(
+ args.adjustment_amount
+ .as_deref()
+ .and_then(non_empty_ref)
+ .ok_or_else(|| {
+ RuntimeError::Config("revision adjustment amount is required".to_owned())
+ })?,
+ "revision_adjustment_amount",
+ )?;
+ if amount.is_zero() {
+ return Err(RuntimeError::Config(
+ "revision adjustment amount must be greater than zero".to_owned(),
+ ));
+ }
+ let reason = args
+ .adjustment_reason
+ .as_deref()
+ .and_then(non_empty_ref)
+ .ok_or_else(|| RuntimeError::Config("revision adjustment reason is required".to_owned()))?;
+ Ok(Some(RadrootsTradeOrderEconomicLine {
+ id: id.to_owned(),
+ kind: RadrootsTradeEconomicLineKind::RevisionAdjustment,
+ actor: RadrootsTradeEconomicActor::Seller,
+ effect,
+ amount: RadrootsCoreMoney::new(amount, currency),
+ reason: reason.to_owned(),
+ }))
+}
+
+fn order_revision_event_parts(
+ status: &OrderStatusView,
+ payload: &RadrootsTradeOrderRevisionProposed,
+) -> Result<WireEventParts, RuntimeError> {
+ let root_event_id = status.request_event_id.as_deref().ok_or_else(|| {
+ RuntimeError::Config("accepted order is missing request_event_id".to_owned())
+ })?;
+ let prev_event_id = status.decision_event_id.as_deref().ok_or_else(|| {
+ RuntimeError::Config("accepted order is missing accepted decision event id".to_owned())
+ })?;
+ active_trade_order_revision_proposal_event_build(root_event_id, prev_event_id, payload).map_err(
+ |error| RuntimeError::Config(format!("encode order revision proposal event: {error}")),
+ )
+}
+
+fn order_revision_inventory_preflight_view(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+ status: &OrderStatusView,
+ payload: &RadrootsTradeOrderRevisionProposed,
+) -> Option<OrderRevisionProposalView> {
+ let Some(current) = status.economics.as_ref() else {
+ return Some(order_revision_invalid_view(
+ config,
+ args,
+ status,
+ "order revision propose refused because current economics are missing",
+ vec![issue_with_code(
+ "revision_current_economics_missing",
+ "economics",
+ "current agreement economics are required before revision proposal",
+ )],
+ ));
+ };
+
+ let current_counts = current
+ .items
+ .iter()
+ .map(|item| (item.bin_id.as_str(), u64::from(item.bin_count)))
+ .collect::<Vec<_>>();
+ let mut issues = Vec::new();
+ for item in &payload.items {
+ let current_count = current_counts
+ .iter()
+ .find(|(bin_id, _)| *bin_id == item.bin_id)
+ .map(|(_, count)| *count)
+ .unwrap_or_default();
+ let revised_count = u64::from(item.bin_count);
+ if revised_count <= current_count {
+ continue;
+ }
+ let Some(bin) = status
+ .inventory
+ .as_ref()
+ .and_then(|inventory| inventory.bins.iter().find(|bin| bin.bin_id == item.bin_id))
+ else {
+ issues.push(issue_with_code(
+ "revision_inventory_unavailable",
+ "inventory.bin_id",
+ format!(
+ "inventory availability for revised bin `{}` is not visible",
+ item.bin_id
+ ),
+ ));
+ continue;
+ };
+ let Some(remaining_count) = bin.remaining_count else {
+ issues.push(issue_with_code(
+ "revision_inventory_unavailable",
+ "inventory.remaining_count",
+ format!(
+ "remaining inventory for revised bin `{}` is not visible",
+ item.bin_id
+ ),
+ ));
+ continue;
+ };
+ let available_for_revision = remaining_count.saturating_add(current_count);
+ if revised_count > available_for_revision {
+ issues.push(issue_with_code(
+ "revision_inventory_unavailable",
+ "inventory.remaining_count",
+ format!(
+ "revision requests {revised_count} of bin `{}`, but only {available_for_revision} are available after current reservation",
+ item.bin_id
+ ),
+ ));
+ }
+ }
+
+ if issues.is_empty() {
+ return None;
+ }
+ let mut view = order_revision_invalid_view(
+ config,
+ args,
+ status,
+ "order revision propose refused because visible inventory is unavailable for the revised items",
+ issues,
+ );
+ apply_order_revision_payload(&mut view, payload);
+ Some(view)
+}
+
+fn apply_order_revision_payload(
+ view: &mut OrderRevisionProposalView,
+ payload: &RadrootsTradeOrderRevisionProposed,
+) {
+ view.revision_id = Some(payload.revision_id.clone());
+ view.root_event_id = Some(payload.root_event_id.clone());
+ view.prev_event_id = Some(payload.prev_event_id.clone());
+ view.items = payload
+ .items
+ .iter()
+ .map(|item| OrderDraftItemView {
+ bin_id: item.bin_id.clone(),
+ bin_count: item.bin_count,
+ })
+ .collect();
+ view.economics = Some(payload.economics.clone());
+}
+
fn order_fulfillment_payload_from_status(
status: &OrderStatusView,
fulfillment_state: RadrootsActiveTradeFulfillmentState,
@@ -4200,6 +4939,49 @@ fn order_receipt_dry_run_view(
view
}
+fn publish_order_revision(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+ status: OrderStatusView,
+ signing: accounts::AccountSigningIdentity,
+ payload: RadrootsTradeOrderRevisionProposed,
+) -> Result<OrderRevisionProposalView, RuntimeError> {
+ let parts = order_revision_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_revision_view(
+ config, args, &status, &payload, event_kind, receipt,
+ ))
+}
+
+fn published_order_revision_view(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+ status: &OrderStatusView,
+ payload: &RadrootsTradeOrderRevisionProposed,
+ event_kind: u32,
+ receipt: DirectRelayPublishReceipt,
+) -> OrderRevisionProposalView {
+ let DirectRelayPublishReceipt {
+ event_id,
+ created_at: _,
+ signature: _,
+ target_relays,
+ acknowledged_relays,
+ failed_relays,
+ } = receipt;
+ let mut view = order_revision_base_view(config, args, "proposed", false);
+ apply_order_revision_status(&mut view, status);
+ apply_order_revision_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 publish_order_fulfillment(
config: &RuntimeConfig,
args: &OrderFulfillmentArgs,
@@ -4360,6 +5142,26 @@ fn order_fulfillment_binding_error_view(
view
}
+fn order_revision_binding_error_view(
+ config: &RuntimeConfig,
+ args: &OrderRevisionProposeArgs,
+ status: &OrderStatusView,
+ error: ActorWriteBindingError,
+) -> OrderRevisionProposalView {
+ 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_revision_base_view(config, args, state.as_str(), config.output.dry_run);
+ apply_order_revision_status(&mut view, status);
+ view.reason = Some(reason);
+ view.actions = actions;
+ view
+}
+
fn order_cancellation_binding_error_view(
config: &RuntimeConfig,
args: &OrderCancelArgs,
@@ -4794,6 +5596,7 @@ fn order_status_filter(order_id: &str) -> Result<RadrootsNostrFilter, RuntimeErr
.kinds([
radroots_nostr_kind(KIND_TRADE_ORDER_REQUEST as u16),
radroots_nostr_kind(KIND_TRADE_ORDER_DECISION as u16),
+ radroots_nostr_kind(KIND_TRADE_ORDER_REVISION as u16),
radroots_nostr_kind(KIND_TRADE_FULFILLMENT_UPDATE as u16),
radroots_nostr_kind(KIND_TRADE_CANCEL as u16),
radroots_nostr_kind(KIND_TRADE_RECEIPT as u16),
@@ -6655,6 +7458,18 @@ fn next_order_id() -> String {
)
}
+fn next_revision_id() -> String {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or_default();
+ let counter = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
+ format!(
+ "rev_{}",
+ encode_base64url_no_pad((nanos ^ counter).to_be_bytes())
+ )
+}
+
fn is_valid_order_id(value: &str) -> bool {
let Some(encoded) = value.strip_prefix("ord_") else {
return false;
@@ -6736,7 +7551,7 @@ mod tests {
use radroots_events::RadrootsNostrEventPtr;
use radroots_events::kinds::{
KIND_TRADE_CANCEL, KIND_TRADE_FULFILLMENT_UPDATE, KIND_TRADE_ORDER_DECISION,
- KIND_TRADE_RECEIPT,
+ KIND_TRADE_ORDER_REVISION, KIND_TRADE_RECEIPT,
};
use radroots_events::trade::{
RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType,
@@ -6744,13 +7559,13 @@ mod tests {
RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision,
RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem,
RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested,
- RadrootsTradePricingBasis,
+ RadrootsTradeOrderRevisionProposed, RadrootsTradePricingBasis,
};
use radroots_events_codec::trade::{
active_trade_buyer_receipt_event_build, active_trade_event_context_from_tags,
active_trade_fulfillment_update_event_build, active_trade_order_cancel_event_build,
active_trade_order_decision_event_build, active_trade_order_decision_from_event,
- active_trade_order_request_event_build,
+ active_trade_order_request_event_build, active_trade_order_revision_proposal_event_build,
};
use radroots_identity::RadrootsIdentity;
use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event};
@@ -6779,7 +7594,9 @@ mod tests {
order_fulfillment_preflight_view_from_status, order_history_entry_from_event,
order_history_from_receipt, order_receipt_dry_run_view, order_receipt_event_parts,
order_receipt_payload_from_status, order_receipt_preflight_view_from_status,
- order_request_filter, order_status_filter, order_status_from_receipt,
+ order_request_filter, 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_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, resolve_local_order_fulfillment_signing_identity,
@@ -6796,7 +7613,7 @@ mod tests {
use crate::runtime::signer::ActorWriteBindingError;
use crate::runtime_args::{
OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftAdjustmentArgs,
- OrderFulfillmentArgs, OrderReceiptArgs, OrderSubmitArgs,
+ OrderFulfillmentArgs, OrderReceiptArgs, OrderRevisionProposeArgs, OrderSubmitArgs,
};
#[test]
@@ -7013,6 +7830,7 @@ mod tests {
assert!(kinds.contains(&serde_json::json!(3422)));
assert!(kinds.contains(&serde_json::json!(3423)));
+ assert!(kinds.contains(&serde_json::json!(3424)));
assert!(kinds.contains(&serde_json::json!(3433)));
assert!(kinds.contains(&serde_json::json!(3432)));
assert!(kinds.contains(&serde_json::json!(3434)));
@@ -7020,6 +7838,228 @@ mod tests {
}
#[test]
+ fn order_revision_payload_updates_items_and_economics() {
+ let fixture = order_status_fixture();
+ let decision_event = signed_order_decision_event(
+ &fixture.seller,
+ &fixture.request_event,
+ fixture.order_id.as_str(),
+ fixture.listing_addr.as_str(),
+ fixture.buyer_pubkey.as_str(),
+ fixture.seller_pubkey.as_str(),
+ RadrootsTradeOrderDecision::Accepted {
+ inventory_commitments: vec![RadrootsTradeInventoryCommitment {
+ bin_id: "bin-1".to_owned(),
+ bin_count: 2,
+ }],
+ },
+ );
+ let status_view = order_status_from_receipt(
+ fixture.order_id.as_str(),
+ DirectRelayFetchReceipt {
+ target_relays: vec!["ws://relay.test".to_owned()],
+ connected_relays: vec!["ws://relay.test".to_owned()],
+ failed_relays: Vec::new(),
+ events: vec![fixture.request_event.clone(), decision_event.clone()],
+ },
+ );
+ let args = revision_args_for_fixture(&fixture, 3);
+
+ let payload =
+ order_revision_payload_from_status(&args, &status_view).expect("revision payload");
+ let parts =
+ order_revision_event_parts(&status_view, &payload).expect("revision event parts");
+ let context = active_trade_event_context_from_tags(
+ RadrootsActiveTradeMessageType::TradeOrderRevisionProposed,
+ &parts.tags,
+ )
+ .expect("revision context");
+ let request_event_id = fixture.request_event.id.to_string();
+ let decision_event_id = decision_event.id.to_string();
+
+ assert_eq!(payload.items[0].bin_id, "bin-1");
+ assert_eq!(payload.items[0].bin_count, 3);
+ assert_eq!(payload.economics.items[0].bin_count, 3);
+ assert_eq!(payload.economics.quote_version, 2);
+ assert!(payload.economics.quote_id.starts_with("revision_rev_"));
+ assert_eq!(payload.reason, "update count");
+ assert_eq!(parts.kind, KIND_TRADE_ORDER_REVISION);
+ assert_eq!(
+ context.root_event_id.as_deref(),
+ Some(request_event_id.as_str())
+ );
+ assert_eq!(
+ context.prev_event_id.as_deref(),
+ Some(decision_event_id.as_str())
+ );
+ }
+
+ #[test]
+ fn order_revision_preflight_rejects_selected_non_seller_account() {
+ let dir = tempdir().expect("tempdir");
+ let mut config = sample_config(dir.path());
+ config.relay.urls = vec!["ws://relay.test".to_owned()];
+ let fixture = order_status_fixture();
+ let decision_event = signed_order_decision_event(
+ &fixture.seller,
+ &fixture.request_event,
+ fixture.order_id.as_str(),
+ fixture.listing_addr.as_str(),
+ fixture.buyer_pubkey.as_str(),
+ fixture.seller_pubkey.as_str(),
+ RadrootsTradeOrderDecision::Accepted {
+ inventory_commitments: vec![RadrootsTradeInventoryCommitment {
+ bin_id: "bin-1".to_owned(),
+ bin_count: 2,
+ }],
+ },
+ );
+ let status_view = order_status_from_receipt(
+ fixture.order_id.as_str(),
+ DirectRelayFetchReceipt {
+ target_relays: vec!["ws://relay.test".to_owned()],
+ connected_relays: vec!["ws://relay.test".to_owned()],
+ failed_relays: Vec::new(),
+ events: vec![fixture.request_event.clone(), decision_event],
+ },
+ );
+ let args = revision_args_for_fixture(&fixture, 3);
+ let candidates = order_revision_proposals_from_events(fixture.order_id.as_str(), &[]);
+
+ let view = order_revision_preflight_view_from_status(
+ &config,
+ &args,
+ &status_view,
+ fixture.buyer_pubkey.as_str(),
+ &candidates,
+ )
+ .expect("non seller revision preflight");
+
+ assert_eq!(view.state, "invalid");
+ assert!(view.event_id.is_none());
+ assert!(
+ view.reason
+ .as_deref()
+ .expect("reason")
+ .contains("selected account is not seller")
+ );
+ }
+
+ #[test]
+ fn order_revision_preflight_rejects_pending_revision_candidate() {
+ 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 revision_event = signed_order_revision_proposal_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(),
+ 3,
+ );
+ let revision_event_id = revision_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,
+ revision_event.clone(),
+ ],
+ },
+ );
+ let args = revision_args_for_fixture(&fixture, 1);
+ let candidates =
+ order_revision_proposals_from_events(fixture.order_id.as_str(), &[revision_event]);
+
+ let view = order_revision_preflight_view_from_status(
+ &config,
+ &args,
+ &status_view,
+ fixture.seller_pubkey.as_str(),
+ &candidates,
+ )
+ .expect("pending revision preflight");
+
+ assert_eq!(view.state, "forked");
+ assert_eq!(view.event_id.as_deref(), Some(revision_event_id.as_str()));
+ assert_eq!(view.event_kind, Some(KIND_TRADE_ORDER_REVISION));
+ assert_eq!(view.issues.len(), 1);
+ assert_eq!(view.issues[0].code, "pending_revision_exists");
+ assert_eq!(view.issues[0].event_ids, vec![revision_event_id]);
+ }
+
+ #[test]
+ fn order_revision_inventory_preflight_rejects_unavailable_increase() {
+ let dir = tempdir().expect("tempdir");
+ let mut config = sample_config(dir.path());
+ config.relay.urls = vec!["ws://relay.test".to_owned()];
+ let fixture = order_status_fixture();
+ let decision_event = signed_order_decision_event(
+ &fixture.seller,
+ &fixture.request_event,
+ fixture.order_id.as_str(),
+ fixture.listing_addr.as_str(),
+ fixture.buyer_pubkey.as_str(),
+ fixture.seller_pubkey.as_str(),
+ RadrootsTradeOrderDecision::Accepted {
+ inventory_commitments: vec![RadrootsTradeInventoryCommitment {
+ bin_id: "bin-1".to_owned(),
+ bin_count: 2,
+ }],
+ },
+ );
+ let status_view = order_status_from_receipt(
+ fixture.order_id.as_str(),
+ DirectRelayFetchReceipt {
+ target_relays: vec!["ws://relay.test".to_owned()],
+ connected_relays: vec!["ws://relay.test".to_owned()],
+ failed_relays: Vec::new(),
+ events: vec![fixture.request_event.clone(), decision_event],
+ },
+ );
+ let args = revision_args_for_fixture(&fixture, 3);
+ let payload =
+ order_revision_payload_from_status(&args, &status_view).expect("revision payload");
+
+ let view = order_revision_inventory_preflight_view(&config, &args, &status_view, &payload)
+ .expect("unavailable inventory preflight");
+
+ assert_eq!(view.state, "invalid");
+ assert_eq!(
+ view.revision_id.as_deref(),
+ Some(payload.revision_id.as_str())
+ );
+ assert!(
+ view.issues
+ .iter()
+ .any(|issue| issue.code == "revision_inventory_unavailable")
+ );
+ assert!(view.event_id.is_none());
+ }
+
+ #[test]
fn order_submit_existing_request_preflight_deduplicates_identical_request() {
let dir = tempdir().expect("tempdir");
let mut config = sample_config(dir.path());
@@ -10562,6 +11602,24 @@ mod tests {
}
}
+ fn revision_args_for_fixture(
+ fixture: &OrderStatusFixture,
+ bin_count: u32,
+ ) -> OrderRevisionProposeArgs {
+ OrderRevisionProposeArgs {
+ key: fixture.order_id.clone(),
+ reason: "update count".to_owned(),
+ bin_id: Some("bin-1".to_owned()),
+ bin_count: Some(bin_count),
+ adjustment_id: None,
+ adjustment_effect: None,
+ adjustment_amount: None,
+ adjustment_currency: None,
+ adjustment_reason: None,
+ idempotency_key: None,
+ }
+ }
+
fn sample_config(root: &Path) -> RuntimeConfig {
let data = root.join("data");
let logs = root.join("logs");
@@ -10714,6 +11772,47 @@ mod tests {
.expect("signed order decision")
}
+ fn signed_order_revision_proposal_event(
+ seller: &RadrootsIdentity,
+ request_event: &radroots_nostr::prelude::RadrootsNostrEvent,
+ decision_event: &radroots_nostr::prelude::RadrootsNostrEvent,
+ order_id: &str,
+ listing_addr: &str,
+ buyer_pubkey: &str,
+ seller_pubkey: &str,
+ bin_count: u32,
+ ) -> radroots_nostr::prelude::RadrootsNostrEvent {
+ let mut economics = sample_order_economics(order_id, "bin-1", bin_count);
+ economics.quote_id = "revision_rev_test".to_owned();
+ economics.quote_version = 2;
+ economics.canonicalize();
+ let payload = RadrootsTradeOrderRevisionProposed {
+ revision_id: "rev_test".to_owned(),
+ order_id: order_id.to_owned(),
+ listing_addr: listing_addr.to_owned(),
+ buyer_pubkey: buyer_pubkey.to_owned(),
+ seller_pubkey: seller_pubkey.to_owned(),
+ root_event_id: request_event.id.to_string(),
+ prev_event_id: decision_event.id.to_string(),
+ items: vec![RadrootsTradeOrderItem {
+ bin_id: "bin-1".to_owned(),
+ bin_count,
+ }],
+ economics,
+ reason: "update count".to_owned(),
+ };
+ let parts = active_trade_order_revision_proposal_event_build(
+ payload.root_event_id.as_str(),
+ payload.prev_event_id.as_str(),
+ &payload,
+ )
+ .expect("revision proposal parts");
+ radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+ .expect("nostr event builder")
+ .sign_with_keys(seller.keys())
+ .expect("signed order revision proposal")
+ }
+
fn signed_fulfillment_update_event(
seller: &RadrootsIdentity,
request_event: &radroots_nostr::prelude::RadrootsNostrEvent,
diff --git a/src/runtime_args.rs b/src/runtime_args.rs
@@ -245,6 +245,20 @@ pub struct OrderReceiptArgs {
}
#[derive(Debug, Clone)]
+pub struct OrderRevisionProposeArgs {
+ pub key: String,
+ pub reason: String,
+ pub bin_id: Option<String>,
+ pub bin_count: Option<u32>,
+ pub adjustment_id: Option<String>,
+ pub adjustment_effect: Option<String>,
+ pub adjustment_amount: Option<String>,
+ pub adjustment_currency: Option<String>,
+ pub adjustment_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
@@ -179,6 +179,9 @@ impl TargetCommand {
OrderCommand::Accept(_) => "order.accept",
OrderCommand::Decline(_) => "order.decline",
OrderCommand::Cancel(_) => "order.cancel",
+ OrderCommand::Revision(revision) => match &revision.command {
+ OrderRevisionCommand::Propose(_) => "order.revision.propose",
+ },
OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command {
OrderFulfillmentCommand::Update(_) => "order.fulfillment.update",
},
@@ -744,6 +747,7 @@ pub enum OrderCommand {
Accept(OrderKeyArgs),
Decline(OrderDeclineArgs),
Cancel(OrderCancelArgs),
+ Revision(OrderRevisionArgs),
Fulfillment(OrderFulfillmentArgs),
Receipt(OrderReceiptArgs),
Status(OrderStatusArgs),
@@ -775,6 +779,38 @@ pub struct OrderCancelArgs {
}
#[derive(Debug, Clone, Args)]
+pub struct OrderRevisionArgs {
+ #[command(subcommand)]
+ pub command: OrderRevisionCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+pub enum OrderRevisionCommand {
+ Propose(OrderRevisionProposeArgs),
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct OrderRevisionProposeArgs {
+ pub order_id: Option<String>,
+ #[arg(long)]
+ pub reason: Option<String>,
+ #[arg(long)]
+ pub bin_id: Option<String>,
+ #[arg(long)]
+ pub bin_count: Option<u32>,
+ #[arg(long)]
+ pub adjustment_id: Option<String>,
+ #[arg(long)]
+ pub adjustment_effect: Option<String>,
+ #[arg(long)]
+ pub adjustment_amount: Option<String>,
+ #[arg(long)]
+ pub adjustment_currency: Option<String>,
+ #[arg(long)]
+ pub adjustment_reason: Option<String>,
+}
+
+#[derive(Debug, Clone, Args)]
pub struct OrderFulfillmentArgs {
#[command(subcommand)]
pub command: OrderFulfillmentCommand,
@@ -871,7 +907,7 @@ mod tests {
use super::{
OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, OrderReceiptCommand,
- TargetCliArgs, TargetOutputFormat,
+ OrderRevisionCommand, TargetCliArgs, TargetOutputFormat,
};
use crate::operation_registry::OPERATION_REGISTRY;
@@ -1008,6 +1044,49 @@ mod tests {
}
#[test]
+ fn target_parser_accepts_order_revision_propose_inputs() {
+ let parsed = TargetCliArgs::try_parse_from([
+ "radroots",
+ "order",
+ "revision",
+ "propose",
+ "ord_test",
+ "--reason",
+ "update count",
+ "--bin-id",
+ "bin-1",
+ "--bin-count",
+ "3",
+ "--adjustment-id",
+ "adj_revision",
+ "--adjustment-effect",
+ "increase",
+ "--adjustment-amount",
+ "2",
+ "--adjustment-currency",
+ "USD",
+ "--adjustment-reason",
+ "packing change",
+ ])
+ .expect("target args parse");
+
+ assert_eq!(parsed.command.operation_id(), "order.revision.propose");
+ let crate::target_cli::TargetCommand::Order(order) = parsed.command else {
+ panic!("expected order command")
+ };
+ let OrderCommand::Revision(revision) = order.command else {
+ panic!("expected order revision command")
+ };
+ let OrderRevisionCommand::Propose(args) = revision.command;
+ assert_eq!(args.order_id.as_deref(), Some("ord_test"));
+ assert_eq!(args.reason.as_deref(), Some("update count"));
+ assert_eq!(args.bin_id.as_deref(), Some("bin-1"));
+ assert_eq!(args.bin_count, Some(3));
+ assert_eq!(args.adjustment_id.as_deref(), Some("adj_revision"));
+ assert_eq!(args.adjustment_effect.as_deref(), Some("increase"));
+ }
+
+ #[test]
fn target_parser_accepts_order_receipt_record_outcomes() {
let received = TargetCliArgs::try_parse_from([
"radroots",