lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

order.rs (60263B)


      1 #![forbid(unsafe_code)]
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{
      5     string::{String, ToString},
      6     vec::Vec,
      7 };
      8 
      9 use crate::envelope::kind::*;
     10 #[cfg(test)]
     11 use crate::id::OrderQuoteId;
     12 use crate::id::{ClassifiedListingAddress, InventoryBinId, OrderId};
     13 #[cfg(test)]
     14 use crate::listing::operational::OperationalListingParseError;
     15 pub use crate::trade::order_economics::*;
     16 #[cfg(test)]
     17 use crate::trade::validation::OperationalListingValidationError;
     18 use radroots_core::{Currency, Decimal, Money};
     19 use radroots_identity::PublicKey;
     20 
     21 pub const RADROOTS_COMMERCIAL_LISTING_DOMAIN: &str = "trade:listing";
     22 pub const RADROOTS_ORDER_ENVELOPE_VERSION: u16 = 1;
     23 
     24 impl OrderEconomics {
     25     pub fn canonicalize(&mut self) {
     26         self.items
     27             .sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
     28         self.discounts.sort_by(|left, right| left.id.cmp(&right.id));
     29         self.adjustments
     30             .sort_by(|left, right| left.id.cmp(&right.id));
     31         if let Ok(totals) = self.derived_totals() {
     32             self.subtotal = totals.subtotal;
     33             self.discount_total = totals.discount_total;
     34             self.adjustment_total = totals.adjustment_total;
     35             self.total = totals.total;
     36         }
     37     }
     38 
     39     pub fn canonicalized(&self) -> Self {
     40         let mut economics = self.clone();
     41         economics.canonicalize();
     42         economics
     43     }
     44 
     45     pub fn derived_totals(&self) -> Result<OrderEconomicTotals, OrderPayloadError> {
     46         if self.items.is_empty() {
     47             return Err(OrderPayloadError::MissingEconomicItems);
     48         }
     49 
     50         let mut subtotal = Money::zero(self.currency);
     51         for (index, item) in self.items.iter().enumerate() {
     52             let line_subtotal = validate_economic_item(item, self.currency, index)?;
     53             subtotal = checked_money_add(&subtotal, &line_subtotal, "subtotal")?;
     54         }
     55 
     56         let mut discount_total = Money::zero(self.currency);
     57         for (index, line) in self.discounts.iter().enumerate() {
     58             validate_economic_line(line, self.currency, "discounts", index)?;
     59             if line.kind != OrderEconomicLineKind::ListingDiscount {
     60                 return Err(OrderPayloadError::InvalidEconomicLineKind {
     61                     field: "discounts",
     62                     index,
     63                 });
     64             }
     65             if line.effect != OrderEconomicEffect::Decrease {
     66                 return Err(OrderPayloadError::InvalidEconomicLineEffect {
     67                     field: "discounts",
     68                     index,
     69                 });
     70             }
     71             discount_total = checked_money_add(&discount_total, &line.amount, "discount_total")?;
     72         }
     73 
     74         let mut adjustment_total = Money::zero(self.currency);
     75         let mut total = checked_money_sub_non_negative(&subtotal, &discount_total, "total")?;
     76         for (index, line) in self.adjustments.iter().enumerate() {
     77             validate_economic_line(line, self.currency, "adjustments", index)?;
     78             if line.kind == OrderEconomicLineKind::ListingDiscount {
     79                 return Err(OrderPayloadError::InvalidEconomicLineKind {
     80                     field: "adjustments",
     81                     index,
     82                 });
     83             }
     84             adjustment_total =
     85                 checked_money_add(&adjustment_total, &line.amount, "adjustment_total")?;
     86             total = match line.effect {
     87                 OrderEconomicEffect::Increase => checked_money_add(&total, &line.amount, "total")?,
     88                 OrderEconomicEffect::Decrease => {
     89                     checked_money_sub_non_negative(&total, &line.amount, "total")?
     90                 }
     91             };
     92         }
     93 
     94         Ok(OrderEconomicTotals {
     95             subtotal,
     96             discount_total,
     97             adjustment_total,
     98             total,
     99         })
    100     }
    101 
    102     pub fn validate(&self) -> Result<(), OrderPayloadError> {
    103         validate_required_field(self.quote_id.as_str(), "quote_id")?;
    104         if self.quote_version == 0 {
    105             return Err(OrderPayloadError::InvalidQuoteVersion);
    106         }
    107 
    108         let totals = self.derived_totals()?;
    109         validate_economic_item_order(&self.items)?;
    110         validate_economic_line_order(&self.discounts, "discounts")?;
    111         validate_economic_line_order(&self.adjustments, "adjustments")?;
    112         validate_total_money(&self.subtotal, self.currency, "subtotal")?;
    113         validate_total_money(&self.discount_total, self.currency, "discount_total")?;
    114         validate_total_money(&self.adjustment_total, self.currency, "adjustment_total")?;
    115         validate_total_money(&self.total, self.currency, "total")?;
    116         validate_total_matches(&self.subtotal, &totals.subtotal, "subtotal")?;
    117         validate_total_matches(
    118             &self.discount_total,
    119             &totals.discount_total,
    120             "discount_total",
    121         )?;
    122         validate_total_matches(
    123             &self.adjustment_total,
    124             &totals.adjustment_total,
    125             "adjustment_total",
    126         )?;
    127         validate_total_matches(&self.total, &totals.total, "total")
    128     }
    129 }
    130 
    131 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    132 #[cfg_attr(all(test, feature = "std"), dto(export))]
    133 #[cfg_attr(all(test, feature = "std"), dto(ts(name = "OrderRequest")))]
    134 #[cfg_attr(
    135     any(feature = "serde", test),
    136     derive(serde::Serialize, serde::Deserialize)
    137 )]
    138 #[derive(Clone, Debug, PartialEq, Eq)]
    139 pub struct OrderRequest {
    140     pub order_id: OrderId,
    141     pub listing_addr: ClassifiedListingAddress,
    142     #[cfg_attr(all(test, feature = "std"), dto(as = "string"))]
    143     pub buyer_pubkey: PublicKey,
    144     #[cfg_attr(all(test, feature = "std"), dto(as = "string"))]
    145     pub seller_pubkey: PublicKey,
    146     pub items: Vec<OrderItem>,
    147     pub economics: OrderEconomics,
    148 }
    149 
    150 impl OrderRequest {
    151     pub fn validate(&self) -> Result<(), OrderPayloadError> {
    152         validate_required_field(self.order_id.as_str(), "order_id")?;
    153         validate_required_field(self.listing_addr.as_str(), "listing_addr")?;
    154         validate_order_items(&self.items)?;
    155         self.economics.validate()?;
    156         validate_order_economics_binding(&self.items, &self.economics)
    157     }
    158 }
    159 
    160 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    161 #[cfg_attr(all(test, feature = "std"), dto(export))]
    162 #[cfg_attr(all(test, feature = "std"), dto(ts(name = "OrderInventoryCommitment")))]
    163 #[cfg_attr(
    164     any(feature = "serde", test),
    165     derive(serde::Serialize, serde::Deserialize)
    166 )]
    167 #[derive(Clone, Debug, PartialEq, Eq)]
    168 pub struct OrderInventoryCommitment {
    169     pub bin_id: InventoryBinId,
    170     pub bin_count: u32,
    171 }
    172 
    173 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    174 #[cfg_attr(all(test, feature = "std"), dto(export))]
    175 #[cfg_attr(all(test, feature = "std"), dto(ts(name = "OrderDecisionOutcome")))]
    176 #[cfg_attr(
    177     any(feature = "serde", test),
    178     derive(serde::Serialize, serde::Deserialize)
    179 )]
    180 #[cfg_attr(
    181     any(feature = "serde", test),
    182     serde(rename_all = "snake_case", tag = "decision")
    183 )]
    184 #[derive(Clone, Debug, PartialEq, Eq)]
    185 pub enum OrderDecisionOutcome {
    186     #[cfg_attr(any(feature = "serde", test), serde(rename = "accepted"))]
    187     Accepted {
    188         inventory_commitments: Vec<OrderInventoryCommitment>,
    189     },
    190     #[cfg_attr(any(feature = "serde", test), serde(rename = "declined"))]
    191     Declined { reason: String },
    192 }
    193 
    194 impl OrderDecisionOutcome {
    195     pub fn validate(&self) -> Result<(), OrderPayloadError> {
    196         match self {
    197             Self::Accepted {
    198                 inventory_commitments,
    199             } => validate_inventory_commitments(inventory_commitments),
    200             Self::Declined { reason } => validate_required_field(reason, "reason"),
    201         }
    202     }
    203 }
    204 
    205 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    206 #[cfg_attr(all(test, feature = "std"), dto(export))]
    207 #[cfg_attr(all(test, feature = "std"), dto(ts(name = "OrderDecision")))]
    208 #[cfg_attr(
    209     any(feature = "serde", test),
    210     derive(serde::Serialize, serde::Deserialize)
    211 )]
    212 #[derive(Clone, Debug, PartialEq, Eq)]
    213 pub struct OrderDecision {
    214     pub order_id: OrderId,
    215     pub listing_addr: ClassifiedListingAddress,
    216     #[cfg_attr(all(test, feature = "std"), dto(as = "string"))]
    217     pub buyer_pubkey: PublicKey,
    218     #[cfg_attr(all(test, feature = "std"), dto(as = "string"))]
    219     pub seller_pubkey: PublicKey,
    220     pub decision: OrderDecisionOutcome,
    221 }
    222 
    223 impl OrderDecision {
    224     pub fn validate(&self) -> Result<(), OrderPayloadError> {
    225         validate_required_field(self.order_id.as_str(), "order_id")?;
    226         validate_required_field(self.listing_addr.as_str(), "listing_addr")?;
    227         self.decision.validate()
    228     }
    229 }
    230 
    231 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    232 #[cfg_attr(all(test, feature = "std"), dto(export))]
    233 #[cfg_attr(all(test, feature = "std"), dto(ts(name = "OrderCancellation")))]
    234 #[cfg_attr(
    235     any(feature = "serde", test),
    236     derive(serde::Serialize, serde::Deserialize)
    237 )]
    238 #[derive(Clone, Debug, PartialEq, Eq)]
    239 pub struct OrderCancellation {
    240     pub order_id: OrderId,
    241     pub listing_addr: ClassifiedListingAddress,
    242     #[cfg_attr(all(test, feature = "std"), dto(as = "string"))]
    243     pub buyer_pubkey: PublicKey,
    244     #[cfg_attr(all(test, feature = "std"), dto(as = "string"))]
    245     pub seller_pubkey: PublicKey,
    246     pub reason: String,
    247 }
    248 
    249 impl OrderCancellation {
    250     pub fn validate(&self) -> Result<(), OrderPayloadError> {
    251         validate_required_field(self.order_id.as_str(), "order_id")?;
    252         validate_required_field(self.listing_addr.as_str(), "listing_addr")?;
    253         validate_required_field(&self.reason, "reason")
    254     }
    255 }
    256 
    257 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    258 #[cfg_attr(all(test, feature = "std"), dto(export))]
    259 #[cfg_attr(all(test, feature = "std"), dto(ts(name = "CommercialDomain")))]
    260 #[cfg_attr(
    261     any(feature = "serde", test),
    262     derive(serde::Serialize, serde::Deserialize)
    263 )]
    264 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
    265 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    266 pub enum CommercialDomain {
    267     #[cfg_attr(any(feature = "serde", test), serde(rename = "trade:listing"))]
    268     Listing,
    269 }
    270 
    271 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
    272 #[cfg_attr(all(test, feature = "std"), dto(export))]
    273 #[cfg_attr(all(test, feature = "std"), dto(ts(name = "OrderEventType")))]
    274 #[cfg_attr(
    275     any(feature = "serde", test),
    276     derive(serde::Serialize, serde::Deserialize)
    277 )]
    278 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    279 pub enum OrderEventType {
    280     #[cfg_attr(any(feature = "serde", test), serde(rename = "TradeOrderRequested"))]
    281     OrderRequested,
    282     #[cfg_attr(any(feature = "serde", test), serde(rename = "TradeOrderDecision"))]
    283     OrderDecision,
    284     #[cfg_attr(any(feature = "serde", test), serde(rename = "TradeOrderCancelled"))]
    285     OrderCancelled,
    286 }
    287 
    288 impl OrderEventType {
    289     #[inline]
    290     pub const fn from_kind(kind: u32) -> Option<Self> {
    291         match kind {
    292             KIND_ORDER_REQUEST => Some(Self::OrderRequested),
    293             KIND_ORDER_DECISION => Some(Self::OrderDecision),
    294             KIND_ORDER_CANCELLATION => Some(Self::OrderCancelled),
    295             _ => None,
    296         }
    297     }
    298 
    299     #[inline]
    300     pub const fn kind(self) -> u32 {
    301         match self {
    302             Self::OrderRequested => KIND_ORDER_REQUEST,
    303             Self::OrderDecision => KIND_ORDER_DECISION,
    304             Self::OrderCancelled => KIND_ORDER_CANCELLATION,
    305         }
    306     }
    307 
    308     #[inline]
    309     pub const fn name(self) -> &'static str {
    310         match self {
    311             Self::OrderRequested => "TradeOrderRequested",
    312             Self::OrderDecision => "TradeOrderDecision",
    313             Self::OrderCancelled => "TradeOrderCancelled",
    314         }
    315     }
    316 
    317     #[inline]
    318     pub const fn requires_listing_snapshot(self) -> bool {
    319         matches!(self, Self::OrderRequested)
    320     }
    321 
    322     #[inline]
    323     pub const fn requires_order_chain(self) -> bool {
    324         matches!(self, Self::OrderDecision | Self::OrderCancelled)
    325     }
    326 }
    327 
    328 #[cfg_attr(
    329     any(feature = "serde", test),
    330     derive(serde::Serialize, serde::Deserialize)
    331 )]
    332 #[derive(Clone, Debug, PartialEq, Eq)]
    333 pub struct OrderEnvelope<T> {
    334     pub version: u16,
    335     pub domain: CommercialDomain,
    336     #[cfg_attr(any(feature = "serde", test), serde(rename = "type"))]
    337     pub message_type: OrderEventType,
    338     pub order_id: String,
    339     pub listing_addr: String,
    340     pub payload: T,
    341 }
    342 
    343 impl<T> OrderEnvelope<T> {
    344     #[inline]
    345     pub fn new(
    346         message_type: OrderEventType,
    347         listing_addr: impl Into<String>,
    348         order_id: impl Into<String>,
    349         payload: T,
    350     ) -> Self {
    351         Self {
    352             version: RADROOTS_ORDER_ENVELOPE_VERSION,
    353             domain: CommercialDomain::Listing,
    354             message_type,
    355             order_id: order_id.into(),
    356             listing_addr: listing_addr.into(),
    357             payload,
    358         }
    359     }
    360 
    361     pub fn validate(&self) -> Result<(), OrderEnvelopeError> {
    362         if self.version != RADROOTS_ORDER_ENVELOPE_VERSION {
    363             return Err(OrderEnvelopeError::InvalidVersion {
    364                 expected: RADROOTS_ORDER_ENVELOPE_VERSION,
    365                 got: self.version,
    366             });
    367         }
    368         if self.order_id.trim().is_empty() {
    369             return Err(OrderEnvelopeError::MissingOrderId);
    370         }
    371         if self.listing_addr.trim().is_empty() {
    372             return Err(OrderEnvelopeError::MissingListingAddr);
    373         }
    374         Ok(())
    375     }
    376 }
    377 
    378 #[derive(Debug, Clone, PartialEq, Eq)]
    379 pub enum OrderEnvelopeError {
    380     InvalidVersion { expected: u16, got: u16 },
    381     MissingOrderId,
    382     MissingListingAddr,
    383 }
    384 
    385 impl core::fmt::Display for OrderEnvelopeError {
    386     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    387         match self {
    388             Self::InvalidVersion { expected, got } => {
    389                 write!(
    390                     f,
    391                     "invalid order envelope version: expected {expected}, got {got}"
    392                 )
    393             }
    394             Self::MissingOrderId => write!(f, "missing order_id for order message"),
    395             Self::MissingListingAddr => write!(f, "missing listing_addr"),
    396         }
    397     }
    398 }
    399 
    400 #[cfg(feature = "std")]
    401 impl std::error::Error for OrderEnvelopeError {}
    402 
    403 #[derive(Debug, Clone, PartialEq, Eq)]
    404 pub enum OrderPayloadError {
    405     EmptyField(&'static str),
    406     MissingItems,
    407     InvalidItemBinCount { index: usize },
    408     MissingEconomicItems,
    409     InvalidEconomicItemBinCount { index: usize },
    410     InvalidEconomicItemQuantity { index: usize },
    411     InvalidEconomicItemPrice { index: usize },
    412     InvalidEconomicItemSubtotal { index: usize },
    413     InvalidEconomicLineAmount { field: &'static str, index: usize },
    414     InvalidEconomicLineKind { field: &'static str, index: usize },
    415     InvalidEconomicLineEffect { field: &'static str, index: usize },
    416     InvalidEconomicCurrency { field: &'static str },
    417     InvalidEconomicOrdering { field: &'static str },
    418     InvalidEconomicTotal { field: &'static str },
    419     InvalidOrderEconomicsBinding { field: &'static str },
    420     InvalidQuoteVersion,
    421     MissingInventoryCommitments,
    422     InvalidInventoryCommitmentCount { index: usize },
    423 }
    424 
    425 impl core::fmt::Display for OrderPayloadError {
    426     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    427         match self {
    428             Self::EmptyField(field) => write!(f, "{field} cannot be empty"),
    429             Self::MissingItems => write!(f, "items must contain at least one item"),
    430             Self::InvalidItemBinCount { index } => {
    431                 write!(f, "items[{index}].bin_count must be greater than zero")
    432             }
    433             Self::MissingEconomicItems => {
    434                 write!(f, "economics.items must contain at least one item")
    435             }
    436             Self::InvalidEconomicItemBinCount { index } => write!(
    437                 f,
    438                 "economics.items[{index}].bin_count must be greater than zero"
    439             ),
    440             Self::InvalidEconomicItemQuantity { index } => write!(
    441                 f,
    442                 "economics.items[{index}].quantity_amount must be greater than zero"
    443             ),
    444             Self::InvalidEconomicItemPrice { index } => write!(
    445                 f,
    446                 "economics.items[{index}].unit_price_amount must not be negative"
    447             ),
    448             Self::InvalidEconomicItemSubtotal { index } => {
    449                 write!(f, "economics.items[{index}].line_subtotal is invalid")
    450             }
    451             Self::InvalidEconomicLineAmount { field, index } => {
    452                 write!(
    453                     f,
    454                     "economics.{field}[{index}].amount must be greater than zero"
    455                 )
    456             }
    457             Self::InvalidEconomicLineKind { field, index } => {
    458                 write!(f, "economics.{field}[{index}].kind is invalid")
    459             }
    460             Self::InvalidEconomicLineEffect { field, index } => {
    461                 write!(f, "economics.{field}[{index}].effect is invalid")
    462             }
    463             Self::InvalidEconomicCurrency { field } => {
    464                 write!(f, "economics.{field} currency is invalid")
    465             }
    466             Self::InvalidEconomicOrdering { field } => {
    467                 write!(f, "economics.{field} is not in canonical order")
    468             }
    469             Self::InvalidEconomicTotal { field } => {
    470                 write!(f, "economics.{field} total is invalid")
    471             }
    472             Self::InvalidOrderEconomicsBinding { field } => {
    473                 write!(f, "order {field} does not match economics")
    474             }
    475             Self::InvalidQuoteVersion => {
    476                 write!(f, "economics.quote_version must be greater than zero")
    477             }
    478             Self::MissingInventoryCommitments => {
    479                 write!(
    480                     f,
    481                     "accepted decisions must contain at least one inventory commitment"
    482                 )
    483             }
    484             Self::InvalidInventoryCommitmentCount { index } => write!(
    485                 f,
    486                 "inventory_commitments[{index}].bin_count must be greater than zero"
    487             ),
    488         }
    489     }
    490 }
    491 
    492 #[cfg(feature = "std")]
    493 impl std::error::Error for OrderPayloadError {}
    494 
    495 fn validate_required_field(value: &str, field: &'static str) -> Result<(), OrderPayloadError> {
    496     if value.trim().is_empty() {
    497         Err(OrderPayloadError::EmptyField(field))
    498     } else {
    499         Ok(())
    500     }
    501 }
    502 
    503 fn validate_order_items(items: &[OrderItem]) -> Result<(), OrderPayloadError> {
    504     if items.is_empty() {
    505         return Err(OrderPayloadError::MissingItems);
    506     }
    507     for (index, item) in items.iter().enumerate() {
    508         validate_required_field(item.bin_id.as_str(), "bin_id")?;
    509         if item.bin_count == 0 {
    510             return Err(OrderPayloadError::InvalidItemBinCount { index });
    511         }
    512     }
    513     Ok(())
    514 }
    515 
    516 fn validate_economic_item(
    517     item: &OrderEconomicItem,
    518     expected_currency: Currency,
    519     index: usize,
    520 ) -> Result<Money, OrderPayloadError> {
    521     validate_required_field(item.bin_id.as_str(), "economics.items.bin_id")?;
    522     if item.bin_count == 0 {
    523         return Err(OrderPayloadError::InvalidEconomicItemBinCount { index });
    524     }
    525     if item.quantity_amount.is_zero() || item.quantity_amount.is_sign_negative() {
    526         return Err(OrderPayloadError::InvalidEconomicItemQuantity { index });
    527     }
    528     if item.unit_price_amount.is_sign_negative() {
    529         return Err(OrderPayloadError::InvalidEconomicItemPrice { index });
    530     }
    531     if item.unit_price_currency != expected_currency {
    532         return Err(OrderPayloadError::InvalidEconomicCurrency {
    533             field: "items.unit_price_currency",
    534         });
    535     }
    536     validate_total_money(
    537         &item.line_subtotal,
    538         expected_currency,
    539         "items.line_subtotal",
    540     )?;
    541 
    542     let quantity_total = checked_decimal_mul(item.quantity_amount, Decimal::from(item.bin_count))
    543         .ok_or(OrderPayloadError::InvalidEconomicItemSubtotal { index })?;
    544     let expected_subtotal = checked_decimal_mul(item.unit_price_amount, quantity_total)
    545         .ok_or(OrderPayloadError::InvalidEconomicItemSubtotal { index })?;
    546     if item.line_subtotal.amount() != expected_subtotal {
    547         return Err(OrderPayloadError::InvalidEconomicItemSubtotal { index });
    548     }
    549     Ok(item.line_subtotal.clone())
    550 }
    551 
    552 fn validate_order_economics_binding(
    553     items: &[OrderItem],
    554     economics: &OrderEconomics,
    555 ) -> Result<(), OrderPayloadError> {
    556     let order_items = normalized_order_item_counts(items).ok_or(
    557         OrderPayloadError::InvalidOrderEconomicsBinding {
    558             field: "items.bin_count",
    559         },
    560     )?;
    561     if order_items.len() != economics.items.len() {
    562         return Err(OrderPayloadError::InvalidOrderEconomicsBinding { field: "items" });
    563     }
    564     for (item, economic_item) in order_items.iter().zip(economics.items.iter()) {
    565         if item.bin_id != economic_item.bin_id.as_str() {
    566             return Err(OrderPayloadError::InvalidOrderEconomicsBinding {
    567                 field: "items.bin_id",
    568             });
    569         }
    570         if item.bin_count != u64::from(economic_item.bin_count) {
    571             return Err(OrderPayloadError::InvalidOrderEconomicsBinding {
    572                 field: "items.bin_count",
    573             });
    574         }
    575     }
    576     Ok(())
    577 }
    578 
    579 #[derive(Debug, PartialEq, Eq)]
    580 struct NormalizedOrderItemCount {
    581     bin_id: String,
    582     bin_count: u64,
    583 }
    584 
    585 fn normalized_order_item_counts(items: &[OrderItem]) -> Option<Vec<NormalizedOrderItemCount>> {
    586     let mut counts: Vec<NormalizedOrderItemCount> = Vec::new();
    587     for item in items {
    588         let bin_id = item.bin_id.as_str();
    589         if item.bin_count == 0 {
    590             return None;
    591         }
    592         if let Some(existing) = counts.iter_mut().find(|count| count.bin_id == bin_id) {
    593             existing.bin_count = existing.bin_count.checked_add(u64::from(item.bin_count))?;
    594         } else {
    595             counts.push(NormalizedOrderItemCount {
    596                 bin_id: bin_id.to_string(),
    597                 bin_count: u64::from(item.bin_count),
    598             });
    599         }
    600     }
    601     counts.sort_by(|left, right| left.bin_id.cmp(&right.bin_id));
    602     Some(counts)
    603 }
    604 
    605 fn validate_economic_line(
    606     line: &OrderEconomicLine,
    607     expected_currency: Currency,
    608     field: &'static str,
    609     index: usize,
    610 ) -> Result<(), OrderPayloadError> {
    611     validate_required_field(&line.id, "economics.line.id")?;
    612     validate_required_field(&line.reason, "economics.line.reason")?;
    613     if line.amount.currency() != expected_currency {
    614         return Err(OrderPayloadError::InvalidEconomicCurrency { field });
    615     }
    616     if line.amount.amount().is_zero() || line.amount.amount().is_sign_negative() {
    617         return Err(OrderPayloadError::InvalidEconomicLineAmount { field, index });
    618     }
    619     Ok(())
    620 }
    621 
    622 fn validate_economic_item_order(items: &[OrderEconomicItem]) -> Result<(), OrderPayloadError> {
    623     for pair in items.windows(2) {
    624         if pair[0].bin_id >= pair[1].bin_id {
    625             return Err(OrderPayloadError::InvalidEconomicOrdering {
    626                 field: "items.bin_id",
    627             });
    628         }
    629     }
    630     Ok(())
    631 }
    632 
    633 fn validate_economic_line_order(
    634     lines: &[OrderEconomicLine],
    635     field: &'static str,
    636 ) -> Result<(), OrderPayloadError> {
    637     for pair in lines.windows(2) {
    638         if pair[0].id >= pair[1].id {
    639             return Err(OrderPayloadError::InvalidEconomicOrdering { field });
    640         }
    641     }
    642     Ok(())
    643 }
    644 
    645 fn validate_total_money(
    646     money: &Money,
    647     expected_currency: Currency,
    648     field: &'static str,
    649 ) -> Result<(), OrderPayloadError> {
    650     if money.currency() != expected_currency {
    651         return Err(OrderPayloadError::InvalidEconomicCurrency { field });
    652     }
    653     if money.amount().is_sign_negative() {
    654         return Err(OrderPayloadError::InvalidEconomicTotal { field });
    655     }
    656     Ok(())
    657 }
    658 
    659 fn validate_total_matches(
    660     actual: &Money,
    661     expected: &Money,
    662     field: &'static str,
    663 ) -> Result<(), OrderPayloadError> {
    664     if actual.currency() != expected.currency() {
    665         return Err(OrderPayloadError::InvalidEconomicCurrency { field });
    666     }
    667     if actual.amount() != expected.amount() {
    668         return Err(OrderPayloadError::InvalidEconomicTotal { field });
    669     }
    670     Ok(())
    671 }
    672 
    673 fn checked_decimal_add(left: Decimal, right: Decimal) -> Option<Decimal> {
    674     left.checked_add(right).ok()
    675 }
    676 
    677 fn checked_decimal_sub(left: Decimal, right: Decimal) -> Option<Decimal> {
    678     left.checked_sub(right).ok()
    679 }
    680 
    681 fn checked_decimal_mul(left: Decimal, right: Decimal) -> Option<Decimal> {
    682     left.checked_mul(right).ok()
    683 }
    684 
    685 fn checked_money_add(
    686     left: &Money,
    687     right: &Money,
    688     field: &'static str,
    689 ) -> Result<Money, OrderPayloadError> {
    690     if left.currency() != right.currency() {
    691         return Err(OrderPayloadError::InvalidEconomicCurrency { field });
    692     }
    693     let amount = checked_decimal_add(left.amount(), right.amount())
    694         .ok_or(OrderPayloadError::InvalidEconomicTotal { field })?;
    695     Money::try_new(amount, left.currency())
    696         .map_err(|_| OrderPayloadError::InvalidEconomicTotal { field })
    697 }
    698 
    699 fn checked_money_sub_non_negative(
    700     left: &Money,
    701     right: &Money,
    702     field: &'static str,
    703 ) -> Result<Money, OrderPayloadError> {
    704     if left.currency() != right.currency() {
    705         return Err(OrderPayloadError::InvalidEconomicCurrency { field });
    706     }
    707     let amount = checked_decimal_sub(left.amount(), right.amount())
    708         .ok_or(OrderPayloadError::InvalidEconomicTotal { field })?;
    709     if amount.is_sign_negative() {
    710         return Err(OrderPayloadError::InvalidEconomicTotal { field });
    711     }
    712     Money::try_new(amount, left.currency())
    713         .map_err(|_| OrderPayloadError::InvalidEconomicTotal { field })
    714 }
    715 
    716 fn validate_inventory_commitments(
    717     commitments: &[OrderInventoryCommitment],
    718 ) -> Result<(), OrderPayloadError> {
    719     if commitments.is_empty() {
    720         return Err(OrderPayloadError::MissingInventoryCommitments);
    721     }
    722     for (index, commitment) in commitments.iter().enumerate() {
    723         validate_required_field(commitment.bin_id.as_str(), "bin_id")?;
    724         if commitment.bin_count == 0 {
    725             return Err(OrderPayloadError::InvalidInventoryCommitmentCount { index });
    726         }
    727     }
    728     Ok(())
    729 }
    730 
    731 #[cfg(test)]
    732 #[cfg_attr(coverage_nightly, coverage(off))]
    733 mod tests {
    734     use super::*;
    735     use radroots_core::{Currency, Decimal, Money, Unit};
    736 
    737     fn pubkey(character: char) -> PublicKey {
    738         crate::test_valid_hex_64(character).parse().unwrap()
    739     }
    740 
    741     fn buyer_pubkey() -> PublicKey {
    742         pubkey('b')
    743     }
    744 
    745     fn seller_pubkey() -> PublicKey {
    746         pubkey('a')
    747     }
    748 
    749     fn sample_listing_addr() -> ClassifiedListingAddress {
    750         format!("30402:{}:AAAAAAAAAAAAAAAAAAAAAg", seller_pubkey())
    751             .parse()
    752             .unwrap()
    753     }
    754 
    755     fn order_id(raw: &str) -> OrderId {
    756         raw.parse().unwrap()
    757     }
    758 
    759     fn quote_id(raw: &str) -> OrderQuoteId {
    760         raw.parse().unwrap()
    761     }
    762 
    763     fn bin_id(raw: &str) -> InventoryBinId {
    764         raw.parse().unwrap()
    765     }
    766 
    767     fn sample_order_request() -> OrderRequest {
    768         OrderRequest {
    769             order_id: order_id("order-1"),
    770             listing_addr: sample_listing_addr(),
    771             buyer_pubkey: buyer_pubkey(),
    772             seller_pubkey: seller_pubkey(),
    773             items: vec![OrderItem {
    774                 bin_id: bin_id("bin-1"),
    775                 bin_count: 2,
    776             }],
    777             economics: sample_bound_order_economics(),
    778         }
    779     }
    780 
    781     fn decimal(raw: &str) -> Decimal {
    782         raw.parse().unwrap()
    783     }
    784 
    785     fn usd(raw: &str) -> Money {
    786         money(raw, Currency::USD)
    787     }
    788 
    789     fn money(raw: &str, currency: Currency) -> Money {
    790         Money::try_new(decimal(raw), currency).unwrap()
    791     }
    792 
    793     fn sample_order_economics() -> OrderEconomics {
    794         OrderEconomics {
    795             quote_id: quote_id("quote-1"),
    796             quote_version: 1,
    797             pricing_basis: OrderPricingBasis::ListingEvent,
    798             currency: Currency::USD,
    799             items: vec![
    800                 OrderEconomicItem {
    801                     bin_id: bin_id("bin-a"),
    802                     bin_count: 2,
    803                     quantity_amount: decimal("1.5"),
    804                     quantity_unit: Unit::Each,
    805                     unit_price_amount: decimal("4"),
    806                     unit_price_currency: Currency::USD,
    807                     line_subtotal: usd("12"),
    808                 },
    809                 OrderEconomicItem {
    810                     bin_id: bin_id("bin-b"),
    811                     bin_count: 1,
    812                     quantity_amount: decimal("2"),
    813                     quantity_unit: Unit::Each,
    814                     unit_price_amount: decimal("3"),
    815                     unit_price_currency: Currency::USD,
    816                     line_subtotal: usd("6"),
    817                 },
    818             ],
    819             discounts: vec![OrderEconomicLine {
    820                 id: "discount-a".into(),
    821                 kind: OrderEconomicLineKind::ListingDiscount,
    822                 actor: OrderEconomicActor::Seller,
    823                 effect: OrderEconomicEffect::Decrease,
    824                 amount: usd("3"),
    825                 reason: "farmstand pickup".into(),
    826             }],
    827             adjustments: vec![
    828                 OrderEconomicLine {
    829                     id: "adjustment-a".into(),
    830                     kind: OrderEconomicLineKind::BasketAdjustment,
    831                     actor: OrderEconomicActor::Buyer,
    832                     effect: OrderEconomicEffect::Increase,
    833                     amount: usd("2"),
    834                     reason: "special handling".into(),
    835                 },
    836                 OrderEconomicLine {
    837                     id: "adjustment-b".into(),
    838                     kind: OrderEconomicLineKind::BasketAdjustment,
    839                     actor: OrderEconomicActor::Buyer,
    840                     effect: OrderEconomicEffect::Decrease,
    841                     amount: usd("1"),
    842                     reason: "local pickup credit".into(),
    843                 },
    844             ],
    845             subtotal: usd("18"),
    846             discount_total: usd("3"),
    847             adjustment_total: usd("3"),
    848             total: usd("16"),
    849         }
    850     }
    851 
    852     fn sample_bound_order_economics() -> OrderEconomics {
    853         OrderEconomics {
    854             quote_id: quote_id("quote-bound-1"),
    855             quote_version: 1,
    856             pricing_basis: OrderPricingBasis::ListingEvent,
    857             currency: Currency::USD,
    858             items: vec![OrderEconomicItem {
    859                 bin_id: bin_id("bin-1"),
    860                 bin_count: 2,
    861                 quantity_amount: decimal("1"),
    862                 quantity_unit: Unit::Each,
    863                 unit_price_amount: decimal("5"),
    864                 unit_price_currency: Currency::USD,
    865                 line_subtotal: usd("10"),
    866             }],
    867             discounts: Vec::new(),
    868             adjustments: Vec::new(),
    869             subtotal: usd("10"),
    870             discount_total: usd("0"),
    871             adjustment_total: usd("0"),
    872             total: usd("10"),
    873         }
    874     }
    875 
    876     fn sample_inventory_commitment() -> OrderInventoryCommitment {
    877         OrderInventoryCommitment {
    878             bin_id: bin_id("bin-1"),
    879             bin_count: 2,
    880         }
    881     }
    882 
    883     fn sample_order_decision() -> OrderDecision {
    884         OrderDecision {
    885             order_id: order_id("order-1"),
    886             listing_addr: sample_listing_addr(),
    887             buyer_pubkey: buyer_pubkey(),
    888             seller_pubkey: seller_pubkey(),
    889             decision: OrderDecisionOutcome::Accepted {
    890                 inventory_commitments: vec![sample_inventory_commitment()],
    891             },
    892         }
    893     }
    894 
    895     fn sample_order_cancellation() -> OrderCancellation {
    896         OrderCancellation {
    897             order_id: order_id("order-1"),
    898             listing_addr: sample_listing_addr(),
    899             buyer_pubkey: buyer_pubkey(),
    900             seller_pubkey: seller_pubkey(),
    901             reason: "changed plans".into(),
    902         }
    903     }
    904 
    905     #[test]
    906     fn order_message_type_uses_canonical_names_and_kinds() {
    907         assert_eq!(
    908             OrderEventType::from_kind(KIND_ORDER_REQUEST),
    909             Some(OrderEventType::OrderRequested)
    910         );
    911         assert_eq!(
    912             OrderEventType::from_kind(KIND_ORDER_DECISION),
    913             Some(OrderEventType::OrderDecision)
    914         );
    915         assert_eq!(OrderEventType::from_kind(3424), None);
    916         assert_eq!(OrderEventType::from_kind(3425), None);
    917         assert_eq!(
    918             OrderEventType::from_kind(KIND_ORDER_CANCELLATION),
    919             Some(OrderEventType::OrderCancelled)
    920         );
    921         assert_eq!(OrderEventType::from_kind(3433), None);
    922         assert_eq!(OrderEventType::from_kind(3434), None);
    923         assert_eq!(OrderEventType::from_kind(3435), None);
    924         assert_eq!(OrderEventType::from_kind(3436), None);
    925         assert_eq!(OrderEventType::from_kind(3431), None);
    926         assert_eq!(OrderEventType::OrderRequested.kind(), KIND_ORDER_REQUEST);
    927         assert_eq!(OrderEventType::OrderDecision.kind(), KIND_ORDER_DECISION);
    928         assert_eq!(
    929             OrderEventType::OrderCancelled.kind(),
    930             KIND_ORDER_CANCELLATION
    931         );
    932         assert_eq!(OrderEventType::OrderRequested.name(), "TradeOrderRequested");
    933         assert_eq!(OrderEventType::OrderDecision.name(), "TradeOrderDecision");
    934         assert_eq!(OrderEventType::OrderCancelled.name(), "TradeOrderCancelled");
    935         assert!(OrderEventType::OrderRequested.requires_listing_snapshot());
    936         assert!(OrderEventType::OrderDecision.requires_order_chain());
    937         assert!(OrderEventType::OrderCancelled.requires_order_chain());
    938         assert!(!OrderEventType::OrderRequested.requires_order_chain());
    939 
    940         let request_name = serde_json::to_value(OrderEventType::OrderRequested).unwrap();
    941         let decision_name = serde_json::to_value(OrderEventType::OrderDecision).unwrap();
    942         let cancellation_name = serde_json::to_value(OrderEventType::OrderCancelled).unwrap();
    943         assert_eq!(request_name, serde_json::json!("TradeOrderRequested"));
    944         assert_eq!(decision_name, serde_json::json!("TradeOrderDecision"));
    945         assert_eq!(cancellation_name, serde_json::json!("TradeOrderCancelled"));
    946     }
    947 
    948     #[test]
    949     fn order_request_validation_rejects_invalid_fields() {
    950         assert_eq!(sample_order_request().validate(), Ok(()));
    951 
    952         let mut missing_items = sample_order_request();
    953         missing_items.items.clear();
    954         assert_eq!(
    955             missing_items.validate().unwrap_err(),
    956             OrderPayloadError::MissingItems
    957         );
    958 
    959         let mut invalid_count = sample_order_request();
    960         invalid_count.items[0].bin_count = 0;
    961         assert_eq!(
    962             invalid_count.validate().unwrap_err(),
    963             OrderPayloadError::InvalidItemBinCount { index: 0 }
    964         );
    965 
    966         let mut mismatched_economic_item = sample_order_request();
    967         mismatched_economic_item.economics.items[0].bin_id = bin_id("bin-other");
    968         assert_eq!(
    969             mismatched_economic_item.validate().unwrap_err(),
    970             OrderPayloadError::InvalidOrderEconomicsBinding {
    971                 field: "items.bin_id"
    972             }
    973         );
    974 
    975         let mut mismatched_economic_count = sample_order_request();
    976         mismatched_economic_count.economics.items[0].bin_count = 3;
    977         mismatched_economic_count.economics.items[0].line_subtotal = usd("15");
    978         mismatched_economic_count.economics.subtotal = usd("15");
    979         mismatched_economic_count.economics.total = usd("15");
    980         assert_eq!(
    981             mismatched_economic_count.validate().unwrap_err(),
    982             OrderPayloadError::InvalidOrderEconomicsBinding {
    983                 field: "items.bin_count"
    984             }
    985         );
    986     }
    987 
    988     #[test]
    989     fn order_payload_json_rejects_invalid_protocol_identifiers() {
    990         let mut request = serde_json::to_value(sample_order_request()).unwrap();
    991         request["buyer_pubkey"] = serde_json::json!("not-a-pubkey");
    992         assert!(serde_json::from_value::<OrderRequest>(request).is_err());
    993     }
    994 
    995     #[test]
    996     fn listing_parse_error_json_preserves_external_tagged_shape() {
    997         assert_eq!(
    998             serde_json::to_value(OperationalListingParseError::InvalidKind(KIND_PROFILE)).unwrap(),
    999             serde_json::json!({ "InvalidKind": KIND_PROFILE })
   1000         );
   1001         assert_eq!(
   1002             serde_json::to_value(OperationalListingParseError::MissingTag("price".into())).unwrap(),
   1003             serde_json::json!({ "MissingTag": "price" })
   1004         );
   1005         assert_eq!(
   1006             serde_json::to_value(OperationalListingParseError::InvalidUnit).unwrap(),
   1007             serde_json::json!("InvalidUnit")
   1008         );
   1009         assert_eq!(
   1010             serde_json::from_value::<OperationalListingParseError>(serde_json::json!({
   1011                 "InvalidJson": "bins"
   1012             }))
   1013             .unwrap(),
   1014             OperationalListingParseError::InvalidJson("bins".into())
   1015         );
   1016     }
   1017 
   1018     #[test]
   1019     fn order_economics_validation_accepts_canonical_totals() {
   1020         let economics = sample_order_economics();
   1021         assert_eq!(economics.validate(), Ok(()));
   1022 
   1023         let totals = economics.derived_totals().unwrap();
   1024         assert_eq!(totals.subtotal, usd("18"));
   1025         assert_eq!(totals.discount_total, usd("3"));
   1026         assert_eq!(totals.adjustment_total, usd("3"));
   1027         assert_eq!(totals.total, usd("16"));
   1028 
   1029         let json = serde_json::to_value(&economics).unwrap();
   1030         assert_eq!(json["pricing_basis"], serde_json::json!("listing_event"));
   1031         assert_eq!(
   1032             json["discounts"][0]["kind"],
   1033             serde_json::json!("listing_discount")
   1034         );
   1035         assert_eq!(
   1036             json["adjustments"][0]["effect"],
   1037             serde_json::json!("increase")
   1038         );
   1039     }
   1040 
   1041     #[test]
   1042     fn order_economics_canonicalized_sorts_items_and_lines() {
   1043         let mut economics = sample_order_economics();
   1044         economics.items.reverse();
   1045         economics.adjustments.reverse();
   1046         economics.discounts.push(OrderEconomicLine {
   1047             id: "discount-b".into(),
   1048             kind: OrderEconomicLineKind::ListingDiscount,
   1049             actor: OrderEconomicActor::Seller,
   1050             effect: OrderEconomicEffect::Decrease,
   1051             amount: usd("1"),
   1052             reason: "market credit".into(),
   1053         });
   1054         economics.discounts.reverse();
   1055         economics.subtotal = usd("19");
   1056         economics.total = usd("17");
   1057         assert_eq!(
   1058             economics.validate().unwrap_err(),
   1059             OrderPayloadError::InvalidEconomicOrdering {
   1060                 field: "items.bin_id"
   1061             }
   1062         );
   1063 
   1064         let canonical = economics.canonicalized();
   1065         assert_eq!(canonical.items[0].bin_id.as_str(), "bin-a");
   1066         assert_eq!(canonical.discounts[0].id, "discount-a");
   1067         assert_eq!(canonical.adjustments[0].id, "adjustment-a");
   1068         assert_eq!(canonical.subtotal, usd("18"));
   1069         assert_eq!(canonical.discount_total, usd("4"));
   1070         assert_eq!(canonical.total, usd("15"));
   1071         assert_eq!(canonical.validate(), Ok(()));
   1072 
   1073         let mut uncanonicalizable = sample_order_economics();
   1074         uncanonicalizable.items.clear();
   1075         uncanonicalizable.subtotal = usd("88");
   1076         uncanonicalizable.canonicalize();
   1077         assert_eq!(uncanonicalizable.subtotal, usd("88"));
   1078     }
   1079 
   1080     #[test]
   1081     fn order_economics_validation_rejects_mixed_currency() {
   1082         let mut economics = sample_order_economics();
   1083         economics.items[0].unit_price_currency = Currency::EUR;
   1084         assert_eq!(
   1085             economics.validate().unwrap_err(),
   1086             OrderPayloadError::InvalidEconomicCurrency {
   1087                 field: "items.unit_price_currency"
   1088             }
   1089         );
   1090 
   1091         let mut economics = sample_order_economics();
   1092         economics.adjustments[0].amount = money("2", Currency::EUR);
   1093         assert_eq!(
   1094             economics.validate().unwrap_err(),
   1095             OrderPayloadError::InvalidEconomicCurrency {
   1096                 field: "adjustments"
   1097             }
   1098         );
   1099     }
   1100 
   1101     #[test]
   1102     fn order_economics_validation_rejects_bad_subtotal() {
   1103         let mut economics = sample_order_economics();
   1104         economics.items[0].bin_count = 0;
   1105         assert_eq!(
   1106             economics.validate().unwrap_err(),
   1107             OrderPayloadError::InvalidEconomicItemBinCount { index: 0 }
   1108         );
   1109 
   1110         let mut economics = sample_order_economics();
   1111         economics.items[0].line_subtotal = usd("11.99");
   1112         assert_eq!(
   1113             economics.validate().unwrap_err(),
   1114             OrderPayloadError::InvalidEconomicItemSubtotal { index: 0 }
   1115         );
   1116 
   1117         let mut economics = sample_order_economics();
   1118         economics.items[0].line_subtotal = money("12", Currency::EUR);
   1119         assert_eq!(
   1120             economics.validate().unwrap_err(),
   1121             OrderPayloadError::InvalidEconomicCurrency {
   1122                 field: "items.line_subtotal"
   1123             }
   1124         );
   1125     }
   1126 
   1127     #[test]
   1128     fn order_economics_validation_covers_remaining_error_paths() {
   1129         let mut economics = sample_order_economics();
   1130         economics.items.clear();
   1131         assert_eq!(
   1132             economics.derived_totals().unwrap_err(),
   1133             OrderPayloadError::MissingEconomicItems
   1134         );
   1135 
   1136         let mut economics = sample_order_economics();
   1137         economics.quote_version = 0;
   1138         assert_eq!(
   1139             economics.validate().unwrap_err(),
   1140             OrderPayloadError::InvalidQuoteVersion
   1141         );
   1142 
   1143         let mut economics = sample_order_economics();
   1144         economics.items[0].quantity_amount = decimal("0");
   1145         assert_eq!(
   1146             economics.validate().unwrap_err(),
   1147             OrderPayloadError::InvalidEconomicItemQuantity { index: 0 }
   1148         );
   1149 
   1150         let mut economics = sample_order_economics();
   1151         economics.items[0].quantity_amount = decimal("-1");
   1152         assert_eq!(
   1153             economics.validate().unwrap_err(),
   1154             OrderPayloadError::InvalidEconomicItemQuantity { index: 0 }
   1155         );
   1156 
   1157         let mut economics = sample_order_economics();
   1158         economics.items[0].unit_price_amount = decimal("-1");
   1159         assert_eq!(
   1160             economics.validate().unwrap_err(),
   1161             OrderPayloadError::InvalidEconomicItemPrice { index: 0 }
   1162         );
   1163 
   1164         let mut economics = sample_order_economics();
   1165         economics.discounts[0].kind = OrderEconomicLineKind::BasketAdjustment;
   1166         assert_eq!(
   1167             economics.validate().unwrap_err(),
   1168             OrderPayloadError::InvalidEconomicLineKind {
   1169                 field: "discounts",
   1170                 index: 0
   1171             }
   1172         );
   1173 
   1174         let mut economics = sample_order_economics();
   1175         economics.subtotal = money("18", Currency::EUR);
   1176         assert_eq!(
   1177             economics.validate().unwrap_err(),
   1178             OrderPayloadError::InvalidEconomicCurrency { field: "subtotal" }
   1179         );
   1180 
   1181         let mut economics = sample_order_economics();
   1182         economics.discount_total = usd("4");
   1183         assert_eq!(
   1184             economics.validate().unwrap_err(),
   1185             OrderPayloadError::InvalidEconomicTotal {
   1186                 field: "discount_total"
   1187             }
   1188         );
   1189 
   1190         let mut economics = sample_order_economics();
   1191         economics.adjustment_total = usd("4");
   1192         assert_eq!(
   1193             economics.validate().unwrap_err(),
   1194             OrderPayloadError::InvalidEconomicTotal {
   1195                 field: "adjustment_total"
   1196             }
   1197         );
   1198 
   1199         let economics = sample_bound_order_economics();
   1200         assert_eq!(
   1201             validate_order_economics_binding(&[], &economics).unwrap_err(),
   1202             OrderPayloadError::InvalidOrderEconomicsBinding { field: "items" }
   1203         );
   1204 
   1205         let invalid_order_items = [OrderItem {
   1206             bin_id: bin_id("bin-1"),
   1207             bin_count: 0,
   1208         }];
   1209         assert_eq!(
   1210             validate_order_economics_binding(&invalid_order_items, &economics).unwrap_err(),
   1211             OrderPayloadError::InvalidOrderEconomicsBinding {
   1212                 field: "items.bin_count"
   1213             }
   1214         );
   1215 
   1216         let duplicate_counts = normalized_order_item_counts(&[
   1217             OrderItem {
   1218                 bin_id: bin_id("bin-1"),
   1219                 bin_count: 1,
   1220             },
   1221             OrderItem {
   1222                 bin_id: bin_id("bin-1"),
   1223                 bin_count: 2,
   1224             },
   1225         ])
   1226         .unwrap();
   1227         assert_eq!(duplicate_counts[0].bin_count, 3);
   1228 
   1229         assert!(
   1230             normalized_order_item_counts(&[OrderItem {
   1231                 bin_id: bin_id("bin-1"),
   1232                 bin_count: 0,
   1233             }])
   1234             .is_none()
   1235         );
   1236         let sorted_counts = normalized_order_item_counts(&[
   1237             OrderItem {
   1238                 bin_id: bin_id("bin-b"),
   1239                 bin_count: 1,
   1240             },
   1241             OrderItem {
   1242                 bin_id: bin_id("bin-a"),
   1243                 bin_count: 1,
   1244             },
   1245         ])
   1246         .unwrap();
   1247         assert_eq!(sorted_counts[0].bin_id, "bin-a");
   1248     }
   1249 
   1250     #[test]
   1251     fn order_economics_validation_rejects_bad_line_semantics() {
   1252         let mut economics = sample_order_economics();
   1253         economics.discounts[0].effect = OrderEconomicEffect::Increase;
   1254         assert_eq!(
   1255             economics.validate().unwrap_err(),
   1256             OrderPayloadError::InvalidEconomicLineEffect {
   1257                 field: "discounts",
   1258                 index: 0
   1259             }
   1260         );
   1261 
   1262         let mut economics = sample_order_economics();
   1263         economics.adjustments[0].kind = OrderEconomicLineKind::ListingDiscount;
   1264         assert_eq!(
   1265             economics.validate().unwrap_err(),
   1266             OrderPayloadError::InvalidEconomicLineKind {
   1267                 field: "adjustments",
   1268                 index: 0
   1269             }
   1270         );
   1271 
   1272         let mut economics = sample_order_economics();
   1273         economics.adjustments[0].amount = usd("0");
   1274         assert_eq!(
   1275             economics.validate().unwrap_err(),
   1276             OrderPayloadError::InvalidEconomicLineAmount {
   1277                 field: "adjustments",
   1278                 index: 0
   1279             }
   1280         );
   1281     }
   1282 
   1283     #[test]
   1284     fn order_economics_helpers_cover_currency_error_paths() {
   1285         assert_eq!(
   1286             validate_total_matches(&usd("1"), &money("1", Currency::EUR), "total").unwrap_err(),
   1287             OrderPayloadError::InvalidEconomicCurrency { field: "total" }
   1288         );
   1289         assert_eq!(
   1290             checked_money_add(&usd("1"), &money("1", Currency::EUR), "subtotal").unwrap_err(),
   1291             OrderPayloadError::InvalidEconomicCurrency { field: "subtotal" }
   1292         );
   1293         assert_eq!(
   1294             checked_money_sub_non_negative(&usd("1"), &money("1", Currency::EUR), "total")
   1295                 .unwrap_err(),
   1296             OrderPayloadError::InvalidEconomicCurrency { field: "total" }
   1297         );
   1298     }
   1299 
   1300     #[test]
   1301     fn order_economics_validation_rejects_duplicate_line_ids() {
   1302         let mut economics = sample_order_economics();
   1303         economics.adjustments[1].id = "adjustment-a".into();
   1304         assert_eq!(
   1305             economics.validate().unwrap_err(),
   1306             OrderPayloadError::InvalidEconomicOrdering {
   1307                 field: "adjustments"
   1308             }
   1309         );
   1310     }
   1311 
   1312     #[test]
   1313     fn order_economics_validation_rejects_negative_derived_total() {
   1314         let mut economics = sample_order_economics();
   1315         economics.adjustments[1].amount = usd("20");
   1316         economics.adjustment_total = usd("22");
   1317         economics.total = usd("0");
   1318         assert_eq!(
   1319             economics.validate().unwrap_err(),
   1320             OrderPayloadError::InvalidEconomicTotal { field: "total" }
   1321         );
   1322     }
   1323 
   1324     #[test]
   1325     fn order_decision_validation_enforces_commitment_invariants() {
   1326         assert_eq!(sample_order_decision().validate(), Ok(()));
   1327 
   1328         let declined = OrderDecision {
   1329             decision: OrderDecisionOutcome::Declined {
   1330                 reason: "out_of_stock".into(),
   1331             },
   1332             ..sample_order_decision()
   1333         };
   1334         assert_eq!(declined.validate(), Ok(()));
   1335 
   1336         let accepted_without_commitments = OrderDecision {
   1337             decision: OrderDecisionOutcome::Accepted {
   1338                 inventory_commitments: Vec::new(),
   1339             },
   1340             ..sample_order_decision()
   1341         };
   1342         assert_eq!(
   1343             accepted_without_commitments.validate().unwrap_err(),
   1344             OrderPayloadError::MissingInventoryCommitments
   1345         );
   1346 
   1347         let accepted_with_zero_count = OrderDecision {
   1348             decision: OrderDecisionOutcome::Accepted {
   1349                 inventory_commitments: vec![OrderInventoryCommitment {
   1350                     bin_id: bin_id("bin-1"),
   1351                     bin_count: 0,
   1352                 }],
   1353             },
   1354             ..sample_order_decision()
   1355         };
   1356         assert_eq!(
   1357             accepted_with_zero_count.validate().unwrap_err(),
   1358             OrderPayloadError::InvalidInventoryCommitmentCount { index: 0 }
   1359         );
   1360 
   1361         let declined_without_reason = OrderDecision {
   1362             decision: OrderDecisionOutcome::Declined { reason: " ".into() },
   1363             ..sample_order_decision()
   1364         };
   1365         assert_eq!(
   1366             declined_without_reason.validate().unwrap_err(),
   1367             OrderPayloadError::EmptyField("reason")
   1368         );
   1369     }
   1370 
   1371     #[test]
   1372     fn order_cancellation_validation_requires_buyer_bindings_and_reason() {
   1373         assert_eq!(sample_order_cancellation().validate(), Ok(()));
   1374 
   1375         let missing_reason = OrderCancellation {
   1376             reason: " ".into(),
   1377             ..sample_order_cancellation()
   1378         };
   1379         assert_eq!(
   1380             missing_reason.validate().unwrap_err(),
   1381             OrderPayloadError::EmptyField("reason")
   1382         );
   1383     }
   1384 
   1385     #[test]
   1386     fn order_envelope_serializes_canonical_type_name() {
   1387         let envelope = OrderEnvelope::new(
   1388             OrderEventType::OrderRequested,
   1389             sample_listing_addr(),
   1390             "order-1",
   1391             sample_order_request(),
   1392         );
   1393         assert_eq!(envelope.validate(), Ok(()));
   1394 
   1395         let json = serde_json::to_value(&envelope).unwrap();
   1396         assert_eq!(json["type"], serde_json::json!("TradeOrderRequested"));
   1397         assert_eq!(json["order_id"], serde_json::json!("order-1"));
   1398         assert_eq!(
   1399             json["listing_addr"],
   1400             serde_json::json!(sample_listing_addr().as_str())
   1401         );
   1402         assert_eq!(json["payload"]["items"][0]["bin_id"], "bin-1");
   1403     }
   1404 
   1405     #[test]
   1406     fn order_envelope_validation_and_display_cover_error_paths() {
   1407         let invalid_version = OrderEnvelope {
   1408             version: RADROOTS_ORDER_ENVELOPE_VERSION + 1,
   1409             domain: CommercialDomain::Listing,
   1410             message_type: OrderEventType::OrderRequested,
   1411             order_id: "order-1".into(),
   1412             listing_addr: sample_listing_addr().into_string(),
   1413             payload: sample_order_request(),
   1414         };
   1415         let invalid_version_err = invalid_version.validate().unwrap_err();
   1416         assert_eq!(
   1417             invalid_version_err,
   1418             OrderEnvelopeError::InvalidVersion {
   1419                 expected: RADROOTS_ORDER_ENVELOPE_VERSION,
   1420                 got: RADROOTS_ORDER_ENVELOPE_VERSION + 1,
   1421             }
   1422         );
   1423         assert_eq!(
   1424             invalid_version_err.to_string(),
   1425             "invalid order envelope version: expected 1, got 2"
   1426         );
   1427 
   1428         let missing_order = OrderEnvelope::new(
   1429             OrderEventType::OrderRequested,
   1430             sample_listing_addr(),
   1431             " ",
   1432             sample_order_request(),
   1433         );
   1434         let missing_order_err = missing_order.validate().unwrap_err();
   1435         assert_eq!(missing_order_err, OrderEnvelopeError::MissingOrderId);
   1436         assert_eq!(
   1437             missing_order_err.to_string(),
   1438             "missing order_id for order message"
   1439         );
   1440 
   1441         let missing_listing = OrderEnvelope::new(
   1442             OrderEventType::OrderRequested,
   1443             " ",
   1444             "order-1",
   1445             sample_order_request(),
   1446         );
   1447         let missing_listing_err = missing_listing.validate().unwrap_err();
   1448         assert_eq!(missing_listing_err, OrderEnvelopeError::MissingListingAddr);
   1449         assert_eq!(missing_listing_err.to_string(), "missing listing_addr");
   1450     }
   1451 
   1452     #[test]
   1453     fn listing_parse_error_display_variants() {
   1454         assert_eq!(
   1455             OperationalListingParseError::InvalidKind(KIND_PROFILE).to_string(),
   1456             "invalid operational listing kind: 0"
   1457         );
   1458         assert_eq!(
   1459             OperationalListingParseError::MissingTag("price".into()).to_string(),
   1460             "missing required tag: price"
   1461         );
   1462         assert_eq!(
   1463             OperationalListingParseError::InvalidTag("farm".into()).to_string(),
   1464             "invalid tag: farm"
   1465         );
   1466         assert_eq!(
   1467             OperationalListingParseError::InvalidNumber("inventory".into()).to_string(),
   1468             "invalid number: inventory"
   1469         );
   1470         assert_eq!(
   1471             OperationalListingParseError::InvalidUnit.to_string(),
   1472             "invalid unit"
   1473         );
   1474         assert_eq!(
   1475             OperationalListingParseError::InvalidCurrency.to_string(),
   1476             "invalid currency"
   1477         );
   1478         assert_eq!(
   1479             OperationalListingParseError::InvalidJson("bins".into()).to_string(),
   1480             "invalid json: bins"
   1481         );
   1482         assert_eq!(
   1483             OperationalListingParseError::InvalidDiscount("offer".into()).to_string(),
   1484             "invalid discount data for offer"
   1485         );
   1486     }
   1487 
   1488     #[test]
   1489     fn listing_validation_error_display_variants() {
   1490         assert_eq!(
   1491             (OperationalListingValidationError::InvalidKind { kind: KIND_PROFILE }).to_string(),
   1492             "invalid listing kind: 0"
   1493         );
   1494         assert_eq!(
   1495             OperationalListingValidationError::InvalidProfile.to_string(),
   1496             "classified listing is not an Operational Listing profile"
   1497         );
   1498         assert_eq!(
   1499             OperationalListingValidationError::MissingListingId.to_string(),
   1500             "missing listing id"
   1501         );
   1502         assert_eq!(
   1503             OperationalListingValidationError::ListingEventNotFound {
   1504                 listing_addr: "listing-1".into(),
   1505             }
   1506             .to_string(),
   1507             "listing event not found: listing-1"
   1508         );
   1509         assert_eq!(
   1510             OperationalListingValidationError::ListingEventFetchFailed {
   1511                 listing_addr: "listing-2".into(),
   1512             }
   1513             .to_string(),
   1514             "listing event fetch failed: listing-2"
   1515         );
   1516         assert_eq!(
   1517             OperationalListingValidationError::ParseError {
   1518                 error: OperationalListingParseError::InvalidJson("payload".into()),
   1519             }
   1520             .to_string(),
   1521             "invalid listing data: invalid json: payload"
   1522         );
   1523         assert_eq!(
   1524             OperationalListingValidationError::InvalidSeller.to_string(),
   1525             "listing author does not match farm pubkey"
   1526         );
   1527         assert_eq!(
   1528             OperationalListingValidationError::MissingFarmProfile.to_string(),
   1529             "missing farm profile"
   1530         );
   1531         assert_eq!(
   1532             OperationalListingValidationError::MissingFarmRecord.to_string(),
   1533             "missing farm record"
   1534         );
   1535         assert_eq!(
   1536             OperationalListingValidationError::MissingTitle.to_string(),
   1537             "missing listing title"
   1538         );
   1539         assert_eq!(
   1540             OperationalListingValidationError::MissingDescription.to_string(),
   1541             "missing listing description"
   1542         );
   1543         assert_eq!(
   1544             OperationalListingValidationError::MissingProductType.to_string(),
   1545             "missing listing product type"
   1546         );
   1547         assert_eq!(
   1548             OperationalListingValidationError::MissingBins.to_string(),
   1549             "missing listing bins"
   1550         );
   1551         assert_eq!(
   1552             OperationalListingValidationError::MissingPrimaryBin.to_string(),
   1553             "missing primary listing bin"
   1554         );
   1555         assert_eq!(
   1556             OperationalListingValidationError::InvalidBin.to_string(),
   1557             "invalid listing bin"
   1558         );
   1559         assert_eq!(
   1560             OperationalListingValidationError::MissingPrice.to_string(),
   1561             "missing listing price"
   1562         );
   1563         assert_eq!(
   1564             OperationalListingValidationError::InvalidPrice.to_string(),
   1565             "invalid listing price"
   1566         );
   1567         assert_eq!(
   1568             OperationalListingValidationError::MissingInventory.to_string(),
   1569             "missing listing inventory"
   1570         );
   1571         assert_eq!(
   1572             OperationalListingValidationError::InvalidInventory.to_string(),
   1573             "invalid listing inventory"
   1574         );
   1575         assert_eq!(
   1576             OperationalListingValidationError::MissingAvailability.to_string(),
   1577             "missing listing availability"
   1578         );
   1579         assert_eq!(
   1580             OperationalListingValidationError::MissingLocation.to_string(),
   1581             "missing listing location"
   1582         );
   1583         assert_eq!(
   1584             OperationalListingValidationError::MissingDeliveryMethod.to_string(),
   1585             "missing listing delivery method"
   1586         );
   1587     }
   1588 
   1589     #[test]
   1590     fn order_payload_error_display_variants_cover_all_messages() {
   1591         let cases = [
   1592             (
   1593                 OrderPayloadError::EmptyField("field"),
   1594                 "field cannot be empty",
   1595             ),
   1596             (
   1597                 OrderPayloadError::MissingItems,
   1598                 "items must contain at least one item",
   1599             ),
   1600             (
   1601                 OrderPayloadError::InvalidItemBinCount { index: 2 },
   1602                 "items[2].bin_count must be greater than zero",
   1603             ),
   1604             (
   1605                 OrderPayloadError::MissingEconomicItems,
   1606                 "economics.items must contain at least one item",
   1607             ),
   1608             (
   1609                 OrderPayloadError::InvalidEconomicItemBinCount { index: 3 },
   1610                 "economics.items[3].bin_count must be greater than zero",
   1611             ),
   1612             (
   1613                 OrderPayloadError::InvalidEconomicItemQuantity { index: 4 },
   1614                 "economics.items[4].quantity_amount must be greater than zero",
   1615             ),
   1616             (
   1617                 OrderPayloadError::InvalidEconomicItemPrice { index: 5 },
   1618                 "economics.items[5].unit_price_amount must not be negative",
   1619             ),
   1620             (
   1621                 OrderPayloadError::InvalidEconomicItemSubtotal { index: 6 },
   1622                 "economics.items[6].line_subtotal is invalid",
   1623             ),
   1624             (
   1625                 OrderPayloadError::InvalidEconomicLineAmount {
   1626                     field: "adjustments",
   1627                     index: 7,
   1628                 },
   1629                 "economics.adjustments[7].amount must be greater than zero",
   1630             ),
   1631             (
   1632                 OrderPayloadError::InvalidEconomicLineKind {
   1633                     field: "discounts",
   1634                     index: 8,
   1635                 },
   1636                 "economics.discounts[8].kind is invalid",
   1637             ),
   1638             (
   1639                 OrderPayloadError::InvalidEconomicLineEffect {
   1640                     field: "discounts",
   1641                     index: 9,
   1642                 },
   1643                 "economics.discounts[9].effect is invalid",
   1644             ),
   1645             (
   1646                 OrderPayloadError::InvalidEconomicCurrency { field: "total" },
   1647                 "economics.total currency is invalid",
   1648             ),
   1649             (
   1650                 OrderPayloadError::InvalidEconomicOrdering { field: "items" },
   1651                 "economics.items is not in canonical order",
   1652             ),
   1653             (
   1654                 OrderPayloadError::InvalidEconomicTotal { field: "subtotal" },
   1655                 "economics.subtotal total is invalid",
   1656             ),
   1657             (
   1658                 OrderPayloadError::InvalidOrderEconomicsBinding { field: "items" },
   1659                 "order items does not match economics",
   1660             ),
   1661             (
   1662                 OrderPayloadError::InvalidQuoteVersion,
   1663                 "economics.quote_version must be greater than zero",
   1664             ),
   1665             (
   1666                 OrderPayloadError::MissingInventoryCommitments,
   1667                 "accepted decisions must contain at least one inventory commitment",
   1668             ),
   1669             (
   1670                 OrderPayloadError::InvalidInventoryCommitmentCount { index: 1 },
   1671                 "inventory_commitments[1].bin_count must be greater than zero",
   1672             ),
   1673         ];
   1674 
   1675         for (error, expected) in cases {
   1676             assert_eq!(error.to_string(), expected);
   1677         }
   1678     }
   1679 }