diff --git a/apps/web/package.json b/apps/web/package.json
index afe5e8c2..f38e4c2f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -21,6 +21,8 @@
"@googlemaps/js-api-loader": "^2.1.0",
"@hookform/resolvers": "^5.2.2",
"@packages/backend": "workspace:*",
+ "@stripe/react-stripe-js": "^6.6.0",
+ "@stripe/stripe-js": "^9.8.0",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
diff --git a/apps/web/src/components/checkout/checkout-form.tsx b/apps/web/src/components/checkout/checkout-form.tsx
index a67afda3..0f982e46 100644
--- a/apps/web/src/components/checkout/checkout-form.tsx
+++ b/apps/web/src/components/checkout/checkout-form.tsx
@@ -1,35 +1,35 @@
"use client";
/**
- * PWA-S6 (#454) — `` : the body of `/checkout` (US 44, PRD
- * §10 PWA Client + decisions-log Q3/Q8).
+ * PWA-S6 / S7 (#454 / #458) — `` : the body of `/checkout`
+ * (US 44-49, PRD §10 PWA Client + decisions-log Q3/Q6/Q7/Q8).
*
- * Owns the React IO around the pure decisions of `lib/checkout-gate`:
+ * Owns the React IO around the pure decisions of `lib/checkout-gate`
+ * (S6) and `lib/stripe-payment` (S7):
* - `usePreloadedQuery(preloadedCustomer)` → reactive Convex sub on
* `customers.pushEnrollment` so a Wallet install / Web Push subscribe
- * happening in another tab / from the address-first soft prompt 5 min
- * earlier unlocks the "Payer X €" button without ANY reload (acceptance
- * criterion #454 « install Wallet pendant client sur page → bouton se
- * débloque sans reload »). The RSC parent (`app/checkout/page.tsx`)
- * seeds this sub via `preloadQuery` so the gate is evaluated on the SSR
- * pass too (no FOUC, no "active → disabled" flash on mount).
+ * happening in another tab unlocks the "Payer X €" CTA without ANY
+ * reload (S6 #454).
* - `useCart()` → reads the localStorage-backed cart (S5). Empty cart
- * on /checkout has no business case → redirect to /panier where the
- * « Ton panier est vide » empty state lives (decided by
- * `decideCheckoutRedirect`).
- * - Form ` ` controls pre-filled via `decideCheckoutPrefill`.
- * - "Payer X €" CTA is the pure decision `decidePaymentGate` reading the
- * live `pushEnrollment` snapshot. S6 stub : click logs to console and
- * surfaces a "Coming next: modal push enrollment (S6a)" placeholder —
- * the actual modal lives in #455 / #456 / #457 and the Stripe payment
- * in #458 / S7.
+ * on /checkout redirects back to /panier.
+ * - Form ` ` controls pre-filled via `decideCheckoutPrefill`. The
+ * refs let the lazy-loaded `` snapshot the live
+ * values at click-Payer (so a user editing post-render is reflected).
+ * - Two payment surfaces depending on the gate state:
+ * - gate DISABLED → render the « Payer » CTA that opens
+ * `` (S6a-c) — no Stripe code is loaded.
+ * - gate ACTIVE → render `` (next/dynamic) which
+ * mounts Stripe Elements + the saved-card / new-card branch (S7).
*
* Consent at click-Payer (ADR 0007) : the CGV/loyalty wording lives ABOVE
* the button, ≥ 12 px (acceptance criterion #454 « Wording CGV visible et
* lisible (≥ 12px) »). The button text IS the consent action — no
- * checkbox, no separate « J'accepte » step.
+ * checkbox, no separate « J'accepte » step. The actual `recordConsentAtCheckout`
+ * mutation is fired by `` (S7) at click-Payer, so
+ * each successful payment re-stamps the then-active CGV hash (re-consent
+ * par achat, ADR 0005).
*/
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { usePreloadedQuery, type Preloaded } from "convex/react";
import { api } from "@packages/backend/convex/_generated/api";
@@ -37,6 +37,10 @@ import type { Id } from "@packages/backend/convex/_generated/dataModel";
import { useCart } from "@/components/cart/cart-context";
import { useDeliveryMode } from "@/components/delivery-mode/delivery-mode-context";
import { PushEnrollmentModal } from "@/components/checkout/push-enrollment-modal";
+import {
+ StripePaymentLazy,
+ type CheckoutContactValues,
+} from "@/components/checkout/stripe-payment";
import { decideCartTotals } from "@/lib/delivery-mode";
import {
decideCheckoutPrefill,
@@ -116,6 +120,21 @@ export function CheckoutForm({
}
}, [gate.kind, modalRequested]);
+ // Refs to the three contact ` ` so `` can
+ // snapshot the live values at click-Payer time (without forcing the
+ // parent to lift state out of the uncontrolled inputs).
+ const firstNameRef = useRef(null);
+ const emailRef = useRef(null);
+ const phoneRef = useRef(null);
+ const getContactValues = useCallback(
+ (): CheckoutContactValues => ({
+ firstName: firstNameRef.current?.value ?? "",
+ email: emailRef.current?.value ?? "",
+ phone: phoneRef.current?.value ?? "",
+ }),
+ [],
+ );
+
if (redirect.kind === "redirect") {
// Render nothing while the navigation is en route — avoids a flash of
// the empty checkout form before the redirect lands.
@@ -131,24 +150,12 @@ export function CheckoutForm({
const modalOpen = modalRequested && gate.kind !== "active";
- const onPayClick = (): void => {
- if (gate.kind !== "active") {
- // Gate closed → open the enrollment modal (S6a). The CTA `disabled`
- // attribute keeps a keyboard user from reaching here on a `disabled`
- // gate, but we double-check defensively so a future ref-driven click
- // still routes correctly.
- setModalRequested(true);
- return;
- }
- // S6 stub — the actual Stripe payment lands in S7 (#458). Logging here
- // makes it easy to confirm in the E2E plan that the gate enabled the
- // click (vs the button being disabled — onClick wouldn't fire then).
- console.log("[PWA-S6] Payer clicked", {
- tenantId,
- subtotalCentimes: totals.subtotalCentimes,
- totalCentimes: totalsRow.totalCentimes,
- enrolledChannels: gate.enrolledChannels,
- });
+ const onGateDisabledClick = (): void => {
+ // Gate closed → open the enrollment modal (S6a). When the modal lands
+ // and the user enrolls, the Convex sub re-runs `decidePaymentGate`
+ // → `gate.kind` flips to "active" → the form switches to the
+ // `` branch automatically (US 27-38).
+ setModalRequested(true);
};
return (
@@ -168,6 +175,7 @@ export function CheckoutForm({
Prénom
Email
Téléphone
KitchenBoost.
-
+ {gate.kind === "active" ? (
+ // S7 (#458) — gate open → mount the lazy Stripe payment surface.
+ // The acceptance criterion « Bundle Stripe lazy-loaded sur /checkout »
+ // is satisfied because `` is the ONLY entry
+ // chain that imports `@stripe/*`, hidden behind `next/dynamic`
+ // (`ssr: false`) — a customer who never reaches gate=active
+ // (e.g. exits at /panier) never downloads the Stripe SDK.
+
+ ) : (
+ // S6 — gate disabled : surface the « Payer » CTA that opens the
+ // enrollment modal. NO Stripe code is loaded in this branch (the
+ // import lives inside ``).
+
+ )}
`; once the user enrolls a push
+ * channel, the parent's Convex sub re-runs `decidePaymentGate`, the
+ * branch switches to `` (S7), and the CTA inside
+ * `` takes over.
*
- * S6a (#455) — the button is ALWAYS clickable now: a gate-disabled click opens
- * the `` (per the parent's `onPayClick`), a gate-active
- * click triggers the Stripe payment (S7). The visual styling still
- * differentiates the two states so the user sees the gate, but `disabled` is
- * deliberately dropped to keep the modal opening on click.
+ * Extracted so the JSX stays readable; `data-gate="disabled"` is kept as
+ * a stable selector for the S6a/b/c E2E plan.
*/
function PayButton({
- gate,
totalCentimes,
onPayClick,
}: {
- gate: ReturnType;
totalCentimes: number;
onPayClick: () => void;
}): React.JSX.Element {
- const isActive = gate.kind === "active";
return (
Payer {formatEur(totalCentimes)}
diff --git a/apps/web/src/components/checkout/stripe-payment/index.ts b/apps/web/src/components/checkout/stripe-payment/index.ts
new file mode 100644
index 00000000..d70d25c4
--- /dev/null
+++ b/apps/web/src/components/checkout/stripe-payment/index.ts
@@ -0,0 +1,10 @@
+/**
+ * PWA-S7 (#458) — `stripe-payment` UI module API.
+ *
+ * The PARENT `` imports `` ONLY — the
+ * `next/dynamic` boundary ensures the Stripe SDK ships in a SEPARATE
+ * chunk fetched only when `/checkout` mounts (acceptance criterion #458
+ * lazy load).
+ */
+export { type CheckoutContactValues } from "./stripe-payment-section";
+export { StripePaymentLazy } from "./stripe-payment-lazy";
diff --git a/apps/web/src/components/checkout/stripe-payment/latching-confirm-modal.tsx b/apps/web/src/components/checkout/stripe-payment/latching-confirm-modal.tsx
new file mode 100644
index 00000000..9b6df43f
--- /dev/null
+++ b/apps/web/src/components/checkout/stripe-payment/latching-confirm-modal.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+/**
+ * PWA-S7 (#458) — `` — the blocking confirm modal
+ * shown when the FRESH `recaptureQuoteAtPayment` verdict returns a higher
+ * fee than the cached panier fee (US 47, decisions-log Q7 « si fee monte →
+ * modal `` bloquant »).
+ *
+ * Stays open until the user explicitly chooses « Oui » (proceed with the
+ * higher fee) or « Non » (cancel — back to the cart). Non-skippable Esc /
+ * click-outside (same Radix primitive contract as
+ * ``): a stray Esc keystroke should not silently
+ * accept a price hike.
+ *
+ * Pure presentation — the latching DECISION is owned by
+ * `decideLatchingOutcome` in `lib/stripe-payment/`; this component just
+ * renders the `confirm-surge` outcome's two values + the two callbacks.
+ */
+import { Dialog as DialogPrimitive } from "radix-ui";
+
+function formatEur(centimes: number): string {
+ return new Intl.NumberFormat("fr-FR", {
+ style: "currency",
+ currency: "EUR",
+ }).format(centimes / 100);
+}
+
+export type LatchingConfirmModalProps = {
+ open: boolean;
+ /** Cached panier fee (centimes) the user agreed to. */
+ fromCentimes: number;
+ /** Fresh fee (centimes) Stripe will charge if confirmed. */
+ toCentimes: number;
+ /** User clicked « Oui » — proceed with the higher fee. */
+ onConfirm: () => void;
+ /** User clicked « Non » — cancel; the form returns to its idle state. */
+ onCancel: () => void;
+};
+
+export function LatchingConfirmModal({
+ open,
+ fromCentimes,
+ toCentimes,
+ onConfirm,
+ onCancel,
+}: LatchingConfirmModalProps): React.JSX.Element {
+ return (
+
+
+
+ e.preventDefault()}
+ onPointerDownOutside={(e) => e.preventDefault()}
+ className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed left-1/2 top-1/2 z-50 grid w-full max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-zinc-200 bg-white p-6 shadow-xl"
+ >
+
+ Le tarif livraison a changé
+
+
+ Le tarif livraison est passé de{" "}
+ {formatEur(fromCentimes)} {" "}
+ à {formatEur(toCentimes)}
+ . Confirmes-tu ?
+
+
+
+ Oui, payer {formatEur(toCentimes)} de livraison
+
+
+ Non, annuler
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/checkout/stripe-payment/new-card-panel.tsx b/apps/web/src/components/checkout/stripe-payment/new-card-panel.tsx
new file mode 100644
index 00000000..d7f5ed35
--- /dev/null
+++ b/apps/web/src/components/checkout/stripe-payment/new-card-panel.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+/**
+ * PWA-S7 (#458) — `` — the new-card branch (US 46): Stripe
+ * Payment Element on the resto's CONNECTED account.
+ *
+ * Renders inside the `` (deferred-intent mode), so
+ * `` auto-shows the available wallet buttons (Apple Pay,
+ * Google Pay) + the card form (acceptance criterion #458 « Apple Pay
+ * button visible iOS »). The actual confirm flow is owned by the parent
+ * `` — this panel just renders the Element.
+ *
+ * ── « Sauvegarder ma carte » — DELIBERATELY NOT RENDERED V1 ─────────────
+ * The PRD US 46 mentions a « Sauvegarder ma carte » checkbox; the issue
+ * description lists it in « What to build ». It is NOT rendered in this
+ * slice because the cross-resto save mechanism (which is the whole VALUE
+ * of the saved-card UX, ADR-aligned, payment CONTEXT « Stripe Customer
+ * cross-tenant ») requires a PLATFORM-level Stripe Elements instance to
+ * collect a PaymentMethod attachable to the platform Customer — and the
+ * backend `saveCard` action (2.5-D, `packages/backend/convex/lib/stripe/
+ * savedCard.ts`) EXPECTS exactly that platform PM.
+ *
+ * S7 (this slice) charges on the CONNECTED account (direct charge,
+ * `Stripe-Account: acct_resto`) — the PaymentMethod created here belongs
+ * to the connected account and CANNOT be re-cloned to other restos. So
+ * surfacing a checkbox here would either:
+ * (a) silently save same-resto-only — breaking the « mes prochaines
+ * commandes » promise across restos;
+ * (b) save nothing — silently lie to the user.
+ *
+ * Both options violate the « no shortcut fixes » project rule. The
+ * checkbox lands in a follow-up slice that:
+ * - adds `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` usage WITHOUT
+ * `stripeAccount` for a second platform-context Elements instance;
+ * - branches the new-card flow into « one-shot » vs « collect + save »
+ * paths (the save path mounts platform Elements, calls
+ * `stripe.createPaymentMethod({ elements })`, persists via the
+ * already-merged `saveCard` mutation, then proceeds with
+ * `payWithSavedCard`);
+ * - SoT-side, the consumption path is already complete here through
+ * `` — a customer who acquired a saved card by any
+ * means (e.g. a future « save » slice OR a manual ops seed) sees it
+ * pre-selected as the tile.
+ *
+ * Tracking: docs/contexts/payment/CONTEXT.md + parent issue #458
+ * follow-up note in the PR body.
+ */
+import { PaymentElement } from "@stripe/react-stripe-js";
+
+export function NewCardPanel(): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/checkout/stripe-payment/saved-card-panel.tsx b/apps/web/src/components/checkout/stripe-payment/saved-card-panel.tsx
new file mode 100644
index 00000000..7c8e366b
--- /dev/null
+++ b/apps/web/src/components/checkout/stripe-payment/saved-card-panel.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+/**
+ * PWA-S7 (#458) — `` — the saved-card branch (US 45). The
+ * customer has a `savedPaymentMethodId` on their fiche (set by a previous
+ * resto via the platform Stripe Customer, 2.5-D « clone PM cross-resto »)
+ * — pre-select it as a tile + offer the escape « Utiliser une autre carte »
+ * link that flips the parent to the new-card branch.
+ *
+ * We deliberately render the card as « Payer avec ma carte enregistrée »
+ * WITHOUT a `**** 1234` suffix : the backend `saveCard` action does NOT
+ * persist `last4` on the fiche (see `packages/backend/convex/lib/stripe/
+ * savedCard.ts` — the platform PaymentMethod is referenced by id only).
+ * Surfacing « **** XXXX » would require either (a) calling Stripe's
+ * `/payment_methods/` from the front (impossible — needs the secret
+ * key; ADR-aligned never expose secret to client) or (b) caching `last4`
+ * on the fiche (out of scope of this issue — a separate slice would have
+ * to extend the schema + the saveCard mutation). The neutral wording is
+ * sufficient for the moat UX while keeping S7's scope tight.
+ */
+
+export type SavedCardPanelProps = {
+ /** Called when the user clicks « Utiliser une autre carte » → parent flips to new-card. */
+ onUseAnotherCard: () => void;
+};
+
+export function SavedCardPanel({
+ onUseAnotherCard,
+}: SavedCardPanelProps): React.JSX.Element {
+ return (
+
+
+
+ •••• •••
+
+
+ Payer avec ma carte enregistrée
+
+
+
+ Tu réutilises la carte que tu as enregistrée lors d'une commande
+ précédente sur KitchenBoost.
+
+
+ Utiliser une autre carte
+
+
+ );
+}
diff --git a/apps/web/src/components/checkout/stripe-payment/stripe-elements-provider.tsx b/apps/web/src/components/checkout/stripe-payment/stripe-elements-provider.tsx
new file mode 100644
index 00000000..174942bf
--- /dev/null
+++ b/apps/web/src/components/checkout/stripe-payment/stripe-elements-provider.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+/**
+ * PWA-S7 (#458) — `` — wraps the Payment Element
+ * with `loadStripe(NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, { stripeAccount })`
+ * and the deferred-intent ``
+ * shape (US 46 + decisions-log Q6).
+ *
+ * Direct charge contract (payment CONTEXT): the resto is merchant of
+ * record, the front MUST init Stripe.js with the resto's connected
+ * account id so the Payment Element renders the resto's Apple Pay /
+ * Google Pay configuration (and a future Stripe Connect-aware webhook
+ * routes correctly).
+ *
+ * Deferred-intent mode (Stripe Elements docs « Accept a payment, deferred
+ * intent ») — we mount Elements with `mode: "payment"` + `amount` +
+ * `currency`; the actual PaymentIntent is created server-side at the
+ * « Payer » click (mutation `api.lib.stripe.paymentIntent.createPaymentIntent`
+ * — 2.5-B). The `clientSecret` is then passed to
+ * `stripe.confirmPayment({ elements, clientSecret, confirmParams })` —
+ * Stripe handles 3DS / redirect / Apple Pay sheet transparently
+ * (acceptance criterion #458 « 3DS handle par SDK sans code custom »).
+ *
+ * `loadStripe` is called HERE (a child of the lazy-imported wrapper),
+ * NOT at module top-level — the import side of `next/dynamic` already
+ * keeps the Stripe SDK out of /menu / /panier bundles (acceptance
+ * criterion #458 « Bundle Stripe lazy-loaded sur /checkout »). Memoised
+ * on `stripeAccount` so a resto switch (which would never happen V1, a
+ * tenant is host-scoped) doesn't re-fetch the same SDK.
+ */
+import { useMemo, type ReactNode } from "react";
+import { Elements } from "@stripe/react-stripe-js";
+import { loadStripe, type Stripe } from "@stripe/stripe-js";
+
+/** Module-level cache so a re-render of the parent doesn't refetch the SDK. */
+const stripeByAccount = new Map>();
+
+function getOrLoadStripe(
+ publishableKey: string,
+ stripeAccount: string,
+): Promise {
+ const key = `${publishableKey}::${stripeAccount}`;
+ const existing = stripeByAccount.get(key);
+ if (existing !== undefined) return existing;
+ const promise = loadStripe(publishableKey, { stripeAccount });
+ stripeByAccount.set(key, promise);
+ return promise;
+}
+
+export type StripeElementsProviderProps = {
+ /** Publishable key (`NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` — pk_test_… / pk_live_…). */
+ publishableKey: string;
+ /** Resto's connected account id (`acct_…`) — direct charge contract. */
+ stripeAccount: string;
+ /** Total amount in centimes to display in the Payment Element preview. */
+ amountCentimes: number;
+ /** ISO currency code (V1 = "eur"). */
+ currency: "eur";
+ /** Optional account country for Apple/Google Pay localization (defaults to `"FR"`). */
+ country?: "FR";
+ children: ReactNode;
+};
+
+export function StripeElementsProvider({
+ publishableKey,
+ stripeAccount,
+ amountCentimes,
+ currency,
+ country = "FR",
+ children,
+}: StripeElementsProviderProps): React.JSX.Element {
+ // Promise is module-cached so a parent re-render does not re-load the
+ // SDK; the `useMemo` is for the React reference stability that Elements
+ // requires (a fresh promise each render would re-mount the iframe).
+ const stripePromise = useMemo(
+ () => getOrLoadStripe(publishableKey, stripeAccount),
+ [publishableKey, stripeAccount],
+ );
+ return (
+ needed. Default automatic
+ // payment-method creation: `confirmPayment({ elements,
+ // clientSecret })` creates the PM + confirms in ONE Stripe call.
+ appearance: { theme: "stripe" },
+ // Localised wording (FR — V1 PWA is fr-only, ADR « PWA mobile
+ // first FR » / out-of-scope multi-langue V3).
+ locale: "fr",
+ loader: "auto",
+ }}
+ // Re-mount on amount change so the deferred-intent preview reflects
+ // a fresh latched fee (e.g. user re-toggles to C&C and back).
+ key={`${stripeAccount}::${amountCentimes}::${currency}::${country}`}
+ >
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/checkout/stripe-payment/stripe-payment-impl.tsx b/apps/web/src/components/checkout/stripe-payment/stripe-payment-impl.tsx
new file mode 100644
index 00000000..df5c2f16
--- /dev/null
+++ b/apps/web/src/components/checkout/stripe-payment/stripe-payment-impl.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+/**
+ * PWA-S7 (#458) — `` — the concrete implementation
+ * imported BEHIND `next/dynamic` by ``.
+ *
+ * Responsibilities:
+ * - Read the tenant's `stripeAccountId` + `stripeStatus` via the
+ * customer-scoped `api.lib.tenants.payment.forCheckout` query (Convex
+ * sub — reactive flip when the resto completes onboarding).
+ * - Read `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` from `process.env` (must be
+ * inlined at build time — required by Next 16 Edge contract for any
+ * `NEXT_PUBLIC_*` value).
+ * - Decide whether to mount Stripe Elements (`stripeStatus === "ready"`
+ * + key present + accountId present) OR to render a degraded
+ * « Paiement indisponible » panel (the backend `assertOwnPendingOrder`
+ * would refuse the PaymentIntent anyway — we short-circuit here for UX).
+ * - Mount `` + ``.
+ *
+ * This is the ONLY component that loads `@stripe/react-stripe-js` /
+ * `@stripe/stripe-js` — every parent reaches it through
+ * `` (a `next/dynamic` boundary with `ssr: false`),
+ * so the Stripe SDK ships in a SEPARATE chunk that is fetched ONLY when
+ * `/checkout` mounts (acceptance criterion #458 lazy load).
+ */
+import { useQuery } from "convex/react";
+import { api } from "@packages/backend/convex/_generated/api";
+import type { Id } from "@packages/backend/convex/_generated/dataModel";
+import { useCart } from "@/components/cart/cart-context";
+import { useDeliveryMode } from "@/components/delivery-mode/delivery-mode-context";
+import { decideCartTotals } from "@/lib/delivery-mode";
+import type { SavedCardFicheSnapshot } from "@/lib/stripe-payment";
+import { StripeElementsProvider } from "./stripe-elements-provider";
+import {
+ StripePaymentSection,
+ type CheckoutContactValues,
+} from "./stripe-payment-section";
+
+export type StripePaymentImplProps = {
+ tenantId: Id<"tenants">;
+ /** Saved-card snapshot from the preloaded customer fiche (drives the branch). */
+ fiche: SavedCardFicheSnapshot | null;
+ /** Customer's stamped address (S3, needed for latching `recaptureQuoteAtPayment`). */
+ customerAddress: string | undefined;
+ customerLat: number | undefined;
+ customerLng: number | undefined;
+ /** Reads the live ` ` form values at click-Payer time. */
+ getContactValues: () => CheckoutContactValues;
+};
+
+export function StripePaymentImpl({
+ tenantId,
+ fiche,
+ customerAddress,
+ customerLat,
+ customerLng,
+ getContactValues,
+}: StripePaymentImplProps): React.JSX.Element {
+ // Customer-scoped sub on the tenant's Stripe context. Reactive: a
+ // resto completing onboarding while the customer is on /checkout will
+ // re-render this component without a reload.
+ const paymentContext = useQuery(api.lib.tenants.payment.forCheckout, {
+ tenantId,
+ });
+
+ const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
+
+ // Compute the displayed amount for the Payment Element preview.
+ const { totals } = useCart();
+ const { mode, verdict } = useDeliveryMode();
+ const totalsRow = decideCartTotals({
+ subtotalCentimes: totals.subtotalCentimes,
+ mode,
+ verdict,
+ });
+
+ if (paymentContext === undefined) {
+ // Sub still loading — render the same skeleton the lazy wrapper does.
+ return (
+
+ Chargement du paiement…
+
+ );
+ }
+ if (
+ publishableKey === undefined ||
+ publishableKey.length === 0 ||
+ paymentContext.stripeAccountId === null ||
+ paymentContext.stripeStatus !== "ready"
+ ) {
+ return (
+
+ Le resto n'accepte pas encore les paiements en ligne. Reviens plus
+ tard ou contacte-le directement.
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/checkout/stripe-payment/stripe-payment-lazy.tsx b/apps/web/src/components/checkout/stripe-payment/stripe-payment-lazy.tsx
new file mode 100644
index 00000000..b6e9780f
--- /dev/null
+++ b/apps/web/src/components/checkout/stripe-payment/stripe-payment-lazy.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+/**
+ * PWA-S7 (#458) — `` — `next/dynamic` wrapper that
+ * KEEPS the Stripe SDK out of any bundle except `/checkout` (acceptance
+ * criterion #458 « Bundle Stripe lazy-loaded sur /checkout (DevTools
+ * Network : stripe.js n'apparaît pas sur /menu) »).
+ *
+ * Why a separate file: `next/dynamic` with `ssr: false` MUST be called
+ * inside a Client Component (`apps/web/src/app/checkout/page.tsx` is RSC);
+ * routing it through `` (which is already a Client
+ * Component) keeps the Stripe imports OUT of the RSC graph + OUT of the
+ * `/checkout` initial HTML payload (Stripe.js only loads on actual
+ * mount, after the user reaches the form). The deep chain
+ * `` → `` → `` →
+ * `@stripe/react-stripe-js` is the boundary the bundler splits on.
+ */
+import dynamic from "next/dynamic";
+
+export const StripePaymentLazy = dynamic(
+ () =>
+ import("./stripe-payment-impl").then((m) => ({
+ default: m.StripePaymentImpl,
+ })),
+ {
+ // Stripe Elements is browser-only (touches `window`/`document` at
+ // load). `ssr: false` keeps it out of the SSR bundle entirely.
+ ssr: false,
+ loading: () => (
+
+ Chargement du paiement…
+
+ ),
+ },
+);
diff --git a/apps/web/src/components/checkout/stripe-payment/stripe-payment-section.tsx b/apps/web/src/components/checkout/stripe-payment/stripe-payment-section.tsx
new file mode 100644
index 00000000..0eebb988
--- /dev/null
+++ b/apps/web/src/components/checkout/stripe-payment/stripe-payment-section.tsx
@@ -0,0 +1,476 @@
+"use client";
+
+/**
+ * PWA-S7 (#458) — `` — the orchestrator of the
+ * S7 payment flow. Rendered as a CHILD of `` so
+ * the `useStripe` / `useElements` hooks resolve.
+ *
+ * Owns the click-Payer flow at the top of the file header
+ * (decisions-log Q6 / Q7, US 44-49) :
+ *
+ * 1. `recordCheckoutContact(firstName/email/phone)` — capture PII for the
+ * NEXT visit's prefill (US 44 prefill loop, decisions-log Q3).
+ * 2. `recordConsentAtCheckout()` — stamp CGV hash (ADR 0007).
+ * 3. `recaptureQuoteAtPayment({tenantId, address})` — latching anti-surge.
+ * `decideLatchingOutcome` →
+ * - `abort` : surface the reason as a toast → return.
+ * - `confirm-surge` : show `` → wait for
+ * user's OK/cancel; on OK → continue with the
+ * FRESH fee; on cancel → return to idle.
+ * - `ok` : continue with the FRESH fee.
+ * 4. `createOrderFromCart({mode, address, lat, lng, restaurantNote, items})`
+ * → get `orderId` (US 23, freezes the cart).
+ * 5. Branch on `decidePaymentBranch(customer)` :
+ * - `saved-card` : `payWithSavedCard({tenantId, orderId,
+ * pricingSnapshot})` → on success, redirect to
+ * `/c/[orderId]` (US 50).
+ * - `new-card` : `createPaymentIntent({tenantId, orderId,
+ * pricingSnapshot})` → `stripe.confirmPayment({
+ * elements, clientSecret, confirmParams: {
+ * return_url: '/c/' } })` (Stripe
+ * handles 3DS / redirect / Apple Pay sheet, US 49).
+ * 6. On failure: increment `attempts`; `decideRetryAction(attempts)` →
+ * - `inline-retry` : surface a discreet inline banner with the
+ * Stripe message + a Retry button.
+ * - `fatal-toast` : surface a terminal toast + call the Sentry shim
+ * (US 48, acceptance criterion « 3ᵉ échec »).
+ *
+ * NO direct `getAuthUserId` (ADR 0011) — identity flows through the Convex
+ * Auth session cookie already used by `usePreloadedQuery`.
+ *
+ * Conditional Stripe.js calls: `useStripe()` / `useElements()` may return
+ * `null` before Stripe.js finishes loading (acceptance criterion #458
+ * « Bundle Stripe lazy-loaded sur /checkout »). The Payer CTA stays
+ * disabled in that window.
+ */
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAction, useMutation } from "convex/react";
+import { useElements, useStripe } from "@stripe/react-stripe-js";
+import { api } from "@packages/backend/convex/_generated/api";
+import type { Id } from "@packages/backend/convex/_generated/dataModel";
+import { useCart } from "@/components/cart/cart-context";
+import { useDeliveryMode } from "@/components/delivery-mode/delivery-mode-context";
+import { decideCartTotals } from "@/lib/delivery-mode";
+import {
+ captureException,
+ decideLatchingOutcome,
+ decidePaymentBranch,
+ decideRetryAction,
+ derivePricingSnapshot,
+ type LatchingOutcome,
+ type PaymentBranch,
+ type SavedCardFicheSnapshot,
+} from "@/lib/stripe-payment";
+import { LatchingConfirmModal } from "./latching-confirm-modal";
+import { NewCardPanel } from "./new-card-panel";
+import { SavedCardPanel } from "./saved-card-panel";
+
+function formatEur(centimes: number): string {
+ return new Intl.NumberFormat("fr-FR", {
+ style: "currency",
+ currency: "EUR",
+ }).format(centimes / 100);
+}
+
+/** Customer fields piped from the form's ` ` controls into the flow. */
+export type CheckoutContactValues = {
+ firstName: string;
+ email: string;
+ phone: string;
+};
+
+export type StripePaymentSectionProps = {
+ tenantId: Id<"tenants">;
+ /** Resolved from preloaded fiche on the parent (drives branch + saved-card label). */
+ fiche: SavedCardFicheSnapshot | null;
+ /** Customer's address from the fiche (needed for `recaptureQuoteAtPayment`). */
+ customerAddress: string | undefined;
+ /** Customer's lat from the fiche (passed to `createOrderFromCart`). */
+ customerLat: number | undefined;
+ /** Customer's lng from the fiche (passed to `createOrderFromCart`). */
+ customerLng: number | undefined;
+ /** Live form values (firstName/email/phone) — read from the parent form. */
+ getContactValues: () => CheckoutContactValues;
+};
+
+/** Cart line → backend `cartItem` shape consumed by `createOrderFromCart`. */
+function mapCartLinesToBackend(
+ lines: ReadonlyArray<{
+ itemId: string;
+ qty: number;
+ modifiers: ReadonlyArray<{ groupId: string; optionLabel: string }>;
+ }>,
+): Array<{
+ itemId: Id<"menuItems">;
+ quantity: number;
+ modifierSelections: Array<{
+ modifierGroupId: Id<"modifierGroups">;
+ optionLabels: string[];
+ }>;
+}> {
+ // Group modifier selections by `groupId` (the backend validator expects
+ // ONE entry per group with an array of option labels; the cart-store
+ // models them as flat per-option entries).
+ return lines.map((line) => {
+ const byGroup = new Map();
+ for (const m of line.modifiers) {
+ const arr = byGroup.get(m.groupId) ?? [];
+ arr.push(m.optionLabel);
+ byGroup.set(m.groupId, arr);
+ }
+ return {
+ itemId: line.itemId as Id<"menuItems">,
+ quantity: line.qty,
+ modifierSelections: Array.from(byGroup.entries()).map(
+ ([groupId, labels]) => ({
+ modifierGroupId: groupId as Id<"modifierGroups">,
+ optionLabels: labels,
+ }),
+ ),
+ };
+ });
+}
+
+export function StripePaymentSection({
+ tenantId,
+ fiche,
+ customerAddress,
+ customerLat,
+ customerLng,
+ getContactValues,
+}: StripePaymentSectionProps): React.JSX.Element {
+ const router = useRouter();
+ const stripe = useStripe();
+ const elements = useElements();
+
+ const { state: cartState, totals } = useCart();
+ const { mode, verdict } = useDeliveryMode();
+ const totalsRow = decideCartTotals({
+ subtotalCentimes: totals.subtotalCentimes,
+ mode,
+ verdict,
+ });
+
+ // Default branch derived from the fiche; the « Use another card » link
+ // forces the new-card branch even when a saved card exists.
+ const defaultBranch = decidePaymentBranch(fiche);
+ const [branch, setBranch] = useState(defaultBranch);
+
+ // Convex bindings
+ const recordCheckoutContact = useMutation(
+ api.lib.customer.checkoutContact.recordCheckoutContact,
+ );
+ const recordConsent = useMutation(
+ api.lib.customer.consent.recordConsentAtCheckout,
+ );
+ const createOrderFromCart = useMutation(
+ api.lib.cart.cart.createOrderFromCart,
+ );
+ const recaptureQuote = useAction(
+ api.lib.delivery.quote.recaptureQuoteAtPayment,
+ );
+ const createPaymentIntent = useAction(
+ api.lib.stripe.paymentIntent.createPaymentIntent,
+ );
+ const payWithSavedCard = useAction(api.lib.stripe.savedCard.payWithSavedCard);
+
+ // State machine — owned locally, narrow on purpose.
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [attempts, setAttempts] = useState(0);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [fatal, setFatal] = useState(null);
+ const [surgeConfirm, setSurgeConfirm] = useState<{
+ fromCentimes: number;
+ toCentimes: number;
+ /** Resolves the user's choice in the pending payment flow. */
+ resolve: (proceed: boolean) => void;
+ } | null>(null);
+
+ /** Run the latching re-quote (with C&C short-circuit) + resolve the outcome. */
+ async function runLatching(): Promise {
+ if (mode === "click_and_collect") {
+ return decideLatchingOutcome({
+ mode,
+ cachedFeeCentimes: 0,
+ freshVerdict: null,
+ });
+ }
+ if (customerAddress === undefined) {
+ // Defensive: the parent only renders the form when the customer is
+ // authenticated; the address-first flow stamps `customers.address`
+ // BEFORE the user can reach /checkout. If it's absent, we cannot
+ // re-quote — abort fail-closed.
+ return { kind: "abort", reason: "hors_zone" };
+ }
+ const freshVerdict = await recaptureQuote({
+ tenantId,
+ address: customerAddress,
+ });
+ return decideLatchingOutcome({
+ mode,
+ cachedFeeCentimes:
+ verdict !== null && verdict.deliverable ? verdict.fee : 0,
+ freshVerdict,
+ });
+ }
+
+ /** Show the surge confirm modal and await user choice. */
+ function awaitSurgeConfirm(
+ fromCentimes: number,
+ toCentimes: number,
+ ): Promise {
+ return new Promise((resolve) => {
+ setSurgeConfirm({ fromCentimes, toCentimes, resolve });
+ });
+ }
+
+ /** Map the latching abort reason to the user-visible toast wording. */
+ function abortReasonToMessage(
+ reason: "hors_zone" | "hors_horaire" | "surge",
+ ): string {
+ if (reason === "hors_horaire") {
+ return "Le resto vient de fermer, ta commande ne peut pas être payée maintenant.";
+ }
+ if (reason === "surge") {
+ return "Livraison temporairement indisponible. Réessaie dans quelques minutes.";
+ }
+ return "Adresse plus en zone de livraison. Reviens à l'accueil pour rebasculer en retrait sur place.";
+ }
+
+ /** Centralised handler called on every failure (Stripe / mutation / action). */
+ function recordFailure(message: string, err: unknown): void {
+ const nextAttempts = attempts + 1;
+ setAttempts(nextAttempts);
+ const action = decideRetryAction(nextAttempts);
+ if (action.kind === "fatal-toast") {
+ setFatal("Paiement impossible, contacte ton resto");
+ setErrorMessage(null);
+ captureException(err, {
+ tags: { slice: "PWA-S7", tenantId: String(tenantId) },
+ extras: { message, attempts: nextAttempts },
+ });
+ } else {
+ setErrorMessage(message);
+ }
+ }
+
+ async function onPay(): Promise {
+ if (isProcessing) return;
+ setIsProcessing(true);
+ setErrorMessage(null);
+ try {
+ // 1. Persist contact + consent (best-effort, NOT a payment blocker).
+ const contact = getContactValues();
+ try {
+ await recordCheckoutContact({ tenantId, ...contact });
+ } catch (err) {
+ // Non-fatal — log and continue (the customer paid for an order
+ // is the SoT; the contact prefill loop only suffers a 1-cycle delay).
+ captureException(err, {
+ tags: { slice: "PWA-S7", step: "recordCheckoutContact" },
+ });
+ }
+ try {
+ await recordConsent({ tenantId });
+ } catch (err) {
+ // Non-fatal in the SAME spirit — ADR 0007 makes the click itself
+ // the consent; the stamp is the audit trail, not the consent gate.
+ captureException(err, {
+ tags: { slice: "PWA-S7", step: "recordConsentAtCheckout" },
+ });
+ }
+
+ // 2. Latching anti-surge.
+ const outcome = await runLatching();
+ if (outcome.kind === "abort") {
+ setFatal(abortReasonToMessage(outcome.reason));
+ return;
+ }
+ let latchedFeeCentimes =
+ mode === "click_and_collect"
+ ? 0
+ : verdict !== null && verdict.deliverable
+ ? verdict.fee
+ : 0;
+ if (outcome.kind === "confirm-surge") {
+ latchedFeeCentimes = outcome.toCentimes;
+ const proceed = await awaitSurgeConfirm(
+ outcome.fromCentimes,
+ outcome.toCentimes,
+ );
+ if (!proceed) {
+ // User declined — return to idle (no order created, no Stripe call).
+ return;
+ }
+ }
+
+ // 3. Build pricing snapshot from the FRESH fee.
+ const pricingSnapshot = derivePricingSnapshot({
+ mode,
+ subtotalCentimes: totals.subtotalCentimes,
+ latchedFeeCentimes,
+ });
+
+ // 4. Create the order. Map the front mode label
+ // (`click_and_collect`) onto the backend `orderMode` validator
+ // (`pickup`) — the two are the SAME concept, the differing labels
+ // are a pre-existing seam (front: UX label, backend: internal).
+ const backendMode = mode === "click_and_collect" ? "pickup" : "delivery";
+ const orderId = await createOrderFromCart({
+ tenantId,
+ mode: backendMode,
+ address: mode === "delivery" ? customerAddress : undefined,
+ lat: mode === "delivery" ? customerLat : undefined,
+ lng: mode === "delivery" ? customerLng : undefined,
+ restaurantNote: cartState.note.length > 0 ? cartState.note : undefined,
+ items: mapCartLinesToBackend(cartState.lines),
+ });
+
+ // 5. Branch on payment mode.
+ if (branch.kind === "saved-card") {
+ const result = await payWithSavedCard({
+ tenantId,
+ orderId,
+ pricingSnapshot,
+ });
+ if (result.paymentIntentId.length === 0) {
+ recordFailure(
+ "Paiement refusé. Essaie une autre carte.",
+ new Error("payWithSavedCard returned an empty paymentIntentId"),
+ );
+ return;
+ }
+ // Saved card direct-charged successfully — redirect to tracking.
+ // The Stripe webhook (`payment_intent.succeeded`) will confirm the
+ // order in backend; the tracking page subscribes via Convex sub.
+ router.replace(`/c/${orderId}`);
+ return;
+ }
+
+ // new-card branch: deferred-intent Payment Element.
+ if (stripe === null || elements === null) {
+ recordFailure(
+ "Le formulaire de paiement n'est pas prêt. Réessaie.",
+ new Error("Stripe.js not initialised"),
+ );
+ return;
+ }
+ // Pre-validate the Payment Element (catches empty card etc. before
+ // calling our backend / creating the PaymentIntent).
+ const submit = await elements.submit();
+ if (submit.error !== undefined) {
+ recordFailure(
+ submit.error.message ?? "Erreur de validation de la carte.",
+ submit.error,
+ );
+ return;
+ }
+ const { clientSecret } = await createPaymentIntent({
+ tenantId,
+ orderId,
+ pricingSnapshot,
+ });
+ const baseUrl = window.location.origin;
+ const confirm = await stripe.confirmPayment({
+ elements,
+ clientSecret,
+ confirmParams: {
+ return_url: `${baseUrl}/c/${orderId}`,
+ // Stripe handles 3DS / Apple Pay redirects via this URL; we
+ // never reach the line after confirmPayment on a SUCCESS that
+ // required a redirect (the user is already on /c/[orderId]).
+ },
+ // The Payment Element handles its own redirect contract; when
+ // the payment is fully inline (no 3DS), Stripe returns here.
+ });
+ if (confirm.error !== undefined) {
+ recordFailure(
+ confirm.error.message ?? "Paiement refusé. Réessaie.",
+ confirm.error,
+ );
+ return;
+ }
+ // No redirect needed (rare: inline success). Push tracking ourselves.
+ router.replace(`/c/${orderId}`);
+ } catch (err) {
+ recordFailure("Une erreur est survenue. Réessaie.", err);
+ } finally {
+ setIsProcessing(false);
+ }
+ }
+
+ // Wire the surge modal's two buttons.
+ function onSurgeConfirm(): void {
+ if (surgeConfirm === null) return;
+ surgeConfirm.resolve(true);
+ setSurgeConfirm(null);
+ }
+ function onSurgeCancel(): void {
+ if (surgeConfirm === null) return;
+ surgeConfirm.resolve(false);
+ setSurgeConfirm(null);
+ }
+
+ const stripeReady = stripe !== null && elements !== null;
+ const payDisabled =
+ isProcessing ||
+ fatal !== null ||
+ (branch.kind === "new-card" && !stripeReady);
+
+ return (
+
+ {branch.kind === "saved-card" ? (
+ setBranch({ kind: "new-card" })}
+ />
+ ) : (
+
+ )}
+
+ {/* Inline retry banner (failures 1 + 2). */}
+ {errorMessage !== null && fatal === null ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ {/* Fatal toast (failure 3 OR unrecoverable abort). */}
+ {fatal !== null ? (
+
+ {fatal}
+
+ ) : null}
+
+
+ {isProcessing
+ ? "Paiement en cours…"
+ : `Payer ${formatEur(totalsRow.totalCentimes)}`}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/lib/stripe-payment/decide-latching-outcome.test.ts b/apps/web/src/lib/stripe-payment/decide-latching-outcome.test.ts
new file mode 100644
index 00000000..47e52c24
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/decide-latching-outcome.test.ts
@@ -0,0 +1,118 @@
+/**
+ * PWA-S7 (#458) — tests for the pure `decideLatchingOutcome` decision
+ * (US 47, decisions-log Q7 « latching anti-surge avant confirmPayment »).
+ *
+ * At click-Payer the front re-runs `recaptureQuoteAtPayment` (already merged
+ * 2.6-B) to catch a surge/stale fee. This pure decision turns the FRESH
+ * verdict + the CACHED fee (from the panier verdict, US 23) into one of:
+ * - `ok` → proceed straight to payment;
+ * - `confirm-surge` → modal `` blocking, user can OK
+ * (proceed with the higher fee) or cancel (abort);
+ * - `abort` → terminal — the resto closed / quote unavailable / out
+ * of zone between cart and Payer; we cannot charge.
+ *
+ * Only DELIVERY mode is latched: a click & collect order has no fee to surge
+ * (CONTEXT delivery « Frais livraison = 0 »), so the decision short-circuits
+ * to `ok` without calling the action at all.
+ */
+import { describe, expect, it } from "vitest";
+import { decideLatchingOutcome } from "./decide-latching-outcome";
+
+describe("decideLatchingOutcome — click & collect short-circuit", () => {
+ it("returns 'ok' on click & collect regardless of any verdict (no fee to latch)", () => {
+ expect(
+ decideLatchingOutcome({
+ mode: "click_and_collect",
+ cachedFeeCentimes: 0,
+ freshVerdict: null,
+ }),
+ ).toEqual({ kind: "ok" });
+ });
+});
+
+describe("decideLatchingOutcome — delivery mode, fresh verdict deliverable", () => {
+ it("returns 'ok' when the fresh fee equals the cached fee", () => {
+ expect(
+ decideLatchingOutcome({
+ mode: "delivery",
+ cachedFeeCentimes: 350,
+ freshVerdict: {
+ deliverable: true,
+ fee: 350,
+ eta: 1700,
+ quoteId: "qt_2",
+ },
+ }),
+ ).toEqual({ kind: "ok" });
+ });
+
+ it("returns 'ok' when the fresh fee is LOWER than the cached fee (no surcharge → silent)", () => {
+ // Decisions-log Q7 explicit : « si fee monte → modal », not « si fee
+ // change ». A lower fee is a happy event — we silently apply it (the
+ // pricing snapshot is built from the FRESH verdict so the customer is
+ // not over-charged).
+ expect(
+ decideLatchingOutcome({
+ mode: "delivery",
+ cachedFeeCentimes: 500,
+ freshVerdict: {
+ deliverable: true,
+ fee: 300,
+ eta: 1700,
+ quoteId: "qt_3",
+ },
+ }),
+ ).toEqual({ kind: "ok" });
+ });
+
+ it("returns 'confirm-surge' carrying both fees when the fresh fee is HIGHER", () => {
+ expect(
+ decideLatchingOutcome({
+ mode: "delivery",
+ cachedFeeCentimes: 350,
+ freshVerdict: {
+ deliverable: true,
+ fee: 590,
+ eta: 1700,
+ quoteId: "qt_surge",
+ },
+ }),
+ ).toEqual({
+ kind: "confirm-surge",
+ fromCentimes: 350,
+ toCentimes: 590,
+ });
+ });
+});
+
+describe("decideLatchingOutcome — delivery mode, fresh verdict NOT deliverable", () => {
+ it("returns 'abort' on hors_horaire (resto closed between cart and Payer)", () => {
+ expect(
+ decideLatchingOutcome({
+ mode: "delivery",
+ cachedFeeCentimes: 350,
+ freshVerdict: { deliverable: false, reason: "hors_horaire" },
+ }),
+ ).toEqual({ kind: "abort", reason: "hors_horaire" });
+ });
+
+ it("returns 'abort' on surge with no fee (Uber out of capacity)", () => {
+ expect(
+ decideLatchingOutcome({
+ mode: "delivery",
+ cachedFeeCentimes: 350,
+ freshVerdict: { deliverable: false, reason: "surge" },
+ }),
+ ).toEqual({ kind: "abort", reason: "surge" });
+ });
+
+ it("returns 'abort' on hors_zone (address normalised differently / changed)", () => {
+ expect(
+ decideLatchingOutcome({
+ mode: "delivery",
+ cachedFeeCentimes: 350,
+ freshVerdict: { deliverable: false, reason: "hors_zone" },
+ }),
+ ).toEqual({ kind: "abort", reason: "hors_zone" });
+ });
+});
diff --git a/apps/web/src/lib/stripe-payment/decide-latching-outcome.ts b/apps/web/src/lib/stripe-payment/decide-latching-outcome.ts
new file mode 100644
index 00000000..14c72a05
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/decide-latching-outcome.ts
@@ -0,0 +1,92 @@
+/**
+ * PWA-S7 (#458) — pure decision that turns a re-captured Uber Direct verdict
+ * (from the backend `recaptureQuoteAtPayment` action — already merged 2.6-B)
+ * into the user-facing latching outcome (US 47, decisions-log Q7 « latching
+ * anti-surge avant confirmPayment »).
+ *
+ * Why latch at all : the panier verdict is from the cart step (possibly
+ * minutes ago); Uber Direct fees fluctuate with demand/surge. The PWA
+ * re-runs the quote at click-Payer to catch a price drift BEFORE charging.
+ * If the fee went UP → blocking confirm modal. If the resto closed between
+ * cart and click → abort (we cannot create a delivery course on a closed
+ * resto). If unchanged or lower → silent proceed.
+ *
+ * `DeliveryQuoteVerdict` is re-imported from `@/lib/address-first` (the
+ * front-side mirror of the backend `lib/delivery/quote.deliveryQuoteVerdict`
+ * shape, kept local on purpose — see `decide-address-first-action.ts`).
+ *
+ * Click & collect mode SHORT-CIRCUITS to `ok` without calling the action:
+ * a C&C order has no delivery fee to surge (CONTEXT delivery « Frais
+ * livraison = 0 ») so re-quoting is wasted work.
+ */
+import type { DeliveryQuoteVerdict } from "@/lib/address-first";
+import type { DeliveryMode } from "@/lib/delivery-mode";
+
+export type DecideLatchingOutcomeInput = {
+ /** Currently-selected mode (from `useDeliveryMode().mode`). */
+ mode: DeliveryMode;
+ /**
+ * The fee the user agreed to in the panier (from the cached
+ * `DeliveryQuoteVerdict` written by `` at S3 submit;
+ * `0` in click & collect mode).
+ */
+ cachedFeeCentimes: number;
+ /**
+ * The FRESH verdict from the latching action, `null` when the mode is
+ * click & collect (no action call needed — the parent passes `null`).
+ */
+ freshVerdict: DeliveryQuoteVerdict | null;
+};
+
+/** The latching outcome — three mutually exclusive shapes. */
+export type LatchingOutcome =
+ | { kind: "ok" }
+ | {
+ kind: "confirm-surge";
+ /** Cached fee the user agreed to (centimes). */
+ fromCentimes: number;
+ /** Fresh fee the user must now confirm (centimes). */
+ toCentimes: number;
+ }
+ | {
+ kind: "abort";
+ /** Reason returned by the backend verdict. */
+ reason: "hors_zone" | "hors_horaire" | "surge";
+ };
+
+/**
+ * Decide the latching outcome for the click-Payer moment.
+ *
+ * Branches:
+ * - mode = `click_and_collect` → `ok` (no fee to latch, no action call);
+ * - fresh verdict not deliverable → `abort` with the verdict's reason;
+ * - fresh fee > cached fee → `confirm-surge` (modal blocking);
+ * - fresh fee ≤ cached fee → `ok` (the parent uses the fresh fee in the
+ * pricing snapshot, so a lower fee is silently applied — never an
+ * over-charge).
+ */
+export function decideLatchingOutcome(
+ input: DecideLatchingOutcomeInput,
+): LatchingOutcome {
+ if (input.mode === "click_and_collect") {
+ return { kind: "ok" };
+ }
+ if (input.freshVerdict === null) {
+ // Defensive : the parent should always pass a verdict in delivery mode.
+ // A `null` here would mean the parent forgot to call the action — fail
+ // closed (abort) rather than silently charging the cached fee, which
+ // is the very thing latching exists to prevent.
+ return { kind: "abort", reason: "surge" };
+ }
+ if (!input.freshVerdict.deliverable) {
+ return { kind: "abort", reason: input.freshVerdict.reason };
+ }
+ if (input.freshVerdict.fee > input.cachedFeeCentimes) {
+ return {
+ kind: "confirm-surge",
+ fromCentimes: input.cachedFeeCentimes,
+ toCentimes: input.freshVerdict.fee,
+ };
+ }
+ return { kind: "ok" };
+}
diff --git a/apps/web/src/lib/stripe-payment/decide-payment-branch.test.ts b/apps/web/src/lib/stripe-payment/decide-payment-branch.test.ts
new file mode 100644
index 00000000..c123e940
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/decide-payment-branch.test.ts
@@ -0,0 +1,41 @@
+/**
+ * PWA-S7 (#458) — tests for the pure `decidePaymentBranch` decision.
+ *
+ * Branches the payment flow on the customer fiche's `savedPaymentMethodId`:
+ * - present → "saved-card" branch (tile « Payer avec ma carte enregistrée »
+ * + link « Utiliser une autre carte » — US 45).
+ * - absent → "new-card" branch (Stripe Payment Element, US 46).
+ *
+ * The form may also force the new-card branch on user click of the
+ * « Utiliser une autre carte » link (the parent component owns that toggle —
+ * the decision here is the DEFAULT branch derived from the fiche).
+ */
+import { describe, expect, it } from "vitest";
+import { decidePaymentBranch } from "./decide-payment-branch";
+
+describe("decidePaymentBranch — branch on savedPaymentMethodId", () => {
+ it("returns 'new-card' when the customer fiche is null (anonymous first visit)", () => {
+ expect(decidePaymentBranch(null)).toEqual({ kind: "new-card" });
+ });
+
+ it("returns 'new-card' when the fiche has no savedPaymentMethodId", () => {
+ expect(decidePaymentBranch({ savedPaymentMethodId: undefined })).toEqual({
+ kind: "new-card",
+ });
+ });
+
+ it("returns 'saved-card' when the fiche has a savedPaymentMethodId", () => {
+ expect(
+ decidePaymentBranch({ savedPaymentMethodId: "pm_test_123" }),
+ ).toEqual({ kind: "saved-card" });
+ });
+
+ it("ignores a stripeCustomerId without a paymentMethod (Customer reused but card removed)", () => {
+ expect(
+ decidePaymentBranch({
+ stripeCustomerId: "cus_test_123",
+ savedPaymentMethodId: undefined,
+ }),
+ ).toEqual({ kind: "new-card" });
+ });
+});
diff --git a/apps/web/src/lib/stripe-payment/decide-payment-branch.ts b/apps/web/src/lib/stripe-payment/decide-payment-branch.ts
new file mode 100644
index 00000000..01a33c78
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/decide-payment-branch.ts
@@ -0,0 +1,48 @@
+/**
+ * PWA-S7 (#458) — pure decision returning which `/checkout` payment branch
+ * to render by DEFAULT, given the customer's preloaded fiche
+ * (US 45 / 46, decisions-log Q6 « branche saved card vs Payment Element »).
+ *
+ * Rule:
+ * - `savedPaymentMethodId` present on the fiche → "saved-card" tile
+ * (« Payer avec ma carte enregistrée » + a link « Utiliser une autre
+ * carte » the parent component owns to toggle to the new-card branch);
+ * - otherwise → "new-card" branch (Stripe Payment Element, US 46 — Apple
+ * Pay / Google Pay via `automatic_payment_methods`).
+ *
+ * `stripeCustomerId` alone (no PaymentMethod) is INSUFFICIENT — a customer
+ * may have a platform Customer record without an active saved card (e.g.
+ * the card was detached by Stripe or removed manually). Only the
+ * PaymentMethod id is the chargeable handle on the platform side.
+ *
+ * `SavedCardFicheSnapshot` mirrors the SUBSET of `Doc<"customers">` the
+ * decision reads — kept local on purpose, the front never imports the
+ * Convex `Doc` shape (decoupled from the backend module graph, same shape
+ * as `CustomerFicheSnapshot` in `decide-prefill.ts`).
+ */
+
+/** Minimal shape of `customers` row the saved-card branch needs. */
+export type SavedCardFicheSnapshot = {
+ /** Platform Stripe Customer id, KB-account level (NOT tenant). */
+ stripeCustomerId?: string;
+ /** Platform PaymentMethod id saved on the platform Customer. */
+ savedPaymentMethodId?: string;
+};
+
+/** The default payment branch the form should render at mount. */
+export type PaymentBranch = { kind: "saved-card" } | { kind: "new-card" };
+
+/**
+ * Decide the default payment branch for a given customer fiche. A `null`
+ * fiche (anonymous first visit) collapses to "new-card" — no saved card to
+ * surface yet. A fiche without `savedPaymentMethodId` is also "new-card",
+ * even if `stripeCustomerId` is present (platform Customer reused but card
+ * removed — see file header).
+ */
+export function decidePaymentBranch(
+ fiche: SavedCardFicheSnapshot | null,
+): PaymentBranch {
+ if (fiche === null) return { kind: "new-card" };
+ if (fiche.savedPaymentMethodId === undefined) return { kind: "new-card" };
+ return { kind: "saved-card" };
+}
diff --git a/apps/web/src/lib/stripe-payment/decide-retry-action.test.ts b/apps/web/src/lib/stripe-payment/decide-retry-action.test.ts
new file mode 100644
index 00000000..0119c6c3
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/decide-retry-action.test.ts
@@ -0,0 +1,34 @@
+/**
+ * PWA-S7 (#458) — tests for the pure `decideRetryAction` decision
+ * (US 48, acceptance « 1ᵉʳ et 2ᵉ échec → retry inline, 3ᵉ échec → toast +
+ * Sentry log »).
+ *
+ * The decision takes the NEW attempt count AFTER an error (so failure #1 is
+ * attemptsSoFar=1) and returns whether to surface an inline retry banner or
+ * to escalate to a fatal toast + Sentry log.
+ */
+import { describe, expect, it } from "vitest";
+import { decideRetryAction } from "./decide-retry-action";
+
+describe("decideRetryAction", () => {
+ it("returns 'inline-retry' on the 1st failure (attempt #1)", () => {
+ expect(decideRetryAction(1)).toEqual({ kind: "inline-retry" });
+ });
+
+ it("returns 'inline-retry' on the 2nd failure (attempt #2)", () => {
+ expect(decideRetryAction(2)).toEqual({ kind: "inline-retry" });
+ });
+
+ it("returns 'fatal-toast' on the 3rd failure (attempt #3)", () => {
+ expect(decideRetryAction(3)).toEqual({ kind: "fatal-toast" });
+ });
+
+ it("returns 'fatal-toast' on any attempt above 3 (defensive: no infinite retry loop)", () => {
+ expect(decideRetryAction(4)).toEqual({ kind: "fatal-toast" });
+ expect(decideRetryAction(99)).toEqual({ kind: "fatal-toast" });
+ });
+
+ it("treats 0 attempts as 'inline-retry' (defensive — should never happen, parent counts from 1)", () => {
+ expect(decideRetryAction(0)).toEqual({ kind: "inline-retry" });
+ });
+});
diff --git a/apps/web/src/lib/stripe-payment/decide-retry-action.ts b/apps/web/src/lib/stripe-payment/decide-retry-action.ts
new file mode 100644
index 00000000..5493f186
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/decide-retry-action.ts
@@ -0,0 +1,29 @@
+/**
+ * PWA-S7 (#458) — pure decision returning the retry action for a Stripe
+ * payment failure (US 48, acceptance criterion #458 « 1ᵉʳ et 2ᵉ échec →
+ * retry inline, 3ᵉ échec → toast + Sentry log »).
+ *
+ * The decision is stateless — the caller passes the COUNT of attempts so
+ * far (1, 2, 3, …) and gets back whether the next thing the UI should
+ * surface is an inline retry banner or a fatal toast. The transient retry
+ * count is owned by the form (a `useState`); this module is the
+ * single source of truth for the "≤2 → inline / ≥3 → fatal" rule.
+ */
+
+export type RetryAction = { kind: "inline-retry" } | { kind: "fatal-toast" };
+
+/** Maximum number of inline retries before escalating to a fatal toast. */
+export const INLINE_RETRY_THRESHOLD = 2;
+
+/**
+ * Decide the retry action for a given attempt count. Defensive: an
+ * `attemptsSoFar` of `0` (shouldn't happen — parent counts from `1`)
+ * collapses to `inline-retry` so we never hide the retry banner from a
+ * confused state.
+ */
+export function decideRetryAction(attemptsSoFar: number): RetryAction {
+ if (attemptsSoFar <= INLINE_RETRY_THRESHOLD) {
+ return { kind: "inline-retry" };
+ }
+ return { kind: "fatal-toast" };
+}
diff --git a/apps/web/src/lib/stripe-payment/derive-pricing-snapshot.test.ts b/apps/web/src/lib/stripe-payment/derive-pricing-snapshot.test.ts
new file mode 100644
index 00000000..6fe5bcb3
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/derive-pricing-snapshot.test.ts
@@ -0,0 +1,63 @@
+/**
+ * PWA-S7 (#458) — tests for the pure `derivePricingSnapshot` helper.
+ *
+ * Builds the `pricingSnapshot` payload the backend `createPaymentIntent` /
+ * `payWithSavedCard` actions consume (see `packages/backend/convex/table/
+ * orders.ts` `pricingSnapshot` validator). The fields are in CENTIMES, with
+ * the invariant `subtotal + deliveryFee = total` (the IMMUTABLE KB
+ * commission of 240 cts TTC is added BACKEND-SIDE by the actions; it is NOT
+ * part of this snapshot).
+ *
+ * The snapshot is built from:
+ * - the cart subtotal (already computed by `cart-store`);
+ * - the FRESH delivery fee from the latched verdict (anti-surge — the
+ * panier verdict is NEVER used here, otherwise a stale fee would charge).
+ */
+import { describe, expect, it } from "vitest";
+import { derivePricingSnapshot } from "./derive-pricing-snapshot";
+
+describe("derivePricingSnapshot — delivery mode", () => {
+ it("uses the FRESH fee from the latched verdict (anti-surge)", () => {
+ expect(
+ derivePricingSnapshot({
+ mode: "delivery",
+ subtotalCentimes: 2500,
+ latchedFeeCentimes: 590,
+ }),
+ ).toEqual({
+ subtotal: 2500,
+ deliveryFee: 590,
+ total: 3090,
+ });
+ });
+
+ it("derives the total as subtotal + deliveryFee verbatim (no rounding, no fee here)", () => {
+ expect(
+ derivePricingSnapshot({
+ mode: "delivery",
+ subtotalCentimes: 1990,
+ latchedFeeCentimes: 350,
+ }),
+ ).toEqual({
+ subtotal: 1990,
+ deliveryFee: 350,
+ total: 2340,
+ });
+ });
+});
+
+describe("derivePricingSnapshot — click & collect", () => {
+ it("zeros the delivery fee regardless of any latched value (no fee in C&C)", () => {
+ expect(
+ derivePricingSnapshot({
+ mode: "click_and_collect",
+ subtotalCentimes: 1500,
+ latchedFeeCentimes: 999, // ignored
+ }),
+ ).toEqual({
+ subtotal: 1500,
+ deliveryFee: 0,
+ total: 1500,
+ });
+ });
+});
diff --git a/apps/web/src/lib/stripe-payment/derive-pricing-snapshot.ts b/apps/web/src/lib/stripe-payment/derive-pricing-snapshot.ts
new file mode 100644
index 00000000..fbef2535
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/derive-pricing-snapshot.ts
@@ -0,0 +1,54 @@
+/**
+ * PWA-S7 (#458) — pure helper building the `pricingSnapshot` payload the
+ * backend `createPaymentIntent` / `payWithSavedCard` actions consume
+ * (validator: `packages/backend/convex/table/orders.ts pricingSnapshot`).
+ *
+ * Three CENTIMES fields, with invariant `subtotal + deliveryFee = total`:
+ * - `subtotal` — sum of cart line items;
+ * - `deliveryFee` — CLIENT share of the delivery fee (Pricing engine
+ * absorption is a V2 wiring — V1 baseline = gross fee verbatim, per
+ * `decide-cart-totals.ts` head comment); zero in click & collect;
+ * - `total` — subtotal + deliveryFee.
+ *
+ * The KB application fee (240 cts TTC, ADR `APPLICATION_FEE_AMOUNT_TTC`)
+ * is added BACKEND-SIDE by the actions — it is NOT part of this snapshot,
+ * the customer is NEVER charged for it (intrinsic to direct charge, the
+ * resto is merchant of record).
+ *
+ * The `deliveryFee` field MUST come from the FRESH latched verdict (the
+ * one returned by `recaptureQuoteAtPayment`), not the cached panier
+ * verdict — otherwise a stale fee would land in the snapshot and we would
+ * either over-charge (if it dropped) or under-charge (if it rose silently,
+ * which the `decide-latching-outcome.ts` flow forbids). The parent passes
+ * `latchedFeeCentimes` derived from the FRESH verdict.
+ */
+import type { DeliveryMode } from "@/lib/delivery-mode";
+
+export type DerivePricingSnapshotInput = {
+ mode: DeliveryMode;
+ subtotalCentimes: number;
+ /**
+ * The FRESH delivery fee (from the latched verdict). Ignored in click &
+ * collect mode (the snapshot's `deliveryFee` is forced to 0).
+ */
+ latchedFeeCentimes: number;
+};
+
+/** Mirror of backend `pricingSnapshot` validator (cents). */
+export type PricingSnapshotPayload = {
+ subtotal: number;
+ deliveryFee: number;
+ total: number;
+};
+
+export function derivePricingSnapshot(
+ input: DerivePricingSnapshotInput,
+): PricingSnapshotPayload {
+ const deliveryFee =
+ input.mode === "click_and_collect" ? 0 : input.latchedFeeCentimes;
+ return {
+ subtotal: input.subtotalCentimes,
+ deliveryFee,
+ total: input.subtotalCentimes + deliveryFee,
+ };
+}
diff --git a/apps/web/src/lib/stripe-payment/index.ts b/apps/web/src/lib/stripe-payment/index.ts
new file mode 100644
index 00000000..2ae33804
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/index.ts
@@ -0,0 +1,55 @@
+/**
+ * PWA-S7 (#458) — `stripe-payment` module API.
+ *
+ * Pure decisions consumed by the `` (S7) Stripe payment flow
+ * (US 45-49, decisions-log Q6/Q7):
+ *
+ * - `decidePaymentBranch` — given the preloaded customer fiche, returns
+ * whether the form should render the « saved-card » tile (the customer
+ * has a `savedPaymentMethodId` from a previous KB resto, cross-tenant
+ * via Stripe Customer at platform level) or the « new-card » Stripe
+ * Payment Element (US 45 / 46).
+ *
+ * - `decideLatchingOutcome` — given the FRESH verdict from
+ * `recaptureQuoteAtPayment` + the cached panier fee, returns whether
+ * the form should proceed straight to payment (`ok`), surface a
+ * blocking confirm modal because the fee rose (`confirm-surge`), or
+ * abort the payment because the resto closed / went out of zone
+ * between cart and Payer (`abort`). Q7 « latching anti-surge avant
+ * confirmPayment ».
+ *
+ * - `decideRetryAction` — given the count of failed Stripe attempts so
+ * far, returns whether the form should surface an inline retry banner
+ * (≤ 2 attempts) or escalate to a fatal toast + Sentry log
+ * (3rd attempt — US 48 acceptance criterion).
+ *
+ * - `derivePricingSnapshot` — pure helper building the `pricingSnapshot`
+ * payload the backend `createPaymentIntent` / `payWithSavedCard`
+ * actions consume; uses the FRESH latched fee (not the cached panier
+ * fee) to never under/over-charge.
+ *
+ * Splitting the decisions from the React IO keeps every branch
+ * vitest-pinnable in node env, mirroring the `decideAddressFirstAction` /
+ * `decideManifest` / `decidePaymentGate` pattern of the previous PWA slices.
+ */
+export {
+ type PaymentBranch,
+ type SavedCardFicheSnapshot,
+ decidePaymentBranch,
+} from "./decide-payment-branch";
+export {
+ type DecideLatchingOutcomeInput,
+ type LatchingOutcome,
+ decideLatchingOutcome,
+} from "./decide-latching-outcome";
+export {
+ INLINE_RETRY_THRESHOLD,
+ type RetryAction,
+ decideRetryAction,
+} from "./decide-retry-action";
+export {
+ type DerivePricingSnapshotInput,
+ type PricingSnapshotPayload,
+ derivePricingSnapshot,
+} from "./derive-pricing-snapshot";
+export { type SentryContext, captureException } from "./sentry-shim";
diff --git a/apps/web/src/lib/stripe-payment/sentry-shim.ts b/apps/web/src/lib/stripe-payment/sentry-shim.ts
new file mode 100644
index 00000000..9716fabb
--- /dev/null
+++ b/apps/web/src/lib/stripe-payment/sentry-shim.ts
@@ -0,0 +1,35 @@
+/**
+ * PWA-S7 (#458) — Sentry shim.
+ *
+ * Acceptance criterion #458 « 3ᵉ échec → toast + Sentry log visible ». V1
+ * has NO real Sentry SDK wired in `apps/web` (no `@sentry/nextjs` in
+ * `apps/web/package.json` — see `grep -rn '@sentry' apps/`). To keep S7
+ * shippable without inventing the wiring of an unrelated dep, this thin
+ * shim is the SEAM the future Sentry adapter swaps into:
+ * - it preserves the call sites (`captureException(err, { tags })`);
+ * - it logs to `console.error` in the meantime — visible in DevTools, in
+ * `vercel logs`, and in the user's bug reports.
+ *
+ * When the real Sentry SDK lands (a separate slice, out of this issue's
+ * scope), this file becomes a 2-line `export const captureException =
+ * Sentry.captureException` wrapper — no call site change.
+ */
+
+/** Structured context for an exception report (tags/extras the future SDK keys on). */
+export type SentryContext = {
+ tags?: Record;
+ extras?: Record;
+};
+
+/**
+ * Send an exception to Sentry. Until the SDK lands, logs to `console.error`
+ * with the structured context inline.
+ */
+export function captureException(
+ error: unknown,
+ context?: SentryContext,
+): void {
+ // Use `console.error` (not `console.log`) so the entry surfaces in Vercel
+ // logs at ERROR severity, matching what the future Sentry SDK would do.
+ console.error("[sentry-shim] captureException", error, context ?? {});
+}
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index 2ec6528a..559b48cc 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -39,6 +39,7 @@ import type * as lib_crypto_envelope from "../lib/crypto/envelope.js";
import type * as lib_crypto_index from "../lib/crypto/index.js";
import type * as lib_customer_address from "../lib/customer/address.js";
import type * as lib_customer_cgv from "../lib/customer/cgv.js";
+import type * as lib_customer_checkoutContact from "../lib/customer/checkoutContact.js";
import type * as lib_customer_consent from "../lib/customer/consent.js";
import type * as lib_customer_identity from "../lib/customer/identity.js";
import type * as lib_customer_index from "../lib/customer/index.js";
@@ -149,6 +150,7 @@ import type * as lib_tenancy_webPushSubscriptionsStore from "../lib/tenancy/webP
import type * as lib_tenancy_withTenant from "../lib/tenancy/withTenant.js";
import type * as lib_tenants_branding from "../lib/tenants/branding.js";
import type * as lib_tenants_index from "../lib/tenants/index.js";
+import type * as lib_tenants_payment from "../lib/tenants/payment.js";
import type * as lib_tenants_resolution from "../lib/tenants/resolution.js";
import type * as lib_uberDirect_account from "../lib/uberDirect/account.js";
import type * as lib_uberDirect_createDelivery from "../lib/uberDirect/createDelivery.js";
@@ -246,6 +248,7 @@ declare const fullApi: ApiFromModules<{
"lib/crypto/index": typeof lib_crypto_index;
"lib/customer/address": typeof lib_customer_address;
"lib/customer/cgv": typeof lib_customer_cgv;
+ "lib/customer/checkoutContact": typeof lib_customer_checkoutContact;
"lib/customer/consent": typeof lib_customer_consent;
"lib/customer/identity": typeof lib_customer_identity;
"lib/customer/index": typeof lib_customer_index;
@@ -356,6 +359,7 @@ declare const fullApi: ApiFromModules<{
"lib/tenancy/withTenant": typeof lib_tenancy_withTenant;
"lib/tenants/branding": typeof lib_tenants_branding;
"lib/tenants/index": typeof lib_tenants_index;
+ "lib/tenants/payment": typeof lib_tenants_payment;
"lib/tenants/resolution": typeof lib_tenants_resolution;
"lib/uberDirect/account": typeof lib_uberDirect_account;
"lib/uberDirect/createDelivery": typeof lib_uberDirect_createDelivery;
diff --git a/packages/backend/convex/lib/customer/checkoutContact.test.ts b/packages/backend/convex/lib/customer/checkoutContact.test.ts
new file mode 100644
index 00000000..1e28fb2b
--- /dev/null
+++ b/packages/backend/convex/lib/customer/checkoutContact.test.ts
@@ -0,0 +1,228 @@
+import { convexTest } from "convex-test";
+import { beforeEach, describe, expect, it } from "vitest";
+import { api } from "../../_generated/api";
+import type { Id } from "../../_generated/dataModel";
+import schema from "../../schema";
+import {
+ type FuzzActor,
+ runCrossTenantFuzz,
+ seedTwoTenantsAllRoles,
+} from "../tenancy/fuzz";
+
+/**
+ * PWA-S7 (#458) — `recordCheckoutContact`, written BEFORE the implementation
+ * (TDD red). The mutation stamps the firstName + email + phone the customer
+ * submits at the `/checkout` form (US 44 prefill, decisions-log Q3 « Recognition
+ * UX » — capture at the first explicit checkout, surface on the next).
+ *
+ * Surface (`lib/customer/checkoutContact`):
+ * - `recordCheckoutContact({ tenantId, firstName, email, phone })`
+ * (self, via `customerMutation`) — stamps the caller's OWN fiche with
+ * `firstName`, `email`, `phone`. Provisions the fiche on the fly if absent
+ * (same idempotent contract as `updateAddress` / `recordConsentAtCheckout`).
+ * - Re-submit OVERWRITES the previous PII (the customer may correct a typo at
+ * a later checkout — the fiche always reflects the latest submit).
+ * - Self-scoped by construction: keyed on `ctx.actor.userId`, no other
+ * customer's fiche is reachable; identity flows ONLY through
+ * `getCurrentActor` (ADR 0011); the GLOBAL `customers` table is reached ONLY
+ * through the sanctioned tenancy seam (`patchCustomerCheckoutContact`),
+ * never raw `ctx.db` (ADR 0010 / `no-untenanted-query`).
+ */
+
+const rawModules = import.meta.glob([
+ "../../**/*.{ts,js}",
+ "!../../**/*.test.*",
+]);
+const modules = Object.fromEntries(
+ Object.entries(rawModules).map(([path, loader]) => [
+ path.startsWith("./") ? `../../lib/customer/${path.slice(2)}` : path,
+ loader,
+ ]),
+);
+
+type Seed = Awaited>;
+
+async function seedAnonymousCustomer(
+ t: ReturnType,
+): Promise> {
+ return t.run(async (ctx) =>
+ ctx.db.insert("users", { role: "customer", isAnonymous: true }),
+ );
+}
+
+const FIXTURE_CONTACT = {
+ firstName: "Sophie",
+ email: "sophie@example.com",
+ phone: "+33612345678",
+};
+
+const FIXTURE_CONTACT_2 = {
+ firstName: "Sophie M.",
+ email: "sophie.m@example.com",
+ phone: "+33698765432",
+};
+
+describe("PWA-S7 recordCheckoutContact — stamps the caller's own fiche", () => {
+ let t: ReturnType;
+ let seed: Seed;
+ beforeEach(async () => {
+ t = convexTest(schema, modules);
+ seed = await seedTwoTenantsAllRoles(t);
+ });
+
+ it("stamps firstName/email/phone on the caller's own fiche", async () => {
+ const userId = await seedAnonymousCustomer(t);
+ const as = t.withIdentity({ subject: userId });
+
+ await as.mutation(api.lib.customer.identity.getOrCreateCurrentCustomer, {
+ tenantId: seed.tenantA.tenantId,
+ });
+ await as.mutation(api.lib.customer.checkoutContact.recordCheckoutContact, {
+ tenantId: seed.tenantA.tenantId,
+ ...FIXTURE_CONTACT,
+ });
+
+ const fiche = await as.query(api.lib.customer.identity.getCurrentCustomer, {
+ tenantId: seed.tenantA.tenantId,
+ });
+ expect(fiche?.firstName).toBe(FIXTURE_CONTACT.firstName);
+ expect(fiche?.email).toBe(FIXTURE_CONTACT.email);
+ expect(fiche?.phone).toBe(FIXTURE_CONTACT.phone);
+ });
+
+ it("provisions the fiche on the fly if called before getOrCreateCurrentCustomer", async () => {
+ const userId = await seedAnonymousCustomer(t);
+ const as = t.withIdentity({ subject: userId });
+
+ await as.mutation(api.lib.customer.checkoutContact.recordCheckoutContact, {
+ tenantId: seed.tenantA.tenantId,
+ ...FIXTURE_CONTACT,
+ });
+
+ const fiche = await as.query(api.lib.customer.identity.getCurrentCustomer, {
+ tenantId: seed.tenantA.tenantId,
+ });
+ expect(fiche?.firstName).toBe(FIXTURE_CONTACT.firstName);
+ expect(fiche?.email).toBe(FIXTURE_CONTACT.email);
+ expect(fiche?.phone).toBe(FIXTURE_CONTACT.phone);
+ });
+
+ it("overwrites the previous contact on re-submit (typo correction at a later checkout)", async () => {
+ const userId = await seedAnonymousCustomer(t);
+ const as = t.withIdentity({ subject: userId });
+
+ await as.mutation(api.lib.customer.checkoutContact.recordCheckoutContact, {
+ tenantId: seed.tenantA.tenantId,
+ ...FIXTURE_CONTACT,
+ });
+ await as.mutation(api.lib.customer.checkoutContact.recordCheckoutContact, {
+ tenantId: seed.tenantA.tenantId,
+ ...FIXTURE_CONTACT_2,
+ });
+
+ const fiche = await as.query(api.lib.customer.identity.getCurrentCustomer, {
+ tenantId: seed.tenantA.tenantId,
+ });
+ expect(fiche?.firstName).toBe(FIXTURE_CONTACT_2.firstName);
+ expect(fiche?.email).toBe(FIXTURE_CONTACT_2.email);
+ expect(fiche?.phone).toBe(FIXTURE_CONTACT_2.phone);
+ });
+
+ it("preserves siblings (address) when stamping contact fields", async () => {
+ const userId = await seedAnonymousCustomer(t);
+ const as = t.withIdentity({ subject: userId });
+
+ await as.mutation(api.lib.customer.address.updateAddress, {
+ tenantId: seed.tenantA.tenantId,
+ address: "12 rue de la Paix, 75002 Paris, France",
+ lat: 48.8698,
+ lng: 2.3318,
+ });
+ await as.mutation(api.lib.customer.checkoutContact.recordCheckoutContact, {
+ tenantId: seed.tenantA.tenantId,
+ ...FIXTURE_CONTACT,
+ });
+
+ const fiche = await as.query(api.lib.customer.identity.getCurrentCustomer, {
+ tenantId: seed.tenantA.tenantId,
+ });
+ expect(fiche?.address).toBe("12 rue de la Paix, 75002 Paris, France");
+ expect(fiche?.firstName).toBe(FIXTURE_CONTACT.firstName);
+ });
+
+ it("self-scope: a caller only ever stamps its OWN fiche (never another customer's)", async () => {
+ const alice = await seedAnonymousCustomer(t);
+ const bob = await seedAnonymousCustomer(t);
+
+ await t
+ .withIdentity({ subject: alice })
+ .mutation(api.lib.customer.checkoutContact.recordCheckoutContact, {
+ tenantId: seed.tenantA.tenantId,
+ ...FIXTURE_CONTACT,
+ });
+
+ const bobFiche = await t
+ .withIdentity({ subject: bob })
+ .query(api.lib.customer.identity.getCurrentCustomer, {
+ tenantId: seed.tenantA.tenantId,
+ });
+ expect(bobFiche?.firstName).toBeUndefined();
+ expect(bobFiche?.email).toBeUndefined();
+ expect(bobFiche?.phone).toBeUndefined();
+ });
+});
+
+describe("PWA-S7 recordCheckoutContact — auth gate (Unauthenticated / Forbidden)", () => {
+ let t: ReturnType;
+ let seed: Seed;
+ beforeEach(async () => {
+ t = convexTest(schema, modules);
+ seed = await seedTwoTenantsAllRoles(t);
+ });
+
+ it("throws Unauthenticated for an anonymous (no-identity) caller", async () => {
+ await expect(
+ t.mutation(api.lib.customer.checkoutContact.recordCheckoutContact, {
+ tenantId: seed.tenantA.tenantId,
+ ...FIXTURE_CONTACT,
+ }),
+ ).rejects.toThrow(/unauthenticated/i);
+ });
+
+ it("throws Forbidden for a PRO (kb_admin) — customer wrapper is customers only", async () => {
+ await expect(
+ t
+ .withIdentity({ subject: seed.adminId })
+ .mutation(api.lib.customer.checkoutContact.recordCheckoutContact, {
+ tenantId: seed.tenantA.tenantId,
+ ...FIXTURE_CONTACT,
+ }),
+ ).rejects.toThrow(/forbidden/i);
+ });
+});
+
+describe("PWA-S7 recordCheckoutContact — cross-tenant fuzz (ADR 0010)", () => {
+ let t: ReturnType;
+ let seed: Seed;
+ beforeEach(async () => {
+ t = convexTest(schema, modules);
+ seed = await seedTwoTenantsAllRoles(t);
+ });
+
+ it("rejects unauthorized GLOBAL actors (kb_admin + anonymous) on the customer-self surface", async () => {
+ // The customer wrapper accepts ONLY a `customer` role; the cross-tenant
+ // fuzz harness pins that no GLOBAL non-customer actor leaks through.
+ const { leaks, pairs } = await runCrossTenantFuzz(t, {
+ functions: [api.lib.customer.checkoutContact.recordCheckoutContact],
+ isQuery: () => false,
+ tenantId: seed.tenantA.tenantId,
+ extraArgs: FIXTURE_CONTACT,
+ actors: [
+ { label: "kb_admin", subject: seed.adminId },
+ { label: "anonymous", subject: null },
+ ] satisfies FuzzActor[],
+ });
+ expect(pairs).toBe(2);
+ expect(leaks).toEqual([]);
+ });
+});
diff --git a/packages/backend/convex/lib/customer/checkoutContact.ts b/packages/backend/convex/lib/customer/checkoutContact.ts
new file mode 100644
index 00000000..cccd4ca6
--- /dev/null
+++ b/packages/backend/convex/lib/customer/checkoutContact.ts
@@ -0,0 +1,48 @@
+import { v } from "convex/values";
+import {
+ customerMutation,
+ getOrCreateCustomerFiche,
+ patchCustomerCheckoutContact,
+} from "../tenancy";
+
+/**
+ * PWA-S7 (#458) — `recordCheckoutContact`: stamp the firstName + email + phone
+ * the customer submits at the `/checkout` form on the caller's OWN `customers`
+ * fiche (US 44 prefill loop, decisions-log Q3 « Recognition UX » — capture at
+ * the FIRST checkout, surface at the NEXT). Closes the gap the
+ * `decide-prefill.ts` comment in `apps/web/src/lib/checkout-gate/` flagged as
+ * « S7 scope, see lib/customer/consent.ts » — `recordConsentAtCheckout` only
+ * stamps the CGV hash, not the PII trio.
+ *
+ * Re-submitting from a later checkout OVERWRITES the previous PII — the
+ * customer can correct a typo on their next order; the fiche reflects the
+ * latest submit (same robustness as `updateAddress`).
+ *
+ * Self-scoped via `customerMutation`: the handler only ever sees
+ * `ctx.actor.userId` (resolved by `getCurrentActor`, ADR 0011), and reaches the
+ * GLOBAL `customers` table ONLY through the sanctioned tenancy seam
+ * (`patchCustomerCheckoutContact`) — never raw `ctx.db` in this business module
+ * (ADR 0010 / `no-untenanted-query`). Tolerates a missing fiche (provisions one
+ * on the fly via `getOrCreateCustomerFiche`) so the chain stays robust if a
+ * future refactor collapses `getOrCreateCurrentCustomer` + this mutation.
+ *
+ * `tenantId` is an explicit wrapper argument (the customer is a guest eater on
+ * a given resto's PWA — see `customerMutation` factory). It is NOT stamped on
+ * the GLOBAL fiche; per-tenant link lives on `customerOrdersPerTenant` (written
+ * by Orders chantier).
+ */
+export const recordCheckoutContact = customerMutation({
+ args: {
+ firstName: v.string(),
+ email: v.string(),
+ phone: v.string(),
+ },
+ handler: async (ctx, { firstName, email, phone }): Promise => {
+ const customerId = await getOrCreateCustomerFiche(ctx, ctx.actor.userId);
+ await patchCustomerCheckoutContact(ctx, customerId, {
+ firstName,
+ email,
+ phone,
+ });
+ },
+});
diff --git a/packages/backend/convex/lib/customer/index.ts b/packages/backend/convex/lib/customer/index.ts
index 2edcabd3..8608f378 100644
--- a/packages/backend/convex/lib/customer/index.ts
+++ b/packages/backend/convex/lib/customer/index.ts
@@ -95,6 +95,7 @@
*/
export { getCurrentCustomer, getOrCreateCurrentCustomer } from "./identity";
export { updateAddress } from "./address";
+export { recordCheckoutContact } from "./checkoutContact";
export {
marketingEligible,
type MarketingEligibilityInput,
diff --git a/packages/backend/convex/lib/tenancy/customerFiche.ts b/packages/backend/convex/lib/tenancy/customerFiche.ts
index 9210d00c..babf86fd 100644
--- a/packages/backend/convex/lib/tenancy/customerFiche.ts
+++ b/packages/backend/convex/lib/tenancy/customerFiche.ts
@@ -119,6 +119,42 @@ export async function patchCustomerAddress(
await ctx.db.patch(customerId, patch);
}
+/** The checkout PII fields the PWA captures at `/checkout` submit (PWA-S7 #458). */
+export type CustomerCheckoutContactPatch = {
+ firstName: string;
+ email: string;
+ phone: string;
+};
+
+/**
+ * PWA-S7 (#458) — stamp the firstName/email/phone the customer submits at the
+ * `/checkout` form on a fiche the caller already resolved as its OWN (the
+ * `customerId` MUST come from `getOrCreateCustomerFiche` / a self-scoped read
+ * keyed on `ctx.actor.userId`). Captures the trio at the FIRST checkout, so the
+ * NEXT `/checkout` visit hydrates the form from the fiche (US 44 prefill,
+ * decisions-log Q3 « Recognition UX »).
+ *
+ * Re-submit OVERWRITES the previous PII (the customer may correct a typo at a
+ * later checkout — the fiche always reflects the latest submit). The fields are
+ * non-optional in the patch shape because the form forbids empty submission
+ * (`required` on every ` `); a caller cannot smuggle a partial-undefined
+ * patch through this seam.
+ *
+ * The single sanctioned `ctx.db.patch` site for these fields on the GLOBAL
+ * `customers` table; the business module `lib/customer/checkoutContact` (NOT
+ * exempt) calls THIS instead of raw `ctx.db` (ADR 0010). The narrow patch type
+ * keeps it confined to the checkout-contact surface — it cannot overwrite
+ * identity (`userId`), consent fields, the address, the saved-card link, or
+ * `pushEnrollment`.
+ */
+export async function patchCustomerCheckoutContact(
+ ctx: MutationCtx,
+ customerId: Id<"customers">,
+ patch: CustomerCheckoutContactPatch,
+): Promise {
+ await ctx.db.patch(customerId, patch);
+}
+
/**
* 2.5-D — stamp the saved-card link (the platform Stripe `Customer` + the
* PaymentMethod saved on it) onto a fiche the caller already resolved as its OWN
diff --git a/packages/backend/convex/lib/tenancy/index.ts b/packages/backend/convex/lib/tenancy/index.ts
index 473a67ff..cb7f179f 100644
--- a/packages/backend/convex/lib/tenancy/index.ts
+++ b/packages/backend/convex/lib/tenancy/index.ts
@@ -46,11 +46,13 @@ export {
export { customerMutation, customerQuery, publicTenantQuery } from "./customer";
export {
type CustomerAddressPatch,
+ type CustomerCheckoutContactPatch,
type CustomerConsentPatch,
anonymizeCustomerFiche,
getOrCreateCustomerFiche,
insertCustomerFiche,
patchCustomerAddress,
+ patchCustomerCheckoutContact,
patchCustomerConsent,
readCustomerFicheById,
readCustomerFicheByUser,
diff --git a/packages/backend/convex/lib/tenants/index.ts b/packages/backend/convex/lib/tenants/index.ts
index 55865c12..6f6b79b0 100644
--- a/packages/backend/convex/lib/tenants/index.ts
+++ b/packages/backend/convex/lib/tenants/index.ts
@@ -19,3 +19,4 @@
*/
export * as resolution from "./resolution";
export * as branding from "./branding";
+export * as payment from "./payment";
diff --git a/packages/backend/convex/lib/tenants/payment.test.ts b/packages/backend/convex/lib/tenants/payment.test.ts
new file mode 100644
index 00000000..3e5792c6
--- /dev/null
+++ b/packages/backend/convex/lib/tenants/payment.test.ts
@@ -0,0 +1,154 @@
+import { convexTest } from "convex-test";
+import { beforeEach, describe, expect, it } from "vitest";
+import { api } from "../../_generated/api";
+import type { Id } from "../../_generated/dataModel";
+import schema from "../../schema";
+import {
+ type FuzzActor,
+ runCrossTenantFuzz,
+ seedTwoTenantsAllRoles,
+} from "../tenancy/fuzz";
+
+/**
+ * PWA-S7 (#458) — `paymentContextForCustomer.forCheckout`, written
+ * BEFORE the implementation (TDD red).
+ *
+ * Surface (`lib/tenants/payment`):
+ * - `forCheckout({ tenantId })` (self via `customerQuery`) — surfaces
+ * `{ tenantId, stripeAccountId, stripeStatus }` for an AUTHENTICATED
+ * customer on the resto's PWA, so `` can
+ * mount Elements with `stripeAccount: acct_…`.
+ * - The wrapper rejects non-customer callers (PRO, anonymous) —
+ * `customerQuery`. A tenant missing `stripeAccountId` returns `null`
+ * for both fields (the front renders the « pas prêt » fallback).
+ */
+
+const rawModules = import.meta.glob([
+ "../../**/*.{ts,js}",
+ "!../../**/*.test.*",
+]);
+const modules = Object.fromEntries(
+ Object.entries(rawModules).map(([path, loader]) => [
+ path.startsWith("./") ? `../../lib/tenants/${path.slice(2)}` : path,
+ loader,
+ ]),
+);
+
+type Seed = Awaited>;
+
+async function seedAnonymousCustomer(
+ t: ReturnType,
+): Promise> {
+ return t.run(async (ctx) =>
+ ctx.db.insert("users", { role: "customer", isAnonymous: true }),
+ );
+}
+
+describe("PWA-S7 forCheckout — surfaces stripeAccountId + stripeStatus to the customer", () => {
+ let t: ReturnType;
+ let seed: Seed;
+ beforeEach(async () => {
+ t = convexTest(schema, modules);
+ seed = await seedTwoTenantsAllRoles(t);
+ });
+
+ it("returns the tenant's stripeAccountId + 'ready' when onboarded", async () => {
+ // Stamp the tenant as Stripe-ready (mirrors what 2.5-A account.updated
+ // webhook produces in prod).
+ await t.run(async (ctx) => {
+ await ctx.db.patch(seed.tenantA.tenantId, {
+ stripeAccountId: "acct_test_ready",
+ stripeStatus: "ready",
+ });
+ });
+ const userId = await seedAnonymousCustomer(t);
+ const got = await t
+ .withIdentity({ subject: userId })
+ .query(api.lib.tenants.payment.forCheckout, {
+ tenantId: seed.tenantA.tenantId,
+ });
+ expect(got).toEqual({
+ tenantId: seed.tenantA.tenantId,
+ stripeAccountId: "acct_test_ready",
+ stripeStatus: "ready",
+ });
+ });
+
+ it("returns null for both fields when the tenant has no Stripe account yet", async () => {
+ const userId = await seedAnonymousCustomer(t);
+ const got = await t
+ .withIdentity({ subject: userId })
+ .query(api.lib.tenants.payment.forCheckout, {
+ tenantId: seed.tenantA.tenantId,
+ });
+ expect(got.stripeAccountId).toBeNull();
+ expect(got.stripeStatus).toBeNull();
+ });
+
+ it("surfaces 'pending' for a tenant whose KYC is incomplete", async () => {
+ await t.run(async (ctx) => {
+ await ctx.db.patch(seed.tenantA.tenantId, {
+ stripeAccountId: "acct_test_pending",
+ stripeStatus: "pending",
+ });
+ });
+ const userId = await seedAnonymousCustomer(t);
+ const got = await t
+ .withIdentity({ subject: userId })
+ .query(api.lib.tenants.payment.forCheckout, {
+ tenantId: seed.tenantA.tenantId,
+ });
+ expect(got.stripeStatus).toBe("pending");
+ });
+});
+
+describe("PWA-S7 forCheckout — auth gate (Unauthenticated / Forbidden)", () => {
+ let t: ReturnType;
+ let seed: Seed;
+ beforeEach(async () => {
+ t = convexTest(schema, modules);
+ seed = await seedTwoTenantsAllRoles(t);
+ });
+
+ it("throws Unauthenticated for an anonymous (no-identity) caller", async () => {
+ await expect(
+ t.query(api.lib.tenants.payment.forCheckout, {
+ tenantId: seed.tenantA.tenantId,
+ }),
+ ).rejects.toThrow(/unauthenticated/i);
+ });
+
+ it("throws Forbidden for a PRO (kb_admin) — customer wrapper is customers only", async () => {
+ await expect(
+ t
+ .withIdentity({ subject: seed.adminId })
+ .query(api.lib.tenants.payment.forCheckout, {
+ tenantId: seed.tenantA.tenantId,
+ }),
+ ).rejects.toThrow(/forbidden/i);
+ });
+});
+
+describe("PWA-S7 forCheckout — cross-tenant fuzz (ADR 0010)", () => {
+ let t: ReturnType;
+ let seed: Seed;
+ beforeEach(async () => {
+ t = convexTest(schema, modules);
+ seed = await seedTwoTenantsAllRoles(t);
+ });
+
+ it("rejects unauthorized GLOBAL actors (kb_admin + anonymous) on the customer-self surface", async () => {
+ const { leaks, pairs } = await runCrossTenantFuzz(t, {
+ functions: [api.lib.tenants.payment.forCheckout],
+ isQuery: () => true,
+ tenantId: seed.tenantA.tenantId,
+ extraArgs: {},
+ actors: [
+ { label: "kb_admin", subject: seed.adminId },
+ { label: "anonymous", subject: null },
+ ] satisfies FuzzActor[],
+ });
+ expect(pairs).toBe(2);
+ expect(leaks).toEqual([]);
+ });
+});
diff --git a/packages/backend/convex/lib/tenants/payment.ts b/packages/backend/convex/lib/tenants/payment.ts
new file mode 100644
index 00000000..a9543f36
--- /dev/null
+++ b/packages/backend/convex/lib/tenants/payment.ts
@@ -0,0 +1,73 @@
+import { v } from "convex/values";
+import type { Id } from "../../_generated/dataModel";
+import { customerQuery, getTenantById } from "../tenancy";
+
+/**
+ * PWA-S7 (#458) — `paymentContextForCustomer`: the connected-account
+ * snapshot the PWA `/checkout` needs to initialise Stripe Elements with
+ * `loadStripe(NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, { stripeAccount })`
+ * (US 46, decisions-log Q6 « direct charge on the resto's connected
+ * account »).
+ *
+ * Two narrow fields surfaced — `stripeAccountId` (the `acct_…`) + the
+ * `stripeStatus` (the onboarding lifecycle gate) — so the front can:
+ * - mount Elements on the right connected account, OR
+ * - render a « Le resto n'accepte pas encore les paiements en ligne »
+ * fallback when `stripeStatus !== "ready"` (the backend `createPaymentIntent`
+ * refuses such a tenant anyway, but we want to avoid mounting Stripe
+ * for nothing).
+ *
+ * Wrapper: `customerQuery` (NOT `publicTenantQuery` / not the unauth
+ * `resolution.byId`). Why : the connected `acct_…` is NOT a CGU-published
+ * piece of data — it surfaces ONLY to authenticated customers actively
+ * on the resto's PWA `/checkout`. The `customerMutation` factory rejects
+ * non-customer callers (PRO, anonymous), so the surface is structurally
+ * minimal — only an authenticated eater can read it. The information
+ * itself is non-sensitive (just a public id), but limiting its read
+ * surface follows the same projection discipline as `resolution.ts` +
+ * `branding.ts` (NEVER surface `Doc<"tenants">` to the wire).
+ *
+ * Tenant-not-found returns `null` so the front renders the same
+ * « Resto non disponible » degraded state as the rest of the PWA chain
+ * (no exception across the wire).
+ */
+
+export type TenantPaymentContextProjection = {
+ tenantId: Id<"tenants">;
+ /** Connected `acct_…` to pass to `loadStripe(pk, { stripeAccount })`. */
+ stripeAccountId: string | null;
+ /** Stripe onboarding lifecycle gate from the `account.updated` webhook. */
+ stripeStatus: "pending" | "ready" | "disabled" | null;
+};
+
+export const forCheckout = customerQuery({
+ args: {},
+ returns: v.object({
+ tenantId: v.id("tenants"),
+ stripeAccountId: v.union(v.string(), v.null()),
+ stripeStatus: v.union(
+ v.literal("pending"),
+ v.literal("ready"),
+ v.literal("disabled"),
+ v.null(),
+ ),
+ }),
+ handler: async (ctx): Promise => {
+ const row = await getTenantById(ctx, ctx.tenantId);
+ if (row === null) {
+ // The `customerQuery` wrapper does not gate on the tenant existing
+ // (that's the role of `publicTenantQuery`); a dangling cookie /
+ // deleted tenant should surface to the front without an exception.
+ return {
+ tenantId: ctx.tenantId,
+ stripeAccountId: null,
+ stripeStatus: null,
+ };
+ }
+ return {
+ tenantId: ctx.tenantId,
+ stripeAccountId: row.stripeAccountId ?? null,
+ stripeStatus: row.stripeStatus ?? null,
+ };
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b8b45c25..79577800 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -504,6 +504,12 @@ importers:
"@packages/backend":
specifier: workspace:*
version: link:../../packages/backend
+ "@stripe/react-stripe-js":
+ specifier: ^6.6.0
+ version: 6.6.0(@stripe/stripe-js@9.8.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ "@stripe/stripe-js":
+ specifier: ^9.8.0
+ version: 9.8.0
"@tabler/icons-react":
specifier: ^3.36.1
version: 3.44.0(react@19.2.3)
@@ -5976,6 +5982,23 @@ packages:
integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==,
}
+ "@stripe/react-stripe-js@6.6.0":
+ resolution:
+ {
+ integrity: sha512-utODPiu2/JGjCnh5BX1M1F2uyjCwDKum4Bo8CeWdTCNOlzM0980YadzBMe7YoIwjfu3uadX4PMe3L2SderejqA==,
+ }
+ peerDependencies:
+ "@stripe/stripe-js": ">=9.5.0 <10.0.0"
+ react: 19.2.3
+ react-dom: 19.2.3
+
+ "@stripe/stripe-js@9.8.0":
+ resolution:
+ {
+ integrity: sha512-DHJpol/98VKyojNSYmpkB5vOMnlf87hPe0wPxyaYTNiTMk5QjKMXDfSZLwGctYIXAgAWDFeRABc8lFAj0BELyw==,
+ }
+ engines: { node: ">=12.16" }
+
"@swc/helpers@0.5.15":
resolution:
{
@@ -18038,6 +18061,15 @@ snapshots:
"@standard-schema/utils@0.3.0": {}
+ "@stripe/react-stripe-js@6.6.0(@stripe/stripe-js@9.8.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)":
+ dependencies:
+ "@stripe/stripe-js": 9.8.0
+ prop-types: 15.8.1
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+
+ "@stripe/stripe-js@9.8.0": {}
+
"@swc/helpers@0.5.15":
dependencies:
tslib: 2.8.1