Wallet express checkout

Mercentia’s wallet express checkout renders an Apple Pay / Google Pay / Stripe Link button anywhere the shopper is ready to buy. It is the platform’s “Shop Pay-class” path — one tap, no form, native wallet UI, ~2× the conversion rate of standard card checkout.

This is distinct from POST /api/v1/checkout/express (the saved-card endpoint, documented in express-checkout.md). The wallet path is anonymous — the buyer doesn’t need a Mercentia customer record. The wallet sheet supplies email, shipping, and a single-use Stripe PaymentMethod inline.

Where it renders

Surface Component Endpoint Multi-line? Inline 3DS?
Product page apps/storefront/components/PdpExpressBuy.vue POST /api/v1/checkout/express/wallet No (one product × qty) No (falls back)
Cart page apps/storefront/components/CartExpressBuy.vue POST /api/v1/checkout/express/wallet/cart Yes (up to 50 lines) Yes (stripe.handleNextAction)
Checkout page Inline in apps/storefront/pages/checkout.vue POST /api/v1/checkout/express/wallet No (uses the canonical Cart at the end) No

Each component renders only when paymentRequest.canMakePayment() returns truthy on the shopper’s device — no fake buttons, no broken integrations on browsers that can’t fulfil them.

Architecture

┌────────────────┐          ┌──────────────────────┐         ┌─────────┐
│ Storefront     │  fetch   │ Gateway              │  charge │ Stripe  │
│ (CartExpressBuy│─────────▶│ POST /checkout/      │────────▶│         │
│  .vue)         │          │   express/wallet/    │         │         │
│                │          │   cart               │         │         │
│  Stripe.js PRB │          │                      │         │         │
└────────┬───────┘          │  walletCartCheckout()│         │         │
         │                  │  - resolveLineTotal  │         │         │
         │                  │  - INSERT order      │         │         │
         │                  │  - INSERT N items    │         │         │
         │                  │  - paymentIntent     │         │         │
         │                  └──────────┬───────────┘         └─────────┘
         │                             │
         │                  outcome    │
         │◀────────────────────────────┘
         │
         │ if requires_action:
         │   stripe.handleNextAction({clientSecret})
         │   → wallet web-view shows 3DS challenge
         │   → on success: navigate to /order-confirmation
         │
         │ if completed:
         │   navigate to /order-confirmation

The cart-level endpoint — POST /api/v1/checkout/express/wallet/cart

Request

{
  "lines": [
    { "productId": "uuid", "variantId": "uuid|null", "quantity": 2 },
    { "productId": "uuid", "variantId": null, "quantity": 1 },
  ],
  "paymentMethodId": "pm_xxx", // From ev.paymentMethod.id (Stripe.js)
  "email": "[email protected]", // From ev.payerEmail
  "shipping": {
    "firstName": "Ada",
    "lastName": "Lovelace",
    "address1": "10 Downing St",
    "address2": null,
    "city": "London",
    "state": null,
    "postcode": "SW1A 2AA",
    "country": "GB",
    "phone": null,
  },
  "requestId": "uuid", // Client-generated; idempotency seed
  "sessionId": "...", // Optional; falls back to sf_session_id cookie
}

Limits

  • lines.length ∈ [1, 50]. Carts bigger than 50 distinct lines return kind: 'fallback', reason: 'cart_too_large' — Apple Pay’s wallet sheet doesn’t render readably past about 8 items anyway.
  • quantity per line ∈ [1, 100].
  • All lines must be the same currency. Mixed-currency carts return kind: 'fallback', reason: 'mixed_currency_cart' — Stripe PaymentIntents are single-currency.

Authoritative pricing

The client-supplied amount is a hint only. For every line, the server re-runs resolveLineTotal(storeId, productId, variantId, quantity) and sums to the canonical cart total. If a product price changed mid-tap, the server charges the new price — the wallet sheet’s displayed total can briefly drift, but the merchant is never under-charged.

Idempotency

