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 }