Skip to main content

coil_runtime/storefront/
mod.rs

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('&', "&amp;")
2963        .replace('"', "&quot;")
2964        .replace('<', "&lt;")
2965        .replace('>', "&gt;")
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}