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
17 changes: 17 additions & 0 deletions components/control/ProvisionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ 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>
<ProvisionPicker
initialModules={prefill?.modules}
initialLanguages={prefill?.languages}
Expand Down
9 changes: 9 additions & 0 deletions docs/adr/ADR-012-auto-provisioning-trigger.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ it keeps the same privilege split without the git round-trip.
that is where the control plane runs. A develop-tracking showcase (`demo`) is a
hand-edit at the merge checkpoint, which is the one place a human already reads
the entry. Workspace `docs/plans/SOFRA-ONBOARDING-PLAN.md` §2b.
- **`modules` is what the entry may carry, not simply what was bought.** `online-payments`
is emitted only alongside a `stripe_account:`, because `provision-tenant.sh` refuses that
pair's lone half and does so *before* the database, the compose project and the image —
so an entry with the module and no account yields no tenant at all, not a tenant lacking
card payment. The founder supplies the account on `/admin/provision` (runbook §2b creates
it first, so it is in hand), and the entry carries both in one shot. A self-serve buyer
has none and cannot be given one — only the restaurant can create it, through Stripe's
hosted onboarding — so their module is withheld, the PR body states what the second
registry PR must add, and `deferred` is recorded on the provisioning audit entry.
- **Deprovision stays founder-only** over SSH. Unchanged.
- **Status reflection back to `/admin` is still open** — the registry `status` flip to
`active` remains a manual follow-up commit, and nothing automatic reads it.
Expand Down
10 changes: 8 additions & 2 deletions lib/actions/provisioning-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,15 @@ export async function openProvisioningPrAction(
}

try {
const { prUrl } = await openProvisioningPr({
const { prUrl, deferred } = await openProvisioningPr({
slug: input.slug,
name: input.name,
adminEmail: input.adminEmail.toLowerCase(),
template: input.template,
currency: input.currency,
languages,
modules,
stripeAccount: input.stripeAccount || undefined,
city: input.city || undefined,
});
// Record it on the billing row when there is one. The auto path reads this as its
Expand All @@ -109,7 +110,12 @@ export async function openProvisioningPrAction(
await db.tenantBilling
.update({ where: { tenantSlug: input.slug }, data: { provisioningPrUrl: prUrl } })
.catch(() => undefined); // no plan for this slug: founder-proposed, nothing to record
await audit(admin.id, "tenant.provision.proposed", "Tenant", input.slug, { prUrl });
// `deferred` only when non-empty: an always-present `[]` reads as a field nobody set
// rather than as the absence of a withheld module.
await audit(admin.id, "tenant.provision.proposed", "Tenant", input.slug, {
prUrl,
...(deferred.length ? { deferred } : {}),
});
return { ok: true, prUrl };
} catch (e) {
if (e instanceof ProvisioningNotConfiguredError) return { error: "provisioningNotConfigured" };
Expand Down
8 changes: 7 additions & 1 deletion lib/auto-provision-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ export type AutoProposePlan =
| { kind: "skipped"; reason: AutoProposeSkip }
| { kind: "failed"; detail: string };

export type AutoProposeOutcome = Exclude<AutoProposePlan, { kind: "propose" }> | { kind: "opened"; prUrl: string };
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[] };

/** The already-validated configuration a lead recorded, plus the slug it must match. */
export type AutoProposeConfig = {
Expand Down
11 changes: 9 additions & 2 deletions lib/auto-provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,21 +85,27 @@ export async function autoProposeProvisioning(billingId: string): Promise<AutoPr
});
}

const { prUrl } = await openProvisioningPr({
const { prUrl, deferred } = 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.
city: lead.city || undefined,
});
await db.tenantBilling.update({
where: { id: billing.id },
data: { provisioningPrUrl: prUrl },
});
return finish(billing.tenantSlug, { kind: "opened", prUrl });
return finish(billing.tenantSlug, {
kind: "opened",
prUrl,
...(deferred.length ? { deferred } : {}),
});
} catch (e) {
return finish(slugFor(billingId), await translate(billingId, e));
}
Expand Down Expand Up @@ -181,6 +187,7 @@ async function finish(
// actor null: this was a payment, not a person.
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 } : {}),
...("reason" in outcome ? { reason: outcome.reason } : {}),
...("detail" in outcome ? { detail: outcome.detail } : {}),
});
Expand Down
5 changes: 0 additions & 5 deletions lib/module-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,6 @@ export const MODULES: readonly CatalogModule[] = [
id: "online-payments",
priceCents: 1900,
surface: "card/TWINT at checkout, paid to the restaurant's own Stripe account",
// NOT YET SELLABLE. The vocabulary lands first (S10) so provisioning accepts the id and the
// registry can record a stripe_account; the endpoint that would honour it arrives in S4 and
// the customer-facing choice in S8. Until then this must not appear on the signup page or in
// the founder's provision picker. Remove this line in S9, when the flow works end to end.
sellable: false,
},
{ id: "extra-languages", priceCents: 500, surface: "beyond Core's en + 1, up to 10 locales" },
] as const;
Expand Down
172 changes: 172 additions & 0 deletions lib/provisioning-pr-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// The PR body for an ADR-012 provisioning proposal — the founder-facing half of
// lib/provisioning-registry.ts, split out when the pair outgrew one file's LOC limit
// (CLAUDE.md §4). Pure: no GitHub API, no secrets, no env.
//
// It describes the entry that generator emits, so it derives every value it quotes
// from the SAME input through the SAME helpers. Two independent literals would drift,
// and a body that misdescribes the diff is worse than no body — the founder ticks the
// checklist against it.

