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
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
8 changes: 7 additions & 1 deletion lib/actions/provisioning-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export async function openProvisioningPrAction(
}

try {
const { prUrl, deferred } = await openProvisioningPr(input);
const { prUrl, deferred, stripeAccount, mintNote } = await openProvisioningPr(input);
// Record it on the billing row when there is one. The auto path reads this as its
// idempotency marker, so a founder proposing by hand must populate it too — otherwise
// a later payment webhook sees no record, tries again, and has to infer the truth from
Expand All @@ -89,6 +89,12 @@ export async function openProvisioningPrAction(
await audit(admin.id, "tenant.provision.proposed", "Tenant", input.slug, {
prUrl,
...(deferred.length ? { deferred } : {}),
// The `acct_` the control plane minted for this proposal, or why it did not
// (ADR-011 amendment, E3). An `acct_` is an identifier and not a secret — the
// fleet list already renders one — and this is the only durable record of a LIVE
// Stripe account created on the founder's behalf while they filled in a form.
...(stripeAccount ? { stripeAccount } : {}),
...(mintNote ? { mintNote } : {}),
});
return { ok: true, prUrl };
} catch (e) {
Expand Down
12 changes: 8 additions & 4 deletions lib/auto-provision-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ export type AutoProposePlan =
export type AutoProposeOutcome =
| Exclude<AutoProposePlan, { kind: "propose" }>
// `deferred` = modules the buyer PAID for that the proposed entry withholds, because
// provisioning refuses them without a Stripe account the self-serve buyer cannot have
// yet. Carried on the outcome so it reaches the audit trail: this is the only durable
// record that someone is being billed for a module their tenant does not yet have.
| { kind: "opened"; prUrl: string; deferred?: string[] };
// provisioning refuses them without a Stripe account. Carried on the outcome so it
// reaches the audit trail: this is the only durable record that someone is being
// billed for a module their tenant does not yet have.
//
// Since the ADR-011 amendment this is the EXCEPTIONAL path — the control plane mints
// the account — so `mintNote` travels beside it saying why the mint did not happen.
// Absent when it worked, and absent when no account was needed at all.
| { kind: "opened"; prUrl: string; deferred?: string[]; mintNote?: string };

/** The already-validated configuration a lead recorded, plus the slug it must match. */
export type AutoProposeConfig = {
Expand Down
63 changes: 28 additions & 35 deletions lib/auto-provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,23 @@
//
// When a SELF-SERVE tenant's first payment settles, propose its registry entry without
// waiting for the founder to open /admin/provision. The founder still reviews and merges
// — that is the human checkpoint, and under the merge chain it is the merge that stands
// the tenant up — so this automates the typing, not the judgement.
// — the human checkpoint, and under the merge chain the merge is what stands the tenant
// up — so this automates the typing, not the judgement.
//
// The decision lives in lib/auto-provision-policy.ts (pure, unit-tested). This file only
// gathers facts, performs the single side effect the policy authorises, and translates
// gathers facts, performs the side effects the policy authorises, and translates
// GitHub's refusals. Two rules it must keep:
//
// 1. **It never throws.** Its caller is the Mollie webhook, where an exception means a
// non-2xx, which means Mollie redelivers, which means a paid customer's activation
// retries for up to ~26h because a GitHub call failed. Every failure becomes a
// returned outcome.
// non-2xx — a paid customer's activation retried for ~26h. Failures become outcomes.
// 2. **The gate is still the authority.** `slugProvisionVerdict` runs here even though
// the only caller reaches this from the `first`+`paid` branch. A second path that
// decides for itself when money counts is how the two drift apart (trap 7).
// the only caller reaches this from `first`+`paid`: a second path deciding for
// itself when money counts is how the two drift apart (trap 7).

import { db } from "@/lib/db";
import { audit } from "@/lib/audit";
import { slugProvisionVerdict } from "@/lib/provisioning-facts";
import { toProvisionPrefill } from "@/lib/provision-prefill";
import {
classifyProvisioningRefusal,
decideAutoPropose,
type AutoProposeOutcome,
} from "@/lib/auto-provision-policy";
import { classifyProvisioningRefusal, decideAutoPropose, type AutoProposeOutcome } from "@/lib/auto-provision-policy";
import { reportFailedProposal } from "@/lib/billing-notify";
import {
openProvisioningPr,
Expand All @@ -34,11 +27,7 @@ import {
ProvisioningNotConfiguredError,
} from "@/lib/provisioning";

export {
AUTO_PROPOSE_NOTES,
type AutoProposeOutcome,
type AutoProposeSkip,
} from "@/lib/auto-provision-policy";
export { AUTO_PROPOSE_NOTES, type AutoProposeOutcome, type AutoProposeSkip } from "@/lib/auto-provision-policy";

/**
* Try to open the registry PR for this billing row. Safe to call repeatedly — that is the
Expand All @@ -47,22 +36,21 @@ export {
* Idempotency is `provisioningPrUrl` on our own row, read by the policy and written here.
* GitHub refusing a duplicate `provision/<slug>` branch is the backstop, not the
* mechanism: two concurrent deliveries can both read null, and the loser is recognised
* rather than reported as a failure.
* rather than reported as a failure. (The MINT has its own, separate idempotency — a
* slug-derived Stripe key plus a unique row; see lib/connect-account-store.ts.)
*/
export async function autoProposeProvisioning(billingId: string): Promise<AutoProposeOutcome> {
try {
const billing = await db.tenantBilling.findUnique({
where: { id: billingId },
include: { signupRequest: true },
});
// Only reachable if the row vanished between recordPayment's read and this one.
// A `skipped` here would email the founder "this plan was created by hand", which
// would be a confident falsehood about a plan that no longer exists.
// Only reachable if the row vanished between recordPayment's read and this one. A
// `skipped` here would email the founder a confident falsehood about a dead plan.
if (!billing) return { kind: "failed", detail: `billing row ${billingId} disappeared mid-delivery` };

// Re-validates every stored answer and DROPS whatever the catalog no longer
// recognises, so a months-old lead cannot carry a retired module id into a registry
// entry that `provision-tenant.sh` would then reject at the box, far from its source.
// recognises, so a months-old lead cannot carry a retired module id to the box.
const lead = billing.signupRequest ? toProvisionPrefill(billing.signupRequest) : null;

const plan = decideAutoPropose({
Expand All @@ -85,16 +73,17 @@ export async function autoProposeProvisioning(billingId: string): Promise<AutoPr
});
}

const { prUrl, deferred } = await openProvisioningPr({
const { prUrl, deferred, mintNote } = await openProvisioningPr({
slug: billing.tenantSlug,
name: lead.name,
adminEmail: lead.adminEmail,
template: lead.template,
currency: lead.currency,
languages: lead.languages,
modules: lead.modules,
// No `stripeAccount`: a self-serve buyer has none and cannot be given one, so a
// bought `online-payments` is always deferred on this path. See the generator.
// Still no `stripeAccount` here, for the OPPOSITE reason to before: it is minted
// inside `openProvisioningPr` (ADR-011 amendment, E3) — the single place that
// writes an entry — so neither caller can forget it or invent one.
city: lead.city || undefined,
});
await db.tenantBilling.update({
Expand All @@ -105,6 +94,9 @@ export async function autoProposeProvisioning(billingId: string): Promise<AutoPr
kind: "opened",
prUrl,
...(deferred.length ? { deferred } : {}),
// Only when the mint failed — "we could not create this paying customer's Stripe
// account" must be answerable from our own rows, not only from a PR body.
...(mintNote ? { mintNote } : {}),
});
} catch (e) {
return finish(slugFor(billingId), await translate(billingId, e));
Expand All @@ -127,11 +119,11 @@ async function slugFor(billingId: string): Promise<string> {
/**
* Turn a thrown error into an outcome. The subtle case is `proposalOpen`: the branch
* exists, which is *usually* a concurrent delivery whose winner has by now recorded the
* PR URL — but `openProvisioningPr` creates the branch before it commits and opens the
* PR, so an attempt that died in between leaves an orphan branch and no PR. Reporting
* that as a benign duplicate is a permanent wedge: every retry would say "already
* exists, nothing to do" while a paid customer has no tenant. So re-read the row, and
* only call it a duplicate if a URL was actually recorded.
* PR URL — but `openProvisioningPr` creates the branch before it opens the PR, so an
* attempt that died in between leaves an orphan branch and no PR. Reporting that as a
* benign duplicate is a permanent wedge: every retry would say "already exists, nothing
* to do" while a paid customer has no tenant. So re-read the row, and only call it a
* duplicate if a URL was actually recorded.
*/
async function translate(billingId: string, e: unknown): Promise<AutoProposeOutcome> {
if (e instanceof ProvisioningNotConfiguredError) {
Expand Down Expand Up @@ -172,8 +164,8 @@ async function translate(billingId: string, e: unknown): Promise<AutoProposeOutc
*
* Both halves exist because the payment email is NOT a reliable carrier for this: it is
* sent after `activatePendingSubscriptions`, which deliberately throws to force a webhook
* 503 during the mandate race. A token that expired silently plus a mandate that lags
* would otherwise be reported nowhere at all — the exact trap the policy makes loud.
* 503 during the mandate race. A silently expired token plus a lagging mandate would
* otherwise be reported nowhere at all — the exact trap the policy makes loud.
*/
async function finish(
slugOrPromise: string | Promise<string>,
Expand All @@ -188,6 +180,7 @@ async function finish(
await audit(null, `tenant.provision.auto.${outcome.kind}`, "Tenant", slug, {
...("prUrl" in outcome ? { prUrl: outcome.prUrl } : {}),
...("deferred" in outcome && outcome.deferred?.length ? { deferred: outcome.deferred } : {}),
...("mintNote" in outcome && outcome.mintNote ? { mintNote: outcome.mintNote } : {}),
...("reason" in outcome ? { reason: outcome.reason } : {}),
...("detail" in outcome ? { detail: outcome.detail } : {}),
});
Expand Down
62 changes: 62 additions & 0 deletions lib/connect-account-country.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Which country a tenant's connected account is created in (ADR-011 amendment,
// slice E3).
//
// This module exists because of a gap nobody planned for: sofra holds a
// restaurant's name, email, city, currency and modules — and NO country. The
// registry has no `country:` key either (`provision-tenant.sh`'s field whitelist
// does not contain one), and neither does `SignupRequest`. But an Express
// account MUST be created in a country, and Stripe fixes that country forever at
// creation — it is refused on update, like the account type and `business_type`.
//
// So the country is DERIVED from the one fact we do hold, the tenant's trading
// currency, and the derivation refuses to guess whenever the answer is not
// unique. EUR is the case that matters: it is spoken by FR, DE, NL, IT, ES, BE
// and AT, and picking one would create a live, uncorrectable account in the
// wrong country for a real restaurant. A refusal costs the founder one hand-edit
// before merging the registry PR (which the PR body spells out); a wrong guess
// costs a Stripe support case and, possibly, a dead account.
//
// This is deliberately narrow rather than clever. The proper fix is to ASK — a
// country on the lead or on the provision form — and it is recorded in
// docs/plans/BACKLOG.md rather than smuggled in here, because it is a form
// change with six locales attached and this slice is already the wide one.

/**
* Currencies whose country is unambiguous among the countries we onboard
* (`CONNECT_ONBOARDABLE_COUNTRIES`).
*
* CHF is the one that matters today: Switzerland is the first market, RUMI is a
* CH tenant, and every tenant in the registry trades in CHF or EUR.
*/
const UNAMBIGUOUS: Readonly<Record<string, string>> = {
CHF: "CH",
GBP: "GB",
USD: "US",
AED: "AE",
};

export type ConnectCountryVerdict =
| { ok: true; country: string }
| { ok: false; reason: string };

/**
* The account country for a tenant trading in this currency, or a refusal that
* says why — in words a founder reading a PR body can act on.
*
* @param currency ISO-4217, as the registry carries it (`currency:`).
*/
export function connectCountryForCurrency(currency: string | undefined): ConnectCountryVerdict {
const code = (currency ?? "").trim().toUpperCase();
if (!code) return { ok: false, reason: "this entry records no currency, so no account country can be derived" };
const country = UNAMBIGUOUS[code];
if (country) return { ok: true, country };
if (code === "EUR") {
return {
ok: false,
reason:
"EUR does not name one country (FR, DE, NL, IT, ES, BE and AT all use it) and Stripe fixes " +
"an account's country permanently at creation, so this is not a guess worth making",
};
}
return { ok: false, reason: `no Stripe Connect country is mapped to currency ${code}` };
}
9 changes: 6 additions & 3 deletions lib/provision-form-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ export function readProvisionForm(formData: FormData): ProvisionFormResult {
city: optionalField(formData, "city"),
// NOT optional to remember. Every field the form posts is read here, and this file
// is the one place where "the form has it and the action does not" can be seen at a
// glance — which is precisely what went wrong with `stripeAccount`.
stripeAccount: optionalField(formData, "stripeAccount"),
// glance — which is precisely what went wrong with `stripeAccount`, back when that
// was a field at all.
baseDomain: optionalField(formData, "baseDomain"),
});
if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "invalidInput" };
Expand All @@ -97,7 +97,10 @@ export function readProvisionForm(formData: FormData): ProvisionFormResult {
currency: data.currency,
languages,
modules,
stripeAccount: data.stripeAccount || undefined,
// No `stripeAccount`: it is not a form field any more. Under the ADR-011
// amendment the control plane MINTS the tenant's connected account
// (lib/provisioning-mint.ts) and the ACTION attaches the result, so the
// mapping from a browser's fields cannot carry it and cannot drop it.
// Re-normalized rather than passed through: the schema only ASKED whether the
// value is a usable base domain, and the answer it validated is a different
// string from the one it was handed (a pasted scheme, a trailing dot). The
Expand Down
Loading
Loading