cli

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

commit 6b6cedd8b415e53c4667d7556f64129ca912e6b3
parent bf0de284675f705418c25c2990112db05d6402e6
Author: triesap <tyson@radroots.org>
Date:   Thu, 30 Apr 2026 06:32:33 +0000

basket: add pricing adjustments

Diffstat:
Msrc/main.rs | 6++++++
Msrc/operation_adapter.rs | 32+++++++++++++++++++++++++++-----
Msrc/operation_basket.rs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/operation_listing.rs | 6++++++
Msrc/operation_registry.rs | 36+++++++++++++++++++++++++++++++++++-
Msrc/runtime/listing.rs | 244++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/order.rs | 272++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/runtime_args.rs | 16++++++++++++++++
Msrc/target_cli.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 876 insertions(+), 35 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -245,6 +245,12 @@ fn execute_request( TargetOperationRequest::BasketItemRemove(request) => { execute_with(BasketOperationService::new(config), request) } + TargetOperationRequest::BasketAdjustmentAdd(request) => { + execute_with(BasketOperationService::new(config), request) + } + TargetOperationRequest::BasketAdjustmentRemove(request) => { + execute_with(BasketOperationService::new(config), request) + } TargetOperationRequest::BasketValidate(request) => { execute_with(BasketOperationService::new(config), request) } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1051,11 +1051,11 @@ fn value_to_data(value: Value) -> OperationData { fn target_operation_input(command: &crate::target_cli::TargetCommand) -> OperationData { use crate::target_cli::{ - AccountCommand, AccountSelectionCommand, BasketCommand, BasketItemCommand, - BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand, - FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand, - MarketProductCommand, OrderCommand, OrderEventCommand, OrderFulfillmentCommand, - OrderReceiptCommand, OrderStatusCommand, TargetCommand, + AccountCommand, AccountSelectionCommand, BasketAdjustmentCommand, BasketCommand, + BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, + FarmLocationCommand, FarmProfileCommand, ListingCommand, MarketCommand, + MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, + OrderFulfillmentCommand, OrderReceiptCommand, OrderStatusCommand, TargetCommand, }; let mut input = OperationData::new(); @@ -1127,6 +1127,12 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "price_per_unit", &args.price_per_unit); insert_string(&mut input, "available", &args.available); insert_string(&mut input, "label", &args.label); + insert_string(&mut input, "discount_id", &args.discount_id); + insert_string(&mut input, "discount_label", &args.discount_label); + insert_string(&mut input, "discount_kind", &args.discount_kind); + insert_string(&mut input, "discount_value", &args.discount_value); + insert_string(&mut input, "discount_amount", &args.discount_amount); + insert_string(&mut input, "discount_currency", &args.discount_currency); } ListingCommand::Get(args) => insert_string(&mut input, "key", &args.key), ListingCommand::Update(args) @@ -1171,6 +1177,20 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "item_id", &args.item_id); } }, + BasketCommand::Adjustment(adjustment) => match &adjustment.command { + BasketAdjustmentCommand::Add(args) => { + insert_string(&mut input, "basket_id", &args.basket_id); + insert_string(&mut input, "id", &args.id); + insert_string(&mut input, "effect", &args.effect); + insert_string(&mut input, "amount", &args.amount); + insert_string(&mut input, "currency", &args.currency); + insert_string(&mut input, "reason", &args.reason); + } + BasketAdjustmentCommand::Remove(args) => { + insert_string(&mut input, "basket_id", &args.basket_id); + insert_string(&mut input, "id", &args.id); + } + }, BasketCommand::Quote(quote) => match &quote.command { BasketQuoteCommand::Create(args) => { insert_string(&mut input, "basket_id", &args.basket_id) @@ -1308,6 +1328,8 @@ target_operation_contracts! { BasketItemAdd => (BasketItemAddRequest, BasketItemAddResult, "basket.item.add"), BasketItemUpdate => (BasketItemUpdateRequest, BasketItemUpdateResult, "basket.item.update"), BasketItemRemove => (BasketItemRemoveRequest, BasketItemRemoveResult, "basket.item.remove"), + BasketAdjustmentAdd => (BasketAdjustmentAddRequest, BasketAdjustmentAddResult, "basket.adjustment.add"), + BasketAdjustmentRemove => (BasketAdjustmentRemoveRequest, BasketAdjustmentRemoveResult, "basket.adjustment.remove"), BasketValidate => (BasketValidateRequest, BasketValidateResult, "basket.validate"), BasketQuoteCreate => (BasketQuoteCreateRequest, BasketQuoteCreateResult, "basket.quote.create"), OrderSubmit => (OrderSubmitRequest, OrderSubmitResult, "order.submit"), diff --git a/src/operation_basket.rs b/src/operation_basket.rs @@ -9,16 +9,17 @@ use serde_json::{Value, json}; use crate::domain::runtime::OrderNewView; use crate::operation_adapter::{ - BasketCreateRequest, BasketCreateResult, BasketGetRequest, BasketGetResult, - BasketItemAddRequest, BasketItemAddResult, BasketItemRemoveRequest, BasketItemRemoveResult, - BasketItemUpdateRequest, BasketItemUpdateResult, BasketListRequest, BasketListResult, - BasketQuoteCreateRequest, BasketQuoteCreateResult, BasketValidateRequest, BasketValidateResult, - OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, - OperationResult, OperationResultData, OperationService, + BasketAdjustmentAddRequest, BasketAdjustmentAddResult, BasketAdjustmentRemoveRequest, + BasketAdjustmentRemoveResult, BasketCreateRequest, BasketCreateResult, BasketGetRequest, + BasketGetResult, BasketItemAddRequest, BasketItemAddResult, BasketItemRemoveRequest, + BasketItemRemoveResult, BasketItemUpdateRequest, BasketItemUpdateResult, BasketListRequest, + BasketListResult, BasketQuoteCreateRequest, BasketQuoteCreateResult, BasketValidateRequest, + BasketValidateResult, OperationAdapterError, OperationRequest, OperationRequestData, + OperationRequestPayload, OperationResult, OperationResultData, OperationService, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; -use crate::runtime_args::OrderDraftCreateArgs; +use crate::runtime_args::{OrderDraftAdjustmentArgs, OrderDraftCreateArgs}; const BASKET_KIND: &str = "basket_v1"; const BASKET_SOURCE: &str = "local baskets - local first"; @@ -45,6 +46,8 @@ struct BasketState { updated_at_unix: u64, #[serde(default, skip_serializing_if = "Vec::is_empty")] items: Vec<BasketItem>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + adjustments: Vec<BasketAdjustment>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -61,6 +64,16 @@ struct BasketItem { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] +struct BasketAdjustment { + id: String, + effect: String, + amount: String, + currency: String, + reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] struct BasketQuote { quote_id: String, quote_version: u32, @@ -133,6 +146,7 @@ impl OperationService<BasketCreateRequest> for BasketOperationService<'_> { created_at_unix: now, updated_at_unix: now, items: initial_item.into_iter().collect(), + adjustments: Vec::new(), }, quote: None, }; @@ -312,6 +326,96 @@ impl OperationService<BasketItemRemoveRequest> for BasketOperationService<'_> { } } +impl OperationService<BasketAdjustmentAddRequest> for BasketOperationService<'_> { + type Result = BasketAdjustmentAddResult; + + fn execute( + &self, + request: OperationRequest<BasketAdjustmentAddRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let basket_id = required_basket_id(&request)?; + let mut loaded = + load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; + let adjustment = required_adjustment_from_request(&request)?; + if loaded + .document + .basket + .adjustments + .iter() + .any(|existing| existing.id == adjustment.id) + { + return Err(invalid_input( + request.operation_id(), + format!("basket adjustment `{}` already exists", adjustment.id), + )); + } + if request.context.dry_run { + return json_operation_result::<BasketAdjustmentAddResult>(json!({ + "state": "dry_run", + "source": BASKET_SOURCE, + "basket_id": basket_id, + "adjustment": adjustment, + "actions": ["radroots basket adjustment add"], + })); + } + + loaded.document.basket.adjustments.push(adjustment); + touch_basket(&mut loaded.document); + loaded.document.quote = None; + save_basket(loaded.file.as_path(), &loaded.document)?; + json_operation_result::<BasketAdjustmentAddResult>(basket_view( + &loaded.document, + loaded.file.as_path(), + Some("updated"), + )) + } +} + +impl OperationService<BasketAdjustmentRemoveRequest> for BasketOperationService<'_> { + type Result = BasketAdjustmentRemoveResult; + + fn execute( + &self, + request: OperationRequest<BasketAdjustmentRemoveRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let basket_id = required_basket_id(&request)?; + let adjustment_id = required_string(&request, "id")?; + let mut loaded = + load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; + let Some(index) = loaded + .document + .basket + .adjustments + .iter() + .position(|adjustment| adjustment.id == adjustment_id) + else { + return Err(invalid_input( + request.operation_id(), + format!("basket adjustment `{adjustment_id}` was not found"), + )); + }; + if request.context.dry_run { + return json_operation_result::<BasketAdjustmentRemoveResult>(json!({ + "state": "dry_run", + "source": BASKET_SOURCE, + "basket_id": basket_id, + "adjustment_id": adjustment_id, + "actions": ["radroots basket adjustment remove"], + })); + } + + loaded.document.basket.adjustments.remove(index); + touch_basket(&mut loaded.document); + loaded.document.quote = None; + save_basket(loaded.file.as_path(), &loaded.document)?; + json_operation_result::<BasketAdjustmentRemoveResult>(basket_view( + &loaded.document, + loaded.file.as_path(), + Some("updated"), + )) + } +} + impl OperationService<BasketValidateRequest> for BasketOperationService<'_> { type Result = BasketValidateResult; @@ -371,6 +475,7 @@ impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> { listing_addr: item.listing_addr.clone(), bin_id: Some(item.bin_id.clone()), bin_count: Some(item.quantity), + adjustments: order_adjustments_from_basket(&loaded.document), }, ))?; return json_operation_result::<BasketQuoteCreateResult>(json!({ @@ -391,6 +496,7 @@ impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> { listing_addr: item.listing_addr.clone(), bin_id: Some(item.bin_id.clone()), bin_count: Some(item.quantity), + adjustments: order_adjustments_from_basket(&loaded.document), }, ))?; let quote_economics = order.economics.clone(); @@ -516,6 +622,66 @@ where Ok(item) } +fn required_adjustment_from_request<P>( + request: &OperationRequest<P>, +) -> Result<BasketAdjustment, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + let id = required_string(request, "id")?.trim().to_owned(); + if id.is_empty() { + return Err(invalid_input( + request.operation_id(), + "`id` must not be empty".to_owned(), + )); + } + let effect = required_string(request, "effect")?.trim().to_owned(); + if effect != "increase" && effect != "decrease" { + return Err(invalid_input( + request.operation_id(), + "`effect` must be increase or decrease".to_owned(), + )); + } + let amount = required_string(request, "amount")?.trim().to_owned(); + let parsed_amount = amount + .parse::<radroots_core::RadrootsCoreDecimal>() + .map_err(|_| { + invalid_input( + request.operation_id(), + "`amount` must be a valid decimal value".to_owned(), + ) + })?; + if parsed_amount.is_sign_negative() || parsed_amount.is_zero() { + return Err(invalid_input( + request.operation_id(), + "`amount` must be greater than zero".to_owned(), + )); + } + let currency = required_string(request, "currency")? + .trim() + .to_ascii_uppercase(); + if radroots_core::RadrootsCoreCurrency::from_str_upper(currency.as_str()).is_err() { + return Err(invalid_input( + request.operation_id(), + "`currency` must be a valid ISO currency code".to_owned(), + )); + } + let reason = required_string(request, "reason")?.trim().to_owned(); + if reason.is_empty() { + return Err(invalid_input( + request.operation_id(), + "`reason` must not be empty".to_owned(), + )); + } + Ok(BasketAdjustment { + id, + effect, + amount, + currency, + reason, + }) +} + fn basket_view(document: &BasketDocument, file: &Path, state: Option<&str>) -> Value { json!({ "state": state.unwrap_or("ready"), @@ -524,6 +690,8 @@ fn basket_view(document: &BasketDocument, file: &Path, state: Option<&str>) -> V "file": file.display().to_string(), "item_count": document.basket.items.len(), "items": document.basket.items, + "adjustment_count": document.basket.adjustments.len(), + "adjustments": document.basket.adjustments, "quote": document.quote, "ready_for_quote": basket_issues(document).is_empty(), "issues": basket_issues(document), @@ -540,6 +708,7 @@ fn basket_validation_view(document: &BasketDocument, file: &Path) -> Value { "file": file.display().to_string(), "ready_for_quote": issues.is_empty(), "item_count": document.basket.items.len(), + "adjustment_count": document.basket.adjustments.len(), "issues": issues, "actions": basket_actions(document), }) @@ -582,6 +751,7 @@ fn list_basket_summaries(config: &RuntimeConfig) -> Result<Vec<Value>, Operation "state": if basket_issues(&loaded.document).is_empty() { "ready" } else { "unconfigured" }, "file": loaded.file.display().to_string(), "item_count": loaded.document.basket.items.len(), + "adjustment_count": loaded.document.basket.adjustments.len(), "ready_for_quote": basket_issues(&loaded.document).is_empty(), "quote": loaded.document.quote, "updated_at_unix": loaded.document.basket.updated_at_unix, @@ -674,6 +844,21 @@ fn quote_issues_from_order(order: &OrderNewView) -> Vec<BasketIssue> { .collect() } +fn order_adjustments_from_basket(document: &BasketDocument) -> Vec<OrderDraftAdjustmentArgs> { + document + .basket + .adjustments + .iter() + .map(|adjustment| OrderDraftAdjustmentArgs { + id: adjustment.id.clone(), + effect: adjustment.effect.clone(), + amount: adjustment.amount.clone(), + currency: adjustment.currency.clone(), + reason: adjustment.reason.clone(), + }) + .collect() +} + fn load_required_basket( config: &RuntimeConfig, lookup: &str, @@ -881,9 +1066,10 @@ mod tests { use super::BasketOperationService; use crate::operation_adapter::{ - BasketCreateRequest, BasketGetRequest, BasketItemAddRequest, BasketItemRemoveRequest, - BasketItemUpdateRequest, BasketListRequest, BasketQuoteCreateRequest, - BasketValidateRequest, OperationAdapter, OperationContext, OperationData, OperationRequest, + BasketAdjustmentAddRequest, BasketAdjustmentRemoveRequest, BasketCreateRequest, + BasketGetRequest, BasketItemAddRequest, BasketItemRemoveRequest, BasketItemUpdateRequest, + BasketListRequest, BasketQuoteCreateRequest, BasketValidateRequest, OperationAdapter, + OperationContext, OperationData, OperationRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -992,6 +1178,48 @@ mod tests { assert_eq!(validate_envelope.operation_id, "basket.validate"); assert_eq!(validate_envelope.result["ready_for_quote"], true); + let adjustment_add = OperationRequest::new( + OperationContext::default(), + BasketAdjustmentAddRequest::from_data(data(&[ + ("basket_id", "basket_items"), + ("id", "adj_pickup"), + ("effect", "decrease"), + ("amount", "1.00"), + ("currency", "USD"), + ("reason", "pickup"), + ])), + ) + .expect("basket adjustment add request"); + let adjustment_add_envelope = service + .execute(adjustment_add) + .expect("basket adjustment add result") + .to_envelope(OperationContext::default().envelope_context("req_basket_adjust_add")) + .expect("basket adjustment add envelope"); + assert_eq!( + adjustment_add_envelope.operation_id, + "basket.adjustment.add" + ); + assert_eq!(adjustment_add_envelope.result["adjustment_count"], 1); + + let adjustment_remove = OperationRequest::new( + OperationContext::default(), + BasketAdjustmentRemoveRequest::from_data(data(&[ + ("basket_id", "basket_items"), + ("id", "adj_pickup"), + ])), + ) + .expect("basket adjustment remove request"); + let adjustment_remove_envelope = service + .execute(adjustment_remove) + .expect("basket adjustment remove result") + .to_envelope(OperationContext::default().envelope_context("req_basket_adjust_remove")) + .expect("basket adjustment remove envelope"); + assert_eq!( + adjustment_remove_envelope.operation_id, + "basket.adjustment.remove" + ); + assert_eq!(adjustment_remove_envelope.result["adjustment_count"], 0); + let remove = OperationRequest::new( OperationContext::default(), BasketItemRemoveRequest::from_data(data(&[ diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -50,6 +50,12 @@ impl OperationService<ListingCreateRequest> for ListingOperationService<'_> { price_per_unit: string_input(&request, "price_per_unit"), available: string_input(&request, "available"), label: string_input(&request, "label"), + discount_id: string_input(&request, "discount_id"), + discount_label: string_input(&request, "discount_label"), + discount_kind: string_input(&request, "discount_kind"), + discount_value: string_input(&request, "discount_value"), + discount_amount: string_input(&request, "discount_amount"), + discount_currency: string_input(&request, "discount_currency"), }; if request.context.dry_run { let view = map_runtime( diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -767,6 +767,36 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ true ), operation!( + "basket.adjustment.add", + "radroots basket adjustment add", + "basket", + "basket_adjustment_add", + "BasketAdjustmentAddRequest", + "BasketAdjustmentAddResult", + "Add buyer basket adjustment.", + Buyer, + true, + None, + Medium, + false, + true + ), + operation!( + "basket.adjustment.remove", + "radroots basket adjustment remove", + "basket", + "basket_adjustment_remove", + "BasketAdjustmentRemoveRequest", + "BasketAdjustmentRemoveResult", + "Remove buyer basket adjustment.", + Buyer, + true, + None, + Medium, + false, + true + ), + operation!( "basket.validate", "radroots basket validate", "basket", @@ -1031,6 +1061,8 @@ mod tests { "basket.item.add", "basket.item.update", "basket.item.remove", + "basket.adjustment.add", + "basket.adjustment.remove", "basket.validate", "basket.quote.create", "order.submit", @@ -1071,6 +1103,8 @@ mod tests { "basket.item.add", "basket.item.update", "basket.item.remove", + "basket.adjustment.add", + "basket.adjustment.remove", "basket.quote.create", "order.submit", "order.accept", @@ -1090,7 +1124,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 59); + assert_eq!(OPERATION_REGISTRY.len(), 61); } #[test] diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -4,8 +4,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, + RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, + RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; use radroots_events::RadrootsNostrEvent; use radroots_events::farm::RadrootsFarmRef; @@ -65,6 +66,8 @@ struct ListingDraftDocument { availability: ListingDraftAvailability, delivery: ListingDraftDelivery, location: ListingDraftLocation, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + discounts: Vec<ListingDraftDiscount>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -135,6 +138,25 @@ struct ListingDraftLocation { country: Option<String>, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ListingDraftDiscount { + id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + label: String, + kind: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + value: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + amount: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + currency: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + bin_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + min_bin_count: Option<u32>, +} + #[derive(Debug, Clone)] struct ListingValidationContext { selected_account_id: Option<String>, @@ -329,10 +351,43 @@ fn build_listing_draft( region: None, country: None, }), + discounts: listing_discount_drafts_from_args(args), }; Ok((draft, defaults)) } +fn listing_discount_drafts_from_args(args: &ListingCreateArgs) -> Vec<ListingDraftDiscount> { + let has_discount = args.discount_id.is_some() + || args.discount_label.is_some() + || args.discount_kind.is_some() + || args.discount_value.is_some() + || args.discount_amount.is_some() + || args.discount_currency.is_some(); + if !has_discount { + return Vec::new(); + } + let kind = args.discount_kind.clone().unwrap_or_else(|| { + if args.discount_amount.is_some() { + "amount".to_owned() + } else { + "percent".to_owned() + } + }); + vec![ListingDraftDiscount { + id: args + .discount_id + .clone() + .unwrap_or_else(|| "discount_1".to_owned()), + label: args.discount_label.clone().unwrap_or_default(), + kind, + value: args.discount_value.clone().unwrap_or_default(), + amount: args.discount_amount.clone().unwrap_or_default(), + currency: args.discount_currency.clone().unwrap_or_default(), + bin_id: None, + min_bin_count: None, + }] +} + fn listing_output_path( config: &RuntimeConfig, explicit: Option<&std::path::PathBuf>, @@ -1036,6 +1091,12 @@ fn canonicalize_draft( let availability = build_availability(draft, contents)?; let delivery_method = build_delivery_method(draft, contents)?; let location = build_location(draft); + let discounts = build_listing_discounts( + draft, + contents, + draft.primary_bin.bin_id.trim(), + price_currency, + )?; let listing = RadrootsListing { d_tag: listing_id.clone(), @@ -1067,7 +1128,7 @@ fn canonicalize_draft( }], resource_area: None, plot: None, - discounts: None, + discounts, inventory_available: Some(inventory_available), availability: Some(availability), delivery_method: Some(delivery_method), @@ -1164,6 +1225,102 @@ fn build_location(draft: &ListingDraftDocument) -> RadrootsListingLocation { } } +fn build_listing_discounts( + draft: &ListingDraftDocument, + contents: &str, + primary_bin_id: &str, + price_currency: RadrootsCoreCurrency, +) -> Result<Option<Vec<RadrootsCoreDiscount>>, ListingValidationIssueView> { + let mut discounts = Vec::new(); + for (index, discount) in draft.discounts.iter().enumerate() { + let field_prefix = format!("discounts.{index}"); + if discount.id.trim().is_empty() { + return Err(issue_for_field( + contents, + field_prefix.as_str(), + "discount id must not be empty", + )); + } + let bin_id = discount + .bin_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(primary_bin_id) + .to_owned(); + let min = discount.min_bin_count.unwrap_or(1); + if min == 0 { + return Err(issue_for_field( + contents, + field_prefix.as_str(), + "discount min_bin_count must be greater than zero", + )); + } + let value = match discount.kind.trim() { + "percent" => { + let raw = discount.value.trim(); + if raw.is_empty() { + return Err(issue_for_field( + contents, + field_prefix.as_str(), + "percent discount requires value", + )); + } + let percent = raw.parse::<RadrootsCorePercent>().map_err(|error| { + issue_for_field( + contents, + field_prefix.as_str(), + format!("percent discount value is invalid: {error}"), + ) + })?; + RadrootsCoreDiscountValue::Percent(percent) + } + "amount" => { + let raw_amount = discount.amount.trim(); + if raw_amount.is_empty() { + return Err(issue_for_field( + contents, + field_prefix.as_str(), + "amount discount requires amount", + )); + } + let amount = parse_decimal_field(raw_amount, contents, field_prefix.as_str())?; + let currency = if discount.currency.trim().is_empty() { + price_currency + } else { + parse_currency_field( + discount.currency.as_str(), + contents, + field_prefix.as_str(), + )? + }; + RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(amount, currency)) + } + other => { + return Err(issue_for_field( + contents, + field_prefix.as_str(), + format!("unsupported discount kind `{other}`"), + )); + } + }; + let discount = RadrootsCoreDiscount { + scope: RadrootsCoreDiscountScope::Bin, + threshold: RadrootsCoreDiscountThreshold::BinCount { bin_id, min }, + value, + }; + if !discount.is_non_negative() { + return Err(issue_for_field( + contents, + field_prefix.as_str(), + "discount value must not be negative", + )); + } + discounts.push(discount); + } + Ok((!discounts.is_empty()).then_some(discounts)) +} + fn invalid_validation_view( file: &Path, listing_id: &str, @@ -1644,6 +1801,7 @@ fn line_for_field(contents: &str, field: &str) -> Option<usize> { "availability.status" => &["status ="], "delivery.method" => &["method ="], "location.primary" => &["primary ="], + field if field.starts_with("discounts.") => &["[[discounts]]"], _ => &[], }; for needle in needles { @@ -1774,8 +1932,88 @@ mod tests { region: None, country: None, }, + discounts: Vec::new(), }; let rendered = toml::to_string_pretty(&document).expect("render draft"); assert!(rendered.contains("kind = \"listing_draft_v1\"")); } + + #[test] + fn listing_draft_canonicalization_preserves_discounts() { + let seller_pubkey = "a".repeat(64); + let document = ListingDraftDocument { + version: 1, + kind: DRAFT_KIND.to_owned(), + listing: super::ListingDraftMeta { + d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(), + farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(), + seller_pubkey: seller_pubkey.clone(), + }, + product: super::ListingDraftProduct { + key: "sku".to_owned(), + title: "Widget".to_owned(), + category: "produce".to_owned(), + summary: "Fresh".to_owned(), + }, + primary_bin: super::ListingDraftPrimaryBin { + bin_id: "bin-1".to_owned(), + quantity_amount: "1".to_owned(), + quantity_unit: "each".to_owned(), + price_amount: "10".to_owned(), + price_currency: "USD".to_owned(), + price_per_amount: "1".to_owned(), + price_per_unit: "each".to_owned(), + label: "each".to_owned(), + }, + inventory: super::ListingDraftInventory { + available: "2".to_owned(), + }, + availability: super::ListingDraftAvailability { + kind: "status".to_owned(), + status: "active".to_owned(), + start: None, + end: None, + }, + delivery: super::ListingDraftDelivery { + method: "pickup".to_owned(), + }, + location: super::ListingDraftLocation { + primary: "Asheville".to_owned(), + city: None, + region: None, + country: None, + }, + discounts: vec![super::ListingDraftDiscount { + id: "discount_farmstand".to_owned(), + label: "farmstand pickup".to_owned(), + kind: "percent".to_owned(), + value: "10".to_owned(), + amount: String::new(), + currency: String::new(), + bin_id: None, + min_bin_count: None, + }], + }; + let contents = toml::to_string_pretty(&document).expect("render draft"); + let context = super::ListingValidationContext { + selected_account_id: Some("acct_seller".to_owned()), + selected_account_pubkey: Some(seller_pubkey), + selected_farm_d_tag: Some("AAAAAAAAAAAAAAAAAAAAAw".to_owned()), + farm_setup_action: "radroots farm create".to_owned(), + }; + + let canonical = + super::canonicalize_draft(&document, contents.as_str(), &context).expect("canonical"); + + assert!(contents.contains("[[discounts]]")); + assert_eq!( + canonical + .listing + .discounts + .as_ref() + .expect("discounts") + .len(), + 1 + ); + } } diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -4,7 +4,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, + RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreUnit, convert_unit_decimal, }; use radroots_events::RadrootsNostrEventPtr; @@ -17,10 +18,11 @@ use radroots_events::listing::{ }; use radroots_events::trade::{ RadrootsActiveTradeFulfillmentState, RadrootsActiveTradeMessageType, RadrootsTradeBuyerReceipt, + RadrootsTradeEconomicActor, RadrootsTradeEconomicEffect, RadrootsTradeEconomicLineKind, RadrootsTradeFulfillmentUpdated, RadrootsTradeInventoryCommitment, RadrootsTradeOrderCancelled, RadrootsTradeOrderDecision, RadrootsTradeOrderDecisionEvent, RadrootsTradeOrderEconomicItem, - RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, - RadrootsTradePricingBasis, + RadrootsTradeOrderEconomicLine, RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, + RadrootsTradeOrderRequested, RadrootsTradePricingBasis, }; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::decode::listing_from_event; @@ -156,6 +158,7 @@ struct ResolvedOrderEconomicsProduct { price_qty_amt: u32, price_qty_unit: String, primary_bin_id: Option<String>, + notes: Option<String>, } impl ResolvedOrderEconomicsProduct { @@ -168,6 +171,7 @@ impl ResolvedOrderEconomicsProduct { price_qty_amt: row.price_qty_amt, price_qty_unit: row.price_qty_unit.clone(), primary_bin_id: row.primary_bin_id.clone(), + notes: row.notes.clone(), } } @@ -180,10 +184,17 @@ impl ResolvedOrderEconomicsProduct { price_qty_amt: row.price_qty_amt, price_qty_unit: row.price_qty_unit, primary_bin_id: row.primary_bin_id, + notes: row.notes, } } } +#[derive(Debug, Clone, Deserialize)] +struct ResolvedTradeProductNotes { + #[serde(default)] + listing_discounts: Vec<RadrootsCoreDiscount>, +} + #[derive(Debug, Clone)] struct ResolvedSellerOrderRequest { request_event_id: String, @@ -283,6 +294,7 @@ pub fn scaffold( order_id.as_str(), resolved_listing.as_ref(), items.as_slice(), + args.adjustments.as_slice(), )?; let drafts_dir = drafts_dir(config); fs::create_dir_all(&drafts_dir)?; @@ -366,6 +378,7 @@ pub fn scaffold_preflight( order_id.as_str(), resolved_listing.as_ref(), items.as_slice(), + args.adjustments.as_slice(), )?; let file = drafts_dir(config).join(format!("{order_id}.toml")); let document = OrderDraftDocument { @@ -5005,6 +5018,7 @@ fn order_economics_from_resolved_listing( order_id: &str, resolved_listing: Option<&ResolvedOrderListing>, items: &[OrderDraftItem], + adjustments: &[crate::runtime_args::OrderDraftAdjustmentArgs], ) -> Result<Option<RadrootsTradeOrderEconomics>, RuntimeError> { let Some(listing) = resolved_listing else { return Ok(None); @@ -5063,6 +5077,14 @@ fn order_economics_from_resolved_listing( } let subtotal = RadrootsCoreMoney::new(subtotal_amount, currency); + let discounts = listing_discount_lines_from_product( + product, + &subtotal, + items, + quantity_amount, + quantity_unit, + )?; + let adjustments = basket_adjustment_lines(adjustments)?; let zero = RadrootsCoreMoney::zero(currency); let mut economics = RadrootsTradeOrderEconomics { quote_id: format!("quote_{order_id}"), @@ -5070,8 +5092,8 @@ fn order_economics_from_resolved_listing( pricing_basis: RadrootsTradePricingBasis::ListingEvent, currency, items: economic_items, - discounts: Vec::new(), - adjustments: Vec::new(), + discounts, + adjustments, subtotal: subtotal.clone(), discount_total: zero.clone(), adjustment_total: zero, @@ -5084,6 +5106,136 @@ fn order_economics_from_resolved_listing( Ok(Some(economics)) } +fn listing_discount_lines_from_product( + product: &ResolvedOrderEconomicsProduct, + subtotal: &RadrootsCoreMoney, + items: &[OrderDraftItem], + quantity_amount: RadrootsCoreDecimal, + quantity_unit: RadrootsCoreUnit, +) -> Result<Vec<RadrootsTradeOrderEconomicLine>, RuntimeError> { + let Some(notes) = product.notes.as_deref().and_then(non_empty_ref) else { + return Ok(Vec::new()); + }; + let parsed = serde_json::from_str::<ResolvedTradeProductNotes>(notes).map_err(|error| { + RuntimeError::Config(format!("listing discount metadata is invalid: {error}")) + })?; + let mut lines = Vec::new(); + for (index, discount) in parsed.listing_discounts.iter().enumerate() { + if !discount_applies(discount, items, quantity_amount, quantity_unit)? { + continue; + } + let amount = listing_discount_amount(discount, subtotal, items)?; + if amount.is_zero() { + return Err(RuntimeError::Config( + "listing discount amount must be greater than zero".to_owned(), + )); + } + lines.push(RadrootsTradeOrderEconomicLine { + id: format!("listing_discount_{}", index + 1), + kind: RadrootsTradeEconomicLineKind::ListingDiscount, + actor: RadrootsTradeEconomicActor::Seller, + effect: RadrootsTradeEconomicEffect::Decrease, + amount, + reason: format!("listing discount {}", index + 1), + }); + } + Ok(lines) +} + +fn discount_applies( + discount: &RadrootsCoreDiscount, + items: &[OrderDraftItem], + quantity_amount: RadrootsCoreDecimal, + quantity_unit: RadrootsCoreUnit, +) -> Result<bool, RuntimeError> { + match &discount.threshold { + RadrootsCoreDiscountThreshold::BinCount { bin_id, min } => Ok(items + .iter() + .any(|item| item.bin_id == *bin_id && item.bin_count >= *min)), + RadrootsCoreDiscountThreshold::OrderQuantity { min } => { + let requested = items.iter().fold(RadrootsCoreDecimal::ZERO, |total, item| { + total + quantity_amount * RadrootsCoreDecimal::from(item.bin_count) + }); + let converted = + convert_unit_decimal(requested, quantity_unit, min.unit).map_err(|error| { + RuntimeError::Config(format!( + "listing discount quantity threshold is incompatible: {error}" + )) + })?; + Ok(converted >= min.amount) + } + } +} + +fn listing_discount_amount( + discount: &RadrootsCoreDiscount, + subtotal: &RadrootsCoreMoney, + items: &[OrderDraftItem], +) -> Result<RadrootsCoreMoney, RuntimeError> { + match &discount.value { + RadrootsCoreDiscountValue::Percent(percent) => Ok(percent.of_money(subtotal)), + RadrootsCoreDiscountValue::MoneyPerBin(money) => { + if money.currency != subtotal.currency { + return Err(RuntimeError::Config( + "listing discount currency must match listing price currency".to_owned(), + )); + } + let multiplier = match &discount.scope { + RadrootsCoreDiscountScope::Bin => { + items.iter().map(|item| item.bin_count).sum::<u32>().max(1) + } + RadrootsCoreDiscountScope::OrderTotal => 1, + }; + Ok(money.mul_decimal(RadrootsCoreDecimal::from(multiplier))) + } + } +} + +fn basket_adjustment_lines( + adjustments: &[crate::runtime_args::OrderDraftAdjustmentArgs], +) -> Result<Vec<RadrootsTradeOrderEconomicLine>, RuntimeError> { + adjustments + .iter() + .map(|adjustment| { + let currency = + parse_economics_currency(adjustment.currency.as_str(), "adjustment_currency")?; + let amount = decimal_from_adjustment(adjustment.amount.as_str(), "adjustment_amount")?; + if amount.is_zero() { + return Err(RuntimeError::Config( + "basket adjustment amount must be greater than zero".to_owned(), + )); + } + let effect = match adjustment.effect.as_str() { + "increase" => RadrootsTradeEconomicEffect::Increase, + "decrease" => RadrootsTradeEconomicEffect::Decrease, + other => { + return Err(RuntimeError::Config(format!( + "basket adjustment effect `{other}` is invalid" + ))); + } + }; + if adjustment.id.trim().is_empty() { + return Err(RuntimeError::Config( + "basket adjustment id must not be empty".to_owned(), + )); + } + if adjustment.reason.trim().is_empty() { + return Err(RuntimeError::Config( + "basket adjustment reason must not be empty".to_owned(), + )); + } + Ok(RadrootsTradeOrderEconomicLine { + id: adjustment.id.trim().to_owned(), + kind: RadrootsTradeEconomicLineKind::BasketAdjustment, + actor: RadrootsTradeEconomicActor::Buyer, + effect, + amount: RadrootsCoreMoney::new(amount, currency), + reason: adjustment.reason.trim().to_owned(), + }) + }) + .collect() +} + fn parse_economics_currency( value: &str, field: &str, @@ -5126,6 +5278,19 @@ fn decimal_from_non_negative_f64( .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) } +fn decimal_from_adjustment(value: &str, field: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { + let parsed = value + .trim() + .parse::<RadrootsCoreDecimal>() + .map_err(|error| RuntimeError::Config(format!("basket {field} is invalid: {error}")))?; + if parsed.is_sign_negative() { + return Err(RuntimeError::Config(format!( + "basket {field} must be non-negative" + ))); + } + Ok(parsed) +} + fn view_from_loaded(loaded: LoadedOrderDraft) -> OrderGetView { let OrderInspection { state, @@ -6601,15 +6766,16 @@ mod tests { use super::{ LoadedOrderDraft, ORDER_DRAFT_KIND, ORDER_SUBMIT_SOURCE, OrderDraft, OrderDraftDocument, - OrderDraftItem, OrderStatusContext, ResolvedSellerOrderRequest, - SellerOrderRequestResolution, accepted_order_decision_payload_from_request, - active_request_record_from_resolved, canonical_order_request_payload_from_loaded, - collect_issues, declined_order_decision_payload_from_request, inspect_document, - next_order_id, order_accept_inventory_preflight_view_from_projection, - order_cancellation_dry_run_view, order_cancellation_event_parts, - order_cancellation_payload_from_status, order_cancellation_preflight_view_from_status, - order_decision_dry_run_view, order_decision_preflight_view_from_status, - order_decision_view_from_resolution, order_fulfillment_dry_run_view, + OrderDraftItem, OrderStatusContext, ResolvedOrderEconomicsProduct, ResolvedOrderListing, + ResolvedSellerOrderRequest, SellerOrderRequestResolution, + accepted_order_decision_payload_from_request, active_request_record_from_resolved, + canonical_order_request_payload_from_loaded, collect_issues, + declined_order_decision_payload_from_request, inspect_document, next_order_id, + order_accept_inventory_preflight_view_from_projection, order_cancellation_dry_run_view, + order_cancellation_event_parts, order_cancellation_payload_from_status, + order_cancellation_preflight_view_from_status, order_decision_dry_run_view, + order_decision_preflight_view_from_status, order_decision_view_from_resolution, + order_economics_from_resolved_listing, order_fulfillment_dry_run_view, 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, @@ -6629,8 +6795,8 @@ mod tests { use crate::runtime::direct_relay::DirectRelayFetchReceipt; use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ - OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, - OrderReceiptArgs, OrderSubmitArgs, + OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftAdjustmentArgs, + OrderFulfillmentArgs, OrderReceiptArgs, OrderSubmitArgs, }; #[test] @@ -6672,6 +6838,80 @@ mod tests { } #[test] + fn order_economics_applies_listing_discounts_and_basket_adjustments() { + let listing = ResolvedOrderListing { + listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), + listing_event_id: "1".repeat(64), + seller_pubkey: "seller".to_owned(), + economics_product: Some(ResolvedOrderEconomicsProduct { + qty_amt: 1, + qty_unit: "each".to_owned(), + price_amt: 10.0, + price_currency: "USD".to_owned(), + price_qty_amt: 1, + price_qty_unit: "each".to_owned(), + primary_bin_id: Some("bin-1".to_owned()), + notes: Some( + serde_json::json!({ + "listing_discounts": [{ + "scope": "bin", + "threshold": { + "kind": "bin_count", + "amount": { "bin_id": "bin-1", "min": 1 } + }, + "value": { + "kind": "percent", + "amount": { "value": "10" } + } + }] + }) + .to_string(), + ), + }), + }; + let items = vec![OrderDraftItem { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }]; + let adjustments = vec![OrderDraftAdjustmentArgs { + id: "adj_delivery".to_owned(), + effect: "increase".to_owned(), + amount: "2".to_owned(), + currency: "USD".to_owned(), + reason: "delivery".to_owned(), + }]; + + let economics = order_economics_from_resolved_listing( + "ord_AAAAAAAAAAAAAAAAAAAAAg", + Some(&listing), + items.as_slice(), + adjustments.as_slice(), + ) + .expect("economics") + .expect("economics present"); + + assert_eq!( + economics.subtotal, + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(20), RadrootsCoreCurrency::USD) + ); + assert_eq!(economics.discounts.len(), 1); + assert_eq!( + economics.discounts[0].amount, + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(2), RadrootsCoreCurrency::USD) + ); + assert_eq!(economics.adjustments.len(), 1); + assert_eq!(economics.adjustments[0].id, "adj_delivery"); + assert_eq!( + economics.adjustments[0].amount, + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(2), RadrootsCoreCurrency::USD) + ); + assert_eq!( + economics.total, + RadrootsCoreMoney::new(RadrootsCoreDecimal::from(20), RadrootsCoreCurrency::USD) + ); + } + + #[test] fn order_draft_requires_listing_event_id_for_submit_readiness() { let document = OrderDraftDocument { version: 1, diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -147,6 +147,12 @@ pub struct ListingCreateArgs { pub price_per_unit: Option<String>, pub available: Option<String>, pub label: Option<String>, + pub discount_id: Option<String>, + pub discount_label: Option<String>, + pub discount_kind: Option<String>, + pub discount_value: Option<String>, + pub discount_amount: Option<String>, + pub discount_currency: Option<String>, } #[derive(Debug, Clone)] @@ -168,6 +174,16 @@ pub struct OrderDraftCreateArgs { pub listing_addr: Option<String>, pub bin_id: Option<String>, pub bin_count: Option<u32>, + pub adjustments: Vec<OrderDraftAdjustmentArgs>, +} + +#[derive(Debug, Clone, Default)] +pub struct OrderDraftAdjustmentArgs { + pub id: String, + pub effect: String, + pub amount: String, + pub currency: String, + pub reason: String, } #[derive(Debug, Clone)] diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -163,6 +163,10 @@ impl TargetCommand { BasketItemCommand::Update(_) => "basket.item.update", BasketItemCommand::Remove(_) => "basket.item.remove", }, + BasketCommand::Adjustment(adjustment) => match &adjustment.command { + BasketAdjustmentCommand::Add(_) => "basket.adjustment.add", + BasketAdjustmentCommand::Remove(_) => "basket.adjustment.remove", + }, BasketCommand::Validate(_) => "basket.validate", BasketCommand::Quote(quote) => match quote.command { BasketQuoteCommand::Create(_) => "basket.quote.create", @@ -548,6 +552,18 @@ pub struct ListingCreateArgs { pub available: Option<String>, #[arg(long)] pub label: Option<String>, + #[arg(long = "discount-id")] + pub discount_id: Option<String>, + #[arg(long = "discount-label")] + pub discount_label: Option<String>, + #[arg(long = "discount-kind")] + pub discount_kind: Option<String>, + #[arg(long = "discount-value")] + pub discount_value: Option<String>, + #[arg(long = "discount-amount")] + pub discount_amount: Option<String>, + #[arg(long = "discount-currency")] + pub discount_currency: Option<String>, } #[derive(Debug, Clone, Args)] @@ -612,6 +628,7 @@ pub enum BasketCommand { Get(BasketKeyArgs), List, Item(BasketItemArgs), + Adjustment(BasketAdjustmentArgs), Validate(BasketKeyArgs), Quote(BasketQuoteArgs), } @@ -648,6 +665,40 @@ pub enum BasketItemCommand { } #[derive(Debug, Clone, Args)] +pub struct BasketAdjustmentArgs { + #[command(subcommand)] + pub command: BasketAdjustmentCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum BasketAdjustmentCommand { + Add(BasketAdjustmentAddArgs), + Remove(BasketAdjustmentRemoveArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct BasketAdjustmentAddArgs { + pub basket_id: Option<String>, + #[arg(long)] + pub id: Option<String>, + #[arg(long)] + pub effect: Option<String>, + #[arg(long)] + pub amount: Option<String>, + #[arg(long)] + pub currency: Option<String>, + #[arg(long)] + pub reason: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct BasketAdjustmentRemoveArgs { + pub basket_id: Option<String>, + #[arg(long)] + pub id: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct BasketItemMutationArgs { pub basket_id: Option<String>, #[arg(long = "item-id")]