import { splitDeferredModules, type TenantProvisionInput } from "./provisioning-registry";

// Close the quote, emit an escaped apostrophe, reopen: the only way to get a
// literal ' inside a POSIX single-quoted argument.
const SHELL_QUOTED_APOSTROPHE = String.raw`'\''`;

/** Quote a value for a POSIX shell single-quoted argument. The tenant name is
* free text and the founder copy-pastes these commands into a terminal, so an
* apostrophe must not end the quoting. */
const shq = (value: string): string =>
"'" + value.replaceAll("'", SHELL_QUOTED_APOSTROPHE) + "'";

/** Collapse anything that would break the markdown fence or the shell command this
* body embeds. `provisionSchema` already refuses control characters in `name`, so in
* practice this changes nothing — it is here so the function is safe on its own,
* because a body builder that depends on a caller's validation is one refactor away
* from emitting an unbalanced code fence built from public-form input. */
const oneLine = (value: string): string => value.replace(/\s+/g, " ").trim();

/**
* The PR body for a provisioning proposal.
*
* **For a staging-box tenant, merging this PR provisions it** (SOFRA-ONBOARDING-PLAN §2
* option B, ADR-012 amendment 2026-07-30): the deploy repo's
* `provision-on-registry-merge.yml` chains the image build and `provision-tenant.sh` off
* the registry sync. So the body leads with what to CHECK before merging — the merge is
* the last reversible moment.
*
* That chain is **staging-only** (it follows `sync-registry-to-staging.yml` and inherits
* its narrowness), so a `box: prod` entry gets the opposite header: merging does nothing
* and the commands are required, not a fallback. Telling a prod entry "merging provisions
* this" would leave the founder waiting on a chain that never runs.
*
* The image-build command stays in the body either way, because that step is the one that
* is easy to skip and fatal to skip: `NEXT_PUBLIC_*` are baked per domain, so provisioning
* without it dies at `docker compose pull` on an image that was never published.
*/
export function buildProvisioningPrBody(input: TenantProvisionInput): string {
const { slug } = input;
const domain = `${slug}.sofrapiwas.com`;
const box = input.box ?? "staging";
const chained = box === "staging";
// Same helper the entry generator uses, so the body cannot describe a split the diff
// does not have.
const { granted, deferred } = splitDeferredModules(input.modules, input.stripeAccount);

// One line, naming the one field in the diff the founder may need to change. It used to
// branch on the box and warn that a staging-box tenant rides develop; the generator no
// longer produces that entry, so warning about it would be an unfalsifiable checkbox.
const tagCheck =
"- [ ] **`backend_tag: latest`** — released code, published only from `main`. If this is a develop-tracking **showcase** rather than a customer, change it to `staging` in Files changed before merging; a customer should stay on `latest`, so their database is never migrated by unreleased code";

const header = chained
? [
"### ⚠️ Merging this PR provisions the tenant",
"",
"`provision-on-registry-merge.yml` builds the per-tenant frontend image and then runs",
"`provision-tenant.sh` on the box — roughly 15 minutes, hands-off. **This is the human",
"checkpoint, and it is the last reversible moment.** Before you merge:",
]
: [
`### Merging this PR does **not** provision — \`box: ${box}\``,
"",
"The post-merge chain is staging-only. This entry will be reported and skipped, so the",
"two commands below are **required**, not a fallback. Still check the entry first:",
];

// Named, not silent. The entry omits a module they PAID for, so the body has to say
// so where the founder is already reading — otherwise the checklist above quietly
// contradicts the receipt, and the gap is discovered by a customer asking why card
// payment does not work. Empty for every tenant that bought nothing deferred.
const deferredBlock = deferred.length
? [
"",
`### ⚠️ Bought but deliberately NOT in this entry: \`${deferred.join(", ")}\``,
"",
`They paid for \`${deferred.join(", ")}\` and they keep it — the plan, the price and the`,
"subscription are unchanged. It is out of **this** entry because `provision-tenant.sh`",
"refuses the pair `online-payments` without `stripe_account:`, and refuses it *before*",
"the database, the compose project or the image — so proposing both here would not give",
"them a restaurant lacking card payment, it would give them **no restaurant at all**.",
"",
"No account was supplied with this proposal. If you are the founder and you already",
"hold their `acct_` — runbook §2b has you create it *before* proposing, exactly so",
"this does not happen — the fix is one shot, not two: add both fields in **Files",
"changed** before merging and delete this section's premise. Otherwise the account",
"genuinely cannot exist yet, because only the restaurant can create it, through",
"Stripe's hosted onboarding, which cannot be pre-filled. In that case provision them",
"now on everything else, then, once they have finished Stripe and you have their id, open a",
"**second** registry PR that adds BOTH halves together — one without the other trips",
"the same guard. Only these two fields change; the rest of the entry stays as merged:",
"",
"```yaml",
` ${slug}:`,
" stripe_account: acct_XXXXXXXXXXXX",
` modules: [${[...granted, ...deferred].join(", ")}]`,
"```",
"",
`…then re-run provisioning (\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`)`,
"and restart the tenant so it picks up the Stripe env. Full recipe — account creation,",
"the KYC sitting, TWINT, the box env — workspace `docs/runbooks/signup-to-live-tenant.md`",
"**§2b**, which is written to be followed BEFORE this second PR.",
]
: [];

const after = chained
? [
"The chain provisions **first-time only**, and reports back on this PR when it is done —",
"or opens an issue on the deploy repo if any stage fails, including the registry sync it",
"waits on. A tenant it has already finished is skipped, so re-merging or",
"reverting-and-remerging this PR will not provision twice. One it left part-way through is",
"*completed* rather than skipped, so a retry is always safe.",
]
: [
"Merging still fires `sync-registry-to-staging.yml`, which copies the registry to the",
"**staging** box only. A prod-box tenant needs the prod box's own access (ADR-012",
"per-box boundary), so run the commands from a machine that has it.",
];

return [
`Adds the \`${slug}\` tenant to \`tenants/registry.yml\`, proposed by the control plane (sofra ADR-012).`,
"",
`- **domain** \`${domain}\` · **template** \`${input.template}\` · **currency** \`${input.currency}\``,
`- **languages** \`${input.languages.join(", ")}\` · **modules** \`${granted.join(", ")}\`${
deferred.length ? ` · **deferred** \`${deferred.join(", ")}\` (see below)` : ""
}`,
`- **box** \`${box}\` · status starts at \`provisioning\``,
"",
...header,
"",
`- [ ] the **slug** \`${slug}\` is what the customer should live on forever — it is the subdomain, database, role and compose project, and changing it later is a full re-provision`,
// With a deferral the "must match what they paid for" wording would be false by
// construction, and a checkbox the founder must tick while knowing it is wrong is
// how the whole checklist stops being read. So the ask changes with the diff.
deferred.length
? `- [ ] **modules** \`${granted.join(", ")}\` are everything they paid for EXCEPT \`${deferred.join(", ")}\`, which is held back on purpose — see the section below. They are enforced at runtime, so any *other* missing id is a feature they bought and will not get`
: `- [ ] **modules** \`${granted.join(", ")}\` match what they actually paid for — they are enforced at runtime now, so a missing id is a feature they bought and will not get`,
tagCheck,
`- [ ] **template** \`${input.template}\` and **currency** \`${input.currency}\` are right — the template is baked into the image at build time, so changing it later is a rebuild`,
...deferredBlock,
"",
...after,
"",
`Afterwards: \`./verify-env.sh https://${domain}\`, hand over the generated admin password from the tenant \`.env\` (and have them change it), then flip this entry's \`status\` to \`active\` in a follow-up commit.`,
"",
chained ? "### If the chain fails" : "### Run these after merging",
"",
"Both are idempotent and safe to re-run:",
"",
"```bash",
"gh workflow run build-tenant-image.yml --repo piwas-21/restaurant-app-frontend \\",
` -f tenant_domain=${domain} \\`,
` -f image_tag=tenant-${slug} \\`,
` -f restaurant_name=${shq(oneLine(input.name))} \\`,
` -f template=${input.template} \\`,
` -f currency=${input.currency}`,
"",
`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}`,
"```",
"",
"Full runbook: deploy repo `DEPLOYMENT.md` §Tenant provisioning.",
].join("\n");
}
Loading
Loading