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
12 changes: 7 additions & 5 deletions app/api/signup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ const FOUNDER_FALLBACK_NOTES: Record<SelfServeFallback, string> = {
async function mintAccount(
outcome: Extract<SelfServeOutcome, { kind: "account" }>,
who: { email: string; contactName: string; restaurantName: string },
signupRequestId: string,
): Promise<{ account: boolean; founderOutcome: string }> {
let minted;
try {
minted = await createSelfServeAccount({
...who,
slug: outcome.slug,
amountCents: outcome.amountCents,
signupRequestId,
});
} catch (e) {
if (!(e instanceof SlugRaceLostError)) throw e;
Expand Down Expand Up @@ -189,11 +191,11 @@ export async function POST(request: Request) {
// ── Mint the account when the decision says so ──────────────────────────
const { account, founderOutcome } =
outcome.kind === "account"
? await mintAccount(outcome, {
email,
contactName: data.contactName,
restaurantName: data.restaurantName,
})
? await mintAccount(
outcome,
{ email, contactName: data.contactName, restaurantName: data.restaurantName },
signup.id,
)
: { account: false, founderOutcome: FOUNDER_FALLBACK_NOTES[outcome.reason] };

// ── Tell the founder what happened ─────────────────────────────────────
Expand Down
35 changes: 9 additions & 26 deletions lib/actions/provisioning-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
// the change syncs to the box, then the provision-tenant Action runs the script.

import { requireAdmin } from "@/lib/rbac";
import { db } from "@/lib/db";
import { audit } from "@/lib/audit";
import { provisionGate, type ProvisionGateVerdict } from "@/lib/provisioning-payment-gate";
import { db } from "@/lib/db";
import { slugProvisionVerdict } from "@/lib/provisioning-facts";
import { provisionSchema, splitCsvLower } from "@/lib/validation";
import { loadTenantRegistry } from "@/lib/tenant-registry";
import { checkSlug } from "@/lib/slug-availability";
Expand All @@ -22,30 +22,6 @@ import {
* GitHub API errors pass through raw. `prUrl` on success. */
export type ProvisionActionState = { error?: string; ok?: boolean; prUrl?: string };

/**
* Read this slug's billing facts and run the O2 payment gate over them.
*
* Kept next to its only caller rather than in the pure gate module, so the
* policy stays unit-testable without a database. Only `first` payments are
* fetched: a settled first payment is what the gate asks about, and the
* recurring history grows without bound.
*/
async function slugProvisionVerdict(slug: string): Promise<ProvisionGateVerdict> {
const billing = await db.tenantBilling.findUnique({
where: { tenantSlug: slug },
include: {
subscriptions: { select: { status: true } },
payments: { where: { sequenceType: "first" }, select: { status: true }, take: 20 },
},
});
if (!billing) return provisionGate(null);
return provisionGate({
selfServe: billing.payerUserId !== null,
firstPaymentSettled: billing.payments.some((p) => p.status === "paid"),
subscriptionActive: billing.subscriptions.some((s) => s.status === "ACTIVE"),
});
}

/** Collapse a repeated (checkbox-group) form field into the comma list the
* schema validates, dropping any non-string entry. */
const csvField = (formData: FormData, name: string): string =>
Expand Down Expand Up @@ -126,6 +102,13 @@ export async function openProvisioningPrAction(
modules,
city: input.city || undefined,
});
// 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
// GitHub refusing a duplicate branch.
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 });
return { ok: true, prUrl };
} catch (e) {
Expand Down
135 changes: 135 additions & 0 deletions lib/auto-provision-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Should the payment-triggered registry proposal be opened, and if not, what should the
// founder be told? (SOFRA-ONBOARDING-PLAN O3, second half.)
//
// Pure — no DB, no network, no GitHub — for the same reason
// lib/provisioning-payment-gate.ts is: the policy is the part worth pinning in tests,
// and the repo forbids the mocks that testing it through Prisma and fetch would need
// (CLAUDE.md §7). lib/auto-provision.ts is the shell that feeds this and performs the
// one side effect it authorises.

/** Why an automatic proposal was not opened. None of these is an error. */
export type AutoProposeSkip =
| "notSelfServe"
| "awaitingPayment"
| "incompleteConfiguration"
| "slugMismatch"
| "unsafeName"
| "proposalExists";

/** The plan: either do the one side effect, or report without doing it. */
export type AutoProposePlan =
| { kind: "propose" }
| { kind: "alreadyProposed"; prUrl: string }
| { kind: "skipped"; reason: AutoProposeSkip }
| { kind: "failed"; detail: string };

export type AutoProposeOutcome = Exclude<AutoProposePlan, { kind: "propose" }> | { kind: "opened"; prUrl: string };

/** The already-validated configuration a lead recorded, plus the slug it must match. */
export type AutoProposeConfig = {
/** The lead's requested slug, after re-validation. */
slug: string;
/** The slug this plan bills against — the immutable anchor. */
billingSlug: string;
/** Becomes the registry `name:`, and from there a Docker build arg. */
name: string;
template?: string;
currency?: string;
modules: string[];
languages: string[];
};

export type AutoProposeFacts = {
/** A proposal already recorded on this plan. */
existingPrUrl: string | null;
/** The configuration from the linked lead; null when there is no lead at all. */
config: AutoProposeConfig | null;
/** The O2 payment gate said this slug may be proposed. */
settled: boolean;
/** PROVISION_GITHUB_TOKEN is present. */
provisioningConfigured: boolean;
};

/**
* Order matters, and each position is a decision:
*
* 1. **Already proposed wins over everything.** Mollie redelivers webhooks, so this is
* the ordinary repeat case, not an edge one — and answering it first means a
* redelivery cannot be reported as a fresh skip or failure.
* 2. **No lead ⇒ not self-serve.** The same signal the payment gate keys on
* (/admin/onboard, the reseller flow, and RUMI have no lead). Not our business to
* automate, and silence would be wrong: the founder should read why.
* 3. **The gate outranks the configuration.** An unpaid plan is refused before we look
* at what it asked for, so a badly configured unpaid plan is reported as unpaid.
* 4. **Slug mismatch is its own answer, not "incomplete".** If the lead's slug and the
* billing anchor disagree, something upstream is wrong; conflating it with a missing
* template would send the founder to fill in a form instead of investigating.
* 5. **Missing configuration is a skip, never a guess.** Template and currency have no
* safe default for someone paying — the theme is baked into their image and the
* currency prices their menu.
* 6. **A missing token is a FAILURE, not a skip.** PROVISION_GITHUB_TOKEN expires
* silently (trap 4); on this path nobody is looking at the "not configured" banner,
* so it has to be loud. Checked last so a plan that was never eligible does not
* raise a token alarm.
*/
export function decideAutoPropose(facts: AutoProposeFacts): AutoProposePlan {
if (facts.existingPrUrl) return { kind: "alreadyProposed", prUrl: facts.existingPrUrl };
if (!facts.config) return { kind: "skipped", reason: "notSelfServe" };
if (!facts.settled) return { kind: "skipped", reason: "awaitingPayment" };

const c = facts.config;
if (c.slug !== c.billingSlug) return { kind: "skipped", reason: "slugMismatch" };
if (!c.template || !c.currency || c.modules.length === 0 || c.languages.length === 0) {
return { kind: "skipped", reason: "incompleteConfiguration" };
}
// Defence in depth behind `signupSchema`. That guard is new (O3), so rows captured
// before it can still hold a newline — and this name travels into
// build-tenant-image.yml's newline-delimited `build-args:`. The deploy chain rejects
// it too, but only AFTER the entry is merged, which would leave a paying customer
// with a merged registry entry that never provisions.
if (/[\u0000-\u001f\u007f]/.test(c.name)) return { kind: "skipped", reason: "unsafeName" };
if (!facts.provisioningConfigured) {
return { kind: "failed", detail: "PROVISION_GITHUB_TOKEN is unset or expired" };
}
return { kind: "propose" };
}

/** Founder-facing one-liners. Deliberately say what to DO, not just what happened. */
export const AUTO_PROPOSE_NOTES: Record<AutoProposeSkip, string> = {
notSelfServe:
"No automatic proposal: this plan was created by hand (no signup lead attached), so provisioning stays manual as before.",
awaitingPayment:
"No automatic proposal: the payment gate does not consider this plan settled yet. Nothing to do — the next webhook delivery retries.",
incompleteConfiguration:
"No automatic proposal: the lead did not record a full configuration (template, currency, modules and languages are all required). Open /admin/provision?from=<signup id> and choose.",
slugMismatch:
"No automatic proposal: the lead's requested web address does not match the slug this plan bills against. Someone should look at that before a tenant is created.",
unsafeName:
"No automatic proposal: the restaurant name holds a line break or control character, which cannot go into a tenant image build. Fix the name on the lead, then open /admin/provision?from=<signup id>.",
proposalExists:
"No automatic proposal opened: a proposal for this slug already exists and is recorded. Nothing to do.",
};

/**
* What `openProvisioningPr` meant by refusing. Pure, and here rather than in the shell,
* because the first version of this lived in the shell as an untested string test and got
* it wrong: it matched BOTH refusals and called them both "a proposal already exists".
*
* - `slugLive` — the slug is already MERGED into the registry, i.e. a live tenant.
* Reported as a benign "already proposed" this becomes: money taken
* for a subdomain that belongs to someone else, and an email saying
* there is nothing to do.
* - `proposalOpen` — the `provision/<slug>` branch exists. Usually a concurrent webhook
* delivery; but `openProvisioningPr` creates the branch BEFORE it
* commits and opens the PR, so it is also what an interrupted attempt
* leaves behind. The caller has to tell those apart by whether a PR
* URL was actually recorded — an orphan branch with no PR is a wedge,
* not a duplicate.
*/
export type ProvisioningRefusal = "slugLive" | "proposalOpen" | "other";

export function classifyProvisioningRefusal(message: string): ProvisioningRefusal {
if (/already has/i.test(message)) return "slugLive";
if (/already open/i.test(message)) return "proposalOpen";
return "other";
}
Loading
Loading