feat(#458): S7 Stripe payment + latching surge + saved card branch#478
Merged
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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é).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #458
Summary
Wires the PWA
/checkoutStripe 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
recordCheckoutContact— capture PII for next visit's prefill (NEW mutation, closes the loop the S6decide-prefill.tscomment flagged as « S7 scope »).recordConsentAtCheckout— stamp CGV hash (ADR 0007).recaptureQuoteAtPayment→decideLatchingOutcome: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 feecreateOrderFromCart→orderIddecidePaymentBranch(fiche):saved-card→payWithSavedCard(clone + direct charge) → redirect/c/[orderId]new-card→createPaymentIntent→stripe.confirmPayment({elements, clientSecret, confirmParams: {return_url}})(3DS / Apple Pay handled by SDK)decideRetryAction(attempts):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
apps/web/src/lib/stripe-payment/decidePaymentBranch/decideLatchingOutcome/decideRetryAction/derivePricingSnapshot+captureExceptionshimstripe-payment/stripe-payment-lazy.tsxnext/dynamicimportstripe-payment/stripe-elements-provider.tsxloadStripe(pk, { stripeAccount })+ deferred-intent<Elements>saved-card-panel.tsx,new-card-panel.tsx(<PaymentElement>)stripe-payment-section.tsxlib/customer/checkoutContact.tsrecordCheckoutContactmutation (self-scoped, cross-tenant fuzz, sanctioned seampatchCustomerCheckoutContact)lib/tenants/payment.tsforCheckoutcustomer-scoped query →{stripeAccountId, stripeStatus}for Elements initKnown 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
saveCardaction expects but V1 doesn't yet collect. The CONSUMPTION side (saved-card tile pre-selected) is fully wired through<SavedCardPanel>+payWithSavedCard. Detailed rationale innew-card-panel.tsxheader. Follow-up slice: addcreatePlatformSetupIntent+ a second platform-context Elements instance branching the new-card flow on the « save » intent.Guardrails (project rules)
customerMutation/customerQuery) — no rawctx.dbin business code.getAuthUserId— identity flows throughgetCurrentActor(ADR 0011).checkoutContact,tenants.payment.forCheckout).ctx.db.patchsites forcustomersextended throughpatchCustomerCheckoutContact(sanctioned seam inlib/tenancy/customerFiche.ts).Plan de test E2E
Scénario 1 — Paiement saved card cross-resto (US 45)
savedPaymentMethodIdnon-null (peut être seedé manuellement viasaveCardaction, OR avoir payé sur un autre resto KB)test-t1avecstripeAccountId+stripeStatus: readysavedPaymentMethodId: pm_test_visa_4242+stripeCustomerId: cus_test_…(Stripe Dashboard sandbox)noChannelPossible=true) — sinon Payer ouvre la modal pushhttps://test-t1.kitchen-boost.fr/checkout/c/<orderId>/c/[orderId]rendu (page tracking)Scénario 2 — Paiement nouvelle carte avec 3DS (US 46 + US 49)
savedPaymentMethodId: undefinedhttps://test-t1.kitchen-boost.fr/checkout4000 0027 6000 3184, expiration12/34, CVC123/c/<orderId>m.stripe.comrequests apparaissent UNIQUEMENT au mount de/checkout, JAMAIS sur/menu)/c/<orderId>payment_intent.succeededwebhook reçuScénario 3 — Latching anti-surge (US 47)
recaptureQuoteAtPaymentà retourner{deliverable: true, fee: 590, eta: 1700, quoteId: 'qt_surge'}(e.g. mocklib/uberDirect/quote.requestQuoteà retourner 590 cts pour la durée du test, OU patch directement le row Uber côté test).https://test-t1.kitchen-boost.fr/checkout<LatchingConfirmModal>qui s'ouvre avec « Le tarif livraison est passé de 3,50 € à 5,90 € »🤖 Generated with Claude Code