Checkout — shipping options, payment, the Buy click, complete
This page is the full checkout knowledge transfer: every listing, the orchestrated Buy-click sequence, the amount-sync matrix, dead-PI recovery, and the completion contract. Amounts are EUR decimal major units and the server totals engine is the only amount authority — the client never supplies an amount anywhere in this flow.
Auth for every endpoint on this page: anon x-client-id (checkout is
guest-capable; the one exception is the store setting
accounts_mode='required' — see complete).
SDK module: @barter/storefront/api/checkout (+ carts.completeCart from
@barter/storefront/api/carts).
The two checkout paths
Orchestrated (recommended — what production storefronts run):
listShippingOptions(cart_id) ─┐ (render pickers)
listPaymentProviders(cart_id) ┘
│ Buy click
▼
prepareCheckout(cart, {address, shipping_method_id, carrier_metadata, payment_provider})
│ ONE atomic call, compensated on failure
├─ pp_stripe → stripe.confirmPayment(client_secret) ─┐
└─ pp_cod / pp_manual ───────────────────────────────┤
▼
completeCart(cart) → {type:"order", order}While checkout stays mounted: syncPaymentAmount() after anything that
changes the total; refreshPaymentIfTerminal() from Stripe Elements
loaderror (never proactively).
Manual (Medusa-style, for custom flows): updateCart (address+email) →
addShippingMethod → createPaymentCollection → initiatePaymentSession
→ completeCart. Both paths are executed against the live server below.
GET /api/store/shipping-options — list (rule-filtered)
- Purpose — render the shipping picker. Pass
cart_id— it prices the options in the cart currency AND gives the checkout-rules engine its evaluation context. - Auth — anon
x-client-id. - Request —
GET ?cart_id=cart_…(optional; without itamountis null and cart-dependent hide rules cannot match). - Response — list envelope;
count/limit= the full filtered list (no pagination):
{
"shipping_options": [{
"id": "so_…", "name": "Standard",
"provider_id": "fp_manual", "service_zone_id": "sz_…",
"shipping_option_type_id": "sotype_…", "shipping_profile_id": "sp_…",
"data": null,
"type": { "…": "shipping_option_types row (label/code/description)" },
"amount": 5, // flat price in the cart currency; null without cart_id
"price_type": "flat" // calculated-rate carriers not wired yet
}],
"count": 1, "offset": 0, "limit": 1
}- Errors — 404
cart_not_found(badcart_id). - SDK —
checkout.listShippingOptions(client, {cart_id}). - Components — shipping picker, carrier/locker pickers (carrier
metadata is collected client-side and handed to
prepareCheckout). - Settings — checkout rules (
target_type=shipping_option) hide options server-side;checkout_method_orderorders them; fail-open (a broken rule never bricks the listing). Hidden-method enforcement is at complete (checkout_method_hidden), not only here.
# Payable cart for the whole page (seeded Linen Shirt M + address + email).
CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"email":"checkout-doc-'"$RUN"'@example.test","currency_code":"eur",
"items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
REGION_ID=$(echo "$CART_JSON" | grep -o '"region_id":"[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$CART_ID" && test -n "$REGION_ID"
OPTS_JSON=$(curl -sf "$BASE/api/store/shipping-options?cart_id=$CART_ID" \
-H "x-client-id: $CLIENT_ID")
echo "$OPTS_JSON" | grep -q '"price_type":"flat"'
# Pick a PRICED option — an option without a price row for the cart currency
# lists amount: null and cannot be calculated or prepared. (The listing is
# shared dev-tenant state; never grab blindly the first row.)
SO_ID=$(echo "$OPTS_JSON" | grep -o '"id":"so_[^"]*","name":"Flat Rate (Bulgaria)"' \
| head -1 | cut -d'"' -f4)
test -n "$SO_ID"POST /api/store/shipping-options/:id/calculate — price one option
- Purpose — price a single option for a cart (kept for API parity /
future calculated-rate carriers; the listing already returns
amount). - Auth — anon
x-client-id. - Request —
{cart_id, data?}—datais provider-specific input, accepted and currently ignored (flat prices only). - Response —
200 {shipping_option}withamountin the cart currency. - Errors — 404
cart_not_found|shipping_option_not_found; 400shipping_price_missing(no price row in the cart currency) |validation_failed. - SDK —
checkout.calculateShippingOption(client, id, {cart_id}).
curl -sf -X POST "$BASE/api/store/shipping-options/$SO_ID/calculate" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"cart_id":"'"$CART_ID"'"}' | grep -q '"amount"'GET /api/store/payment-providers — list (rule-filtered)
- Purpose — render the payment-method picker.
- Auth — anon
x-client-id. - Request —
GET ?region_id=…&cart_id=…, both optional. Withoutregion_id: the tenant's enabled providers (global catalog ∩ tenant enablement). With it: providers linked to that region.cart_idfeeds the rules engine — storefronts SHOULD pass it during checkout. - Response — list envelope, full filtered list:
{
"payment_providers": [
{ "id": "pp_manual", "is_enabled": true, "created_at": "…" }
// pp_stripe / pp_cod appear when their integrations are enabled;
// pp_giftcard is INTERNAL tender and is never listed
],
"count": 1, "offset": 0, "limit": 1
}- Errors — none beyond the standard envelope (empty list when nothing is enabled).
- SDK —
checkout.listPaymentProviders(client, {region_id, cart_id}). - Components — payment picker.
- Settings — admin integrations provision providers (enabling COD
provisions
pp_cod+ its fee config; enabling Stripe provisionspp_stripe+ credentials); checkout rules (target_type=payment_method) +checkout_method_order.
curl -sf "$BASE/api/store/payment-providers?cart_id=$CART_ID" \
-H "x-client-id: $CLIENT_ID" | grep -q '"pp_manual"'
curl -sf "$BASE/api/store/payment-providers?region_id=$REGION_ID&cart_id=$CART_ID" \
-H "x-client-id: $CLIENT_ID" | grep -q '"pp_manual"'POST /api/store/carts/:id/prepare-checkout — the atomic Buy click
ONE call writes everything the customer toggled on /checkout, in the only
safe order: address first (option pricing reads the destination) →
shipping method → payment collection at the shipped total →
payment session LAST at the FINAL amount (real Stripe PaymentIntent,
idempotency key = session id; plain row for pp_cod/pp_manual). For
pp_cod the native fee only applies once the session exists, so amounts
are re-synced after it. Fully compensated: any failure rolls back session
→ collection → shipping method → addresses/metadata to the pre-call
snapshot (recorded in the execution ledger, workflow prepare-checkout,
states done/reverted; failures also land in checkout_error_logs, step
prepare-checkout).
- Auth — anon
x-client-id. - Request (
.strict(); DTO verbatim fromsrc/lib/checkout-orchestration/prepare.ts) — all address fields required exceptaddress_2/company/province;phoneis required (courier recovery channel).payment_provider=pp_stripe|pp_cod|pp_manual, neverpp_giftcard:
{
"shipping_address": {
"first_name": "Jane", "last_name": "Dow",
"address_1": "Vitosha 1", "address_2": "",
"company": "", "province": "",
"city": "Sofia", "postal_code": "1000",
"country_code": "bg", "phone": "+359888123456"
},
"shipping_method_id": "so_…", // a shipping-option id
"shipping_method_data": {}, // optional, stored on the method row
"carrier_metadata": { // optional, opaque per-carrier keys
"office_code": "X1", "office_name": "Center"
},
"payment_provider": "pp_manual",
"save_payment_method": false // optional — subscription carts only;
// same semantics + consent duty as on
// payment-sessions below
} Billing mirrors shipping (own row). carrier_metadata merges into
cart.metadata; keys written by the PREVIOUS prepare call are removed
first (tracked under the reserved _prepared_carrier_keys marker) —
switching carriers can never leak the old carrier's fields into the
order.
- Response (verbatim
PrepareCheckoutResult):
{
"cart_id": "cart_…",
"payment_collection_id": "pc_…",
"client_secret": "pi_…_secret_…", // Stripe only; null for pp_cod/pp_manual AND zero-remainder carts
"provider_id": "pp_manual" // null when zero-remainder skipped the provider session
}- Zero-remainder gift path — when applied gift cards cover the whole
total, the provider session is skipped entirely (
client_secretandprovider_idcome back null) and the cart completes on the gift session alone — no Stripe involved (gift-cards.md). - Errors — 404
cart_not_found|shipping_option_not_found; 409cart_completed; 400validation_failed|invalid_provider(pp_giftcard) |shipping_price_missing|stripe_not_configured. - SDK —
checkout.prepareCheckout(client, cartId, input). - Components — the checkout form's Buy button; carrier/locker pickers
feed
carrier_metadata. - Settings — Stripe credentials (admin integrations), COD fee, gift cards; checkout rules are enforced at the listings and at complete, not here.
PREP_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
"address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
"country_code":"bg","phone":"+359888123456"},
"shipping_method_id":"'"$SO_ID"'",
"carrier_metadata":{"office_code":"X1"},
"payment_provider":"pp_manual"}')
echo "$PREP_JSON" | grep -q '"provider_id":"pp_manual"'
echo "$PREP_JSON" | grep -q '"client_secret":null'
PC_ID=$(echo "$PREP_JSON" | grep -o '"payment_collection_id":"pc_[^"]*"' | cut -d'"' -f4)
test -n "$PC_ID"
# Error contract: pp_giftcard is never a selectable provider.
GC_RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"shipping_address":{"first_name":"Doc","last_name":"Run",
"address_1":"Vitosha 1","city":"Sofia","postal_code":"1000",
"country_code":"bg","phone":"+359888123456"},
"shipping_method_id":"'"$SO_ID"'",
"payment_provider":"pp_giftcard"}')
echo "$GC_RES" | grep -q '"code":"invalid_provider"'POST /api/store/carts/:id/sync-payment-amount — align amounts in place
Aligns the pending provider session with the cart's CURRENT total, in
place when possible — the happy path returns the same client_secret
so <Elements> never remounts (the fix for the "InitiateCheckout fires
four times when I change shipping" bug class). Call after anything that
changes the total while checkout is mounted (quantity change, gift card
applied/removed, shipping switch).
- Auth — anon
x-client-id. - Request —
{provider_id?}(.strict(); empty object fine). Passing a DIFFERENT provider than the pending session's forces rotation to it. - Response matrix (verbatim
src/lib/checkout-orchestration/sync.ts):
| state | response |
|---|---|
| completed cart | {"synced":false,"reason":"cart-completed"} |
| no payment collection | {"synced":false,"reason":"no_payment_collection"} |
| no pending provider session (gift sessions excluded) | {"synced":false,"reason":"no_pending_session"} |
| provider matches, amount current | {"synced":true,"rotated":false,"client_secret":…,"provider_id":…} (no-op) |
| provider matches, amount drifted | in-place update (Stripe paymentIntents.update; plain field for pp_cod/pp_manual) → {"synced":true,"rotated":false,…} — same secret |
| provider mismatch OR update refused (terminal PI) | rotation: old session retired (+ PI voided best-effort), fresh session at the new remainder → {"synced":true,"rotated":true,"client_secret":…,"provider_id":…} |
Rotation retires the old session BEFORE recomputing so session-dependent
totals (the COD fee) settle for the NEW provider; a final resync pass
aligns collection + session + PI. client_secret is null for non-Stripe
sessions.
- Errors — 404
cart_not_found; 400validation_failed|stripe_not_configured. Failures land incheckout_error_logs(stepsync-payment-amount). - SDK —
checkout.syncPaymentAmount(client, cartId, {provider_id}). - Components — checkout totals watcher (debounced), payment-method
switcher (pass the new
provider_id).
# No-drift no-op on the prepared pp_manual cart: same-session, not rotated.
SYNC_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
echo "$SYNC_JSON" | grep -q '"synced":true'
echo "$SYNC_JSON" | grep -q '"rotated":false'
echo "$SYNC_JSON" | grep -q '"provider_id":"pp_manual"'POST /api/store/carts/:id/refresh-payment-if-terminal — dead-PI recovery
A Stripe PaymentIntent can die out-of-band (canceled in the Dashboard,
Stripe's 24h auto-cancel, captured externally) while the local session
stays pending; mounting Elements on the dead client_secret fails with
"PaymentIntent is in a terminal state". This route reconciles against
Stripe's ACTUAL PI and rotates a fresh session/PI only when the intent is
truly dead. Call it reactively — Elements loaderror / page mount for
aged carts — never proactively per render (the proactive variant caused a
production reload loop).
- Auth — anon
x-client-id. No body. - Response (verbatim
src/lib/checkout-orchestration/refresh.ts) — rotated:{"rotated":true,"reason":"pi-terminal"|"pi-missing", "previous_status":…}(terminal =succeeded/canceled/requires_capture, or aresource_missingPI). Not rotated:cart-completed|no-stripe-session(also when the pending session is non-Stripe) |no-pi-id|stripe-not-configured|still-usable(+status) |stripe-error(+error) — any transient Stripe error refuses to rotate (rotating on transient failures was the loop bug). Every rotation writes an audit row (checkout_error_logssteprefresh-payment, coderotated). - Errors — 404
cart_not_found. - SDK —
checkout.refreshPaymentIfTerminal(client, cartId). - Components — Stripe Elements mount error handler.
# pp_manual session ⇒ documented no-op reason (Stripe-specific rotation
# needs STRIPE credentials — proven by tests/store/checkout-orchestration-sync.test.ts).
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
-H "x-client-id: $CLIENT_ID" | grep -q '"reason":"no-stripe-session"'POST /api/store/carts/:id/complete — place the order
The last call of every checkout. Sequence server-side: idempotency check →
CAS lock → validation → checkout-rules completion guard → inventory
reservation (kit-aware) → order creation (rows copied cart→order) →
subscription contracts (carts with plan lines: one contract per plan,
cycle 1 tied to this order, cycle 2 scheduled at the next CHARGE date;
guests are refused with 400 customer_required) → payment authorization
LAST (gift tender redeemed atomically first; real Stripe authorize for
pp_stripe; best-effort stub for pp_cod/pp_manual) → order.placed
(+ subscription.created per contract) on the durable bus. Any failure
before authorize compensates fully (order deleted, contracts deleted,
inventory released, gift tender reversed, cart unlocked) — the cart stays
open and retryable. Note: subscription carts auto-save the card at the
payment-session step (see save_payment_method above) — by complete time
the mandate already exists.
- Auth — anon
x-client-id(guest checkout). Store settingaccounts_mode='required'→ guest carts (no attached customer) get 403account_required;disabled/optionalleave guests untouched. - Request —
POST, empty body. - Response —
200 {"type":"order","order":{…}}— the order withsummaryand flatteneditems. Idempotent: re-calling returns the SAME order; concurrent completes are serialized by the CAS lock (the loser returns the winner's order or 409cart_locked).Contract note (code wins over store-api.md): the documented
{type:"cart", cart, error}failure union is never returned — failures throw the standard error envelope. - Errors —
- 400 validation:
cart_email_required|cart_empty|shipping_address_required|shipping_method_required(only when a linerequires_shipping— digital-only carts, e.g. digital gift cards, skip it) |payment_collection_required|payment_session_required|insufficient_inventory|customer_required(plan lines on a guest cart — subscribing needs an account; normally already refused at the payment-session step). - 400
checkout_method_hidden— the checkout-rules security boundary: every live payment session's provider (except internalpp_giftcard) and every chosen shipping option is re-validated against the live rules with the full cart context. A stale session that picked a method before a rule started matching, or a hostile client that skipped the filtered listings, is rejected here and the rejection is recorded incheckout_error_logs(stepcomplete). - 402 payment family:
requires_action(3DS —details.client_secretcarries the intent to confirm) |payment_not_authorized|payment_not_initiated(Stripe session without a PI — re-initiate) |payment_incomplete(gift tender no longer covers a session-less total) |gift_card_insufficient_balance(lost double-spend race) |gift_card_not_redeemable. - 403
account_required; 409cart_locked.
- 400 validation:
- SDK —
carts.completeCart(client, cartId)(module@barter/storefront/api/carts). - Components — Buy button (orchestrated path), order-confirmation page.
- Settings — checkout rules;
accounts_mode; COD fee (timing: the fee exists only while a livepp_codsession does — it appears on the cart at prepare, ridescart.total, and is carried onto the order viaorder_summaries.totals, where waybill COD amounts read it); gift-card tender (zero-remainder carts complete on the gift session alone).
# Complete the prepared pp_manual cart → a real order, no Stripe env needed.
ORDER_JSON=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
echo "$ORDER_JSON" | grep -q '"type":"order"'
ORDER_ID=$(echo "$ORDER_JSON" | grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
test -n "$ORDER_ID"
# Idempotency: completing again returns the SAME order.
ORDER_ID2=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/complete" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
| grep -o '"id":"order_[^"]*"' | head -1 | cut -d'"' -f4)
test "$ORDER_ID" = "$ORDER_ID2"
# Post-completion contracts: mutations 409, sync/refresh report the reason.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
"$BASE/api/store/carts/$CART_ID/line-items" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"variant_id":"variant_01tst000000000000000003","quantity":1}')
test "$STATUS" = 409
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/sync-payment-amount" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
| grep -q '"reason":"cart-completed"'
curl -sf -X POST "$BASE/api/store/carts/$CART_ID/refresh-payment-if-terminal" \
-H "x-client-id: $CLIENT_ID" | grep -q '"reason":"cart-completed"'Manual path — collections + sessions (Medusa-style)
POST /api/store/payment-collections
- Purpose — ensure the cart's payment collection (ONE per cart, idempotent; the amount is refreshed to the CURRENT decorated total on every call).
- Auth — anon
x-client-id. - Request —
{cart_id}.Contract note (code wins over store-api.md): the contract's
provider_id/datafields are ignored — the provider is chosen when initiating the session. - Response —
201 {payment_collection}when created,200when the existing one was refreshed.{id, amount, currency_code, status:"not_paid", payment_sessions:[…]}. The moment a collection exists, applied gift-card tender is composed as an internalpp_giftcardsession. - Errors — 404
cart_not_found; 400validation_failed. - SDK —
checkout.createPaymentCollection(client, {cart_id}).
POST /api/store/payment-collections/:id/payment-sessions
- Purpose — mint (or repair) the provider session — idempotent per provider. For Stripe the PaymentIntent is minted FIRST (idempotency key = session id) so a Stripe session row can never exist without its intent; amount drift syncs the PI in place; terminal PIs self-heal by rotation.
- Auth — anon
x-client-id. - Request —
{provider_id, data?, save_payment_method?}. Keys the server owns (payment_intent_id,client_secret,status,stripe_customer_id,setup_future_usage) are stripped fromdata— they cannot be forged from the client. save_payment_method— saves the card for future off-session renewal charges: the server resolves the CART'S customer (never a client-supplied id), ensures a Stripe Customer for them, and mints the PaymentIntent withsetup_future_usage: "off_session". Automatic for subscription carts: when the cart carries plan lines the server applies the mandate even without the flag (a subscription cannot renew without it — the server owns the decision). Requires a logged-in customer — a guest cart is a 400customer_required(subscribing needs an account), raised HERE, before any payment. Consent: the mandate moment — the storefront MUST render the saved-card consent text with the plan selection / next to the payment element (e.g. "Your card will be saved for future subscription charges"). Re-initiating an existing session with the flag (or after a plan line appears) upgrades the live intent in place (sameclient_secret). No-op on non-card providers (COD/manual subscriptions renew offline). Never set the flag on ordinary checkouts.- Response —
201 {payment_session}when created,200when the existing one was returned/repaired:{id, provider_id, amount, currency_code, status:"pending", authorized_at:null, data}— for Stripe,datacarriespayment_intent_id+client_secret(mount Elements with it); withsave_payment_methodit also carriessetup_future_usage: "off_session"+stripe_customer_id. The sessionamountiscollection.amount − gift_card_total— the remainder. - Errors — 404
payment_collection_not_found; 400invalid_provider(pp_giftcard) |stripe_not_configured|customer_required|validation_failed. - SDK —
checkout.initiatePaymentSession(client, pcId, {provider_id, save_payment_method?}).
POST /api/store/carts/:id/shipping-methods
- Purpose — set the cart's shipping method manually (single-method model: the previous method rows are replaced).
- Auth — anon
x-client-id. - Request —
{option_id, data?}(.strict()). - Response —
200 {cart}(decorated;shipping_totalnow non-zero). - Errors — 404
cart_not_found|shipping_option_not_found; 409cart_completed; 400shipping_price_missing|validation_failed. - SDK —
checkout.addShippingMethod(client, cartId, {option_id}).
# The whole manual path, executable: cart → method → collection → session → order.
CART2_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"email":"checkout-doc-manual-'"$RUN"'@example.test",
"items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}],
"shipping_address":{"first_name":"Doc","last_name":"Manual",
"address_1":"Vitosha 2","city":"Sofia","postal_code":"1000",
"country_code":"bg","phone":"+359888123457"}}')
CART2_ID=$(echo "$CART2_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/shipping-methods" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"option_id":"'"$SO_ID"'"}' \
| grep -q '"shipping_option_id":"'"$SO_ID"'"'
# Fresh cart, no collection yet → sync reports why it can't sync.
curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/sync-payment-amount" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
| grep -q '"reason":"no_payment_collection"'
PC2_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"cart_id":"'"$CART2_ID"'"}')
echo "$PC2_JSON" | grep -q '"status":"not_paid"'
PC2_ID=$(echo "$PC2_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
SES_JSON=$(curl -sf -X POST \
"$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"provider_id":"pp_manual"}')
echo "$SES_JSON" | grep -q '"provider_id":"pp_manual"'
echo "$SES_JSON" | grep -q '"status":"pending"'
# Error contract: the internal gift tender is not initiable.
GCS_RES=$(curl -s -X POST \
"$BASE/api/store/payment-collections/$PC2_ID/payment-sessions" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"provider_id":"pp_giftcard"}')
echo "$GCS_RES" | grep -q '"code":"invalid_provider"'
curl -sf -X POST "$BASE/api/store/carts/$CART2_ID/complete" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}' \
| grep -q '"type":"order"'# save_payment_method on a GUEST cart is refused — subscribing needs an
# account (provider-independent: fires before any Stripe call).
CART3_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"email":"checkout-doc-guest-sub-'"$RUN"'@example.test",
"items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
CART3_ID=$(echo "$CART3_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
PC3_JSON=$(curl -sf -X POST "$BASE/api/store/payment-collections" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"cart_id":"'"$CART3_ID"'"}')
PC3_ID=$(echo "$PC3_JSON" | grep -o '"id":"pc_[^"]*"' | head -1 | cut -d'"' -f4)
curl -s -X POST "$BASE/api/store/payment-collections/$PC3_ID/payment-sessions" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"provider_id":"pp_manual","save_payment_method":true}' \
| grep -q '"code":"customer_required"'Stripe specifics (needs STRIPE credentials — not executable here)
# doc-noexec — requires the store's Stripe integration (admin-configured
# credentials); the mocked equivalents run in
# tests/store/checkout-orchestration.test.ts + checkout-stripe.test.ts.
PREP=$(curl -sf -X POST "$BASE/api/store/carts/$CART_ID/prepare-checkout" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
-d '{"shipping_address":{…},"shipping_method_id":"so_…","payment_provider":"pp_stripe"}')
# → {"cart_id":…,"payment_collection_id":"pc_…","client_secret":"pi_…_secret_…","provider_id":"pp_stripe"}
# Storefront: stripe.confirmPayment({clientSecret}) → POST …/complete.
# 3DS never-returned / redirect flows: the payment_intent.succeeded webhook
# (POST /api/webhooks/payment/complete-on-success, configured in Stripe)
# completes the cart server-side through the SAME complete flow.Sync/refresh behavior with Stripe follows the matrices above: in-place
paymentIntents.update keeps the secret stable; provider mismatch or a
terminal PI rotates (old PI voided best-effort); refreshPaymentIfTerminal
rotates only on succeeded/canceled/requires_capture/missing.
Admin-config-dependent contracts (documented, proven by the suite)
checkout_method_hidden(400) — requires an admin checkout rule; exercised bytests/store/checkout-rules.test.ts.account_required(403) — requiresaccounts_mode='required'on the store; exercised bytests/store/customer-accounts-policy.test.ts.- COD fee — requires the admin COD integration
(
{config:{fee_amount, fee_label}}); exercised bytests/store/checkout-cod-fee.test.tsandtests/store/checkout-orchestration.test.ts(fee-inclusive session on prepare withpp_cod).
Cleanup / accretion note
This page creates two carts and completes two pp_manual orders on the
shared dev tenant — the same inert accretion the checkout test suites
produce (no store-facing delete exists for either; suites always create
their own carts/orders and never re-read foreign ones).