The Stripe idempotency key is derived from a sha256-able fingerprint of the sorted line product ids + the client’s requestId. Two taps of the same cart within seconds collapse into one PaymentIntent. If the merchant is on Stripe Connect, the request is scoped to the connected account.

Response shape

type Outcome =
  | { kind: 'completed'; orderId; orderNumber; chargedAmount: number }
  | { kind: 'requires_action'; orderId; clientSecret: string }
  | { kind: 'failed'; orderId?; reason: string }
  | { kind: 'fallback'; reason: string };

completedHTTP 200. Order is paid; storefront navigates to /order-confirmation. requires_actionHTTP 200. 3DS challenge required. Storefront calls stripe.handleNextAction({ clientSecret }). The wallet sheet’s inline web-view renders the bank’s challenge; on success the storefront polls or directly navigates. failedHTTP 402. Order row is marked failed; storefront tells the wallet ev.complete('fail') and shows a “try standard checkout” hint. fallbackHTTP 200. Soft failure (Stripe not connected, mixed currency, etc.). The wallet is not invoked.

Why we deprecated the pre-2026 placeholder

Before this release, a 3DS challenge on a wallet tap surfaced as a hard failure — the comment in PdpExpressBuy.vue was:

3DS challenge inside a wallet sheet is unreliable — tell the wallet we couldn’t complete and surface a soft fall-back hint to the shopper.

Apple’s and Google’s 2024-25 wallet SDKs now render bank challenges in an inline web-view controlled by Stripe.js. Calling stripe.handleNextAction({ clientSecret }) after the wallet has handed back the PaymentMethod is the supported path. Shop Pay (Shopify) uses the same flow. Our cart-level component adopts it; PDP keeps the older fallback because PDP latency budgets are stricter.

Per-tap data flow

  1. Component mount — fetch GET /api/v1/checkout/express/wallet/config to get { publishableKey, stripeAccount? }.
  2. Stripe.js bootStripe(pk, { stripeAccount }) and stripe.paymentRequest({ ... }).
  3. Wallet probepaymentRequest.canMakePayment(). If falsy → render nothing.
  4. Mount PRBelements.create('paymentRequestButton', { paymentRequest }).
  5. Tap — wallet sheet opens. Shopper authorises (Face ID / fingerprint / device PIN).
  6. paymentmethod event firesev.paymentMethod.id + ev.payerEmail + ev.shippingAddress arrive.
  7. POST to gateway — body above. Server creates order, items, PaymentIntent.
  8. Outcome — completed (navigate), requires_action (handleNextAction → navigate), failed (show error).

Operations

  • Apple Pay domain verification — required before the PRB renders for Apple Pay. Mint the association file in Stripe Dashboard → Settings → Payments → Apple Pay, paste into APPLE_PAY_DOMAIN_ASSOCIATION env var. Storefront serves it at /.well-known/apple-developer-merchantid-domain-association. See Dashboard → Settings → Express checkout for the full walkthrough.
  • Stripe Link — automatically available when the Stripe installation has card in enabled_methods and the publishable key is from a Link-enrolled account. No domain verification.
  • Google Pay — no domain verification when used via Stripe (Stripe signs the request server-side).

Testing

Unit-level coverage lives in:

  • apps/gateway/src/routes/express-checkout.wallet.test.ts — single-product (PDP) wallet endpoint.
  • apps/gateway/src/services/payments/methodRouter.test.ts — eligibility filter (UPI never to US, etc.).

The cart-level endpoint inherits resolution + idempotency behaviour from the PDP test suite plus the line-fan-out logic in walletCartCheckout(). Full-fixture integration tests (Stripe mock + Postgres) land in apps/gateway/tests/integration in a follow-up.

Migration notes for theme authors

Themes do not need to wire any wallet code themselves — the platform mounts PdpExpressBuy and CartExpressBuy in the page wrappers, so all 21+ themes inherit the wallet path automatically. A theme that deliberately wants to suppress wallets can override the page wrapper and omit the slot — but the default for every theme is “wallet enabled”.

Related