buyer.rs (159072B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 3 use radroots_app_view::{ 4 BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, 5 BuyerContext, BuyerListingRow, BuyerListingsProjection, BuyerOrderDetailProjection, 6 BuyerOrderReviewDisabledReason, BuyerOrderReviewDraft, BuyerOrderReviewProjection, 7 BuyerOrderReviewSummaryProjection, BuyerOrderStatus, BuyerOrdersListRow, BuyerOrdersProjection, 8 BuyerProductDetailProjection, FarmId, FarmOrderMethod, FulfillmentWindowId, OrderDetailItemRow, 9 OrderId, OrderStatus, ProductAvailabilityState, ProductAvailabilitySummary, ProductId, 10 ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary, 11 RepeatDemandEligibility, RepeatDemandHandoffProjection, 12 }; 13 use rusqlite::{Connection, OptionalExtension, params, params_from_iter}; 14 use serde_json::Value; 15 16 use super::{ 17 order_detail::{order_detail_economics, order_detail_item_row, order_validation_receipts}, 18 parse_trade_revision_status, 19 workflow::{StoredTradeWorkflowSnapshot, trade_workflow_projection_from_storage}, 20 }; 21 use crate::AppSqliteError; 22 23 const BUYER_LOW_STOCK_THRESHOLD: u32 = 3; 24 25 #[derive(Clone, Debug, Eq, PartialEq)] 26 pub enum BuyerRepeatDemandApplyOutcome { 27 Applied, 28 ConfirmationRequired(BuyerCartReplaceConfirmationProjection), 29 Unavailable, 30 } 31 32 #[derive(Clone, Debug, Default, Eq, PartialEq)] 33 pub struct SelectedBuyerOrderScope { 34 context_keys: Vec<String>, 35 } 36 37 impl SelectedBuyerOrderScope { 38 pub fn from_buyer_context(context: &BuyerContext) -> Self { 39 Self::from_context_keys([context.storage_key()]) 40 } 41 42 pub fn for_selected_account( 43 account_id: impl AsRef<str>, 44 selected_account_pubkey: Option<&str>, 45 ) -> Self { 46 let mut context_keys = Vec::new(); 47 let account_id = account_id.as_ref().trim(); 48 if !account_id.is_empty() { 49 context_keys.push(format!("account:{account_id}")); 50 } 51 if let Some(pubkey) = selected_account_pubkey.and_then(normalized_nostr_pubkey) { 52 context_keys.push(format!("nostr:{pubkey}")); 53 } 54 Self::from_context_keys(context_keys) 55 } 56 57 fn from_context_keys(context_keys: impl IntoIterator<Item = String>) -> Self { 58 let mut unique = BTreeSet::new(); 59 let context_keys = context_keys 60 .into_iter() 61 .map(|key| key.trim().to_owned()) 62 .filter(|key| !key.is_empty()) 63 .filter(|key| unique.insert(key.clone())) 64 .collect(); 65 Self { context_keys } 66 } 67 68 fn context_keys(&self) -> &[String] { 69 self.context_keys.as_slice() 70 } 71 } 72 73 fn normalized_nostr_pubkey(pubkey: &str) -> Option<String> { 74 let pubkey = pubkey.trim().to_ascii_lowercase(); 75 if pubkey.len() == 64 && pubkey.bytes().all(|byte| byte.is_ascii_hexdigit()) { 76 Some(pubkey) 77 } else { 78 None 79 } 80 } 81 82 #[derive(Clone, Debug, Eq, PartialEq)] 83 pub struct BuyerOrderLocalEventExport { 84 pub order_id: OrderId, 85 pub farm_id: FarmId, 86 pub farm_display_name: String, 87 pub order_number: String, 88 pub status: String, 89 pub buyer_context_key: String, 90 pub buyer_name: String, 91 pub buyer_email: String, 92 pub buyer_phone: String, 93 pub buyer_order_note: String, 94 pub updated_at: String, 95 pub fulfillment_window_id: Option<FulfillmentWindowId>, 96 pub fulfillment_window_label: Option<String>, 97 pub fulfillment_starts_at: Option<String>, 98 pub fulfillment_ends_at: Option<String>, 99 pub lines: Vec<BuyerOrderLocalEventLine>, 100 } 101 102 #[derive(Clone, Debug, Eq, PartialEq)] 103 pub struct BuyerOrderLocalEventLine { 104 pub product_id: ProductId, 105 pub title: String, 106 pub quantity: u32, 107 pub quantity_unit_label: String, 108 pub quantity_display: String, 109 pub unit_price_minor_units: Option<u32>, 110 pub price_currency: String, 111 pub listing_bin_id: Option<String>, 112 pub farm_key: Option<String>, 113 pub listing_addr: Option<String>, 114 pub listing_event_id: Option<String>, 115 pub listing_relays: Vec<String>, 116 pub seller_pubkey: Option<String>, 117 } 118 119 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 120 pub enum BuyerOrderCoordinationState { 121 Pending, 122 Synced, 123 Failed, 124 } 125 126 impl BuyerOrderCoordinationState { 127 fn from_storage_key(field: &'static str, value: String) -> Result<Self, AppSqliteError> { 128 match value.as_str() { 129 "pending" => Ok(Self::Pending), 130 "synced" => Ok(Self::Synced), 131 "failed" => Ok(Self::Failed), 132 _ => Err(AppSqliteError::DecodeEnum { field, value }), 133 } 134 } 135 } 136 137 #[derive(Clone, Debug, Eq, PartialEq)] 138 pub struct BuyerOrderCoordinationRecord { 139 pub order_id: OrderId, 140 pub buyer_context_key: String, 141 pub record_id: Option<String>, 142 pub state: BuyerOrderCoordinationState, 143 pub payload_json: Option<String>, 144 pub attempt_count: u32, 145 pub last_error_message: Option<String>, 146 } 147 148 pub struct AppBuyerRepository<'a> { 149 connection: &'a Connection, 150 } 151 152 impl<'a> AppBuyerRepository<'a> { 153 pub const fn new(connection: &'a Connection) -> Self { 154 Self { connection } 155 } 156 157 pub fn load_buyer_listings( 158 &self, 159 search_query: &str, 160 fulfillment_methods: &BTreeSet<FarmOrderMethod>, 161 ) -> Result<BuyerListingsProjection, AppSqliteError> { 162 let now_utc = self.current_utc_timestamp()?; 163 let normalized_search = normalize_search_query(search_query); 164 let mut records = self.load_listing_records()?; 165 166 records.retain(|record| { 167 record.is_buyer_visible(&now_utc) 168 && record.matches_search(normalized_search.as_deref()) 169 && record.matches_fulfillment_methods(fulfillment_methods) 170 }); 171 sort_listing_records(&mut records, &now_utc); 172 173 Ok(BuyerListingsProjection { 174 rows: records 175 .into_iter() 176 .map(|record| record.into_listing_row(&now_utc)) 177 .collect::<Result<Vec<_>, _>>()?, 178 }) 179 } 180 181 pub fn load_buyer_product_detail( 182 &self, 183 product_id: ProductId, 184 ) -> Result<Option<BuyerProductDetailProjection>, AppSqliteError> { 185 let now_utc = self.current_utc_timestamp()?; 186 187 self.load_listing_record_by_id(product_id)? 188 .filter(|record| record.is_buyer_visible(&now_utc)) 189 .map(|record| { 190 Ok(BuyerProductDetailProjection { 191 detail_text: record.detail_text(), 192 listing: record.into_listing_row(&now_utc)?, 193 selected_quantity: 1, 194 }) 195 }) 196 .transpose() 197 } 198 199 pub fn load_buyer_cart( 200 &self, 201 context: &BuyerContext, 202 ) -> Result<BuyerCartProjection, AppSqliteError> { 203 let context_key = context.storage_key(); 204 let header = self.load_cart_header(&context_key)?; 205 let line_records = self.load_cart_line_records(&context_key)?; 206 207 self.build_cart_projection(header, line_records) 208 } 209 210 pub fn replace_buyer_cart( 211 &self, 212 context: &BuyerContext, 213 cart: &BuyerCartProjection, 214 ) -> Result<(), AppSqliteError> { 215 validate_cart_projection(cart)?; 216 let context_key = context.storage_key(); 217 let farm_id = if cart.lines.is_empty() { 218 None 219 } else { 220 cart.farm_id 221 }; 222 223 self.connection 224 .execute( 225 "insert into buyer_carts ( 226 buyer_context_key, 227 farm_id, 228 updated_at 229 ) values (?1, ?2, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 230 on conflict(buyer_context_key) do update set 231 farm_id = excluded.farm_id, 232 updated_at = excluded.updated_at", 233 params![context_key.as_str(), farm_id.map(|id| id.to_string())], 234 ) 235 .map_err(|source| AppSqliteError::Query { 236 operation: "save buyer cart header", 237 source, 238 })?; 239 self.connection 240 .execute( 241 "delete from buyer_cart_lines where buyer_context_key = ?1", 242 params![context_key.as_str()], 243 ) 244 .map_err(|source| AppSqliteError::Query { 245 operation: "clear buyer cart lines", 246 source, 247 })?; 248 249 for line in &cart.lines { 250 let snapshot = self.load_buyer_cart_line_snapshot(line.product_id)?; 251 let listing_relays_json = encode_listing_relays(&snapshot.listing_relays)?; 252 self.connection 253 .execute( 254 "insert into buyer_cart_lines ( 255 buyer_context_key, 256 product_id, 257 quantity, 258 listing_bin_id, 259 quantity_unit_label, 260 unit_price_minor_units, 261 price_currency, 262 farm_key, 263 listing_addr, 264 listing_event_id, 265 listing_relays_json, 266 seller_pubkey, 267 updated_at 268 ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))", 269 params![ 270 context_key.as_str(), 271 line.product_id.to_string(), 272 i64::from(line.quantity), 273 snapshot.listing_bin_id.as_deref(), 274 line.unit_price.unit_label.as_str(), 275 line.unit_price.amount_minor_units, 276 normalize_currency_code(&line.unit_price.currency_code), 277 snapshot.farm_key.as_deref(), 278 snapshot.listing_addr.as_deref(), 279 snapshot.listing_event_id.as_deref(), 280 listing_relays_json.as_deref(), 281 snapshot.seller_pubkey.as_deref(), 282 ], 283 ) 284 .map_err(|source| AppSqliteError::Query { 285 operation: "save buyer cart line", 286 source, 287 })?; 288 } 289 290 Ok(()) 291 } 292 293 pub fn clear_buyer_cart(&self, context: &BuyerContext) -> Result<(), AppSqliteError> { 294 let context_key = context.storage_key(); 295 296 self.connection 297 .execute( 298 "delete from buyer_cart_lines where buyer_context_key = ?1", 299 params![context_key.as_str()], 300 ) 301 .map_err(|source| AppSqliteError::Query { 302 operation: "delete buyer cart lines", 303 source, 304 })?; 305 self.connection 306 .execute( 307 "update buyer_carts 308 set 309 farm_id = null, 310 buyer_order_note = '', 311 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 312 where buyer_context_key = ?1", 313 params![context_key.as_str()], 314 ) 315 .map_err(|source| AppSqliteError::Query { 316 operation: "clear buyer cart header", 317 source, 318 })?; 319 320 Ok(()) 321 } 322 323 pub fn load_buyer_order_review( 324 &self, 325 context: &BuyerContext, 326 ) -> Result<BuyerOrderReviewProjection, AppSqliteError> { 327 let context_key = context.storage_key(); 328 let header = self.load_cart_header(&context_key)?; 329 let cart = 330 self.build_cart_projection(header.clone(), self.load_cart_line_records(&context_key)?)?; 331 let draft = header 332 .map(BuyerCartHeader::into_order_review_draft) 333 .unwrap_or_default(); 334 let fulfillment_summary = shared_fulfillment_summary(&cart.lines); 335 let place_order_disabled_reason = buyer_order_review_disabled_reason( 336 context, 337 &cart, 338 fulfillment_summary.as_ref(), 339 &draft, 340 ); 341 342 Ok(BuyerOrderReviewProjection { 343 draft: draft.clone(), 344 summary: BuyerOrderReviewSummaryProjection { 345 farm_display_name: cart.farm_display_name.clone(), 346 fulfillment_summary: fulfillment_summary.clone(), 347 line_count: cart.lines.len() as u32, 348 subtotal_minor_units: cart.subtotal_minor_units, 349 currency_code: cart.currency_code.clone(), 350 }, 351 can_place_order: place_order_disabled_reason.is_none(), 352 place_order_disabled_reason, 353 }) 354 } 355 356 pub fn save_buyer_order_review_draft( 357 &self, 358 context: &BuyerContext, 359 draft: &BuyerOrderReviewDraft, 360 ) -> Result<(), AppSqliteError> { 361 let context_key = context.storage_key(); 362 363 self.connection 364 .execute( 365 "insert into buyer_carts ( 366 buyer_context_key, 367 farm_id, 368 updated_at 369 ) values (?1, null, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 370 on conflict(buyer_context_key) do nothing", 371 params![context_key.as_str()], 372 ) 373 .map_err(|source| AppSqliteError::Query { 374 operation: "ensure buyer order review header", 375 source, 376 })?; 377 self.connection 378 .execute( 379 "update buyer_carts 380 set 381 buyer_name = ?2, 382 buyer_email = ?3, 383 buyer_phone = ?4, 384 buyer_order_note = ?5, 385 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 386 where buyer_context_key = ?1", 387 params![ 388 context_key.as_str(), 389 draft.name.trim(), 390 draft.email.trim(), 391 draft.phone.trim(), 392 draft.order_note.trim(), 393 ], 394 ) 395 .map_err(|source| AppSqliteError::Query { 396 operation: "save buyer order review draft", 397 source, 398 })?; 399 400 Ok(()) 401 } 402 403 pub fn place_buyer_order(&self, context: &BuyerContext) -> Result<OrderId, AppSqliteError> { 404 let context_key = context.storage_key(); 405 let header = 406 self.load_cart_header(&context_key)? 407 .ok_or(AppSqliteError::InvalidProjection { 408 reason: "buyer cart header is missing", 409 })?; 410 let line_records = self.load_cart_line_records(&context_key)?; 411 let cart = self.build_cart_projection(Some(header.clone()), line_records.clone())?; 412 let order_review = self.load_buyer_order_review(context)?; 413 414 if let Some(disabled_reason) = order_review.place_order_disabled_reason { 415 return Err(AppSqliteError::InvalidProjection { 416 reason: buyer_order_review_disabled_error(disabled_reason), 417 }); 418 } 419 420 let farm_id = cart.farm_id.ok_or(AppSqliteError::InvalidProjection { 421 reason: "buyer cart farm is missing", 422 })?; 423 let fulfillment_window_id = shared_fulfillment_window_id(&line_records)?; 424 let order_id = OrderId::new(); 425 let order_number = self.next_order_number(farm_id)?; 426 427 self.connection 428 .execute_batch("begin immediate transaction") 429 .map_err(|source| AppSqliteError::Query { 430 operation: "begin buyer order review write", 431 source, 432 })?; 433 434 let result = (|| { 435 self.connection 436 .execute( 437 "insert into orders ( 438 id, 439 farm_id, 440 fulfillment_window_id, 441 order_number, 442 customer_display_name, 443 status, 444 updated_at, 445 buyer_context_key, 446 buyer_email, 447 buyer_phone, 448 buyer_order_note 449 ) values ( 450 ?1, 451 ?2, 452 ?3, 453 ?4, 454 ?5, 455 'needs_action', 456 strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 457 ?6, 458 ?7, 459 ?8, 460 ?9 461 )", 462 params![ 463 order_id.to_string(), 464 farm_id.to_string(), 465 fulfillment_window_id.map(|id| id.to_string()), 466 order_number, 467 order_review.draft.name.trim(), 468 context_key.as_str(), 469 order_review.draft.email.trim(), 470 order_review.draft.phone.trim(), 471 order_review.draft.order_note.trim(), 472 ], 473 ) 474 .map_err(|source| AppSqliteError::Query { 475 operation: "insert buyer order", 476 source, 477 })?; 478 479 for (index, line) in line_records.iter().enumerate() { 480 let listing_relays_json = encode_listing_relays(&line.listing.listing_relays)?; 481 self.connection 482 .execute( 483 "insert into order_lines ( 484 id, 485 order_id, 486 title, 487 quantity_value, 488 quantity_unit_label, 489 quantity_display, 490 listing_bin_id, 491 unit_price_minor_units, 492 price_currency, 493 farm_key, 494 listing_addr, 495 listing_event_id, 496 listing_relays_json, 497 seller_pubkey, 498 sort_index 499 ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", 500 params![ 501 format!("{}:{}", order_id, line.listing.product_id), 502 order_id.to_string(), 503 line.listing.title, 504 i64::from(line.quantity), 505 line.listing.unit_label.as_str(), 506 format_quantity_display(line.quantity, &line.listing.unit_label), 507 line.listing.listing_bin_id.as_deref(), 508 line.listing.price_minor_units, 509 normalize_currency_code(&line.listing.price_currency), 510 line.listing.farm_key.as_deref(), 511 line.listing.listing_addr.as_deref(), 512 line.listing.listing_event_id.as_deref(), 513 listing_relays_json.as_deref(), 514 line.listing.seller_pubkey.as_deref(), 515 index as i64, 516 ], 517 ) 518 .map_err(|source| AppSqliteError::Query { 519 operation: "insert buyer order line", 520 source, 521 })?; 522 } 523 524 self.connection 525 .execute( 526 "delete from buyer_cart_lines where buyer_context_key = ?1", 527 params![context_key.as_str()], 528 ) 529 .map_err(|source| AppSqliteError::Query { 530 operation: "clear buyer cart lines after order review", 531 source, 532 })?; 533 self.connection 534 .execute( 535 "update buyer_carts 536 set 537 farm_id = null, 538 buyer_order_note = '', 539 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 540 where buyer_context_key = ?1", 541 params![context_key.as_str()], 542 ) 543 .map_err(|source| AppSqliteError::Query { 544 operation: "reset buyer cart header after order review", 545 source, 546 })?; 547 self.insert_pending_buyer_order_coordination(context_key.as_str(), order_id)?; 548 549 Ok(order_id) 550 })(); 551 552 match result { 553 Ok(order_id) => { 554 self.connection.execute_batch("commit").map_err(|source| { 555 AppSqliteError::Query { 556 operation: "commit buyer order review write", 557 source, 558 } 559 })?; 560 Ok(order_id) 561 } 562 Err(error) => { 563 let _ = self.connection.execute_batch("rollback"); 564 Err(error) 565 } 566 } 567 } 568 569 pub fn load_buyer_order_coordination_record( 570 &self, 571 context: &BuyerContext, 572 order_id: OrderId, 573 ) -> Result<Option<BuyerOrderCoordinationRecord>, AppSqliteError> { 574 let context_key = context.storage_key(); 575 576 self.connection 577 .query_row( 578 "select 579 order_id, 580 buyer_context_key, 581 record_id, 582 state, 583 payload_json, 584 attempt_count, 585 last_error_message 586 from buyer_order_coordination_records 587 where buyer_context_key = ?1 and order_id = ?2 588 limit 1", 589 params![context_key.as_str(), order_id.to_string()], 590 |row| { 591 Ok(( 592 row.get::<_, String>(0)?, 593 row.get::<_, String>(1)?, 594 row.get::<_, Option<String>>(2)?, 595 row.get::<_, String>(3)?, 596 row.get::<_, Option<String>>(4)?, 597 row.get::<_, i64>(5)?, 598 row.get::<_, Option<String>>(6)?, 599 )) 600 }, 601 ) 602 .optional() 603 .map_err(|source| AppSqliteError::Query { 604 operation: "load buyer order coordination record", 605 source, 606 })? 607 .map(buyer_order_coordination_record_from_row) 608 .transpose() 609 } 610 611 pub fn load_recoverable_buyer_order_coordination_records( 612 &self, 613 context: &BuyerContext, 614 ) -> Result<Vec<BuyerOrderCoordinationRecord>, AppSqliteError> { 615 let context_key = context.storage_key(); 616 let mut statement = self 617 .connection 618 .prepare( 619 "select 620 order_id, 621 buyer_context_key, 622 record_id, 623 state, 624 payload_json, 625 attempt_count, 626 last_error_message 627 from buyer_order_coordination_records 628 where buyer_context_key = ?1 and state in ('pending', 'failed') 629 order by updated_at asc, order_id asc", 630 ) 631 .map_err(|source| AppSqliteError::Query { 632 operation: "prepare recoverable buyer order coordination records", 633 source, 634 })?; 635 let rows = statement 636 .query_map(params![context_key.as_str()], |row| { 637 Ok(( 638 row.get::<_, String>(0)?, 639 row.get::<_, String>(1)?, 640 row.get::<_, Option<String>>(2)?, 641 row.get::<_, String>(3)?, 642 row.get::<_, Option<String>>(4)?, 643 row.get::<_, i64>(5)?, 644 row.get::<_, Option<String>>(6)?, 645 )) 646 }) 647 .map_err(|source| AppSqliteError::Query { 648 operation: "query recoverable buyer order coordination records", 649 source, 650 })?; 651 652 rows.map(|row| { 653 buyer_order_coordination_record_from_row(row.map_err(|source| { 654 AppSqliteError::Query { 655 operation: "read recoverable buyer order coordination record", 656 source, 657 } 658 })?) 659 }) 660 .collect() 661 } 662 663 pub fn buyer_order_coordination_is_synced( 664 &self, 665 context: &BuyerContext, 666 order_id: OrderId, 667 ) -> Result<bool, AppSqliteError> { 668 Ok(self 669 .load_buyer_order_coordination_record(context, order_id)? 670 .is_some_and(|record| record.state == BuyerOrderCoordinationState::Synced)) 671 } 672 673 pub fn prepare_buyer_order_coordination_attempt( 674 &self, 675 context: &BuyerContext, 676 order_id: OrderId, 677 record_id: &str, 678 payload_json: &str, 679 ) -> Result<bool, AppSqliteError> { 680 let context_key = context.storage_key(); 681 let changed = self 682 .connection 683 .execute( 684 "insert into buyer_order_coordination_records ( 685 order_id, 686 buyer_context_key, 687 record_id, 688 state, 689 payload_json, 690 attempt_count, 691 last_error_message, 692 created_at, 693 updated_at, 694 synced_at 695 ) values ( 696 ?1, 697 ?2, 698 ?3, 699 'pending', 700 ?4, 701 1, 702 null, 703 strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 704 strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 705 null 706 ) 707 on conflict(order_id) do update set 708 record_id = excluded.record_id, 709 state = 'pending', 710 payload_json = excluded.payload_json, 711 attempt_count = buyer_order_coordination_records.attempt_count + 1, 712 last_error_message = null, 713 updated_at = excluded.updated_at, 714 synced_at = null 715 where buyer_order_coordination_records.buyer_context_key = excluded.buyer_context_key 716 and buyer_order_coordination_records.state <> 'synced'", 717 params![ 718 order_id.to_string(), 719 context_key.as_str(), 720 record_id, 721 payload_json 722 ], 723 ) 724 .map_err(|source| AppSqliteError::Query { 725 operation: "prepare buyer order coordination attempt", 726 source, 727 })?; 728 729 Ok(changed == 1) 730 } 731 732 pub fn mark_buyer_order_coordination_synced( 733 &self, 734 context: &BuyerContext, 735 order_id: OrderId, 736 ) -> Result<bool, AppSqliteError> { 737 let context_key = context.storage_key(); 738 let changed = self 739 .connection 740 .execute( 741 "update buyer_order_coordination_records 742 set 743 state = 'synced', 744 last_error_message = null, 745 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 746 synced_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 747 where buyer_context_key = ?1 and order_id = ?2", 748 params![context_key.as_str(), order_id.to_string()], 749 ) 750 .map_err(|source| AppSqliteError::Query { 751 operation: "mark buyer order coordination synced", 752 source, 753 })?; 754 755 Ok(changed == 1) 756 } 757 758 pub fn mark_buyer_order_coordination_failed( 759 &self, 760 context: &BuyerContext, 761 order_id: OrderId, 762 error_message: &str, 763 ) -> Result<bool, AppSqliteError> { 764 let context_key = context.storage_key(); 765 let changed = self 766 .connection 767 .execute( 768 "update buyer_order_coordination_records 769 set 770 state = 'failed', 771 last_error_message = ?3, 772 updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 773 synced_at = null 774 where buyer_context_key = ?1 and order_id = ?2", 775 params![context_key.as_str(), order_id.to_string(), error_message], 776 ) 777 .map_err(|source| AppSqliteError::Query { 778 operation: "mark buyer order coordination failed", 779 source, 780 })?; 781 782 Ok(changed == 1) 783 } 784 785 pub fn load_buyer_orders( 786 &self, 787 context: &BuyerContext, 788 ) -> Result<BuyerOrdersProjection, AppSqliteError> { 789 self.load_buyer_orders_for_scope(&SelectedBuyerOrderScope::from_buyer_context(context)) 790 } 791 792 pub fn load_buyer_orders_for_scope( 793 &self, 794 scope: &SelectedBuyerOrderScope, 795 ) -> Result<BuyerOrdersProjection, AppSqliteError> { 796 let now_utc = self.current_utc_timestamp()?; 797 let visible_listings = self.visible_listing_index(&now_utc)?; 798 let context_keys = scope.context_keys(); 799 if context_keys.is_empty() { 800 return Ok(BuyerOrdersProjection::default()); 801 } 802 let placeholders = sql_placeholders(context_keys.len()); 803 let query = format!( 804 "select 805 o.id, 806 o.farm_id, 807 o.order_number, 808 o.status, 809 o.workflow_revision, 810 o.workflow_agreement, 811 o.workflow_inventory, 812 o.workflow_provenance_source, 813 o.workflow_provenance_last_event_id, 814 f.display_name, 815 fw.label, 816 fw.starts_at, 817 fw.ends_at 818 from orders o 819 inner join farms f on f.id = o.farm_id 820 left join fulfillment_windows fw on fw.id = o.fulfillment_window_id 821 where o.buyer_context_key in ({placeholders}) 822 order by o.updated_at desc, o.id desc" 823 ); 824 let mut statement = 825 self.connection 826 .prepare(query.as_str()) 827 .map_err(|source| AppSqliteError::Query { 828 operation: "prepare buyer orders list", 829 source, 830 })?; 831 let rows = statement 832 .query_map(params_from_iter(context_keys.iter()), |row| { 833 Ok(( 834 row.get::<_, String>(0)?, 835 row.get::<_, String>(1)?, 836 row.get::<_, String>(2)?, 837 row.get::<_, String>(3)?, 838 row.get::<_, String>(4)?, 839 row.get::<_, String>(5)?, 840 row.get::<_, String>(6)?, 841 row.get::<_, String>(7)?, 842 row.get::<_, Option<String>>(8)?, 843 row.get::<_, String>(9)?, 844 row.get::<_, Option<String>>(10)?, 845 row.get::<_, Option<String>>(11)?, 846 row.get::<_, Option<String>>(12)?, 847 )) 848 }) 849 .map_err(|source| AppSqliteError::Query { 850 operation: "query buyer orders list", 851 source, 852 })?; 853 let mut orders = Vec::new(); 854 855 for row in rows { 856 let ( 857 order_id, 858 farm_id, 859 order_number, 860 status, 861 workflow_revision, 862 workflow_agreement, 863 workflow_inventory, 864 workflow_provenance_source, 865 workflow_provenance_last_event_id, 866 farm_display_name, 867 fulfillment_label, 868 fulfillment_starts_at, 869 fulfillment_ends_at, 870 ) = row.map_err(|source| AppSqliteError::Query { 871 operation: "read buyer orders list", 872 source, 873 })?; 874 let order_id: OrderId = parse_typed_id("orders.id", order_id)?; 875 let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?; 876 let buyer_status = BuyerOrderStatus::from(parse_order_status("orders.status", status)?); 877 let revision = 878 parse_trade_revision_status("orders.workflow_revision", workflow_revision)?; 879 let items = self.load_order_detail_items(order_id.to_string())?; 880 let economics = order_detail_economics(&items)?; 881 let workflow = trade_workflow_projection_from_storage(StoredTradeWorkflowSnapshot { 882 order_id, 883 revision, 884 economics, 885 agreement: workflow_agreement, 886 inventory: workflow_inventory, 887 provenance_source: workflow_provenance_source, 888 provenance_last_event_id: workflow_provenance_last_event_id, 889 })?; 890 891 orders.push(BuyerOrdersListRow { 892 order_id, 893 farm_id, 894 order_number, 895 repeat_demand: self.build_repeat_demand_handoff( 896 order_id, 897 farm_id, 898 farm_display_name.as_str(), 899 &visible_listings, 900 )?, 901 farm_display_name, 902 fulfillment_summary: format_fulfillment_summary( 903 fulfillment_label, 904 fulfillment_starts_at, 905 fulfillment_ends_at, 906 ), 907 status: buyer_status, 908 workflow, 909 }); 910 } 911 912 Ok(BuyerOrdersProjection { rows: orders }) 913 } 914 915 pub fn load_buyer_order_detail( 916 &self, 917 context: &BuyerContext, 918 order_id: OrderId, 919 ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> { 920 self.load_buyer_order_detail_for_scope( 921 &SelectedBuyerOrderScope::from_buyer_context(context), 922 order_id, 923 ) 924 } 925 926 pub fn load_buyer_order_detail_for_scope( 927 &self, 928 scope: &SelectedBuyerOrderScope, 929 order_id: OrderId, 930 ) -> Result<Option<BuyerOrderDetailProjection>, AppSqliteError> { 931 let now_utc = self.current_utc_timestamp()?; 932 let visible_listings = self.visible_listing_index(&now_utc)?; 933 let context_keys = scope.context_keys(); 934 if context_keys.is_empty() { 935 return Ok(None); 936 } 937 let placeholders = sql_placeholders(context_keys.len()); 938 let query = format!( 939 "select 940 o.id, 941 o.farm_id, 942 o.order_number, 943 o.status, 944 o.buyer_order_note, 945 o.workflow_revision, 946 o.workflow_agreement, 947 o.workflow_inventory, 948 o.workflow_provenance_source, 949 o.workflow_provenance_last_event_id, 950 f.display_name, 951 fw.label, 952 fw.starts_at, 953 fw.ends_at 954 from orders o 955 inner join farms f on f.id = o.farm_id 956 left join fulfillment_windows fw on fw.id = o.fulfillment_window_id 957 where o.buyer_context_key in ({placeholders}) and o.id = ? 958 limit 1" 959 ); 960 let mut params = context_keys.to_vec(); 961 params.push(order_id.to_string()); 962 let record = self 963 .connection 964 .query_row(query.as_str(), params_from_iter(params.iter()), |row| { 965 Ok(( 966 row.get::<_, String>(0)?, 967 row.get::<_, String>(1)?, 968 row.get::<_, String>(2)?, 969 row.get::<_, String>(3)?, 970 row.get::<_, String>(4)?, 971 row.get::<_, String>(5)?, 972 row.get::<_, String>(6)?, 973 row.get::<_, String>(7)?, 974 row.get::<_, String>(8)?, 975 row.get::<_, Option<String>>(9)?, 976 row.get::<_, String>(10)?, 977 row.get::<_, Option<String>>(11)?, 978 row.get::<_, Option<String>>(12)?, 979 row.get::<_, Option<String>>(13)?, 980 )) 981 }) 982 .optional() 983 .map_err(|source| AppSqliteError::Query { 984 operation: "load buyer order detail", 985 source, 986 })?; 987 988 record 989 .map( 990 |( 991 order_id, 992 farm_id, 993 order_number, 994 status, 995 order_note, 996 workflow_revision, 997 workflow_agreement, 998 workflow_inventory, 999 workflow_provenance_source, 1000 workflow_provenance_last_event_id, 1001 farm_display_name, 1002 fulfillment_label, 1003 fulfillment_starts_at, 1004 fulfillment_ends_at, 1005 )| { 1006 let order_id: OrderId = parse_typed_id("orders.id", order_id)?; 1007 let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?; 1008 let status = 1009 BuyerOrderStatus::from(parse_order_status("orders.status", status)?); 1010 let revision = 1011 parse_trade_revision_status("orders.workflow_revision", workflow_revision)?; 1012 let items = self.load_order_detail_items(order_id.to_string())?; 1013 let economics = order_detail_economics(&items)?; 1014 let workflow = 1015 trade_workflow_projection_from_storage(StoredTradeWorkflowSnapshot { 1016 order_id, 1017 revision, 1018 economics: economics.clone(), 1019 agreement: workflow_agreement, 1020 inventory: workflow_inventory, 1021 provenance_source: workflow_provenance_source, 1022 provenance_last_event_id: workflow_provenance_last_event_id, 1023 })?; 1024 let validation_receipts = order_validation_receipts(self.connection, order_id)?; 1025 Ok(BuyerOrderDetailProjection { 1026 order_id, 1027 farm_id, 1028 order_number, 1029 farm_display_name: farm_display_name.clone(), 1030 fulfillment_summary: format_fulfillment_summary( 1031 fulfillment_label, 1032 fulfillment_starts_at, 1033 fulfillment_ends_at, 1034 ), 1035 status, 1036 items, 1037 economics, 1038 workflow, 1039 validation_receipts, 1040 order_note: empty_string_to_none(order_note), 1041 repeat_demand: self.build_repeat_demand_handoff( 1042 order_id, 1043 farm_id, 1044 farm_display_name.as_str(), 1045 &visible_listings, 1046 )?, 1047 }) 1048 }, 1049 ) 1050 .transpose() 1051 } 1052 1053 pub fn load_buyer_order_local_event_export( 1054 &self, 1055 context: &BuyerContext, 1056 order_id: OrderId, 1057 ) -> Result<Option<BuyerOrderLocalEventExport>, AppSqliteError> { 1058 let context_key = context.storage_key(); 1059 let Some(record) = self 1060 .connection 1061 .query_row( 1062 "select 1063 o.id, 1064 o.farm_id, 1065 o.order_number, 1066 o.status, 1067 o.buyer_context_key, 1068 o.customer_display_name, 1069 o.buyer_email, 1070 o.buyer_phone, 1071 o.buyer_order_note, 1072 o.updated_at, 1073 f.display_name, 1074 fw.id, 1075 fw.label, 1076 fw.starts_at, 1077 fw.ends_at 1078 from orders o 1079 inner join farms f on f.id = o.farm_id 1080 left join fulfillment_windows fw on fw.id = o.fulfillment_window_id 1081 where o.buyer_context_key = ?1 and o.id = ?2 1082 limit 1", 1083 params![context_key.as_str(), order_id.to_string()], 1084 |row| { 1085 Ok(( 1086 row.get::<_, String>(0)?, 1087 row.get::<_, String>(1)?, 1088 row.get::<_, String>(2)?, 1089 row.get::<_, String>(3)?, 1090 row.get::<_, Option<String>>(4)?, 1091 row.get::<_, String>(5)?, 1092 row.get::<_, String>(6)?, 1093 row.get::<_, String>(7)?, 1094 row.get::<_, String>(8)?, 1095 row.get::<_, String>(9)?, 1096 row.get::<_, String>(10)?, 1097 row.get::<_, Option<String>>(11)?, 1098 row.get::<_, Option<String>>(12)?, 1099 row.get::<_, Option<String>>(13)?, 1100 row.get::<_, Option<String>>(14)?, 1101 )) 1102 }, 1103 ) 1104 .optional() 1105 .map_err(|source| AppSqliteError::Query { 1106 operation: "load buyer order local event export header", 1107 source, 1108 })? 1109 else { 1110 return Ok(None); 1111 }; 1112 let ( 1113 order_id, 1114 farm_id, 1115 order_number, 1116 status, 1117 buyer_context_key, 1118 buyer_name, 1119 buyer_email, 1120 buyer_phone, 1121 buyer_order_note, 1122 updated_at, 1123 farm_display_name, 1124 fulfillment_window_id, 1125 fulfillment_window_label, 1126 fulfillment_starts_at, 1127 fulfillment_ends_at, 1128 ) = record; 1129 let order_id = parse_typed_id("orders.id", order_id)?; 1130 let farm_id = parse_typed_id("orders.farm_id", farm_id)?; 1131 let lines = self.load_buyer_order_local_event_lines(order_id)?; 1132 1133 Ok(Some(BuyerOrderLocalEventExport { 1134 order_id, 1135 farm_id, 1136 farm_display_name, 1137 order_number, 1138 status, 1139 buyer_context_key: buyer_context_key.unwrap_or_else(|| context_key.clone()), 1140 buyer_name, 1141 buyer_email, 1142 buyer_phone, 1143 buyer_order_note, 1144 updated_at, 1145 fulfillment_window_id: parse_optional_typed_id( 1146 "orders.fulfillment_window_id", 1147 fulfillment_window_id, 1148 )?, 1149 fulfillment_window_label: empty_string_to_none_option(fulfillment_window_label), 1150 fulfillment_starts_at, 1151 fulfillment_ends_at, 1152 lines, 1153 })) 1154 } 1155 1156 pub fn apply_buyer_repeat_demand_to_cart( 1157 &self, 1158 context: &BuyerContext, 1159 order_id: OrderId, 1160 replace_existing: bool, 1161 ) -> Result<BuyerRepeatDemandApplyOutcome, AppSqliteError> { 1162 self.apply_buyer_repeat_demand_from_scope_to_cart( 1163 &SelectedBuyerOrderScope::from_buyer_context(context), 1164 context, 1165 order_id, 1166 replace_existing, 1167 ) 1168 } 1169 1170 pub fn apply_buyer_repeat_demand_from_scope_to_cart( 1171 &self, 1172 source_scope: &SelectedBuyerOrderScope, 1173 cart_context: &BuyerContext, 1174 order_id: OrderId, 1175 replace_existing: bool, 1176 ) -> Result<BuyerRepeatDemandApplyOutcome, AppSqliteError> { 1177 let Some((farm_id, farm_display_name)) = 1178 self.load_buyer_order_repeat_demand_header_for_scope(source_scope, order_id)? 1179 else { 1180 return Ok(BuyerRepeatDemandApplyOutcome::Unavailable); 1181 }; 1182 let now_utc = self.current_utc_timestamp()?; 1183 let visible_listings = self.visible_listing_index(&now_utc)?; 1184 let Some(candidate) = self.build_repeat_demand_candidate( 1185 order_id, 1186 farm_id, 1187 farm_display_name.as_str(), 1188 &visible_listings, 1189 )? 1190 else { 1191 return Ok(BuyerRepeatDemandApplyOutcome::Unavailable); 1192 }; 1193 if candidate.available_lines.is_empty() { 1194 return Ok(BuyerRepeatDemandApplyOutcome::Unavailable); 1195 } 1196 1197 let current_cart = self.load_buyer_cart(cart_context)?; 1198 if !replace_existing 1199 && !current_cart.is_empty() 1200 && current_cart.farm_id != Some(candidate.farm_id) 1201 { 1202 let current_farm_display_name = current_cart 1203 .farm_display_name 1204 .clone() 1205 .or_else(|| { 1206 current_cart 1207 .lines 1208 .first() 1209 .map(|line| line.farm_display_name.clone()) 1210 }) 1211 .ok_or(AppSqliteError::InvalidProjection { 1212 reason: "buyer cart farm display name is missing", 1213 })?; 1214 1215 return Ok(BuyerRepeatDemandApplyOutcome::ConfirmationRequired( 1216 BuyerCartReplaceConfirmationProjection { 1217 current_farm_display_name, 1218 incoming_farm_display_name: candidate.farm_display_name, 1219 }, 1220 )); 1221 } 1222 1223 let next_cart = next_buyer_cart_for_repeat_demand( 1224 current_cart, 1225 candidate.farm_id, 1226 candidate.farm_display_name.as_str(), 1227 &candidate.available_lines, 1228 replace_existing, 1229 )?; 1230 self.replace_buyer_cart(cart_context, &next_cart)?; 1231 1232 Ok(BuyerRepeatDemandApplyOutcome::Applied) 1233 } 1234 1235 fn build_cart_projection( 1236 &self, 1237 header: Option<BuyerCartHeader>, 1238 line_records: Vec<BuyerCartLineRecord>, 1239 ) -> Result<BuyerCartProjection, AppSqliteError> { 1240 let mut lines = Vec::with_capacity(line_records.len()); 1241 let mut subtotal_minor_units = 0_u32; 1242 let mut currency_code = None; 1243 1244 for line_record in line_records { 1245 let line_projection = line_record.into_projection()?; 1246 subtotal_minor_units = subtotal_minor_units 1247 .checked_add(line_projection.line_total_minor_units) 1248 .ok_or(AppSqliteError::InvalidProjection { 1249 reason: "buyer cart subtotal overflowed", 1250 })?; 1251 1252 if currency_code.is_none() { 1253 currency_code = Some(line_projection.unit_price.currency_code.clone()); 1254 } 1255 lines.push(line_projection); 1256 } 1257 1258 let farm_id = if lines.is_empty() { 1259 None 1260 } else { 1261 lines.first().map(|line| line.farm_id) 1262 } 1263 .or(header.as_ref().and_then(|header| header.farm_id)); 1264 let farm_display_name = if let Some(line) = lines.first() { 1265 Some(line.farm_display_name.clone()) 1266 } else if let Some(farm_id) = farm_id { 1267 self.load_farm_display_name(farm_id)? 1268 } else { 1269 None 1270 }; 1271 let has_lines = !lines.is_empty(); 1272 1273 Ok(BuyerCartProjection { 1274 farm_id, 1275 farm_display_name, 1276 lines, 1277 subtotal_minor_units: has_lines.then_some(subtotal_minor_units), 1278 currency_code: has_lines.then_some(currency_code.unwrap_or_default()), 1279 replace_confirmation: None, 1280 }) 1281 } 1282 1283 fn current_utc_timestamp(&self) -> Result<String, AppSqliteError> { 1284 self.connection 1285 .query_row("select strftime('%Y-%m-%dT%H:%M:%SZ', 'now')", [], |row| { 1286 row.get(0) 1287 }) 1288 .map_err(|source| AppSqliteError::Query { 1289 operation: "load buyer current utc timestamp", 1290 source, 1291 }) 1292 } 1293 1294 fn insert_pending_buyer_order_coordination( 1295 &self, 1296 context_key: &str, 1297 order_id: OrderId, 1298 ) -> Result<(), AppSqliteError> { 1299 self.connection 1300 .execute( 1301 "insert into buyer_order_coordination_records ( 1302 order_id, 1303 buyer_context_key, 1304 state, 1305 attempt_count, 1306 created_at, 1307 updated_at 1308 ) values ( 1309 ?1, 1310 ?2, 1311 'pending', 1312 0, 1313 strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 1314 strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 1315 )", 1316 params![order_id.to_string(), context_key], 1317 ) 1318 .map_err(|source| AppSqliteError::Query { 1319 operation: "insert pending buyer order coordination record", 1320 source, 1321 })?; 1322 1323 Ok(()) 1324 } 1325 1326 fn load_listing_records(&self) -> Result<Vec<BuyerListingRecord>, AppSqliteError> { 1327 let mut statement = self 1328 .connection 1329 .prepare( 1330 "select 1331 p.id, 1332 p.farm_id, 1333 f.display_name, 1334 f.readiness, 1335 p.title, 1336 p.subtitle, 1337 p.status, 1338 p.unit_label, 1339 p.price_minor_units, 1340 p.price_currency, 1341 p.listing_bin_id, 1342 ( 1343 select li.farm_key 1344 from local_interop_imports li 1345 where li.projected_kind = 'listing' 1346 and li.projected_id = p.id 1347 order by li.local_seq desc 1348 limit 1 1349 ), 1350 ( 1351 select li.listing_addr 1352 from local_interop_imports li 1353 where li.projected_kind = 'listing' 1354 and li.projected_id = p.id 1355 and li.listing_addr is not null 1356 and trim(li.listing_addr) <> '' 1357 order by li.local_seq desc 1358 limit 1 1359 ), 1360 ( 1361 select li.event_id 1362 from local_interop_imports li 1363 where li.projected_kind = 'listing' 1364 and li.projected_id = p.id 1365 and li.event_id is not null 1366 and trim(li.event_id) <> '' 1367 order by li.local_seq desc 1368 limit 1 1369 ), 1370 ( 1371 select li.owner_pubkey 1372 from local_interop_imports li 1373 where li.projected_kind = 'listing' 1374 and li.projected_id = p.id 1375 and li.owner_pubkey is not null 1376 and trim(li.owner_pubkey) <> '' 1377 order by li.local_seq desc 1378 limit 1 1379 ), 1380 ( 1381 select li.relay_delivery_json 1382 from local_interop_imports li 1383 where li.projected_kind = 'listing' 1384 and li.projected_id = p.id 1385 and li.relay_delivery_json is not null 1386 and trim(li.relay_delivery_json) <> '' 1387 order by li.local_seq desc 1388 limit 1 1389 ), 1390 p.stock_count, 1391 fw.id, 1392 fw.label, 1393 fw.starts_at, 1394 fw.ends_at, 1395 fw.pickup_location_id, 1396 coalesce(( 1397 select max(afs.pickup_enabled) 1398 from account_farm_setups afs 1399 where afs.saved_farm_id = p.farm_id 1400 ), 0), 1401 coalesce(( 1402 select max(afs.delivery_enabled) 1403 from account_farm_setups afs 1404 where afs.saved_farm_id = p.farm_id 1405 ), 0), 1406 coalesce(( 1407 select max(afs.shipping_enabled) 1408 from account_farm_setups afs 1409 where afs.saved_farm_id = p.farm_id 1410 ), 0) 1411 from products p 1412 inner join farms f on f.id = p.farm_id 1413 left join fulfillment_windows fw on fw.id = p.availability_window_id", 1414 ) 1415 .map_err(|source| AppSqliteError::Query { 1416 operation: "prepare buyer listings query", 1417 source, 1418 })?; 1419 let rows = statement 1420 .query_map([], |row| { 1421 Ok(( 1422 row.get::<_, String>(0)?, 1423 row.get::<_, String>(1)?, 1424 row.get::<_, String>(2)?, 1425 row.get::<_, String>(3)?, 1426 row.get::<_, String>(4)?, 1427 row.get::<_, String>(5)?, 1428 row.get::<_, String>(6)?, 1429 row.get::<_, String>(7)?, 1430 row.get::<_, Option<u32>>(8)?, 1431 row.get::<_, String>(9)?, 1432 row.get::<_, Option<String>>(10)?, 1433 row.get::<_, Option<String>>(11)?, 1434 row.get::<_, Option<String>>(12)?, 1435 row.get::<_, Option<String>>(13)?, 1436 row.get::<_, Option<String>>(14)?, 1437 row.get::<_, Option<String>>(15)?, 1438 row.get::<_, Option<u32>>(16)?, 1439 row.get::<_, Option<String>>(17)?, 1440 row.get::<_, Option<String>>(18)?, 1441 row.get::<_, Option<String>>(19)?, 1442 row.get::<_, Option<String>>(20)?, 1443 row.get::<_, Option<String>>(21)?, 1444 row.get::<_, i64>(22)?, 1445 row.get::<_, i64>(23)?, 1446 row.get::<_, i64>(24)?, 1447 )) 1448 }) 1449 .map_err(|source| AppSqliteError::Query { 1450 operation: "query buyer listings", 1451 source, 1452 })?; 1453 let mut records = Vec::new(); 1454 1455 for row in rows { 1456 let ( 1457 product_id, 1458 farm_id, 1459 farm_display_name, 1460 farm_readiness, 1461 title, 1462 subtitle, 1463 status, 1464 unit_label, 1465 price_minor_units, 1466 price_currency, 1467 listing_bin_id, 1468 farm_key, 1469 listing_addr, 1470 listing_event_id, 1471 seller_pubkey, 1472 listing_relay_delivery_json, 1473 stock_count, 1474 fulfillment_window_id, 1475 fulfillment_window_label, 1476 fulfillment_starts_at, 1477 fulfillment_ends_at, 1478 pickup_location_id, 1479 pickup_enabled, 1480 delivery_enabled, 1481 shipping_enabled, 1482 ) = row.map_err(|source| AppSqliteError::Query { 1483 operation: "read buyer listings", 1484 source, 1485 })?; 1486 1487 records.push(BuyerListingRecord { 1488 product_id: parse_typed_id("products.id", product_id)?, 1489 farm_id: parse_typed_id("products.farm_id", farm_id)?, 1490 farm_display_name, 1491 farm_is_ready: farm_readiness == "ready", 1492 title, 1493 subtitle: empty_string_to_none(subtitle), 1494 status: parse_product_status("products.status", status)?, 1495 unit_label, 1496 price_minor_units, 1497 price_currency, 1498 listing_bin_id: listing_bin_id.and_then(empty_string_to_none), 1499 farm_key: farm_key.and_then(empty_string_to_none), 1500 listing_addr: listing_addr.and_then(empty_string_to_none), 1501 listing_event_id: listing_event_id.and_then(empty_string_to_none), 1502 listing_relays: listing_relays_from_json(listing_relay_delivery_json)?, 1503 seller_pubkey: seller_pubkey.and_then(empty_string_to_none), 1504 stock_count, 1505 fulfillment_window_id: parse_optional_typed_id( 1506 "products.availability_window_id", 1507 fulfillment_window_id, 1508 )?, 1509 fulfillment_window_label: empty_string_to_none_option(fulfillment_window_label), 1510 fulfillment_starts_at, 1511 fulfillment_ends_at, 1512 pickup_location_present: pickup_location_id.is_some(), 1513 pickup_enabled: parse_sqlite_bool( 1514 "account_farm_setups.pickup_enabled", 1515 pickup_enabled, 1516 )?, 1517 delivery_enabled: parse_sqlite_bool( 1518 "account_farm_setups.delivery_enabled", 1519 delivery_enabled, 1520 )?, 1521 shipping_enabled: parse_sqlite_bool( 1522 "account_farm_setups.shipping_enabled", 1523 shipping_enabled, 1524 )?, 1525 }); 1526 } 1527 1528 Ok(records) 1529 } 1530 1531 fn load_listing_record_by_id( 1532 &self, 1533 product_id: ProductId, 1534 ) -> Result<Option<BuyerListingRecord>, AppSqliteError> { 1535 Ok(self 1536 .load_listing_records()? 1537 .into_iter() 1538 .find(|record| record.product_id == product_id)) 1539 } 1540 1541 fn load_buyer_cart_line_snapshot( 1542 &self, 1543 product_id: ProductId, 1544 ) -> Result<BuyerCartLineSnapshot, AppSqliteError> { 1545 Ok(self 1546 .load_listing_record_by_id(product_id)? 1547 .map(|listing| BuyerCartLineSnapshot { 1548 listing_bin_id: listing.listing_bin_id, 1549 farm_key: listing.farm_key, 1550 listing_addr: listing.listing_addr, 1551 listing_event_id: listing.listing_event_id, 1552 listing_relays: listing.listing_relays, 1553 seller_pubkey: listing.seller_pubkey, 1554 }) 1555 .unwrap_or_default()) 1556 } 1557 1558 fn load_cart_header( 1559 &self, 1560 context_key: &str, 1561 ) -> Result<Option<BuyerCartHeader>, AppSqliteError> { 1562 self.connection 1563 .query_row( 1564 "select 1565 farm_id, 1566 buyer_name, 1567 buyer_email, 1568 buyer_phone, 1569 buyer_order_note 1570 from buyer_carts 1571 where buyer_context_key = ?1 1572 limit 1", 1573 params![context_key], 1574 |row| { 1575 Ok(( 1576 row.get::<_, Option<String>>(0)?, 1577 row.get::<_, String>(1)?, 1578 row.get::<_, String>(2)?, 1579 row.get::<_, String>(3)?, 1580 row.get::<_, String>(4)?, 1581 )) 1582 }, 1583 ) 1584 .optional() 1585 .map_err(|source| AppSqliteError::Query { 1586 operation: "load buyer cart header", 1587 source, 1588 })? 1589 .map( 1590 |(farm_id, buyer_name, buyer_email, buyer_phone, buyer_order_note)| { 1591 Ok(BuyerCartHeader { 1592 farm_id: parse_optional_typed_id("buyer_carts.farm_id", farm_id)?, 1593 buyer_name, 1594 buyer_email, 1595 buyer_phone, 1596 buyer_order_note, 1597 }) 1598 }, 1599 ) 1600 .transpose() 1601 } 1602 1603 fn load_cart_line_records( 1604 &self, 1605 context_key: &str, 1606 ) -> Result<Vec<BuyerCartLineRecord>, AppSqliteError> { 1607 let now_utc = self.current_utc_timestamp()?; 1608 let mut statement = self 1609 .connection 1610 .prepare( 1611 "select 1612 bcl.quantity, 1613 p.id, 1614 p.farm_id, 1615 f.display_name, 1616 f.readiness, 1617 p.title, 1618 p.subtitle, 1619 p.status, 1620 coalesce(nullif(bcl.quantity_unit_label, ''), p.unit_label), 1621 coalesce(bcl.unit_price_minor_units, p.price_minor_units), 1622 coalesce(nullif(bcl.price_currency, ''), p.price_currency), 1623 coalesce(nullif(bcl.listing_bin_id, ''), p.listing_bin_id), 1624 coalesce(nullif(bcl.farm_key, ''), ( 1625 select li.farm_key 1626 from local_interop_imports li 1627 where li.projected_kind = 'listing' 1628 and li.projected_id = p.id 1629 order by li.local_seq desc 1630 limit 1 1631 )), 1632 coalesce(nullif(bcl.listing_addr, ''), ( 1633 select li.listing_addr 1634 from local_interop_imports li 1635 where li.projected_kind = 'listing' 1636 and li.projected_id = p.id 1637 and li.listing_addr is not null 1638 and trim(li.listing_addr) <> '' 1639 order by li.local_seq desc 1640 limit 1 1641 )), 1642 coalesce(nullif(bcl.listing_event_id, ''), ( 1643 select li.event_id 1644 from local_interop_imports li 1645 where li.projected_kind = 'listing' 1646 and li.projected_id = p.id 1647 and li.event_id is not null 1648 and trim(li.event_id) <> '' 1649 order by li.local_seq desc 1650 limit 1 1651 )), 1652 coalesce(nullif(bcl.listing_relays_json, ''), ( 1653 select li.relay_delivery_json 1654 from local_interop_imports li 1655 where li.projected_kind = 'listing' 1656 and li.projected_id = p.id 1657 and li.relay_delivery_json is not null 1658 and trim(li.relay_delivery_json) <> '' 1659 order by li.local_seq desc 1660 limit 1 1661 )), 1662 coalesce(nullif(bcl.seller_pubkey, ''), ( 1663 select li.owner_pubkey 1664 from local_interop_imports li 1665 where li.projected_kind = 'listing' 1666 and li.projected_id = p.id 1667 and li.owner_pubkey is not null 1668 and trim(li.owner_pubkey) <> '' 1669 order by li.local_seq desc 1670 limit 1 1671 )), 1672 p.stock_count, 1673 fw.id, 1674 fw.label, 1675 fw.starts_at, 1676 fw.ends_at, 1677 fw.pickup_location_id, 1678 coalesce(( 1679 select max(afs.pickup_enabled) 1680 from account_farm_setups afs 1681 where afs.saved_farm_id = p.farm_id 1682 ), 0), 1683 coalesce(( 1684 select max(afs.delivery_enabled) 1685 from account_farm_setups afs 1686 where afs.saved_farm_id = p.farm_id 1687 ), 0), 1688 coalesce(( 1689 select max(afs.shipping_enabled) 1690 from account_farm_setups afs 1691 where afs.saved_farm_id = p.farm_id 1692 ), 0) 1693 from buyer_cart_lines bcl 1694 inner join products p on p.id = bcl.product_id 1695 inner join farms f on f.id = p.farm_id 1696 left join fulfillment_windows fw on fw.id = p.availability_window_id 1697 where bcl.buyer_context_key = ?1 1698 order by bcl.updated_at desc, p.id desc", 1699 ) 1700 .map_err(|source| AppSqliteError::Query { 1701 operation: "prepare buyer cart lines", 1702 source, 1703 })?; 1704 let rows = statement 1705 .query_map(params![context_key], |row| { 1706 Ok(( 1707 row.get::<_, u32>(0)?, 1708 row.get::<_, String>(1)?, 1709 row.get::<_, String>(2)?, 1710 row.get::<_, String>(3)?, 1711 row.get::<_, String>(4)?, 1712 row.get::<_, String>(5)?, 1713 row.get::<_, String>(6)?, 1714 row.get::<_, String>(7)?, 1715 row.get::<_, String>(8)?, 1716 row.get::<_, Option<u32>>(9)?, 1717 row.get::<_, String>(10)?, 1718 row.get::<_, Option<String>>(11)?, 1719 row.get::<_, Option<String>>(12)?, 1720 row.get::<_, Option<String>>(13)?, 1721 row.get::<_, Option<String>>(14)?, 1722 row.get::<_, Option<String>>(15)?, 1723 row.get::<_, Option<String>>(16)?, 1724 row.get::<_, Option<u32>>(17)?, 1725 row.get::<_, Option<String>>(18)?, 1726 row.get::<_, Option<String>>(19)?, 1727 row.get::<_, Option<String>>(20)?, 1728 row.get::<_, Option<String>>(21)?, 1729 row.get::<_, Option<String>>(22)?, 1730 row.get::<_, i64>(23)?, 1731 row.get::<_, i64>(24)?, 1732 row.get::<_, i64>(25)?, 1733 )) 1734 }) 1735 .map_err(|source| AppSqliteError::Query { 1736 operation: "query buyer cart lines", 1737 source, 1738 })?; 1739 let mut line_records = Vec::new(); 1740 1741 for row in rows { 1742 let ( 1743 quantity, 1744 product_id, 1745 farm_id, 1746 farm_display_name, 1747 farm_readiness, 1748 title, 1749 subtitle, 1750 status, 1751 unit_label, 1752 price_minor_units, 1753 price_currency, 1754 listing_bin_id, 1755 farm_key, 1756 listing_addr, 1757 listing_event_id, 1758 listing_relays_json, 1759 seller_pubkey, 1760 stock_count, 1761 fulfillment_window_id, 1762 fulfillment_window_label, 1763 fulfillment_starts_at, 1764 fulfillment_ends_at, 1765 pickup_location_id, 1766 pickup_enabled, 1767 delivery_enabled, 1768 shipping_enabled, 1769 ) = row.map_err(|source| AppSqliteError::Query { 1770 operation: "read buyer cart lines", 1771 source, 1772 })?; 1773 let listing = BuyerListingRecord { 1774 product_id: parse_typed_id("products.id", product_id)?, 1775 farm_id: parse_typed_id("products.farm_id", farm_id)?, 1776 farm_display_name, 1777 farm_is_ready: farm_readiness == "ready", 1778 title, 1779 subtitle: empty_string_to_none(subtitle), 1780 status: parse_product_status("products.status", status)?, 1781 unit_label, 1782 price_minor_units, 1783 price_currency, 1784 listing_bin_id: listing_bin_id.and_then(empty_string_to_none), 1785 farm_key: farm_key.and_then(empty_string_to_none), 1786 listing_addr: listing_addr.and_then(empty_string_to_none), 1787 listing_event_id: listing_event_id.and_then(empty_string_to_none), 1788 listing_relays: listing_relays_from_json(listing_relays_json)?, 1789 seller_pubkey: seller_pubkey.and_then(empty_string_to_none), 1790 stock_count, 1791 fulfillment_window_id: parse_optional_typed_id( 1792 "products.availability_window_id", 1793 fulfillment_window_id, 1794 )?, 1795 fulfillment_window_label: empty_string_to_none_option(fulfillment_window_label), 1796 fulfillment_starts_at, 1797 fulfillment_ends_at, 1798 pickup_location_present: pickup_location_id.is_some(), 1799 pickup_enabled: parse_sqlite_bool( 1800 "account_farm_setups.pickup_enabled", 1801 pickup_enabled, 1802 )?, 1803 delivery_enabled: parse_sqlite_bool( 1804 "account_farm_setups.delivery_enabled", 1805 delivery_enabled, 1806 )?, 1807 shipping_enabled: parse_sqlite_bool( 1808 "account_farm_setups.shipping_enabled", 1809 shipping_enabled, 1810 )?, 1811 }; 1812 1813 if listing.is_buyer_visible(&now_utc) { 1814 line_records.push(BuyerCartLineRecord { listing, quantity }); 1815 } 1816 } 1817 1818 Ok(line_records) 1819 } 1820 1821 fn load_order_detail_items( 1822 &self, 1823 order_id: String, 1824 ) -> Result<Vec<OrderDetailItemRow>, AppSqliteError> { 1825 let mut statement = self 1826 .connection 1827 .prepare( 1828 "select 1829 title, 1830 quantity_display, 1831 quantity_value, 1832 quantity_unit_label, 1833 unit_price_minor_units, 1834 price_currency 1835 from order_lines 1836 where order_id = ?1 1837 order by sort_index asc, id asc", 1838 ) 1839 .map_err(|source| AppSqliteError::Query { 1840 operation: "prepare buyer order detail items", 1841 source, 1842 })?; 1843 let rows = statement 1844 .query_map(params![order_id], |row| { 1845 Ok(( 1846 row.get::<_, String>(0)?, 1847 row.get::<_, String>(1)?, 1848 row.get::<_, i64>(2)?, 1849 row.get::<_, String>(3)?, 1850 row.get::<_, Option<u32>>(4)?, 1851 row.get::<_, Option<String>>(5)?, 1852 )) 1853 }) 1854 .map_err(|source| AppSqliteError::Query { 1855 operation: "query buyer order detail items", 1856 source, 1857 })?; 1858 let mut items = Vec::new(); 1859 1860 for row in rows { 1861 let ( 1862 title, 1863 quantity_display, 1864 quantity_value, 1865 quantity_unit_label, 1866 unit_price_minor_units, 1867 price_currency, 1868 ) = row.map_err(|source| AppSqliteError::Query { 1869 operation: "read buyer order detail items", 1870 source, 1871 })?; 1872 items.push(order_detail_item_row( 1873 title, 1874 quantity_display, 1875 quantity_value, 1876 quantity_unit_label, 1877 unit_price_minor_units, 1878 price_currency, 1879 )?); 1880 } 1881 1882 Ok(items) 1883 } 1884 1885 fn load_buyer_order_local_event_lines( 1886 &self, 1887 order_id: OrderId, 1888 ) -> Result<Vec<BuyerOrderLocalEventLine>, AppSqliteError> { 1889 let order_id_string = order_id.to_string(); 1890 let mut statement = self 1891 .connection 1892 .prepare( 1893 "select 1894 ol.id, 1895 ol.title, 1896 ol.quantity_value, 1897 ol.quantity_unit_label, 1898 ol.quantity_display, 1899 ol.unit_price_minor_units, 1900 ol.price_currency, 1901 ol.listing_bin_id, 1902 ol.farm_key, 1903 ol.listing_addr, 1904 ol.listing_event_id, 1905 ol.listing_relays_json, 1906 ol.seller_pubkey 1907 from order_lines ol 1908 where ol.order_id = ?1 1909 order by ol.sort_index asc, ol.id asc", 1910 ) 1911 .map_err(|source| AppSqliteError::Query { 1912 operation: "prepare buyer order local event lines", 1913 source, 1914 })?; 1915 let rows = statement 1916 .query_map(params![order_id_string.as_str()], |row| { 1917 Ok(( 1918 row.get::<_, String>(0)?, 1919 row.get::<_, String>(1)?, 1920 row.get::<_, i64>(2)?, 1921 row.get::<_, String>(3)?, 1922 row.get::<_, String>(4)?, 1923 row.get::<_, Option<u32>>(5)?, 1924 row.get::<_, Option<String>>(6)?, 1925 row.get::<_, Option<String>>(7)?, 1926 row.get::<_, Option<String>>(8)?, 1927 row.get::<_, Option<String>>(9)?, 1928 row.get::<_, Option<String>>(10)?, 1929 row.get::<_, Option<String>>(11)?, 1930 row.get::<_, Option<String>>(12)?, 1931 )) 1932 }) 1933 .map_err(|source| AppSqliteError::Query { 1934 operation: "query buyer order local event lines", 1935 source, 1936 })?; 1937 let mut lines = Vec::new(); 1938 1939 for row in rows { 1940 let ( 1941 line_id, 1942 title, 1943 quantity, 1944 quantity_unit_label, 1945 quantity_display, 1946 unit_price_minor_units, 1947 price_currency, 1948 listing_bin_id, 1949 farm_key, 1950 listing_addr, 1951 listing_event_id, 1952 listing_relays_json, 1953 seller_pubkey, 1954 ) = row.map_err(|source| AppSqliteError::Query { 1955 operation: "read buyer order local event line", 1956 source, 1957 })?; 1958 let product_id = parse_order_line_product_id(line_id.as_str(), order_id)?; 1959 let quantity = 1960 u32::try_from(quantity).map_err(|_| AppSqliteError::InvalidProjection { 1961 reason: "buyer order local event quantity must be non-negative", 1962 })?; 1963 if quantity == 0 { 1964 return Err(AppSqliteError::InvalidProjection { 1965 reason: "buyer order local event quantity must be positive", 1966 }); 1967 } 1968 1969 lines.push(BuyerOrderLocalEventLine { 1970 product_id, 1971 title, 1972 quantity, 1973 quantity_unit_label, 1974 quantity_display, 1975 unit_price_minor_units, 1976 price_currency: price_currency.unwrap_or_else(|| "USD".to_owned()), 1977 listing_bin_id: listing_bin_id.and_then(empty_string_to_none), 1978 farm_key: farm_key.and_then(empty_string_to_none), 1979 listing_addr: listing_addr.and_then(empty_string_to_none), 1980 listing_event_id: listing_event_id.and_then(empty_string_to_none), 1981 listing_relays: listing_relays_from_json(listing_relays_json)?, 1982 seller_pubkey: seller_pubkey.and_then(empty_string_to_none), 1983 }); 1984 } 1985 1986 Ok(lines) 1987 } 1988 1989 fn load_repeat_demand_order_lines( 1990 &self, 1991 order_id: OrderId, 1992 ) -> Result<Vec<RepeatDemandOrderLine>, AppSqliteError> { 1993 let mut statement = self 1994 .connection 1995 .prepare( 1996 "select id, quantity_value, listing_addr 1997 from order_lines 1998 where order_id = ?1 1999 order by sort_index asc, id asc", 2000 ) 2001 .map_err(|source| AppSqliteError::Query { 2002 operation: "prepare repeat demand order lines", 2003 source, 2004 })?; 2005 let rows = statement 2006 .query_map(params![order_id.to_string()], |row| { 2007 Ok(( 2008 row.get::<_, String>(0)?, 2009 row.get::<_, i64>(1)?, 2010 row.get::<_, Option<String>>(2)?, 2011 )) 2012 }) 2013 .map_err(|source| AppSqliteError::Query { 2014 operation: "query repeat demand order lines", 2015 source, 2016 })?; 2017 let mut order_lines = Vec::new(); 2018 2019 for row in rows { 2020 let (line_id, quantity_value, listing_addr) = 2021 row.map_err(|source| AppSqliteError::Query { 2022 operation: "read repeat demand order lines", 2023 source, 2024 })?; 2025 let product_id = parse_repeat_demand_product_id(line_id.as_str())?; 2026 let quantity = 2027 u32::try_from(quantity_value).map_err(|_| AppSqliteError::InvalidProjection { 2028 reason: "repeat demand quantity must be non-negative", 2029 })?; 2030 if quantity == 0 { 2031 return Err(AppSqliteError::InvalidProjection { 2032 reason: "repeat demand quantity must be positive", 2033 }); 2034 } 2035 2036 order_lines.push(RepeatDemandOrderLine { 2037 product_id, 2038 quantity, 2039 listing_addr: listing_addr.and_then(empty_string_to_none), 2040 }); 2041 } 2042 2043 Ok(order_lines) 2044 } 2045 2046 fn load_buyer_order_repeat_demand_header_for_scope( 2047 &self, 2048 scope: &SelectedBuyerOrderScope, 2049 order_id: OrderId, 2050 ) -> Result<Option<(FarmId, String)>, AppSqliteError> { 2051 let context_keys = scope.context_keys(); 2052 if context_keys.is_empty() { 2053 return Ok(None); 2054 } 2055 let placeholders = sql_placeholders(context_keys.len()); 2056 let query = format!( 2057 "select o.farm_id, f.display_name 2058 from orders o 2059 inner join farms f on f.id = o.farm_id 2060 where o.buyer_context_key in ({placeholders}) and o.id = ? 2061 limit 1" 2062 ); 2063 let mut params = context_keys.to_vec(); 2064 params.push(order_id.to_string()); 2065 self.connection 2066 .query_row(query.as_str(), params_from_iter(params.iter()), |row| { 2067 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) 2068 }) 2069 .optional() 2070 .map_err(|source| AppSqliteError::Query { 2071 operation: "load buyer repeat demand header", 2072 source, 2073 })? 2074 .map(|(farm_id, farm_display_name)| { 2075 Ok(( 2076 parse_typed_id("orders.farm_id", farm_id)?, 2077 farm_display_name, 2078 )) 2079 }) 2080 .transpose() 2081 } 2082 2083 fn visible_listing_index(&self, now_utc: &str) -> Result<VisibleListingIndex, AppSqliteError> { 2084 Ok(VisibleListingIndex::from_records( 2085 self.load_listing_records()? 2086 .into_iter() 2087 .filter(|record| record.is_buyer_visible(now_utc)), 2088 )) 2089 } 2090 2091 fn build_repeat_demand_handoff( 2092 &self, 2093 order_id: OrderId, 2094 farm_id: FarmId, 2095 farm_display_name: &str, 2096 visible_listings: &VisibleListingIndex, 2097 ) -> Result<Option<RepeatDemandHandoffProjection>, AppSqliteError> { 2098 Ok(self 2099 .build_repeat_demand_candidate(order_id, farm_id, farm_display_name, visible_listings)? 2100 .map(|candidate| candidate.handoff)) 2101 } 2102 2103 fn build_repeat_demand_candidate( 2104 &self, 2105 order_id: OrderId, 2106 farm_id: FarmId, 2107 farm_display_name: &str, 2108 visible_listings: &VisibleListingIndex, 2109 ) -> Result<Option<RepeatDemandCandidate>, AppSqliteError> { 2110 let order_lines = self.load_repeat_demand_order_lines(order_id)?; 2111 if order_lines.is_empty() { 2112 return Ok(None); 2113 } 2114 2115 let mut available_lines = Vec::new(); 2116 let mut unavailable_item_count = 0u32; 2117 2118 for order_line in &order_lines { 2119 if let Some(listing) = visible_listings.resolve(order_line).filter(|listing| { 2120 listing 2121 .stock_count 2122 .is_some_and(|quantity| quantity >= order_line.quantity) 2123 }) { 2124 available_lines.push(BuyerCartLineRecord { 2125 listing, 2126 quantity: order_line.quantity, 2127 }); 2128 } else { 2129 unavailable_item_count = unavailable_item_count.checked_add(1).ok_or( 2130 AppSqliteError::InvalidProjection { 2131 reason: "repeat demand unavailable count overflowed", 2132 }, 2133 )?; 2134 } 2135 } 2136 2137 let available_item_count = available_lines.len() as u32; 2138 let eligibility = if available_item_count == 0 { 2139 RepeatDemandEligibility::Unavailable 2140 } else if unavailable_item_count == 0 { 2141 RepeatDemandEligibility::Eligible 2142 } else { 2143 RepeatDemandEligibility::Partial 2144 }; 2145 let current_farm = available_lines 2146 .first() 2147 .map(|line| (line.listing.farm_id, line.listing.farm_display_name.clone())) 2148 .unwrap_or_else(|| (farm_id, farm_display_name.to_owned())); 2149 let (current_farm_id, current_farm_display_name) = current_farm; 2150 2151 Ok(Some(RepeatDemandCandidate { 2152 farm_id: current_farm_id, 2153 farm_display_name: current_farm_display_name, 2154 available_lines, 2155 handoff: RepeatDemandHandoffProjection { 2156 order_id, 2157 farm_id: current_farm_id, 2158 eligibility, 2159 available_item_count, 2160 unavailable_item_count, 2161 }, 2162 })) 2163 } 2164 2165 fn load_farm_display_name(&self, farm_id: FarmId) -> Result<Option<String>, AppSqliteError> { 2166 self.connection 2167 .query_row( 2168 "select display_name from farms where id = ?1 limit 1", 2169 params![farm_id.to_string()], 2170 |row| row.get::<_, String>(0), 2171 ) 2172 .optional() 2173 .map_err(|source| AppSqliteError::Query { 2174 operation: "load buyer cart farm display name", 2175 source, 2176 }) 2177 } 2178 2179 fn next_order_number(&self, farm_id: FarmId) -> Result<String, AppSqliteError> { 2180 let max_suffix = self 2181 .connection 2182 .query_row( 2183 "select coalesce(max(cast(substr(order_number, 3) as integer)), 999) 2184 from orders 2185 where farm_id = ?1 2186 and order_number like 'R-%' 2187 and substr(order_number, 3) glob '[0-9]*'", 2188 params![farm_id.to_string()], 2189 |row| row.get::<_, i64>(0), 2190 ) 2191 .map_err(|source| AppSqliteError::Query { 2192 operation: "load next buyer order number", 2193 source, 2194 })?; 2195 2196 Ok(format!("R-{}", max_suffix + 1)) 2197 } 2198 } 2199 2200 #[derive(Clone, Debug, Eq, PartialEq)] 2201 struct BuyerCartHeader { 2202 farm_id: Option<FarmId>, 2203 buyer_name: String, 2204 buyer_email: String, 2205 buyer_phone: String, 2206 buyer_order_note: String, 2207 } 2208 2209 impl BuyerCartHeader { 2210 fn into_order_review_draft(self) -> BuyerOrderReviewDraft { 2211 BuyerOrderReviewDraft { 2212 name: self.buyer_name, 2213 email: self.buyer_email, 2214 phone: self.buyer_phone, 2215 order_note: self.buyer_order_note, 2216 } 2217 } 2218 } 2219 2220 #[derive(Clone, Debug, Eq, PartialEq)] 2221 struct BuyerListingRecord { 2222 product_id: ProductId, 2223 farm_id: FarmId, 2224 farm_display_name: String, 2225 farm_is_ready: bool, 2226 title: String, 2227 subtitle: Option<String>, 2228 status: ProductStatus, 2229 unit_label: String, 2230 price_minor_units: Option<u32>, 2231 price_currency: String, 2232 listing_bin_id: Option<String>, 2233 farm_key: Option<String>, 2234 listing_addr: Option<String>, 2235 listing_event_id: Option<String>, 2236 listing_relays: Vec<String>, 2237 seller_pubkey: Option<String>, 2238 stock_count: Option<u32>, 2239 fulfillment_window_id: Option<FulfillmentWindowId>, 2240 fulfillment_window_label: Option<String>, 2241 fulfillment_starts_at: Option<String>, 2242 fulfillment_ends_at: Option<String>, 2243 pickup_location_present: bool, 2244 pickup_enabled: bool, 2245 delivery_enabled: bool, 2246 shipping_enabled: bool, 2247 } 2248 2249 impl BuyerListingRecord { 2250 fn is_buyer_visible(&self, now_utc: &str) -> bool { 2251 self.farm_is_ready 2252 && self.status == ProductStatus::Published 2253 && self.stock_count.is_some_and(|quantity| quantity > 0) 2254 && self.price_minor_units.is_some_and(|amount| amount > 0) 2255 && !self.unit_label.trim().is_empty() 2256 && self.fulfillment_window_id.is_some() 2257 && self 2258 .fulfillment_ends_at 2259 .as_deref() 2260 .is_some_and(|ends_at| ends_at >= now_utc) 2261 && !self.fulfillment_methods().is_empty() 2262 } 2263 2264 fn matches_search(&self, search_query: Option<&str>) -> bool { 2265 let Some(search_query) = search_query else { 2266 return true; 2267 }; 2268 2269 self.title.to_lowercase().contains(search_query) 2270 || self 2271 .subtitle 2272 .as_deref() 2273 .is_some_and(|subtitle| subtitle.to_lowercase().contains(search_query)) 2274 || self.farm_display_name.to_lowercase().contains(search_query) 2275 } 2276 2277 fn matches_fulfillment_methods(&self, selected: &BTreeSet<FarmOrderMethod>) -> bool { 2278 selected.is_empty() 2279 || self 2280 .fulfillment_methods() 2281 .iter() 2282 .any(|method| selected.contains(method)) 2283 } 2284 2285 fn detail_text(&self) -> Option<String> { 2286 self.subtitle.clone() 2287 } 2288 2289 fn into_listing_row(self, now_utc: &str) -> Result<BuyerListingRow, AppSqliteError> { 2290 let price = self 2291 .price_presentation() 2292 .ok_or(AppSqliteError::InvalidProjection { 2293 reason: "buyer listing price is missing", 2294 })?; 2295 let availability = self.availability_summary(now_utc)?; 2296 let stock = self.stock_summary(); 2297 let fulfillment_methods = self.fulfillment_methods(); 2298 let next_fulfillment_window_label = Some(self.fulfillment_summary_label()?); 2299 2300 Ok(BuyerListingRow { 2301 product_id: self.product_id, 2302 farm_id: self.farm_id, 2303 farm_display_name: self.farm_display_name, 2304 listing_relays: self.listing_relays, 2305 title: self.title, 2306 subtitle: self.subtitle, 2307 price, 2308 availability, 2309 stock, 2310 fulfillment_methods, 2311 next_fulfillment_window_label, 2312 }) 2313 } 2314 2315 fn availability_summary( 2316 &self, 2317 now_utc: &str, 2318 ) -> Result<ProductAvailabilitySummary, AppSqliteError> { 2319 let starts_at = 2320 self.fulfillment_starts_at 2321 .clone() 2322 .ok_or(AppSqliteError::InvalidProjection { 2323 reason: "buyer listing fulfillment start is missing", 2324 })?; 2325 let ends_at = 2326 self.fulfillment_ends_at 2327 .clone() 2328 .ok_or(AppSqliteError::InvalidProjection { 2329 reason: "buyer listing fulfillment end is missing", 2330 })?; 2331 let state = if starts_at.as_str() <= now_utc { 2332 ProductAvailabilityState::Open 2333 } else { 2334 ProductAvailabilityState::Scheduled 2335 }; 2336 2337 Ok(ProductAvailabilitySummary { 2338 state, 2339 label: self 2340 .fulfillment_window_label 2341 .clone() 2342 .unwrap_or_else(|| format_window_label(&starts_at, &ends_at)), 2343 }) 2344 } 2345 2346 fn fulfillment_summary_label(&self) -> Result<String, AppSqliteError> { 2347 match ( 2348 self.fulfillment_window_label.clone(), 2349 self.fulfillment_starts_at.as_deref(), 2350 self.fulfillment_ends_at.as_deref(), 2351 ) { 2352 (Some(label), _, _) => Ok(label), 2353 (None, Some(starts_at), Some(ends_at)) => Ok(format_window_label(starts_at, ends_at)), 2354 _ => Err(AppSqliteError::InvalidProjection { 2355 reason: "buyer listing fulfillment summary is missing", 2356 }), 2357 } 2358 } 2359 2360 fn stock_summary(&self) -> ProductStockSummary { 2361 let quantity = self.stock_count; 2362 let state = match quantity { 2363 Some(0) => ProductStockState::SoldOut, 2364 Some(quantity) if quantity <= BUYER_LOW_STOCK_THRESHOLD => ProductStockState::LowStock, 2365 Some(_) => ProductStockState::InStock, 2366 None => ProductStockState::Unset, 2367 }; 2368 2369 ProductStockSummary { 2370 quantity, 2371 unit_label: Some(self.unit_label.clone()), 2372 state, 2373 } 2374 } 2375 2376 fn price_presentation(&self) -> Option<ProductPricePresentation> { 2377 self.price_minor_units 2378 .filter(|amount| *amount > 0) 2379 .map(|amount_minor_units| ProductPricePresentation { 2380 amount_minor_units, 2381 currency_code: normalize_currency_code(&self.price_currency), 2382 unit_label: self.unit_label.clone(), 2383 }) 2384 } 2385 2386 fn fulfillment_methods(&self) -> BTreeSet<FarmOrderMethod> { 2387 let mut methods = BTreeSet::new(); 2388 if self.pickup_enabled 2389 || (!self.delivery_enabled && !self.shipping_enabled && self.pickup_location_present) 2390 { 2391 methods.insert(FarmOrderMethod::Pickup); 2392 } 2393 if self.delivery_enabled { 2394 methods.insert(FarmOrderMethod::Delivery); 2395 } 2396 if self.shipping_enabled { 2397 methods.insert(FarmOrderMethod::Shipping); 2398 } 2399 2400 methods 2401 } 2402 } 2403 2404 #[derive(Clone, Debug, Eq, PartialEq)] 2405 struct BuyerCartLineRecord { 2406 listing: BuyerListingRecord, 2407 quantity: u32, 2408 } 2409 2410 #[derive(Clone, Debug, Default, Eq, PartialEq)] 2411 struct VisibleListingIndex { 2412 by_product_id: BTreeMap<ProductId, BuyerListingRecord>, 2413 by_listing_addr: BTreeMap<String, BuyerListingRecord>, 2414 } 2415 2416 impl VisibleListingIndex { 2417 fn from_records(records: impl IntoIterator<Item = BuyerListingRecord>) -> Self { 2418 let mut index = Self::default(); 2419 for record in records { 2420 if let Some(listing_addr) = record.listing_addr.as_deref() { 2421 index 2422 .by_listing_addr 2423 .insert(listing_addr.to_owned(), record.clone()); 2424 } 2425 index.by_product_id.insert(record.product_id, record); 2426 } 2427 index 2428 } 2429 2430 fn resolve(&self, order_line: &RepeatDemandOrderLine) -> Option<BuyerListingRecord> { 2431 self.by_product_id 2432 .get(&order_line.product_id) 2433 .or_else(|| { 2434 order_line 2435 .listing_addr 2436 .as_deref() 2437 .and_then(|listing_addr| self.by_listing_addr.get(listing_addr)) 2438 }) 2439 .cloned() 2440 } 2441 } 2442 2443 #[derive(Clone, Debug, Default, Eq, PartialEq)] 2444 struct BuyerCartLineSnapshot { 2445 listing_bin_id: Option<String>, 2446 farm_key: Option<String>, 2447 listing_addr: Option<String>, 2448 listing_event_id: Option<String>, 2449 listing_relays: Vec<String>, 2450 seller_pubkey: Option<String>, 2451 } 2452 2453 impl BuyerCartLineRecord { 2454 fn into_projection(self) -> Result<BuyerCartLineProjection, AppSqliteError> { 2455 let unit_price = 2456 self.listing 2457 .price_presentation() 2458 .ok_or(AppSqliteError::InvalidProjection { 2459 reason: "buyer cart line price is missing", 2460 })?; 2461 let line_total_minor_units = unit_price 2462 .amount_minor_units 2463 .checked_mul(self.quantity) 2464 .ok_or(AppSqliteError::InvalidProjection { 2465 reason: "buyer cart line total overflowed", 2466 })?; 2467 2468 Ok(BuyerCartLineProjection { 2469 product_id: self.listing.product_id, 2470 farm_id: self.listing.farm_id, 2471 farm_display_name: self.listing.farm_display_name.clone(), 2472 title: self.listing.title.clone(), 2473 quantity: self.quantity, 2474 unit_price, 2475 line_total_minor_units, 2476 fulfillment_summary: self.listing.fulfillment_summary_label()?, 2477 }) 2478 } 2479 } 2480 2481 #[derive(Clone, Debug, Eq, PartialEq)] 2482 struct RepeatDemandOrderLine { 2483 product_id: ProductId, 2484 quantity: u32, 2485 listing_addr: Option<String>, 2486 } 2487 2488 #[derive(Clone, Debug, Eq, PartialEq)] 2489 struct RepeatDemandCandidate { 2490 farm_id: FarmId, 2491 farm_display_name: String, 2492 available_lines: Vec<BuyerCartLineRecord>, 2493 handoff: RepeatDemandHandoffProjection, 2494 } 2495 2496 fn validate_cart_projection(cart: &BuyerCartProjection) -> Result<(), AppSqliteError> { 2497 if cart.lines.is_empty() { 2498 return Ok(()); 2499 } 2500 2501 let farm_id = cart.farm_id.ok_or(AppSqliteError::InvalidProjection { 2502 reason: "buyer cart farm is required when cart has lines", 2503 })?; 2504 2505 for line in &cart.lines { 2506 if line.quantity == 0 { 2507 return Err(AppSqliteError::InvalidProjection { 2508 reason: "buyer cart quantities must stay positive", 2509 }); 2510 } 2511 if line.farm_id != farm_id { 2512 return Err(AppSqliteError::InvalidProjection { 2513 reason: "buyer cart must remain single farm", 2514 }); 2515 } 2516 } 2517 2518 Ok(()) 2519 } 2520 2521 fn next_buyer_cart_for_repeat_demand( 2522 mut current_cart: BuyerCartProjection, 2523 farm_id: FarmId, 2524 farm_display_name: &str, 2525 lines: &[BuyerCartLineRecord], 2526 replace_existing: bool, 2527 ) -> Result<BuyerCartProjection, AppSqliteError> { 2528 if replace_existing || current_cart.is_empty() || current_cart.farm_id != Some(farm_id) { 2529 current_cart.lines.clear(); 2530 } 2531 2532 current_cart.farm_id = Some(farm_id); 2533 current_cart.farm_display_name = Some(farm_display_name.to_owned()); 2534 current_cart.replace_confirmation = None; 2535 2536 for line in lines { 2537 let incoming_line = line.clone().into_projection()?; 2538 2539 if let Some(existing_line) = current_cart 2540 .lines 2541 .iter_mut() 2542 .find(|existing| existing.product_id == incoming_line.product_id) 2543 { 2544 existing_line.quantity = existing_line 2545 .quantity 2546 .checked_add(incoming_line.quantity) 2547 .ok_or(AppSqliteError::InvalidProjection { 2548 reason: "buyer cart quantity overflow", 2549 })?; 2550 existing_line.line_total_minor_units = existing_line 2551 .unit_price 2552 .amount_minor_units 2553 .checked_mul(existing_line.quantity) 2554 .ok_or(AppSqliteError::InvalidProjection { 2555 reason: "buyer cart line total overflow", 2556 })?; 2557 existing_line.fulfillment_summary = incoming_line.fulfillment_summary.clone(); 2558 continue; 2559 } 2560 2561 current_cart.lines.push(incoming_line); 2562 } 2563 2564 refresh_buyer_cart_summary(&mut current_cart)?; 2565 2566 Ok(current_cart) 2567 } 2568 2569 fn sql_placeholders(count: usize) -> String { 2570 std::iter::repeat_n("?", count) 2571 .collect::<Vec<_>>() 2572 .join(", ") 2573 } 2574 2575 fn parse_repeat_demand_product_id(line_id: &str) -> Result<ProductId, AppSqliteError> { 2576 let Some((_, product_id)) = line_id.rsplit_once(':') else { 2577 return Err(AppSqliteError::InvalidProjection { 2578 reason: "repeat demand order line is missing a product id", 2579 }); 2580 }; 2581 2582 parse_typed_id("order_lines.id", product_id.to_owned()) 2583 } 2584 2585 fn parse_order_line_product_id( 2586 line_id: &str, 2587 order_id: OrderId, 2588 ) -> Result<ProductId, AppSqliteError> { 2589 let order_id = order_id.to_string(); 2590 let prefix = format!("{order_id}:"); 2591 let Some(product_id) = line_id.strip_prefix(prefix.as_str()) else { 2592 return Err(AppSqliteError::InvalidProjection { 2593 reason: "buyer order local event line is missing its order id prefix", 2594 }); 2595 }; 2596 2597 parse_typed_id("order_lines.id", product_id.to_owned()) 2598 } 2599 2600 fn refresh_buyer_cart_summary(cart: &mut BuyerCartProjection) -> Result<(), AppSqliteError> { 2601 if cart.lines.is_empty() { 2602 cart.subtotal_minor_units = None; 2603 cart.currency_code = None; 2604 cart.replace_confirmation = None; 2605 return Ok(()); 2606 } 2607 2608 let mut subtotal_minor_units = 0_u32; 2609 let mut currency_code = None; 2610 2611 for line in &cart.lines { 2612 subtotal_minor_units = subtotal_minor_units 2613 .checked_add(line.line_total_minor_units) 2614 .ok_or(AppSqliteError::InvalidProjection { 2615 reason: "buyer cart subtotal overflowed", 2616 })?; 2617 currency_code.get_or_insert_with(|| line.unit_price.currency_code.clone()); 2618 } 2619 2620 cart.subtotal_minor_units = Some(subtotal_minor_units); 2621 cart.currency_code = Some(currency_code.unwrap_or_default()); 2622 2623 Ok(()) 2624 } 2625 2626 fn shared_fulfillment_summary(lines: &[BuyerCartLineProjection]) -> Option<String> { 2627 let first = lines.first()?.fulfillment_summary.clone(); 2628 2629 lines 2630 .iter() 2631 .all(|line| line.fulfillment_summary == first) 2632 .then_some(first) 2633 } 2634 2635 fn buyer_order_review_disabled_reason( 2636 context: &BuyerContext, 2637 cart: &BuyerCartProjection, 2638 fulfillment_summary: Option<&String>, 2639 draft: &BuyerOrderReviewDraft, 2640 ) -> Option<BuyerOrderReviewDisabledReason> { 2641 if cart.lines.is_empty() { 2642 return Some(BuyerOrderReviewDisabledReason::EmptyCart); 2643 } 2644 if fulfillment_summary.is_none() { 2645 return Some(BuyerOrderReviewDisabledReason::MissingFulfillment); 2646 } 2647 if draft.name.trim().is_empty() { 2648 return Some(BuyerOrderReviewDisabledReason::MissingName); 2649 } 2650 if draft.email.trim().is_empty() { 2651 return Some(BuyerOrderReviewDisabledReason::MissingEmail); 2652 } 2653 if matches!(context, BuyerContext::Guest) { 2654 return Some(BuyerOrderReviewDisabledReason::AccountRequired); 2655 } 2656 None 2657 } 2658 2659 fn buyer_order_review_disabled_error(reason: BuyerOrderReviewDisabledReason) -> &'static str { 2660 match reason { 2661 BuyerOrderReviewDisabledReason::EmptyCart => "buyer order review cart is empty", 2662 BuyerOrderReviewDisabledReason::MissingFulfillment => { 2663 "buyer order review fulfillment is unavailable" 2664 } 2665 BuyerOrderReviewDisabledReason::MissingName => "buyer order review buyer name is missing", 2666 BuyerOrderReviewDisabledReason::MissingEmail => "buyer order review buyer email is missing", 2667 BuyerOrderReviewDisabledReason::AccountRequired => { 2668 "buyer order review requires a selected account" 2669 } 2670 } 2671 } 2672 2673 fn shared_fulfillment_window_id( 2674 lines: &[BuyerCartLineRecord], 2675 ) -> Result<Option<FulfillmentWindowId>, AppSqliteError> { 2676 let Some(first) = lines.first() else { 2677 return Err(AppSqliteError::InvalidProjection { 2678 reason: "buyer cart must contain at least one line", 2679 }); 2680 }; 2681 let first_window_id = first.listing.fulfillment_window_id; 2682 2683 if lines 2684 .iter() 2685 .all(|line| line.listing.fulfillment_window_id == first_window_id) 2686 { 2687 Ok(first_window_id) 2688 } else { 2689 Err(AppSqliteError::InvalidProjection { 2690 reason: "buyer cart must share one fulfillment window at order review", 2691 }) 2692 } 2693 } 2694 2695 fn normalize_search_query(search_query: &str) -> Option<String> { 2696 let trimmed = search_query.trim(); 2697 if trimmed.is_empty() { 2698 None 2699 } else { 2700 Some(trimmed.to_lowercase()) 2701 } 2702 } 2703 2704 fn sort_listing_records(records: &mut [BuyerListingRecord], now_utc: &str) { 2705 records.sort_by(|left, right| { 2706 left.fulfillment_starts_at 2707 .cmp(&right.fulfillment_starts_at) 2708 .then_with(|| { 2709 left.availability_state(now_utc) 2710 .cmp(&right.availability_state(now_utc)) 2711 }) 2712 .then_with(|| { 2713 left.farm_display_name 2714 .to_lowercase() 2715 .cmp(&right.farm_display_name.to_lowercase()) 2716 }) 2717 .then_with(|| left.title.to_lowercase().cmp(&right.title.to_lowercase())) 2718 .then_with(|| left.product_id.cmp(&right.product_id)) 2719 }); 2720 } 2721 2722 impl BuyerListingRecord { 2723 fn availability_state(&self, now_utc: &str) -> u8 { 2724 match self.fulfillment_starts_at.as_deref() { 2725 Some(starts_at) if starts_at <= now_utc => 0, 2726 Some(_) => 1, 2727 None => 2, 2728 } 2729 } 2730 } 2731 2732 fn format_window_label(starts_at: &str, ends_at: &str) -> String { 2733 let start_date = starts_at.get(0..10); 2734 let start_time = starts_at.get(11..16); 2735 let end_date = ends_at.get(0..10); 2736 let end_time = ends_at.get(11..16); 2737 2738 match (start_date, start_time, end_date, end_time) { 2739 (Some(start_date), Some(start_time), Some(end_date), Some(end_time)) 2740 if start_date == end_date => 2741 { 2742 format!("{start_date} {start_time}-{end_time} UTC") 2743 } 2744 (Some(start_date), Some(start_time), Some(end_date), Some(end_time)) => { 2745 format!("{start_date} {start_time} UTC to {end_date} {end_time} UTC") 2746 } 2747 _ => starts_at.to_owned(), 2748 } 2749 } 2750 2751 fn format_fulfillment_summary( 2752 label: Option<String>, 2753 starts_at: Option<String>, 2754 ends_at: Option<String>, 2755 ) -> String { 2756 if let Some(label) = empty_string_to_none_option(label) { 2757 return label; 2758 } 2759 2760 match (starts_at.as_deref(), ends_at.as_deref()) { 2761 (Some(starts_at), Some(ends_at)) => format_window_label(starts_at, ends_at), 2762 _ => "Fulfillment pending".to_owned(), 2763 } 2764 } 2765 2766 fn format_quantity_display(quantity: u32, unit_label: &str) -> String { 2767 let trimmed = unit_label.trim(); 2768 if trimmed.is_empty() { 2769 quantity.to_string() 2770 } else { 2771 format!("{quantity} {trimmed}") 2772 } 2773 } 2774 2775 fn normalize_currency_code(value: &str) -> String { 2776 let trimmed = value.trim(); 2777 if trimmed.is_empty() { 2778 "USD".to_owned() 2779 } else { 2780 trimmed.to_ascii_uppercase() 2781 } 2782 } 2783 2784 fn parse_typed_id<T>(field: &'static str, value: String) -> Result<T, AppSqliteError> 2785 where 2786 T: std::str::FromStr, 2787 { 2788 value 2789 .parse() 2790 .map_err(|_| AppSqliteError::DecodeId { field, value }) 2791 } 2792 2793 fn buyer_order_coordination_record_from_row( 2794 row: ( 2795 String, 2796 String, 2797 Option<String>, 2798 String, 2799 Option<String>, 2800 i64, 2801 Option<String>, 2802 ), 2803 ) -> Result<BuyerOrderCoordinationRecord, AppSqliteError> { 2804 let (order_id, buyer_context_key, record_id, state, payload_json, attempt_count, last_error) = 2805 row; 2806 let attempt_count = 2807 u32::try_from(attempt_count).map_err(|_| AppSqliteError::InvalidProjection { 2808 reason: "buyer order coordination attempt count must be non-negative", 2809 })?; 2810 2811 Ok(BuyerOrderCoordinationRecord { 2812 order_id: parse_typed_id("buyer_order_coordination_records.order_id", order_id)?, 2813 buyer_context_key, 2814 record_id: record_id.and_then(empty_string_to_none), 2815 state: BuyerOrderCoordinationState::from_storage_key( 2816 "buyer_order_coordination_records.state", 2817 state, 2818 )?, 2819 payload_json: payload_json.and_then(empty_string_to_none), 2820 attempt_count, 2821 last_error_message: last_error.and_then(empty_string_to_none), 2822 }) 2823 } 2824 2825 fn parse_optional_typed_id<T>( 2826 field: &'static str, 2827 value: Option<String>, 2828 ) -> Result<Option<T>, AppSqliteError> 2829 where 2830 T: std::str::FromStr, 2831 { 2832 value.map(|value| parse_typed_id(field, value)).transpose() 2833 } 2834 2835 fn parse_sqlite_bool(field: &'static str, value: i64) -> Result<bool, AppSqliteError> { 2836 match value { 2837 0 => Ok(false), 2838 1 => Ok(true), 2839 _ => Err(AppSqliteError::DecodeEnum { 2840 field, 2841 value: value.to_string(), 2842 }), 2843 } 2844 } 2845 2846 fn parse_product_status( 2847 field: &'static str, 2848 value: String, 2849 ) -> Result<ProductStatus, AppSqliteError> { 2850 match value.as_str() { 2851 "draft" => Ok(ProductStatus::Draft), 2852 "published" => Ok(ProductStatus::Published), 2853 "paused" => Ok(ProductStatus::Paused), 2854 "archived" => Ok(ProductStatus::Archived), 2855 _ => Err(AppSqliteError::DecodeEnum { field, value }), 2856 } 2857 } 2858 2859 fn parse_order_status(field: &'static str, value: String) -> Result<OrderStatus, AppSqliteError> { 2860 match value.as_str() { 2861 "needs_action" => Ok(OrderStatus::NeedsAction), 2862 "scheduled" => Ok(OrderStatus::Scheduled), 2863 "packed" => Ok(OrderStatus::Packed), 2864 "completed" => Ok(OrderStatus::Completed), 2865 "declined" => Ok(OrderStatus::Declined), 2866 "needs_review" => Ok(OrderStatus::NeedsReview), 2867 _ => Err(AppSqliteError::DecodeEnum { field, value }), 2868 } 2869 } 2870 2871 fn empty_string_to_none(value: String) -> Option<String> { 2872 let trimmed = value.trim(); 2873 if trimmed.is_empty() { 2874 None 2875 } else { 2876 Some(trimmed.to_owned()) 2877 } 2878 } 2879 2880 fn empty_string_to_none_option(value: Option<String>) -> Option<String> { 2881 value.and_then(empty_string_to_none) 2882 } 2883 2884 fn encode_listing_relays(relays: &[String]) -> Result<Option<String>, AppSqliteError> { 2885 let relays = normalized_listing_relays(relays.iter().map(String::as_str)); 2886 if relays.is_empty() { 2887 return Ok(None); 2888 } 2889 2890 serde_json::to_string(&relays) 2891 .map(Some) 2892 .map_err(|_| AppSqliteError::InvalidProjection { 2893 reason: "listing relay provenance must encode", 2894 }) 2895 } 2896 2897 fn listing_relays_from_json(value: Option<String>) -> Result<Vec<String>, AppSqliteError> { 2898 let Some(value) = empty_string_to_none_option(value) else { 2899 return Ok(Vec::new()); 2900 }; 2901 let value = serde_json::from_str::<Value>(value.as_str()).map_err(|_| { 2902 AppSqliteError::InvalidProjection { 2903 reason: "listing relay provenance json must decode", 2904 } 2905 })?; 2906 if let Some(relays) = value.as_array() { 2907 return Ok(relays_from_json_array(relays)); 2908 } 2909 2910 let relay_key = match value.get("state").and_then(Value::as_str) { 2911 Some("acknowledged") => Some("acknowledged_relays"), 2912 Some("observed") => Some("observed_relays"), 2913 _ => None, 2914 }; 2915 if let Some(key) = relay_key { 2916 return Ok(value 2917 .get(key) 2918 .and_then(Value::as_array) 2919 .map(|relays| relays_from_json_array(relays)) 2920 .unwrap_or_default()); 2921 } 2922 2923 Ok(Vec::new()) 2924 } 2925 2926 fn relays_from_json_array(relays: &[Value]) -> Vec<String> { 2927 normalized_listing_relays(relays.iter().filter_map(Value::as_str)) 2928 } 2929 2930 fn normalized_listing_relays<'a>(relays: impl IntoIterator<Item = &'a str>) -> Vec<String> { 2931 let mut seen = BTreeSet::new(); 2932 let mut normalized = Vec::new(); 2933 for relay in relays { 2934 let relay = relay.trim(); 2935 if !relay.is_empty() && seen.insert(relay.to_owned()) { 2936 normalized.push(relay.to_owned()); 2937 } 2938 } 2939 normalized 2940 } 2941 2942 #[cfg(test)] 2943 mod tests { 2944 use std::collections::BTreeSet; 2945 2946 use radroots_app_view::{ 2947 BuyerContext, BuyerOrderReviewDisabledReason, BuyerOrderStatus, FarmId, FarmOrderMethod, 2948 FulfillmentWindowId, OrderId, PickupLocationId, ProductId, TradeAgreementStatus, 2949 TradeInventoryStatus, TradeRevisionStatus, TradeWorkflowSource, 2950 }; 2951 use rusqlite::{Connection, params}; 2952 use serde_json::json; 2953 2954 use crate::{ 2955 AppSqliteError, AppSqliteStore, BuyerRepeatDemandApplyOutcome, DatabaseTarget, 2956 SelectedBuyerOrderScope, 2957 }; 2958 2959 use super::AppBuyerRepository; 2960 2961 const LINKED_BUYER_PUBKEY: &str = 2962 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; 2963 2964 #[test] 2965 fn selected_buyer_order_scope_uses_only_valid_context_keys() { 2966 let upper_pubkey = LINKED_BUYER_PUBKEY.to_ascii_uppercase(); 2967 let valid_scope = SelectedBuyerOrderScope::for_selected_account( 2968 " acct_buyer ", 2969 Some(upper_pubkey.as_str()), 2970 ); 2971 let expected_valid = vec![ 2972 "account:acct_buyer".to_owned(), 2973 format!("nostr:{LINKED_BUYER_PUBKEY}"), 2974 ]; 2975 2976 let malformed_scope = 2977 SelectedBuyerOrderScope::for_selected_account("acct_buyer", Some("buyer-pubkey")); 2978 let expected_account_only = vec!["account:acct_buyer".to_owned()]; 2979 2980 let blank_scope = SelectedBuyerOrderScope::for_selected_account(" ", Some(" ")); 2981 let duplicate_scope = SelectedBuyerOrderScope::from_context_keys([ 2982 format!("nostr:{LINKED_BUYER_PUBKEY}"), 2983 format!("nostr:{LINKED_BUYER_PUBKEY}"), 2984 ]); 2985 let expected_deduped = vec![format!("nostr:{LINKED_BUYER_PUBKEY}")]; 2986 2987 assert_eq!(valid_scope.context_keys(), expected_valid.as_slice()); 2988 assert_eq!( 2989 malformed_scope.context_keys(), 2990 expected_account_only.as_slice() 2991 ); 2992 assert!(blank_scope.context_keys().is_empty()); 2993 assert_eq!(duplicate_scope.context_keys(), expected_deduped.as_slice()); 2994 } 2995 2996 #[test] 2997 fn listing_relays_from_json_uses_only_acknowledged_or_observed_relays() { 2998 assert_eq!( 2999 super::listing_relays_from_json(Some( 3000 json!({ 3001 "state": "acknowledged", 3002 "target_relays": ["wss://target.example"], 3003 "connected_relays": ["wss://connected.example"], 3004 "acknowledged_relays": ["wss://ack.example"] 3005 }) 3006 .to_string() 3007 )) 3008 .expect("acknowledged relays"), 3009 vec!["wss://ack.example"] 3010 ); 3011 assert_eq!( 3012 super::listing_relays_from_json(Some( 3013 json!({ 3014 "state": "observed", 3015 "target_relays": ["wss://target.example"], 3016 "connected_relays": ["wss://connected.example"], 3017 "observed_relays": ["wss://observed.example"] 3018 }) 3019 .to_string() 3020 )) 3021 .expect("observed relays"), 3022 vec!["wss://observed.example"] 3023 ); 3024 assert!( 3025 super::listing_relays_from_json(Some( 3026 json!({ 3027 "state": "observed", 3028 "target_relays": ["wss://target.example"], 3029 "connected_relays": ["wss://connected.example"], 3030 "observed_relays": [] 3031 }) 3032 .to_string() 3033 )) 3034 .expect("unknown observed relays") 3035 .is_empty() 3036 ); 3037 assert!( 3038 super::listing_relays_from_json(Some( 3039 json!({ 3040 "state": "pending", 3041 "target_relays": ["wss://target.example"], 3042 "connected_relays": ["wss://connected.example"] 3043 }) 3044 .to_string() 3045 )) 3046 .expect("pending relays") 3047 .is_empty() 3048 ); 3049 } 3050 3051 #[test] 3052 fn buyer_listings_and_product_detail_follow_catalog_truth() { 3053 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3054 let connection = store.connection(); 3055 let repository = AppBuyerRepository::new(connection); 3056 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3057 let future_window_id = insert_window( 3058 connection, 3059 farm_id, 3060 Some(insert_pickup_location(connection, farm_id, "Barn pickup")), 3061 "Friday pickup", 3062 "2099-04-18T16:00:00Z", 3063 "2099-04-18T18:00:00Z", 3064 ); 3065 3066 insert_farm_setup_binding(connection, "acct_farmer", farm_id, true, false, false); 3067 let visible_product_id = insert_product( 3068 connection, 3069 farm_id, 3070 SeedProduct { 3071 title: "Salad mix", 3072 subtitle: "Spring blend", 3073 status: "published", 3074 unit_label: "bag", 3075 price_minor_units: Some(650), 3076 price_currency: "USD", 3077 stock_count: Some(8), 3078 availability_window_id: Some(future_window_id), 3079 }, 3080 ); 3081 insert_product( 3082 connection, 3083 farm_id, 3084 SeedProduct { 3085 title: "Pea shoots", 3086 subtitle: "Tray-grown", 3087 status: "draft", 3088 unit_label: "bag", 3089 price_minor_units: Some(450), 3090 price_currency: "USD", 3091 stock_count: Some(4), 3092 availability_window_id: Some(future_window_id), 3093 }, 3094 ); 3095 insert_product( 3096 connection, 3097 farm_id, 3098 SeedProduct { 3099 title: "Sold out carrots", 3100 subtitle: "", 3101 status: "published", 3102 unit_label: "bunch", 3103 price_minor_units: Some(500), 3104 price_currency: "USD", 3105 stock_count: Some(0), 3106 availability_window_id: Some(future_window_id), 3107 }, 3108 ); 3109 3110 let listings = repository 3111 .load_buyer_listings("salad", &BTreeSet::from([FarmOrderMethod::Pickup])) 3112 .expect("buyer listings should load"); 3113 let detail = repository 3114 .load_buyer_product_detail(visible_product_id) 3115 .expect("buyer detail should load") 3116 .expect("buyer detail should exist"); 3117 3118 assert_eq!(listings.rows.len(), 1); 3119 assert_eq!(listings.rows[0].title, "Salad mix"); 3120 assert_eq!( 3121 listings.rows[0].fulfillment_methods, 3122 BTreeSet::from([FarmOrderMethod::Pickup]) 3123 ); 3124 assert_eq!( 3125 listings.rows[0].next_fulfillment_window_label.as_deref(), 3126 Some("Friday pickup") 3127 ); 3128 assert_eq!(detail.selected_quantity, 1); 3129 assert_eq!(detail.detail_text.as_deref(), Some("Spring blend")); 3130 } 3131 3132 #[test] 3133 fn buyer_orders_can_read_account_and_linked_nostr_contexts() { 3134 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3135 let connection = store.connection(); 3136 let repository = AppBuyerRepository::new(connection); 3137 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3138 let account_order_id = OrderId::new(); 3139 let relay_order_id = OrderId::new(); 3140 let linked_context_key = format!("nostr:{LINKED_BUYER_PUBKEY}"); 3141 insert_order( 3142 connection, 3143 account_order_id, 3144 farm_id, 3145 "1001", 3146 "scheduled", 3147 Some("account:acct_buyer"), 3148 "casey@example.com", 3149 "555-0100", 3150 "", 3151 ); 3152 insert_order( 3153 connection, 3154 relay_order_id, 3155 farm_id, 3156 "1002", 3157 "packed", 3158 Some(linked_context_key.as_str()), 3159 "", 3160 "", 3161 "", 3162 ); 3163 3164 let scope = 3165 SelectedBuyerOrderScope::for_selected_account("acct_buyer", Some(LINKED_BUYER_PUBKEY)); 3166 let linked_orders = repository 3167 .load_buyer_orders_for_scope(&scope) 3168 .expect("linked buyer orders should load"); 3169 let linked_detail = repository 3170 .load_buyer_order_detail_for_scope(&scope, relay_order_id) 3171 .expect("linked buyer order detail should load") 3172 .expect("linked buyer order detail should exist"); 3173 let account_only_orders = repository 3174 .load_buyer_orders(&BuyerContext::account("acct_buyer")) 3175 .expect("account buyer orders should load"); 3176 let account_only_detail = repository 3177 .load_buyer_order_detail(&BuyerContext::account("acct_buyer"), relay_order_id) 3178 .expect("account buyer order detail should load"); 3179 3180 assert_eq!(linked_orders.rows.len(), 2); 3181 assert!( 3182 linked_orders 3183 .rows 3184 .iter() 3185 .any(|row| row.order_id == account_order_id) 3186 ); 3187 assert!( 3188 linked_orders 3189 .rows 3190 .iter() 3191 .any(|row| row.order_id == relay_order_id) 3192 ); 3193 assert_eq!(linked_detail.order_id, relay_order_id); 3194 assert_eq!(linked_detail.status, BuyerOrderStatus::Ready); 3195 assert_eq!(account_only_orders.rows.len(), 1); 3196 assert_eq!(account_only_orders.rows[0].order_id, account_order_id); 3197 assert!(account_only_detail.is_none()); 3198 } 3199 3200 #[test] 3201 fn buyer_order_review_requires_account_before_order_write() { 3202 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3203 let connection = store.connection(); 3204 let repository = AppBuyerRepository::new(connection); 3205 let context = BuyerContext::Guest; 3206 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3207 let pickup_location_id = insert_pickup_location(connection, farm_id, "Barn pickup"); 3208 let future_window_id = insert_window( 3209 connection, 3210 farm_id, 3211 Some(pickup_location_id), 3212 "Friday pickup", 3213 "2099-04-18T16:00:00Z", 3214 "2099-04-18T18:00:00Z", 3215 ); 3216 3217 insert_farm_setup_binding(connection, "acct_farmer", farm_id, true, false, false); 3218 let product_id = insert_product( 3219 connection, 3220 farm_id, 3221 SeedProduct { 3222 title: "Salad mix", 3223 subtitle: "Spring blend", 3224 status: "published", 3225 unit_label: "bag", 3226 price_minor_units: Some(650), 3227 price_currency: "USD", 3228 stock_count: Some(8), 3229 availability_window_id: Some(future_window_id), 3230 }, 3231 ); 3232 let listing = repository 3233 .load_buyer_product_detail(product_id) 3234 .expect("buyer detail should load") 3235 .expect("listing should exist") 3236 .listing; 3237 3238 repository 3239 .replace_buyer_cart( 3240 &context, 3241 &radroots_app_view::BuyerCartProjection { 3242 farm_id: Some(farm_id), 3243 farm_display_name: Some("Willow Farm".to_owned()), 3244 lines: vec![radroots_app_view::BuyerCartLineProjection { 3245 product_id: listing.product_id, 3246 farm_id: listing.farm_id, 3247 farm_display_name: listing.farm_display_name.clone(), 3248 title: listing.title.clone(), 3249 quantity: 2, 3250 unit_price: listing.price.clone(), 3251 line_total_minor_units: 1300, 3252 fulfillment_summary: "Friday pickup".to_owned(), 3253 }], 3254 subtotal_minor_units: Some(1300), 3255 currency_code: Some("USD".to_owned()), 3256 replace_confirmation: None, 3257 }, 3258 ) 3259 .expect("buyer cart should save"); 3260 repository 3261 .save_buyer_order_review_draft( 3262 &context, 3263 &radroots_app_view::BuyerOrderReviewDraft { 3264 name: "Casey Buyer".to_owned(), 3265 email: "casey@example.com".to_owned(), 3266 phone: "555-0101".to_owned(), 3267 order_note: "Leave by the cooler".to_owned(), 3268 }, 3269 ) 3270 .expect("buyer order review draft should save"); 3271 3272 let order_review = repository 3273 .load_buyer_order_review(&context) 3274 .expect("buyer order review should load"); 3275 let error = repository 3276 .place_buyer_order(&context) 3277 .expect_err("guest order review should require an account"); 3278 let cart_after_order_review = repository 3279 .load_buyer_cart(&context) 3280 .expect("buyer cart should remain after blocked order review"); 3281 3282 assert!(matches!(error, AppSqliteError::InvalidProjection { .. })); 3283 assert!(!order_review.can_place_order); 3284 assert_eq!( 3285 order_review.place_order_disabled_reason, 3286 Some(BuyerOrderReviewDisabledReason::AccountRequired) 3287 ); 3288 assert_eq!(order_review.summary.line_count, 1); 3289 assert_eq!(cart_after_order_review.lines.len(), 1); 3290 assert_eq!(cart_after_order_review.farm_id, Some(farm_id)); 3291 assert_eq!(row_count(connection, "orders"), 0); 3292 assert_eq!(row_count(connection, "order_lines"), 0); 3293 assert_eq!(row_count(connection, "buyer_order_coordination_records"), 0); 3294 assert_eq!(row_count(connection, "local_outbox"), 0); 3295 assert_eq!(row_count(connection, "local_conflicts"), 0); 3296 assert_eq!(row_count(connection, "sync_checkpoints"), 0); 3297 } 3298 3299 #[test] 3300 fn buyer_order_history_derives_repeat_demand_from_current_listing_truth() { 3301 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3302 let connection = store.connection(); 3303 let repository = AppBuyerRepository::new(connection); 3304 let context = BuyerContext::account("acct_buyer"); 3305 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3306 let pickup_location_id = insert_pickup_location(connection, farm_id, "Barn pickup"); 3307 let future_window_id = insert_window( 3308 connection, 3309 farm_id, 3310 Some(pickup_location_id), 3311 "Friday pickup", 3312 "2099-04-18T16:00:00Z", 3313 "2099-04-18T18:00:00Z", 3314 ); 3315 3316 insert_farm_setup_binding(connection, "acct_farmer", farm_id, true, false, false); 3317 let available_product_id = insert_product( 3318 connection, 3319 farm_id, 3320 SeedProduct { 3321 title: "Salad mix", 3322 subtitle: "Spring blend", 3323 status: "published", 3324 unit_label: "bag", 3325 price_minor_units: Some(650), 3326 price_currency: "USD", 3327 stock_count: Some(8), 3328 availability_window_id: Some(future_window_id), 3329 }, 3330 ); 3331 let unavailable_product_id = insert_product( 3332 connection, 3333 farm_id, 3334 SeedProduct { 3335 title: "Pea shoots", 3336 subtitle: "Tray-grown", 3337 status: "published", 3338 unit_label: "bag", 3339 price_minor_units: Some(450), 3340 price_currency: "USD", 3341 stock_count: Some(4), 3342 availability_window_id: Some(future_window_id), 3343 }, 3344 ); 3345 let available_listing = repository 3346 .load_buyer_product_detail(available_product_id) 3347 .expect("available buyer detail should load") 3348 .expect("available listing should exist") 3349 .listing; 3350 let unavailable_listing = repository 3351 .load_buyer_product_detail(unavailable_product_id) 3352 .expect("unavailable buyer detail should load") 3353 .expect("unavailable listing should exist") 3354 .listing; 3355 3356 repository 3357 .replace_buyer_cart( 3358 &context, 3359 &radroots_app_view::BuyerCartProjection { 3360 farm_id: Some(farm_id), 3361 farm_display_name: Some("Willow Farm".to_owned()), 3362 lines: vec![ 3363 radroots_app_view::BuyerCartLineProjection { 3364 product_id: available_listing.product_id, 3365 farm_id: available_listing.farm_id, 3366 farm_display_name: available_listing.farm_display_name.clone(), 3367 title: available_listing.title.clone(), 3368 quantity: 2, 3369 unit_price: available_listing.price.clone(), 3370 line_total_minor_units: 1300, 3371 fulfillment_summary: "Friday pickup".to_owned(), 3372 }, 3373 radroots_app_view::BuyerCartLineProjection { 3374 product_id: unavailable_listing.product_id, 3375 farm_id: unavailable_listing.farm_id, 3376 farm_display_name: unavailable_listing.farm_display_name.clone(), 3377 title: unavailable_listing.title.clone(), 3378 quantity: 1, 3379 unit_price: unavailable_listing.price.clone(), 3380 line_total_minor_units: 450, 3381 fulfillment_summary: "Friday pickup".to_owned(), 3382 }, 3383 ], 3384 subtotal_minor_units: Some(1750), 3385 currency_code: Some("USD".to_owned()), 3386 replace_confirmation: None, 3387 }, 3388 ) 3389 .expect("buyer cart should save"); 3390 repository 3391 .save_buyer_order_review_draft( 3392 &context, 3393 &radroots_app_view::BuyerOrderReviewDraft { 3394 name: "Casey Buyer".to_owned(), 3395 email: "casey@example.com".to_owned(), 3396 phone: String::new(), 3397 order_note: String::new(), 3398 }, 3399 ) 3400 .expect("buyer order review draft should save"); 3401 let order_id = repository 3402 .place_buyer_order(&context) 3403 .expect("buyer order review should place order"); 3404 3405 connection 3406 .execute( 3407 "update products set status = 'archived' where id = ?1", 3408 params![unavailable_product_id.to_string()], 3409 ) 3410 .expect("product should archive"); 3411 3412 let buyer_orders = repository 3413 .load_buyer_orders(&context) 3414 .expect("buyer orders should load"); 3415 let buyer_order_detail = repository 3416 .load_buyer_order_detail(&context, order_id) 3417 .expect("buyer order detail should load") 3418 .expect("buyer order detail should exist"); 3419 let row_repeat_demand = buyer_orders.rows[0] 3420 .repeat_demand 3421 .as_ref() 3422 .expect("repeat demand should derive for buyer order row"); 3423 let detail_repeat_demand = buyer_order_detail 3424 .repeat_demand 3425 .as_ref() 3426 .expect("repeat demand should derive for buyer order detail"); 3427 3428 assert_eq!(buyer_orders.rows.len(), 1); 3429 assert_eq!( 3430 row_repeat_demand.eligibility, 3431 radroots_app_view::RepeatDemandEligibility::Partial 3432 ); 3433 assert_eq!(row_repeat_demand.available_item_count, 1); 3434 assert_eq!(row_repeat_demand.unavailable_item_count, 1); 3435 assert_eq!(detail_repeat_demand, row_repeat_demand); 3436 assert_eq!(buyer_order_detail.items.len(), 2); 3437 assert!( 3438 buyer_order_detail 3439 .items 3440 .iter() 3441 .any(|item| item.line_total_minor_units == Some(1300)) 3442 ); 3443 assert!( 3444 buyer_order_detail 3445 .items 3446 .iter() 3447 .any(|item| item.line_total_minor_units == Some(450)) 3448 ); 3449 assert_eq!(buyer_order_detail.economics.total_minor_units, Some(1750)); 3450 assert_eq!( 3451 buyer_order_detail.economics.currency_code.as_deref(), 3452 Some("USD") 3453 ); 3454 } 3455 3456 #[test] 3457 fn buyer_repeat_demand_from_linked_order_writes_selected_account_cart() { 3458 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3459 let connection = store.connection(); 3460 let repository = AppBuyerRepository::new(connection); 3461 let context = BuyerContext::account("acct_buyer"); 3462 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3463 let pickup_location_id = insert_pickup_location(connection, farm_id, "Barn pickup"); 3464 let future_window_id = insert_window( 3465 connection, 3466 farm_id, 3467 Some(pickup_location_id), 3468 "Friday pickup", 3469 "2099-04-18T16:00:00Z", 3470 "2099-04-18T18:00:00Z", 3471 ); 3472 3473 insert_farm_setup_binding(connection, "acct_farmer", farm_id, true, false, false); 3474 let product_id = insert_product( 3475 connection, 3476 farm_id, 3477 SeedProduct { 3478 title: "Salad mix", 3479 subtitle: "Spring blend", 3480 status: "published", 3481 unit_label: "bag", 3482 price_minor_units: Some(650), 3483 price_currency: "USD", 3484 stock_count: Some(8), 3485 availability_window_id: Some(future_window_id), 3486 }, 3487 ); 3488 let listing = repository 3489 .load_buyer_product_detail(product_id) 3490 .expect("buyer detail should load") 3491 .expect("listing should exist") 3492 .listing; 3493 3494 repository 3495 .replace_buyer_cart( 3496 &context, 3497 &radroots_app_view::BuyerCartProjection { 3498 farm_id: Some(farm_id), 3499 farm_display_name: Some("Willow Farm".to_owned()), 3500 lines: vec![radroots_app_view::BuyerCartLineProjection { 3501 product_id: listing.product_id, 3502 farm_id: listing.farm_id, 3503 farm_display_name: listing.farm_display_name.clone(), 3504 title: listing.title.clone(), 3505 quantity: 2, 3506 unit_price: listing.price.clone(), 3507 line_total_minor_units: 1300, 3508 fulfillment_summary: "Friday pickup".to_owned(), 3509 }], 3510 subtotal_minor_units: Some(1300), 3511 currency_code: Some("USD".to_owned()), 3512 replace_confirmation: None, 3513 }, 3514 ) 3515 .expect("buyer cart should save"); 3516 repository 3517 .save_buyer_order_review_draft( 3518 &context, 3519 &radroots_app_view::BuyerOrderReviewDraft { 3520 name: "Casey Buyer".to_owned(), 3521 email: "casey@example.com".to_owned(), 3522 phone: String::new(), 3523 order_note: String::new(), 3524 }, 3525 ) 3526 .expect("buyer order review draft should save"); 3527 let order_id = repository 3528 .place_buyer_order(&context) 3529 .expect("buyer order review should place order"); 3530 3531 connection 3532 .execute( 3533 "update orders set buyer_context_key = ?1 where id = ?2", 3534 params![format!("nostr:{LINKED_BUYER_PUBKEY}"), order_id.to_string()], 3535 ) 3536 .expect("linked order context should mutate"); 3537 3538 let scope = 3539 SelectedBuyerOrderScope::for_selected_account("acct_buyer", Some(LINKED_BUYER_PUBKEY)); 3540 let linked_detail = repository 3541 .load_buyer_order_detail_for_scope(&scope, order_id) 3542 .expect("linked detail should load") 3543 .expect("linked order should be visible"); 3544 let account_only_detail = repository 3545 .load_buyer_order_detail(&context, order_id) 3546 .expect("account-only detail should load"); 3547 let outcome = repository 3548 .apply_buyer_repeat_demand_from_scope_to_cart(&scope, &context, order_id, false) 3549 .expect("linked repeat demand should apply"); 3550 let account_cart = repository 3551 .load_buyer_cart(&context) 3552 .expect("selected account cart should load"); 3553 let nostr_cart_line_count: u32 = connection 3554 .query_row( 3555 "select count(*) from buyer_cart_lines where buyer_context_key = ?1", 3556 params![format!("nostr:{LINKED_BUYER_PUBKEY}")], 3557 |row| row.get(0), 3558 ) 3559 .expect("nostr cart line count should load"); 3560 3561 assert!(linked_detail.repeat_demand.is_some()); 3562 assert!(account_only_detail.is_none()); 3563 assert_eq!(outcome, BuyerRepeatDemandApplyOutcome::Applied); 3564 assert_eq!(account_cart.lines.len(), 1); 3565 assert_eq!(account_cart.lines[0].product_id, product_id); 3566 assert_eq!(account_cart.lines[0].quantity, 2); 3567 assert_eq!(nostr_cart_line_count, 0); 3568 } 3569 3570 #[test] 3571 fn buyer_repeat_demand_requires_current_stock_for_full_historical_quantity() { 3572 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3573 let connection = store.connection(); 3574 let repository = AppBuyerRepository::new(connection); 3575 let context = BuyerContext::account("acct_buyer"); 3576 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3577 let pickup_location_id = insert_pickup_location(connection, farm_id, "Barn pickup"); 3578 let future_window_id = insert_window( 3579 connection, 3580 farm_id, 3581 Some(pickup_location_id), 3582 "Friday pickup", 3583 "2099-04-18T16:00:00Z", 3584 "2099-04-18T18:00:00Z", 3585 ); 3586 3587 insert_farm_setup_binding(connection, "acct_farmer", farm_id, true, false, false); 3588 let product_id = insert_product( 3589 connection, 3590 farm_id, 3591 SeedProduct { 3592 title: "Salad mix", 3593 subtitle: "Spring blend", 3594 status: "published", 3595 unit_label: "bag", 3596 price_minor_units: Some(650), 3597 price_currency: "USD", 3598 stock_count: Some(8), 3599 availability_window_id: Some(future_window_id), 3600 }, 3601 ); 3602 let listing = repository 3603 .load_buyer_product_detail(product_id) 3604 .expect("buyer detail should load") 3605 .expect("listing should exist") 3606 .listing; 3607 3608 repository 3609 .replace_buyer_cart( 3610 &context, 3611 &radroots_app_view::BuyerCartProjection { 3612 farm_id: Some(farm_id), 3613 farm_display_name: Some("Willow Farm".to_owned()), 3614 lines: vec![radroots_app_view::BuyerCartLineProjection { 3615 product_id: listing.product_id, 3616 farm_id: listing.farm_id, 3617 farm_display_name: listing.farm_display_name.clone(), 3618 title: listing.title.clone(), 3619 quantity: 2, 3620 unit_price: listing.price.clone(), 3621 line_total_minor_units: 1300, 3622 fulfillment_summary: "Friday pickup".to_owned(), 3623 }], 3624 subtotal_minor_units: Some(1300), 3625 currency_code: Some("USD".to_owned()), 3626 replace_confirmation: None, 3627 }, 3628 ) 3629 .expect("buyer cart should save"); 3630 repository 3631 .save_buyer_order_review_draft( 3632 &context, 3633 &radroots_app_view::BuyerOrderReviewDraft { 3634 name: "Casey Buyer".to_owned(), 3635 email: "casey@example.com".to_owned(), 3636 phone: String::new(), 3637 order_note: String::new(), 3638 }, 3639 ) 3640 .expect("buyer order review draft should save"); 3641 let order_id = repository 3642 .place_buyer_order(&context) 3643 .expect("buyer order review should place order"); 3644 3645 connection 3646 .execute( 3647 "update products set stock_count = 1 where id = ?1", 3648 params![product_id.to_string()], 3649 ) 3650 .expect("product stock should lower"); 3651 3652 let repeat_demand = repository 3653 .load_buyer_order_detail(&context, order_id) 3654 .expect("buyer order detail should load") 3655 .expect("buyer order detail should exist") 3656 .repeat_demand 3657 .expect("repeat demand should stay visible for unavailable reorder"); 3658 3659 assert_eq!( 3660 repeat_demand.eligibility, 3661 radroots_app_view::RepeatDemandEligibility::Unavailable 3662 ); 3663 assert_eq!(repeat_demand.available_item_count, 0); 3664 assert_eq!(repeat_demand.unavailable_item_count, 1); 3665 assert_eq!( 3666 repository 3667 .apply_buyer_repeat_demand_to_cart(&context, order_id, false) 3668 .expect("repeat demand apply should load"), 3669 BuyerRepeatDemandApplyOutcome::Unavailable 3670 ); 3671 assert!( 3672 repository 3673 .load_buyer_cart(&context) 3674 .expect("buyer cart should reload") 3675 .lines 3676 .is_empty() 3677 ); 3678 } 3679 3680 #[test] 3681 fn buyer_orders_filter_to_context_and_ignore_seller_orders() { 3682 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3683 let connection = store.connection(); 3684 let repository = AppBuyerRepository::new(connection); 3685 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3686 3687 insert_order( 3688 connection, 3689 OrderId::new(), 3690 farm_id, 3691 "R-100", 3692 "needs_action", 3693 None, 3694 "", 3695 "", 3696 "", 3697 ); 3698 insert_order( 3699 connection, 3700 OrderId::new(), 3701 farm_id, 3702 "R-101", 3703 "scheduled", 3704 Some("guest"), 3705 "guest@example.com", 3706 "", 3707 "", 3708 ); 3709 insert_order( 3710 connection, 3711 OrderId::new(), 3712 farm_id, 3713 "R-102", 3714 "packed", 3715 Some("account:acct_buyer"), 3716 "buyer@example.com", 3717 "", 3718 "", 3719 ); 3720 3721 let guest_orders = repository 3722 .load_buyer_orders(&BuyerContext::Guest) 3723 .expect("guest orders should load"); 3724 let account_orders = repository 3725 .load_buyer_orders(&BuyerContext::account("acct_buyer")) 3726 .expect("account orders should load"); 3727 3728 assert_eq!(guest_orders.rows.len(), 1); 3729 assert_eq!(guest_orders.rows[0].order_number, "R-101"); 3730 assert_eq!(account_orders.rows.len(), 1); 3731 assert_eq!(account_orders.rows[0].order_number, "R-102"); 3732 } 3733 3734 #[test] 3735 fn buyer_order_projections_fail_closed_for_invalid_workflow_revision() { 3736 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3737 let connection = store.connection(); 3738 let repository = AppBuyerRepository::new(connection); 3739 let context = BuyerContext::Guest; 3740 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3741 let order_id = OrderId::new(); 3742 3743 insert_order( 3744 connection, 3745 order_id, 3746 farm_id, 3747 "R-100", 3748 "scheduled", 3749 Some("guest"), 3750 "guest@example.com", 3751 "", 3752 "", 3753 ); 3754 set_order_workflow_revision( 3755 connection, 3756 order_id, 3757 TradeRevisionStatus::KeptAsPlaced.storage_key(), 3758 ); 3759 3760 let list = repository 3761 .load_buyer_orders(&context) 3762 .expect("valid revision should load in buyer list"); 3763 let detail = repository 3764 .load_buyer_order_detail(&context, order_id) 3765 .expect("valid revision should load in buyer detail") 3766 .expect("buyer detail should exist"); 3767 3768 assert_eq!( 3769 list.rows[0].workflow.revision, 3770 TradeRevisionStatus::KeptAsPlaced 3771 ); 3772 assert_eq!(detail.workflow.revision, TradeRevisionStatus::KeptAsPlaced); 3773 3774 corrupt_order_workflow_revision(connection, order_id, "future_revision"); 3775 3776 let list_error = repository 3777 .load_buyer_orders(&context) 3778 .expect_err("invalid revision should fail buyer list projection"); 3779 let detail_error = repository 3780 .load_buyer_order_detail(&context, order_id) 3781 .expect_err("invalid revision should fail buyer detail projection"); 3782 3783 assert_decode_enum(list_error, "orders.workflow_revision", "future_revision"); 3784 assert_decode_enum(detail_error, "orders.workflow_revision", "future_revision"); 3785 } 3786 3787 #[test] 3788 fn buyer_order_projections_read_workflow_display_snapshot() { 3789 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3790 let connection = store.connection(); 3791 let repository = AppBuyerRepository::new(connection); 3792 let context = BuyerContext::account("acct_buyer"); 3793 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3794 let order_id = OrderId::new(); 3795 let product_id = ProductId::new(); 3796 3797 insert_order( 3798 connection, 3799 order_id, 3800 farm_id, 3801 "R-100", 3802 "needs_action", 3803 Some("account:acct_buyer"), 3804 "buyer@example.com", 3805 "", 3806 "", 3807 ); 3808 insert_order_line( 3809 connection, 3810 order_id, 3811 product_id, 3812 "Salad mix", 3813 2, 3814 "bag", 3815 Some(650), 3816 "USD", 3817 ); 3818 set_order_workflow_revision( 3819 connection, 3820 order_id, 3821 TradeRevisionStatus::KeptAsPlaced.storage_key(), 3822 ); 3823 set_order_workflow_display_projection( 3824 connection, 3825 order_id, 3826 "confirmed", 3827 "reserved", 3828 "local_events", 3829 Some("agreement-event-1"), 3830 ); 3831 3832 let list = repository 3833 .load_buyer_orders(&context) 3834 .expect("buyer order list should load"); 3835 let detail = repository 3836 .load_buyer_order_detail(&context, order_id) 3837 .expect("buyer order detail should load") 3838 .expect("buyer order detail should exist"); 3839 let row = &list.rows[0]; 3840 3841 assert_eq!(list.rows.len(), 1); 3842 assert_eq!(row.workflow.agreement, TradeAgreementStatus::Confirmed); 3843 assert_eq!(row.workflow.inventory, TradeInventoryStatus::Reserved); 3844 assert_eq!( 3845 row.workflow.provenance.primary_source, 3846 TradeWorkflowSource::LocalEvents 3847 ); 3848 assert_eq!( 3849 row.workflow.provenance.last_event_id.as_deref(), 3850 Some("agreement-event-1") 3851 ); 3852 assert_eq!(row.workflow.economics.total_minor_units, Some(1300)); 3853 assert_eq!(row.workflow.economics.currency_code.as_deref(), Some("USD")); 3854 assert_eq!(detail.workflow, row.workflow); 3855 } 3856 3857 #[test] 3858 fn buyer_order_projections_fail_closed_for_invalid_workflow_snapshot_keys() { 3859 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3860 let connection = store.connection(); 3861 let repository = AppBuyerRepository::new(connection); 3862 let context = BuyerContext::account("acct_buyer"); 3863 let farm_id = insert_farm(connection, "Willow Farm", "ready"); 3864 let order_id = OrderId::new(); 3865 3866 insert_order( 3867 connection, 3868 order_id, 3869 farm_id, 3870 "R-100", 3871 "scheduled", 3872 Some("account:acct_buyer"), 3873 "buyer@example.com", 3874 "", 3875 "", 3876 ); 3877 set_order_workflow_display_projection( 3878 connection, 3879 order_id, 3880 "confirmed", 3881 "reserved", 3882 "local_events", 3883 Some("buyer-workflow-event"), 3884 ); 3885 3886 for (column, expected_field) in [ 3887 ("workflow_agreement", "orders.workflow_agreement"), 3888 ("workflow_inventory", "orders.workflow_inventory"), 3889 ( 3890 "workflow_provenance_source", 3891 "orders.workflow_provenance_source", 3892 ), 3893 ] { 3894 set_order_workflow_display_projection( 3895 connection, 3896 order_id, 3897 "confirmed", 3898 "reserved", 3899 "local_events", 3900 Some("buyer-workflow-event"), 3901 ); 3902 corrupt_order_workflow_display_projection(connection, order_id, column, "future_state"); 3903 3904 let list_error = repository 3905 .load_buyer_orders(&context) 3906 .expect_err("invalid workflow snapshot should fail buyer list projection"); 3907 let detail_error = repository 3908 .load_buyer_order_detail(&context, order_id) 3909 .expect_err("invalid workflow snapshot should fail buyer detail projection"); 3910 3911 assert_decode_enum(list_error, expected_field, "future_state"); 3912 assert_decode_enum(detail_error, expected_field, "future_state"); 3913 } 3914 } 3915 3916 #[test] 3917 fn buyer_cart_rejects_cross_farm_lines() { 3918 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 3919 let farm_id = FarmId::new(); 3920 let other_farm_id = FarmId::new(); 3921 3922 let error = repository_error(&store, farm_id, other_farm_id); 3923 3924 assert!(matches!(error, AppSqliteError::InvalidProjection { .. })); 3925 } 3926 3927 fn repository_error( 3928 store: &AppSqliteStore, 3929 farm_id: FarmId, 3930 other_farm_id: FarmId, 3931 ) -> AppSqliteError { 3932 AppBuyerRepository::new(store.connection()) 3933 .replace_buyer_cart( 3934 &BuyerContext::Guest, 3935 &radroots_app_view::BuyerCartProjection { 3936 farm_id: Some(farm_id), 3937 farm_display_name: Some("Willow Farm".to_owned()), 3938 lines: vec![radroots_app_view::BuyerCartLineProjection { 3939 product_id: ProductId::new(), 3940 farm_id: other_farm_id, 3941 farm_display_name: "Other Farm".to_owned(), 3942 title: "Mismatch".to_owned(), 3943 quantity: 1, 3944 unit_price: radroots_app_view::ProductPricePresentation { 3945 amount_minor_units: 500, 3946 currency_code: "USD".to_owned(), 3947 unit_label: "bag".to_owned(), 3948 }, 3949 line_total_minor_units: 500, 3950 fulfillment_summary: "Friday pickup".to_owned(), 3951 }], 3952 subtotal_minor_units: Some(500), 3953 currency_code: Some("USD".to_owned()), 3954 replace_confirmation: None, 3955 }, 3956 ) 3957 .expect_err("cross-farm cart should fail") 3958 } 3959 3960 fn insert_farm(connection: &Connection, display_name: &str, readiness: &str) -> FarmId { 3961 let farm_id = FarmId::new(); 3962 3963 connection 3964 .execute( 3965 "insert into farms ( 3966 id, 3967 display_name, 3968 readiness, 3969 timezone, 3970 currency_code, 3971 created_at, 3972 updated_at 3973 ) values (?1, ?2, ?3, 'UTC', 'USD', '2026-04-20T08:00:00Z', '2026-04-20T08:00:00Z')", 3974 params![farm_id.to_string(), display_name, readiness], 3975 ) 3976 .expect("farm insert should succeed"); 3977 3978 farm_id 3979 } 3980 3981 fn insert_pickup_location( 3982 connection: &Connection, 3983 farm_id: FarmId, 3984 label: &str, 3985 ) -> PickupLocationId { 3986 let pickup_location_id = PickupLocationId::new(); 3987 3988 connection 3989 .execute( 3990 "insert into pickup_locations ( 3991 id, 3992 farm_id, 3993 label, 3994 address_line, 3995 directions, 3996 is_default, 3997 created_at, 3998 updated_at 3999 ) values (?1, ?2, ?3, '14 County Road', null, 1, '2026-04-20T08:00:00Z', '2026-04-20T08:00:00Z')", 4000 params![pickup_location_id.to_string(), farm_id.to_string(), label], 4001 ) 4002 .expect("pickup location insert should succeed"); 4003 4004 pickup_location_id 4005 } 4006 4007 fn insert_window( 4008 connection: &Connection, 4009 farm_id: FarmId, 4010 pickup_location_id: Option<PickupLocationId>, 4011 label: &str, 4012 starts_at: &str, 4013 ends_at: &str, 4014 ) -> FulfillmentWindowId { 4015 let fulfillment_window_id = FulfillmentWindowId::new(); 4016 4017 connection 4018 .execute( 4019 "insert into fulfillment_windows ( 4020 id, 4021 farm_id, 4022 starts_at, 4023 ends_at, 4024 capacity_limit, 4025 created_at, 4026 updated_at, 4027 pickup_location_id, 4028 label, 4029 order_cutoff_at 4030 ) values (?1, ?2, ?3, ?4, null, ?3, ?3, ?5, ?6, ?3)", 4031 params![ 4032 fulfillment_window_id.to_string(), 4033 farm_id.to_string(), 4034 starts_at, 4035 ends_at, 4036 pickup_location_id.map(|id| id.to_string()), 4037 label, 4038 ], 4039 ) 4040 .expect("window insert should succeed"); 4041 4042 fulfillment_window_id 4043 } 4044 4045 fn insert_farm_setup_binding( 4046 connection: &Connection, 4047 account_id: &str, 4048 farm_id: FarmId, 4049 pickup_enabled: bool, 4050 delivery_enabled: bool, 4051 shipping_enabled: bool, 4052 ) { 4053 connection 4054 .execute( 4055 "insert into account_farm_setups ( 4056 account_id, 4057 farm_name, 4058 location_or_service_area, 4059 pickup_enabled, 4060 delivery_enabled, 4061 shipping_enabled, 4062 saved_farm_id, 4063 saved_farm_display_name, 4064 saved_farm_readiness, 4065 updated_at 4066 ) values (?1, 'Willow Farm', 'County Road', ?2, ?3, ?4, ?5, 'Willow Farm', 'ready', '2026-04-20T08:00:00Z')", 4067 params![ 4068 account_id, 4069 i64::from(pickup_enabled), 4070 i64::from(delivery_enabled), 4071 i64::from(shipping_enabled), 4072 farm_id.to_string(), 4073 ], 4074 ) 4075 .expect("farm setup binding insert should succeed"); 4076 } 4077 4078 struct SeedProduct<'a> { 4079 title: &'a str, 4080 subtitle: &'a str, 4081 status: &'a str, 4082 unit_label: &'a str, 4083 price_minor_units: Option<u32>, 4084 price_currency: &'a str, 4085 stock_count: Option<u32>, 4086 availability_window_id: Option<FulfillmentWindowId>, 4087 } 4088 4089 fn insert_product( 4090 connection: &Connection, 4091 farm_id: FarmId, 4092 product: SeedProduct<'_>, 4093 ) -> ProductId { 4094 let product_id = ProductId::new(); 4095 4096 connection 4097 .execute( 4098 "insert into products ( 4099 id, 4100 farm_id, 4101 title, 4102 subtitle, 4103 status, 4104 unit_label, 4105 price_minor_units, 4106 price_currency, 4107 stock_count, 4108 availability_window_id, 4109 updated_at 4110 ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, '2026-04-20T09:00:00Z')", 4111 params![ 4112 product_id.to_string(), 4113 farm_id.to_string(), 4114 product.title, 4115 product.subtitle, 4116 product.status, 4117 product.unit_label, 4118 product.price_minor_units, 4119 product.price_currency, 4120 product.stock_count, 4121 product.availability_window_id.map(|id| id.to_string()), 4122 ], 4123 ) 4124 .expect("product insert should succeed"); 4125 4126 product_id 4127 } 4128 4129 fn insert_order( 4130 connection: &Connection, 4131 order_id: OrderId, 4132 farm_id: FarmId, 4133 order_number: &str, 4134 status: &str, 4135 buyer_context_key: Option<&str>, 4136 buyer_email: &str, 4137 buyer_phone: &str, 4138 buyer_order_note: &str, 4139 ) { 4140 connection 4141 .execute( 4142 "insert into orders ( 4143 id, 4144 farm_id, 4145 fulfillment_window_id, 4146 order_number, 4147 customer_display_name, 4148 status, 4149 updated_at, 4150 buyer_context_key, 4151 buyer_email, 4152 buyer_phone, 4153 buyer_order_note 4154 ) values (?1, ?2, null, ?3, 'Casey', ?4, '2026-04-20T10:00:00Z', ?5, ?6, ?7, ?8)", 4155 params![ 4156 order_id.to_string(), 4157 farm_id.to_string(), 4158 order_number, 4159 status, 4160 buyer_context_key, 4161 buyer_email, 4162 buyer_phone, 4163 buyer_order_note, 4164 ], 4165 ) 4166 .expect("order insert should succeed"); 4167 } 4168 4169 fn set_order_workflow_revision( 4170 connection: &Connection, 4171 order_id: OrderId, 4172 workflow_revision: &str, 4173 ) { 4174 connection 4175 .execute( 4176 "update orders set workflow_revision = ?1 where id = ?2", 4177 params![workflow_revision, order_id.to_string()], 4178 ) 4179 .expect("order workflow revision update should succeed"); 4180 } 4181 4182 fn set_order_workflow_display_projection( 4183 connection: &Connection, 4184 order_id: OrderId, 4185 agreement: &str, 4186 inventory: &str, 4187 provenance_source: &str, 4188 provenance_last_event_id: Option<&str>, 4189 ) { 4190 connection 4191 .execute( 4192 "update orders 4193 set workflow_agreement = ?1, 4194 workflow_inventory = ?2, 4195 workflow_provenance_source = ?3, 4196 workflow_provenance_last_event_id = ?4 4197 where id = ?5", 4198 params![ 4199 agreement, 4200 inventory, 4201 provenance_source, 4202 provenance_last_event_id, 4203 order_id.to_string(), 4204 ], 4205 ) 4206 .expect("order workflow display projection update should succeed"); 4207 } 4208 4209 fn insert_order_line( 4210 connection: &Connection, 4211 order_id: OrderId, 4212 product_id: ProductId, 4213 title: &str, 4214 quantity: i64, 4215 unit_label: &str, 4216 unit_price_minor_units: Option<u32>, 4217 price_currency: &str, 4218 ) { 4219 connection 4220 .execute( 4221 "insert into order_lines ( 4222 id, 4223 order_id, 4224 title, 4225 quantity_value, 4226 quantity_unit_label, 4227 quantity_display, 4228 unit_price_minor_units, 4229 price_currency, 4230 sort_index 4231 ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0)", 4232 params![ 4233 format!("{order_id}:{product_id}"), 4234 order_id.to_string(), 4235 title, 4236 quantity, 4237 unit_label, 4238 format!("{quantity} {unit_label}"), 4239 unit_price_minor_units, 4240 price_currency, 4241 ], 4242 ) 4243 .expect("order line insert should succeed"); 4244 } 4245 4246 fn corrupt_order_workflow_revision( 4247 connection: &Connection, 4248 order_id: OrderId, 4249 workflow_revision: &str, 4250 ) { 4251 connection 4252 .execute_batch("pragma ignore_check_constraints = on") 4253 .expect("check constraints should disable"); 4254 set_order_workflow_revision(connection, order_id, workflow_revision); 4255 connection 4256 .execute_batch("pragma ignore_check_constraints = off") 4257 .expect("check constraints should re-enable"); 4258 } 4259 4260 fn corrupt_order_workflow_display_projection( 4261 connection: &Connection, 4262 order_id: OrderId, 4263 column: &str, 4264 value: &str, 4265 ) { 4266 connection 4267 .execute_batch("pragma ignore_check_constraints = on") 4268 .expect("check constraints should disable"); 4269 let statement = match column { 4270 "workflow_agreement" => "update orders set workflow_agreement = ?1 where id = ?2", 4271 "workflow_inventory" => "update orders set workflow_inventory = ?1 where id = ?2", 4272 "workflow_provenance_source" => { 4273 "update orders set workflow_provenance_source = ?1 where id = ?2" 4274 } 4275 _ => panic!("unsupported workflow display projection column {column}"), 4276 }; 4277 connection 4278 .execute(statement, params![value, order_id.to_string()]) 4279 .expect("order workflow display projection corruption should succeed"); 4280 connection 4281 .execute_batch("pragma ignore_check_constraints = off") 4282 .expect("check constraints should re-enable"); 4283 } 4284 4285 fn assert_decode_enum(error: AppSqliteError, expected_field: &str, expected_value: &str) { 4286 match error { 4287 AppSqliteError::DecodeEnum { field, value } => { 4288 assert_eq!(field, expected_field); 4289 assert_eq!(value, expected_value); 4290 } 4291 other => panic!("expected DecodeEnum error, got {other:?}"), 4292 } 4293 } 4294 4295 fn row_count(connection: &Connection, table_name: &str) -> i64 { 4296 let sql = format!("SELECT COUNT(*) FROM {table_name}"); 4297 4298 connection 4299 .query_row(&sql, [], |row| row.get(0)) 4300 .expect("row count query should succeed") 4301 } 4302 }