Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions app/[locale]/onboarding/payments/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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,
}: Readonly<{
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 (
<>
<Header />
<main className="mx-auto grid max-w-2xl gap-4 px-6 py-24">
<h1 className="font-display text-3xl text-foreground">{t("title")}</h1>
<p className="font-body text-base text-muted-foreground">{t(body)}</p>
<p className="font-body text-sm text-muted-foreground">{t("contact")}</p>
</main>
<Footer />
</>
);
}
64 changes: 64 additions & 0 deletions lib/connect-account-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// WHICH Stripe link a restaurant should be sent to, and what to ask Stripe for
// (ADR-011 amendment, slice E4). Pure: no network, no env, no clock.
//
// There are two links, not one, and the choice is not cosmetic — MEASURED
// 2026-09-05 on a fresh CH Express account (deleted afterwards; GET -> 403):
//
// POST /v1/account_links type=account_onboarding -> 200, and
// `expires_at - created` = 300 SECONDS. Two calls return two DIFFERENT
// urls, so a link is a one-shot handle, never a stored address.
// POST /v1/accounts/{id}/login_links -> 400 "Cannot create a login
// link for an account that has not completed onboarding."
// POST /v1/account_links type=account_update -> 400 "You cannot create
// `account_update` type Account Links for this account. Valid types for
// this account are ["account_onboarding"]."
//
// So: before onboarding, only an onboarding link exists; after it, only a login
// link does; and `account_update` — the obvious "let them edit their details"
// choice — does not exist on Express at all. Sending the wrong one is a 400 in
// the restaurant's face at the exact moment they are trying to get paid.

/** What Stripe reports about an account, reduced to the fields that decide. */
export type ConnectAccountState = {
/** True once the restaurant has finished Stripe's hosted form. */
details_submitted?: boolean;
};

export type ConnectLinkKind = "onboarding" | "login";

/**
* `details_submitted` and nothing else.
*
* NOT `charges_enabled`: an account can have submitted everything and still be
* under review, and in that window `charges_enabled` is false while the
* onboarding link is refused — asking for the onboarding link there would show a
* restaurant a form it has already filled in. `details_submitted` is precisely
* the fact the login-link refusal is worded against ("has not completed
* onboarding").
*/
export function chooseConnectLink(account: ConnectAccountState): ConnectLinkKind {
return account.details_submitted === true ? "login" : "onboarding";
}

/**
* The `POST /v1/account_links` form.
*
* Both URLs point back at OUR page, and both are the same URL on purpose:
*
* - `refresh_url` is where Stripe sends the restaurant when the link has died —
* it lasts 300 seconds, so this is an ordinary event, not an error. Our page
* mints a fresh link on every request, so landing back on it simply continues
* the journey. Anything else here (an error page, the marketing site) would
* strand someone mid-KYC for having taken a phone call.
* - `return_url` is where they land when they are done. The same page then
* chooses the LOGIN link instead (see `chooseConnectLink`), which is what a
* finished restaurant should get.
*/
export function onboardingLinkForm(accountId: string, pageUrl: string): Record<string, string> {
return {
account: accountId,
type: "account_onboarding",
refresh_url: pageUrl,
return_url: pageUrl,
};
}
54 changes: 52 additions & 2 deletions lib/connect-account-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
// decisions that are easy to get wrong — what the key is, and what the write is
// keyed on — are then decidable by a unit test with no DB and no network.

import { randomBytes } from "node:crypto";

import { db } from "@/lib/db";

/**
Expand Down Expand Up @@ -63,12 +65,33 @@ export function connectExpressIdempotencyKey(slug: string): string {
return `${slug}-${CONNECT_KEY_SUFFIX}`;
}

/**
* The unguessable half of `/onboarding/payments/<token>` (E4).
*
* That page mints a Stripe Account Link and redirects to it, and an Account Link
* is a BEARER capability: whoever opens it can submit this restaurant's KYC and
* set the bank account its money is paid into. So the page cannot be addressed by
* slug — `?slug=rumi` would be an open door onto a real restaurant's payout
* details. 32 random bytes from `node:crypto`, base64url so it survives a URL and
* an email client, and never derived from anything about the tenant.
*
* It is long-lived on purpose, unlike the 300-second link it produces: it travels
* in the tenant's own env (`Stripe__PaymentsLinkUrl`) and in a welcome mail, both
* of which outlive five minutes. Its blast radius is one restaurant's onboarding,
* and it sits beside a box `.env` that already holds the platform Stripe key —
* strictly less powerful than what is next to it.
*/
export function newOnboardingToken(): string {
return randomBytes(32).toString("base64url");
}

/** One row of `StripeConnectAccount`, exactly as the database takes it. */
export type ConnectAccountRow = {
tenantSlug: string;
stripeAccountId: string;
idempotencyKey: string;
country: string;
onboardingToken: string;
};

