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
25 changes: 25 additions & 0 deletions lib/connect-account-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,28 @@ export function onboardingLinkForm(accountId: string, pageUrl: string): Record<s
return_url: pageUrl,
};
}

/**
* The tenant's own onboarding page — the URL that goes into the registry entry
* (`payments_link_url:`) and from there into the tenant's `Stripe:PaymentsLinkUrl`
* setting, which is what the restaurant's Payments tab links to.
*
* NO LOCALE PREFIX, deliberately. `middleware.ts` redirects an unprefixed path to
* the visitor's own language, so one stored URL serves a Swiss restaurant whose
* staff read French and whose owner reads German. Baking `/en/` in would pick for
* them, permanently, in a value that is copied into a box `.env`.
*
* The BASE is passed in rather than read here so this stays pure and so the
* caller uses the one seam every link we send already goes through
* (`siteUrl()` — runtime `NEXTAUTH_URL` first, so staging cannot mint links that
* point at production).
*/
export function paymentsPageUrl(baseUrl: string, token: string): string {
// Built with the URL constructor rather than by trimming a trailing slash off
// the base: `NEXTAUTH_URL` is hand-written in a box `.env`, and the obvious
// `replace(/\/+$/, "")` is the ReDoS shape Sonar rejects (S5852) — the same
// reason lib/provisioning.ts uses `trimEnd()` instead of `\n*$`. This also
// normalises the join, so `https://host/` and `https://host` cannot produce two
// different links.
return new URL(`/onboarding/payments/${token}`, baseUrl).toString();
}
20 changes: 19 additions & 1 deletion lib/provisioning-mint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
// payments is a real object with a real compliance obligation attached.

import { createExpressAccount } from "@/lib/stripe-connect-accounts";
import { paymentsPageUrl } from "@/lib/connect-account-links";
import { siteUrl } from "@/lib/email";
import { connectCountryForCurrency } from "@/lib/connect-account-country";
import { isStripeAccountId } from "@/lib/validation-provision";
import { stripeConfigured } from "@/lib/stripe";
Expand All @@ -45,6 +47,16 @@ export type MintForProposalInput = {
export type MintForProposalResult = {
/** The minted (or already-recorded) `acct_…`, when there is one. */
stripeAccount?: string;
/**
* The tenant's own `/onboarding/payments/<token>` URL, finished and absolute —
* never a raw token. It travels into the registry entry as `payments_link_url:`
* and from there into the tenant's `Stripe:PaymentsLinkUrl`, so the deploy repo
* copies one opaque field and never learns that a token is a token. It also
* cannot get the origin subtly wrong per environment, which is the failure a
* box-side concatenation would eventually produce: a link that works and points
* at the wrong site.
*/
paymentsLinkUrl?: string;
/**
* Why there is none — founder-facing, and written into the PR body. Absent when
* nothing was attempted (the tenant did not buy the module) or when it worked.
Expand Down Expand Up @@ -84,7 +96,13 @@ export async function mintForProposal(input: MintForProposalInput): Promise<Mint
if (!isStripeAccountId(minted.stripeAccountId)) {
return { note: `Stripe returned an account id in an unexpected shape and it was not recorded in the entry` };
}
return { stripeAccount: minted.stripeAccountId };
return {
stripeAccount: minted.stripeAccountId,
// `siteUrl()` — the ONE base every link we send already goes through
// (runtime NEXTAUTH_URL first), so staging can never mint a link that
// points at production.
paymentsLinkUrl: paymentsPageUrl(siteUrl(), minted.onboardingToken),
};
} catch (e) {
// No PII: a slug and Stripe's own message (CLAUDE.md §5.8).
console.error("mintForProposal failed", input.slug, e);
Expand Down
14 changes: 14 additions & 0 deletions lib/provisioning-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ export interface TenantProvisionInput {
* Absent only when the mint could not happen, which `stripeAccountNote` explains.
*/
stripeAccount?: string;
/**
* The tenant's own onboarding page (`https://…/onboarding/payments/<token>`),
* server-derived exactly like `stripeAccount` and emitted BESIDE it. It becomes
* the tenant's `Stripe:PaymentsLinkUrl`, i.e. the button in their own Payments
* tab. One opaque field: `provision-tenant.sh` copies it and never learns that
* part of it is a credential, and no per-environment concatenation can put a
* working link in front of the wrong site.
*/
paymentsLinkUrl?: string;
/**
* Why there is no `stripeAccount`, in founder-facing words. NOT a registry field —
* `buildTenantRegistryEntry` ignores it entirely; it exists so the PR body can say
Expand Down Expand Up @@ -155,6 +164,11 @@ export function buildTenantRegistryEntry(input: TenantProvisionInput): {
// it — the two are written from the same `granted`/`stripeAccount` pair so the
// entry can never carry one half of the guard's condition.
...(stripeAccount ? { stripe_account: stripeAccount } : {}),
// Emitted only ALONGSIDE the account, never on its own: without an account
// there is no onboarding page to point at, and a link to a page that 404s is
// worse than no button at all. Same pairing discipline as the two fields
// above, one field along.
...(stripeAccount && input.paymentsLinkUrl ? { payments_link_url: input.paymentsLinkUrl } : {}),
// Whether the rate belongs in THIS entry: grantedCommissionBps (same pairing
// rule as stripe_account above, applied to a second field).
...(commissionBps !== undefined ? { payments_commission_bps: commissionBps } : {}),
Expand Down
1 change: 1 addition & 0 deletions lib/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export async function openProvisioningPr(
...input,
partnerBrand,
...(mint.stripeAccount ? { stripeAccount: mint.stripeAccount } : {}),
...(mint.paymentsLinkUrl ? { paymentsLinkUrl: mint.paymentsLinkUrl } : {}),
...(mint.note ? { stripeAccountNote: mint.note } : {}),
};

Expand Down
19 changes: 16 additions & 3 deletions lib/stripe-connect-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ type StripeAccountCreated = { id: string };
export type MintedConnectAccount = {
/** `acct_...` */
stripeAccountId: string;
/**
* The unguessable segment of this tenant's `/onboarding/payments/<token>` page
* (E4). Returned so the caller can put the finished URL in the registry entry
* — the box never sees the token as a token, only a URL it copies.
*/
onboardingToken: string;
/**
* True when the account already existed for this slug and no Stripe call was
* made. Reported rather than hidden because the caller's audit line should say
Expand All @@ -57,9 +63,16 @@ export async function createExpressAccount(input: ExpressAccountInput): Promise<
const form = expressAccountForm(input);

const existing = await findConnectAccountForSlug(input.slug);
if (existing) return { stripeAccountId: existing.stripeAccountId, reused: true };
if (existing) {
return {
stripeAccountId: existing.stripeAccountId,
onboardingToken: existing.onboardingToken,
reused: true,
};
}

const account = await stripePost<StripeAccountCreated>("/v1/accounts", form, { idempotencyKey });
const onboardingToken = newOnboardingToken();

// Not in a transaction with the call above, because there is no such thing: the
// account exists at Stripe the moment it returns. What makes the gap survivable
Expand All @@ -72,10 +85,10 @@ export async function createExpressAccount(input: ExpressAccountInput): Promise<
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(),
onboardingToken,
});

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

/** Stripe's `AccountLink` / `LoginLink` — both are `{ url }` and nothing else is read. */
Expand Down
33 changes: 32 additions & 1 deletion tests/unit/connect-account-links.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { chooseConnectLink, onboardingLinkForm } from "@/lib/connect-account-links";
import { chooseConnectLink, onboardingLinkForm, paymentsPageUrl } from "@/lib/connect-account-links";
import { newOnboardingToken } from "@/lib/connect-account-store";
import { resolvePaymentsLink } from "@/lib/onboarding-payments";

Expand Down Expand Up @@ -81,3 +81,34 @@ describe("resolvePaymentsLink", () => {
});
});
});

