Skip to content

feat(#458): S7 Stripe payment + latching surge + saved card branch#478

Merged
alexpmichelet merged 3 commits into
mainfrom
agent/458
Jun 10, 2026
Merged

feat(#458): S7 Stripe payment + latching surge + saved card branch#478
alexpmichelet merged 3 commits into
mainfrom
agent/458

Conversation

@alexpmichelet

Copy link
Copy Markdown
Contributor

Closes #458

Summary

Wires the PWA /checkout Stripe payment flow on top of the merged backend (2.5-B paymentIntent + 2.5-D savedCard + 2.6-B latching). Bridges the gap between S6 (push-enrollment gate) and /c/[orderId] (tracking).

At click-Payer the form runs

  1. recordCheckoutContact — capture PII for next visit's prefill (NEW mutation, closes the loop the S6 decide-prefill.ts comment flagged as « S7 scope »).
  2. recordConsentAtCheckout — stamp CGV hash (ADR 0007).
  3. recaptureQuoteAtPaymentdecideLatchingOutcome :
    • abort → toast with the verdict's reason (hors_horaire / surge / hors_zone)
    • confirm-surge → blocking <LatchingConfirmModal> (Esc / click-outside prevented)
    • ok → continue with the FRESH fee
  4. createOrderFromCartorderId
  5. Branch on decidePaymentBranch(fiche) :
    • saved-cardpayWithSavedCard (clone + direct charge) → redirect /c/[orderId]
    • new-cardcreatePaymentIntentstripe.confirmPayment({elements, clientSecret, confirmParams: {return_url}}) (3DS / Apple Pay handled by SDK)
  6. On failure → decideRetryAction(attempts) :
    • ≤2 attempts → inline retry banner
    • ≥3 attempts → fatal toast + captureException (Sentry shim)

Lazy load (acceptance criterion satisfied)

The Stripe SDK is loaded via next/dynamic({ ssr: false }) (<StripePaymentLazy>), keeping @stripe/* out of every bundle except /checkout. The S6 gate-disabled branch (modal only) also never loads the SDK.

Architecture

Layer File Role
Pure decisions apps/web/src/lib/stripe-payment/ decidePaymentBranch / decideLatchingOutcome / decideRetryAction / derivePricingSnapshot + captureException shim
Lazy boundary stripe-payment/stripe-payment-lazy.tsx next/dynamic import
Stripe provider stripe-payment/stripe-elements-provider.tsx loadStripe(pk, { stripeAccount }) + deferred-intent <Elements>
Branches saved-card-panel.tsx, new-card-panel.tsx (<PaymentElement>) Per-branch UI
Orchestrator stripe-payment-section.tsx The 6-step click-Payer flow above
Backend lib/customer/checkoutContact.ts NEW recordCheckoutContact mutation (self-scoped, cross-tenant fuzz, sanctioned seam patchCustomerCheckoutContact)
Backend lib/tenants/payment.ts NEW forCheckout customer-scoped query → {stripeAccountId, stripeStatus} for Elements init

Known scope gap (documented)

The « Sauvegarder ma carte » checkbox mentioned in the issue description is deliberately NOT rendered V1 — cross-resto save (which is the whole value of the saved-card UX) requires a platform-level SetupIntent flow the existing backend saveCard action expects but V1 doesn't yet collect. The CONSUMPTION side (saved-card tile pre-selected) is fully wired through <SavedCardPanel> + payWithSavedCard. Detailed rationale in new-card-panel.tsx header. Follow-up slice: add createPlatformSetupIntent + a second platform-context Elements instance branching the new-card flow on the « save » intent.

Guardrails (project rules)

  • All new mutations/queries via sanctioned wrappers (customerMutation / customerQuery) — no raw ctx.db in business code.
  • No getAuthUserId — identity flows through getCurrentActor (ADR 0011).
  • Cross-tenant fuzz on every new tenant-scoped surface (checkoutContact, tenants.payment.forCheckout).
  • All sensitive ctx.db.patch sites for customers extended through patchCustomerCheckoutContact (sanctioned seam in lib/tenancy/customerFiche.ts).
  • No secrets / no template structure changes / crypto stays in Node routes.

Plan de test E2E

Scénario 1 — Paiement saved card cross-resto (US 45)

  • Acteur : Sophie sur iPhone Safari, cookie session valide, fiche customer avec savedPaymentMethodId non-null (peut être seedé manuellement via saveCard action, OR avoir payé sur un autre resto KB)
  • Pré-requis :
    • Tenant test-t1 avec stripeAccountId + stripeStatus: ready
    • Customer fiche avec savedPaymentMethodId: pm_test_visa_4242 + stripeCustomerId: cus_test_… (Stripe Dashboard sandbox)
    • Push enrollment active (Wallet OU webPush OU noChannelPossible=true) — sinon Payer ouvre la modal push
    • Cart avec ≥1 item, address-first validé
  • URL de départ : https://test-t1.kitchen-boost.fr/checkout
  • Étapes :
    1. Vérifier que le tile « Payer avec ma carte enregistrée » apparaît pré-sélectionné (et qu'il y a un lien « Utiliser une autre carte »)
    2. Taper « Payer XX € »
    3. Observer le redirect automatique vers /c/<orderId>
  • Attendu :
    • Tile saved card visible avant clic
    • Aucun formulaire carte Stripe affiché
    • Après clic : /c/[orderId] rendu (page tracking)
    • Stripe Dashboard sandbox montre la commande payée + 240 cts application_fee
  • Couvre : US 45 (saved card), US 50 (redirect tracking), slice S7 — Stripe payment + latching surge + Apple Pay verification #458

Scénario 2 — Paiement nouvelle carte avec 3DS (US 46 + US 49)

  • Acteur : Sophie sur iPhone Safari, fiche SANS saved card
  • Pré-requis : idem scénario 1 mais savedPaymentMethodId: undefined
  • URL de départ : https://test-t1.kitchen-boost.fr/checkout
  • Étapes :
    1. Vérifier que le Payment Element Stripe s'affiche avec le bouton Apple Pay visible (haut de la modal Stripe)
    2. Saisir la carte test 3DS Stripe 4000 0027 6000 3184, expiration 12/34, CVC 123
    3. Taper « Payer XX € »
    4. Sur la sheet 3DS qui s'ouvre, taper « Complete authentication »
    5. Observer le redirect automatique vers /c/<orderId>
  • Attendu :
    • Payment Element rendu correctement (cf. DevTools Network : m.stripe.com requests apparaissent UNIQUEMENT au mount de /checkout, JAMAIS sur /menu)
    • Apple Pay button rendu en haut iOS
    • Sheet 3DS Stripe ouverte sans code custom
    • Post-3DS : redirect natif vers /c/<orderId>
    • Stripe Dashboard : payment_intent.succeeded webhook reçu
  • Couvre : US 46 (Payment Element + Apple Pay), US 49 (3DS auto), AC « 3DS handle par SDK sans code custom », AC « Bundle Stripe lazy-loaded »

Scénario 3 — Latching anti-surge (US 47)

  • Acteur : Sophie sur Android Chrome
  • Pré-requis :
    • Address validée → verdict cached avec fee=350 cts
    • Patch backend manuel : forcer recaptureQuoteAtPayment à retourner {deliverable: true, fee: 590, eta: 1700, quoteId: 'qt_surge'} (e.g. mock lib/uberDirect/quote.requestQuote à retourner 590 cts pour la durée du test, OU patch directement le row Uber côté test).
  • URL de départ : https://test-t1.kitchen-boost.fr/checkout
  • Étapes :
    1. Taper « Payer XX € »
    2. Observer la modal <LatchingConfirmModal> qui s'ouvre avec « Le tarif livraison est passé de 3,50 € à 5,90 € »
    3. Vérifier qu'Esc et clic en dehors NE FERMENT PAS la modal
    4. Taper « Oui, payer 5,90 € de livraison »
    5. Observer que le flow continue (Payment Element ou redirect saved-card)
  • Attendu :
    • Modal bloquante affichée
    • Esc / click-outside ne ferment pas
    • Sur « Oui » → flow paiement reprend avec FRESH fee dans le pricingSnapshot
    • Sur « Non » → retour à idle, AUCUNE commande créée
  • Couvre : US 47 (latching surge), AC « Latching : surge artificiel → modal confirm bloquant apparaît »

🤖 Generated with Claude Code

alexpmichelet and others added 3 commits June 10, 2026 21:24
…ordCheckoutContact

Red phase for PWA-S7 (Stripe payment frontend + latching anti-surge):
- 4 pure decisions in `apps/web/src/lib/stripe-payment/`:
  - decidePaymentBranch (saved-card vs new-card, US 45/46)
  - decideLatchingOutcome (ok / confirm-surge / abort, US 47)
  - decideRetryAction (inline-retry / fatal-toast, US 48)
  - derivePricingSnapshot (subtotal/deliveryFee/total from fresh fee)
- recordCheckoutContact backend mutation (firstName/email/phone capture
  at checkout — closes the prefill loop the comment in
  apps/web/src/lib/checkout-gate/decide-prefill.ts flagged as « S7 scope »).

Adds @stripe/stripe-js + @stripe/react-stripe-js to apps/web for the
Payment Element + Apple Pay/Google Pay (acceptance criterion #458 lazy
load on /checkout).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the PWA `/checkout` Stripe payment flow on top of the merged
backend (2.5-B paymentIntent + 2.5-D savedCard + 2.6-B latching). At
click-Payer the form runs:

  recordCheckoutContact (prefill loop)
  recordConsentAtCheckout (CGV)
  recaptureQuoteAtPayment (latching) → decideLatchingOutcome
    ├─ abort → toast
    ├─ confirm-surge → <LatchingConfirmModal> blocking
    └─ ok → continue with FRESH fee
  createOrderFromCart → orderId
  decidePaymentBranch(fiche) →
    ├─ saved-card → payWithSavedCard → redirect /c/[orderId]
    └─ new-card → createPaymentIntent → stripe.confirmPayment (3DS auto)
  on failure: decideRetryAction → inline-retry (≤2) | fatal-toast + Sentry shim (≥3)

The Stripe SDK is lazy-loaded via `next/dynamic` (`<StripePaymentLazy>`),
keeping `@stripe/*` out of every bundle except `/checkout` post-mount
(acceptance: « stripe.js n'apparaît pas sur /menu »). The gate-disabled
S6 branch never loads the SDK either.

Frontend additions (apps/web):
- 4 pure decisions + sentry-shim in `src/lib/stripe-payment/`
- `src/components/checkout/stripe-payment/`: lazy wrapper, Elements
  provider (deferred-intent mode + connected-account stripeAccount),
  SavedCardPanel, NewCardPanel, LatchingConfirmModal, orchestrator
- `<CheckoutForm>` switches between S6 PayButton (gate disabled) and
  `<StripePaymentLazy>` (gate active)

Backend additions:
- `lib/customer/checkoutContact.ts` — `recordCheckoutContact` mutation
  closes the prefill loop the S6 `decide-prefill.ts` comment flagged
  (« S7 scope »). Self-scoped, cross-tenant fuzz, sanctioned seam
  (`patchCustomerCheckoutContact` in `lib/tenancy/customerFiche.ts`).
- `lib/tenants/payment.ts` — `forCheckout` customer-scoped query
  exposes `{ stripeAccountId, stripeStatus }` so the Elements provider
  initialises on the right connected account.

Known scope gap documented in `<NewCardPanel>` header: the « Sauvegarder
ma carte » checkbox is NOT rendered V1. Cross-resto save requires a
platform-level SetupIntent flow (the backend `saveCard` action expects
a platform PM), out of S7 scope — a follow-up slice will add it. The
CONSUMPTION side (saved-card tile pre-selected) is fully wired.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`<StripePaymentSection>` imports `useDeliveryMode` (which returns the
typed mode) and never references the standalone `DeliveryMode` type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
kitchen-boost-repo-admin Ready Ready Preview, Comment Jun 10, 2026 7:52pm
kitchen-boost-repo-web Ready Ready Preview, Comment Jun 10, 2026 7:52pm

@alexpmichelet
alexpmichelet merged commit dfd9e4f into main Jun 10, 2026
4 checks passed
@alexpmichelet
alexpmichelet deleted the agent/458 branch June 10, 2026 19:52
alexpmichelet added a commit that referenced this pull request Jun 10, 2026
3 scénarios E2E filtrés/inscrits depuis la PR #478 :
- PAY.1 saved card cross-resto pre-selected → tracking
- PAY.2 nouvelle carte 3DS + Apple Pay button + lazy-load vérifié
- PAY.3 latching anti-surge modal bloquante + fresh fee

Aligné format strict, domaine .com aligné, scope gap deferred V1
documenté (checkbox 'Sauvegarder ma carte' new-card branch).
alexpmichelet added a commit that referenced this pull request Jun 10, 2026
Drainage final de la chain Stripe/tracking/A2HS iOS/recognition :

- REC : 2 scénarios (branche banner happy path + branche silent), avec
  baseline branche `none` documentée en préambule (3ᵉ scénario PR
  fusionné car trivial + déjà couvert par 7 unit tests vitest).
- Statut global checklist : 🟢 PRÊT À TESTER — 16 PRs PWA Client mergées
  au total, 11 groupes peuplés, 34 scénarios E2E sur devices physiques.
- Bilan drain 06-10 ajouté (4 PRs séquentielles #478-#481 + agrégations
  notables + scope gap PAY documenté).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

S7 — Stripe payment + latching surge + Apple Pay verification

1 participant