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