describe("paymentsPageUrl — what goes into the registry entry", () => {
it("is the page's own URL with NO locale prefix", () => {
// `middleware.ts` redirects an unprefixed path to the visitor's language, so
// one stored URL serves a Swiss restaurant whose staff read French and whose
// owner reads German. Baking `/en/` in would choose for them, permanently, in
// a value that is copied into a box `.env`.
expect(paymentsPageUrl("https://sofrapiwas.com", "tok123")).toBe(
"https://sofrapiwas.com/onboarding/payments/tok123",
);
expect(paymentsPageUrl("https://sofrapiwas.com", "tok123")).not.toContain("/en/");
});

it("survives a base URL with a trailing slash", () => {
// `NEXTAUTH_URL` is hand-written in a box `.env`; a trailing slash there would
// otherwise produce `//onboarding/...`, which is a different path and a 404 on
// the day somebody is trying to get paid.
expect(paymentsPageUrl("https://staging.sofrapiwas.com/", "tok")).toBe(
"https://staging.sofrapiwas.com/onboarding/payments/tok",
);
expect(paymentsPageUrl("https://staging.sofrapiwas.com///", "tok")).toBe(
"https://staging.sofrapiwas.com/onboarding/payments/tok",
);
});

it("keeps the environment it was given", () => {
// The reason the URL is finished HERE and not on the box: a per-environment
// concatenation eventually points a working link at the wrong site.
expect(paymentsPageUrl("https://staging.sofrapiwas.com", "t")).toContain("staging.");
});
});
29 changes: 29 additions & 0 deletions tests/unit/provisioning-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,35 @@ describe("deferring online-payments out of the generated entry", () => {
expect(deferred).toEqual(["online-payments"]);
});

it("emits payments_link_url beside the account, and never without one", () => {
// The tenant's own onboarding page, finished here and copied verbatim by
// provision-tenant.sh into `Stripe:PaymentsLinkUrl`. It is PAIRED with the
// account for the same reason the module is: with no account there is no
// onboarding page to point at, and a button that 404s is worse than no button.
const withAccount = buildTenantRegistryEntry({
...base,
modules: bought,
stripeAccount: "acct_1UCOkhCSPiP2JWOQ",
paymentsLinkUrl: "https://sofrapiwas.com/onboarding/payments/tok123",
});
const t = asTenant(withAccount, base.slug) as Record<string, unknown>;
expect(t.payments_link_url).toBe("https://sofrapiwas.com/onboarding/payments/tok123");
expect(t.stripe_account).toBe("acct_1UCOkhCSPiP2JWOQ");

// No account: the link is withheld even though it was supplied.
const orphan = buildTenantRegistryEntry({
...base,
modules: bought,
paymentsLinkUrl: "https://sofrapiwas.com/onboarding/payments/tok123",
});
expect("payments_link_url" in (asTenant(orphan, base.slug) as Record<string, unknown>)).toBe(false);

// And an entry that never asked for one is byte-identical to what this
// generator emitted before the field existed.
const none = buildTenantRegistryEntry({ ...base, modules: bought });
expect("payments_link_url" in (asTenant(none, base.slug) as Record<string, unknown>)).toBe(false);
});

it("changes nothing for a tenant that did not buy it", () => {
// The strip must be surgical: a generator that quietly dropped ids would be the same
// class of bug pointed the other way.
Expand Down
Loading