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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ MOLLIE_API_KEY=
# --- Stripe platform webhooks (POST /api/webhooks/stripe) ---
# ADR-011 amendment. This app never creates Stripe charges (that's the backend,
# Job B); it returns an application fee when a connected account refunds a
# charge, and it records the fees it earns.
# charge, it records the fees it earns, and since the Express migration it MINTS
# each tenant's connected account (lib/stripe-connect-accounts.ts).
# Platform secret key (sk_test_/sk_live_). Unset -> the webhook 503s.
#
# SCOPES: `Application fees -> write` (the refund rail) plus, for minting,
# `Connect -> write`. That second scope is the reason this key stays in the
# CONTROL PLANE and is never the box's: `provision-tenant.sh` reads
# STRIPE_PLATFORM_API_KEY on the box, and a key that can mint connected accounts
# is a higher-value target than any box. Do NOT add `Connect -> write` there.
STRIPE_API_KEY=
# ONE url, TWO Stripe endpoints, one handler — because the two event scopes are
# ORTHOGONAL and Stripe will not merge them. Each endpoint has its own whsec_;
Expand Down
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 />
</>
);
}
37 changes: 35 additions & 2 deletions app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,22 @@
// not handling it costs nothing today (the dispute is still visible in
// the connected account's own Stripe dashboard) and can be added once the
// behaviour is actually measured.
// - everything else Connect can send (account.updated, payout.*, …) is
// simply not this endpoint's job.
// - `account.updated` IS handled since the ADR-011 amendment (E5): it is how
// this platform learns a restaurant finished Stripe onboarding, now that the
// platform is the one that created the account. Its branch sits BELOW the
// `event.account` guard — the slot above is reserved for
// `application_fee.created`, which has no `event.account` at all.
// - everything else Connect can send (payout.*, balance.available, …) is
// simply not this endpoint's job yet. `payout.failed` is the next one worth
// having: under Express a bad IBAN becomes our support ticket rather than
// something the restaurant sees in a dashboard they do not have.
import { NextResponse } from "next/server";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { stripeConfigured, StripeError } from "@/lib/stripe";
import { verifyingScope, webhookSecrets, type WebhookScope } from "@/lib/stripe-webhook-secrets";
import { refundApplicationFeeForCharge } from "@/lib/stripe-fee-refund";
import { recordApplicationFee } from "@/lib/stripe-fee-earned";
import { recordAccountStatus } from "@/lib/stripe-account-status";

