commit 6b6cedd8b415e53c4667d7556f64129ca912e6b3
parent bf0de284675f705418c25c2990112db05d6402e6
Author: triesap <tyson@radroots.org>
Date: Thu, 30 Apr 2026 06:32:33 +0000
basket: add pricing adjustments
Diffstat:
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 "e.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")]