order_work.rs (30744B)
1 use serde_json::Value; 2 3 use crate::LocalEventsError; 4 use crate::models::validate_non_empty; 5 6 pub const BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND: &str = "buyer_order_request_v1"; 7 pub const BUYER_ORDER_REQUEST_DOCUMENT_KIND: &str = "order_draft_v1"; 8 pub const BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; 9 pub const BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP: &str = "app_unresolved"; 10 11 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 12 pub enum BuyerOrderRequestSupportState { 13 Supported, 14 Unsupported, 15 } 16 17 impl BuyerOrderRequestSupportState { 18 pub fn as_str(self) -> &'static str { 19 match self { 20 Self::Supported => "supported", 21 Self::Unsupported => "unsupported", 22 } 23 } 24 } 25 26 #[derive(Clone, Debug, Eq, PartialEq)] 27 pub struct BuyerOrderRequestLocalWorkValidation { 28 pub order_id: String, 29 pub support_state: BuyerOrderRequestSupportState, 30 pub support_issues: Vec<String>, 31 } 32 33 pub fn buyer_order_request_local_work_record_id( 34 order_id: &str, 35 ) -> Result<String, LocalEventsError> { 36 let order_id = order_id.trim(); 37 validate_non_empty("order_id", order_id)?; 38 Ok(format!("app:local_work:order_request:{order_id}")) 39 } 40 41 pub fn validate_buyer_order_request_local_work_payload( 42 payload: &Value, 43 ) -> Result<BuyerOrderRequestLocalWorkValidation, LocalEventsError> { 44 validate_string_field( 45 payload, 46 &["record_kind"], 47 BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, 48 )?; 49 validate_string_field(payload, &["scope"], "app")?; 50 validate_string_field( 51 payload, 52 &["document", "kind"], 53 BUYER_ORDER_REQUEST_DOCUMENT_KIND, 54 )?; 55 validate_bool_field(payload, &["currentness", "current"], true)?; 56 validate_string_field(payload, &["currentness", "source"], "app_sqlite_order")?; 57 58 let order_id = validate_required_string(payload, &["document", "order", "order_id"])?; 59 let currentness_order_id = validate_required_string(payload, &["currentness", "order_id"])?; 60 if currentness_order_id != order_id { 61 return Err(invalid_field( 62 "currentness.order_id", 63 "must match document.order.order_id", 64 )); 65 } 66 validate_required_string(payload, &["currentness", "record_id"])?; 67 validate_positive_i64(payload, &["currentness", "created_at_ms"])?; 68 validate_required_string(payload, &["currentness", "order_updated_at"])?; 69 70 let (support_state, support_issues) = validate_support_status(payload)?; 71 validate_exportability(payload, support_state)?; 72 validate_order_identity(payload, support_state)?; 73 validate_order_items(payload)?; 74 validate_order_economics(payload)?; 75 76 Ok(BuyerOrderRequestLocalWorkValidation { 77 order_id: order_id.to_owned(), 78 support_state, 79 support_issues, 80 }) 81 } 82 83 pub fn validate_supported_buyer_order_request_local_work_payload( 84 payload: &Value, 85 ) -> Result<BuyerOrderRequestLocalWorkValidation, LocalEventsError> { 86 let validation = validate_buyer_order_request_local_work_payload(payload)?; 87 if validation.support_state != BuyerOrderRequestSupportState::Supported { 88 return Err(invalid_field( 89 "support_status.state", 90 "must be supported for exportable app order work", 91 )); 92 } 93 Ok(validation) 94 } 95 96 pub fn validate_unsupported_buyer_order_request_local_work_payload( 97 payload: &Value, 98 ) -> Result<BuyerOrderRequestLocalWorkValidation, LocalEventsError> { 99 let validation = validate_buyer_order_request_local_work_payload(payload)?; 100 if validation.support_state != BuyerOrderRequestSupportState::Unsupported { 101 return Err(invalid_field( 102 "support_status.state", 103 "must be unsupported for unsupported app order work", 104 )); 105 } 106 Ok(validation) 107 } 108 109 fn validate_support_status( 110 payload: &Value, 111 ) -> Result<(BuyerOrderRequestSupportState, Vec<String>), LocalEventsError> { 112 let state = validate_required_string(payload, &["support_status", "state"])?; 113 let issues = support_issues(payload)?; 114 match state { 115 "supported" => { 116 if !issues.is_empty() { 117 return Err(invalid_field( 118 "support_status.issues", 119 "must be empty when support_status.state is supported", 120 )); 121 } 122 Ok((BuyerOrderRequestSupportState::Supported, issues)) 123 } 124 "unsupported" => { 125 if issues.is_empty() { 126 return Err(invalid_field( 127 "support_status.issues", 128 "must contain at least one issue when support_status.state is unsupported", 129 )); 130 } 131 Ok((BuyerOrderRequestSupportState::Unsupported, issues)) 132 } 133 _ => Err(invalid_field( 134 "support_status.state", 135 "must be supported or unsupported", 136 )), 137 } 138 } 139 140 fn validate_exportability( 141 payload: &Value, 142 support_state: BuyerOrderRequestSupportState, 143 ) -> Result<(), LocalEventsError> { 144 let state = validate_required_string(payload, &["exportability", "state"])?; 145 match state { 146 "exportable" => { 147 validate_string_field( 148 payload, 149 &["document", "buyer_actor", "source"], 150 BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT, 151 )?; 152 validate_buyer_pubkey(payload)?; 153 } 154 "identity_unresolved" => { 155 validate_required_string(payload, &["exportability", "reason"])?; 156 validate_string_field( 157 payload, 158 &["document", "buyer_actor", "source"], 159 BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP, 160 )?; 161 if support_state == BuyerOrderRequestSupportState::Supported { 162 return Err(invalid_field( 163 "exportability.state", 164 "supported app order work must be exportable", 165 )); 166 } 167 } 168 _ => { 169 return Err(invalid_field( 170 "exportability.state", 171 "must be exportable or identity_unresolved", 172 )); 173 } 174 } 175 Ok(()) 176 } 177 178 fn validate_order_identity( 179 payload: &Value, 180 support_state: BuyerOrderRequestSupportState, 181 ) -> Result<(), LocalEventsError> { 182 validate_required_string(payload, &["document", "order", "listing_addr"])?; 183 validate_required_string(payload, &["document", "order", "listing_event_id"])?; 184 validate_required_string(payload, &["document", "order", "seller_pubkey"])?; 185 if support_state == BuyerOrderRequestSupportState::Supported { 186 validate_buyer_pubkey(payload)?; 187 } 188 Ok(()) 189 } 190 191 fn validate_buyer_pubkey(payload: &Value) -> Result<(), LocalEventsError> { 192 let order_buyer_pubkey = 193 validate_required_string(payload, &["document", "order", "buyer_pubkey"])?; 194 let actor_buyer_pubkey = 195 validate_required_string(payload, &["document", "buyer_actor", "pubkey"])?; 196 if order_buyer_pubkey != actor_buyer_pubkey { 197 return Err(invalid_field( 198 "document.buyer_actor.pubkey", 199 "must match document.order.buyer_pubkey", 200 )); 201 } 202 Ok(()) 203 } 204 205 fn validate_order_items(payload: &Value) -> Result<(), LocalEventsError> { 206 let items = required_array(payload, &["document", "order", "items"])?; 207 if items.is_empty() { 208 return Err(invalid_field( 209 "document.order.items", 210 "must contain at least one item", 211 )); 212 } 213 for (index, item) in items.iter().enumerate() { 214 validate_required_string(item, &["bin_id"]).map_err(|_| { 215 invalid_field_at( 216 format!("document.order.items[{index}].bin_id"), 217 "is required", 218 ) 219 })?; 220 validate_positive_u64(item, &["bin_count"]).map_err(|_| { 221 invalid_field_at( 222 format!("document.order.items[{index}].bin_count"), 223 "must be positive", 224 ) 225 })?; 226 } 227 Ok(()) 228 } 229 230 fn validate_order_economics(payload: &Value) -> Result<(), LocalEventsError> { 231 let economics = value_at(payload, &["document", "order", "economics"]).ok_or_else(|| { 232 invalid_field("document.order.economics", "is required for app order work") 233 })?; 234 if !economics.is_object() { 235 return Err(invalid_field( 236 "document.order.economics", 237 "must be an object", 238 )); 239 } 240 validate_string_field(economics, &["pricing_basis"], "listing_event")?; 241 let currency = validate_required_string(economics, &["currency"])?; 242 validate_currency("document.order.economics.currency", currency)?; 243 let economics_items = required_array(economics, &["items"])?; 244 let order_items = required_array(payload, &["document", "order", "items"])?; 245 if economics_items.is_empty() { 246 return Err(invalid_field( 247 "document.order.economics.items", 248 "must contain at least one item", 249 )); 250 } 251 if economics_items.len() != order_items.len() { 252 return Err(invalid_field( 253 "document.order.economics.items", 254 "must match document.order.items length", 255 )); 256 } 257 for (index, item) in economics_items.iter().enumerate() { 258 let order_item = &order_items[index]; 259 let economics_bin_id = validate_required_string(item, &["bin_id"]).map_err(|_| { 260 invalid_field_at( 261 format!("document.order.economics.items[{index}].bin_id"), 262 "is required", 263 ) 264 })?; 265 let order_bin_id = validate_required_string(order_item, &["bin_id"])?; 266 if economics_bin_id != order_bin_id { 267 return Err(invalid_field_at( 268 format!("document.order.economics.items[{index}].bin_id"), 269 "must match document.order.items bin_id", 270 )); 271 } 272 let economics_bin_count = validate_positive_u64(item, &["bin_count"]).map_err(|_| { 273 invalid_field_at( 274 format!("document.order.economics.items[{index}].bin_count"), 275 "must be positive", 276 ) 277 })?; 278 let order_bin_count = validate_positive_u64(order_item, &["bin_count"])?; 279 if economics_bin_count != order_bin_count { 280 return Err(invalid_field_at( 281 format!("document.order.economics.items[{index}].bin_count"), 282 "must match document.order.items bin_count", 283 )); 284 } 285 validate_required_string(item, &["quantity_amount"]).map_err(|_| { 286 invalid_field_at( 287 format!("document.order.economics.items[{index}].quantity_amount"), 288 "is required", 289 ) 290 })?; 291 validate_required_string(item, &["quantity_unit"]).map_err(|_| { 292 invalid_field_at( 293 format!("document.order.economics.items[{index}].quantity_unit"), 294 "is required", 295 ) 296 })?; 297 validate_required_string(item, &["unit_price_amount"]).map_err(|_| { 298 invalid_field_at( 299 format!("document.order.economics.items[{index}].unit_price_amount"), 300 "is required", 301 ) 302 })?; 303 let unit_price_currency = validate_required_string(item, &["unit_price_currency"])?; 304 if unit_price_currency != currency { 305 return Err(invalid_field_at( 306 format!("document.order.economics.items[{index}].unit_price_currency"), 307 "must match document.order.economics.currency", 308 )); 309 } 310 validate_money(item, &["line_subtotal"], currency)?; 311 } 312 validate_money(economics, &["subtotal"], currency)?; 313 validate_money(economics, &["discount_total"], currency)?; 314 validate_money(economics, &["adjustment_total"], currency)?; 315 validate_money(economics, &["total"], currency)?; 316 Ok(()) 317 } 318 319 fn validate_money(payload: &Value, path: &[&str], currency: &str) -> Result<(), LocalEventsError> { 320 let Some(money) = value_at(payload, path) else { 321 return Err(missing_field(path)); 322 }; 323 validate_required_string(money, &["amount"])?; 324 let money_currency = validate_required_string(money, &["currency"])?; 325 if money_currency != currency { 326 return Err(invalid_field( 327 &format!("{}.currency", path.join(".")), 328 "must match currency", 329 )); 330 } 331 Ok(()) 332 } 333 334 fn validate_string_field( 335 payload: &Value, 336 path: &[&str], 337 expected: &str, 338 ) -> Result<(), LocalEventsError> { 339 let Some(value) = value_at(payload, path).and_then(Value::as_str) else { 340 return Err(missing_field(path)); 341 }; 342 if value != expected { 343 return Err(invalid_field( 344 &path.join("."), 345 &format!("must be `{expected}`"), 346 )); 347 } 348 Ok(()) 349 } 350 351 fn validate_required_string<'a>( 352 payload: &'a Value, 353 path: &[&str], 354 ) -> Result<&'a str, LocalEventsError> { 355 let Some(value) = value_at(payload, path).and_then(Value::as_str) else { 356 return Err(missing_field(path)); 357 }; 358 validate_non_empty(&path.join("."), value)?; 359 Ok(value.trim()) 360 } 361 362 fn validate_bool_field( 363 payload: &Value, 364 path: &[&str], 365 expected: bool, 366 ) -> Result<(), LocalEventsError> { 367 let Some(value) = value_at(payload, path).and_then(Value::as_bool) else { 368 return Err(missing_field(path)); 369 }; 370 if value != expected { 371 return Err(invalid_field( 372 &path.join("."), 373 &format!("must be `{expected}`"), 374 )); 375 } 376 Ok(()) 377 } 378 379 fn validate_positive_i64(payload: &Value, path: &[&str]) -> Result<(), LocalEventsError> { 380 match value_at(payload, path).and_then(Value::as_i64) { 381 Some(value) if value > 0 => Ok(()), 382 _ => Err(invalid_field(&path.join("."), "must be positive")), 383 } 384 } 385 386 fn validate_positive_u64(payload: &Value, path: &[&str]) -> Result<u64, LocalEventsError> { 387 match value_at(payload, path).and_then(Value::as_u64) { 388 Some(value) if value > 0 => Ok(value), 389 _ => Err(invalid_field(&path.join("."), "must be positive")), 390 } 391 } 392 393 fn validate_currency(field: &str, value: &str) -> Result<(), LocalEventsError> { 394 if value.len() != 3 || !value.bytes().all(|byte| byte.is_ascii_uppercase()) { 395 return Err(invalid_field( 396 field, 397 "must be an uppercase ISO currency code", 398 )); 399 } 400 Ok(()) 401 } 402 403 fn required_array<'a>( 404 payload: &'a Value, 405 path: &[&str], 406 ) -> Result<&'a Vec<Value>, LocalEventsError> { 407 let Some(value) = value_at(payload, path).and_then(Value::as_array) else { 408 return Err(missing_field(path)); 409 }; 410 Ok(value) 411 } 412 413 fn support_issues(payload: &Value) -> Result<Vec<String>, LocalEventsError> { 414 let issues = required_array(payload, &["support_status", "issues"])?; 415 let mut parsed = Vec::with_capacity(issues.len()); 416 for (index, issue) in issues.iter().enumerate() { 417 let Some(issue) = issue.as_str() else { 418 return Err(invalid_field_at( 419 format!("support_status.issues[{index}]"), 420 "must be a string", 421 )); 422 }; 423 validate_non_empty("support_status.issues", issue)?; 424 parsed.push(issue.trim().to_owned()); 425 } 426 Ok(parsed) 427 } 428 429 fn value_at<'a>(payload: &'a Value, path: &[&str]) -> Option<&'a Value> { 430 let mut current = payload; 431 for part in path { 432 current = current.get(*part)?; 433 } 434 Some(current) 435 } 436 437 fn missing_field(path: &[&str]) -> LocalEventsError { 438 invalid_field(&path.join("."), "is required") 439 } 440 441 fn invalid_field(field: &str, requirement: &str) -> LocalEventsError { 442 LocalEventsError::InvalidRecord(format!("local order field `{field}` {requirement}")) 443 } 444 445 fn invalid_field_at(field: String, requirement: &str) -> LocalEventsError { 446 LocalEventsError::InvalidRecord(format!("local order field `{field}` {requirement}")) 447 } 448 449 #[cfg(test)] 450 mod tests { 451 use serde_json::{Value, json}; 452 453 use super::*; 454 455 #[test] 456 fn support_state_labels_and_record_id_validation_are_stable() { 457 assert_eq!( 458 BuyerOrderRequestSupportState::Supported.as_str(), 459 "supported" 460 ); 461 assert_eq!( 462 BuyerOrderRequestSupportState::Unsupported.as_str(), 463 "unsupported" 464 ); 465 assert_eq!( 466 buyer_order_request_local_work_record_id(" ord-a ").expect("record id"), 467 "app:local_work:order_request:ord-a" 468 ); 469 assert_error_contains( 470 buyer_order_request_local_work_record_id(" "), 471 "order_id must not be empty", 472 ); 473 } 474 475 #[test] 476 fn private_validation_helpers_cover_successful_payload() { 477 let payload = supported_payload(); 478 479 assert_eq!( 480 validate_support_status(&payload).expect("support status"), 481 ( 482 BuyerOrderRequestSupportState::Supported, 483 Vec::<String>::new() 484 ) 485 ); 486 validate_supported_buyer_order_request_local_work_payload(&payload) 487 .expect("supported payload"); 488 validate_exportability(&payload, BuyerOrderRequestSupportState::Supported) 489 .expect("exportability"); 490 validate_order_identity(&payload, BuyerOrderRequestSupportState::Supported) 491 .expect("identity"); 492 validate_order_items(&payload).expect("items"); 493 validate_order_economics(&payload).expect("economics"); 494 assert_eq!( 495 validate_required_string(&payload, &["document", "order", "order_id"]) 496 .expect("order id"), 497 "ord_1" 498 ); 499 validate_bool_field(&payload, &["currentness", "current"], true).expect("bool"); 500 assert_eq!( 501 support_issues(&payload).expect("support issues"), 502 Vec::<String>::new() 503 ); 504 assert!(value_at(&payload, &["document", "order"]).is_some()); 505 } 506 507 #[test] 508 fn payload_validation_rejects_top_level_contract_drift() { 509 let mut wrong_kind = supported_payload(); 510 wrong_kind["record_kind"] = json!("other"); 511 assert_invalid(wrong_kind, "record_kind"); 512 513 let mut missing_scope = supported_payload(); 514 missing_scope["scope"] = Value::Null; 515 assert_invalid(missing_scope, "scope"); 516 517 let mut wrong_document_kind = supported_payload(); 518 wrong_document_kind["document"]["kind"] = json!("other"); 519 assert_invalid(wrong_document_kind, "document.kind"); 520 521 let mut wrong_currentness_source = supported_payload(); 522 wrong_currentness_source["currentness"]["source"] = json!("other"); 523 assert_invalid(wrong_currentness_source, "currentness.source"); 524 525 let mut missing_order_updated = supported_payload(); 526 missing_order_updated["currentness"]["order_updated_at"] = Value::Null; 527 assert_invalid(missing_order_updated, "order_updated_at"); 528 529 let mut bad_created_at = supported_payload(); 530 bad_created_at["currentness"]["created_at_ms"] = json!(0); 531 assert_invalid(bad_created_at, "created_at_ms"); 532 } 533 534 #[test] 535 fn support_and_exportability_rejections_cover_private_branches() { 536 let mut invalid_state = supported_payload(); 537 invalid_state["support_status"]["state"] = json!("partial"); 538 assert_invalid(invalid_state, "support_status.state"); 539 540 let mut issue_not_string = supported_payload(); 541 issue_not_string["support_status"] = json!({ 542 "state": "unsupported", 543 "issues": [42] 544 }); 545 assert_invalid(issue_not_string, "support_status.issues[0]"); 546 547 let mut issue_empty = supported_payload(); 548 issue_empty["support_status"] = json!({ 549 "state": "unsupported", 550 "issues": [" "] 551 }); 552 assert_invalid(issue_empty, "support_status.issues"); 553 554 let mut supported_but_unresolved = unsupported_payload(); 555 supported_but_unresolved["support_status"] = json!({ 556 "state": "supported", 557 "issues": [] 558 }); 559 assert_invalid(supported_but_unresolved, "exportability.state"); 560 561 let mut unknown_exportability = supported_payload(); 562 unknown_exportability["exportability"]["state"] = json!("queued"); 563 assert_invalid(unknown_exportability, "exportability.state"); 564 565 let mut missing_reason = unsupported_payload(); 566 missing_reason["exportability"]["reason"] = Value::Null; 567 assert_invalid(missing_reason, "exportability.reason"); 568 569 let mut wrong_actor_source = unsupported_payload(); 570 wrong_actor_source["document"]["buyer_actor"]["source"] = 571 json!(BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT); 572 assert_invalid(wrong_actor_source, "buyer_actor.source"); 573 574 let mut mismatched_buyer = supported_payload(); 575 mismatched_buyer["document"]["buyer_actor"]["pubkey"] = json!("other"); 576 assert_invalid(mismatched_buyer, "buyer_actor.pubkey"); 577 578 let supported_error = 579 validate_unsupported_buyer_order_request_local_work_payload(&supported_payload()) 580 .expect_err("supported payload is not unsupported"); 581 assert!(supported_error.to_string().contains("support_status.state")); 582 } 583 584 #[test] 585 fn item_and_economics_rejections_cover_private_branches() { 586 let mut economics_not_object = supported_payload(); 587 economics_not_object["document"]["order"]["economics"] = json!("bad"); 588 assert_invalid(economics_not_object, "economics"); 589 590 let mut bad_pricing_basis = supported_payload(); 591 bad_pricing_basis["document"]["order"]["economics"]["pricing_basis"] = json!("manual"); 592 assert_invalid(bad_pricing_basis, "pricing_basis"); 593 594 let mut bad_currency = supported_payload(); 595 bad_currency["document"]["order"]["economics"]["currency"] = json!("usd"); 596 assert_invalid(bad_currency, "currency"); 597 598 let mut bad_currency_length = supported_payload(); 599 bad_currency_length["document"]["order"]["economics"]["currency"] = json!("US"); 600 assert_invalid(bad_currency_length, "currency"); 601 602 let mut missing_economics = supported_payload(); 603 missing_economics["document"]["order"] 604 .as_object_mut() 605 .expect("order object") 606 .remove("economics"); 607 assert_invalid(missing_economics, "economics"); 608 609 let mut economics_items_missing = supported_payload(); 610 economics_items_missing["document"]["order"]["economics"]["items"] = Value::Null; 611 assert_invalid(economics_items_missing, "items"); 612 613 let mut economics_items_short = supported_payload(); 614 economics_items_short["document"]["order"]["economics"]["items"] = json!([]); 615 assert_invalid(economics_items_short, "economics.items"); 616 617 let mut economics_items_long = supported_payload(); 618 economics_items_long["document"]["order"]["economics"]["items"] = json!([ 619 { 620 "bin_id": "dozen-eggs", 621 "bin_count": 2, 622 "quantity_amount": "1", 623 "quantity_unit": "dozen", 624 "unit_price_amount": "8.00", 625 "unit_price_currency": "USD", 626 "line_subtotal": { 627 "amount": "16.00", 628 "currency": "USD" 629 } 630 }, 631 { 632 "bin_id": "half-dozen-eggs", 633 "bin_count": 1 634 } 635 ]); 636 assert_invalid(economics_items_long, "economics.items"); 637 638 let mut economics_bin_missing = supported_payload(); 639 economics_bin_missing["document"]["order"]["economics"]["items"][0]["bin_id"] = Value::Null; 640 assert_invalid(economics_bin_missing, "economics.items[0].bin_id"); 641 642 let mut economics_count_bad = supported_payload(); 643 economics_count_bad["document"]["order"]["economics"]["items"][0]["bin_count"] = json!(0); 644 assert_invalid(economics_count_bad, "economics.items[0].bin_count"); 645 646 let mut order_count_mismatch = supported_payload(); 647 order_count_mismatch["document"]["order"]["economics"]["items"][0]["bin_count"] = json!(3); 648 assert_invalid(order_count_mismatch, "economics.items[0].bin_count"); 649 650 let mut quantity_amount_missing = supported_payload(); 651 quantity_amount_missing["document"]["order"]["economics"]["items"][0]["quantity_amount"] = 652 Value::Null; 653 assert_invalid(quantity_amount_missing, "quantity_amount"); 654 655 let mut quantity_unit_missing = supported_payload(); 656 quantity_unit_missing["document"]["order"]["economics"]["items"][0]["quantity_unit"] = 657 Value::Null; 658 assert_invalid(quantity_unit_missing, "quantity_unit"); 659 660 let mut unit_price_amount_missing = supported_payload(); 661 unit_price_amount_missing["document"]["order"]["economics"]["items"][0]["unit_price_amount"] = 662 Value::Null; 663 assert_invalid(unit_price_amount_missing, "unit_price_amount"); 664 665 let mut line_subtotal_missing = supported_payload(); 666 line_subtotal_missing["document"]["order"]["economics"]["items"][0]["line_subtotal"] = 667 Value::Null; 668 assert_invalid(line_subtotal_missing, "amount"); 669 670 let mut missing_line_subtotal = supported_payload(); 671 missing_line_subtotal["document"]["order"]["economics"]["items"][0] 672 .as_object_mut() 673 .expect("economics item") 674 .remove("line_subtotal"); 675 assert_invalid(missing_line_subtotal, "line_subtotal"); 676 677 let mut line_subtotal_currency = supported_payload(); 678 line_subtotal_currency["document"]["order"]["economics"]["items"][0]["line_subtotal"]["currency"] = 679 json!("CAD"); 680 assert_invalid(line_subtotal_currency, "line_subtotal.currency"); 681 682 let mut subtotal_currency = supported_payload(); 683 subtotal_currency["document"]["order"]["economics"]["subtotal"]["currency"] = json!("CAD"); 684 assert_invalid(subtotal_currency, "subtotal.currency"); 685 686 let mut order_item_missing = supported_payload(); 687 order_item_missing["document"]["order"]["items"] = Value::Null; 688 assert_invalid(order_item_missing, "document.order.items"); 689 690 let mut missing_order_bin = supported_payload(); 691 missing_order_bin["document"]["order"]["items"][0]["bin_id"] = Value::Null; 692 assert_error_contains(validate_order_items(&missing_order_bin), "items[0].bin_id"); 693 } 694 695 fn supported_payload() -> Value { 696 json!({ 697 "record_kind": BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, 698 "scope": "app", 699 "exportability": { 700 "state": "exportable" 701 }, 702 "support_status": { 703 "state": "supported", 704 "issues": [] 705 }, 706 "currentness": { 707 "current": true, 708 "source": "app_sqlite_order", 709 "record_id": "app:local_work:order_request:ord_1", 710 "order_id": "ord_1", 711 "order_updated_at": "2026-05-24T12:00:00Z", 712 "created_at_ms": 1777777777000_i64 713 }, 714 "document": { 715 "kind": BUYER_ORDER_REQUEST_DOCUMENT_KIND, 716 "order": { 717 "order_id": "ord_1", 718 "listing_addr": "30402:seller_pubkey:listing_key", 719 "listing_event_id": "event-listing-1", 720 "buyer_pubkey": "buyer_pubkey", 721 "seller_pubkey": "seller_pubkey", 722 "items": [ 723 { 724 "bin_id": "dozen-eggs", 725 "bin_count": 2 726 } 727 ], 728 "economics": { 729 "pricing_basis": "listing_event", 730 "currency": "USD", 731 "items": [ 732 { 733 "bin_id": "dozen-eggs", 734 "bin_count": 2, 735 "quantity_amount": "1", 736 "quantity_unit": "dozen", 737 "unit_price_amount": "8.00", 738 "unit_price_currency": "USD", 739 "line_subtotal": { 740 "amount": "16.00", 741 "currency": "USD" 742 } 743 } 744 ], 745 "subtotal": { 746 "amount": "16.00", 747 "currency": "USD" 748 }, 749 "discount_total": { 750 "amount": "0", 751 "currency": "USD" 752 }, 753 "adjustment_total": { 754 "amount": "0", 755 "currency": "USD" 756 }, 757 "total": { 758 "amount": "16.00", 759 "currency": "USD" 760 } 761 } 762 }, 763 "buyer_actor": { 764 "account_id": "buyer-account", 765 "pubkey": "buyer_pubkey", 766 "source": BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT 767 } 768 } 769 }) 770 } 771 772 fn unsupported_payload() -> Value { 773 let mut payload = supported_payload(); 774 payload["exportability"] = json!({ 775 "state": "identity_unresolved", 776 "reason": "canonical_hex_pubkey_required" 777 }); 778 payload["support_status"] = json!({ 779 "state": "unsupported", 780 "issues": ["buyer_pubkey_required"] 781 }); 782 payload["document"]["order"]["buyer_pubkey"] = json!(""); 783 payload["document"]["buyer_actor"]["pubkey"] = json!(""); 784 payload["document"]["buyer_actor"]["source"] = 785 json!(BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP); 786 payload 787 } 788 789 fn assert_invalid(payload: Value, expected: &str) { 790 assert_error_contains( 791 validate_buyer_order_request_local_work_payload(&payload), 792 expected, 793 ); 794 } 795 796 fn assert_error_contains<T: std::fmt::Debug>( 797 result: Result<T, LocalEventsError>, 798 expected: &str, 799 ) { 800 let error = result.expect_err("expected validation error"); 801 assert!( 802 error.to_string().contains(expected), 803 "expected error to contain {expected}, got {error}" 804 ); 805 } 806 }