basket.rs (73022B)
1 use std::fs; 2 use std::path::{Path, PathBuf}; 3 use std::sync::atomic::{AtomicU64, Ordering}; 4 use std::time::{SystemTime, UNIX_EPOCH}; 5 6 use radroots_events::order::RadrootsOrderEconomics; 7 use radroots_replica_db::{ReplicaSql, trade_product}; 8 use radroots_replica_db_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany}; 9 use radroots_sql_core::SqliteExecutor; 10 use serde::{Deserialize, Serialize}; 11 use serde_json::{Value, json}; 12 13 use crate::cli::global::{OrderDraftAdjustmentArgs, OrderDraftCreateArgs}; 14 use crate::ops::{ 15 BasketAdjustmentAddRequest, BasketAdjustmentAddResult, BasketAdjustmentRemoveRequest, 16 BasketAdjustmentRemoveResult, BasketCreateRequest, BasketCreateResult, BasketGetRequest, 17 BasketGetResult, BasketItemAddRequest, BasketItemAddResult, BasketItemRemoveRequest, 18 BasketItemRemoveResult, BasketItemUpdateRequest, BasketItemUpdateResult, BasketListRequest, 19 BasketListResult, BasketQuoteCreateRequest, BasketQuoteCreateResult, BasketValidateRequest, 20 BasketValidateResult, OperationAdapterError, OperationRequest, OperationRequestData, 21 OperationRequestPayload, OperationResult, OperationResultData, OperationService, 22 }; 23 use crate::runtime::config::RuntimeConfig; 24 use crate::view::runtime::OrderNewView; 25 26 const BASKET_KIND: &str = "basket_v1"; 27 const BASKET_SOURCE: &str = "local baskets - local first"; 28 const BASKET_QUOTE_SOURCE: &str = "local baskets - deterministic quote"; 29 const BASKETS_DIR: &str = "baskets"; 30 31 static BASKET_COUNTER: AtomicU64 = AtomicU64::new(0); 32 33 #[derive(Debug, Clone, Serialize, Deserialize)] 34 #[serde(deny_unknown_fields)] 35 struct BasketDocument { 36 version: u32, 37 kind: String, 38 basket: BasketState, 39 #[serde(default, skip_serializing_if = "Option::is_none")] 40 quote: Option<BasketQuote>, 41 } 42 43 #[derive(Debug, Clone, Serialize, Deserialize)] 44 #[serde(deny_unknown_fields)] 45 struct BasketState { 46 basket_id: String, 47 created_at_unix: u64, 48 updated_at_unix: u64, 49 #[serde(default, skip_serializing_if = "Vec::is_empty")] 50 items: Vec<BasketItem>, 51 #[serde(default, skip_serializing_if = "Vec::is_empty")] 52 adjustments: Vec<BasketAdjustment>, 53 } 54 55 #[derive(Debug, Clone, Serialize, Deserialize)] 56 #[serde(deny_unknown_fields)] 57 struct BasketItem { 58 item_id: String, 59 #[serde(default, skip_serializing_if = "Option::is_none")] 60 listing: Option<String>, 61 #[serde(default, skip_serializing_if = "Option::is_none")] 62 listing_addr: Option<String>, 63 bin_id: String, 64 quantity: u32, 65 } 66 67 #[derive(Debug, Clone, Serialize, Deserialize)] 68 #[serde(deny_unknown_fields)] 69 struct BasketAdjustment { 70 id: String, 71 effect: String, 72 amount: String, 73 currency: String, 74 reason: String, 75 } 76 77 #[derive(Debug, Clone, Serialize, Deserialize)] 78 #[serde(deny_unknown_fields)] 79 struct BasketQuote { 80 quote_id: String, 81 quote_version: u32, 82 order_id: String, 83 order_file: String, 84 #[serde(default, skip_serializing_if = "Option::is_none")] 85 economics: Option<RadrootsOrderEconomics>, 86 ready_for_submit: bool, 87 created_at_unix: u64, 88 #[serde(default, skip_serializing_if = "Vec::is_empty")] 89 issues: Vec<BasketIssue>, 90 } 91 92 #[derive(Debug, Clone, Serialize, Deserialize)] 93 #[serde(deny_unknown_fields)] 94 struct BasketIssue { 95 code: String, 96 field: String, 97 message: String, 98 } 99 100 #[derive(Debug, Clone)] 101 struct LoadedBasket { 102 file: PathBuf, 103 document: BasketDocument, 104 } 105 106 #[derive(Debug, Clone)] 107 struct BasketProductBinState { 108 primary_bin_id: Option<String>, 109 verified_primary_bin_id: Option<String>, 110 } 111 112 #[derive(Debug, Clone)] 113 enum BasketProductResolution { 114 Resolved(BasketProductBinState), 115 Unresolved, 116 Ambiguous(usize), 117 } 118 119 pub struct BasketOperationService<'a> { 120 config: &'a RuntimeConfig, 121 } 122 123 impl<'a> BasketOperationService<'a> { 124 pub fn new(config: &'a RuntimeConfig) -> Self { 125 Self { config } 126 } 127 } 128 129 impl OperationService<BasketCreateRequest> for BasketOperationService<'_> { 130 type Result = BasketCreateResult; 131 132 fn execute( 133 &self, 134 request: OperationRequest<BasketCreateRequest>, 135 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 136 let basket_id = string_input(&request, "basket_id").unwrap_or_else(next_basket_id); 137 let initial_item = optional_item_from_request(&request, None)?; 138 let file = basket_lookup_path(self.config, basket_id.as_str()); 139 if file.exists() { 140 return Err(invalid_input( 141 request.operation_id(), 142 format!("basket `{basket_id}` already exists"), 143 )); 144 } 145 if request.context.dry_run { 146 return json_operation_result::<BasketCreateResult>(json!({ 147 "state": "dry_run", 148 "source": BASKET_SOURCE, 149 "basket_id": basket_id, 150 "file": file.display().to_string(), 151 "item_count": initial_item.as_ref().map(|_| 1).unwrap_or(0), 152 "actions": ["radroots basket create"], 153 })); 154 } 155 156 let now = now_unix(); 157 let document = BasketDocument { 158 version: 1, 159 kind: BASKET_KIND.to_owned(), 160 basket: BasketState { 161 basket_id, 162 created_at_unix: now, 163 updated_at_unix: now, 164 items: initial_item.into_iter().collect(), 165 adjustments: Vec::new(), 166 }, 167 quote: None, 168 }; 169 save_basket(file.as_path(), &document)?; 170 json_operation_result::<BasketCreateResult>(basket_view( 171 self.config, 172 &document, 173 file.as_path(), 174 None, 175 )?) 176 } 177 } 178 179 impl OperationService<BasketGetRequest> for BasketOperationService<'_> { 180 type Result = BasketGetResult; 181 182 fn execute( 183 &self, 184 request: OperationRequest<BasketGetRequest>, 185 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 186 let lookup = required_basket_id(&request)?; 187 let Some(loaded) = load_basket_optional(self.config, lookup.as_str())? else { 188 return json_operation_result::<BasketGetResult>(missing_basket_view( 189 self.config, 190 lookup.as_str(), 191 )); 192 }; 193 json_operation_result::<BasketGetResult>(basket_view( 194 self.config, 195 &loaded.document, 196 loaded.file.as_path(), 197 None, 198 )?) 199 } 200 } 201 202 impl OperationService<BasketListRequest> for BasketOperationService<'_> { 203 type Result = BasketListResult; 204 205 fn execute( 206 &self, 207 _request: OperationRequest<BasketListRequest>, 208 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 209 let baskets = list_basket_summaries(self.config)?; 210 json_operation_result::<BasketListResult>(json!({ 211 "state": if baskets.is_empty() { "empty" } else { "ready" }, 212 "source": BASKET_SOURCE, 213 "count": baskets.len(), 214 "baskets": baskets, 215 "actions": if baskets.is_empty() { 216 vec!["radroots basket create".to_owned()] 217 } else { 218 Vec::new() 219 }, 220 })) 221 } 222 } 223 224 impl OperationService<BasketItemAddRequest> for BasketOperationService<'_> { 225 type Result = BasketItemAddResult; 226 227 fn execute( 228 &self, 229 request: OperationRequest<BasketItemAddRequest>, 230 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 231 let basket_id = required_basket_id(&request)?; 232 let mut loaded = 233 load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; 234 let item = required_item_from_request(&request, Some(next_item_id(&loaded.document)))?; 235 if request.context.dry_run { 236 return json_operation_result::<BasketItemAddResult>(json!({ 237 "state": "dry_run", 238 "source": BASKET_SOURCE, 239 "basket_id": basket_id, 240 "item": item, 241 "actions": ["radroots basket item add"], 242 })); 243 } 244 245 loaded.document.basket.items.push(item); 246 touch_basket(&mut loaded.document); 247 loaded.document.quote = None; 248 save_basket(loaded.file.as_path(), &loaded.document)?; 249 json_operation_result::<BasketItemAddResult>(basket_view( 250 self.config, 251 &loaded.document, 252 loaded.file.as_path(), 253 Some("updated"), 254 )?) 255 } 256 } 257 258 impl OperationService<BasketItemUpdateRequest> for BasketOperationService<'_> { 259 type Result = BasketItemUpdateResult; 260 261 fn execute( 262 &self, 263 request: OperationRequest<BasketItemUpdateRequest>, 264 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 265 let basket_id = required_basket_id(&request)?; 266 let item_id = required_string(&request, "item_id")?; 267 let mut loaded = 268 load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; 269 let Some(index) = loaded 270 .document 271 .basket 272 .items 273 .iter() 274 .position(|item| item.item_id == item_id) 275 else { 276 return Err(invalid_input( 277 request.operation_id(), 278 format!("basket item `{item_id}` was not found"), 279 )); 280 }; 281 282 let updated = 283 update_item_from_request(&request, loaded.document.basket.items[index].clone())?; 284 if request.context.dry_run { 285 return json_operation_result::<BasketItemUpdateResult>(json!({ 286 "state": "dry_run", 287 "source": BASKET_SOURCE, 288 "basket_id": basket_id, 289 "item": updated, 290 "actions": ["radroots basket item update"], 291 })); 292 } 293 294 loaded.document.basket.items[index] = updated; 295 touch_basket(&mut loaded.document); 296 loaded.document.quote = None; 297 save_basket(loaded.file.as_path(), &loaded.document)?; 298 json_operation_result::<BasketItemUpdateResult>(basket_view( 299 self.config, 300 &loaded.document, 301 loaded.file.as_path(), 302 Some("updated"), 303 )?) 304 } 305 } 306 307 impl OperationService<BasketItemRemoveRequest> for BasketOperationService<'_> { 308 type Result = BasketItemRemoveResult; 309 310 fn execute( 311 &self, 312 request: OperationRequest<BasketItemRemoveRequest>, 313 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 314 let basket_id = required_basket_id(&request)?; 315 let item_id = required_string(&request, "item_id")?; 316 let mut loaded = 317 load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; 318 let Some(index) = loaded 319 .document 320 .basket 321 .items 322 .iter() 323 .position(|item| item.item_id == item_id) 324 else { 325 return Err(invalid_input( 326 request.operation_id(), 327 format!("basket item `{item_id}` was not found"), 328 )); 329 }; 330 331 if request.context.dry_run { 332 return json_operation_result::<BasketItemRemoveResult>(json!({ 333 "state": "dry_run", 334 "source": BASKET_SOURCE, 335 "basket_id": basket_id, 336 "item_id": item_id, 337 "actions": ["radroots basket item remove"], 338 })); 339 } 340 341 loaded.document.basket.items.remove(index); 342 touch_basket(&mut loaded.document); 343 loaded.document.quote = None; 344 save_basket(loaded.file.as_path(), &loaded.document)?; 345 json_operation_result::<BasketItemRemoveResult>(basket_view( 346 self.config, 347 &loaded.document, 348 loaded.file.as_path(), 349 Some("updated"), 350 )?) 351 } 352 } 353 354 impl OperationService<BasketAdjustmentAddRequest> for BasketOperationService<'_> { 355 type Result = BasketAdjustmentAddResult; 356 357 fn execute( 358 &self, 359 request: OperationRequest<BasketAdjustmentAddRequest>, 360 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 361 let basket_id = required_basket_id(&request)?; 362 let mut loaded = 363 load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; 364 let adjustment = required_adjustment_from_request(&request)?; 365 if loaded 366 .document 367 .basket 368 .adjustments 369 .iter() 370 .any(|existing| existing.id == adjustment.id) 371 { 372 return Err(invalid_input( 373 request.operation_id(), 374 format!("basket adjustment `{}` already exists", adjustment.id), 375 )); 376 } 377 if request.context.dry_run { 378 return json_operation_result::<BasketAdjustmentAddResult>(json!({ 379 "state": "dry_run", 380 "source": BASKET_SOURCE, 381 "basket_id": basket_id, 382 "adjustment": adjustment, 383 "actions": ["radroots basket adjustment add"], 384 })); 385 } 386 387 loaded.document.basket.adjustments.push(adjustment); 388 touch_basket(&mut loaded.document); 389 loaded.document.quote = None; 390 save_basket(loaded.file.as_path(), &loaded.document)?; 391 json_operation_result::<BasketAdjustmentAddResult>(basket_view( 392 self.config, 393 &loaded.document, 394 loaded.file.as_path(), 395 Some("updated"), 396 )?) 397 } 398 } 399 400 impl OperationService<BasketAdjustmentRemoveRequest> for BasketOperationService<'_> { 401 type Result = BasketAdjustmentRemoveResult; 402 403 fn execute( 404 &self, 405 request: OperationRequest<BasketAdjustmentRemoveRequest>, 406 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 407 let basket_id = required_basket_id(&request)?; 408 let adjustment_id = required_string(&request, "id")?; 409 let mut loaded = 410 load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; 411 let Some(index) = loaded 412 .document 413 .basket 414 .adjustments 415 .iter() 416 .position(|adjustment| adjustment.id == adjustment_id) 417 else { 418 return Err(invalid_input( 419 request.operation_id(), 420 format!("basket adjustment `{adjustment_id}` was not found"), 421 )); 422 }; 423 if request.context.dry_run { 424 return json_operation_result::<BasketAdjustmentRemoveResult>(json!({ 425 "state": "dry_run", 426 "source": BASKET_SOURCE, 427 "basket_id": basket_id, 428 "adjustment_id": adjustment_id, 429 "actions": ["radroots basket adjustment remove"], 430 })); 431 } 432 433 loaded.document.basket.adjustments.remove(index); 434 touch_basket(&mut loaded.document); 435 loaded.document.quote = None; 436 save_basket(loaded.file.as_path(), &loaded.document)?; 437 json_operation_result::<BasketAdjustmentRemoveResult>(basket_view( 438 self.config, 439 &loaded.document, 440 loaded.file.as_path(), 441 Some("updated"), 442 )?) 443 } 444 } 445 446 impl OperationService<BasketValidateRequest> for BasketOperationService<'_> { 447 type Result = BasketValidateResult; 448 449 fn execute( 450 &self, 451 request: OperationRequest<BasketValidateRequest>, 452 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 453 let basket_id = required_basket_id(&request)?; 454 let Some(loaded) = load_basket_optional(self.config, basket_id.as_str())? else { 455 return json_operation_result::<BasketValidateResult>(missing_basket_view( 456 self.config, 457 basket_id.as_str(), 458 )); 459 }; 460 json_operation_result::<BasketValidateResult>(basket_validation_view( 461 self.config, 462 &loaded.document, 463 loaded.file.as_path(), 464 )?) 465 } 466 } 467 468 impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> { 469 type Result = BasketQuoteCreateResult; 470 471 fn execute( 472 &self, 473 request: OperationRequest<BasketQuoteCreateRequest>, 474 ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { 475 let basket_id = required_basket_id(&request)?; 476 let mut loaded = 477 load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; 478 let issues = basket_issues(self.config, &loaded.document)?; 479 if !issues.is_empty() { 480 let actions = basket_actions(&loaded.document, issues.as_slice()); 481 return json_operation_result::<BasketQuoteCreateResult>(json!({ 482 "state": "unconfigured", 483 "source": BASKET_QUOTE_SOURCE, 484 "basket_id": basket_id, 485 "file": loaded.file.display().to_string(), 486 "ready_for_quote": false, 487 "issues": issues, 488 "actions": actions, 489 })); 490 } 491 492 let item = loaded 493 .document 494 .basket 495 .items 496 .first() 497 .expect("validated basket has one item") 498 .clone(); 499 if request.context.dry_run { 500 let order = crate::runtime::order::scaffold_preflight( 501 self.config, 502 &OrderDraftCreateArgs { 503 listing: item.listing.clone(), 504 listing_addr: item.listing_addr.clone(), 505 bin_id: Some(item.bin_id.clone()), 506 bin_count: Some(item.quantity), 507 adjustments: order_adjustments_from_basket(&loaded.document), 508 }, 509 ) 510 .map_err(|error| { 511 OperationAdapterError::runtime_failure(request.operation_id(), error) 512 })?; 513 return json_operation_result::<BasketQuoteCreateResult>(json!({ 514 "state": "dry_run", 515 "source": BASKET_QUOTE_SOURCE, 516 "basket_id": basket_id, 517 "file": loaded.file.display().to_string(), 518 "item": item, 519 "order": order, 520 "actions": ["radroots basket quote create"], 521 })); 522 } 523 524 let order = crate::runtime::order::scaffold( 525 self.config, 526 &OrderDraftCreateArgs { 527 listing: item.listing.clone(), 528 listing_addr: item.listing_addr.clone(), 529 bin_id: Some(item.bin_id.clone()), 530 bin_count: Some(item.quantity), 531 adjustments: order_adjustments_from_basket(&loaded.document), 532 }, 533 ) 534 .map_err(|error| OperationAdapterError::runtime_failure(request.operation_id(), error))?; 535 let quote_economics = order.economics.clone(); 536 let quote = BasketQuote { 537 quote_id: quote_economics 538 .as_ref() 539 .map(|economics| economics.quote_id.to_string()) 540 .unwrap_or_else(|| format!("quote_{}", loaded.document.basket.basket_id)), 541 quote_version: quote_economics 542 .as_ref() 543 .map(|economics| economics.quote_version) 544 .unwrap_or(1), 545 order_id: order.order_id.clone(), 546 order_file: order.file.clone(), 547 economics: quote_economics, 548 ready_for_submit: order.ready_for_submit, 549 created_at_unix: now_unix(), 550 issues: quote_issues_from_order(&order), 551 }; 552 loaded.document.quote = Some(quote.clone()); 553 touch_basket(&mut loaded.document); 554 save_basket(loaded.file.as_path(), &loaded.document)?; 555 556 json_operation_result::<BasketQuoteCreateResult>(json!({ 557 "state": "quoted", 558 "source": BASKET_QUOTE_SOURCE, 559 "basket_id": loaded.document.basket.basket_id, 560 "file": loaded.file.display().to_string(), 561 "quote": quote, 562 "order": order, 563 "actions": quote_actions(&order), 564 })) 565 } 566 } 567 568 fn optional_item_from_request<P>( 569 request: &OperationRequest<P>, 570 item_id: Option<String>, 571 ) -> Result<Option<BasketItem>, OperationAdapterError> 572 where 573 P: OperationRequestPayload + OperationRequestData, 574 { 575 if string_input(request, "listing").is_none() 576 && string_input(request, "listing_addr").is_none() 577 && string_input(request, "bin_id").is_none() 578 { 579 return Ok(None); 580 } 581 required_item_from_request(request, item_id).map(Some) 582 } 583 584 fn required_item_from_request<P>( 585 request: &OperationRequest<P>, 586 item_id: Option<String>, 587 ) -> Result<BasketItem, OperationAdapterError> 588 where 589 P: OperationRequestPayload + OperationRequestData, 590 { 591 let listing = string_input(request, "listing"); 592 let listing_addr = string_input(request, "listing_addr"); 593 if listing.is_none() && listing_addr.is_none() { 594 return Err(invalid_input( 595 request.operation_id(), 596 "missing required `listing` or `listing_addr` input".to_owned(), 597 )); 598 } 599 let bin_id = required_string(request, "bin_id")?; 600 let quantity = quantity_input(request)?.unwrap_or(1); 601 if quantity == 0 { 602 return Err(invalid_input( 603 request.operation_id(), 604 "`quantity` must be greater than 0".to_owned(), 605 )); 606 } 607 608 Ok(BasketItem { 609 item_id: item_id 610 .or_else(|| string_input(request, "item_id")) 611 .unwrap_or_else(|| "item_1".to_owned()), 612 listing, 613 listing_addr, 614 bin_id, 615 quantity, 616 }) 617 } 618 619 fn update_item_from_request<P>( 620 request: &OperationRequest<P>, 621 mut item: BasketItem, 622 ) -> Result<BasketItem, OperationAdapterError> 623 where 624 P: OperationRequestPayload + OperationRequestData, 625 { 626 let mut changed = false; 627 if let Some(listing) = string_input(request, "listing") { 628 item.listing = Some(listing); 629 changed = true; 630 } 631 if let Some(listing_addr) = string_input(request, "listing_addr") { 632 item.listing_addr = Some(listing_addr); 633 changed = true; 634 } 635 if let Some(bin_id) = string_input(request, "bin_id") { 636 item.bin_id = bin_id; 637 changed = true; 638 } 639 if let Some(quantity) = quantity_input(request)? { 640 if quantity == 0 { 641 return Err(invalid_input( 642 request.operation_id(), 643 "`quantity` must be greater than 0".to_owned(), 644 )); 645 } 646 item.quantity = quantity; 647 changed = true; 648 } 649 if !changed { 650 return Err(invalid_input( 651 request.operation_id(), 652 "no item update input was provided".to_owned(), 653 )); 654 } 655 Ok(item) 656 } 657 658 fn required_adjustment_from_request<P>( 659 request: &OperationRequest<P>, 660 ) -> Result<BasketAdjustment, OperationAdapterError> 661 where 662 P: OperationRequestPayload + OperationRequestData, 663 { 664 let id = required_string(request, "id")?.trim().to_owned(); 665 if id.is_empty() { 666 return Err(invalid_input( 667 request.operation_id(), 668 "`id` must not be empty".to_owned(), 669 )); 670 } 671 let effect = required_string(request, "effect")?.trim().to_owned(); 672 if effect != "increase" && effect != "decrease" { 673 return Err(invalid_input( 674 request.operation_id(), 675 "`effect` must be increase or decrease".to_owned(), 676 )); 677 } 678 let amount = required_string(request, "amount")?.trim().to_owned(); 679 let parsed_amount = amount 680 .parse::<radroots_core::RadrootsCoreDecimal>() 681 .map_err(|_| { 682 invalid_input( 683 request.operation_id(), 684 "`amount` must be a valid decimal value".to_owned(), 685 ) 686 })?; 687 if parsed_amount.is_sign_negative() || parsed_amount.is_zero() { 688 return Err(invalid_input( 689 request.operation_id(), 690 "`amount` must be greater than zero".to_owned(), 691 )); 692 } 693 let currency = required_string(request, "currency")? 694 .trim() 695 .to_ascii_uppercase(); 696 if radroots_core::RadrootsCoreCurrency::from_str_upper(currency.as_str()).is_err() { 697 return Err(invalid_input( 698 request.operation_id(), 699 "`currency` must be a valid ISO currency code".to_owned(), 700 )); 701 } 702 let reason = required_string(request, "reason")?.trim().to_owned(); 703 if reason.is_empty() { 704 return Err(invalid_input( 705 request.operation_id(), 706 "`reason` must not be empty".to_owned(), 707 )); 708 } 709 Ok(BasketAdjustment { 710 id, 711 effect, 712 amount, 713 currency, 714 reason, 715 }) 716 } 717 718 fn basket_view( 719 config: &RuntimeConfig, 720 document: &BasketDocument, 721 file: &Path, 722 state: Option<&str>, 723 ) -> Result<Value, OperationAdapterError> { 724 let issues = basket_issues(config, document)?; 725 let ready_for_quote = issues.is_empty(); 726 let actions = basket_actions(document, issues.as_slice()); 727 Ok(json!({ 728 "state": state.unwrap_or("ready"), 729 "source": BASKET_SOURCE, 730 "basket_id": document.basket.basket_id, 731 "file": file.display().to_string(), 732 "item_count": document.basket.items.len(), 733 "items": document.basket.items, 734 "adjustment_count": document.basket.adjustments.len(), 735 "adjustments": document.basket.adjustments, 736 "quote": document.quote, 737 "ready_for_quote": ready_for_quote, 738 "issues": issues, 739 "actions": actions, 740 })) 741 } 742 743 fn basket_validation_view( 744 config: &RuntimeConfig, 745 document: &BasketDocument, 746 file: &Path, 747 ) -> Result<Value, OperationAdapterError> { 748 let issues = basket_issues(config, document)?; 749 let ready_for_quote = issues.is_empty(); 750 let actions = basket_actions(document, issues.as_slice()); 751 Ok(json!({ 752 "state": if ready_for_quote { "ready" } else { "unconfigured" }, 753 "source": BASKET_SOURCE, 754 "basket_id": document.basket.basket_id, 755 "file": file.display().to_string(), 756 "ready_for_quote": ready_for_quote, 757 "item_count": document.basket.items.len(), 758 "adjustment_count": document.basket.adjustments.len(), 759 "issues": issues, 760 "actions": actions, 761 })) 762 } 763 764 fn missing_basket_view(config: &RuntimeConfig, lookup: &str) -> Value { 765 json!({ 766 "state": "missing", 767 "source": BASKET_SOURCE, 768 "lookup": lookup, 769 "file": basket_lookup_path(config, lookup).display().to_string(), 770 "reason": format!("basket `{lookup}` was not found"), 771 "actions": ["radroots basket list", "radroots basket create"], 772 }) 773 } 774 775 fn list_basket_summaries(config: &RuntimeConfig) -> Result<Vec<Value>, OperationAdapterError> { 776 let dir = baskets_dir(config); 777 if !dir.exists() { 778 return Ok(Vec::new()); 779 } 780 781 let mut baskets = Vec::new(); 782 for entry in fs::read_dir(&dir).map_err(|error| { 783 OperationAdapterError::Runtime(format!("read basket directory {}: {error}", dir.display())) 784 })? { 785 let entry = entry.map_err(|error| { 786 OperationAdapterError::Runtime(format!( 787 "read basket directory {}: {error}", 788 dir.display() 789 )) 790 })?; 791 let path = entry.path(); 792 if path.extension().and_then(|value| value.to_str()) != Some("json") { 793 continue; 794 } 795 let loaded = load_basket_path(path.as_path())?; 796 let issues = basket_issues(config, &loaded.document)?; 797 let ready_for_quote = issues.is_empty(); 798 baskets.push(json!({ 799 "basket_id": loaded.document.basket.basket_id, 800 "state": if ready_for_quote { "ready" } else { "unconfigured" }, 801 "file": loaded.file.display().to_string(), 802 "item_count": loaded.document.basket.items.len(), 803 "adjustment_count": loaded.document.basket.adjustments.len(), 804 "ready_for_quote": ready_for_quote, 805 "issues": issues, 806 "quote": loaded.document.quote, 807 "updated_at_unix": loaded.document.basket.updated_at_unix, 808 })); 809 } 810 baskets.sort_by(|left, right| { 811 right["updated_at_unix"] 812 .as_u64() 813 .cmp(&left["updated_at_unix"].as_u64()) 814 .then_with(|| { 815 left["basket_id"] 816 .as_str() 817 .unwrap_or_default() 818 .cmp(right["basket_id"].as_str().unwrap_or_default()) 819 }) 820 }); 821 Ok(baskets) 822 } 823 824 fn basket_issues( 825 config: &RuntimeConfig, 826 document: &BasketDocument, 827 ) -> Result<Vec<BasketIssue>, OperationAdapterError> { 828 let mut issues = Vec::new(); 829 if document.basket.items.is_empty() { 830 issues.push(basket_issue( 831 "basket_items_missing", 832 "basket.items", 833 "basket must contain one item before quote creation", 834 )); 835 } 836 if document.basket.items.len() > 1 { 837 issues.push(basket_issue( 838 "basket_items_unsupported", 839 "basket.items", 840 "basket quotes support exactly one item", 841 )); 842 } 843 for item in &document.basket.items { 844 if item.listing.is_none() && item.listing_addr.is_none() { 845 issues.push(basket_issue( 846 "basket_item_listing_missing", 847 format!("basket.items.{}.listing", item.item_id), 848 "item must include listing or listing_addr", 849 )); 850 } 851 if item.bin_id.trim().is_empty() { 852 issues.push(basket_issue( 853 "basket_item_bin_missing", 854 format!("basket.items.{}.bin_id", item.item_id), 855 "item must include bin_id", 856 )); 857 } 858 if item.quantity == 0 { 859 issues.push(basket_issue( 860 "basket_item_quantity_invalid", 861 format!("basket.items.{}.quantity", item.item_id), 862 "item quantity must be greater than 0", 863 )); 864 } 865 } 866 if issues.is_empty() { 867 issues.extend(basket_market_issues(config, document)?); 868 } 869 Ok(issues) 870 } 871 872 fn basket_market_issues( 873 config: &RuntimeConfig, 874 document: &BasketDocument, 875 ) -> Result<Vec<BasketIssue>, OperationAdapterError> { 876 if !config.local.replica_db_path.exists() { 877 return Ok(vec![basket_issue( 878 "basket_market_replica_missing", 879 "local.replica_db", 880 "current local replica data is required before quote creation; run `radroots store init` and `radroots market refresh`", 881 )]); 882 } 883 let executor = SqliteExecutor::open(&config.local.replica_db_path).map_err(|error| { 884 OperationAdapterError::Runtime(format!( 885 "open local replica {}: {error}", 886 config.local.replica_db_path.display() 887 )) 888 })?; 889 let mut issues = Vec::new(); 890 for item in &document.basket.items { 891 let product = match basket_product_bin_state(config, &executor, item)? { 892 BasketProductResolution::Resolved(product) => product, 893 BasketProductResolution::Unresolved => { 894 issues.push(basket_issue( 895 "basket_item_listing_unresolved", 896 basket_item_listing_field(item), 897 "basket item listing is not active in the current local replica; run `radroots market refresh` before quote creation", 898 )); 899 continue; 900 } 901 BasketProductResolution::Ambiguous(count) => { 902 issues.push(basket_issue( 903 "basket_item_listing_ambiguous", 904 basket_item_listing_field(item), 905 format!( 906 "basket item listing matched {count} active local replica rows; choose a unique listing before quote creation" 907 ), 908 )); 909 continue; 910 } 911 }; 912 let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else { 913 issues.push(basket_issue( 914 "listing_primary_bin_missing", 915 format!("basket.items.{}.bin_id", item.item_id), 916 "current local replica listing primary bin is required before quote creation", 917 )); 918 continue; 919 }; 920 let Some(verified_primary_bin_id) = product 921 .verified_primary_bin_id 922 .as_deref() 923 .and_then(non_empty_ref) 924 else { 925 issues.push(basket_issue( 926 "listing_primary_bin_invalid", 927 format!("basket.items.{}.bin_id", item.item_id), 928 format!("current local replica primary bin `{primary_bin_id}` is not verified"), 929 )); 930 continue; 931 }; 932 if verified_primary_bin_id != primary_bin_id { 933 issues.push(basket_issue( 934 "listing_primary_bin_invalid", 935 format!("basket.items.{}.bin_id", item.item_id), 936 format!( 937 "current local replica primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}`" 938 ), 939 )); 940 continue; 941 } 942 if item.bin_id != primary_bin_id { 943 issues.push(basket_issue( 944 "order_bin_unknown", 945 format!("basket.items.{}.bin_id", item.item_id), 946 format!( 947 "basket bin `{}` is not in the current local listing bin set; expected primary bin `{primary_bin_id}`", 948 item.bin_id 949 ), 950 )); 951 } 952 } 953 Ok(issues) 954 } 955 956 fn basket_product_bin_state( 957 config: &RuntimeConfig, 958 executor: &SqliteExecutor, 959 item: &BasketItem, 960 ) -> Result<BasketProductResolution, OperationAdapterError> { 961 if let Some(listing_addr) = item.listing_addr.as_deref().and_then(non_empty_ref) { 962 let product_rows = trade_product::find_many( 963 executor, 964 &ITradeProductFindMany { 965 filter: Some(trade_product_listing_addr_filter(listing_addr)), 966 }, 967 ) 968 .map_err(|error| { 969 OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}")) 970 })? 971 .results; 972 let product = match product_rows.as_slice() { 973 [] => return Ok(BasketProductResolution::Unresolved), 974 [product] => product, 975 rows => return Ok(BasketProductResolution::Ambiguous(rows.len())), 976 }; 977 return Ok(BasketProductResolution::Resolved(BasketProductBinState { 978 primary_bin_id: product.primary_bin_id.clone(), 979 verified_primary_bin_id: product.verified_primary_bin_id.clone(), 980 })); 981 } 982 983 let Some(listing_lookup) = item.listing.as_deref().and_then(non_empty_ref) else { 984 return Ok(BasketProductResolution::Unresolved); 985 }; 986 let lookup_executor = SqliteExecutor::open(&config.local.replica_db_path).map_err(|error| { 987 OperationAdapterError::Runtime(format!( 988 "open local replica {}: {error}", 989 config.local.replica_db_path.display() 990 )) 991 })?; 992 let rows = ReplicaSql::new(lookup_executor) 993 .trade_product_lookup(listing_lookup) 994 .map_err(|error| { 995 OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}")) 996 })?; 997 let product = match rows.as_slice() { 998 [] => return Ok(BasketProductResolution::Unresolved), 999 [product] => product, 1000 rows => return Ok(BasketProductResolution::Ambiguous(rows.len())), 1001 }; 1002 Ok(BasketProductResolution::Resolved(BasketProductBinState { 1003 primary_bin_id: product.primary_bin_id.clone(), 1004 verified_primary_bin_id: product.verified_primary_bin_id.clone(), 1005 })) 1006 } 1007 1008 fn basket_item_listing_field(item: &BasketItem) -> String { 1009 if item 1010 .listing_addr 1011 .as_deref() 1012 .and_then(non_empty_ref) 1013 .is_some() 1014 { 1015 format!("basket.items.{}.listing_addr", item.item_id) 1016 } else { 1017 format!("basket.items.{}.listing", item.item_id) 1018 } 1019 } 1020 1021 fn basket_issue( 1022 code: impl Into<String>, 1023 field: impl Into<String>, 1024 message: impl Into<String>, 1025 ) -> BasketIssue { 1026 BasketIssue { 1027 code: code.into(), 1028 field: field.into(), 1029 message: message.into(), 1030 } 1031 } 1032 1033 fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter { 1034 ITradeProductFieldsFilter { 1035 id: None, 1036 created_at: None, 1037 updated_at: None, 1038 key: None, 1039 category: None, 1040 title: None, 1041 summary: None, 1042 process: None, 1043 lot: None, 1044 profile: None, 1045 year: None, 1046 qty_amt: None, 1047 qty_amt_exact: None, 1048 qty_unit: None, 1049 qty_label: None, 1050 qty_avail: None, 1051 price_amt: None, 1052 price_amt_exact: None, 1053 price_currency: None, 1054 price_qty_amt: None, 1055 price_qty_amt_exact: None, 1056 price_qty_unit: None, 1057 listing_addr: Some(listing_addr.to_owned()), 1058 primary_bin_id: None, 1059 verified_primary_bin_id: None, 1060 notes: None, 1061 } 1062 } 1063 1064 fn non_empty_ref(value: &str) -> Option<&str> { 1065 let value = value.trim(); 1066 if value.is_empty() { None } else { Some(value) } 1067 } 1068 1069 fn basket_actions(document: &BasketDocument, issues: &[BasketIssue]) -> Vec<String> { 1070 let basket_id = document.basket.basket_id.as_str(); 1071 if document.basket.items.is_empty() { 1072 return vec![format!("radroots basket item add {basket_id}")]; 1073 } 1074 if issues.is_empty() { 1075 vec![ 1076 format!("radroots basket validate {basket_id}"), 1077 format!("radroots basket quote create {basket_id}"), 1078 ] 1079 } else { 1080 vec![format!("radroots basket get {basket_id}")] 1081 } 1082 } 1083 1084 fn quote_actions(order: &OrderNewView) -> Vec<String> { 1085 if order.ready_for_submit { 1086 vec![format!("radroots order submit {}", order.order_id)] 1087 } else { 1088 let mut actions = vec![format!("radroots order get {}", order.order_id)]; 1089 actions.extend(order.actions.iter().cloned()); 1090 actions 1091 } 1092 } 1093 1094 fn quote_issues_from_order(order: &OrderNewView) -> Vec<BasketIssue> { 1095 order 1096 .issues 1097 .iter() 1098 .map(|issue| BasketIssue { 1099 code: issue.code.clone(), 1100 field: issue.field.clone(), 1101 message: issue.message.clone(), 1102 }) 1103 .collect() 1104 } 1105 1106 fn order_adjustments_from_basket(document: &BasketDocument) -> Vec<OrderDraftAdjustmentArgs> { 1107 document 1108 .basket 1109 .adjustments 1110 .iter() 1111 .map(|adjustment| OrderDraftAdjustmentArgs { 1112 id: adjustment.id.clone(), 1113 effect: adjustment.effect.clone(), 1114 amount: adjustment.amount.clone(), 1115 currency: adjustment.currency.clone(), 1116 reason: adjustment.reason.clone(), 1117 }) 1118 .collect() 1119 } 1120 1121 fn load_required_basket( 1122 config: &RuntimeConfig, 1123 lookup: &str, 1124 operation_id: &str, 1125 ) -> Result<LoadedBasket, OperationAdapterError> { 1126 load_basket_optional(config, lookup)?.ok_or_else(|| { 1127 invalid_input( 1128 operation_id, 1129 format!("basket `{lookup}` was not found; run `radroots basket create` first"), 1130 ) 1131 }) 1132 } 1133 1134 fn load_basket_optional( 1135 config: &RuntimeConfig, 1136 lookup: &str, 1137 ) -> Result<Option<LoadedBasket>, OperationAdapterError> { 1138 let path = basket_lookup_path(config, lookup); 1139 if !path.exists() { 1140 return Ok(None); 1141 } 1142 load_basket_path(path.as_path()).map(Some) 1143 } 1144 1145 fn load_basket_path(path: &Path) -> Result<LoadedBasket, OperationAdapterError> { 1146 let contents = fs::read_to_string(path).map_err(|error| { 1147 OperationAdapterError::Runtime(format!("read basket {}: {error}", path.display())) 1148 })?; 1149 let document = serde_json::from_str::<BasketDocument>(contents.as_str()).map_err(|error| { 1150 OperationAdapterError::Runtime(format!("parse basket {}: {error}", path.display())) 1151 })?; 1152 Ok(LoadedBasket { 1153 file: path.to_path_buf(), 1154 document, 1155 }) 1156 } 1157 1158 fn save_basket(path: &Path, document: &BasketDocument) -> Result<(), OperationAdapterError> { 1159 if let Some(parent) = path.parent() { 1160 fs::create_dir_all(parent).map_err(|error| { 1161 OperationAdapterError::Runtime(format!( 1162 "create basket directory {}: {error}", 1163 parent.display() 1164 )) 1165 })?; 1166 } 1167 let contents = serde_json::to_string_pretty(document) 1168 .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?; 1169 fs::write(path, contents).map_err(|error| { 1170 OperationAdapterError::Runtime(format!("write basket {}: {error}", path.display())) 1171 }) 1172 } 1173 1174 fn baskets_dir(config: &RuntimeConfig) -> PathBuf { 1175 config.paths.app_data_root.join(BASKETS_DIR) 1176 } 1177 1178 fn basket_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf { 1179 let candidate = PathBuf::from(lookup); 1180 if candidate.is_absolute() || lookup.contains(std::path::MAIN_SEPARATOR) { 1181 return candidate; 1182 } 1183 let file_name = if lookup.ends_with(".json") { 1184 lookup.to_owned() 1185 } else { 1186 format!("{lookup}.json") 1187 }; 1188 baskets_dir(config).join(file_name) 1189 } 1190 1191 fn touch_basket(document: &mut BasketDocument) { 1192 document.basket.updated_at_unix = now_unix(); 1193 } 1194 1195 fn next_item_id(document: &BasketDocument) -> String { 1196 for index in 1.. { 1197 let candidate = format!("item_{index}"); 1198 if document 1199 .basket 1200 .items 1201 .iter() 1202 .all(|item| item.item_id != candidate) 1203 { 1204 return candidate; 1205 } 1206 } 1207 unreachable!("unbounded item id search should always return") 1208 } 1209 1210 fn next_basket_id() -> String { 1211 let sequence = BASKET_COUNTER.fetch_add(1, Ordering::Relaxed) + 1; 1212 format!("basket_{}_{}", now_unix(), sequence) 1213 } 1214 1215 fn now_unix() -> u64 { 1216 SystemTime::now() 1217 .duration_since(UNIX_EPOCH) 1218 .map(|duration| duration.as_secs()) 1219 .unwrap_or_default() 1220 } 1221 1222 fn required_basket_id<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError> 1223 where 1224 P: OperationRequestPayload + OperationRequestData, 1225 { 1226 string_input(request, "basket_id") 1227 .or_else(|| string_input(request, "key")) 1228 .ok_or_else(|| { 1229 invalid_input( 1230 request.operation_id(), 1231 "missing required `basket_id` input".to_owned(), 1232 ) 1233 }) 1234 } 1235 1236 fn required_string<P>( 1237 request: &OperationRequest<P>, 1238 key: &str, 1239 ) -> Result<String, OperationAdapterError> 1240 where 1241 P: OperationRequestPayload + OperationRequestData, 1242 { 1243 string_input(request, key).ok_or_else(|| { 1244 invalid_input( 1245 request.operation_id(), 1246 format!("missing required `{key}` input"), 1247 ) 1248 }) 1249 } 1250 1251 fn quantity_input<P>(request: &OperationRequest<P>) -> Result<Option<u32>, OperationAdapterError> 1252 where 1253 P: OperationRequestPayload + OperationRequestData, 1254 { 1255 let value = request 1256 .payload 1257 .input() 1258 .get("quantity") 1259 .or_else(|| request.payload.input().get("bin_count")); 1260 let Some(value) = value else { 1261 return Ok(None); 1262 }; 1263 match value { 1264 Value::Number(number) => number 1265 .as_u64() 1266 .and_then(|value| u32::try_from(value).ok()) 1267 .map(Some) 1268 .ok_or_else(|| { 1269 invalid_input( 1270 request.operation_id(), 1271 "`quantity` input must fit in u32".to_owned(), 1272 ) 1273 }), 1274 Value::String(value) => value.parse::<u32>().map(Some).map_err(|error| { 1275 invalid_input( 1276 request.operation_id(), 1277 format!("`quantity` input must be a u32: {error}"), 1278 ) 1279 }), 1280 _ => Err(invalid_input( 1281 request.operation_id(), 1282 "`quantity` input must be a number or string".to_owned(), 1283 )), 1284 } 1285 } 1286 1287 fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> 1288 where 1289 R: OperationResultData, 1290 { 1291 OperationResult::new(R::from_value(value)) 1292 } 1293 1294 fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> 1295 where 1296 P: OperationRequestPayload + OperationRequestData, 1297 { 1298 request 1299 .payload 1300 .input() 1301 .get(key) 1302 .and_then(Value::as_str) 1303 .map(str::to_owned) 1304 } 1305 1306 fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { 1307 OperationAdapterError::InvalidInput { 1308 operation_id: operation_id.to_owned(), 1309 message, 1310 } 1311 } 1312 1313 #[cfg(test)] 1314 mod tests { 1315 use std::path::{Path, PathBuf}; 1316 1317 use radroots_events::RadrootsNostrEvent; 1318 use radroots_events::ids::RadrootsListingAddress; 1319 use radroots_events::kinds::{KIND_FARM, KIND_LISTING}; 1320 use radroots_replica_sync::{RadrootsReplicaIngestOutcome, radroots_replica_ingest_event}; 1321 use radroots_runtime_paths::RadrootsMigrationReport; 1322 use radroots_secret_vault::RadrootsSecretBackend; 1323 use radroots_sql_core::{SqlExecutor, SqliteExecutor}; 1324 use serde_json::{Map, Value, json}; 1325 use tempfile::tempdir; 1326 1327 use super::BasketOperationService; 1328 use crate::ops::{ 1329 BasketAdjustmentAddRequest, BasketAdjustmentRemoveRequest, BasketCreateRequest, 1330 BasketGetRequest, BasketItemAddRequest, BasketItemRemoveRequest, BasketItemUpdateRequest, 1331 BasketListRequest, BasketQuoteCreateRequest, BasketValidateRequest, OperationAdapter, 1332 OperationContext, OperationData, OperationRequest, 1333 }; 1334 use crate::runtime::account; 1335 use crate::runtime::config::{ 1336 AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, 1337 LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, 1338 PathsConfig, PublishConfig, PublishTransport, PublishTransportSource, RelayConfig, 1339 RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, 1340 SignerConfig, Verbosity, 1341 }; 1342 1343 const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; 1344 1345 #[test] 1346 fn basket_service_creates_gets_and_lists_local_baskets() { 1347 let dir = tempdir().expect("tempdir"); 1348 let config = sample_config(dir.path()); 1349 let service = OperationAdapter::new(BasketOperationService::new(&config)); 1350 let create = OperationRequest::new( 1351 OperationContext::default(), 1352 BasketCreateRequest::from_data(data(&[("basket_id", "basket_test")])), 1353 ) 1354 .expect("basket create request"); 1355 let create_envelope = service 1356 .execute(create) 1357 .expect("basket create result") 1358 .to_envelope(OperationContext::default().envelope_context("req_basket_create")) 1359 .expect("basket create envelope"); 1360 assert_eq!(create_envelope.operation_id, "basket.create"); 1361 assert_eq!(create_envelope.result["basket_id"], "basket_test"); 1362 assert_eq!(create_envelope.result["item_count"], 0); 1363 1364 let get = OperationRequest::new( 1365 OperationContext::default(), 1366 BasketGetRequest::from_data(data(&[("basket_id", "basket_test")])), 1367 ) 1368 .expect("basket get request"); 1369 let get_envelope = service 1370 .execute(get) 1371 .expect("basket get result") 1372 .to_envelope(OperationContext::default().envelope_context("req_basket_get")) 1373 .expect("basket get envelope"); 1374 assert_eq!(get_envelope.operation_id, "basket.get"); 1375 assert_eq!(get_envelope.result["state"], "ready"); 1376 1377 let list = OperationRequest::new(OperationContext::default(), BasketListRequest::default()) 1378 .expect("basket list request"); 1379 let list_envelope = service 1380 .execute(list) 1381 .expect("basket list result") 1382 .to_envelope(OperationContext::default().envelope_context("req_basket_list")) 1383 .expect("basket list envelope"); 1384 assert_eq!(list_envelope.operation_id, "basket.list"); 1385 assert_eq!(list_envelope.result["count"], 1); 1386 } 1387 1388 #[test] 1389 fn basket_service_mutates_items_and_validates_readiness() { 1390 let dir = tempdir().expect("tempdir"); 1391 let config = sample_config(dir.path()); 1392 let service = OperationAdapter::new(BasketOperationService::new(&config)); 1393 create_basket(&service, "basket_items"); 1394 1395 let add = OperationRequest::new( 1396 OperationContext::default(), 1397 BasketItemAddRequest::from_data(data(&[ 1398 ("basket_id", "basket_items"), 1399 ("listing_addr", LISTING_ADDR), 1400 ("bin_id", "bin-1"), 1401 ("quantity", "2"), 1402 ])), 1403 ) 1404 .expect("basket item add request"); 1405 let add_envelope = service 1406 .execute(add) 1407 .expect("basket item add result") 1408 .to_envelope(OperationContext::default().envelope_context("req_basket_add")) 1409 .expect("basket item add envelope"); 1410 assert_eq!(add_envelope.operation_id, "basket.item.add"); 1411 assert_eq!(add_envelope.result["item_count"], 1); 1412 1413 let update = OperationRequest::new( 1414 OperationContext::default(), 1415 BasketItemUpdateRequest::from_data(data(&[ 1416 ("basket_id", "basket_items"), 1417 ("item_id", "item_1"), 1418 ("quantity", "3"), 1419 ])), 1420 ) 1421 .expect("basket item update request"); 1422 let update_envelope = service 1423 .execute(update) 1424 .expect("basket item update result") 1425 .to_envelope(OperationContext::default().envelope_context("req_basket_update")) 1426 .expect("basket item update envelope"); 1427 assert_eq!(update_envelope.operation_id, "basket.item.update"); 1428 assert_eq!(update_envelope.result["items"][0]["quantity"], 3); 1429 1430 let validate = OperationRequest::new( 1431 OperationContext::default(), 1432 BasketValidateRequest::from_data(data(&[("basket_id", "basket_items")])), 1433 ) 1434 .expect("basket validate request"); 1435 let validate_envelope = service 1436 .execute(validate) 1437 .expect("basket validate result") 1438 .to_envelope(OperationContext::default().envelope_context("req_basket_validate")) 1439 .expect("basket validate envelope"); 1440 assert_eq!(validate_envelope.operation_id, "basket.validate"); 1441 assert_eq!(validate_envelope.result["ready_for_quote"], false); 1442 assert_eq!( 1443 validate_envelope.result["issues"][0]["code"], 1444 "basket_market_replica_missing" 1445 ); 1446 1447 let adjustment_add = OperationRequest::new( 1448 OperationContext::default(), 1449 BasketAdjustmentAddRequest::from_data(data(&[ 1450 ("basket_id", "basket_items"), 1451 ("id", "adj_pickup"), 1452 ("effect", "decrease"), 1453 ("amount", "1.00"), 1454 ("currency", "USD"), 1455 ("reason", "pickup"), 1456 ])), 1457 ) 1458 .expect("basket adjustment add request"); 1459 let adjustment_add_envelope = service 1460 .execute(adjustment_add) 1461 .expect("basket adjustment add result") 1462 .to_envelope(OperationContext::default().envelope_context("req_basket_adjust_add")) 1463 .expect("basket adjustment add envelope"); 1464 assert_eq!( 1465 adjustment_add_envelope.operation_id, 1466 "basket.adjustment.add" 1467 ); 1468 assert_eq!(adjustment_add_envelope.result["adjustment_count"], 1); 1469 1470 let adjustment_remove = OperationRequest::new( 1471 OperationContext::default(), 1472 BasketAdjustmentRemoveRequest::from_data(data(&[ 1473 ("basket_id", "basket_items"), 1474 ("id", "adj_pickup"), 1475 ])), 1476 ) 1477 .expect("basket adjustment remove request"); 1478 let adjustment_remove_envelope = service 1479 .execute(adjustment_remove) 1480 .expect("basket adjustment remove result") 1481 .to_envelope(OperationContext::default().envelope_context("req_basket_adjust_remove")) 1482 .expect("basket adjustment remove envelope"); 1483 assert_eq!( 1484 adjustment_remove_envelope.operation_id, 1485 "basket.adjustment.remove" 1486 ); 1487 assert_eq!(adjustment_remove_envelope.result["adjustment_count"], 0); 1488 1489 let remove = OperationRequest::new( 1490 OperationContext::default(), 1491 BasketItemRemoveRequest::from_data(data(&[ 1492 ("basket_id", "basket_items"), 1493 ("item_id", "item_1"), 1494 ])), 1495 ) 1496 .expect("basket item remove request"); 1497 let remove_envelope = service 1498 .execute(remove) 1499 .expect("basket item remove result") 1500 .to_envelope(OperationContext::default().envelope_context("req_basket_remove")) 1501 .expect("basket item remove envelope"); 1502 assert_eq!(remove_envelope.operation_id, "basket.item.remove"); 1503 assert_eq!(remove_envelope.result["item_count"], 0); 1504 assert_eq!(remove_envelope.result["ready_for_quote"], false); 1505 } 1506 1507 #[test] 1508 fn basket_quote_create_materializes_order_draft() { 1509 let dir = tempdir().expect("tempdir"); 1510 let config = sample_config(dir.path()); 1511 seed_current_listing(&config); 1512 account::create_or_migrate_default_account(&config).expect("create buyer account"); 1513 let service = OperationAdapter::new(BasketOperationService::new(&config)); 1514 create_basket(&service, "basket_quote"); 1515 add_listing_item(&service, "basket_quote"); 1516 1517 let quote = OperationRequest::new( 1518 OperationContext::default(), 1519 BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_quote")])), 1520 ) 1521 .expect("basket quote request"); 1522 let envelope = service 1523 .execute(quote) 1524 .expect("basket quote result") 1525 .to_envelope(OperationContext::default().envelope_context("req_basket_quote")) 1526 .expect("basket quote envelope"); 1527 1528 assert_eq!(envelope.operation_id, "basket.quote.create"); 1529 assert_eq!(envelope.result["state"], "quoted"); 1530 assert!( 1531 envelope.result["quote"]["order_id"] 1532 .as_str() 1533 .unwrap() 1534 .starts_with("ord_") 1535 ); 1536 assert!( 1537 envelope.result["order"]["buyer_account_id"] 1538 .as_str() 1539 .expect("buyer account id") 1540 .len() 1541 > 8 1542 ); 1543 assert!( 1544 envelope.result["order"]["buyer_pubkey"] 1545 .as_str() 1546 .expect("buyer pubkey") 1547 .len() 1548 == 64 1549 ); 1550 assert_eq!( 1551 envelope.result["order"]["buyer_actor_source"], 1552 "resolved_account" 1553 ); 1554 let order_file = PathBuf::from(envelope.result["quote"]["order_file"].as_str().unwrap()); 1555 assert!(order_file.exists()); 1556 let draft = std::fs::read_to_string(order_file).expect("read order draft"); 1557 assert!(draft.contains("[buyer_actor]")); 1558 assert!(draft.contains("source = \"resolved_account\"")); 1559 } 1560 1561 #[test] 1562 fn basket_quote_create_dry_run_skips_order_draft() { 1563 let dir = tempdir().expect("tempdir"); 1564 let config = sample_config(dir.path()); 1565 seed_current_listing(&config); 1566 account::create_or_migrate_default_account(&config).expect("create buyer account"); 1567 let service = OperationAdapter::new(BasketOperationService::new(&config)); 1568 create_basket(&service, "basket_dry_run"); 1569 add_listing_item(&service, "basket_dry_run"); 1570 1571 let mut context = OperationContext::default(); 1572 context.dry_run = true; 1573 let quote = OperationRequest::new( 1574 context.clone(), 1575 BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_dry_run")])), 1576 ) 1577 .expect("basket quote request"); 1578 let envelope = service 1579 .execute(quote) 1580 .expect("basket quote dry run") 1581 .to_envelope(context.envelope_context("req_basket_quote")) 1582 .expect("basket quote envelope"); 1583 1584 assert_eq!(envelope.operation_id, "basket.quote.create"); 1585 assert_eq!(envelope.dry_run, true); 1586 assert_eq!(envelope.result["state"], "dry_run"); 1587 assert_eq!(envelope.result["order"]["state"], "dry_run"); 1588 assert_eq!( 1589 envelope.result["order"]["buyer_actor_source"], 1590 "resolved_account" 1591 ); 1592 assert!(!PathBuf::from(envelope.result["order"]["file"].as_str().unwrap()).exists()); 1593 } 1594 1595 #[test] 1596 fn basket_quote_create_requires_resolved_buyer_account() { 1597 let dir = tempdir().expect("tempdir"); 1598 let config = sample_config(dir.path()); 1599 seed_current_listing(&config); 1600 let service = OperationAdapter::new(BasketOperationService::new(&config)); 1601 create_basket(&service, "basket_no_buyer"); 1602 add_listing_item(&service, "basket_no_buyer"); 1603 1604 let quote = OperationRequest::new( 1605 OperationContext::default(), 1606 BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_no_buyer")])), 1607 ) 1608 .expect("basket quote request"); 1609 let error = service.execute(quote).expect_err("missing buyer account"); 1610 1611 let output_error = error.to_output_error(); 1612 assert_eq!(output_error.code, "account_unresolved"); 1613 let detail = output_error.detail.expect("account detail"); 1614 assert_eq!(detail["buyer_actor_source"], "resolved_account"); 1615 assert_eq!(detail["actions"][0], "radroots account create"); 1616 } 1617 1618 #[test] 1619 fn basket_readiness_fails_closed_without_replica_data() { 1620 let dir = tempdir().expect("tempdir"); 1621 let config = sample_config(dir.path()); 1622 let service = OperationAdapter::new(BasketOperationService::new(&config)); 1623 create_basket(&service, "basket_missing_replica"); 1624 let add = add_listing_item(&service, "basket_missing_replica"); 1625 assert_eq!(add.result["ready_for_quote"], false); 1626 assert_eq!( 1627 add.result["issues"][0]["code"], 1628 "basket_market_replica_missing" 1629 ); 1630 1631 let list = OperationRequest::new(OperationContext::default(), BasketListRequest::default()) 1632 .expect("basket list request"); 1633 let list_envelope = service 1634 .execute(list) 1635 .expect("basket list result") 1636 .to_envelope(OperationContext::default().envelope_context("req_basket_list")) 1637 .expect("basket list envelope"); 1638 assert_eq!( 1639 list_envelope.result["baskets"][0]["issues"][0]["code"], 1640 "basket_market_replica_missing" 1641 ); 1642 1643 let validate = OperationRequest::new( 1644 OperationContext::default(), 1645 BasketValidateRequest::from_data(data(&[("basket_id", "basket_missing_replica")])), 1646 ) 1647 .expect("basket validate request"); 1648 let validate_envelope = service 1649 .execute(validate) 1650 .expect("basket validate result") 1651 .to_envelope(OperationContext::default().envelope_context("req_basket_validate")) 1652 .expect("basket validate envelope"); 1653 assert_eq!(validate_envelope.result["state"], "unconfigured"); 1654 assert_eq!( 1655 validate_envelope.result["issues"][0]["code"], 1656 "basket_market_replica_missing" 1657 ); 1658 1659 let quote = OperationRequest::new( 1660 OperationContext::default(), 1661 BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_missing_replica")])), 1662 ) 1663 .expect("basket quote request"); 1664 let quote_envelope = service 1665 .execute(quote) 1666 .expect("basket quote result") 1667 .to_envelope(OperationContext::default().envelope_context("req_basket_quote")) 1668 .expect("basket quote envelope"); 1669 assert_eq!(quote_envelope.result["state"], "unconfigured"); 1670 assert_eq!( 1671 quote_envelope.result["issues"][0]["code"], 1672 "basket_market_replica_missing" 1673 ); 1674 assert!(!config.paths.app_data_root.join("orders/drafts").exists()); 1675 } 1676 1677 #[test] 1678 fn basket_readiness_fails_closed_for_unresolved_listing() { 1679 let dir = tempdir().expect("tempdir"); 1680 let config = sample_config(dir.path()); 1681 crate::runtime::store::init(&config).expect("store init"); 1682 let service = OperationAdapter::new(BasketOperationService::new(&config)); 1683 create_basket(&service, "basket_unresolved"); 1684 let add = add_listing_item(&service, "basket_unresolved"); 1685 assert_eq!(add.result["ready_for_quote"], false); 1686 assert_eq!( 1687 add.result["issues"][0]["code"], 1688 "basket_item_listing_unresolved" 1689 ); 1690 1691 let quote = OperationRequest::new( 1692 OperationContext::default(), 1693 BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_unresolved")])), 1694 ) 1695 .expect("basket quote request"); 1696 let quote_envelope = service 1697 .execute(quote) 1698 .expect("basket quote result") 1699 .to_envelope(OperationContext::default().envelope_context("req_basket_quote")) 1700 .expect("basket quote envelope"); 1701 assert_eq!(quote_envelope.result["state"], "unconfigured"); 1702 assert_eq!( 1703 quote_envelope.result["issues"][0]["code"], 1704 "basket_item_listing_unresolved" 1705 ); 1706 assert!(!config.paths.app_data_root.join("orders/drafts").exists()); 1707 } 1708 1709 #[test] 1710 fn basket_readiness_fails_closed_for_ambiguous_listing() { 1711 let dir = tempdir().expect("tempdir"); 1712 let config = sample_config(dir.path()); 1713 seed_current_listing(&config); 1714 duplicate_current_listing_row(&config); 1715 let service = OperationAdapter::new(BasketOperationService::new(&config)); 1716 create_basket(&service, "basket_ambiguous"); 1717 let add = add_listing_item(&service, "basket_ambiguous"); 1718 assert_eq!(add.result["ready_for_quote"], false); 1719 assert_eq!( 1720 add.result["issues"][0]["code"], 1721 "basket_item_listing_ambiguous" 1722 ); 1723 1724 let quote = OperationRequest::new( 1725 OperationContext::default(), 1726 BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_ambiguous")])), 1727 ) 1728 .expect("basket quote request"); 1729 let quote_envelope = service 1730 .execute(quote) 1731 .expect("basket quote result") 1732 .to_envelope(OperationContext::default().envelope_context("req_basket_quote")) 1733 .expect("basket quote envelope"); 1734 assert_eq!(quote_envelope.result["state"], "unconfigured"); 1735 assert_eq!( 1736 quote_envelope.result["issues"][0]["code"], 1737 "basket_item_listing_ambiguous" 1738 ); 1739 assert!(!config.paths.app_data_root.join("orders/drafts").exists()); 1740 } 1741 1742 fn create_basket(service: &OperationAdapter<BasketOperationService<'_>>, basket_id: &str) { 1743 let request = OperationRequest::new( 1744 OperationContext::default(), 1745 BasketCreateRequest::from_data(data(&[("basket_id", basket_id)])), 1746 ) 1747 .expect("basket create request"); 1748 service.execute(request).expect("basket create result"); 1749 } 1750 1751 fn add_listing_item( 1752 service: &OperationAdapter<BasketOperationService<'_>>, 1753 basket_id: &str, 1754 ) -> crate::out::envelope::OutputEnvelope { 1755 let request = OperationRequest::new( 1756 OperationContext::default(), 1757 BasketItemAddRequest::from_data(data(&[ 1758 ("basket_id", basket_id), 1759 ("listing_addr", LISTING_ADDR), 1760 ("bin_id", "bin-1"), 1761 ("quantity", "1"), 1762 ])), 1763 ) 1764 .expect("basket item add request"); 1765 service 1766 .execute(request) 1767 .expect("basket item add result") 1768 .to_envelope(OperationContext::default().envelope_context("req_basket_add")) 1769 .expect("basket item add envelope") 1770 } 1771 1772 fn seed_current_listing(config: &RuntimeConfig) { 1773 crate::runtime::store::init(config).expect("store init"); 1774 let (seller_pubkey, listing_id) = listing_addr_parts(LISTING_ADDR); 1775 let event = RadrootsNostrEvent { 1776 id: "2".repeat(64), 1777 author: seller_pubkey.clone(), 1778 created_at: 1, 1779 kind: KIND_LISTING, 1780 tags: vec![ 1781 vec!["d".to_owned(), listing_id], 1782 vec![ 1783 "a".to_owned(), 1784 format!( 1785 "{}:{}:{}", 1786 KIND_FARM, seller_pubkey, "AAAAAAAAAAAAAAAAAAAAAA" 1787 ), 1788 ], 1789 vec!["p".to_owned(), seller_pubkey], 1790 vec!["key".to_owned(), "pasture-eggs".to_owned()], 1791 vec!["title".to_owned(), "Market Eggs".to_owned()], 1792 vec!["category".to_owned(), "eggs".to_owned()], 1793 vec!["summary".to_owned(), "Pasture-raised eggs".to_owned()], 1794 vec!["process".to_owned(), "washed".to_owned()], 1795 vec!["lot".to_owned(), "lot-a".to_owned()], 1796 vec!["profile".to_owned(), "dozen".to_owned()], 1797 vec!["year".to_owned(), "2026".to_owned()], 1798 vec!["radroots:primary_bin".to_owned(), "bin-1".to_owned()], 1799 vec![ 1800 "radroots:bin".to_owned(), 1801 "bin-1".to_owned(), 1802 "12".to_owned(), 1803 "each".to_owned(), 1804 "12".to_owned(), 1805 "each".to_owned(), 1806 "dozen".to_owned(), 1807 ], 1808 vec![ 1809 "radroots:price".to_owned(), 1810 "bin-1".to_owned(), 1811 "6".to_owned(), 1812 "USD".to_owned(), 1813 "1".to_owned(), 1814 "each".to_owned(), 1815 "6".to_owned(), 1816 "each".to_owned(), 1817 ], 1818 vec!["inventory".to_owned(), "5".to_owned()], 1819 vec!["status".to_owned(), "active".to_owned()], 1820 ], 1821 content: "# Market Eggs".to_owned(), 1822 sig: "f".repeat(128), 1823 }; 1824 let executor = SqliteExecutor::open(&config.local.replica_db_path).expect("open replica"); 1825 assert_eq!( 1826 radroots_replica_ingest_event(&executor, &event).expect("ingest listing"), 1827 RadrootsReplicaIngestOutcome::Applied 1828 ); 1829 } 1830 1831 fn listing_addr_parts(listing_addr: &str) -> (String, String) { 1832 let parsed = RadrootsListingAddress::parse(listing_addr).expect("listing addr"); 1833 let (_, rest) = parsed.as_str().split_once(':').expect("listing addr kind"); 1834 let (seller_pubkey, listing_id) = rest.split_once(':').expect("listing addr parts"); 1835 (seller_pubkey.to_owned(), listing_id.to_owned()) 1836 } 1837 1838 fn duplicate_current_listing_row(config: &RuntimeConfig) { 1839 let executor = SqliteExecutor::open(&config.local.replica_db_path).expect("open replica"); 1840 let params = json!(["33333333-3333-3333-3333-333333333333", LISTING_ADDR]).to_string(); 1841 executor 1842 .exec( 1843 "INSERT INTO trade_product (id, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes, listing_addr, primary_bin_id, qty_amt_exact, price_amt_exact, price_qty_amt_exact, verified_primary_bin_id) SELECT ?, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes, listing_addr, primary_bin_id, qty_amt_exact, price_amt_exact, price_qty_amt_exact, verified_primary_bin_id FROM trade_product WHERE listing_addr = ?;", 1844 params.as_str(), 1845 ) 1846 .expect("duplicate listing row"); 1847 } 1848 1849 fn sample_config(root: &Path) -> RuntimeConfig { 1850 let data = root.join("data"); 1851 let logs = root.join("logs"); 1852 let secrets = root.join("secrets"); 1853 RuntimeConfig { 1854 output: OutputConfig { 1855 format: OutputFormat::Human, 1856 verbosity: Verbosity::Normal, 1857 color: true, 1858 dry_run: false, 1859 }, 1860 interaction: InteractionConfig { 1861 input_enabled: true, 1862 assume_yes: false, 1863 stdin_tty: false, 1864 stdout_tty: false, 1865 prompts_allowed: false, 1866 confirmations_allowed: false, 1867 }, 1868 paths: PathsConfig { 1869 profile: "interactive_user".into(), 1870 profile_source: "test".into(), 1871 allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], 1872 root_source: "test".into(), 1873 repo_local_root: None, 1874 repo_local_root_source: None, 1875 subordinate_path_override_source: "runtime_config".into(), 1876 app_namespace: "apps/cli".into(), 1877 shared_accounts_namespace: "shared/accounts".into(), 1878 shared_identities_namespace: "shared/identities".into(), 1879 app_config_path: root.join("config/apps/cli/config.toml"), 1880 workspace_config_path: None, 1881 app_data_root: data.join("apps/cli"), 1882 app_logs_root: logs.join("apps/cli"), 1883 shared_accounts_data_root: data.join("shared/accounts"), 1884 shared_accounts_secrets_root: secrets.join("shared/accounts"), 1885 default_identity_path: secrets.join("shared/identities/default.json"), 1886 }, 1887 migration: MigrationConfig { 1888 report: RadrootsMigrationReport::empty(), 1889 }, 1890 logging: LoggingConfig { 1891 filter: "info".into(), 1892 directory: None, 1893 stdout: false, 1894 }, 1895 account: AccountConfig { 1896 selector: None, 1897 store_path: data.join("shared/accounts/store.json"), 1898 secrets_dir: secrets.join("shared/accounts"), 1899 secret_backend: RadrootsSecretBackend::EncryptedFile, 1900 secret_fallback: None, 1901 }, 1902 account_secret_contract: AccountSecretContractConfig { 1903 default_backend: "host_vault".into(), 1904 default_fallback: Some("encrypted_file".into()), 1905 allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], 1906 host_vault_policy: Some("desktop".into()), 1907 uses_protected_store: true, 1908 }, 1909 identity: IdentityConfig { 1910 path: secrets.join("shared/identities/default.json"), 1911 }, 1912 signer: SignerConfig { 1913 backend: SignerBackend::Local, 1914 }, 1915 publish: PublishConfig { 1916 transport: PublishTransport::DirectNostrRelay, 1917 source: PublishTransportSource::Defaults, 1918 radrootsd_proxy: crate::runtime::config::RadrootsdProxyConfig::default(), 1919 }, 1920 relay: RelayConfig { 1921 urls: Vec::new(), 1922 publish_policy: RelayPublishPolicy::Any, 1923 source: RelayConfigSource::Defaults, 1924 }, 1925 local: LocalConfig { 1926 root: data.join("apps/cli/replica"), 1927 replica_db_path: data.join("apps/cli/replica/replica.sqlite"), 1928 backups_dir: data.join("apps/cli/replica/backups"), 1929 exports_dir: data.join("apps/cli/replica/exports"), 1930 }, 1931 myc: MycConfig { 1932 executable: PathBuf::from("myc"), 1933 status_timeout_ms: 2_000, 1934 }, 1935 hyf: HyfConfig { 1936 enabled: false, 1937 executable: PathBuf::from("hyfd"), 1938 }, 1939 rpc: RpcConfig { 1940 url: "http://127.0.0.1:7070".into(), 1941 }, 1942 rhi: crate::runtime::config::RhiConfig { 1943 trusted_worker_pubkeys: Vec::new(), 1944 }, 1945 capability_bindings: Vec::new(), 1946 } 1947 } 1948 1949 fn data(entries: &[(&str, &str)]) -> OperationData { 1950 entries 1951 .iter() 1952 .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) 1953 .collect::<Map<String, Value>>() 1954 } 1955 }