diff --git a/lib/connect-account-links.ts b/lib/connect-account-links.ts index 0d909a8..d03a77e 100644 --- a/lib/connect-account-links.ts +++ b/lib/connect-account-links.ts @@ -62,3 +62,28 @@ export function onboardingLinkForm(accountId: string, pageUrl: string): Record` 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. @@ -84,7 +96,13 @@ export async function mintForProposal(input: MintForProposalInput): Promise`), + * 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 @@ -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 } : {}), diff --git a/lib/provisioning.ts b/lib/provisioning.ts index b2681c0..6de0486 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -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 } : {}), }; diff --git a/lib/stripe-connect-accounts.ts b/lib/stripe-connect-accounts.ts index 6576e04..2bdcf24 100644 --- a/lib/stripe-connect-accounts.ts +++ b/lib/stripe-connect-accounts.ts @@ -43,6 +43,12 @@ type StripeAccountCreated = { id: string }; export type MintedConnectAccount = { /** `acct_...` */ stripeAccountId: string; + /** + * The unguessable segment of this tenant's `/onboarding/payments/` 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 @@ -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("/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 @@ -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. */ diff --git a/tests/unit/connect-account-links.test.ts b/tests/unit/connect-account-links.test.ts index a6417b4..595b8de 100644 --- a/tests/unit/connect-account-links.test.ts +++ b/tests/unit/connect-account-links.test.ts @@ -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"; @@ -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."); + }); +}); diff --git a/tests/unit/provisioning-registry.test.ts b/tests/unit/provisioning-registry.test.ts index 35d6ded..98ee448 100644 --- a/tests/unit/provisioning-registry.test.ts +++ b/tests/unit/provisioning-registry.test.ts @@ -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; + 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)).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)).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.