From 1d2a87e83716108b47d68e85af2f941b6b548552 Mon Sep 17 00:00:00 2001 From: mahmutkaya Date: Sat, 5 Sep 2026 21:46:24 +0200 Subject: [PATCH 1/2] feat(payments): the restaurant's own onboarding page, minting a fresh Stripe link per request (E4) --- .../onboarding/payments/[token]/page.tsx | 76 +++++++++++++++++ lib/connect-account-links.ts | 64 ++++++++++++++ lib/connect-account-store.ts | 54 +++++++++++- lib/onboarding-payments.ts | 76 +++++++++++++++++ lib/stripe-connect-accounts.ts | 33 +++++++- messages/ar.json | 9 ++ messages/de.json | 9 ++ messages/en.json | 9 ++ messages/fr.json | 9 ++ messages/nl.json | 9 ++ messages/tr.json | 9 ++ .../migration.sql | 35 ++++++++ prisma/schema.prisma | 12 +++ tests/unit/connect-account-links.test.ts | 83 +++++++++++++++++++ tests/unit/connect-account-store.test.ts | 7 ++ vitest.config.ts | 5 ++ 16 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 app/[locale]/onboarding/payments/[token]/page.tsx create mode 100644 lib/connect-account-links.ts create mode 100644 lib/onboarding-payments.ts create mode 100644 prisma/migrations/20260905210000_connect_account_onboarding_token/migration.sql create mode 100644 tests/unit/connect-account-links.test.ts diff --git a/app/[locale]/onboarding/payments/[token]/page.tsx b/app/[locale]/onboarding/payments/[token]/page.tsx new file mode 100644 index 0000000..b501730 --- /dev/null +++ b/app/[locale]/onboarding/payments/[token]/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import { SITE_URL } from "@/lib/seo"; +import { resolvePaymentsLink } from "@/lib/onboarding-payments"; + +// RUNTIME, never prerendered, and never indexed. +// +// Every request must mint a NEW Stripe Account Link: one lives 300 seconds +// (measured), and two calls return two different URLs. A cached page would hand +// a restaurant a dead link and a static one could not exist at all. `noindex` for +// the obvious reason — the URL is the credential. +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "onboardingPayments" }); + return { title: t("meta.title"), robots: { index: false, follow: false } }; +} + +/** + * The one door between a restaurant and Stripe's hosted onboarding (ADR-011 + * amendment, E4). + * + * UNAUTHENTICATED by necessity, and that is why the path carries a 32-byte token + * rather than a slug: the restaurant has no login here — they log into their own + * tenant app, never into the control plane — and the link this page produces is a + * bearer capability over their KYC and their payout bank account. CLAUDE.md §5.1 + * governs the `(control)` plane; this page is deliberately NOT in it. It is on the + * public site, beside `/signup`, because its visitor is a member of the public, + * and it holds the same obligation the five unauthenticated control surfaces + * hold: it answers the same way to every wrong input, so it cannot be asked + * whether a token is nearly right. + * + * It never renders a Stripe URL and never stores one. On success it redirects; the + * body below only exists for the two states where it cannot. + */ +export default async function OnboardingPaymentsPage({ + params, +}: { + params: Promise<{ locale: string; token: string }>; +}) { + const { locale, token } = await params; + const t = await getTranslations({ locale, namespace: "onboardingPayments" }); + + // The page's OWN url becomes Stripe's refresh_url and return_url, so it is built + // from the request that actually arrived rather than from a constant: a tenant on + // a partner's own zone, or staging, must come back to where it left. + const host = (await headers()).get("host"); + const origin = host ? `https://${host}` : SITE_URL; + const outcome = await resolvePaymentsLink(token, `${origin}/${locale}/onboarding/payments/${token}`); + + // Outside the try/catch-free zone on purpose: `redirect` throws by design in + // Next, so it must be called where nothing will swallow it. + if (outcome.kind === "redirect") redirect(outcome.url); + + const body = outcome.kind === "unknownToken" ? "unknownToken" : "unavailable"; + return ( + <> +
+
+

{t("title")}

+

{t(body)}

+

{t("contact")}

+
+