@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.
- package/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +22 -1
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +3 -2
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +3 -2
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +12 -0
- package/src/reviews-ui/labels.ts +5 -2
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -191
- package/src/reviews-ui/review-widget.tsx +63 -71
- package/src/reviews-ui/star-badge.tsx +6 -4
- package/src/reviews-ui/widget-options.ts +55 -0
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
package/src/api/checkout.ts
CHANGED
|
@@ -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
|
+
}
|