1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4
5use rusqlite::{Connection, OptionalExtension, Transaction, params, types::Type};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use super::*;
10
11const DEFAULT_CURRENCY: &str = "GBP";
12const INITIAL_ORDER_SEQUENCE: i64 = 10_042;
13const INITIAL_REFUND_SEQUENCE: i64 = 7_001;
14const STOREFRONT_FORM_STATE_PREFIX: &str = "__coil_storefront_form_state__:";
15
16#[derive(Debug, Error)]
17pub enum StorefrontStateError {
18 #[error("storefront state store is poisoned")]
19 Poisoned,
20 #[error("unknown storefront sku `{sku}`")]
21 UnknownSku { sku: String },
22 #[error("quantity must be greater than zero")]
23 InvalidQuantity,
24 #[error("checkout requires a payment method")]
25 MissingPaymentMethod,
26 #[error("checkout requires a billing email")]
27 MissingCheckoutEmail,
28 #[error("card payments require a 4-digit last4 value")]
29 InvalidPaymentLast4,
30 #[error("checkout completion requires a payment intent reference")]
31 MissingPaymentIntent,
32 #[error("checkout intent `{received}` does not match the reserved payment intent `{expected}`")]
33 PaymentIntentMismatch { expected: String, received: String },
34 #[error("checkout for session `{session_id}` is not ready for payment")]
35 CheckoutNotReady { session_id: String },
36 #[error("cart for session `{session_id}` is empty")]
37 EmptyCart { session_id: String },
38 #[error("payment reference `{payment_reference}` does not match any storefront order")]
39 UnknownPaymentReference { payment_reference: String },
40 #[error("storefront order `{order_id}` could not be found")]
41 UnknownOrder { order_id: String },
42 #[error("unknown storefront payment webhook event `{event}`")]
43 UnknownPaymentWebhookEvent { event: String },
44 #[error(
45 "payment webhook provider `{received}` does not match configured provider `{expected}`"
46 )]
47 UnexpectedPaymentWebhookProvider { expected: String, received: String },
48 #[error("payment webhook verification failed")]
49 InvalidPaymentWebhookSignature,
50 #[error("payment webhook payload did not include a delivery id")]
51 MissingPaymentWebhookDeliveryId,
52 #[error("payment webhook delivery `{delivery_id}` has already been processed")]
53 ReplayedPaymentWebhookDelivery { delivery_id: String },
54 #[error("payment webhook secret is not configured")]
55 MissingPaymentWebhookSecret,
56 #[error("refund reason is required")]
57 MissingRefundReason,
58 #[error("order `{order_id}` cannot be fulfilled while it is `{status}`")]
59 FulfillmentNotAllowed { order_id: String, status: String },
60 #[error("order `{order_id}` cannot be refunded while it is `{status}`")]
61 RefundNotAllowed { order_id: String, status: String },
62 #[error("catalog product `{handle}` does not exist")]
63 MissingCatalogProduct { handle: String },
64 #[error("catalog collection `{handle}` does not exist")]
65 MissingCatalogCollection { handle: String },
66 #[error("failed to serialize storefront state: {reason}")]
67 Serialization { reason: String },
68 #[error("failed to initialize storefront state store `{path}`: {reason}")]
69 Initialization { path: String, reason: String },
70 #[error("storefront state query failed: {reason}")]
71 Query { reason: String },
72}
73
74#[derive(Debug, Clone)]
75pub struct StorefrontStateStore {
76 path: PathBuf,
77 connection: Arc<Mutex<Connection>>,
78 catalog: Arc<StorefrontCatalog>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82pub struct StorefrontPaymentSnapshot {
83 pub status: String,
84 pub method: Option<String>,
85 pub reference: Option<String>,
86 pub last4: Option<String>,
87 pub checkout_email: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct StorefrontPaymentInput {
92 pub method: String,
93 pub last4: Option<String>,
94 pub checkout_email: String,
95 pub intent_reference: String,
96}
97
98impl StorefrontPaymentInput {
99 fn validate_common(
100 method: impl Into<String>,
101 checkout_email: impl Into<String>,
102 intent_reference: impl Into<String>,
103 ) -> Result<(String, String, String), StorefrontStateError> {
104 let method = method.into().trim().to_ascii_lowercase();
105 if method.is_empty() {
106 return Err(StorefrontStateError::MissingPaymentMethod);
107 }
108 let checkout_email = checkout_email.into().trim().to_string();
109 if checkout_email.is_empty() {
110 return Err(StorefrontStateError::MissingCheckoutEmail);
111 }
112 let intent_reference = intent_reference.into().trim().to_string();
113 if intent_reference.is_empty() {
114 return Err(StorefrontStateError::MissingPaymentIntent);
115 }
116 Ok((method, checkout_email, intent_reference))
117 }
118
119 pub fn new(
120 method: impl Into<String>,
121 checkout_email: impl Into<String>,
122 last4: Option<String>,
123 intent_reference: impl Into<String>,
124 ) -> Result<Self, StorefrontStateError> {
125 let (method, checkout_email, intent_reference) =
126 Self::validate_common(method, checkout_email, intent_reference)?;
127 let last4 = last4
128 .map(|value| value.trim().to_string())
129 .filter(|value| !value.is_empty());
130 let has_valid_last4 = last4
131 .as_deref()
132 .map(|value| value.len() == 4 && value.chars().all(|ch| ch.is_ascii_digit()))
133 .unwrap_or(false);
134 if method == "card" && !has_valid_last4 {
135 return Err(StorefrontStateError::InvalidPaymentLast4);
136 }
137 Ok(Self {
138 method,
139 last4,
140 checkout_email,
141 intent_reference,
142 })
143 }
144
145 pub fn hosted(
146 method: impl Into<String>,
147 checkout_email: impl Into<String>,
148 intent_reference: impl Into<String>,
149 ) -> Result<Self, StorefrontStateError> {
150 let (method, checkout_email, intent_reference) =
151 Self::validate_common(method, checkout_email, intent_reference)?;
152 Ok(Self {
153 method,
154 last4: None,
155 checkout_email,
156 intent_reference,
157 })
158 }
159
160 pub fn card(
161 checkout_email: impl Into<String>,
162 last4: impl Into<String>,
163 intent_reference: impl Into<String>,
164 ) -> Result<Self, StorefrontStateError> {
165 Self::new("card", checkout_email, Some(last4.into()), intent_reference)
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub struct StorefrontCartLine {
171 pub sku: String,
172 pub title: String,
173 pub variant_title: String,
174 pub product_kind: String,
175 pub entitlement_key: Option<String>,
176 pub metadata: BTreeMap<String, String>,
177 pub quantity: u32,
178 pub unit_price_minor: i64,
179 pub total_minor: i64,
180 pub currency: String,
181 pub total: String,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185pub struct StorefrontCartSnapshot {
186 pub status: String,
187 pub payment: StorefrontPaymentSnapshot,
188 pub currency: String,
189 pub item_count: u32,
190 pub subtotal_minor: i64,
191 pub subtotal: String,
192 pub lines: Vec<StorefrontCartLine>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
196pub struct StorefrontOrderLine {
197 pub sku: String,
198 pub title: String,
199 pub variant_title: String,
200 pub product_kind: String,
201 pub entitlement_key: Option<String>,
202 pub metadata: BTreeMap<String, String>,
203 pub quantity: u32,
204 pub unit_price_minor: i64,
205 pub total_minor: i64,
206 pub currency: String,
207 pub total: String,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
211pub struct StorefrontOrderRefundSnapshot {
212 pub refund_id: String,
213 pub order_id: String,
214 pub amount_minor: i64,
215 pub amount: String,
216 pub currency: String,
217 pub reason: String,
218 pub created_at_unix_seconds: u64,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
222pub struct StorefrontOrderSnapshot {
223 pub order_id: String,
224 pub session_id: String,
225 pub principal_id: Option<String>,
226 pub metadata: BTreeMap<String, String>,
227 pub status: String,
228 pub payment: StorefrontPaymentSnapshot,
229 pub currency: String,
230 pub line_count: u32,
231 pub subtotal_minor: i64,
232 pub total_minor: i64,
233 pub refunded_total_minor: i64,
234 pub refundable_total_minor: i64,
235 pub subtotal: String,
236 pub total: String,
237 pub refunded_total: String,
238 pub refundable_total: String,
239 pub created_at_unix_seconds: u64,
240 pub lines: Vec<StorefrontOrderLine>,
241 pub refunds: Vec<StorefrontOrderRefundSnapshot>,
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
245pub struct StorefrontStateSnapshot {
246 pub session_id: String,
247 pub principal_id: Option<String>,
248 pub cart: StorefrontCartSnapshot,
249 pub payment: StorefrontPaymentSnapshot,
250 pub recent_orders: Vec<StorefrontOrderSnapshot>,
251 pub latest_order: Option<StorefrontOrderSnapshot>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
255pub struct StorefrontOrderHistoryResponse {
256 pub session_id: String,
257 pub principal_id: Option<String>,
258 pub orders: Vec<StorefrontOrderSnapshot>,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct StorefrontPaymentWebhookReceipt {
263 pub order: StorefrontOrderSnapshot,
264 pub needs_paid_event_dispatch: bool,
265}
266
267#[derive(Debug, Clone, Default)]
268pub struct StorefrontResponseAugmentation {
269 pub html_fragment: Option<String>,
270 pub headers: BTreeMap<String, String>,
271}
272
273const ACCOUNT_SESSION_END_ACTION: &str = "commerce.account-session-end";
274const ACCOUNT_SESSION_END_PATH: &str = "/account/session/end";
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct StorefrontFormState {
278 pub route_name: String,
279 pub summary: String,
280 pub field_errors: BTreeMap<String, String>,
281 pub fields: BTreeMap<String, String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct StorefrontCatalogProductUpdate {
286 pub handle: String,
287 pub title: String,
288 pub summary: String,
289 pub price_minor: i64,
290 pub collection_handle: String,
291 pub is_visible: bool,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct StorefrontCatalogCollectionUpdate {
296 pub handle: String,
297 pub title: String,
298 pub label: String,
299 pub summary: String,
300 pub is_visible: bool,
301}
302
303impl StorefrontFormState {
304 pub fn new(route_name: impl Into<String>, summary: impl Into<String>) -> Self {
305 Self {
306 route_name: route_name.into(),
307 summary: summary.into(),
308 field_errors: BTreeMap::new(),
309 fields: BTreeMap::new(),
310 }
311 }
312
313 pub fn with_field_error(
314 mut self,
315 field: impl Into<String>,
316 message: impl Into<String>,
317 ) -> Self {
318 self.field_errors.insert(field.into(), message.into());
319 self
320 }
321
322 pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
323 self.summary = summary.into();
324 self
325 }
326
327 pub fn with_field_value(mut self, field: impl Into<String>, value: impl Into<String>) -> Self {
328 self.fields.insert(field.into(), value.into());
329 self
330 }
331
332 pub fn encode(&self) -> Result<String, StorefrontStateError> {
333 let payload =
334 serde_json::to_string(self).map_err(|error| StorefrontStateError::Serialization {
335 reason: error.to_string(),
336 })?;
337 Ok(format!("{STOREFRONT_FORM_STATE_PREFIX}{payload}"))
338 }
339
340 pub fn decode(payload: &str) -> Option<Self> {
341 let encoded = payload.strip_prefix(STOREFRONT_FORM_STATE_PREFIX)?;
342 serde_json::from_str(encoded).ok()
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq)]
347struct CatalogItem {
348 sku: String,
349 title: String,
350 variant_title: String,
351 product_kind: String,
352 entitlement_key: Option<String>,
353 unit_price_minor: i64,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
357pub struct StorefrontCatalog {
358 pub collections: Vec<StorefrontCollectionDefinition>,
359 pub products: Vec<StorefrontProductDefinition>,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
363pub struct StorefrontCollectionDefinition {
364 pub handle: String,
365 pub title: String,
366 pub label: String,
367 pub summary: String,
368 #[serde(default = "default_catalog_visibility")]
369 pub is_visible: bool,
370 #[serde(default)]
371 pub site_ids: Vec<String>,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
375pub struct StorefrontProductDefinition {
376 pub sku: String,
377 pub handle: String,
378 pub title: String,
379 pub summary: String,
380 pub price_minor: i64,
381 #[serde(default = "default_currency")]
382 pub currency: String,
383 pub collection_handle: String,
384 #[serde(default = "default_catalog_visibility")]
385 pub is_visible: bool,
386 #[serde(default = "default_variant_title")]
387 pub variant_title: String,
388 #[serde(default = "default_product_kind")]
389 pub product_kind: String,
390 pub entitlement_key: Option<String>,
391 #[serde(default)]
392 pub site_ids: Vec<String>,
393 #[serde(default)]
394 pub inventory_locations: Vec<String>,
395}
396
397impl StorefrontCatalog {
398 pub fn default_sample() -> Self {
399 Self {
400 collections: vec![
401 StorefrontCollectionDefinition {
402 handle: "featured".to_string(),
403 title: "Featured".to_string(),
404 label: "Featured edit".to_string(),
405 summary:
406 "Current campaign picks spanning merch, memberships, and event offers."
407 .to_string(),
408 is_visible: true,
409 site_ids: Vec::new(),
410 },
411 StorefrontCollectionDefinition {
412 handle: "memberships".to_string(),
413 title: "Memberships".to_string(),
414 label: "Recurring value".to_string(),
415 summary:
416 "Recurring and premium access products that unlock customer benefits."
417 .to_string(),
418 is_visible: true,
419 site_ids: Vec::new(),
420 },
421 StorefrontCollectionDefinition {
422 handle: "events".to_string(),
423 title: "Events".to_string(),
424 label: "Event-led offer".to_string(),
425 summary:
426 "Bookable offers and event-linked passes surfaced alongside editorial content."
427 .to_string(),
428 is_visible: true,
429 site_ids: Vec::new(),
430 },
431 ],
432 products: vec![
433 StorefrontProductDefinition {
434 sku: "harbor-cap".to_string(),
435 handle: "harbor-cap".to_string(),
436 title: "Harbor Cap".to_string(),
437 summary: "A classic canvas cap with embroidered harbor mark.".to_string(),
438 price_minor: 2_900,
439 currency: default_currency(),
440 collection_handle: "featured".to_string(),
441 is_visible: true,
442 variant_title: "Standard".to_string(),
443 product_kind: "physical".to_string(),
444 entitlement_key: None,
445 site_ids: Vec::new(),
446 inventory_locations: Vec::new(),
447 },
448 StorefrontProductDefinition {
449 sku: "membership-gold".to_string(),
450 handle: "gold-membership".to_string(),
451 title: "Gold Membership".to_string(),
452 summary:
453 "Priority event booking, exclusive offers, and member-only access."
454 .to_string(),
455 price_minor: 8_900,
456 currency: default_currency(),
457 collection_handle: "memberships".to_string(),
458 is_visible: true,
459 variant_title: "Annual".to_string(),
460 product_kind: "membership".to_string(),
461 entitlement_key: Some("membership.gold".to_string()),
462 site_ids: Vec::new(),
463 inventory_locations: Vec::new(),
464 },
465 StorefrontProductDefinition {
466 sku: "tasting-pass".to_string(),
467 handle: "tasting-pass".to_string(),
468 title: "Spring Tasting Pass".to_string(),
469 summary:
470 "An event-linked pass for the next seasonal tasting series.".to_string(),
471 price_minor: 4_500,
472 currency: default_currency(),
473 collection_handle: "events".to_string(),
474 is_visible: true,
475 variant_title: "Single pass".to_string(),
476 product_kind: "physical".to_string(),
477 entitlement_key: None,
478 site_ids: Vec::new(),
479 inventory_locations: Vec::new(),
480 },
481 ],
482 }
483 }
484
485 pub(crate) fn load_from_roots(roots: &[PathBuf]) -> Result<Self, RuntimeBuildError> {
486 for root in roots {
487 let path = root.join("catalog.toml");
488 if !path.exists() {
489 continue;
490 }
491 let source = std::fs::read_to_string(&path).map_err(|error| {
492 RuntimeBuildError::StorefrontCatalogRead {
493 path: path.display().to_string(),
494 message: error.to_string(),
495 }
496 })?;
497 let catalog = toml::from_str::<Self>(&source).map_err(|error| {
498 RuntimeBuildError::StorefrontCatalogParse {
499 path: path.display().to_string(),
500 message: error.to_string(),
501 }
502 })?;
503 catalog.validate(&path)?;
504 return Ok(catalog);
505 }
506 Ok(Self::default_sample())
507 }
508
509 fn validate(&self, path: &Path) -> Result<(), RuntimeBuildError> {
510 if self.collections.is_empty() {
511 return Err(RuntimeBuildError::StorefrontCatalogValidation {
512 path: path.display().to_string(),
513 message: "at least one collection is required".to_string(),
514 });
515 }
516 if self.products.is_empty() {
517 return Err(RuntimeBuildError::StorefrontCatalogValidation {
518 path: path.display().to_string(),
519 message: "at least one product is required".to_string(),
520 });
521 }
522 let collection_handles = self
523 .collections
524 .iter()
525 .map(|collection| collection.handle.as_str())
526 .collect::<std::collections::BTreeSet<_>>();
527 for product in &self.products {
528 if !collection_handles.contains(product.collection_handle.as_str()) {
529 return Err(RuntimeBuildError::StorefrontCatalogValidation {
530 path: path.display().to_string(),
531 message: format!(
532 "product `{}` references unknown collection `{}`",
533 product.handle, product.collection_handle
534 ),
535 });
536 }
537 if product.price_minor <= 0 {
538 return Err(RuntimeBuildError::StorefrontCatalogValidation {
539 path: path.display().to_string(),
540 message: format!(
541 "product `{}` must declare a positive `price_minor`",
542 product.handle
543 ),
544 });
545 }
546 }
547 Ok(())
548 }
549
550 pub fn collection(&self, handle: &str) -> Option<&StorefrontCollectionDefinition> {
551 self.collections
552 .iter()
553 .find(|collection| collection.handle == handle)
554 }
555
556 pub fn visible_collection(&self, handle: &str) -> Option<&StorefrontCollectionDefinition> {
557 self.visible_collection_for_site(None, handle)
558 }
559
560 pub fn visible_collection_for_site(
561 &self,
562 site_id: Option<&str>,
563 handle: &str,
564 ) -> Option<&StorefrontCollectionDefinition> {
565 self.collection(handle)
566 .filter(|collection| self.is_collection_visible_for_site(collection, site_id))
567 }
568
569 pub fn product(&self, handle: &str) -> Option<&StorefrontProductDefinition> {
570 self.products
571 .iter()
572 .find(|product| product.handle == handle)
573 }
574
575 pub fn visible_product(&self, handle: &str) -> Option<&StorefrontProductDefinition> {
576 self.visible_product_for_site(None, handle)
577 }
578
579 pub fn visible_product_for_site(
580 &self,
581 site_id: Option<&str>,
582 handle: &str,
583 ) -> Option<&StorefrontProductDefinition> {
584 self.product(handle)
585 .filter(|product| self.is_product_visible_for_site(product, site_id))
586 }
587
588 pub fn product_by_sku_or_handle(&self, value: &str) -> Option<&StorefrontProductDefinition> {
589 self.product_by_sku_or_handle_for_site(None, value)
590 }
591
592 pub fn product_by_sku_or_handle_for_site(
593 &self,
594 site_id: Option<&str>,
595 value: &str,
596 ) -> Option<&StorefrontProductDefinition> {
597 self.products.iter().find(|product| {
598 (product.sku == value || product.handle == value)
599 && self.is_product_visible_for_site(product, site_id)
600 })
601 }
602
603 pub fn products_for_collection(&self, handle: &str) -> Vec<&StorefrontProductDefinition> {
604 self.products_for_collection_for_site(None, handle)
605 }
606
607 pub fn products_for_collection_for_site(
608 &self,
609 site_id: Option<&str>,
610 handle: &str,
611 ) -> Vec<&StorefrontProductDefinition> {
612 if handle != "featured" && self.visible_collection_for_site(site_id, handle).is_none() {
613 return Vec::new();
614 }
615 self.products
616 .iter()
617 .filter(|product| {
618 self.is_product_visible_for_site(product, site_id)
619 && (product.collection_handle == handle || handle == "featured")
620 })
621 .collect()
622 }
623
624 pub fn related_products_for_product(&self, handle: &str) -> Vec<&StorefrontProductDefinition> {
625 self.related_products_for_product_for_site(None, handle)
626 }
627
628 pub fn related_products_for_product_for_site(
629 &self,
630 site_id: Option<&str>,
631 handle: &str,
632 ) -> Vec<&StorefrontProductDefinition> {
633 let Some(product) = self.visible_product_for_site(site_id, handle) else {
634 return self
635 .products
636 .iter()
637 .filter(|candidate| self.is_product_visible_for_site(candidate, site_id))
638 .collect();
639 };
640 self.products_for_collection_for_site(site_id, &product.collection_handle)
641 .into_iter()
642 .filter(|candidate| candidate.handle != product.handle)
643 .collect()
644 }
645
646 fn is_product_visible(&self, product: &StorefrontProductDefinition) -> bool {
647 self.is_product_visible_for_site(product, None)
648 }
649
650 fn is_product_visible_for_site(
651 &self,
652 product: &StorefrontProductDefinition,
653 site_id: Option<&str>,
654 ) -> bool {
655 product.is_visible
656 && self
657 .collection(product.collection_handle.as_str())
658 .is_some_and(|collection| self.is_collection_visible_for_site(collection, site_id))
659 && self.applies_to_site(&product.site_ids, site_id)
660 }
661
662 fn is_collection_visible_for_site(
663 &self,
664 collection: &StorefrontCollectionDefinition,
665 site_id: Option<&str>,
666 ) -> bool {
667 collection.is_visible && self.applies_to_site(&collection.site_ids, site_id)
668 }
669
670 fn applies_to_site(&self, site_ids: &[String], site_id: Option<&str>) -> bool {
671 site_ids.is_empty()
672 || site_id
673 .map(|site_id| site_ids.iter().any(|candidate| candidate == site_id))
674 .unwrap_or(true)
675 }
676
677 fn catalog_item(&self, sku: &str) -> Option<CatalogItem> {
678 self.catalog_item_for_site(None, sku)
679 }
680
681 fn catalog_item_for_site(&self, site_id: Option<&str>, sku: &str) -> Option<CatalogItem> {
682 let product = self
683 .product_by_sku_or_handle_for_site(site_id, sku)
684 .filter(|product| self.is_product_visible_for_site(product, site_id))?;
685 Some(CatalogItem {
686 sku: product.sku.clone(),
687 title: product.title.clone(),
688 variant_title: product.variant_title.clone(),
689 product_kind: product.product_kind.clone(),
690 entitlement_key: product.entitlement_key.clone(),
691 unit_price_minor: product.price_minor,
692 })
693 }
694}
695
696fn default_currency() -> String {
697 DEFAULT_CURRENCY.to_string()
698}
699
700fn default_catalog_visibility() -> bool {
701 true
702}
703
704fn default_product_kind() -> String {
705 "physical".to_string()
706}
707
708fn default_variant_title() -> String {
709 "Standard".to_string()
710}
711
712impl StorefrontStateStore {
713 pub fn open_for_plan(plan: &RuntimePlan) -> Result<Self, StorefrontStateError> {
714 Self::open_with_catalog(
715 plan.shared_state_root().clone(),
716 plan.shared_backend_namespace(),
717 Arc::new(plan.storefront_catalog.clone()),
718 )
719 }
720
721 pub fn open_with_root(
722 root: impl Into<PathBuf>,
723 namespace: impl Into<String>,
724 ) -> Result<Self, StorefrontStateError> {
725 Self::open_with_catalog(
726 root,
727 namespace,
728 Arc::new(StorefrontCatalog::default_sample()),
729 )
730 }
731
732 fn open_with_catalog(
733 root: impl Into<PathBuf>,
734 namespace: impl Into<String>,
735 catalog: Arc<StorefrontCatalog>,
736 ) -> Result<Self, StorefrontStateError> {
737 let root = root.into();
738 let namespace = namespace.into();
739 let path = database_path(&root, &namespace);
740 if let Some(parent) = path.parent() {
741 std::fs::create_dir_all(parent).map_err(|error| {
742 StorefrontStateError::Initialization {
743 path: parent.display().to_string(),
744 reason: error.to_string(),
745 }
746 })?;
747 }
748
749 let connection =
750 Connection::open(&path).map_err(|error| StorefrontStateError::Initialization {
751 path: path.display().to_string(),
752 reason: error.to_string(),
753 })?;
754 connection
755 .execute_batch(
756 r#"
757 PRAGMA journal_mode = WAL;
758 PRAGMA synchronous = FULL;
759 CREATE TABLE IF NOT EXISTS carts (
760 session_id TEXT PRIMARY KEY,
761 principal_id TEXT,
762 status TEXT NOT NULL,
763 payment_status TEXT NOT NULL DEFAULT 'not_started',
764 payment_method TEXT,
765 payment_reference TEXT,
766 payment_last4 TEXT,
767 checkout_email TEXT,
768 currency TEXT NOT NULL,
769 updated_at_unix_seconds INTEGER NOT NULL
770 );
771 CREATE TABLE IF NOT EXISTS cart_lines (
772 session_id TEXT NOT NULL,
773 sku TEXT NOT NULL,
774 title TEXT NOT NULL,
775 variant_title TEXT NOT NULL,
776 product_kind TEXT NOT NULL,
777 entitlement_key TEXT,
778 metadata_json TEXT,
779 quantity INTEGER NOT NULL,
780 unit_price_minor INTEGER NOT NULL,
781 currency TEXT NOT NULL,
782 PRIMARY KEY (session_id, sku)
783 );
784 CREATE TABLE IF NOT EXISTS storefront_sequences (
785 name TEXT PRIMARY KEY,
786 next_value INTEGER NOT NULL
787 );
788 CREATE TABLE IF NOT EXISTS orders (
789 order_id TEXT PRIMARY KEY,
790 session_id TEXT NOT NULL,
791 principal_id TEXT,
792 metadata_json TEXT,
793 status TEXT NOT NULL,
794 payment_status TEXT NOT NULL DEFAULT 'captured',
795 payment_method TEXT,
796 payment_reference TEXT,
797 payment_last4 TEXT,
798 checkout_email TEXT,
799 currency TEXT NOT NULL,
800 line_count INTEGER NOT NULL,
801 subtotal_minor INTEGER NOT NULL,
802 total_minor INTEGER NOT NULL,
803 order_paid_event_dispatched_at_unix_seconds INTEGER,
804 created_at_unix_seconds INTEGER NOT NULL
805 );
806 CREATE TABLE IF NOT EXISTS order_lines (
807 order_id TEXT NOT NULL,
808 sku TEXT NOT NULL,
809 title TEXT NOT NULL,
810 variant_title TEXT NOT NULL,
811 product_kind TEXT NOT NULL,
812 entitlement_key TEXT,
813 metadata_json TEXT,
814 quantity INTEGER NOT NULL,
815 unit_price_minor INTEGER NOT NULL,
816 currency TEXT NOT NULL,
817 PRIMARY KEY (order_id, sku)
818 );
819 CREATE TABLE IF NOT EXISTS catalog_product_overrides (
820 handle TEXT PRIMARY KEY,
821 title TEXT NOT NULL,
822 summary TEXT NOT NULL,
823 price_minor INTEGER NOT NULL,
824 collection_handle TEXT NOT NULL,
825 updated_at_unix_seconds INTEGER NOT NULL
826 );
827 CREATE TABLE IF NOT EXISTS catalog_collection_overrides (
828 handle TEXT PRIMARY KEY,
829 title TEXT NOT NULL,
830 label TEXT NOT NULL,
831 summary TEXT NOT NULL,
832 updated_at_unix_seconds INTEGER NOT NULL
833 );
834 CREATE TABLE IF NOT EXISTS catalog_product_visibility_overrides (
835 handle TEXT PRIMARY KEY,
836 is_visible INTEGER NOT NULL,
837 updated_at_unix_seconds INTEGER NOT NULL
838 );
839 CREATE TABLE IF NOT EXISTS catalog_collection_visibility_overrides (
840 handle TEXT PRIMARY KEY,
841 is_visible INTEGER NOT NULL,
842 updated_at_unix_seconds INTEGER NOT NULL
843 );
844 CREATE INDEX IF NOT EXISTS orders_by_session
845 ON orders (session_id, created_at_unix_seconds DESC);
846 CREATE INDEX IF NOT EXISTS orders_by_principal
847 ON orders (principal_id, created_at_unix_seconds DESC);
848 INSERT INTO storefront_sequences (name, next_value)
849 VALUES ('order', 10042)
850 ON CONFLICT(name) DO NOTHING;
851 INSERT INTO storefront_sequences (name, next_value)
852 VALUES ('payment', 50001)
853 ON CONFLICT(name) DO NOTHING;
854 INSERT INTO storefront_sequences (name, next_value)
855 VALUES ('refund', 7001)
856 ON CONFLICT(name) DO NOTHING;
857 CREATE TABLE IF NOT EXISTS order_refunds (
858 refund_id TEXT PRIMARY KEY,
859 order_id TEXT NOT NULL,
860 amount_minor INTEGER NOT NULL,
861 currency TEXT NOT NULL,
862 reason TEXT NOT NULL,
863 created_at_unix_seconds INTEGER NOT NULL
864 );
865 CREATE INDEX IF NOT EXISTS order_refunds_by_order
866 ON order_refunds (order_id, created_at_unix_seconds DESC);
867 "#,
868 )
869 .map_err(|error| StorefrontStateError::Initialization {
870 path: path.display().to_string(),
871 reason: error.to_string(),
872 })?;
873 ensure_storefront_columns(&connection)?;
874
875 Ok(Self {
876 path,
877 connection: Arc::new(Mutex::new(connection)),
878 catalog,
879 })
880 }
881
882 pub fn path(&self) -> &Path {
883 self.path.as_path()
884 }
885
886 pub fn snapshot(
887 &self,
888 session_id: &str,
889 principal_id: Option<&str>,
890 ) -> Result<StorefrontStateSnapshot, StorefrontStateError> {
891 let mut connection = self.lock_connection()?;
892 let tx = connection.transaction().map_err(|error| {
893 query_error(format!("failed to start storefront snapshot: {error}"))
894 })?;
895 self.ensure_cart(&tx, session_id, principal_id, "active", 0)?;
896 let snapshot = self.load_snapshot(&tx, session_id, principal_id, 50)?;
897 tx.commit().map_err(|error| {
898 query_error(format!("failed to commit storefront snapshot: {error}"))
899 })?;
900 Ok(snapshot)
901 }
902
903 pub fn catalog(&self) -> Result<StorefrontCatalog, StorefrontStateError> {
904 let mut connection = self.lock_connection()?;
905 let tx = connection.transaction().map_err(|error| {
906 query_error(format!(
907 "failed to start storefront catalog transaction: {error}"
908 ))
909 })?;
910 let catalog = self.load_effective_catalog(&tx)?;
911 tx.commit().map_err(|error| {
912 query_error(format!(
913 "failed to commit storefront catalog transaction: {error}"
914 ))
915 })?;
916 Ok(catalog)
917 }
918
919 pub fn update_catalog_product(
920 &self,
921 update: &StorefrontCatalogProductUpdate,
922 now_unix_seconds: u64,
923 ) -> Result<StorefrontCatalog, StorefrontStateError> {
924 let mut connection = self.lock_connection()?;
925 let tx = connection.transaction().map_err(|error| {
926 query_error(format!(
927 "failed to start storefront catalog product update transaction: {error}"
928 ))
929 })?;
930 let catalog = self.load_effective_catalog(&tx)?;
931 if catalog.product(update.handle.as_str()).is_none() {
932 return Err(StorefrontStateError::MissingCatalogProduct {
933 handle: update.handle.clone(),
934 });
935 }
936 if catalog
937 .collection(update.collection_handle.as_str())
938 .is_none()
939 {
940 return Err(StorefrontStateError::MissingCatalogCollection {
941 handle: update.collection_handle.clone(),
942 });
943 }
944 tx.execute(
945 r#"
946 INSERT INTO catalog_product_overrides (
947 handle, title, summary, price_minor, collection_handle, updated_at_unix_seconds
948 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
949 ON CONFLICT(handle) DO UPDATE SET
950 title = excluded.title,
951 summary = excluded.summary,
952 price_minor = excluded.price_minor,
953 collection_handle = excluded.collection_handle,
954 updated_at_unix_seconds = excluded.updated_at_unix_seconds
955 "#,
956 params![
957 update.handle,
958 update.title,
959 update.summary,
960 update.price_minor,
961 update.collection_handle,
962 saturating_i64(now_unix_seconds),
963 ],
964 )
965 .map_err(|error| {
966 query_error(format!(
967 "failed to persist storefront catalog product override: {error}"
968 ))
969 })?;
970 tx.execute(
971 r#"
972 INSERT INTO catalog_product_visibility_overrides (
973 handle, is_visible, updated_at_unix_seconds
974 ) VALUES (?1, ?2, ?3)
975 ON CONFLICT(handle) DO UPDATE SET
976 is_visible = excluded.is_visible,
977 updated_at_unix_seconds = excluded.updated_at_unix_seconds
978 "#,
979 params![
980 update.handle,
981 if update.is_visible { 1_i64 } else { 0_i64 },
982 saturating_i64(now_unix_seconds),
983 ],
984 )
985 .map_err(|error| {
986 query_error(format!(
987 "failed to persist storefront catalog product visibility override: {error}"
988 ))
989 })?;
990 let catalog = self.load_effective_catalog(&tx)?;
991 tx.commit().map_err(|error| {
992 query_error(format!(
993 "failed to commit storefront catalog product update transaction: {error}"
994 ))
995 })?;
996 Ok(catalog)
997 }
998
999 pub fn update_catalog_collection(
1000 &self,
1001 update: &StorefrontCatalogCollectionUpdate,
1002 now_unix_seconds: u64,
1003 ) -> Result<StorefrontCatalog, StorefrontStateError> {
1004 let mut connection = self.lock_connection()?;
1005 let tx = connection.transaction().map_err(|error| {
1006 query_error(format!(
1007 "failed to start storefront catalog collection update transaction: {error}"
1008 ))
1009 })?;
1010 let catalog = self.load_effective_catalog(&tx)?;
1011 if catalog.collection(update.handle.as_str()).is_none() {
1012 return Err(StorefrontStateError::MissingCatalogCollection {
1013 handle: update.handle.clone(),
1014 });
1015 }
1016 tx.execute(
1017 r#"
1018 INSERT INTO catalog_collection_overrides (
1019 handle, title, label, summary, updated_at_unix_seconds
1020 ) VALUES (?1, ?2, ?3, ?4, ?5)
1021 ON CONFLICT(handle) DO UPDATE SET
1022 title = excluded.title,
1023 label = excluded.label,
1024 summary = excluded.summary,
1025 updated_at_unix_seconds = excluded.updated_at_unix_seconds
1026 "#,
1027 params![
1028 update.handle,
1029 update.title,
1030 update.label,
1031 update.summary,
1032 saturating_i64(now_unix_seconds),
1033 ],
1034 )
1035 .map_err(|error| {
1036 query_error(format!(
1037 "failed to persist storefront catalog collection override: {error}"
1038 ))
1039 })?;
1040 tx.execute(
1041 r#"
1042 INSERT INTO catalog_collection_visibility_overrides (
1043 handle, is_visible, updated_at_unix_seconds
1044 ) VALUES (?1, ?2, ?3)
1045 ON CONFLICT(handle) DO UPDATE SET
1046 is_visible = excluded.is_visible,
1047 updated_at_unix_seconds = excluded.updated_at_unix_seconds
1048 "#,
1049 params![
1050 update.handle,
1051 if update.is_visible { 1_i64 } else { 0_i64 },
1052 saturating_i64(now_unix_seconds),
1053 ],
1054 )
1055 .map_err(|error| {
1056 query_error(format!(
1057 "failed to persist storefront catalog collection visibility override: {error}"
1058 ))
1059 })?;
1060 let catalog = self.load_effective_catalog(&tx)?;
1061 tx.commit().map_err(|error| {
1062 query_error(format!(
1063 "failed to commit storefront catalog collection update transaction: {error}"
1064 ))
1065 })?;
1066 Ok(catalog)
1067 }
1068
1069 pub fn add_to_cart(
1070 &self,
1071 session_id: &str,
1072 principal_id: Option<&str>,
1073 sku: &str,
1074 quantity: u32,
1075 now_unix_seconds: u64,
1076 ) -> Result<StorefrontStateSnapshot, StorefrontStateError> {
1077 if quantity == 0 {
1078 return Err(StorefrontStateError::InvalidQuantity);
1079 }
1080
1081 let mut connection = self.lock_connection()?;
1082 let tx = connection.transaction().map_err(|error| {
1083 query_error(format!("failed to start add-to-cart transaction: {error}"))
1084 })?;
1085 self.ensure_cart(&tx, session_id, principal_id, "active", now_unix_seconds)?;
1086 let item = self.catalog_item_in_tx(&tx, sku)?;
1087 tx.execute(
1088 r#"
1089 INSERT INTO cart_lines (
1090 session_id, sku, title, variant_title, product_kind, entitlement_key,
1091 metadata_json, quantity, unit_price_minor, currency
1092 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
1093 ON CONFLICT(session_id, sku) DO UPDATE SET
1094 quantity = cart_lines.quantity + excluded.quantity,
1095 title = excluded.title,
1096 variant_title = excluded.variant_title,
1097 product_kind = excluded.product_kind,
1098 entitlement_key = excluded.entitlement_key,
1099 metadata_json = excluded.metadata_json,
1100 unit_price_minor = excluded.unit_price_minor,
1101 currency = excluded.currency
1102 "#,
1103 params![
1104 session_id,
1105 item.sku,
1106 item.title,
1107 item.variant_title,
1108 item.product_kind,
1109 item.entitlement_key,
1110 storefront_metadata_json(&cart_line_metadata(&self.catalog, &item))?,
1111 i64::from(quantity),
1112 item.unit_price_minor,
1113 DEFAULT_CURRENCY,
1114 ],
1115 )
1116 .map_err(|error| query_error(format!("failed to add storefront cart line: {error}")))?;
1117 self.touch_cart(&tx, session_id, principal_id, "active", now_unix_seconds)?;
1118 self.update_cart_payment(&tx, session_id, "not_started", None, None, None, None)?;
1119 let snapshot = self.load_snapshot(&tx, session_id, principal_id, 50)?;
1120 tx.commit().map_err(|error| {
1121 query_error(format!("failed to commit add-to-cart transaction: {error}"))
1122 })?;
1123 Ok(snapshot)
1124 }
1125
1126 pub fn update_cart(
1127 &self,
1128 session_id: &str,
1129 principal_id: Option<&str>,
1130 sku: &str,
1131 quantity: u32,
1132 now_unix_seconds: u64,
1133 ) -> Result<StorefrontStateSnapshot, StorefrontStateError> {
1134 let mut connection = self.lock_connection()?;
1135 let tx = connection.transaction().map_err(|error| {
1136 query_error(format!("failed to start cart update transaction: {error}"))
1137 })?;
1138 self.ensure_cart(&tx, session_id, principal_id, "active", now_unix_seconds)?;
1139 if quantity == 0 {
1140 tx.execute(
1141 "DELETE FROM cart_lines WHERE session_id = ?1 AND sku = ?2",
1142 params![session_id, sku],
1143 )
1144 .map_err(|error| {
1145 query_error(format!("failed to remove storefront cart line: {error}"))
1146 })?;
1147 } else {
1148 let item = self.catalog_item_in_tx(&tx, sku)?;
1149 tx.execute(
1150 r#"
1151 INSERT INTO cart_lines (
1152 session_id, sku, title, variant_title, product_kind, entitlement_key,
1153 metadata_json, quantity, unit_price_minor, currency
1154 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
1155 ON CONFLICT(session_id, sku) DO UPDATE SET
1156 quantity = excluded.quantity,
1157 title = excluded.title,
1158 variant_title = excluded.variant_title,
1159 product_kind = excluded.product_kind,
1160 entitlement_key = excluded.entitlement_key,
1161 metadata_json = excluded.metadata_json,
1162 unit_price_minor = excluded.unit_price_minor,
1163 currency = excluded.currency
1164 "#,
1165 params![
1166 session_id,
1167 item.sku,
1168 item.title,
1169 item.variant_title,
1170 item.product_kind,
1171 item.entitlement_key,
1172 storefront_metadata_json(&cart_line_metadata(&self.catalog, &item))?,
1173 i64::from(quantity),
1174 item.unit_price_minor,
1175 DEFAULT_CURRENCY,
1176 ],
1177 )
1178 .map_err(|error| {
1179 query_error(format!("failed to update storefront cart line: {error}"))
1180 })?;
1181 }
1182 self.touch_cart(&tx, session_id, principal_id, "active", now_unix_seconds)?;
1183 self.update_cart_payment(&tx, session_id, "not_started", None, None, None, None)?;
1184 let snapshot = self.load_snapshot(&tx, session_id, principal_id, 50)?;
1185 tx.commit().map_err(|error| {
1186 query_error(format!("failed to commit cart update transaction: {error}"))
1187 })?;
1188 Ok(snapshot)
1189 }
1190
1191 fn catalog_item(&self, sku: &str) -> Result<CatalogItem, StorefrontStateError> {
1192 self.catalog()?
1193 .catalog_item(sku)
1194 .ok_or_else(|| StorefrontStateError::UnknownSku {
1195 sku: sku.to_string(),
1196 })
1197 }
1198
1199 fn catalog_item_in_tx(
1200 &self,
1201 tx: &Transaction<'_>,
1202 sku: &str,
1203 ) -> Result<CatalogItem, StorefrontStateError> {
1204 self.load_effective_catalog(tx)?
1205 .catalog_item(sku)
1206 .ok_or_else(|| StorefrontStateError::UnknownSku {
1207 sku: sku.to_string(),
1208 })
1209 }
1210
1211 fn load_effective_catalog(
1212 &self,
1213 tx: &Transaction<'_>,
1214 ) -> Result<StorefrontCatalog, StorefrontStateError> {
1215 let mut catalog = self.catalog.as_ref().clone();
1216
1217 let mut collection_statement = tx
1218 .prepare(
1219 r#"
1220 SELECT handle, title, label, summary
1221 FROM catalog_collection_overrides
1222 ORDER BY handle ASC
1223 "#,
1224 )
1225 .map_err(|error| {
1226 query_error(format!(
1227 "failed to prepare storefront catalog collection override query: {error}"
1228 ))
1229 })?;
1230 let collection_overrides = collection_statement
1231 .query_map([], |row| {
1232 Ok((
1233 row.get::<_, String>(0)?,
1234 row.get::<_, String>(1)?,
1235 row.get::<_, String>(2)?,
1236 row.get::<_, String>(3)?,
1237 ))
1238 })
1239 .map_err(|error| {
1240 query_error(format!(
1241 "failed to query storefront catalog collection overrides: {error}"
1242 ))
1243 })?
1244 .collect::<Result<Vec<_>, _>>()
1245 .map_err(|error| {
1246 query_error(format!(
1247 "failed to collect storefront catalog collection overrides: {error}"
1248 ))
1249 })?;
1250 for (handle, title, label, summary) in collection_overrides {
1251 if let Some(collection) = catalog
1252 .collections
1253 .iter_mut()
1254 .find(|collection| collection.handle == handle)
1255 {
1256 collection.title = title;
1257 collection.label = label;
1258 collection.summary = summary;
1259 }
1260 }
1261 let mut collection_visibility_statement = tx
1262 .prepare(
1263 r#"
1264 SELECT handle, is_visible
1265 FROM catalog_collection_visibility_overrides
1266 ORDER BY handle ASC
1267 "#,
1268 )
1269 .map_err(|error| {
1270 query_error(format!(
1271 "failed to prepare storefront catalog collection visibility query: {error}"
1272 ))
1273 })?;
1274 let collection_visibility_overrides = collection_visibility_statement
1275 .query_map([], |row| {
1276 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? != 0))
1277 })
1278 .map_err(|error| {
1279 query_error(format!(
1280 "failed to query storefront catalog collection visibility overrides: {error}"
1281 ))
1282 })?
1283 .collect::<Result<Vec<_>, _>>()
1284 .map_err(|error| {
1285 query_error(format!(
1286 "failed to collect storefront catalog collection visibility overrides: {error}"
1287 ))
1288 })?;
1289 for (handle, is_visible) in collection_visibility_overrides {
1290 if let Some(collection) = catalog
1291 .collections
1292 .iter_mut()
1293 .find(|collection| collection.handle == handle)
1294 {
1295 collection.is_visible = is_visible;
1296 }
1297 }
1298
1299 let mut product_statement = tx
1300 .prepare(
1301 r#"
1302 SELECT handle, title, summary, price_minor, collection_handle
1303 FROM catalog_product_overrides
1304 ORDER BY handle ASC
1305 "#,
1306 )
1307 .map_err(|error| {
1308 query_error(format!(
1309 "failed to prepare storefront catalog product override query: {error}"
1310 ))
1311 })?;
1312 let product_overrides = product_statement
1313 .query_map([], |row| {
1314 Ok((
1315 row.get::<_, String>(0)?,
1316 row.get::<_, String>(1)?,
1317 row.get::<_, String>(2)?,
1318 row.get::<_, i64>(3)?,
1319 row.get::<_, String>(4)?,
1320 ))
1321 })
1322 .map_err(|error| {
1323 query_error(format!(
1324 "failed to query storefront catalog product overrides: {error}"
1325 ))
1326 })?
1327 .collect::<Result<Vec<_>, _>>()
1328 .map_err(|error| {
1329 query_error(format!(
1330 "failed to collect storefront catalog product overrides: {error}"
1331 ))
1332 })?;
1333 for (handle, title, summary, price_minor, collection_handle) in product_overrides {
1334 if catalog.collection(collection_handle.as_str()).is_none() {
1335 return Err(StorefrontStateError::MissingCatalogCollection {
1336 handle: collection_handle,
1337 });
1338 }
1339 if let Some(product) = catalog
1340 .products
1341 .iter_mut()
1342 .find(|product| product.handle == handle)
1343 {
1344 product.title = title;
1345 product.summary = summary;
1346 product.price_minor = price_minor;
1347 product.collection_handle = collection_handle;
1348 }
1349 }
1350 let mut product_visibility_statement = tx
1351 .prepare(
1352 r#"
1353 SELECT handle, is_visible
1354 FROM catalog_product_visibility_overrides
1355 ORDER BY handle ASC
1356 "#,
1357 )
1358 .map_err(|error| {
1359 query_error(format!(
1360 "failed to prepare storefront catalog product visibility query: {error}"
1361 ))
1362 })?;
1363 let product_visibility_overrides = product_visibility_statement
1364 .query_map([], |row| {
1365 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? != 0))
1366 })
1367 .map_err(|error| {
1368 query_error(format!(
1369 "failed to query storefront catalog product visibility overrides: {error}"
1370 ))
1371 })?
1372 .collect::<Result<Vec<_>, _>>()
1373 .map_err(|error| {
1374 query_error(format!(
1375 "failed to collect storefront catalog product visibility overrides: {error}"
1376 ))
1377 })?;
1378 for (handle, is_visible) in product_visibility_overrides {
1379 if let Some(product) = catalog
1380 .products
1381 .iter_mut()
1382 .find(|product| product.handle == handle)
1383 {
1384 product.is_visible = is_visible;
1385 }
1386 }
1387
1388 Ok(catalog)
1389 }
1390
1391 pub fn checkout_start(
1392 &self,
1393 session_id: &str,
1394 principal_id: Option<&str>,
1395 now_unix_seconds: u64,
1396 ) -> Result<StorefrontStateSnapshot, StorefrontStateError> {
1397 let mut connection = self.lock_connection()?;
1398 let tx = connection.transaction().map_err(|error| {
1399 query_error(format!(
1400 "failed to start checkout-start transaction: {error}"
1401 ))
1402 })?;
1403 self.ensure_cart(&tx, session_id, principal_id, "active", now_unix_seconds)?;
1404 if self.cart_line_count(&tx, session_id)? == 0 {
1405 return Err(StorefrontStateError::EmptyCart {
1406 session_id: session_id.to_string(),
1407 });
1408 }
1409 let payment_reference = self
1410 .load_cart_payment(&tx, session_id)?
1411 .reference
1412 .unwrap_or(next_payment_reference(&tx)?);
1413 self.touch_cart(&tx, session_id, principal_id, "checkout", now_unix_seconds)?;
1414 self.update_cart_payment(
1415 &tx,
1416 session_id,
1417 "ready_for_payment",
1418 None,
1419 None,
1420 None,
1421 Some(payment_reference.as_str()),
1422 )?;
1423 let snapshot = self.load_snapshot(&tx, session_id, principal_id, 50)?;
1424 tx.commit().map_err(|error| {
1425 query_error(format!(
1426 "failed to commit checkout-start transaction: {error}"
1427 ))
1428 })?;
1429 Ok(snapshot)
1430 }
1431
1432 pub fn checkout_complete(
1433 &self,
1434 session_id: &str,
1435 principal_id: Option<&str>,
1436 payment: &StorefrontPaymentInput,
1437 now_unix_seconds: u64,
1438 ) -> Result<StorefrontStateSnapshot, StorefrontStateError> {
1439 self.checkout_complete_with_metadata(
1440 session_id,
1441 principal_id,
1442 payment,
1443 &BTreeMap::new(),
1444 now_unix_seconds,
1445 )
1446 }
1447
1448 pub fn checkout_complete_with_metadata(
1449 &self,
1450 session_id: &str,
1451 principal_id: Option<&str>,
1452 payment: &StorefrontPaymentInput,
1453 order_metadata: &BTreeMap<String, String>,
1454 now_unix_seconds: u64,
1455 ) -> Result<StorefrontStateSnapshot, StorefrontStateError> {
1456 let mut connection = self.lock_connection()?;
1457 let tx = connection.transaction().map_err(|error| {
1458 query_error(format!(
1459 "failed to start checkout-complete transaction: {error}"
1460 ))
1461 })?;
1462 self.ensure_cart(&tx, session_id, principal_id, "checkout", now_unix_seconds)?;
1463 let lines = self.load_cart_lines(&tx, session_id)?;
1464 if lines.is_empty() {
1465 return Err(StorefrontStateError::EmptyCart {
1466 session_id: session_id.to_string(),
1467 });
1468 }
1469 let (cart_status, payment_status) = self.cart_status(&tx, session_id)?;
1470 if cart_status != "checkout" || payment_status != "ready_for_payment" {
1471 return Err(StorefrontStateError::CheckoutNotReady {
1472 session_id: session_id.to_string(),
1473 });
1474 }
1475
1476 let subtotal_minor = lines.iter().map(|line| line.total_minor).sum::<i64>();
1477 let line_count = lines.iter().map(|line| line.quantity).sum::<u32>();
1478 let order_id = next_order_id(&tx)?;
1479 let reserved_payment = self.load_cart_payment(&tx, session_id)?;
1480 let payment_reference = reserved_payment
1481 .reference
1482 .ok_or(StorefrontStateError::MissingPaymentIntent)?;
1483 if payment.intent_reference != payment_reference {
1484 return Err(StorefrontStateError::PaymentIntentMismatch {
1485 expected: payment_reference,
1486 received: payment.intent_reference.clone(),
1487 });
1488 }
1489 let order_metadata_json = storefront_metadata_json(order_metadata)?;
1490 tx.execute(
1491 r#"
1492 INSERT INTO orders (
1493 order_id, session_id, principal_id, metadata_json, status, payment_status, payment_method,
1494 payment_reference, payment_last4, checkout_email, currency,
1495 line_count, subtotal_minor, total_minor, created_at_unix_seconds
1496 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
1497 "#,
1498 params![
1499 order_id,
1500 session_id,
1501 principal_id,
1502 order_metadata_json,
1503 "pending_payment",
1504 "provider_pending",
1505 payment.method.as_str(),
1506 payment.intent_reference.as_str(),
1507 payment.last4.as_deref(),
1508 payment.checkout_email.as_str(),
1509 DEFAULT_CURRENCY,
1510 i64::from(line_count),
1511 subtotal_minor,
1512 subtotal_minor,
1513 saturating_i64(now_unix_seconds),
1514 ],
1515 )
1516 .map_err(|error| query_error(format!("failed to create storefront order: {error}")))?;
1517 for line in &lines {
1518 tx.execute(
1519 r#"
1520 INSERT INTO order_lines (
1521 order_id, sku, title, variant_title, product_kind,
1522 entitlement_key, metadata_json, quantity, unit_price_minor, currency
1523 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
1524 "#,
1525 params![
1526 order_id,
1527 line.sku,
1528 line.title,
1529 line.variant_title,
1530 line.product_kind,
1531 line.entitlement_key,
1532 storefront_metadata_json(&line.metadata)?,
1533 i64::from(line.quantity),
1534 line.unit_price_minor,
1535 line.currency,
1536 ],
1537 )
1538 .map_err(|error| {
1539 query_error(format!("failed to persist storefront order line: {error}"))
1540 })?;
1541 }
1542 tx.execute(
1543 "DELETE FROM cart_lines WHERE session_id = ?1",
1544 params![session_id],
1545 )
1546 .map_err(|error| query_error(format!("failed to clear storefront cart lines: {error}")))?;
1547 self.touch_cart(&tx, session_id, principal_id, "completed", now_unix_seconds)?;
1548 self.update_cart_payment(
1549 &tx,
1550 session_id,
1551 "provider_pending",
1552 Some(payment.method.as_str()),
1553 payment.last4.as_deref(),
1554 Some(payment.checkout_email.as_str()),
1555 Some(payment.intent_reference.as_str()),
1556 )?;
1557 let snapshot = self.load_snapshot(&tx, session_id, principal_id, 50)?;
1558 tx.commit().map_err(|error| {
1559 query_error(format!(
1560 "failed to commit checkout-complete transaction: {error}"
1561 ))
1562 })?;
1563 Ok(snapshot)
1564 }
1565
1566 pub fn order_history(
1567 &self,
1568 session_id: &str,
1569 principal_id: Option<&str>,
1570 limit: usize,
1571 ) -> Result<StorefrontOrderHistoryResponse, StorefrontStateError> {
1572 let mut connection = self.lock_connection()?;
1573 let tx = connection.transaction().map_err(|error| {
1574 query_error(format!(
1575 "failed to start storefront order-history transaction: {error}"
1576 ))
1577 })?;
1578 self.ensure_cart(&tx, session_id, principal_id, "active", 0)?;
1579 let orders = self.load_orders(&tx, session_id, principal_id, limit)?;
1580 tx.commit().map_err(|error| {
1581 query_error(format!(
1582 "failed to commit storefront order-history transaction: {error}"
1583 ))
1584 })?;
1585 Ok(StorefrontOrderHistoryResponse {
1586 session_id: session_id.to_string(),
1587 principal_id: principal_id.map(ToOwned::to_owned),
1588 orders,
1589 })
1590 }
1591
1592 pub fn admin_orders(
1593 &self,
1594 limit: usize,
1595 ) -> Result<Vec<StorefrontOrderSnapshot>, StorefrontStateError> {
1596 let mut connection = self.lock_connection()?;
1597 let tx = connection.transaction().map_err(|error| {
1598 query_error(format!(
1599 "failed to start storefront admin order-history transaction: {error}"
1600 ))
1601 })?;
1602 let orders = self.load_all_orders(&tx, limit)?;
1603 tx.commit().map_err(|error| {
1604 query_error(format!(
1605 "failed to commit storefront admin order-history transaction: {error}"
1606 ))
1607 })?;
1608 Ok(orders)
1609 }
1610
1611 pub fn admin_order(
1612 &self,
1613 order_id: &str,
1614 ) -> Result<Option<StorefrontOrderSnapshot>, StorefrontStateError> {
1615 let mut connection = self.lock_connection()?;
1616 let tx = connection.transaction().map_err(|error| {
1617 query_error(format!(
1618 "failed to start storefront admin order-detail transaction: {error}"
1619 ))
1620 })?;
1621 let order = self.load_order_by_id(&tx, order_id).map_err(|error| {
1622 query_error(format!(
1623 "failed to load storefront admin order detail: {error}"
1624 ))
1625 })?;
1626 tx.commit().map_err(|error| {
1627 query_error(format!(
1628 "failed to commit storefront admin order-detail transaction: {error}"
1629 ))
1630 })?;
1631 Ok(order)
1632 }
1633
1634 pub fn order_by_payment_reference(
1635 &self,
1636 payment_reference: &str,
1637 ) -> Result<Option<StorefrontOrderSnapshot>, StorefrontStateError> {
1638 let mut connection = self.lock_connection()?;
1639 let tx = connection.transaction().map_err(|error| {
1640 query_error(format!(
1641 "failed to start storefront payment-reference order lookup transaction: {error}"
1642 ))
1643 })?;
1644 let order = if let Some((order_id, _, _, _, _, _)) =
1645 self.load_order_header_by_payment_reference(&tx, payment_reference)?
1646 {
1647 self.load_order_by_id(&tx, order_id.as_str()).map_err(|error| {
1648 query_error(format!(
1649 "failed to load storefront order detail for payment reference `{payment_reference}`: {error}"
1650 ))
1651 })?
1652 } else {
1653 None
1654 };
1655 tx.commit().map_err(|error| {
1656 query_error(format!(
1657 "failed to commit storefront payment-reference order lookup transaction: {error}"
1658 ))
1659 })?;
1660 Ok(order)
1661 }
1662
1663 pub fn refund_order(
1664 &self,
1665 order_id: &str,
1666 reason: &str,
1667 now_unix_seconds: u64,
1668 ) -> Result<StorefrontOrderSnapshot, StorefrontStateError> {
1669 let reason = reason.trim();
1670 if reason.is_empty() {
1671 return Err(StorefrontStateError::MissingRefundReason);
1672 }
1673
1674 let mut connection = self.lock_connection()?;
1675 let tx = connection.transaction().map_err(|error| {
1676 query_error(format!(
1677 "failed to start storefront refund transaction: {error}"
1678 ))
1679 })?;
1680 let order = self
1681 .load_order_by_id(&tx, order_id)
1682 .map_err(|error| {
1683 query_error(format!(
1684 "failed to load storefront order for refund: {error}"
1685 ))
1686 })?
1687 .ok_or_else(|| StorefrontStateError::UnknownOrder {
1688 order_id: order_id.to_string(),
1689 })?;
1690 if !matches!(
1691 order.status.as_str(),
1692 "paid" | "fulfilled" | "partially_refunded"
1693 ) || order.refundable_total_minor <= 0
1694 {
1695 return Err(StorefrontStateError::RefundNotAllowed {
1696 order_id: order_id.to_string(),
1697 status: order.status,
1698 });
1699 }
1700
1701 tx.execute(
1702 r#"
1703 INSERT INTO order_refunds (
1704 refund_id, order_id, amount_minor, currency, reason, created_at_unix_seconds
1705 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1706 "#,
1707 params![
1708 next_refund_id(&tx)?,
1709 order_id,
1710 order.refundable_total_minor,
1711 order.currency,
1712 reason,
1713 saturating_i64(now_unix_seconds),
1714 ],
1715 )
1716 .map_err(|error| query_error(format!("failed to persist storefront refund: {error}")))?;
1717 tx.execute(
1718 r#"
1719 UPDATE orders
1720 SET status = 'refunded',
1721 payment_status = 'refunded'
1722 WHERE order_id = ?1
1723 "#,
1724 params![order_id],
1725 )
1726 .map_err(|error| {
1727 query_error(format!(
1728 "failed to update storefront refunded order: {error}"
1729 ))
1730 })?;
1731 let updated = self
1732 .load_order_by_id(&tx, order_id)
1733 .map_err(|error| {
1734 query_error(format!(
1735 "failed to reload refunded storefront order: {error}"
1736 ))
1737 })?
1738 .ok_or_else(|| StorefrontStateError::UnknownOrder {
1739 order_id: order_id.to_string(),
1740 })?;
1741 tx.commit().map_err(|error| {
1742 query_error(format!(
1743 "failed to commit storefront refund transaction: {error}"
1744 ))
1745 })?;
1746 Ok(updated)
1747 }
1748
1749 pub fn fulfill_order(
1750 &self,
1751 order_id: &str,
1752 now_unix_seconds: u64,
1753 ) -> Result<StorefrontOrderSnapshot, StorefrontStateError> {
1754 let mut connection = self.lock_connection()?;
1755 let tx = connection.transaction().map_err(|error| {
1756 query_error(format!(
1757 "failed to start storefront fulfillment transaction: {error}"
1758 ))
1759 })?;
1760 let order = self
1761 .load_order_by_id(&tx, order_id)
1762 .map_err(|error| {
1763 query_error(format!(
1764 "failed to load storefront order for fulfillment: {error}"
1765 ))
1766 })?
1767 .ok_or_else(|| StorefrontStateError::UnknownOrder {
1768 order_id: order_id.to_string(),
1769 })?;
1770 if order.status == "fulfilled" {
1771 tx.commit().map_err(|error| {
1772 query_error(format!(
1773 "failed to commit storefront fulfillment transaction: {error}"
1774 ))
1775 })?;
1776 return Ok(order);
1777 }
1778 if order.status != "paid" {
1779 return Err(StorefrontStateError::FulfillmentNotAllowed {
1780 order_id: order_id.to_string(),
1781 status: order.status,
1782 });
1783 }
1784
1785 tx.execute(
1786 r#"
1787 UPDATE orders
1788 SET status = 'fulfilled'
1789 WHERE order_id = ?1
1790 "#,
1791 params![order_id],
1792 )
1793 .map_err(|error| {
1794 query_error(format!(
1795 "failed to update storefront fulfilled order: {error}"
1796 ))
1797 })?;
1798 let updated = self
1799 .load_order_by_id(&tx, order_id)
1800 .map_err(|error| {
1801 query_error(format!(
1802 "failed to reload fulfilled storefront order: {error}"
1803 ))
1804 })?
1805 .ok_or_else(|| StorefrontStateError::UnknownOrder {
1806 order_id: order_id.to_string(),
1807 })?;
1808 tx.commit().map_err(|error| {
1809 query_error(format!(
1810 "failed to commit storefront fulfillment transaction: {error}"
1811 ))
1812 })?;
1813 Ok(updated)
1814 }
1815
1816 pub fn apply_payment_webhook(
1817 &self,
1818 payment_reference: &str,
1819 event: &str,
1820 now_unix_seconds: u64,
1821 ) -> Result<StorefrontPaymentWebhookReceipt, StorefrontStateError> {
1822 let mut connection = self.lock_connection()?;
1823 let tx = connection.transaction().map_err(|error| {
1824 query_error(format!(
1825 "failed to start storefront payment-webhook transaction: {error}"
1826 ))
1827 })?;
1828
1829 let Some((
1830 order_id,
1831 session_id,
1832 principal_id,
1833 current_status,
1834 payment_status,
1835 paid_event_dispatched_at,
1836 )) = self.load_order_header_by_payment_reference(&tx, payment_reference)?
1837 else {
1838 return Err(StorefrontStateError::UnknownPaymentReference {
1839 payment_reference: payment_reference.to_string(),
1840 });
1841 };
1842
1843 let payment_is_captured = payment_status == "captured";
1844 let payment_is_failed = payment_status == "failed";
1845 let (next_status, next_payment_status) = match event {
1846 "payment.captured" | "payment.succeeded" | "commerce.order.paid" => {
1847 if payment_is_failed {
1848 (current_status.clone(), payment_status.clone())
1849 } else {
1850 (
1851 if current_status == "fulfilled" {
1852 current_status.clone()
1853 } else {
1854 "paid".to_string()
1855 },
1856 "captured".to_string(),
1857 )
1858 }
1859 }
1860 "payment.authorized" => {
1861 if payment_is_captured || payment_is_failed {
1862 (current_status.clone(), payment_status.clone())
1863 } else {
1864 (current_status.clone(), "authorized".to_string())
1865 }
1866 }
1867 "payment.failed" => {
1868 if payment_is_captured || payment_is_failed {
1869 (current_status.clone(), payment_status.clone())
1870 } else {
1871 ("pending_payment".to_string(), "failed".to_string())
1872 }
1873 }
1874 other => {
1875 return Err(StorefrontStateError::UnknownPaymentWebhookEvent {
1876 event: other.to_string(),
1877 });
1878 }
1879 };
1880 let needs_paid_event_dispatch = next_payment_status == "captured"
1881 && matches!(next_status.as_str(), "paid" | "fulfilled")
1882 && paid_event_dispatched_at.is_none();
1883
1884 if current_status != next_status || payment_status != next_payment_status {
1885 tx.execute(
1886 r#"
1887 UPDATE orders
1888 SET status = ?2,
1889 payment_status = ?3
1890 WHERE order_id = ?1
1891 "#,
1892 params![order_id, next_status, next_payment_status],
1893 )
1894 .map_err(|error| {
1895 query_error(format!(
1896 "failed to update storefront order payment state: {error}"
1897 ))
1898 })?;
1899 }
1900
1901 let cart_payment = self.load_cart_payment(&tx, session_id.as_str())?;
1902 let should_restore_checkout = payment_status != "failed" && next_payment_status == "failed";
1903 let should_clear_restored_cart =
1904 payment_status != "captured" && next_payment_status == "captured";
1905 if should_restore_checkout {
1906 let _ = self.restore_order_lines_to_cart_if_empty(
1907 &tx,
1908 session_id.as_str(),
1909 order_id.as_str(),
1910 )?;
1911 } else if should_clear_restored_cart {
1912 let _ = self.clear_cart_lines_if_matching_order(
1913 &tx,
1914 session_id.as_str(),
1915 order_id.as_str(),
1916 )?;
1917 }
1918 let cart_status = if should_restore_checkout {
1919 "active"
1920 } else {
1921 "completed"
1922 };
1923 let cart_payment_reference = if should_restore_checkout {
1924 None
1925 } else {
1926 Some(payment_reference)
1927 };
1928 self.touch_cart(
1929 &tx,
1930 session_id.as_str(),
1931 principal_id.as_deref(),
1932 cart_status,
1933 now_unix_seconds,
1934 )?;
1935 self.update_cart_payment(
1936 &tx,
1937 session_id.as_str(),
1938 next_payment_status.as_str(),
1939 cart_payment.method.as_deref(),
1940 cart_payment.last4.as_deref(),
1941 cart_payment.checkout_email.as_deref(),
1942 cart_payment_reference,
1943 )?;
1944
1945 let order = self
1946 .load_order_by_id(&tx, order_id.as_str())
1947 .map_err(|error| {
1948 query_error(format!("failed to load updated storefront order: {error}"))
1949 })?
1950 .ok_or_else(|| {
1951 query_error(format!(
1952 "updated storefront order `{order_id}` could not be reloaded"
1953 ))
1954 })?;
1955 tx.commit().map_err(|error| {
1956 query_error(format!(
1957 "failed to commit storefront payment-webhook transaction: {error}"
1958 ))
1959 })?;
1960 Ok(StorefrontPaymentWebhookReceipt {
1961 order,
1962 needs_paid_event_dispatch,
1963 })
1964 }
1965
1966 pub fn mark_order_paid_event_dispatched(
1967 &self,
1968 order_id: &str,
1969 now_unix_seconds: u64,
1970 ) -> Result<(), StorefrontStateError> {
1971 let mut connection = self.lock_connection()?;
1972 let tx = connection.transaction().map_err(|error| {
1973 query_error(format!(
1974 "failed to start storefront paid-event-dispatch transaction: {error}"
1975 ))
1976 })?;
1977 tx.execute(
1978 r#"
1979 UPDATE orders
1980 SET order_paid_event_dispatched_at_unix_seconds = COALESCE(
1981 order_paid_event_dispatched_at_unix_seconds,
1982 ?2
1983 )
1984 WHERE order_id = ?1
1985 "#,
1986 params![order_id, saturating_i64(now_unix_seconds)],
1987 )
1988 .map_err(|error| {
1989 query_error(format!(
1990 "failed to mark storefront order paid event dispatched: {error}"
1991 ))
1992 })?;
1993 tx.commit().map_err(|error| {
1994 query_error(format!(
1995 "failed to commit storefront paid-event-dispatch transaction: {error}"
1996 ))
1997 })?;
1998 Ok(())
1999 }
2000
2001 pub fn build_response_augmentation(
2002 &self,
2003 route_name: &str,
2004 snapshot: &StorefrontStateSnapshot,
2005 csrf_tokens: BTreeMap<String, String>,
2006 ) -> Result<StorefrontResponseAugmentation, StorefrontStateError> {
2007 let account_session_end_markup = csrf_tokens
2008 .get(ACCOUNT_SESSION_END_ACTION)
2009 .map(|token| account_session_end_form_markup(token));
2010 let payload = serde_json::to_string(&serde_json::json!({
2011 "route": route_name,
2012 "session_id": snapshot.session_id,
2013 "principal_id": snapshot.principal_id,
2014 "cart": snapshot.cart,
2015 "payment": snapshot.payment,
2016 "recent_orders": snapshot.recent_orders,
2017 "latest_order": snapshot.latest_order,
2018 "csrf": csrf_tokens,
2019 }))
2020 .map_err(|error| StorefrontStateError::Serialization {
2021 reason: error.to_string(),
2022 })?;
2023 let escaped = payload.replace("</script", "<\\/script");
2024 let mut html_fragment = format!(
2025 r#"<script id="coil-storefront-state" type="application/json">{escaped}</script>"#
2026 );
2027 if let Some(markup) = account_session_end_markup {
2028 html_fragment.push_str(&markup);
2029 }
2030 let mut headers = BTreeMap::new();
2031 headers.insert(
2032 "x-coil-storefront-cart-items".to_string(),
2033 snapshot.cart.item_count.to_string(),
2034 );
2035 headers.insert(
2036 "x-coil-storefront-cart-subtotal-minor".to_string(),
2037 snapshot.cart.subtotal_minor.to_string(),
2038 );
2039 headers.insert(
2040 "x-coil-storefront-cart-status".to_string(),
2041 snapshot.cart.status.clone(),
2042 );
2043 headers.insert(
2044 "x-coil-storefront-payment-status".to_string(),
2045 snapshot.payment.status.clone(),
2046 );
2047 if let Some(method) = &snapshot.payment.method {
2048 headers.insert(
2049 "x-coil-storefront-payment-method".to_string(),
2050 method.clone(),
2051 );
2052 }
2053 if let Some(reference) = &snapshot.payment.reference {
2054 headers.insert(
2055 "x-coil-storefront-payment-reference".to_string(),
2056 reference.clone(),
2057 );
2058 }
2059 headers.insert(
2060 "x-coil-storefront-order-count".to_string(),
2061 snapshot.recent_orders.len().to_string(),
2062 );
2063 if let Some(order) = &snapshot.latest_order {
2064 headers.insert(
2065 "x-coil-storefront-latest-order".to_string(),
2066 order.order_id.clone(),
2067 );
2068 headers.insert(
2069 "x-coil-storefront-latest-order-status".to_string(),
2070 order.status.clone(),
2071 );
2072 }
2073 for (action, token) in csrf_tokens {
2074 headers.insert(
2075 format!("x-coil-storefront-csrf-{}", action.replace('.', "-")),
2076 token,
2077 );
2078 }
2079 Ok(StorefrontResponseAugmentation {
2080 html_fragment: Some(html_fragment),
2081 headers,
2082 })
2083 }
2084
2085 fn lock_connection(
2086 &self,
2087 ) -> Result<std::sync::MutexGuard<'_, Connection>, StorefrontStateError> {
2088 self.connection
2089 .lock()
2090 .map_err(|_| StorefrontStateError::Poisoned)
2091 }
2092
2093 fn ensure_cart(
2094 &self,
2095 tx: &Transaction<'_>,
2096 session_id: &str,
2097 principal_id: Option<&str>,
2098 status: &str,
2099 now_unix_seconds: u64,
2100 ) -> Result<(), StorefrontStateError> {
2101 tx.execute(
2102 r#"
2103 INSERT INTO carts (
2104 session_id,
2105 principal_id,
2106 status,
2107 payment_status,
2108 payment_method,
2109 payment_reference,
2110 payment_last4,
2111 checkout_email,
2112 currency,
2113 updated_at_unix_seconds
2114 )
2115 VALUES (?1, ?2, ?3, 'not_started', NULL, NULL, NULL, NULL, ?4, ?5)
2116 ON CONFLICT(session_id) DO UPDATE SET
2117 principal_id = COALESCE(excluded.principal_id, carts.principal_id),
2118 status = CASE
2119 WHEN carts.status = 'completed' AND excluded.status = 'active' THEN carts.status
2120 ELSE excluded.status
2121 END,
2122 currency = excluded.currency,
2123 updated_at_unix_seconds = MAX(carts.updated_at_unix_seconds, excluded.updated_at_unix_seconds)
2124 "#,
2125 params![
2126 session_id,
2127 principal_id,
2128 status,
2129 DEFAULT_CURRENCY,
2130 saturating_i64(now_unix_seconds),
2131 ],
2132 )
2133 .map_err(|error| query_error(format!("failed to ensure storefront cart: {error}")))?;
2134 Ok(())
2135 }
2136
2137 fn touch_cart(
2138 &self,
2139 tx: &Transaction<'_>,
2140 session_id: &str,
2141 principal_id: Option<&str>,
2142 status: &str,
2143 now_unix_seconds: u64,
2144 ) -> Result<(), StorefrontStateError> {
2145 tx.execute(
2146 r#"
2147 UPDATE carts
2148 SET principal_id = COALESCE(?2, principal_id),
2149 status = ?3,
2150 updated_at_unix_seconds = ?4
2151 WHERE session_id = ?1
2152 "#,
2153 params![
2154 session_id,
2155 principal_id,
2156 status,
2157 saturating_i64(now_unix_seconds),
2158 ],
2159 )
2160 .map_err(|error| query_error(format!("failed to update storefront cart: {error}")))?;
2161 Ok(())
2162 }
2163
2164 fn update_cart_payment(
2165 &self,
2166 tx: &Transaction<'_>,
2167 session_id: &str,
2168 payment_status: &str,
2169 payment_method: Option<&str>,
2170 payment_last4: Option<&str>,
2171 checkout_email: Option<&str>,
2172 payment_reference: Option<&str>,
2173 ) -> Result<(), StorefrontStateError> {
2174 tx.execute(
2175 r#"
2176 UPDATE carts
2177 SET payment_status = ?2,
2178 payment_method = ?3,
2179 payment_reference = ?4,
2180 payment_last4 = ?5,
2181 checkout_email = ?6
2182 WHERE session_id = ?1
2183 "#,
2184 params![
2185 session_id,
2186 payment_status,
2187 payment_method,
2188 payment_reference,
2189 payment_last4,
2190 checkout_email,
2191 ],
2192 )
2193 .map_err(|error| {
2194 query_error(format!(
2195 "failed to update storefront payment state: {error}"
2196 ))
2197 })?;
2198 Ok(())
2199 }
2200
2201 fn cart_status(
2202 &self,
2203 tx: &Transaction<'_>,
2204 session_id: &str,
2205 ) -> Result<(String, String), StorefrontStateError> {
2206 tx.query_row(
2207 "SELECT status, payment_status FROM carts WHERE session_id = ?1",
2208 params![session_id],
2209 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
2210 )
2211 .optional()
2212 .map_err(|error| query_error(format!("failed to read storefront cart state: {error}")))?
2213 .ok_or_else(|| StorefrontStateError::CheckoutNotReady {
2214 session_id: session_id.to_string(),
2215 })
2216 }
2217
2218 fn cart_line_count(
2219 &self,
2220 tx: &Transaction<'_>,
2221 session_id: &str,
2222 ) -> Result<usize, StorefrontStateError> {
2223 let count: i64 = tx
2224 .query_row(
2225 "SELECT COUNT(*) FROM cart_lines WHERE session_id = ?1",
2226 params![session_id],
2227 |row| row.get(0),
2228 )
2229 .map_err(|error| {
2230 query_error(format!("failed to count storefront cart lines: {error}"))
2231 })?;
2232 usize::try_from(count).map_err(|_| query_error("storefront cart count overflowed".into()))
2233 }
2234
2235 fn load_snapshot(
2236 &self,
2237 tx: &Transaction<'_>,
2238 session_id: &str,
2239 principal_id: Option<&str>,
2240 order_limit: usize,
2241 ) -> Result<StorefrontStateSnapshot, StorefrontStateError> {
2242 let cart = self.load_cart(tx, session_id)?;
2243 let recent_orders = self.load_orders(tx, session_id, principal_id, order_limit)?;
2244 let latest_order = recent_orders.first().cloned();
2245 let principal = tx
2246 .query_row(
2247 "SELECT principal_id FROM carts WHERE session_id = ?1",
2248 params![session_id],
2249 |row| row.get::<_, Option<String>>(0),
2250 )
2251 .optional()
2252 .map_err(|error| query_error(format!("failed to load storefront principal: {error}")))?
2253 .flatten()
2254 .or_else(|| principal_id.map(ToOwned::to_owned));
2255 let payment = cart.payment.clone();
2256 Ok(StorefrontStateSnapshot {
2257 session_id: session_id.to_string(),
2258 principal_id: principal,
2259 cart,
2260 payment,
2261 recent_orders,
2262 latest_order,
2263 })
2264 }
2265
2266 fn load_cart(
2267 &self,
2268 tx: &Transaction<'_>,
2269 session_id: &str,
2270 ) -> Result<StorefrontCartSnapshot, StorefrontStateError> {
2271 let status = tx
2272 .query_row(
2273 "SELECT status FROM carts WHERE session_id = ?1",
2274 params![session_id],
2275 |row| row.get::<_, String>(0),
2276 )
2277 .optional()
2278 .map_err(|error| {
2279 query_error(format!("failed to load storefront cart status: {error}"))
2280 })?
2281 .unwrap_or_else(|| "active".to_string());
2282 let lines = self.load_cart_lines(tx, session_id)?;
2283 let item_count = lines.iter().map(|line| line.quantity).sum::<u32>();
2284 let subtotal_minor = lines.iter().map(|line| line.total_minor).sum::<i64>();
2285 let payment = self.load_cart_payment(tx, session_id)?;
2286 Ok(StorefrontCartSnapshot {
2287 status,
2288 payment,
2289 currency: DEFAULT_CURRENCY.to_string(),
2290 item_count,
2291 subtotal_minor,
2292 subtotal: format_minor_currency(subtotal_minor),
2293 lines,
2294 })
2295 }
2296
2297 fn load_cart_lines(
2298 &self,
2299 tx: &Transaction<'_>,
2300 session_id: &str,
2301 ) -> Result<Vec<StorefrontCartLine>, StorefrontStateError> {
2302 let mut statement = tx
2303 .prepare(
2304 r#"
2305 SELECT
2306 sku, title, variant_title, product_kind, entitlement_key,
2307 metadata_json, quantity, unit_price_minor, currency
2308 FROM cart_lines
2309 WHERE session_id = ?1
2310 ORDER BY sku ASC
2311 "#,
2312 )
2313 .map_err(|error| {
2314 query_error(format!(
2315 "failed to prepare storefront cart line query: {error}"
2316 ))
2317 })?;
2318 statement
2319 .query_map(params![session_id], |row| {
2320 let metadata = parse_storefront_metadata_json(row.get::<_, Option<String>>(5)?)
2321 .map_err(|reason| {
2322 rusqlite::Error::FromSqlConversionFailure(
2323 5,
2324 Type::Text,
2325 Box::new(std::io::Error::other(reason)),
2326 )
2327 })?;
2328 let quantity_i64: i64 = row.get(6)?;
2329 let quantity = u32::try_from(quantity_i64)
2330 .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, quantity_i64))?;
2331 let unit_price_minor: i64 = row.get(7)?;
2332 let total_minor = unit_price_minor.saturating_mul(i64::from(quantity));
2333 Ok(StorefrontCartLine {
2334 sku: row.get(0)?,
2335 title: row.get(1)?,
2336 variant_title: row.get(2)?,
2337 product_kind: row.get(3)?,
2338 entitlement_key: row.get(4)?,
2339 metadata,
2340 quantity,
2341 unit_price_minor,
2342 total_minor,
2343 currency: row.get(8)?,
2344 total: format_minor_currency(total_minor),
2345 })
2346 })
2347 .map_err(|error| {
2348 query_error(format!("failed to query storefront cart lines: {error}"))
2349 })?
2350 .collect::<Result<Vec<_>, _>>()
2351 .map_err(|error| {
2352 query_error(format!("failed to collect storefront cart lines: {error}"))
2353 })
2354 }
2355
2356 fn load_cart_payment(
2357 &self,
2358 tx: &Transaction<'_>,
2359 session_id: &str,
2360 ) -> Result<StorefrontPaymentSnapshot, StorefrontStateError> {
2361 tx.query_row(
2362 r#"
2363 SELECT
2364 payment_status,
2365 payment_method,
2366 payment_reference,
2367 payment_last4,
2368 checkout_email
2369 FROM carts
2370 WHERE session_id = ?1
2371 "#,
2372 params![session_id],
2373 |row| {
2374 Ok(StorefrontPaymentSnapshot {
2375 status: row.get::<_, String>(0)?,
2376 method: row.get::<_, Option<String>>(1)?,
2377 reference: row.get::<_, Option<String>>(2)?,
2378 last4: row.get::<_, Option<String>>(3)?,
2379 checkout_email: row.get::<_, Option<String>>(4)?,
2380 })
2381 },
2382 )
2383 .optional()
2384 .map_err(|error| query_error(format!("failed to load storefront payment state: {error}")))?
2385 .ok_or_else(|| {
2386 query_error(format!(
2387 "storefront cart `{session_id}` is missing payment state"
2388 ))
2389 })
2390 }
2391
2392 fn load_orders(
2393 &self,
2394 tx: &Transaction<'_>,
2395 session_id: &str,
2396 principal_id: Option<&str>,
2397 limit: usize,
2398 ) -> Result<Vec<StorefrontOrderSnapshot>, StorefrontStateError> {
2399 if limit == 0 {
2400 return Ok(Vec::new());
2401 }
2402 let mut statement = if principal_id.is_some() {
2403 tx.prepare(
2404 r#"
2405 SELECT
2406 order_id, session_id, principal_id, metadata_json, status, payment_status,
2407 payment_method, payment_reference, payment_last4, checkout_email,
2408 currency, line_count, subtotal_minor, total_minor, created_at_unix_seconds
2409 FROM orders
2410 WHERE session_id = ?1 OR principal_id = ?2
2411 ORDER BY created_at_unix_seconds DESC, order_id DESC
2412 LIMIT ?3
2413 "#,
2414 )
2415 } else {
2416 tx.prepare(
2417 r#"
2418 SELECT
2419 order_id, session_id, principal_id, metadata_json, status, payment_status,
2420 payment_method, payment_reference, payment_last4, checkout_email,
2421 currency, line_count, subtotal_minor, total_minor, created_at_unix_seconds
2422 FROM orders
2423 WHERE session_id = ?1
2424 ORDER BY created_at_unix_seconds DESC, order_id DESC
2425 LIMIT ?2
2426 "#,
2427 )
2428 }
2429 .map_err(|error| {
2430 query_error(format!("failed to prepare storefront order query: {error}"))
2431 })?;
2432
2433 let order_headers = if let Some(principal_id) = principal_id {
2434 let rows = statement
2435 .query_map(
2436 params![session_id, principal_id, saturating_i64(limit as u64)],
2437 |row| {
2438 Ok((
2439 row.get::<_, String>(0)?,
2440 row.get::<_, String>(1)?,
2441 row.get::<_, Option<String>>(2)?,
2442 row.get::<_, Option<String>>(3)?,
2443 row.get::<_, String>(4)?,
2444 row.get::<_, String>(5)?,
2445 row.get::<_, Option<String>>(6)?,
2446 row.get::<_, Option<String>>(7)?,
2447 row.get::<_, Option<String>>(8)?,
2448 row.get::<_, Option<String>>(9)?,
2449 row.get::<_, String>(10)?,
2450 row.get::<_, i64>(11)?,
2451 row.get::<_, i64>(12)?,
2452 row.get::<_, i64>(13)?,
2453 row.get::<_, i64>(14)?,
2454 ))
2455 },
2456 )
2457 .map_err(|error| {
2458 query_error(format!("failed to query storefront orders: {error}"))
2459 })?;
2460 rows.collect::<Result<Vec<_>, _>>().map_err(|error| {
2461 query_error(format!("failed to collect storefront orders: {error}"))
2462 })?
2463 } else {
2464 let rows = statement
2465 .query_map(params![session_id, saturating_i64(limit as u64)], |row| {
2466 Ok((
2467 row.get::<_, String>(0)?,
2468 row.get::<_, String>(1)?,
2469 row.get::<_, Option<String>>(2)?,
2470 row.get::<_, Option<String>>(3)?,
2471 row.get::<_, String>(4)?,
2472 row.get::<_, String>(5)?,
2473 row.get::<_, Option<String>>(6)?,
2474 row.get::<_, Option<String>>(7)?,
2475 row.get::<_, Option<String>>(8)?,
2476 row.get::<_, Option<String>>(9)?,
2477 row.get::<_, String>(10)?,
2478 row.get::<_, i64>(11)?,
2479 row.get::<_, i64>(12)?,
2480 row.get::<_, i64>(13)?,
2481 row.get::<_, i64>(14)?,
2482 ))
2483 })
2484 .map_err(|error| {
2485 query_error(format!("failed to query storefront orders: {error}"))
2486 })?;
2487 rows.collect::<Result<Vec<_>, _>>().map_err(|error| {
2488 query_error(format!("failed to collect storefront orders: {error}"))
2489 })?
2490 };
2491
2492 self.order_snapshots_from_headers(tx, order_headers)
2493 }
2494
2495 fn load_all_orders(
2496 &self,
2497 tx: &Transaction<'_>,
2498 limit: usize,
2499 ) -> Result<Vec<StorefrontOrderSnapshot>, StorefrontStateError> {
2500 if limit == 0 {
2501 return Ok(Vec::new());
2502 }
2503 let mut statement = tx
2504 .prepare(
2505 r#"
2506 SELECT
2507 order_id, session_id, principal_id, metadata_json, status, payment_status,
2508 payment_method, payment_reference, payment_last4, checkout_email,
2509 currency, line_count, subtotal_minor, total_minor, created_at_unix_seconds
2510 FROM orders
2511 ORDER BY created_at_unix_seconds DESC, order_id DESC
2512 LIMIT ?1
2513 "#,
2514 )
2515 .map_err(|error| {
2516 query_error(format!(
2517 "failed to prepare storefront admin order query: {error}"
2518 ))
2519 })?;
2520 let rows = statement
2521 .query_map(params![saturating_i64(limit as u64)], |row| {
2522 Ok((
2523 row.get::<_, String>(0)?,
2524 row.get::<_, String>(1)?,
2525 row.get::<_, Option<String>>(2)?,
2526 row.get::<_, Option<String>>(3)?,
2527 row.get::<_, String>(4)?,
2528 row.get::<_, String>(5)?,
2529 row.get::<_, Option<String>>(6)?,
2530 row.get::<_, Option<String>>(7)?,
2531 row.get::<_, Option<String>>(8)?,
2532 row.get::<_, Option<String>>(9)?,
2533 row.get::<_, String>(10)?,
2534 row.get::<_, i64>(11)?,
2535 row.get::<_, i64>(12)?,
2536 row.get::<_, i64>(13)?,
2537 row.get::<_, i64>(14)?,
2538 ))
2539 })
2540 .map_err(|error| {
2541 query_error(format!("failed to query storefront admin orders: {error}"))
2542 })?;
2543 let headers = rows.collect::<Result<Vec<_>, _>>().map_err(|error| {
2544 query_error(format!(
2545 "failed to collect storefront admin orders: {error}"
2546 ))
2547 })?;
2548 self.order_snapshots_from_headers(tx, headers)
2549 }
2550
2551 fn order_snapshots_from_headers(
2552 &self,
2553 tx: &Transaction<'_>,
2554 headers: Vec<(
2555 String,
2556 String,
2557 Option<String>,
2558 Option<String>,
2559 String,
2560 String,
2561 Option<String>,
2562 Option<String>,
2563 Option<String>,
2564 Option<String>,
2565 String,
2566 i64,
2567 i64,
2568 i64,
2569 i64,
2570 )>,
2571 ) -> Result<Vec<StorefrontOrderSnapshot>, StorefrontStateError> {
2572 let mut orders = Vec::with_capacity(headers.len());
2573 for (
2574 order_id,
2575 order_session_id,
2576 order_principal_id,
2577 metadata_json,
2578 status,
2579 payment_status,
2580 payment_method,
2581 payment_reference,
2582 payment_last4,
2583 checkout_email,
2584 currency,
2585 line_count_i64,
2586 subtotal_minor,
2587 total_minor,
2588 created_i64,
2589 ) in headers
2590 {
2591 let lines = self
2592 .load_order_lines(tx, order_id.as_str())
2593 .map_err(|error| {
2594 query_error(format!("failed to load storefront order lines: {error}"))
2595 })?;
2596 let refunds = self
2597 .load_order_refunds(tx, order_id.as_str())
2598 .map_err(|error| {
2599 query_error(format!("failed to load storefront order refunds: {error}"))
2600 })?;
2601 let refunded_total_minor = refunds
2602 .iter()
2603 .map(|refund| refund.amount_minor)
2604 .sum::<i64>();
2605 let refundable_total_minor = total_minor.saturating_sub(refunded_total_minor);
2606 orders.push(StorefrontOrderSnapshot {
2607 order_id,
2608 session_id: order_session_id,
2609 principal_id: order_principal_id,
2610 metadata: parse_storefront_metadata_json(metadata_json).map_err(|reason| {
2611 query_error(format!(
2612 "failed to decode storefront order metadata: {reason}"
2613 ))
2614 })?,
2615 status,
2616 payment: StorefrontPaymentSnapshot {
2617 status: payment_status,
2618 method: payment_method,
2619 reference: payment_reference,
2620 last4: payment_last4,
2621 checkout_email,
2622 },
2623 currency,
2624 line_count: u32::try_from(line_count_i64)
2625 .map_err(|_| query_error("storefront order line count overflowed".into()))?,
2626 subtotal_minor,
2627 total_minor,
2628 refunded_total_minor,
2629 refundable_total_minor,
2630 subtotal: format_minor_currency(subtotal_minor),
2631 total: format_minor_currency(total_minor),
2632 refunded_total: format_minor_currency(refunded_total_minor),
2633 refundable_total: format_minor_currency(refundable_total_minor),
2634 created_at_unix_seconds: u64::try_from(created_i64)
2635 .map_err(|_| query_error("storefront order timestamp overflowed".into()))?,
2636 lines,
2637 refunds,
2638 });
2639 }
2640 Ok(orders)
2641 }
2642
2643 fn restore_order_lines_to_cart_if_empty(
2644 &self,
2645 tx: &Transaction<'_>,
2646 session_id: &str,
2647 order_id: &str,
2648 ) -> Result<bool, StorefrontStateError> {
2649 if self.cart_line_count(tx, session_id)? > 0 {
2650 return Ok(false);
2651 }
2652 let lines = self.load_order_lines(tx, order_id).map_err(|error| {
2653 query_error(format!(
2654 "failed to load storefront order lines for recovery: {error}"
2655 ))
2656 })?;
2657 if lines.is_empty() {
2658 return Ok(false);
2659 }
2660 for line in lines {
2661 tx.execute(
2662 r#"
2663 INSERT INTO cart_lines (
2664 session_id, sku, title, variant_title, product_kind, entitlement_key,
2665 metadata_json, quantity, unit_price_minor, currency
2666 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
2667 ON CONFLICT(session_id, sku) DO UPDATE SET
2668 quantity = excluded.quantity,
2669 title = excluded.title,
2670 variant_title = excluded.variant_title,
2671 product_kind = excluded.product_kind,
2672 entitlement_key = excluded.entitlement_key,
2673 metadata_json = excluded.metadata_json,
2674 unit_price_minor = excluded.unit_price_minor,
2675 currency = excluded.currency
2676 "#,
2677 params![
2678 session_id,
2679 line.sku,
2680 line.title,
2681 line.variant_title,
2682 line.product_kind,
2683 line.entitlement_key,
2684 storefront_metadata_json(&line.metadata)?,
2685 i64::from(line.quantity),
2686 line.unit_price_minor,
2687 line.currency,
2688 ],
2689 )
2690 .map_err(|error| {
2691 query_error(format!(
2692 "failed to restore storefront cart line from order: {error}"
2693 ))
2694 })?;
2695 }
2696 Ok(true)
2697 }
2698
2699 fn clear_cart_lines_if_matching_order(
2700 &self,
2701 tx: &Transaction<'_>,
2702 session_id: &str,
2703 order_id: &str,
2704 ) -> Result<bool, StorefrontStateError> {
2705 let cart_lines = self.load_cart_lines(tx, session_id)?;
2706 if cart_lines.is_empty() {
2707 return Ok(false);
2708 }
2709 let order_lines = self.load_order_lines(tx, order_id).map_err(|error| {
2710 query_error(format!(
2711 "failed to load storefront order lines for settlement: {error}"
2712 ))
2713 })?;
2714 if !cart_lines_match_order(&cart_lines, &order_lines) {
2715 return Ok(false);
2716 }
2717 tx.execute(
2718 "DELETE FROM cart_lines WHERE session_id = ?1",
2719 params![session_id],
2720 )
2721 .map_err(|error| {
2722 query_error(format!(
2723 "failed to clear restored storefront cart lines after payment capture: {error}"
2724 ))
2725 })?;
2726 Ok(true)
2727 }
2728
2729 fn load_order_lines(
2730 &self,
2731 tx: &Transaction<'_>,
2732 order_id: &str,
2733 ) -> rusqlite::Result<Vec<StorefrontOrderLine>> {
2734 let mut statement = tx.prepare(
2735 r#"
2736 SELECT
2737 sku, title, variant_title, product_kind, entitlement_key,
2738 metadata_json, quantity, unit_price_minor, currency
2739 FROM order_lines
2740 WHERE order_id = ?1
2741 ORDER BY sku ASC
2742 "#,
2743 )?;
2744 statement
2745 .query_map(params![order_id], |row| {
2746 let metadata = parse_storefront_metadata_json(row.get::<_, Option<String>>(5)?)
2747 .map_err(|reason| {
2748 rusqlite::Error::FromSqlConversionFailure(
2749 5,
2750 Type::Text,
2751 Box::new(std::io::Error::other(reason)),
2752 )
2753 })?;
2754 let quantity_i64: i64 = row.get(6)?;
2755 let quantity = u32::try_from(quantity_i64)
2756 .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(6, quantity_i64))?;
2757 let unit_price_minor: i64 = row.get(7)?;
2758 let total_minor = unit_price_minor.saturating_mul(i64::from(quantity));
2759 Ok(StorefrontOrderLine {
2760 sku: row.get(0)?,
2761 title: row.get(1)?,
2762 variant_title: row.get(2)?,
2763 product_kind: row.get(3)?,
2764 entitlement_key: row.get(4)?,
2765 metadata,
2766 quantity,
2767 unit_price_minor,
2768 total_minor,
2769 currency: row.get(8)?,
2770 total: format_minor_currency(total_minor),
2771 })
2772 })?
2773 .collect()
2774 }
2775
2776 fn load_order_refunds(
2777 &self,
2778 tx: &Transaction<'_>,
2779 order_id: &str,
2780 ) -> rusqlite::Result<Vec<StorefrontOrderRefundSnapshot>> {
2781 let mut statement = tx.prepare(
2782 r#"
2783 SELECT refund_id, order_id, amount_minor, currency, reason, created_at_unix_seconds
2784 FROM order_refunds
2785 WHERE order_id = ?1
2786 ORDER BY created_at_unix_seconds DESC, refund_id DESC
2787 "#,
2788 )?;
2789 statement
2790 .query_map(params![order_id], |row| {
2791 let amount_minor: i64 = row.get(2)?;
2792 let created_i64: i64 = row.get(5)?;
2793 Ok(StorefrontOrderRefundSnapshot {
2794 refund_id: row.get(0)?,
2795 order_id: row.get(1)?,
2796 amount_minor,
2797 amount: format_minor_currency(amount_minor),
2798 currency: row.get(3)?,
2799 reason: row.get(4)?,
2800 created_at_unix_seconds: u64::try_from(created_i64)
2801 .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(5, created_i64))?,
2802 })
2803 })?
2804 .collect()
2805 }
2806
2807 fn load_order_header_by_payment_reference(
2808 &self,
2809 tx: &Transaction<'_>,
2810 payment_reference: &str,
2811 ) -> Result<
2812 Option<(String, String, Option<String>, String, String, Option<i64>)>,
2813 StorefrontStateError,
2814 > {
2815 tx.query_row(
2816 r#"
2817 SELECT
2818 order_id,
2819 session_id,
2820 principal_id,
2821 status,
2822 payment_status,
2823 order_paid_event_dispatched_at_unix_seconds
2824 FROM orders
2825 WHERE payment_reference = ?1
2826 ORDER BY created_at_unix_seconds DESC, order_id DESC
2827 LIMIT 1
2828 "#,
2829 params![payment_reference],
2830 |row| {
2831 Ok((
2832 row.get::<_, String>(0)?,
2833 row.get::<_, String>(1)?,
2834 row.get::<_, Option<String>>(2)?,
2835 row.get::<_, String>(3)?,
2836 row.get::<_, String>(4)?,
2837 row.get::<_, Option<i64>>(5)?,
2838 ))
2839 },
2840 )
2841 .optional()
2842 .map_err(|error| {
2843 query_error(format!(
2844 "failed to load storefront order by payment reference: {error}"
2845 ))
2846 })
2847 }
2848
2849 fn load_order_by_id(
2850 &self,
2851 tx: &Transaction<'_>,
2852 order_id: &str,
2853 ) -> rusqlite::Result<Option<StorefrontOrderSnapshot>> {
2854 let order_header = tx
2855 .query_row(
2856 r#"
2857 SELECT
2858 order_id, session_id, principal_id, metadata_json, status, payment_status,
2859 payment_method, payment_reference, payment_last4, checkout_email,
2860 currency, line_count, subtotal_minor, total_minor, created_at_unix_seconds
2861 FROM orders
2862 WHERE order_id = ?1
2863 "#,
2864 params![order_id],
2865 |row| {
2866 Ok((
2867 row.get::<_, String>(0)?,
2868 row.get::<_, String>(1)?,
2869 row.get::<_, Option<String>>(2)?,
2870 row.get::<_, Option<String>>(3)?,
2871 row.get::<_, String>(4)?,
2872 row.get::<_, String>(5)?,
2873 row.get::<_, Option<String>>(6)?,
2874 row.get::<_, Option<String>>(7)?,
2875 row.get::<_, Option<String>>(8)?,
2876 row.get::<_, Option<String>>(9)?,
2877 row.get::<_, String>(10)?,
2878 row.get::<_, i64>(11)?,
2879 row.get::<_, i64>(12)?,
2880 row.get::<_, i64>(13)?,
2881 row.get::<_, i64>(14)?,
2882 ))
2883 },
2884 )
2885 .optional()?;
2886
2887 let Some((
2888 order_id,
2889 session_id,
2890 principal_id,
2891 metadata_json,
2892 status,
2893 payment_status,
2894 payment_method,
2895 payment_reference,
2896 payment_last4,
2897 checkout_email,
2898 currency,
2899 line_count_i64,
2900 subtotal_minor,
2901 total_minor,
2902 created_i64,
2903 )) = order_header
2904 else {
2905 return Ok(None);
2906 };
2907
2908 let lines = self.load_order_lines(tx, order_id.as_str())?;
2909 let refunds = self.load_order_refunds(tx, order_id.as_str())?;
2910 let refunded_total_minor = refunds
2911 .iter()
2912 .map(|refund| refund.amount_minor)
2913 .sum::<i64>();
2914 let refundable_total_minor = total_minor.saturating_sub(refunded_total_minor);
2915 Ok(Some(StorefrontOrderSnapshot {
2916 order_id,
2917 session_id,
2918 principal_id,
2919 metadata: parse_storefront_metadata_json(metadata_json).map_err(|reason| {
2920 rusqlite::Error::FromSqlConversionFailure(
2921 3,
2922 Type::Text,
2923 Box::new(std::io::Error::other(reason)),
2924 )
2925 })?,
2926 status,
2927 payment: StorefrontPaymentSnapshot {
2928 status: payment_status,
2929 method: payment_method,
2930 reference: payment_reference,
2931 last4: payment_last4,
2932 checkout_email,
2933 },
2934 currency,
2935 line_count: u32::try_from(line_count_i64)
2936 .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(10, line_count_i64))?,
2937 subtotal_minor,
2938 total_minor,
2939 refunded_total_minor,
2940 refundable_total_minor,
2941 subtotal: format_minor_currency(subtotal_minor),
2942 total: format_minor_currency(total_minor),
2943 refunded_total: format_minor_currency(refunded_total_minor),
2944 refundable_total: format_minor_currency(refundable_total_minor),
2945 created_at_unix_seconds: u64::try_from(created_i64)
2946 .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(13, created_i64))?,
2947 lines,
2948 refunds,
2949 }))
2950 }
2951}
2952
2953fn account_session_end_form_markup(token: &str) -> String {
2954 let escaped = escape_html_attribute(token);
2955 format!(
2956 r#"<form id="coil-account-session-end" action="{ACCOUNT_SESSION_END_PATH}" method="post" hidden><input type="hidden" name="_csrf" value="{escaped}" /></form>"#
2957 )
2958}
2959
2960fn escape_html_attribute(value: &str) -> String {
2961 value
2962 .replace('&', "&")
2963 .replace('"', """)
2964 .replace('<', "<")
2965 .replace('>', ">")
2966}
2967
2968fn next_order_id(tx: &Transaction<'_>) -> Result<String, StorefrontStateError> {
2969 let next_value: i64 = tx
2970 .query_row(
2971 "SELECT next_value FROM storefront_sequences WHERE name = 'order'",
2972 [],
2973 |row| row.get(0),
2974 )
2975 .optional()
2976 .map_err(|error| query_error(format!("failed to load storefront order sequence: {error}")))?
2977 .unwrap_or(INITIAL_ORDER_SEQUENCE);
2978 tx.execute(
2979 r#"
2980 INSERT INTO storefront_sequences (name, next_value)
2981 VALUES ('order', ?1)
2982 ON CONFLICT(name) DO UPDATE SET next_value = excluded.next_value
2983 "#,
2984 params![next_value.saturating_add(1)],
2985 )
2986 .map_err(|error| {
2987 query_error(format!(
2988 "failed to advance storefront order sequence: {error}"
2989 ))
2990 })?;
2991 Ok(format!("ORD-{next_value:05}"))
2992}
2993
2994fn next_refund_id(tx: &Transaction<'_>) -> Result<String, StorefrontStateError> {
2995 let next_value: i64 = tx
2996 .query_row(
2997 "SELECT next_value FROM storefront_sequences WHERE name = 'refund'",
2998 [],
2999 |row| row.get(0),
3000 )
3001 .optional()
3002 .map_err(|error| {
3003 query_error(format!(
3004 "failed to load storefront refund sequence: {error}"
3005 ))
3006 })?
3007 .unwrap_or(INITIAL_REFUND_SEQUENCE);
3008 tx.execute(
3009 r#"
3010 INSERT INTO storefront_sequences (name, next_value)
3011 VALUES ('refund', ?1)
3012 ON CONFLICT(name) DO UPDATE SET next_value = excluded.next_value
3013 "#,
3014 params![next_value.saturating_add(1)],
3015 )
3016 .map_err(|error| {
3017 query_error(format!(
3018 "failed to advance storefront refund sequence: {error}"
3019 ))
3020 })?;
3021 Ok(format!("RFD-{next_value:05}"))
3022}
3023
3024fn next_payment_reference(tx: &Transaction<'_>) -> Result<String, StorefrontStateError> {
3025 let next_value: i64 = tx
3026 .query_row(
3027 "SELECT next_value FROM storefront_sequences WHERE name = 'payment'",
3028 [],
3029 |row| row.get(0),
3030 )
3031 .optional()
3032 .map_err(|error| {
3033 query_error(format!(
3034 "failed to load storefront payment sequence: {error}"
3035 ))
3036 })?
3037 .unwrap_or(50_001);
3038 tx.execute(
3039 r#"
3040 INSERT INTO storefront_sequences (name, next_value)
3041 VALUES ('payment', ?1)
3042 ON CONFLICT(name) DO UPDATE SET next_value = excluded.next_value
3043 "#,
3044 params![next_value.saturating_add(1)],
3045 )
3046 .map_err(|error| {
3047 query_error(format!(
3048 "failed to advance storefront payment sequence: {error}"
3049 ))
3050 })?;
3051 Ok(format!("PAY-{next_value:05}"))
3052}
3053
3054fn ensure_storefront_columns(connection: &Connection) -> Result<(), StorefrontStateError> {
3055 ensure_table_columns(
3056 connection,
3057 "carts",
3058 &[
3059 "payment_status TEXT NOT NULL DEFAULT 'not_started'",
3060 "payment_method TEXT",
3061 "payment_reference TEXT",
3062 "payment_last4 TEXT",
3063 "checkout_email TEXT",
3064 ],
3065 )?;
3066 ensure_table_columns(connection, "cart_lines", &["metadata_json TEXT"])?;
3067 ensure_table_columns(
3068 connection,
3069 "orders",
3070 &[
3071 "payment_status TEXT NOT NULL DEFAULT 'captured'",
3072 "payment_method TEXT",
3073 "payment_reference TEXT",
3074 "payment_last4 TEXT",
3075 "checkout_email TEXT",
3076 "metadata_json TEXT",
3077 "order_paid_event_dispatched_at_unix_seconds INTEGER",
3078 ],
3079 )?;
3080 ensure_table_columns(connection, "order_lines", &["metadata_json TEXT"])?;
3081 Ok(())
3082}
3083
3084fn storefront_metadata_json(
3085 metadata: &BTreeMap<String, String>,
3086) -> Result<String, StorefrontStateError> {
3087 serde_json::to_string(metadata).map_err(|error| StorefrontStateError::Serialization {
3088 reason: error.to_string(),
3089 })
3090}
3091
3092fn parse_storefront_metadata_json(
3093 encoded: Option<String>,
3094) -> Result<BTreeMap<String, String>, String> {
3095 match encoded
3096 .as_deref()
3097 .map(str::trim)
3098 .filter(|value| !value.is_empty())
3099 {
3100 Some(value) => serde_json::from_str(value).map_err(|error| error.to_string()),
3101 None => Ok(BTreeMap::new()),
3102 }
3103}
3104
3105fn storefront_order_line_metadata(
3106 catalog: &StorefrontCatalog,
3107 line: &StorefrontCartLine,
3108) -> BTreeMap<String, String> {
3109 if !line.metadata.is_empty() {
3110 return line.metadata.clone();
3111 }
3112 let mut metadata = BTreeMap::new();
3113 metadata.insert("variant_title".to_string(), line.variant_title.clone());
3114 if let Some(product) = catalog.product_by_sku_or_handle(&line.sku) {
3115 metadata.insert(
3116 "collection_handle".to_string(),
3117 product.collection_handle.clone(),
3118 );
3119 }
3120 if let Some(entitlement_key) = line.entitlement_key.as_ref() {
3121 metadata.insert("entitlement_key".to_string(), entitlement_key.clone());
3122 }
3123 metadata
3124}
3125
3126fn cart_line_metadata(catalog: &StorefrontCatalog, item: &CatalogItem) -> BTreeMap<String, String> {
3127 let mut metadata = BTreeMap::new();
3128 metadata.insert("variant_title".to_string(), item.variant_title.clone());
3129 if let Some(product) = catalog.product_by_sku_or_handle(&item.sku) {
3130 metadata.insert(
3131 "collection_handle".to_string(),
3132 product.collection_handle.clone(),
3133 );
3134 }
3135 if let Some(entitlement_key) = item.entitlement_key.as_ref() {
3136 metadata.insert("entitlement_key".to_string(), entitlement_key.clone());
3137 }
3138 metadata
3139}
3140
3141fn ensure_table_columns(
3142 connection: &Connection,
3143 table: &str,
3144 columns: &[&str],
3145) -> Result<(), StorefrontStateError> {
3146 let mut statement = connection
3147 .prepare(&format!("PRAGMA table_info({table})"))
3148 .map_err(|error| {
3149 query_error(format!(
3150 "failed to inspect storefront table `{table}`: {error}"
3151 ))
3152 })?;
3153 let existing = statement
3154 .query_map([], |row| row.get::<_, String>(1))
3155 .map_err(|error| {
3156 query_error(format!(
3157 "failed to read storefront table `{table}` columns: {error}"
3158 ))
3159 })?
3160 .collect::<Result<Vec<_>, _>>()
3161 .map_err(|error| {
3162 query_error(format!(
3163 "failed to collect storefront table `{table}` columns: {error}"
3164 ))
3165 })?;
3166 for column in columns {
3167 let Some(name) = column.split_whitespace().next() else {
3168 continue;
3169 };
3170 if existing.iter().any(|candidate| candidate == name) {
3171 continue;
3172 }
3173 connection
3174 .execute(&format!("ALTER TABLE {table} ADD COLUMN {column}"), [])
3175 .map_err(|error| {
3176 query_error(format!(
3177 "failed to add storefront column `{table}.{name}`: {error}"
3178 ))
3179 })?;
3180 }
3181 Ok(())
3182}
3183
3184fn query_error(reason: String) -> StorefrontStateError {
3185 StorefrontStateError::Query { reason }
3186}
3187
3188fn database_path(root: &Path, namespace: &str) -> PathBuf {
3189 root.join("storefront")
3190 .join(format!("{}.sqlite3", sanitize_namespace(namespace)))
3191}
3192
3193fn sanitize_namespace(namespace: &str) -> String {
3194 namespace
3195 .chars()
3196 .map(|ch| {
3197 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
3198 ch
3199 } else {
3200 '_'
3201 }
3202 })
3203 .collect()
3204}
3205
3206fn cart_lines_match_order(
3207 cart_lines: &[StorefrontCartLine],
3208 order_lines: &[StorefrontOrderLine],
3209) -> bool {
3210 if cart_lines.len() != order_lines.len() {
3211 return false;
3212 }
3213 let cart_map = cart_lines
3214 .iter()
3215 .map(|line| {
3216 (
3217 line.sku.as_str(),
3218 (
3219 line.quantity,
3220 line.unit_price_minor,
3221 line.product_kind.as_str(),
3222 line.entitlement_key.as_deref(),
3223 ),
3224 )
3225 })
3226 .collect::<BTreeMap<_, _>>();
3227 let order_map = order_lines
3228 .iter()
3229 .map(|line| {
3230 (
3231 line.sku.as_str(),
3232 (
3233 line.quantity,
3234 line.unit_price_minor,
3235 line.product_kind.as_str(),
3236 line.entitlement_key.as_deref(),
3237 ),
3238 )
3239 })
3240 .collect::<BTreeMap<_, _>>();
3241 cart_map == order_map
3242}
3243
3244fn saturating_i64(value: u64) -> i64 {
3245 i64::try_from(value).unwrap_or(i64::MAX)
3246}
3247
3248fn format_minor_currency(value: i64) -> String {
3249 let sign = if value < 0 { "-" } else { "" };
3250 let absolute = value.saturating_abs();
3251 let major = absolute / 100;
3252 let minor = absolute % 100;
3253 format!("{sign}£{major}.{minor:02}")
3254}