/**
* The ONE error taxonomy this endpoint has, shared by both branches rather than
Expand Down Expand Up @@ -142,8 +150,33 @@ export async function POST(request: Request) {
if (!account) {
// A platform-level event we do not act on — acknowledge and ignore rather
// than guess at what it might mean.
//
// One exception is LOGGED rather than acted on. `account.updated` is expected
// to arrive on the `connect: true` endpoint naming its account, which is why
// its branch sits below this guard. If it ever arrives without one, that
// branch would never fire and the tell would be an empty table — the exact
// shape of silence that made `application_fee.created` invisible for a week.
// So say it out loud. No PII: an event id and a type.
if (event.type === "account.updated") {
console.warn("stripe webhook: account.updated with no event.account", event.id, scope);
}
return NextResponse.json({ ok: true });
}

// BELOW the account guard, deliberately, and the position is not free: the slot
// above it is taken by `application_fee.created`, which has NO `event.account`
// (measured) and would be discarded by the guard. This event is the opposite —
// it is ABOUT a connected account, so it names one, and reading the id from the
// guard rather than from the body is what keeps the two branches honest about
// which endpoint delivers what.
//
// It is how onboarding completion reaches us at all (E5). The alternative was a
// fleet-wide `GET /v1/accounts` poll, which is the rate-limit shape the
// backend's own account cache warns about.
if (event.type === "account.updated") {
return acknowledge("account status", scope, event.id, () => recordAccountStatus(account));
}

if (event.type !== "charge.refunded") {
return NextResponse.json({ ok: true });
}
Expand Down
33 changes: 20 additions & 13 deletions components/control/PaymentsPendingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import { getTranslations } from "next-intl/server";
/**
* "Card payments are on their way" (SOFRA-PAYMENTS-PLAN §9 P4).
*
* The one window nothing else covers. A self-serve buyer of `online-payments` is
* provisioned WITHOUT it — P1 makes the module and the connected account a pair,
* because `provision-tenant.sh` refuses one without the other and refuses it before
* the database — so they go live on everything else and trade on cash while their
* Stripe account is being verified. Every other surface is honest about that state
* The one window nothing else covers. A buyer of `online-payments` is provisioned
* WITHOUT it — P1 makes the module and the connected account a pair, because
* `provision-tenant.sh` refuses one without the other and refuses it before the
* database — so they go live on everything else and trade on cash meanwhile.
*
* WHAT THIS WINDOW NOW MEANS (ADR-011 amendment). It used to be the ordinary state
* of every self-serve buyer, on the premise that only the restaurant could create a
* Stripe account. The control plane MINTS the account now, so reaching this panel
* means OUR creation did not happen — which makes "there is nothing for you to do
* right now" more true than it was, not less: the outstanding work is ours. That is
* also why the copy still gives no estimate and still names nothing they cannot act
* on; the failure is on our side of a wall they cannot see. Every other surface is honest about that state
* and silent about it: RUMI's own checklist cannot see the purchase (only the grant),
* and the tenant's checkout simply does not offer a card. Only this dashboard holds
* both halves, so only this dashboard can say the thing out loud.
Expand Down Expand Up @@ -38,14 +45,14 @@ export default async function PaymentsPendingPanel({ locale }: { readonly locale
<p className="font-hand text-2xl font-bold">{t("title")}</p>
<p className="text-muted-foreground">{t("body")}</p>
<p className="font-label text-sm text-muted-foreground">{t("billingNote")}</p>
<a
href="https://dashboard.stripe.com"
target="_blank"
rel="noopener noreferrer"
className="font-label underline underline-offset-4 text-muted-foreground w-fit"
>
{t("stripeLink")}
</a>
{/* Prose, not a link, and that is the Express change (ADR-011 amendment).
This used to be an <a> to https://dashboard.stripe.com. Under Express a
restaurant has no full Stripe dashboard to log into, and in THIS state
they have no account at all — so that link led somewhere they could not
get in. The sentence it carried was still useful ("your own Stripe view is
coming, here is what it will ask"), so it is re-stated rather than
deleted: what the short form asks for, and why no link can be kept. */}
<p className="font-label text-sm text-muted-foreground">{t("nextStep")}</p>
</div>
);
}
28 changes: 11 additions & 17 deletions components/control/ProvisionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,23 +103,17 @@ export default function ProvisionForm({
aria-label={t("provision.currency")}
className="input-primary"
/>
{/* Optional, and deliberately NOT prefilled from a signup: a lead has no connected
account (only the restaurant can create one, via Stripe's hosted onboarding).
It is here for the founder path, where runbook §2b creates the account BEFORE
proposing — with it the entry carries `online-payments` in one shot, without it
the generator holds the module back rather than proposing an entry that
provision-tenant.sh refuses. */}
<label className="sm:col-span-2 grid gap-1 font-label text-sm text-muted-foreground">
<input
name="stripeAccount"
pattern="acct_[A-Za-z0-9]{8,32}"
defaultValue=""
placeholder={t("provision.stripeAccount")}
aria-label={t("provision.stripeAccount")}
className="input-primary"
/>
<span>{t("provision.stripeAccountHint")}</span>
</label>
{/* NOT an input any more (ADR-011 amendment). The premise this field rested on —
"only the restaurant can create a connected account, and it cannot be
pre-filled" — was measured false at CREATE time, so the control plane mints the
account itself before this proposal is composed and the entry carries
`online-payments` AND `stripe_account:` in one commit. Left as a read-only
sentence rather than deleted: the founder is about to review a PR whose diff
contains an `acct_` nobody typed, and this is where they learn where it came
from. If the mint fails, the PR body says so and says what to do. */}
<p className="sm:col-span-2 font-label text-sm text-muted-foreground">
{t("provision.stripeAccountNote")}
</p>
{/* A partner's own zone (SOFRA-PARTNER-FLEXIBILITY-PLAN D1). The default option is
empty and emits exactly the entry this form emitted before the field existed:
`<slug>.sofrapiwas.com`, no `base_domain:` key. Picked, the entry's domain is
Expand Down
Loading
Loading