/**
Expand Down Expand Up @@ -106,11 +129,38 @@ export function connectAccountUpsert(row: ConnectAccountRow): ConnectAccountUpse
export async function findConnectAccountForSlug(slug: string): Promise<ConnectAccountRow | null> {
const row = await db.stripeConnectAccount.findUnique({
where: { tenantSlug: slug },
select: { tenantSlug: true, stripeAccountId: true, idempotencyKey: true, country: true },
select: SELECT,
});
return row;
return row?.onboardingToken ? { ...row, onboardingToken: row.onboardingToken } : null;
}

/**
* The account a `/onboarding/payments/<token>` request is about, or null.
*
* Null covers both "no such token" and "a row minted before tokens existed", and
* the caller must answer both with the SAME 404: a page that distinguished them
* would confirm to a stranger that a given token is nearly right.
*/
export async function findConnectAccountByToken(token: string): Promise<ConnectAccountRow | null> {
// A blank token would otherwise match a NULL-free `findUnique` on nothing —
// cheap to refuse, and the one input an empty URL segment produces.
if (!token) return null;
const row = await db.stripeConnectAccount.findUnique({
where: { onboardingToken: token },
select: SELECT,
});
return row?.onboardingToken ? { ...row, onboardingToken: row.onboardingToken } : null;
}

/** The columns every read here returns — one list, so two readers cannot drift. */
const SELECT = {
tenantSlug: true,
stripeAccountId: true,
idempotencyKey: true,
country: true,
onboardingToken: true,
} as const;

/**
* Record a minted account. Called immediately after `POST /v1/accounts` returns
* and before the registry PR is composed.
Expand Down
76 changes: 76 additions & 0 deletions lib/onboarding-payments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// What `/onboarding/payments/<token>` should do with one request (E4).
//
// The restaurant's whole journey into Stripe runs through this: a welcome mail
// and the tenant's own Payments tab both point at that page, never at a Stripe
// URL, because an Account Link lives 300 SECONDS (measured) and is dead before
// most people finish reading an email.
//
// Every branch is a decision about a person mid-onboarding, so none of them may
// throw and none may be vague:
//
// - unknown token -> 404, and the SAME 404 for a row that predates
// tokens. Telling them apart would confirm to a
// stranger that a token is nearly right.
// - onboarding unfinished -> a FRESH onboarding link, every single request.
// This is also the `refresh_url` landing, i.e. the
// restaurant took a phone call and their link
// expired — the ordinary case, not an error.
// - onboarding finished -> the Express dashboard LOGIN link instead. Asking
// for an onboarding link there shows someone a form
// they already filled in; `account_update` links do
// not exist on Express at all (400, measured).
// - Stripe unreachable -> say so plainly and let them retry. Never a blank
// page, and never a Stripe error string.

import { findConnectAccountByToken } from "@/lib/connect-account-store";
import { chooseConnectLink } from "@/lib/connect-account-links";
import {
createLoginLink,
createOnboardingLink,
readConnectAccount,
} from "@/lib/stripe-connect-accounts";
import { stripeConfigured } from "@/lib/stripe";

export type PaymentsLinkOutcome =
| { kind: "redirect"; url: string }
| { kind: "unknownToken" }
| { kind: "unavailable" };

/**
* @param token the URL segment, exactly as received.
* @param pageUrl this page's own absolute URL — handed in rather than derived
* here, because it becomes Stripe's `refresh_url`/`return_url` and a wrong
* origin would send a restaurant somewhere that does not exist. The route
* builds it from the request it actually received.
*/
export async function resolvePaymentsLink(token: string, pageUrl: string): Promise<PaymentsLinkOutcome> {
// The two failures are NOT the same answer, and collapsing them is the exact
// fail-quiet this repo keeps catching: a database outage would otherwise tell a
// restaurant "this link is not one we recognise" — a confident falsehood about
// their own link, sending them to hunt for a better one that does not exist —
// when the truthful sentence is "we could not reach it, try again in a minute".
let account;
try {
account = await findConnectAccountByToken(token);
} catch (e) {
console.error("resolvePaymentsLink could not read the token", e);
return { kind: "unavailable" };
}
if (!account) return { kind: "unknownToken" };
if (!stripeConfigured()) return { kind: "unavailable" };

try {
const state = await readConnectAccount(account.stripeAccountId);
if (chooseConnectLink(state) === "login") {
const login = await createLoginLink(account.stripeAccountId);
return { kind: "redirect", url: login.url };
}
const link = await createOnboardingLink(account.stripeAccountId, pageUrl);
return { kind: "redirect", url: link.url };
} catch (e) {
// No PII, and no Stripe wording passed to the visitor: they are a restaurant
// owner, not an operator, and "No such account" would be alarming and useless.
console.error("resolvePaymentsLink failed", account.tenantSlug, e);
return { kind: "unavailable" };
}
}
33 changes: 32 additions & 1 deletion lib/stripe-connect-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@
import {
connectExpressIdempotencyKey,
findConnectAccountForSlug,
newOnboardingToken,
recordConnectAccount,
} from "@/lib/connect-account-store";
import { expressAccountForm, type ExpressAccountInput } from "@/lib/connect-account-request";
import { stripePost } from "@/lib/stripe";
import { onboardingLinkForm, type ConnectAccountState } from "@/lib/connect-account-links";
import { stripeGet, stripePost } from "@/lib/stripe";

