@cartbase/storefront 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -258
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/reviews.ts +22 -1
  20. package/src/api/search.ts +163 -163
  21. package/src/api/store.ts +35 -35
  22. package/src/api/types.ts +91 -91
  23. package/src/cart-drawer/context.tsx +778 -778
  24. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  25. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  26. package/src/cart-drawer/gift-wrap.tsx +82 -82
  27. package/src/cart-drawer/item/index.tsx +162 -162
  28. package/src/cart-drawer/item/upsell.tsx +110 -110
  29. package/src/cart-drawer/labels.ts +123 -123
  30. package/src/cart-drawer/mutation-queue.ts +78 -78
  31. package/src/cart-drawer/notes.tsx +131 -131
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/index.ts +20 -20
  49. package/src/common/market-select.tsx +57 -57
  50. package/src/index.ts +12 -12
  51. package/src/lib/country-name.ts +59 -59
  52. package/src/lib/get-product-price.ts +133 -133
  53. package/src/lib/media-image.tsx +39 -39
  54. package/src/lib/payment-constants.ts +53 -53
  55. package/src/lib/platform.ts +13 -13
  56. package/src/lib/price.tsx +39 -39
  57. package/src/lib/store-api-error.ts +36 -36
  58. package/src/lib/variant-caption.ts +32 -32
  59. package/src/locales/bg.ts +3 -2
  60. package/src/locales/context.ts +37 -37
  61. package/src/locales/en.ts +26 -26
  62. package/src/locales/es.ts +3 -2
  63. package/src/locales/index.ts +19 -19
  64. package/src/locales/provider.tsx +59 -59
  65. package/src/locales/types.ts +77 -77
  66. package/src/order/index.ts +62 -62
  67. package/src/order/labels.ts +79 -79
  68. package/src/order/order-totals.tsx +250 -250
  69. package/src/primitives/select-field.tsx +93 -93
  70. package/src/products/image-gallery.tsx +43 -43
  71. package/src/products/option-select.tsx +67 -67
  72. package/src/products/product-info.tsx +5 -5
  73. package/src/products/product-price.tsx +69 -69
  74. package/src/products/product-promises.tsx +61 -61
  75. package/src/products/product-specs.tsx +65 -65
  76. package/src/products/product-tabs.tsx +123 -123
  77. package/src/products/purchase-options.tsx +130 -130
  78. package/src/products/sets.ts +110 -110
  79. package/src/products/variant-matching.ts +71 -71
  80. package/src/products/variant-url.ts +74 -74
  81. package/src/reviews-ui/index.ts +12 -0
  82. package/src/reviews-ui/labels.ts +5 -2
  83. package/src/reviews-ui/lightbox-state.ts +46 -46
  84. package/src/reviews-ui/review-header.tsx +166 -0
  85. package/src/reviews-ui/review-lightbox.tsx +271 -271
  86. package/src/reviews-ui/review-list.tsx +193 -191
  87. package/src/reviews-ui/review-widget.tsx +63 -71
  88. package/src/reviews-ui/star-badge.tsx +6 -4
  89. package/src/reviews-ui/widget-options.ts +55 -0
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
@@ -1,526 +1,526 @@
1
- /**
2
- * @cartbase/storefront/api/checkout — the checkout sequence.
3
- *
4
- * Two ways to drive checkout, both wrapped here:
5
- *
6
- * 1. **Orchestrated (recommended)** — `prepareCheckout()` is the atomic
7
- * Buy-click: ONE call writes address (+billing mirror), shipping method
8
- * (+ carrier metadata), payment collection and the payment session at
9
- * the FINAL amount (real Stripe PaymentIntent for a processor, or a
10
- * provider-less row for a merchant method). Then `completeCart()`
11
- * (see `./carts`). Amount drift/repair between the two:
12
- * `syncPaymentAmount()` and `refreshPaymentIfTerminal()`.
13
- *
14
- * 2. **Manual (Medusa-style)** — `listShippingOptions()` →
15
- * `addShippingMethod()` → `createPaymentCollection()` →
16
- * `initiatePaymentSession()` → `completeCart()`.
17
- *
18
- * Amount authority everywhere: the SERVER totals engine. The client never
19
- * supplies an amount; sessions charge `total − gift_card_total`. Money is
20
- * EUR decimal major units. Route ground truth: src/app/api/store/
21
- * {shipping-options,payment-providers,payment-collections}/**, carts/[id]/
22
- * {shipping-methods,prepare-checkout,sync-payment-amount,
23
- * refresh-payment-if-terminal} + src/lib/checkout-orchestration/*.
24
- * Doc: docs/storefront/checkout.md.
25
- */
26
- import type { StorefrontClient } from "./http"
27
- import type { ListEnvelope, MajorUnitAmount } from "./types"
28
- import type { CartResponse } from "./carts"
29
-
30
- // ---------------------------------------------------------------------------
31
- // Shipping options
32
- // ---------------------------------------------------------------------------
33
-
34
- export interface ListShippingOptionsQuery {
35
- /**
36
- * Prices the options in the cart's currency AND gives the checkout-rules
37
- * engine its evaluation context. Without it `amount` is null and
38
- * cart-dependent hide rules cannot match (the completion guard remains
39
- * the boundary).
40
- */
41
- cart_id?: string
42
- }
43
-
44
- /** Row of GET /api/store/shipping-options. */
45
- export interface StoreShippingOption {
46
- id: string
47
- name: string
48
- provider_id: string | null
49
- service_zone_id: string | null
50
- shipping_profile_id: string | null
51
- data: Record<string, unknown> | null
52
- /** The option's display label (e.g. "Express") as `{ label }`, or null. */
53
- type: { label: string } | null
54
- /** Flat price in the cart currency; null without `cart_id` or when no price row matches. */
55
- amount: MajorUnitAmount | null
56
- /** Always "flat" today — calculated-rate carriers are not wired yet. */
57
- price_type: "flat"
58
- [key: string]: unknown
59
- }
60
-
61
- export interface ShippingOptionListResponse extends ListEnvelope {
62
- shipping_options: StoreShippingOption[]
63
- }
64
-
65
- /**
66
- * List shipping options (rule-filtered, display-ordered). → 200 list
67
- * envelope; `count`/`limit` = full filtered list (no pagination).
68
- * Auth: anon `x-client-id`.
69
- * Errors: 404 `cart_not_found` (bad `cart_id`).
70
- * Settings: checkout rules (`target_type=shipping_option`) hide options
71
- * server-side; `checkout_method_order` orders them; rules evaluate the cart
72
- * context (customer tags/groups, totals, weight, channel, country) when
73
- * `cart_id` is passed. Fail-open: a broken rule never bricks the listing.
74
- */
75
- export async function listShippingOptions(
76
- client: StorefrontClient,
77
- query: ListShippingOptionsQuery = {}
78
- ): Promise<ShippingOptionListResponse> {
79
- return client.get("/api/store/shipping-options", { query: { ...query } })
80
- }
81
-
82
- export interface CalculateShippingOptionInput {
83
- cart_id: string
84
- /** Provider-specific fields — accepted and currently ignored (flat prices only). */
85
- data?: Record<string, unknown>
86
- }
87
-
88
- export interface CalculateShippingOptionResponse {
89
- shipping_option: StoreShippingOption & { amount: MajorUnitAmount }
90
- }
91
-
92
- /**
93
- * Price one option for a cart. → 200 `{shipping_option}` with `amount` in
94
- * the cart currency. Today this returns the flat price-set match —
95
- * calculated-rate carriers are not wired (the `data` body is reserved).
96
- * Auth: anon `x-client-id`.
97
- * Errors: 404 `cart_not_found` | `shipping_option_not_found`, 400
98
- * `shipping_price_missing` (no price row in the cart currency) |
99
- * `validation_failed`.
100
- */
101
- export async function calculateShippingOption(
102
- client: StorefrontClient,
103
- shippingOptionId: string,
104
- input: CalculateShippingOptionInput
105
- ): Promise<CalculateShippingOptionResponse> {
106
- return client.post(`/api/store/shipping-options/${shippingOptionId}/calculate`, input)
107
- }
108
-
109
- /**
110
- * Add/replace the cart's shipping method (manual checkout path — the
111
- * orchestrated path does this inside `prepareCheckout`). → 200 `{cart}`.
112
- * Body: `{option_id, data?}` (`.strict()`).
113
- * Auth: anon `x-client-id`.
114
- * Errors: 404 `cart_not_found` | `shipping_option_not_found`, 409
115
- * `cart_completed`, 400 `shipping_price_missing` | `validation_failed`.
116
- */
117
- export async function addShippingMethod(
118
- client: StorefrontClient,
119
- cartId: string,
120
- input: { option_id: string; data?: Record<string, unknown> }
121
- ): Promise<CartResponse> {
122
- return client.post(`/api/store/carts/${cartId}/shipping-methods`, input)
123
- }
124
-
125
- // ---------------------------------------------------------------------------
126
- // Payment providers
127
- // ---------------------------------------------------------------------------
128
-
129
- export interface ListPaymentProvidersQuery {
130
- /** Scope to providers linked to this region (`region_payment_provider`). */
131
- region_id?: string
132
- /**
133
- * Checkout-rules evaluation context — storefronts SHOULD pass it during
134
- * checkout; without it cart-dependent hide rules cannot match here.
135
- */
136
- cart_id?: string
137
- }
138
-
139
- /** A connected PROCESSOR entry of GET /api/store/payment-providers. */
140
- export interface StorePaymentProcessorEntry {
141
- /** e.g. `pp_stripe`. `pp_giftcard` is internal and never listed. */
142
- id: string
143
- is_enabled: boolean
144
- created_at?: string
145
- }
146
-
147
- /** A merchant PAYMENT METHOD entry (pp_* kill, 20260811250000): methods
148
- * stand alone — no provider id anywhere. Initiate sessions with
149
- * `payment_method_id`; `kind === "cod"` marks the pre-created
150
- * collection-on-delivery method (courier cash collection); `instructions`
151
- * is the merchant's checkout note, rendered under the method. */
152
- export interface StorePaymentMethodEntry {
153
- payment_method_id: string
154
- name: string
155
- kind: "manual" | "cod"
156
- instructions: string | null
157
- /** The method's fee (any method may carry one) — predict
158
- * `payment_method_fee_total` from it before a session exists. */
159
- fee_amount: number | null
160
- fee_label: string | null
161
- }
162
-
163
- /** Row of GET /api/store/payment-providers — a processor or a method. */
164
- export type StorePaymentEntry = StorePaymentProcessorEntry | StorePaymentMethodEntry
165
-
166
- /** True when the entry is a merchant payment method (vs a processor). */
167
- export const isMethodEntry = (
168
- entry: StorePaymentEntry
169
- ): entry is StorePaymentMethodEntry => "payment_method_id" in entry
170
-
171
- export interface PaymentProviderListResponse extends ListEnvelope {
172
- payment_providers: StorePaymentEntry[]
173
- }
174
-
175
- /**
176
- * List everything payable (rule-filtered, display-ordered): connected
177
- * processors + every ENABLED merchant method. → 200 list envelope;
178
- * `count`/`limit` = full filtered list (no pagination). An empty list is
179
- * honest: a fresh store cannot take money until the merchant connects a
180
- * processor, flips the COD switch, or creates a manual method
181
- * (no-payment-method-by-default).
182
- * Auth: anon `x-client-id`.
183
- * Settings: processor enablement (admin integrations), the Payments
184
- * settings methods manager, checkout rules (`target_type=payment_method`,
185
- * methods participate under their `payment_method_id`) +
186
- * `checkout_method_order`.
187
- */
188
- export async function listPaymentProviders(
189
- client: StorefrontClient,
190
- query: ListPaymentProvidersQuery = {}
191
- ): Promise<PaymentProviderListResponse> {
192
- return client.get("/api/store/payment-providers", { query: { ...query } })
193
- }
194
-
195
- // ---------------------------------------------------------------------------
196
- // Payment collections + sessions (manual checkout path)
197
- // ---------------------------------------------------------------------------
198
-
199
- /**
200
- * Body of POST /api/store/payment-collections.
201
- * NOTE (code wins over store-api.md): ONLY `cart_id` is read — the
202
- * contract's `provider_id`/`data` fields are ignored by the route; the
203
- * provider is chosen when initiating the payment session.
204
- */
205
- export interface CreatePaymentCollectionInput {
206
- cart_id: string
207
- }
208
-
209
- export interface StorePaymentCollection {
210
- id: string
211
- /** Always the cart's CURRENT decorated total (refreshed on every call). */
212
- amount: MajorUnitAmount
213
- currency_code: string
214
- /** `not_paid` until complete authorizes. */
215
- status: string
216
- payment_sessions?: StorePaymentSession[]
217
- [key: string]: unknown
218
- }
219
-
220
- export interface CreatePaymentCollectionResponse {
221
- payment_collection: StorePaymentCollection
222
- }
223
-
224
- /**
225
- * Ensure the cart's payment collection (ONE per cart, idempotent). → 201
226
- * `{payment_collection}` when created, 200 when the existing one was
227
- * refreshed to the current total.
228
- * Auth: anon `x-client-id`.
229
- * Errors: 404 `cart_not_found`, 400 `validation_failed`.
230
- * Behavior: the moment a collection exists, any applied gift-card tender is
231
- * composed as an internal `pp_giftcard` session (gift-cards contract).
232
- */
233
- export async function createPaymentCollection(
234
- client: StorefrontClient,
235
- input: CreatePaymentCollectionInput
236
- ): Promise<CreatePaymentCollectionResponse> {
237
- return client.post("/api/store/payment-collections", input)
238
- }
239
-
240
- export interface InitiatePaymentSessionInput {
241
- /** A connected processor (`pp_stripe`) — NEVER `pp_giftcard` (→ 400
242
- * `invalid_provider`). Exactly ONE of `provider_id` /
243
- * `payment_method_id` (→ 400 `invalid_data` otherwise). */
244
- provider_id?: string
245
- /** A merchant payment method (`payment_method_id` from the listing's
246
- * method entries) — the session lands with `provider_id` NULL and the
247
- * method snapshot (`payment_method_id`/`_name`/`_kind`) in `data`. */
248
- payment_method_id?: string
249
- /**
250
- * Merged into the session's `data` blob. Server-owned keys
251
- * (`payment_intent_id`, `client_secret`, `status`, `stripe_customer_id`,
252
- * `setup_future_usage`, and the method snapshot keys) are stripped.
253
- */
254
- data?: Record<string, unknown>
255
- /**
256
- * Subscription carts only — saves the card for future off-session renewal
257
- * charges. Requires a logged-in customer (guest → 400 `customer_required`);
258
- * the server resolves WHICH customer from the cart, never from the client.
259
- * Setting this IS the mandate moment: render the saved-card consent text
260
- * next to the payment element when you set it. No-op on non-card providers.
261
- */
262
- save_payment_method?: boolean
263
- }
264
-
265
- export interface StorePaymentSession {
266
- id: string
267
- /** The processor, or NULL for a merchant-method session (the method
268
- * snapshot lives in `data`). */
269
- provider_id: string | null
270
- /** `collection.amount − gift_card_total` — the remainder this tender charges. */
271
- amount: MajorUnitAmount
272
- currency_code: string
273
- /** `pending` until authorized at complete. */
274
- status: string
275
- authorized_at: string | null
276
- /**
277
- * Tender blob. For Stripe: `payment_intent_id`, `client_secret`
278
- * (mount Stripe Elements with it), `status`. For methods: the snapshot
279
- * `payment_method_id` / `payment_method_name` / `payment_method_kind`.
280
- */
281
- data: Record<string, unknown> | null
282
- [key: string]: unknown
283
- }
284
-
285
- export interface InitiatePaymentSessionResponse {
286
- payment_session: StorePaymentSession
287
- }
288
-
289
- /**
290
- * Mint (or repair) the payment session for a provider on a collection —
291
- * idempotent per provider. → 201 `{payment_session}` when created, 200 when
292
- * the existing session was returned/repaired. For Stripe the PaymentIntent
293
- * is minted FIRST (idempotency key = session id); amount drift is synced
294
- * in place; terminal PIs self-heal by rotation.
295
- * Auth: anon `x-client-id`.
296
- * Errors: 404 `payment_collection_not_found`, 400 `invalid_provider`
297
- * (pp_giftcard / unknown processor) | `invalid_data` (tender XOR violated,
298
- * unknown/disabled method) | `stripe_not_configured` | `validation_failed`.
299
- * Settings: Stripe credentials (admin integrations); gift-card tender
300
- * shrinks the session amount; the COD fee appears on the cart the moment a
301
- * live COD-method session exists.
302
- */
303
- export async function initiatePaymentSession(
304
- client: StorefrontClient,
305
- paymentCollectionId: string,
306
- input: InitiatePaymentSessionInput
307
- ): Promise<InitiatePaymentSessionResponse> {
308
- return client.post(
309
- `/api/store/payment-collections/${paymentCollectionId}/payment-sessions`,
310
- input
311
- )
312
- }
313
-
314
- // ---------------------------------------------------------------------------
315
- // Orchestrated Buy-click (prepare → confirm → complete)
316
- // ---------------------------------------------------------------------------
317
-
318
- /**
319
- * Address for prepare-checkout — STRICTER than the generic cart address:
320
- * every field required except `address_2`/`company`/`province` (min 1 char;
321
- * phone included — it's the courier's primary recovery channel).
322
- */
323
- export interface PrepareCheckoutAddress {
324
- first_name: string
325
- last_name: string
326
- address_1: string
327
- address_2?: string
328
- company?: string
329
- province?: string
330
- city: string
331
- postal_code: string
332
- country_code: string
333
- phone: string
334
- }
335
-
336
- /** Body of POST /api/store/carts/:id/prepare-checkout (`.strict()`). */
337
- export interface PrepareCheckoutInput {
338
- shipping_address: PrepareCheckoutAddress
339
- /** A shipping option id (billing address mirrors the shipping address). */
340
- shipping_method_id: string
341
- /** Stored on the cart_shipping_methods row. */
342
- shipping_method_data?: Record<string, unknown>
343
- /**
344
- * Opaque per-carrier keys (locker/office pickers) merged into
345
- * `cart.metadata`; keys written by the PREVIOUS prepare call are removed
346
- * first (tracked under the reserved `_prepared_carrier_keys` marker) so
347
- * switching carriers never leaks stale fields into the order.
348
- */
349
- carrier_metadata?: Record<string, unknown>
350
- /** A connected processor (`pp_stripe`) — never `pp_giftcard`. Exactly ONE
351
- * of `payment_provider` / `payment_method_id`. */
352
- payment_provider?: string
353
- /** A merchant payment method — the session lands provider-less with the
354
- * method snapshot (pp_* kill, 20260811250000). */
355
- payment_method_id?: string
356
- }
357
-
358
- /** Response of prepare-checkout (verbatim src/lib/checkout-orchestration/prepare.ts). */
359
- export interface PrepareCheckoutResult {
360
- cart_id: string
361
- payment_collection_id: string | null
362
- /** Stripe Elements secret; null for method sessions and zero-remainder carts. */
363
- client_secret: string | null
364
- /** The processor whose session was minted; null for method sessions and
365
- * when zero-remainder skipped the session. */
366
- provider_id: string | null
367
- /** The method whose session was minted; null for processor sessions and
368
- * when zero-remainder skipped the session. */
369
- payment_method_id: string | null
370
- }
371
-
372
- /**
373
- * Atomic Buy-click step 1 of 2. ONE call writes address (+billing mirror) +
374
- * carrier metadata → shipping method (priced against the new destination) →
375
- * payment collection at the shipped total → payment session at the FINAL
376
- * amount. For the COD method the native fee applies once the session
377
- * exists, so amounts are re-synced after it. Zero-remainder carts (gift
378
- * cards cover the total) skip the tender session — `client_secret`,
379
- * `provider_id` and `payment_method_id` come back null and the cart
380
- * completes on the gift session.
381
- *
382
- * Fully compensated (wire-pattern): any failure rolls back session →
383
- * collection → shipping method → addresses/metadata to the pre-call
384
- * snapshot and records a reverted execution-ledger row; failures land in
385
- * `checkout_error_logs` (step `prepare-checkout`).
386
- *
387
- * After prepare: Stripe → `stripe.confirmPayment(client_secret)` →
388
- * `completeCart()`; merchant methods → `completeCart()` directly.
389
- *
390
- * Auth: anon `x-client-id`.
391
- * Errors: 404 `cart_not_found` | `shipping_option_not_found`, 409
392
- * `cart_completed`, 400 `validation_failed` | `invalid_provider` |
393
- * `invalid_data` (tender XOR violated) | `shipping_price_missing` |
394
- * `stripe_not_configured`.
395
- * Settings: Stripe credentials, the COD method's fee, gift-card tender,
396
- * checkout rules (enforced at listings + complete, not here).
397
- */
398
- export async function prepareCheckout(
399
- client: StorefrontClient,
400
- cartId: string,
401
- input: PrepareCheckoutInput
402
- ): Promise<PrepareCheckoutResult> {
403
- return client.post(`/api/store/carts/${cartId}/prepare-checkout`, input)
404
- }
405
-
406
- /** Body of POST /api/store/carts/:id/sync-payment-amount (`.strict()`).
407
- * At most ONE of the two — the requested tender; omit both to sync
408
- * whatever session is pending. */
409
- export interface SyncPaymentAmountInput {
410
- /** Rotate to this processor when the pending session's tender differs. */
411
- provider_id?: string
412
- /** Rotate to this merchant method when the pending session's tender differs. */
413
- payment_method_id?: string
414
- }
415
-
416
- /** Response matrix (verbatim src/lib/checkout-orchestration/sync.ts). */
417
- export interface SyncPaymentAmountResult {
418
- synced: boolean
419
- /** true = old session retired + fresh session minted (new client_secret). */
420
- rotated?: boolean
421
- client_secret?: string | null
422
- provider_id?: string | null
423
- payment_method_id?: string | null
424
- /** No-op reasons: `cart-completed` | `no_payment_collection` | `no_pending_session`. */
425
- reason?: string
426
- }
427
-
428
- /**
429
- * Align the pending provider session with the cart's CURRENT total, in
430
- * place when possible — the happy path returns the SAME `client_secret` so
431
- * `<Elements>` never remounts. Call after anything that changes the total
432
- * (line/shipping/gift-card changes) while checkout is mounted.
433
- *
434
- * Matrix: completed cart → `{synced:false, reason:"cart-completed"}`; no
435
- * collection → `no_payment_collection`; no pending provider session (gift
436
- * sessions excluded) → `no_pending_session`; provider matches + amount
437
- * current → no-op `{synced:true, rotated:false}`; amount drifted →
438
- * in-place update (same secret); provider mismatch OR the Stripe update
439
- * refused (terminal PI) → rotation `{synced:true, rotated:true}` with a
440
- * fresh secret. The old PI is voided best-effort on rotation.
441
- *
442
- * Auth: anon `x-client-id`.
443
- * Errors: 404 `cart_not_found`, 400 `validation_failed` |
444
- * `stripe_not_configured`. Failures land in `checkout_error_logs`
445
- * (step `sync-payment-amount`).
446
- */
447
- export async function syncPaymentAmount(
448
- client: StorefrontClient,
449
- cartId: string,
450
- input: SyncPaymentAmountInput = {}
451
- ): Promise<SyncPaymentAmountResult> {
452
- return client.post(`/api/store/carts/${cartId}/sync-payment-amount`, input)
453
- }
454
-
455
- /** Response (verbatim src/lib/checkout-orchestration/refresh.ts). */
456
- export interface RefreshPaymentResult {
457
- rotated: boolean
458
- /**
459
- * Rotated: `pi-terminal` | `pi-missing`. Not rotated: `cart-completed` |
460
- * `no-stripe-session` | `no-pi-id` | `stripe-not-configured` |
461
- * `still-usable` (+ `status`) | `stripe-error` (+ `error`).
462
- */
463
- reason: string
464
- /** Present with `still-usable` — the live PI status. */
465
- status?: string
466
- /** Present after rotation — the dead PI's status (or "missing"). */
467
- previous_status?: string
468
- /** Present with `stripe-error`. */
469
- error?: string
470
- }
471
-
472
- /**
473
- * Reactive dead-PI recovery — reconcile the pending STRIPE session against
474
- * Stripe's actual PaymentIntent and rotate a fresh session/PI when the
475
- * intent is terminal (`succeeded`/`canceled`/`requires_capture`) or
476
- * missing. Call from Elements `loaderror` or on page mount for aged carts —
477
- * NEVER proactively per render (the proactive variant caused a production
478
- * reload loop). Any transient Stripe error returns
479
- * `{rotated:false, reason:"stripe-error"}` WITHOUT rotating. No body.
480
- *
481
- * Auth: anon `x-client-id`.
482
- * Errors: 404 `cart_not_found`. Every rotation writes an audit row
483
- * (`checkout_error_logs` step `refresh-payment`, code `rotated`).
484
- */
485
- export async function refreshPaymentIfTerminal(
486
- client: StorefrontClient,
487
- cartId: string
488
- ): Promise<RefreshPaymentResult> {
489
- return client.post(`/api/store/carts/${cartId}/refresh-payment-if-terminal`)
490
- }
491
-
492
- // ---------------------------------------------------------------------------
493
- // Browser-side error reporting
494
- // ---------------------------------------------------------------------------
495
-
496
- /** Body of POST /api/store/checkout-errors (`.strict()`). */
497
- export interface ReportCheckoutErrorInput {
498
- /** The orchestration hook's errorType, e.g. "place_order_error". */
499
- error_type: string
500
- message: string
501
- cart_id?: string
502
- /** Redacted context — ids, codes, flags. Never card data, never addresses. */
503
- context?: Record<string, unknown>
504
- }
505
-
506
- /**
507
- * Report a browser-side checkout failure into the platform's
508
- * checkout_error_logs (step `browser`) so the merchant sees WHY a checkout
509
- * died in the customer's browser — Stripe.js confirm errors, a 3DS return
510
- * that came back not-succeeded, a place-order rejection after payment.
511
- * `useCheckoutOrchestration` calls this BY DEFAULT when no `logError`
512
- * override is given. Fire-and-forget by contract: swallows every failure —
513
- * reporting an error must never break a checkout. Rate-limited server-side.
514
- *
515
- * Auth: anon `x-client-id`.
516
- */
517
- export async function reportCheckoutError(
518
- client: StorefrontClient,
519
- input: ReportCheckoutErrorInput
520
- ): Promise<void> {
521
- try {
522
- await client.post(`/api/store/checkout-errors`, input)
523
- } catch {
524
- // Swallowed by contract — the sink must never take the checkout down.
525
- }
526
- }
1
+ /**
2
+ * @cartbase/storefront/api/checkout — the checkout sequence.
3
+ *
4
+ * Two ways to drive checkout, both wrapped here:
5
+ *
6
+ * 1. **Orchestrated (recommended)** — `prepareCheckout()` is the atomic
7
+ * Buy-click: ONE call writes address (+billing mirror), shipping method
8
+ * (+ carrier metadata), payment collection and the payment session at
9
+ * the FINAL amount (real Stripe PaymentIntent for a processor, or a
10
+ * provider-less row for a merchant method). Then `completeCart()`
11
+ * (see `./carts`). Amount drift/repair between the two:
12
+ * `syncPaymentAmount()` and `refreshPaymentIfTerminal()`.
13
+ *
14
+ * 2. **Manual (Medusa-style)** — `listShippingOptions()` →
15
+ * `addShippingMethod()` → `createPaymentCollection()` →
16
+ * `initiatePaymentSession()` → `completeCart()`.
17
+ *
18
+ * Amount authority everywhere: the SERVER totals engine. The client never
19
+ * supplies an amount; sessions charge `total − gift_card_total`. Money is
20
+ * EUR decimal major units. Route ground truth: src/app/api/store/
21
+ * {shipping-options,payment-providers,payment-collections}/**, carts/[id]/
22
+ * {shipping-methods,prepare-checkout,sync-payment-amount,
23
+ * refresh-payment-if-terminal} + src/lib/checkout-orchestration/*.
24
+ * Doc: docs/storefront/checkout.md.
25
+ */
26
+ import type { StorefrontClient } from "./http"
27
+ import type { ListEnvelope, MajorUnitAmount } from "./types"
28
+ import type { CartResponse } from "./carts"
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Shipping options
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export interface ListShippingOptionsQuery {
35
+ /**
36
+ * Prices the options in the cart's currency AND gives the checkout-rules
37
+ * engine its evaluation context. Without it `amount` is null and
38
+ * cart-dependent hide rules cannot match (the completion guard remains
39
+ * the boundary).
40
+ */
41
+ cart_id?: string
42
+ }
43
+
44
+ /** Row of GET /api/store/shipping-options. */
45
+ export interface StoreShippingOption {
46
+ id: string
47
+ name: string
48
+ provider_id: string | null
49
+ service_zone_id: string | null
50
+ shipping_profile_id: string | null
51
+ data: Record<string, unknown> | null
52
+ /** The option's display label (e.g. "Express") as `{ label }`, or null. */
53
+ type: { label: string } | null
54
+ /** Flat price in the cart currency; null without `cart_id` or when no price row matches. */
55
+ amount: MajorUnitAmount | null
56
+ /** Always "flat" today — calculated-rate carriers are not wired yet. */
57
+ price_type: "flat"
58
+ [key: string]: unknown
59
+ }
60
+
61
+ export interface ShippingOptionListResponse extends ListEnvelope {
62
+ shipping_options: StoreShippingOption[]
63
+ }
64
+
65
+ /**
66
+ * List shipping options (rule-filtered, display-ordered). → 200 list
67
+ * envelope; `count`/`limit` = full filtered list (no pagination).
68
+ * Auth: anon `x-client-id`.
69
+ * Errors: 404 `cart_not_found` (bad `cart_id`).
70
+ * Settings: checkout rules (`target_type=shipping_option`) hide options
71
+ * server-side; `checkout_method_order` orders them; rules evaluate the cart
72
+ * context (customer tags/groups, totals, weight, channel, country) when
73
+ * `cart_id` is passed. Fail-open: a broken rule never bricks the listing.
74
+ */
75
+ export async function listShippingOptions(
76
+ client: StorefrontClient,
77
+ query: ListShippingOptionsQuery = {}
78
+ ): Promise<ShippingOptionListResponse> {
79
+ return client.get("/api/store/shipping-options", { query: { ...query } })
80
+ }
81
+
82
+ export interface CalculateShippingOptionInput {
83
+ cart_id: string
84
+ /** Provider-specific fields — accepted and currently ignored (flat prices only). */
85
+ data?: Record<string, unknown>
86
+ }
87
+
88
+ export interface CalculateShippingOptionResponse {
89
+ shipping_option: StoreShippingOption & { amount: MajorUnitAmount }
90
+ }
91
+
92
+ /**
93
+ * Price one option for a cart. → 200 `{shipping_option}` with `amount` in
94
+ * the cart currency. Today this returns the flat price-set match —
95
+ * calculated-rate carriers are not wired (the `data` body is reserved).
96
+ * Auth: anon `x-client-id`.
97
+ * Errors: 404 `cart_not_found` | `shipping_option_not_found`, 400
98
+ * `shipping_price_missing` (no price row in the cart currency) |
99
+ * `validation_failed`.
100
+ */
101
+ export async function calculateShippingOption(
102
+ client: StorefrontClient,
103
+ shippingOptionId: string,
104
+ input: CalculateShippingOptionInput
105
+ ): Promise<CalculateShippingOptionResponse> {
106
+ return client.post(`/api/store/shipping-options/${shippingOptionId}/calculate`, input)
107
+ }
108
+
109
+ /**
110
+ * Add/replace the cart's shipping method (manual checkout path — the
111
+ * orchestrated path does this inside `prepareCheckout`). → 200 `{cart}`.
112
+ * Body: `{option_id, data?}` (`.strict()`).
113
+ * Auth: anon `x-client-id`.
114
+ * Errors: 404 `cart_not_found` | `shipping_option_not_found`, 409
115
+ * `cart_completed`, 400 `shipping_price_missing` | `validation_failed`.
116
+ */
117
+ export async function addShippingMethod(
118
+ client: StorefrontClient,
119
+ cartId: string,
120
+ input: { option_id: string; data?: Record<string, unknown> }
121
+ ): Promise<CartResponse> {
122
+ return client.post(`/api/store/carts/${cartId}/shipping-methods`, input)
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Payment providers
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export interface ListPaymentProvidersQuery {
130
+ /** Scope to providers linked to this region (`region_payment_provider`). */
131
+ region_id?: string
132
+ /**
133
+ * Checkout-rules evaluation context — storefronts SHOULD pass it during
134
+ * checkout; without it cart-dependent hide rules cannot match here.
135
+ */
136
+ cart_id?: string
137
+ }
138
+
139
+ /** A connected PROCESSOR entry of GET /api/store/payment-providers. */
140
+ export interface StorePaymentProcessorEntry {
141
+ /** e.g. `pp_stripe`. `pp_giftcard` is internal and never listed. */
142
+ id: string
143
+ is_enabled: boolean
144
+ created_at?: string
145
+ }
146
+
147
+ /** A merchant PAYMENT METHOD entry (pp_* kill, 20260811250000): methods
148
+ * stand alone — no provider id anywhere. Initiate sessions with
149
+ * `payment_method_id`; `kind === "cod"` marks the pre-created
150
+ * collection-on-delivery method (courier cash collection); `instructions`
151
+ * is the merchant's checkout note, rendered under the method. */
152
+ export interface StorePaymentMethodEntry {
153
+ payment_method_id: string
154
+ name: string
155
+ kind: "manual" | "cod"
156
+ instructions: string | null
157
+ /** The method's fee (any method may carry one) — predict
158
+ * `payment_method_fee_total` from it before a session exists. */
159
+ fee_amount: number | null
160
+ fee_label: string | null
161
+ }
162
+
163
+ /** Row of GET /api/store/payment-providers — a processor or a method. */
164
+ export type StorePaymentEntry = StorePaymentProcessorEntry | StorePaymentMethodEntry
165
+
166
+ /** True when the entry is a merchant payment method (vs a processor). */
167
+ export const isMethodEntry = (
168
+ entry: StorePaymentEntry
169
+ ): entry is StorePaymentMethodEntry => "payment_method_id" in entry
170
+
171
+ export interface PaymentProviderListResponse extends ListEnvelope {
172
+ payment_providers: StorePaymentEntry[]
173
+ }
174
+
175
+ /**
176
+ * List everything payable (rule-filtered, display-ordered): connected
177
+ * processors + every ENABLED merchant method. → 200 list envelope;
178
+ * `count`/`limit` = full filtered list (no pagination). An empty list is
179
+ * honest: a fresh store cannot take money until the merchant connects a
180
+ * processor, flips the COD switch, or creates a manual method
181
+ * (no-payment-method-by-default).
182
+ * Auth: anon `x-client-id`.
183
+ * Settings: processor enablement (admin integrations), the Payments
184
+ * settings methods manager, checkout rules (`target_type=payment_method`,
185
+ * methods participate under their `payment_method_id`) +
186
+ * `checkout_method_order`.
187
+ */
188
+ export async function listPaymentProviders(
189
+ client: StorefrontClient,
190
+ query: ListPaymentProvidersQuery = {}
191
+ ): Promise<PaymentProviderListResponse> {
192
+ return client.get("/api/store/payment-providers", { query: { ...query } })
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Payment collections + sessions (manual checkout path)
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Body of POST /api/store/payment-collections.
201
+ * NOTE (code wins over store-api.md): ONLY `cart_id` is read — the
202
+ * contract's `provider_id`/`data` fields are ignored by the route; the
203
+ * provider is chosen when initiating the payment session.
204
+ */
205
+ export interface CreatePaymentCollectionInput {
206
+ cart_id: string
207
+ }
208
+
209
+ export interface StorePaymentCollection {
210
+ id: string
211
+ /** Always the cart's CURRENT decorated total (refreshed on every call). */
212
+ amount: MajorUnitAmount
213
+ currency_code: string
214
+ /** `not_paid` until complete authorizes. */
215
+ status: string
216
+ payment_sessions?: StorePaymentSession[]
217
+ [key: string]: unknown
218
+ }
219
+
220
+ export interface CreatePaymentCollectionResponse {
221
+ payment_collection: StorePaymentCollection
222
+ }
223
+
224
+ /**
225
+ * Ensure the cart's payment collection (ONE per cart, idempotent). → 201
226
+ * `{payment_collection}` when created, 200 when the existing one was
227
+ * refreshed to the current total.
228
+ * Auth: anon `x-client-id`.
229
+ * Errors: 404 `cart_not_found`, 400 `validation_failed`.
230
+ * Behavior: the moment a collection exists, any applied gift-card tender is
231
+ * composed as an internal `pp_giftcard` session (gift-cards contract).
232
+ */
233
+ export async function createPaymentCollection(
234
+ client: StorefrontClient,
235
+ input: CreatePaymentCollectionInput
236
+ ): Promise<CreatePaymentCollectionResponse> {
237
+ return client.post("/api/store/payment-collections", input)
238
+ }
239
+
240
+ export interface InitiatePaymentSessionInput {
241
+ /** A connected processor (`pp_stripe`) — NEVER `pp_giftcard` (→ 400
242
+ * `invalid_provider`). Exactly ONE of `provider_id` /
243
+ * `payment_method_id` (→ 400 `invalid_data` otherwise). */
244
+ provider_id?: string
245
+ /** A merchant payment method (`payment_method_id` from the listing's
246
+ * method entries) — the session lands with `provider_id` NULL and the
247
+ * method snapshot (`payment_method_id`/`_name`/`_kind`) in `data`. */
248
+ payment_method_id?: string
249
+ /**
250
+ * Merged into the session's `data` blob. Server-owned keys
251
+ * (`payment_intent_id`, `client_secret`, `status`, `stripe_customer_id`,
252
+ * `setup_future_usage`, and the method snapshot keys) are stripped.
253
+ */
254
+ data?: Record<string, unknown>
255
+ /**
256
+ * Subscription carts only — saves the card for future off-session renewal
257
+ * charges. Requires a logged-in customer (guest → 400 `customer_required`);
258
+ * the server resolves WHICH customer from the cart, never from the client.
259
+ * Setting this IS the mandate moment: render the saved-card consent text
260
+ * next to the payment element when you set it. No-op on non-card providers.
261
+ */
262
+ save_payment_method?: boolean
263
+ }
264
+
265
+ export interface StorePaymentSession {
266
+ id: string
267
+ /** The processor, or NULL for a merchant-method session (the method
268
+ * snapshot lives in `data`). */
269
+ provider_id: string | null
270
+ /** `collection.amount − gift_card_total` — the remainder this tender charges. */
271
+ amount: MajorUnitAmount
272
+ currency_code: string
273
+ /** `pending` until authorized at complete. */
274
+ status: string
275
+ authorized_at: string | null
276
+ /**
277
+ * Tender blob. For Stripe: `payment_intent_id`, `client_secret`
278
+ * (mount Stripe Elements with it), `status`. For methods: the snapshot
279
+ * `payment_method_id` / `payment_method_name` / `payment_method_kind`.
280
+ */
281
+ data: Record<string, unknown> | null
282
+ [key: string]: unknown
283
+ }
284
+
285
+ export interface InitiatePaymentSessionResponse {
286
+ payment_session: StorePaymentSession
287
+ }
288
+
289
+ /**
290
+ * Mint (or repair) the payment session for a provider on a collection —
291
+ * idempotent per provider. → 201 `{payment_session}` when created, 200 when
292
+ * the existing session was returned/repaired. For Stripe the PaymentIntent
293
+ * is minted FIRST (idempotency key = session id); amount drift is synced
294
+ * in place; terminal PIs self-heal by rotation.
295
+ * Auth: anon `x-client-id`.
296
+ * Errors: 404 `payment_collection_not_found`, 400 `invalid_provider`
297
+ * (pp_giftcard / unknown processor) | `invalid_data` (tender XOR violated,
298
+ * unknown/disabled method) | `stripe_not_configured` | `validation_failed`.
299
+ * Settings: Stripe credentials (admin integrations); gift-card tender
300
+ * shrinks the session amount; the COD fee appears on the cart the moment a
301
+ * live COD-method session exists.
302
+ */
303
+ export async function initiatePaymentSession(
304
+ client: StorefrontClient,
305
+ paymentCollectionId: string,
306
+ input: InitiatePaymentSessionInput
307
+ ): Promise<InitiatePaymentSessionResponse> {
308
+ return client.post(
309
+ `/api/store/payment-collections/${paymentCollectionId}/payment-sessions`,
310
+ input
311
+ )
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Orchestrated Buy-click (prepare → confirm → complete)
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /**
319
+ * Address for prepare-checkout — STRICTER than the generic cart address:
320
+ * every field required except `address_2`/`company`/`province` (min 1 char;
321
+ * phone included — it's the courier's primary recovery channel).
322
+ */
323
+ export interface PrepareCheckoutAddress {
324
+ first_name: string
325
+ last_name: string
326
+ address_1: string
327
+ address_2?: string
328
+ company?: string
329
+ province?: string
330
+ city: string
331
+ postal_code: string
332
+ country_code: string
333
+ phone: string
334
+ }
335
+
336
+ /** Body of POST /api/store/carts/:id/prepare-checkout (`.strict()`). */
337
+ export interface PrepareCheckoutInput {
338
+ shipping_address: PrepareCheckoutAddress
339
+ /** A shipping option id (billing address mirrors the shipping address). */
340
+ shipping_method_id: string
341
+ /** Stored on the cart_shipping_methods row. */
342
+ shipping_method_data?: Record<string, unknown>
343
+ /**
344
+ * Opaque per-carrier keys (locker/office pickers) merged into
345
+ * `cart.metadata`; keys written by the PREVIOUS prepare call are removed
346
+ * first (tracked under the reserved `_prepared_carrier_keys` marker) so
347
+ * switching carriers never leaks stale fields into the order.
348
+ */
349
+ carrier_metadata?: Record<string, unknown>
350
+ /** A connected processor (`pp_stripe`) — never `pp_giftcard`. Exactly ONE
351
+ * of `payment_provider` / `payment_method_id`. */
352
+ payment_provider?: string
353
+ /** A merchant payment method — the session lands provider-less with the
354
+ * method snapshot (pp_* kill, 20260811250000). */
355
+ payment_method_id?: string
356
+ }
357
+
358
+ /** Response of prepare-checkout (verbatim src/lib/checkout-orchestration/prepare.ts). */
359
+ export interface PrepareCheckoutResult {
360
+ cart_id: string
361
+ payment_collection_id: string | null
362
+ /** Stripe Elements secret; null for method sessions and zero-remainder carts. */
363
+ client_secret: string | null
364
+ /** The processor whose session was minted; null for method sessions and
365
+ * when zero-remainder skipped the session. */
366
+ provider_id: string | null
367
+ /** The method whose session was minted; null for processor sessions and
368
+ * when zero-remainder skipped the session. */
369
+ payment_method_id: string | null
370
+ }
371
+
372
+ /**
373
+ * Atomic Buy-click step 1 of 2. ONE call writes address (+billing mirror) +
374
+ * carrier metadata → shipping method (priced against the new destination) →
375
+ * payment collection at the shipped total → payment session at the FINAL
376
+ * amount. For the COD method the native fee applies once the session
377
+ * exists, so amounts are re-synced after it. Zero-remainder carts (gift
378
+ * cards cover the total) skip the tender session — `client_secret`,
379
+ * `provider_id` and `payment_method_id` come back null and the cart
380
+ * completes on the gift session.
381
+ *
382
+ * Fully compensated (wire-pattern): any failure rolls back session →
383
+ * collection → shipping method → addresses/metadata to the pre-call
384
+ * snapshot and records a reverted execution-ledger row; failures land in
385
+ * `checkout_error_logs` (step `prepare-checkout`).
386
+ *
387
+ * After prepare: Stripe → `stripe.confirmPayment(client_secret)` →
388
+ * `completeCart()`; merchant methods → `completeCart()` directly.
389
+ *
390
+ * Auth: anon `x-client-id`.
391
+ * Errors: 404 `cart_not_found` | `shipping_option_not_found`, 409
392
+ * `cart_completed`, 400 `validation_failed` | `invalid_provider` |
393
+ * `invalid_data` (tender XOR violated) | `shipping_price_missing` |
394
+ * `stripe_not_configured`.
395
+ * Settings: Stripe credentials, the COD method's fee, gift-card tender,
396
+ * checkout rules (enforced at listings + complete, not here).
397
+ */
398
+ export async function prepareCheckout(
399
+ client: StorefrontClient,
400
+ cartId: string,
401
+ input: PrepareCheckoutInput
402
+ ): Promise<PrepareCheckoutResult> {
403
+ return client.post(`/api/store/carts/${cartId}/prepare-checkout`, input)
404
+ }
405
+
406
+ /** Body of POST /api/store/carts/:id/sync-payment-amount (`.strict()`).
407
+ * At most ONE of the two — the requested tender; omit both to sync
408
+ * whatever session is pending. */
409
+ export interface SyncPaymentAmountInput {
410
+ /** Rotate to this processor when the pending session's tender differs. */
411
+ provider_id?: string
412
+ /** Rotate to this merchant method when the pending session's tender differs. */
413
+ payment_method_id?: string
414
+ }
415
+
416
+ /** Response matrix (verbatim src/lib/checkout-orchestration/sync.ts). */
417
+ export interface SyncPaymentAmountResult {
418
+ synced: boolean
419
+ /** true = old session retired + fresh session minted (new client_secret). */
420
+ rotated?: boolean
421
+ client_secret?: string | null
422
+ provider_id?: string | null
423
+ payment_method_id?: string | null
424
+ /** No-op reasons: `cart-completed` | `no_payment_collection` | `no_pending_session`. */
425
+ reason?: string
426
+ }
427
+
428
+ /**
429
+ * Align the pending provider session with the cart's CURRENT total, in
430
+ * place when possible — the happy path returns the SAME `client_secret` so
431
+ * `<Elements>` never remounts. Call after anything that changes the total
432
+ * (line/shipping/gift-card changes) while checkout is mounted.
433
+ *
434
+ * Matrix: completed cart → `{synced:false, reason:"cart-completed"}`; no
435
+ * collection → `no_payment_collection`; no pending provider session (gift
436
+ * sessions excluded) → `no_pending_session`; provider matches + amount
437
+ * current → no-op `{synced:true, rotated:false}`; amount drifted →
438
+ * in-place update (same secret); provider mismatch OR the Stripe update
439
+ * refused (terminal PI) → rotation `{synced:true, rotated:true}` with a
440
+ * fresh secret. The old PI is voided best-effort on rotation.
441
+ *
442
+ * Auth: anon `x-client-id`.
443
+ * Errors: 404 `cart_not_found`, 400 `validation_failed` |
444
+ * `stripe_not_configured`. Failures land in `checkout_error_logs`
445
+ * (step `sync-payment-amount`).
446
+ */
447
+ export async function syncPaymentAmount(
448
+ client: StorefrontClient,
449
+ cartId: string,
450
+ input: SyncPaymentAmountInput = {}
451
+ ): Promise<SyncPaymentAmountResult> {
452
+ return client.post(`/api/store/carts/${cartId}/sync-payment-amount`, input)
453
+ }
454
+
455
+ /** Response (verbatim src/lib/checkout-orchestration/refresh.ts). */
456
+ export interface RefreshPaymentResult {
457
+ rotated: boolean
458
+ /**
459
+ * Rotated: `pi-terminal` | `pi-missing`. Not rotated: `cart-completed` |
460
+ * `no-stripe-session` | `no-pi-id` | `stripe-not-configured` |
461
+ * `still-usable` (+ `status`) | `stripe-error` (+ `error`).
462
+ */
463
+ reason: string
464
+ /** Present with `still-usable` — the live PI status. */
465
+ status?: string
466
+ /** Present after rotation — the dead PI's status (or "missing"). */
467
+ previous_status?: string
468
+ /** Present with `stripe-error`. */
469
+ error?: string
470
+ }
471
+
472
+ /**
473
+ * Reactive dead-PI recovery — reconcile the pending STRIPE session against
474
+ * Stripe's actual PaymentIntent and rotate a fresh session/PI when the
475
+ * intent is terminal (`succeeded`/`canceled`/`requires_capture`) or
476
+ * missing. Call from Elements `loaderror` or on page mount for aged carts —
477
+ * NEVER proactively per render (the proactive variant caused a production
478
+ * reload loop). Any transient Stripe error returns
479
+ * `{rotated:false, reason:"stripe-error"}` WITHOUT rotating. No body.
480
+ *
481
+ * Auth: anon `x-client-id`.
482
+ * Errors: 404 `cart_not_found`. Every rotation writes an audit row
483
+ * (`checkout_error_logs` step `refresh-payment`, code `rotated`).
484
+ */
485
+ export async function refreshPaymentIfTerminal(
486
+ client: StorefrontClient,
487
+ cartId: string
488
+ ): Promise<RefreshPaymentResult> {
489
+ return client.post(`/api/store/carts/${cartId}/refresh-payment-if-terminal`)
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // Browser-side error reporting
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /** Body of POST /api/store/checkout-errors (`.strict()`). */
497
+ export interface ReportCheckoutErrorInput {
498
+ /** The orchestration hook's errorType, e.g. "place_order_error". */
499
+ error_type: string
500
+ message: string
501
+ cart_id?: string
502
+ /** Redacted context — ids, codes, flags. Never card data, never addresses. */
503
+ context?: Record<string, unknown>
504
+ }
505
+
506
+ /**
507
+ * Report a browser-side checkout failure into the platform's
508
+ * checkout_error_logs (step `browser`) so the merchant sees WHY a checkout
509
+ * died in the customer's browser — Stripe.js confirm errors, a 3DS return
510
+ * that came back not-succeeded, a place-order rejection after payment.
511
+ * `useCheckoutOrchestration` calls this BY DEFAULT when no `logError`
512
+ * override is given. Fire-and-forget by contract: swallows every failure —
513
+ * reporting an error must never break a checkout. Rate-limited server-side.
514
+ *
515
+ * Auth: anon `x-client-id`.
516
+ */
517
+ export async function reportCheckoutError(
518
+ client: StorefrontClient,
519
+ input: ReportCheckoutErrorInput
520
+ ): Promise<void> {
521
+ try {
522
+ await client.post(`/api/store/checkout-errors`, input)
523
+ } catch {
524
+ // Swallowed by contract — the sink must never take the checkout down.
525
+ }
526
+ }