/** The slice of Stripe's Account object this module reads. */
type StripeAccountCreated = { id: string };
Expand Down Expand Up @@ -68,7 +70,36 @@ export async function createExpressAccount(input: ExpressAccountInput): Promise<
stripeAccountId: account.id,
idempotencyKey,
country: form.country,
// Minted here, with the account, and never re-issued: it is how the restaurant
// reaches its own onboarding page for as long as that page exists.
onboardingToken: newOnboardingToken(),
});

return { stripeAccountId: account.id, reused: false };
}

/** Stripe's `AccountLink` / `LoginLink` — both are `{ url }` and nothing else is read. */
type StripeLink = { url: string };

/**
* A FRESH onboarding link. Never cached, never stored, never emailed: it lives
* 300 seconds (measured), so any copy of it is a dead link by the time a person
* reads it. That is why the restaurant is given a page of OURS instead.
*/
export function createOnboardingLink(accountId: string, pageUrl: string): Promise<StripeLink> {
return stripePost<StripeLink>("/v1/account_links", onboardingLinkForm(accountId, pageUrl));
}

/**
* The Express dashboard login link, for an account that has finished onboarding.
* Refused (400) before that, which is why `chooseConnectLink` decides first.
*/
export function createLoginLink(accountId: string): Promise<StripeLink> {
return stripePost<StripeLink>(`/v1/accounts/${accountId}/login_links`, {});
}

/** Read an account's onboarding state. The 5-minute-cached tenant-side reader in
* the backend is unchanged; this is the control plane's own, per request. */
export function readConnectAccount(accountId: string): Promise<ConnectAccountState> {
return stripeGet<ConnectAccountState>(`/v1/accounts/${accountId}`);
}
9 changes: 9 additions & 0 deletions messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1673,5 +1673,14 @@
"vat": "إذا كان لشركتك رقم ضريبة قيمة مضافة في الاتحاد الأوروبي، فأضفه أيضًا وسنطبّق الاحتساب العكسي للضريبة بدلًا من تحصيلها منك.",
"cta": "أضف بيانات الفوترة"
}
},
"onboardingPayments": {
"meta": {
"title": "إعداد المدفوعات بالبطاقة"
},
"title": "المدفوعات بالبطاقة",
"unknownToken": "لا نعرف هذا الرابط. كل رابط يخص مطعمًا واحدًا — من فضلك استخدم الرابط الوارد في رسالة الترحيب، أو الزر الموجود في لوحة إدارتك ضمن الإعدادات ← المدفوعات.",
"unavailable": "تعذّر الوصول إلى Stripe الآن، لذلك لا يمكن فتح النموذج. لم يضع شيء — أعد المحاولة بالرابط نفسه بعد بضع دقائق.",
"contact": "إذا تكرر الأمر، ردّ على أي من رسائلنا وسنحلّه معك."
}
}
9 changes: 9 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1673,5 +1673,14 @@
"vat": "Wenn Ihr Unternehmen eine EU-USt-IdNr. hat, tragen Sie sie ebenfalls ein — dann wenden wir das Reverse-Charge-Verfahren an, statt Ihnen die Umsatzsteuer zu berechnen.",
"cta": "Rechnungsdaten hinzufügen"
}
},
"onboardingPayments": {
"meta": {
"title": "Kartenzahlungen einrichten"
},
"title": "Kartenzahlungen",
"unknownToken": "Diesen Link kennen wir nicht. Jeder Link gehört zu genau einem Restaurant — bitte nehmen Sie den aus Ihrer Willkommens-E-Mail oder die Schaltfläche in Ihrer eigenen Verwaltung unter Einstellungen → Zahlungen.",
"unavailable": "Wir konnten Stripe gerade nicht erreichen, deshalb lässt sich Ihr Formular nicht öffnen. Es geht nichts verloren — versuchen Sie denselben Link in ein paar Minuten erneut.",
"contact": "Wenn es weiter auftritt, antworten Sie einfach auf eine unserer E-Mails, und wir klären es gemeinsam."
}
}
9 changes: 9 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1673,5 +1673,14 @@
"vat": "If your company has an EU VAT number, add it too and we will reverse-charge the VAT instead of charging it.",
"cta": "Add your billing details"
}
},
"onboardingPayments": {
"meta": {
"title": "Set up card payments"
},
"title": "Card payments",
"unknownToken": "This link is not one we recognise. Links are personal to one restaurant, so please use the one in your welcome email or the button in your own admin under Settings → Payments.",
"unavailable": "We could not reach Stripe just now, so we cannot open your form. Nothing is lost — please try the same link again in a few minutes.",
"contact": "If it keeps happening, reply to any of our emails and we will sort it out with you."
}
}
Loading
Loading