diff --git a/app/(control)/dashboard/billing/page.tsx b/app/(control)/dashboard/billing/page.tsx index 1bf184d..e229d4c 100644 --- a/app/(control)/dashboard/billing/page.tsx +++ b/app/(control)/dashboard/billing/page.tsx @@ -3,7 +3,12 @@ import { requirePartner } from "@/lib/rbac"; import { controlLocale } from "@/lib/control-locale"; import { db } from "@/lib/db"; import { eur, shortDate } from "@/lib/format"; -import { intervalKeyOf, planState, type PlanState } from "@/lib/billing-display"; +import { + intervalKeyOf, + nextChargeDate, + planState, + type PlanState, +} from "@/lib/billing-display"; import StartPaymentButton from "@/components/control/StartPaymentButton"; export default async function DashboardBillingPage() { @@ -12,11 +17,17 @@ export default async function DashboardBillingPage() { const t = await getTranslations({ locale, namespace: "control.plan" }); // Plan-status node via if/else (avoids a nested ternary — Sonar S3358). - const statusNode = (state: PlanState, startDate: Date | null, billingId: string) => { + // + // `nextChargeDate` rather than the raw `startDate` this used to print: that column is + // the FIRST recurring charge and is never advanced, so from month two onward it named + // a date in the past (see lib/billing-display.ts). Same defect, same fix, on both the + // reseller's page and the owner's card. + const statusNode = (sub: { startDate: Date | null; interval: string }, state: PlanState, billingId: string) => { if (state === "active") { + const next = nextChargeDate(sub.startDate, sub.interval, new Date()); return (

- {startDate ? t("activeNextCharge", { date: shortDate(startDate) }) : t("active")} + {next ? t("activeNextCharge", { date: shortDate(next) }) : t("active")}

); } @@ -78,7 +89,7 @@ export default async function DashboardBillingPage() { interval: t(`interval.${intervalKeyOf(sub.interval)}`), })}

- {statusNode(state, sub.startDate, b.id)} + {statusNode(sub, state, b.id)} ) : (

{t("noPlan")}

diff --git a/app/(control)/dashboard/page.tsx b/app/(control)/dashboard/page.tsx index 17214ad..d8e8480 100644 --- a/app/(control)/dashboard/page.tsx +++ b/app/(control)/dashboard/page.tsx @@ -1,75 +1,26 @@ -import { getTranslations } from "next-intl/server"; import { requirePartnerOrOwner } from "@/lib/rbac"; import { controlLocale } from "@/lib/control-locale"; import { db } from "@/lib/db"; -import { eur, shortDate } from "@/lib/format"; -import { intervalKeyOf, planState, type PlanState } from "@/lib/billing-display"; -import ClientForm from "@/components/control/ClientForm"; -import ClientStatusBadge from "@/components/control/ClientStatusBadge"; -import StartPaymentButton from "@/components/control/StartPaymentButton"; -import ActivatingPanel from "@/components/control/ActivatingPanel"; +import OwnerDashboard from "@/components/control/OwnerDashboard"; +import PartnerDashboard from "@/components/control/PartnerDashboard"; /** - * Which "where is my restaurant" line the welcome hero shows — or none. - * Extracted so the choice reads as a decision instead of a nested ternary - * (Sonar S3358). + * `/dashboard` — shared by the reseller (PARTNER) and the restaurant owner (OWNER), + * who want almost nothing in common. * - * Returns null for an owner who is already activating: `` - * says what is happening in full, and the hero's "start your subscription" - * nudge directly contradicts a panel that opens with "your first payment went - * through". A line that argues with the line below it is worse than no line. - */ -function liveSinceLine( - liveSince: Date | null, - restaurant: string, - isOwner: boolean, - activating: boolean, - tp: (key: string, values?: Record) => string, -): string | null { - if (liveSince) return tp("liveSince", { restaurant, date: shortDate(liveSince) }); - if (!isOwner) return tp("liveSinceUnknown", { restaurant }); - return activating ? null : tp("notLiveYet", { restaurant }); -} - -/** - * What the payer can do about this plan right now. Written as guard clauses - * rather than a chain of ternaries (Sonar S3358), mirroring `statusNode` in - * `/dashboard/billing`. + * A partner reads a pipeline: many clients, each a row, the plans needing attention + * pulled to the top. An owner has exactly one plan and one question — *where is my + * restaurant app and how do I get into it?* Until O4 they shared one render, and the + * owner's half of it was a single sentence ("nothing to do here right now") with no + * amount, no next-charge date and no mention of their app at all. * - * `state` is only ever "pay" or "processing" here — the caller's filter admits - * exactly those two — so "processing" is the mandate-validation window. An owner - * gets it spelled out; a partner keeps the terse line, because a reseller reads - * this queue as a pipeline and is not the one who just watched money leave their - * account. Neither branch renders a pay button in that window: a second payment - * is the trap it sets. + * The two views are now separate components; this page owns only the guard, the + * locale, and the one query both need. */ -function planAction(args: { - state: PlanState; - billingId: string; - isOwner: boolean; - locale: string; - restaurant: string; - tp: (key: string) => string; -}) { - const { state, billingId, isOwner, locale, restaurant, tp } = args; - if (state === "pay") { - return ( -
- - {tp("firstChargeNote")} -
- ); - } - if (isOwner) return ; - return

{tp("processing")}

; -} - export default async function DashboardPage() { const user = await requirePartnerOrOwner(); const isOwner = user.role === "OWNER"; const locale = await controlLocale(); - const t = await getTranslations({ locale, namespace: "control.dashboard" }); - const tp = await getTranslations({ locale, namespace: "control.plan" }); // Billings scoped to the caller: an OWNER pays via payerUserId (ADR-004); a // PARTNER via their CRM clients. @@ -78,116 +29,33 @@ export default async function DashboardPage() { include: { client: true, subscriptions: { orderBy: { createdAt: "desc" } }, - // Only first payments distinguish "pay" from "processing" (planState); - // scope + bound so the unboundedly-growing recurring history is never - // pulled into this request path. + // Only first payments distinguish "pay" from "processing" (planState); scope + + // bound so the unboundedly-growing recurring history is never pulled into this + // request path. KEEP THIS SCOPED now that an owner also sees a history list: + // widening it to "the 10 newest payments" would push the `first` payment out of + // the window once a plan has ten recurring charges, and planState would then + // read a paid, activating plan as "pay" — a pay button shown to somebody who + // already paid. OwnerDashboard fetches the history separately, and bounds it + // separately, for exactly that reason. payments: { where: { sequenceType: "first" }, orderBy: { createdAt: "desc" }, take: 20 }, }, orderBy: { createdAt: "desc" }, }); - // Plans that still need the payer's attention (welcome hero): awaiting a - // payment, or a payment being processed. - const awaiting = billings.filter((b) => { - const st = planState(b.subscriptions[0], b.payments); - return st === "pay" || st === "processing"; - }); - // Reseller CRM — partners only; an owner has no clients. - const clients = isOwner - ? [] - : await db.client.findMany({ where: { partnerId: user.id }, orderBy: { updatedAt: "desc" } }); - - return ( -
-
-

{isOwner ? t("ownerTitle") : t("title")}

-

{isOwner ? t("ownerIntro") : t("intro")}

-
- - {awaiting.map((b) => { - const sub = b.subscriptions[0]; - if (!sub) return null; - // Owner billings carry no CRM client; the slug identifies the restaurant. - const restaurant = b.client?.restaurantName ?? b.tenantSlug; - const state = planState(sub, b.payments); - // `liveSince` is set by the founder at onboarding, so for a reseller plan - // (defined AFTER the tenant is live) its absence just means "date - // unknown" — the tenant is live either way. A self-serve owner is the - // opposite case: they signed up minutes ago and nothing has been - // provisioned, so telling them their restaurant "is live" is simply false. - const whereItStands = liveSinceLine( - b.liveSince, - restaurant, - isOwner, - state === "processing", - tp, - ); - return ( -
-

- {tp("welcomeKicker")} -

-

- {tp("welcomeTitle", { name: user.name })} -

- {whereItStands &&

{whereItStands}

} -

- {tp("amountLine", { - amount: eur(sub.amountCents), - interval: tp(`interval.${intervalKeyOf(sub.interval)}`), - })} -

- {planAction({ - state, - billingId: b.id, - isOwner, - locale, - restaurant, - tp, - })} -
- ); - })} - {isOwner ? ( - awaiting.length === 0 && ( -

{t("ownerAllSet")}

- ) - ) : ( - <> -
-

{t("addClient")}

-
- -
-
+ if (isOwner) { + return ; + } - {clients.length === 0 ? ( -

{t("empty")}

- ) : ( - - )} - - )} -
+ const clients = await db.client.findMany({ + where: { partnerId: user.id }, + orderBy: { updatedAt: "desc" }, + }); + return ( + ); } diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts index 3c0bee6..0268437 100644 --- a/app/api/signup/route.ts +++ b/app/api/signup/route.ts @@ -46,6 +46,7 @@ const FOUNDER_FALLBACK_NOTES: Record = { async function mintAccount( outcome: Extract, who: { email: string; contactName: string; restaurantName: string }, + signupRequestId: string, ): Promise<{ account: boolean; founderOutcome: string }> { let minted; try { @@ -53,6 +54,7 @@ async function mintAccount( ...who, slug: outcome.slug, amountCents: outcome.amountCents, + signupRequestId, }); } catch (e) { if (!(e instanceof SlugRaceLostError)) throw e; @@ -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 ───────────────────────────────────── diff --git a/components/control/OwnerDashboard.tsx b/components/control/OwnerDashboard.tsx new file mode 100644 index 0000000..cb4bdc6 --- /dev/null +++ b/components/control/OwnerDashboard.tsx @@ -0,0 +1,163 @@ +import { getTranslations } from "next-intl/server"; +import { db } from "@/lib/db"; +import { loadTenantRegistry } from "@/lib/tenant-registry"; +import { tenantStage } from "@/lib/tenant-liveness"; +import { probeTenantHealthy } from "@/lib/tenant-health"; +import OwnerPlanCard from "./OwnerPlanCard"; + +/** + * The restaurant owner's dashboard (SOFRA-ONBOARDING-PLAN O4). + * + * Shows EVERY plan they pay for, not only the ones "awaiting attention" — that filter + * is what left an owner with an active subscription reading one sentence and no + * numbers. Each plan carries the panel that says where their app is and how to get + * into it, which is the piece O3 handed over. + */ + +type PaymentRow = { + id: string; + billingId: string; + createdAt: Date; + sequenceType: string; + status: string; + amountCents: number; +}; + +type OwnerBilling = { + id: string; + tenantSlug: string; + liveSince: Date | null; + provisioningPrUrl: string | null; + client: { restaurantName: string } | null; + subscriptions: { status: string; amountCents: number; interval: string; startDate: Date | null }[]; + payments: { sequenceType: string; status: string }[]; +}; + +/** Newest payments shown in an owner's history, per plan. */ +const HISTORY_LIMIT = 10; + +/** + * Ceiling on the rows the history query may read, across every plan the owner holds. + * + * The cap exists so the query cannot grow with the age of an account, not because the + * number is meaningful: an owner holds ONE plan in practice (the self-serve signup + * mints exactly one), so 100 rows is over eight years of monthly charges for the + * realistic case and the slice below is exact. + * + * The one case where it is lossy is stated rather than hidden: an owner holding several + * plans, one of them far busier, could see the quiet plan's history thinned — the rows + * are taken newest-first across all of them. That is display-only history on a page + * whose purpose is the CURRENT plan, and the alternative (a query per plan) is the + * N+1 this replaced. + */ +const HISTORY_ROW_CAP = 100; + +/** + * The newest payments for every plan in one round trip, grouped by plan. + * + * Prisma has no per-group limit, so the slice happens in memory. Ordering is done by + * the database and preserved by `Map`/array insertion order, so each plan's list stays + * newest-first without a second sort. + */ +async function paymentHistory(billingIds: string[]) { + if (billingIds.length === 0) return new Map(); + const rows = await db.billingPayment.findMany({ + where: { billingId: { in: billingIds } }, + orderBy: { createdAt: "desc" }, + take: HISTORY_ROW_CAP, + }); + const byBilling = new Map(); + for (const row of rows) { + const list = byBilling.get(row.billingId) ?? []; + if (list.length < HISTORY_LIMIT) list.push(row); + byBilling.set(row.billingId, list); + } + return byBilling; +} + +/** + * The registry `domain` for each slug, or an empty map when the registry cannot be + * read at all. + * + * An unreadable registry degrading to "no domain" is the fail-closed direction here: + * `tenantStage` then cannot reach "ready", so the worst case is a live owner briefly + * told their app is still being set up. The alternative — surfacing the registry read + * error on a customer's dashboard — reports one of our ops conditions to somebody who + * cannot act on it, and the founder already gets it loudly on `/admin/provision`. + */ +async function registryDomains(slugs: string[]): Promise> { + if (slugs.length === 0) return new Map(); + const registry = await loadTenantRegistry(); + if (!registry.ok) return new Map(); + const wanted = new Set(slugs); + return new Map(registry.tenants.filter((t) => wanted.has(t.slug)).map((t) => [t.slug, t.domain])); +} + +export default async function OwnerDashboard({ + locale, + ownerName, + billings, +}: { + readonly locale: string; + readonly ownerName: string; + readonly billings: OwnerBilling[]; +}) { + const t = await getTranslations({ locale, namespace: "control.dashboard" }); + const tp = await getTranslations({ locale, namespace: "control.plan" }); + const domains = await registryDomains(billings.map((b) => b.tenantSlug)); + + // ONE history query for every plan, grouped in memory — not one per row. The probe + // is what this render can actually wait on (3s cap, 60s cache per domain), so the + // queries should not add round-trips on top of it. + const historyByBilling = await paymentHistory(billings.map((b) => b.id)); + + const cards = await Promise.all( + billings.map(async (b) => { + const domain = domains.get(b.tenantSlug) ?? null; + return { + billing: b, + domain, + history: historyByBilling.get(b.id) ?? [], + stage: tenantStage({ + // Read from the SAME `first`-scoped payment window planState uses, so the + // panel and the plan status can never disagree about whether money moved. + paid: b.payments.some((p) => p.sequenceType === "first" && p.status === "paid"), + provisioningPrUrl: b.provisioningPrUrl, + registryDomain: domain, + // `tenantSlug` is unique, so no two plans share a domain and this is one + // probe per plan however the loop is written. + healthy: domain ? await probeTenantHealthy(domain) : false, + }), + }; + }), + ); + + return ( +
+
+

{t("ownerTitle")}

+

{t("ownerIntro")}

+
+ + {cards.length === 0 ? ( +

{tp("noPlan")}

+ ) : ( + cards.map(({ billing, domain, history, stage }) => ( + + )) + )} +
+ ); +} diff --git a/components/control/OwnerPlanCard.tsx b/components/control/OwnerPlanCard.tsx new file mode 100644 index 0000000..9678708 --- /dev/null +++ b/components/control/OwnerPlanCard.tsx @@ -0,0 +1,150 @@ +import { getTranslations } from "next-intl/server"; +import { eur, shortDate } from "@/lib/format"; +import { + intervalKeyOf, + nextChargeDate, + paymentStatusKey, + planState, + sequenceKey, + type PlanState, +} from "@/lib/billing-display"; +import { visibleTenantStage, type TenantStage } from "@/lib/tenant-liveness"; +import StartPaymentButton from "./StartPaymentButton"; +import ActivatingPanel from "./ActivatingPanel"; +import TenantReadyPanel from "./TenantReadyPanel"; + +/** + * An owner's single plan, in full (SOFRA-ONBOARDING-PLAN O4 — the gap O2 named and + * deliberately left open). + * + * Until this existed, an owner whose plan went ACTIVE saw one sentence: *"Your + * subscription is active — nothing to do here right now."* No amount, no next-charge + * date, no payment history, no mention of the app they are paying for. Not broken — + * but a defaulted "nothing to do", which is the shape this funnel keeps having to + * remove now that no founder is on the call to fill it in. Someone whose card is + * charged monthly is entitled to see what for and when next. + * + * The fix is NOT to loosen `/dashboard/billing`. That page is `requirePartner()` + * because it is the reseller's book — every plan under their partner id, priced as a + * pipeline. An owner needs their own plan, and the panel that tells them where their + * restaurant is; those are one card, not a page they share with a reseller. + */ +export interface OwnerPlanCardProps { + readonly locale: string; + readonly billingId: string; + readonly restaurant: string; + readonly ownerName: string; + readonly subscription: { readonly status: string; readonly amountCents: number; readonly interval: string; readonly startDate: Date | null } | null; + /** `first`-sequence payments only — what `planState` reads. */ + readonly firstPayments: ReadonlyArray<{ readonly sequenceType: string; readonly status: string }>; + /** Newest-first, bounded: every payment, for the history list. */ + readonly history: ReadonlyArray<{ readonly id: string; readonly createdAt: Date; readonly sequenceType: string; readonly status: string; readonly amountCents: number }>; + readonly liveSince: Date | null; + readonly stage: TenantStage; + readonly tenantDomain: string | null; +} + +/** + * What the owner can do about this plan right now. Guard clauses rather than nested + * ternaries (Sonar S3358), and no pay button in the "processing" window — a second + * payment there is the double-charge trap `ActivatingPanel` exists to prevent. + */ +function planAction(args: { + state: PlanState; + billingId: string; + locale: string; + restaurant: string; + nextCharge: Date | null; + t: (key: string, values?: Record) => string; +}) { + const { state, billingId, locale, restaurant, nextCharge, t } = args; + if (state === "pay") { + return ( +
+ + {t("firstChargeNote")} +
+ ); + } + if (state === "processing") return ; + if (state === "active") { + return ( +

+ {nextCharge ? t("activeNextCharge", { date: shortDate(nextCharge) }) : t("active")} +

+ ); + } + return

{t("inactive")}

; +} + +export default async function OwnerPlanCard(props: OwnerPlanCardProps) { + const { locale, billingId, restaurant, ownerName, subscription, firstPayments, history } = props; + const t = await getTranslations({ locale, namespace: "control.plan" }); + const state = planState(subscription ?? undefined, [...firstPayments]); + + return ( +
+

+ {t("welcomeKicker")} +

+

{t("welcomeTitle", { name: ownerName })}

+ +
+

{restaurant}

+ {props.liveSince && ( + + {t("liveSinceShort", { date: shortDate(props.liveSince) })} + + )} +
+ + {subscription ? ( + <> +

+ {t("amountLine", { + amount: eur(subscription.amountCents), + interval: t(`interval.${intervalKeyOf(subscription.interval)}`), + })} +

+ {planAction({ + state, + billingId, + locale, + restaurant, + nextCharge: nextChargeDate(subscription.startDate, subscription.interval, new Date()), + t, + })} + + ) : ( +

{t("noPlan")}

+ )} + + + + {history.length > 0 && ( +
+

+ {t("history")} +

+
    + {history.map((p) => ( +
  • + + {shortDate(p.createdAt)} · {t(`sequence.${sequenceKey(p.sequenceType)}`)} + + + {eur(p.amountCents)} ·{" "} + {t(`paymentStatus.${paymentStatusKey(p.status)}`, { status: p.status })} + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/components/control/PartnerDashboard.tsx b/components/control/PartnerDashboard.tsx new file mode 100644 index 0000000..d841206 --- /dev/null +++ b/components/control/PartnerDashboard.tsx @@ -0,0 +1,146 @@ +import { getTranslations } from "next-intl/server"; +import { eur, shortDate } from "@/lib/format"; +import { intervalKeyOf, planState, type PlanState } from "@/lib/billing-display"; +import ClientForm from "./ClientForm"; +import ClientStatusBadge from "./ClientStatusBadge"; +import StartPaymentButton from "./StartPaymentButton"; + +/** + * The reseller's dashboard — unchanged behaviour, lifted out of `page.tsx` when the + * owner view stopped being a branch inside it (SOFRA-ONBOARDING-PLAN O4). + */ + +type PartnerBilling = { + id: string; + tenantSlug: string; + liveSince: Date | null; + client: { restaurantName: string } | null; + subscriptions: { amountCents: number; interval: string; status: string }[]; + payments: { sequenceType: string; status: string }[]; +}; + +type PartnerClient = { + id: string; + restaurantName: string; + city: string | null; + contactName: string | null; + status: string; + updatedAt: Date; +}; + +/** + * What the partner can do about a client's plan right now. Guard clauses rather than a + * chain of ternaries (Sonar S3358). + * + * `state` is only ever "pay" or "processing" here — the caller's filter admits exactly + * those two — so "processing" is the mandate-validation window. The reseller keeps the + * terse line: they read this queue as a pipeline and are not the one who just watched + * money leave their own account (the owner gets `` instead). Neither + * branch renders a pay button in that window: a second payment is the trap it sets. + */ +function planAction(args: { state: PlanState; billingId: string; tp: (key: string) => string }) { + const { state, billingId, tp } = args; + if (state === "pay") { + return ( +
+ + {tp("firstChargeNote")} +
+ ); + } + return

{tp("processing")}

; +} + +export default async function PartnerDashboard({ + locale, + partnerName, + billings, + clients, +}: { + readonly locale: string; + readonly partnerName: string; + readonly billings: PartnerBilling[]; + readonly clients: PartnerClient[]; +}) { + const t = await getTranslations({ locale, namespace: "control.dashboard" }); + const tp = await getTranslations({ locale, namespace: "control.plan" }); + + // Plans that still need the payer's attention: awaiting a payment, or a payment + // being processed. + const awaiting = billings.filter((b) => { + const st = planState(b.subscriptions[0], b.payments); + return st === "pay" || st === "processing"; + }); + + return ( +
+
+

{t("title")}

+

{t("intro")}

+
+ + {awaiting.map((b) => { + const sub = b.subscriptions[0]; + if (!sub) return null; + const restaurant = b.client?.restaurantName ?? b.tenantSlug; + // `liveSince` is set by the founder at onboarding, so for a reseller plan + // (defined AFTER the tenant is live) its absence just means "date unknown" — + // the tenant is live either way. + const whereItStands = b.liveSince + ? tp("liveSince", { restaurant, date: shortDate(b.liveSince) }) + : tp("liveSinceUnknown", { restaurant }); + return ( +
+

+ {tp("welcomeKicker")} +

+

+ {tp("welcomeTitle", { name: partnerName })} +

+

{whereItStands}

+

+ {tp("amountLine", { + amount: eur(sub.amountCents), + interval: tp(`interval.${intervalKeyOf(sub.interval)}`), + })} +

+ {planAction({ state: planState(sub, b.payments), billingId: b.id, tp })} +
+ ); + })} + +
+

{t("addClient")}

+
+ +
+
+ + {clients.length === 0 ? ( +

{t("empty")}

+ ) : ( + + )} +
+ ); +} diff --git a/components/control/TenantReadyPanel.tsx b/components/control/TenantReadyPanel.tsx new file mode 100644 index 0000000..752a9a8 --- /dev/null +++ b/components/control/TenantReadyPanel.tsx @@ -0,0 +1,91 @@ +import { getTranslations } from "next-intl/server"; +import { tenantForgotPasswordUrl, tenantOrigin, type TenantStage } from "@/lib/tenant-liveness"; + +/** + * "Your app is ready — set your admin password" (SOFRA-ONBOARDING-PLAN O4, the piece + * O3 handed over). + * + * O3 closed the credential story mechanically: the tenant frontend now has + * /forgot-password and /reset-password, so the owner sets their own password and the + * bootstrap password generated on the box is never read, never emailed, never spoken. + * The gap left behind was that nobody TELLS the owner. Between paying and being told, + * a self-serve customer has a running restaurant app on their own subdomain and no + * idea it exists — which is the same as not having one. + * + * The stage comes from `tenantStage`, which earns each claim rather than defaulting to + * the friendliest one. Only "ready" — the stage backed by the app answering its own + * health endpoint — renders a link, because only "ready" knows the link works. + * + * Deliberately NOT an email. The address on file is the one that signed up; sending + * "here is where your admin panel lives" unprompted is a phishing shape we would be + * teaching our own customers to trust. They are already logged in here. + */ +export default async function TenantReadyPanel({ + locale, + stage, + domain, +}: { + readonly locale: string; + readonly stage: TenantStage; + readonly domain: string | null; +}) { + const t = await getTranslations({ locale, namespace: "control.tenantReady" }); + if (stage === "none") return null; + + if (stage === "ready" && domain) { + const origin = tenantOrigin(domain); + const resetUrl = tenantForgotPasswordUrl(domain); + // `tenantOrigin` rejecting the domain is the one way a "ready" tenant reaches this + // with no usable link — a malformed registry entry that still somehow answered. + // Fall through to the waiting copy rather than render a broken anchor. + if (origin && resetUrl) { + return ( +
+

+ {t("readyKicker")} +

+

{t("readyTitle")}

+

{t("readyBody", { domain })}

+ +

{t("setPasswordWhy")}

+
+ ); + } + } + + // Everything short of observed-serving. Three distinct waits, because "we are + // building it" and "it is built, waiting on a review" are not the same news to + // someone counting the minutes — and none of the three implies anything is wrong. + // + // A "ready" that fell through the block above (it answered, but its registry domain + // is not a usable host) reads as almostReady, which is precisely what it is: seen + // serving, no link we are willing to hand out. + const key = stage === "ready" ? "almostReady" : stage; + + return ( +
+

+ {t("waitingKicker")} +

+

{t(`${key}Title`)}

+

{t(`${key}Body`)}

+
+ ); +} diff --git a/docs/adr/ADR-012-auto-provisioning-trigger.md b/docs/adr/ADR-012-auto-provisioning-trigger.md index 68c22ae..6496659 100644 --- a/docs/adr/ADR-012-auto-provisioning-trigger.md +++ b/docs/adr/ADR-012-auto-provisioning-trigger.md @@ -1,7 +1,9 @@ # ADR-012 — Auto-provisioning trigger: how the control plane runs the tenant scripts -**Status:** proposed 2026-07-18 (owner decision pending — this ADR frames the -options; no code ships until a mechanism is chosen) +**Status:** **accepted 2026-07-26** — **D + A** was chosen and shipped, and proven end +to end (merged registry PR → live HTTPS tenant in ~12 min); **amended 2026-07-30** — the +second half of the chain is now automatic: merging the registry PR builds the tenant +image and provisions. See §Amendment. ## Context @@ -69,7 +71,7 @@ via a contents-scoped GitHub token; `sync-to-*.yml` delivers it; a founder (or a preserves ADR-003/007 unchanged, is fully auditable/reversible (a reviewable PR), and adds **no box privilege** to the app — only repo-contents write. -## Recommendation (proposed) +## Recommendation — chosen and shipped **D + A, staged.** The app, on convert/provision, **opens a registry PR** via a narrowly-scoped GitHub token (contents write on the deploy repo only) — honoring @@ -81,22 +83,94 @@ most defensible for a solo operator: every provision is a reviewable, revertable PR; the app holds only a repo-scoped token; and it composes from patterns already in the repo rather than a new privileged box listener. -For **staging/demo** tenants the registry PR may auto-merge (trusted, low-stakes); -**prod** provisioning keeps the human merge. Cross-box prod provisioning from the -staging control plane stays out of scope (per-box boundary) until a prod-box CI -leg exists. +Cross-box prod provisioning from the staging control plane stays out of scope (per-box +boundary) until a prod-box CI leg exists. + +**Auto-merge was not taken**, and the 2026-07-30 amendment is why it is not needed: +automating the work *after* the merge gets the same hands-off result while keeping the one +thing worth a human, which is reading the proposed entry. Auto-merging is the *onboarding +plan's* option C (§2), explicitly rejected there — not to be confused with option C above. If CI-in-the-loop latency proves unacceptable, fall back to **B** (box listener) — it keeps the same privilege split without the git round-trip. -## Decide at implementation - -- GitHub token scope + storage for the app (fine-grained, contents-write on the - deploy repo only; box `.env`, never committed). -- Whether the registry PR carries the full computed entry (slug/db/domain/ - languages/modules/currency/template from the signup + module choices, ADR-010) — - the app already reads the registry grammar (`lib/tenant-registry.ts`). -- Idempotency + status reflection: the script is idempotent; the control plane - needs to surface provisioning state (the `Client.status='LIVE'` / registry - `status` flip) back to `/admin`. -- Deprovision path (same trigger, `deprovision-tenant.sh`) — likely founder-only. +## Decided at implementation + +- **Token scope + storage.** `PROVISION_GITHUB_TOKEN` — fine-grained, `piwas-21/restaurant-app-deploy` + only, Contents + Pull requests: write. Lives in `/opt/rumi/deploy/.env` on the box, + never committed. It can propose a tenant and nothing else. **Its expiry is silent** + (`/admin/provision` degrades to a "not configured" banner rather than erroring), so + the expiry is calendared — see the workspace runbook §0. +- **The PR carries the full computed entry** (`lib/provisioning-registry.ts`): + slug-derived `db`/`db_role`/`compose_project`/`domain`/`frontend_tag`, plus + languages/modules/currency/template from the signup, `status: provisioning`, + `managed: scripts`, and a box-aware `backend_tag`. +- **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. + +## Amendment — 2026-07-30: the merge chains build + provision + +> **Mind the option letters.** A–D above are this document's, and what shipped is +> **D + A**. SOFRA-ONBOARDING-PLAN §2 re-uses A/B/C for a *different* question — how much +> of the post-merge work to automate — and this amendment implements that plan's **option +> B**. Same letters, different axis; the plan's B is not the box listener described above. + +Shipped as the deploy repo's `provision-on-registry-merge.yml`: merging the registry PR +now chains `build-tenant-image.yml` (frontend repo) → `provision-tenant.sh` on the box. +The founder merges; nothing else is theirs to do. + +**Why the invariants survive** — this is an amendment, not a violation: + +| Invariant | Still holds because | +|---|---| +| 1. registry stays git-first | the chain only **reads** the registry, after the entry is committed, reviewed and synced. Nothing writes it. | +| 2. the public container stays unprivileged | unchanged. The box SSH key is still only in Actions secrets; `sofra` gained no capability. The chain's one new credential (`FRONTEND_DISPATCH_TOKEN`, Actions:write on the frontend repo) lives in the **deploy repo's** Actions secrets, not in the app. | +| 3. a human review checkpoint before first live provisioning | the checkpoint **is the merge**. This ADR's own recommendation already allowed for it: *"a founder (or a `workflow_run`-chained Action) then runs the script."* Its value was a human reading the proposed YAML; it was never improved by that human also copying two `gh workflow run` commands. | + +What made this safe now and not at proposal time is **payment gating** +(`lib/provisioning-payment-gate.ts`, O2): a self-serve tenant gets no proposal at all +until its first payment settles. Without that, coupling an anonymous form to a merge +that provisions would put spam one rubber-stamp away from a database. + +**Two properties the chain owes, and how it pays them:** + +- **Idempotent.** Selection is on state, never on the push diff — a diff-based trigger + cannot survive a revert-and-remerge, which reproduces the same diff. A slug is + **eligible** when the registry declares intent (`managed: scripts` + `box: staging` + + `status: provisioning`) and the chain has not already finished it. Eligible is not the + same as provisioned: the run still refuses the whole batch over a cap (2), and refuses + everything if `FRONTEND_DISPATCH_TOKEN` is missing — both reported, neither silent. + + The completion marker is one the chain writes itself + (`/opt/rumi/tenants//.chain-provisioned`), **not** the tenant's `.env`. + `provision-tenant.sh` renders `.env` early and then keeps going through + `docker compose pull`, `up -d` and a five-minute health wait, so `.env` means "the + script started". Keying on it would make the most likely failure this chain introduces — + provisioning against an image the build never published — permanently invisible: the + retry the failure notice recommends would find `.env`, skip, and report green. A tenant + with `.env` but no marker is therefore **completed**, not skipped. + Consequence, deliberate: **first provisioning only.** Re-provisioning a live tenant + (a module upsell) stays an explicit `provision-tenant.yml` dispatch, because an + unattended trigger that also re-applied would let an unrelated registry edit restart + every tenant on the box. +- **Failure-visible.** Nobody watches a terminal now, so every outcome is reported to + where the founder already is: a comment on the registry PR they just merged, plus an + issue on the deploy repo when anything fails. A silent automatic chain would be worse + than a noisy manual one. + + Two cases are easy to leave silent and are deliberately not. The **upstream registry + sync failing** is checked in a *step* rather than the job's `if:` — gating the job would + skip the workflow entirely, so the `if: always()` reporter would never run, and a failed + sync is exactly when the founder is wondering why their merge did nothing. And an entry + the chain **refuses** (a `box: prod` tenant, a malformed field) is reported too, not just + dropped: a merged tenant that will never be provisioned is the same silence in a + different costume. + +**Still founder-operated after this amendment:** credential handover. The generated +admin password is read off the box by hand. The one-time-reveal replacement (never +emailed, forced change at first login) is the remaining half of O3. + +**Scope unchanged:** staging box only — the chain follows `sync-registry-to-staging.yml` +and inherits its narrowness. A `box: prod` entry is reported and never provisioned, +per the per-box boundary above. diff --git a/lib/actions/provisioning-actions.ts b/lib/actions/provisioning-actions.ts index 104d360..9a351ff 100644 --- a/lib/actions/provisioning-actions.ts +++ b/lib/actions/provisioning-actions.ts @@ -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"; @@ -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 { - 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 => @@ -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) { diff --git a/lib/auto-provision-policy.ts b/lib/auto-provision-policy.ts new file mode 100644 index 0000000..0f1cf37 --- /dev/null +++ b/lib/auto-provision-policy.ts @@ -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 | { 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 = { + 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= 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=.", + 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/` 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"; +} diff --git a/lib/auto-provision.ts b/lib/auto-provision.ts new file mode 100644 index 0000000..f9bbfc8 --- /dev/null +++ b/lib/auto-provision.ts @@ -0,0 +1,192 @@ +// Payment-triggered provisioning (SOFRA-ONBOARDING-PLAN O3, second half) — the shell. +// +// 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 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 +// 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. +// 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). + +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 { reportFailedProposal } from "@/lib/billing-notify"; +import { + openProvisioningPr, + provisioningConfigured, + ProvisioningApiError, + ProvisioningNotConfiguredError, +} from "@/lib/provisioning"; + +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 + * ordinary case, since Mollie redelivers webhooks. + * + * Idempotency is `provisioningPrUrl` on our own row, read by the policy and written here. + * GitHub refusing a duplicate `provision/` 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. + */ +export async function autoProposeProvisioning(billingId: string): Promise { + 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. + 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. + const lead = billing.signupRequest ? toProvisionPrefill(billing.signupRequest) : null; + + const plan = decideAutoPropose({ + existingPrUrl: billing.provisioningPrUrl, + config: lead + ? { ...lead, billingSlug: billing.tenantSlug } + : null, + // Only asked when it can matter — the gate is a query. + settled: lead ? (await slugProvisionVerdict(billing.tenantSlug)) === "allowed" : false, + provisioningConfigured: provisioningConfigured(), + }); + if (plan.kind !== "propose") return finish(billing.tenantSlug, plan); + + // Non-null by the policy's own checks; asserted so a future policy edit that drops a + // guard fails here loudly instead of proposing a tenant with no theme. + if (!lead?.template || !lead.currency) { + return finish(billing.tenantSlug, { + kind: "failed", + detail: "policy authorised an incomplete configuration", + }); + } + + const { prUrl } = await openProvisioningPr({ + slug: billing.tenantSlug, + name: lead.name, + adminEmail: lead.adminEmail, + template: lead.template, + currency: lead.currency, + languages: lead.languages, + modules: lead.modules, + city: lead.city || undefined, + }); + await db.tenantBilling.update({ + where: { id: billing.id }, + data: { provisioningPrUrl: prUrl }, + }); + return finish(billing.tenantSlug, { kind: "opened", prUrl }); + } catch (e) { + return finish(slugFor(billingId), await translate(billingId, e)); + } +} + +/** Best-effort slug for the failure path, where the row read may itself have failed. */ +async function slugFor(billingId: string): Promise { + try { + const b = await db.tenantBilling.findUnique({ + where: { id: billingId }, + select: { tenantSlug: true }, + }); + return b?.tenantSlug ?? billingId; + } catch { + return billingId; + } +} + +/** + * 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. + */ +async function translate(billingId: string, e: unknown): Promise { + if (e instanceof ProvisioningNotConfiguredError) { + return { kind: "failed", detail: "PROVISION_GITHUB_TOKEN is unset or expired" }; + } + if (e instanceof ProvisioningApiError) { + switch (classifyProvisioningRefusal(e.message)) { + case "slugLive": + return { + kind: "failed", + detail: + "this slug is already a live tenant in the registry — a payment was taken for a subdomain that is not available. Needs a human.", + }; + case "proposalOpen": { + const recorded = await db.tenantBilling + .findUnique({ where: { id: billingId }, select: { provisioningPrUrl: true } }) + .catch(() => null); + if (recorded?.provisioningPrUrl) { + return { kind: "alreadyProposed", prUrl: recorded.provisioningPrUrl }; + } + return { + kind: "failed", + detail: + "a provision/ branch exists but no PR is recorded — an earlier attempt probably died between creating the branch and opening the PR. Delete the orphan branch on the deploy repo, then retry.", + }; + } + default: + return { kind: "failed", detail: e.message }; + } + } + // Never rethrow: see rule 1 in the header. + console.error("autoProposeProvisioning failed", billingId, e); + return { kind: "failed", detail: "unexpected error — see the control-plane logs" }; +} + +/** + * Record every outcome, and email the founder about a failure immediately. + * + * 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. + */ +async function finish( + slugOrPromise: string | Promise, + outcome: AutoProposeOutcome, +): Promise { + // Fully guarded: this also runs from inside the catch block, so a throw here would + // escape the module and break rule 1 — and it would do so while reporting a failure, + // i.e. at the worst possible moment. + try { + const slug = await slugOrPromise; + // 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 } : {}), + ...("reason" in outcome ? { reason: outcome.reason } : {}), + ...("detail" in outcome ? { detail: outcome.detail } : {}), + }); + if (outcome.kind === "failed") await reportFailedProposal(slug, outcome.detail); + } catch (e) { + console.error("autoProposeProvisioning: could not record outcome", e); + } + return outcome; +} diff --git a/lib/billing-display.ts b/lib/billing-display.ts index 1daff56..ad40fbd 100644 --- a/lib/billing-display.ts +++ b/lib/billing-display.ts @@ -19,6 +19,82 @@ type PayLike = { sequenceType: string; status: string }; */ export type PlanState = "pay" | "processing" | "active" | "inactive" | "none"; +/** + * When this subscription is charged NEXT, or null when it cannot be stated. + * + * `BillingSubscription.startDate` is NOT the next charge — it is the FIRST recurring + * charge, written once at activation (`lib/billing.ts` `subscriptionStartDate`, = + * activation + one interval) and never advanced again: `recordPayment` inserts a + * `BillingPayment` row for each recurring charge and touches nothing on the + * subscription. So rendering `startDate` as "next charge on {date}" is correct for + * exactly one billing period and then prints a date in the past, forever — which is + * how it read on the partner billing page before O4, and what an owner would otherwise + * have been shown as the answer to the one billing question they actually ask. + * + * Derived rather than fetched: Mollie's subscription resource carries an authoritative + * `nextPaymentDate`, but reading it means a network call on every dashboard render, + * and persisting it means a schema change for a display field. Stepping the anchor + * forward by the interval needs neither and is right whenever charges land on + * schedule. + * + * The caveat, stated rather than hidden: if a charge FAILS, Mollie's own retry + * schedule diverges from this arithmetic and the date shown is optimistic by up to one + * retry window. That is strictly better than a frozen past date, and a failed charge + * shows up in the payment history the owner is now also shown. Month-end anchors also + * carry the same 1–3 day overflow drift `subscriptionStartDate` already documents + * (`setUTCMonth` turns Jan 31 into Mar 3), which settles after the first wrap. + * + * Returns null for a missing anchor or an interval outside the catalog, so the caller + * can fall back to a plain "Active." rather than print a guess. + */ +export function nextChargeDate( + startDate: Date | null, + mollieInterval: string, + now: Date, +): Date | null { + if (!startDate || Number.isNaN(startDate.getTime())) return null; + const months = Object.values(BILLING_INTERVALS).find((i) => i.mollie === mollieInterval)?.months; + if (!months) return null; + + // Step from the anchor rather than from `now`, so the answer stays on the + // subscription's own day-of-month instead of drifting to whenever the page was + // loaded. Bounded by construction: each iteration advances at least one month, and + // the loop stops the moment it passes `now`. + const next = new Date(startDate); + while (next <= now) { + next.setUTCMonth(next.getUTCMonth() + months); + } + return next; +} + +/** + * Mollie's `sequenceType` → a `control.plan.sequence.*` key. + * + * The partner billing page prints the raw value, which is fine for a reseller reading + * their own book. An OWNER's history is customer-facing, and "recurring"/"oneoff" are + * our vendor's vocabulary, not theirs. + */ +export function sequenceKey(sequenceType: string): "first" | "recurring" | "oneoff" { + if (sequenceType === "first") return "first"; + if (sequenceType === "oneoff") return "oneoff"; + return "recurring"; +} + +/** + * Mollie's payment `status` → a `control.plan.paymentStatus.*` key. + * + * Collapses the seven Mollie statuses into the four distinctions a payer acts on: + * it went through, it is in flight, it did not go through, or something we do not + * recognise (kept as a bucket so a status Mollie adds later renders as text rather + * than a missing-key crash — never silently as "paid"). + */ +export function paymentStatusKey(status: string): "paid" | "pending" | "failed" | "other" { + if (status === "paid" || status === "authorized") return "paid"; + if (status === "open" || status === "pending") return "pending"; + if (status === "failed" || status === "canceled" || status === "expired") return "failed"; + return "other"; +} + export function planState(sub: SubLike, payments: PayLike[]): PlanState { if (!sub) return "none"; if (sub.status === "ACTIVE") return "active"; diff --git a/lib/billing-notify.ts b/lib/billing-notify.ts new file mode 100644 index 0000000..40fb5b0 --- /dev/null +++ b/lib/billing-notify.ts @@ -0,0 +1,109 @@ +// Founder-facing notification for a payment (S9), split out of lib/billing.ts so the +// billing STATE MACHINE and the prose about it stay separate concerns. The split earned +// itself when O3 added the automatic-proposal outcome: billing.ts is a grandfathered +// over-limit file (scripts/file-length-baseline.txt), and email formatting is the part +// that had no business growing it. + +import { sendEmail, founderInbox } from "@/lib/email"; +import { craftEmail, detailRows } from "@/lib/email-templates"; +import { eur } from "@/lib/format"; +import { AUTO_PROPOSE_NOTES, type AutoProposeOutcome } from "@/lib/auto-provision-policy"; +import type { MolliePayment } from "@/lib/mollie"; + +/** One row for the payment email. `detailRows` escapes both columns itself. */ +function proposalLine(proposal: AutoProposeOutcome): string { + switch (proposal.kind) { + case "opened": + return `registry PR opened automatically — ${proposal.prUrl}`; + case "alreadyProposed": + return `already proposed — ${proposal.prUrl}`; + case "skipped": + return AUTO_PROPOSE_NOTES[proposal.reason]; + case "failed": + return `AUTOMATIC PROPOSAL FAILED — open it by hand at /admin/provision. Reason: ${proposal.detail}`; + } +} + +/** A failed auto-open is the one case where the footer has to ask for action. */ +function proposalFooter(proposal: AutoProposeOutcome | null): string { + if (proposal?.kind === "failed") { + return "The payment is fine — the automatic registry proposal is not. Open it by hand; nothing else is owed."; + } + if (proposal?.kind === "opened") { + return "Review and merge the registry PR to stand the tenant up. Merging provisions it."; + } + return "Mirrored into the control plane automatically."; +} + +export async function notifyFounder( + tenantSlug: string, + payment: MolliePayment, + amountCents: number, + proposal: AutoProposeOutcome | null, +) { + const interesting = + payment.status === "paid" || + payment.status === "failed" || + payment.status === "expired" || + payment.status === "canceled"; + if (!interesting) return; + const inbox = founderInbox(); + if (!inbox) return; + const ok = payment.status === "paid"; + await sendEmail({ + to: inbox, + subject: `[SofraPiwas billing] ${tenantSlug}: ${payment.sequenceType} payment ${payment.status} (${eur(amountCents)})`, + html: craftEmail({ + kicker: "Billing", + title: ok ? "Payment received" : `Payment ${payment.status}`, + // detailRows escapes both columns itself. + bodyHtml: detailRows([ + ["Tenant", tenantSlug], + ["Amount", eur(amountCents)], + ["Type", payment.sequenceType], + ["Status", payment.status], + ["Mollie id", payment.id], + // The automatic proposal's outcome rides the email the founder already opens + // for a payment, rather than a second message. It is the ONLY place a failed + // auto-open surfaces: the webhook must answer 2xx, so it cannot signal there. + ...(proposal ? [["Provisioning", proposalLine(proposal)] as [string, string]] : []), + ]), + footerNote: ok + ? proposalFooter(proposal) + : "Check the Mollie dashboard — a failed recurring charge may need dunning.", + }), + }); +} + +/** + * A failed automatic proposal gets its OWN message rather than a line in the payment + * email, because that email is sent after `activatePendingSubscriptions` — which + * deliberately throws during the mandate race to force a webhook 503. A silently expired + * PROVISION_GITHUB_TOKEN plus a lagging mandate would otherwise be reported nowhere. + * + * Never throws: `sendEmail` swallows a non-2xx into `{sent:false}`, but `fetch` itself + * REJECTS on a DNS/connect failure, and letting that escape would turn a reporting + * problem into a webhook 500 and a Mollie retry loop (the O2 lesson, one layer along). + */ +export async function reportFailedProposal(tenantSlug: string, detail: string): Promise { + try { + const inbox = founderInbox(); + if (!inbox) return; + await sendEmail({ + to: inbox, + subject: `[SofraPiwas] ${tenantSlug}: automatic registry proposal FAILED`, + html: craftEmail({ + kicker: "Provisioning", + title: "Automatic proposal failed", + bodyHtml: detailRows([ + ["Tenant", tenantSlug], + ["Reason", detail], + ]), + footerNote: + "The payment itself is fine. Open the registry PR by hand at /admin/provision — nothing else is owed.", + }), + }); + } catch (e) { + console.error("reportFailedProposal: could not notify", tenantSlug, e); + } +} diff --git a/lib/billing.ts b/lib/billing.ts index 71891b6..73c3fe1 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -14,9 +14,10 @@ import { db } from "@/lib/db"; import { audit } from "@/lib/audit"; -import { sendEmail, founderInbox, siteUrl } from "@/lib/email"; -import { craftEmail, detailRows } from "@/lib/email-templates"; -import { eur } from "@/lib/format"; +import { siteUrl } from "@/lib/email"; +import { autoProposeProvisioning } from "@/lib/auto-provision"; +import type { AutoProposeOutcome } from "@/lib/auto-provision-policy"; +import { notifyFounder } from "@/lib/billing-notify"; import { createCustomer, createFirstPayment, @@ -211,14 +212,25 @@ export async function recordPayment(payment: MolliePayment) { sequenceType: payment.sequenceType, }); + let proposal: AutoProposeOutcome | null = null; if (payment.sequenceType === "first" && payment.status === "paid") { + // O3: propose the registry entry BEFORE activation, deliberately. Activation can + // throw MandateNotReadyError (-> webhook 503 -> Mollie retry) and that window runs + // ~80s typically but up to ~26h in the worst case. The customer has paid; making + // their tenant wait on a mandate would be waiting on the wrong thing. The payment + // gate treats a settled first payment as sufficient, so this agrees with it. + // + // It cannot throw (see lib/auto-provision.ts rule 1) — a GitHub outage must not turn + // a successful payment into a retry loop. + proposal = await autoProposeProvisioning(billing.id); + // billing was located BY this customerId (guarded non-null above), so it is // the customer to activate against — pass it directly (the column is now // nullable for plans defined before their first payment). await activatePendingSubscriptions(billing.id, payment.customerId); } - await notifyFounder(billing.tenantSlug, payment, amountCents); + await notifyFounder(billing.tenantSlug, payment, amountCents, proposal); } /** Create the real Mollie subscription for every PENDING plan (idempotent). */ @@ -290,34 +302,3 @@ async function activatePendingSubscriptions(billingId: string, mollieCustomerId: } } -/** Founder notification on money events — paid, or anything gone wrong. */ -async function notifyFounder(tenantSlug: string, payment: MolliePayment, amountCents: number) { - const interesting = - payment.status === "paid" || - payment.status === "failed" || - payment.status === "expired" || - payment.status === "canceled"; - if (!interesting) return; - const inbox = founderInbox(); - if (!inbox) return; - const ok = payment.status === "paid"; - await sendEmail({ - to: inbox, - subject: `[SofraPiwas billing] ${tenantSlug}: ${payment.sequenceType} payment ${payment.status} (${eur(amountCents)})`, - html: craftEmail({ - kicker: "Billing", - title: ok ? "Payment received" : `Payment ${payment.status}`, - // detailRows escapes both columns itself. - bodyHtml: detailRows([ - ["Tenant", tenantSlug], - ["Amount", eur(amountCents)], - ["Type", payment.sequenceType], - ["Status", payment.status], - ["Mollie id", payment.id], - ]), - footerNote: ok - ? "Mirrored into the control plane automatically." - : "Check the Mollie dashboard — a failed recurring charge may need dunning.", - }), - }); -} diff --git a/lib/provisioning-facts.ts b/lib/provisioning-facts.ts new file mode 100644 index 0000000..efa9d4c --- /dev/null +++ b/lib/provisioning-facts.ts @@ -0,0 +1,35 @@ +// The database half of the O2 payment gate: read a slug's billing facts and run the +// pure policy (lib/provisioning-payment-gate.ts) over them. +// +// This lived inside `openProvisioningPrAction` while that action was its only caller, +// with a comment explaining that keeping it there left the policy unit-testable without +// a database. That reasoning still holds for the POLICY — which is why it stays in the +// pure module — but the query now has a second caller (the payment-triggered proposal, +// O3), and a second copy of "what counts as settled" is exactly the kind of drift that +// would let one path provision what the other refuses. +// +// It cannot live in the action file: that is `"use server"`, where every export must be +// an async server action. + +import { db } from "@/lib/db"; +import { provisionGate, type ProvisionGateVerdict } from "@/lib/provisioning-payment-gate"; + +/** + * Only `first` payments are fetched: a settled first payment is what the gate asks + * about, and the recurring history grows without bound. + */ +export async function slugProvisionVerdict(slug: string): Promise { + 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"), + }); +} diff --git a/lib/provisioning-registry.ts b/lib/provisioning-registry.ts index 02cc135..33764a7 100644 --- a/lib/provisioning-registry.ts +++ b/lib/provisioning-registry.ts @@ -76,46 +76,107 @@ const SHELL_QUOTED_APOSTROPHE = String.raw`'\''`; 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: what is being added, then the exact - * post-merge commands in order. It is a checklist rather than prose because the - * step that is easy to forget — building the per-tenant frontend image — is a - * hard prerequisite: `NEXT_PUBLIC_*` are baked per domain, so provisioning + * 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"; + + // `backend_tag` is pinned by box above, so the risk is NOT "a staging tenant might be + // on :latest" — that pairing cannot be generated. It is the reverse, and it is the one + // judgement no generator can make: every self-serve tenant lands on the staging box, so + // every self-serve tenant rides the DEVELOP build. Right for a showcase; a decision for + // someone paying. + const tagCheck = chained + ? `- [ ] **\`backend_tag: staging\`** is deliberate — a staging-box tenant rides the *develop* build, i.e. unreleased backend code. Correct for a showcase; for a paying customer, pin \`backend_tag: latest\` before merging` + : `- [ ] **\`backend_tag: latest\`** (prod box) — released code, which is what a prod tenant should ride`; + + 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:", + ]; + + 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** \`${input.modules.join(", ")}\``, - `- **box** \`${input.box ?? "staging"}\` · status starts at \`provisioning\``, + `- **box** \`${box}\` · status starts at \`provisioning\``, + "", + ...header, "", - "Review the entry before merging — this is the human checkpoint before any box provisioning.", + `- [ ] 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`, + `- [ ] **modules** \`${input.modules.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`, "", - "### After merging, in order", + ...after, "", - "1. **Registry sync** — automatic: merging to `develop` fires `sync-registry-to-staging.yml`. Check it went green; the box reads the registry, so nothing below works until it has.", - "2. **Build the tenant frontend image** — required *before* provisioning (`NEXT_PUBLIC_*` are baked per domain):", + `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.`, "", - " ```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(input.name)} \\`, - ` -f template=${input.template} \\`, - ` -f currency=${input.currency}`, - " ```", + chained ? "### If the chain fails" : "### Run these after merging", "", - "3. **Provision on the box**:", + "Both are idempotent and safe to re-run:", "", - " ```bash", - ` gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}`, - " ```", + "```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}`, "", - `4. **Verify** — \`./verify-env.sh https://${domain}\`, log in with the generated admin password from the tenant \`.env\` and change it, then flip this entry's \`status\` to \`active\` in a follow-up commit.`, + `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"); diff --git a/lib/provisioning.ts b/lib/provisioning.ts index a8dd53e..2ace22c 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -33,10 +33,18 @@ export function provisioningConfigured(): boolean { return Boolean(process.env.PROVISION_GITHUB_TOKEN); } +/** Per-call ceiling. Since O3 these calls sit in the Mollie webhook's critical path, + * ahead of subscription activation — and a HANG there (not an error, a hang) would stall + * activation on a dependency that has nothing to do with billing, until Mollie times the + * delivery out and redelivers on top of the one still in flight. `fetch` has no default + * timeout, so it needs an explicit one. */ +const GH_TIMEOUT_MS = 15_000; + async function gh(token: string, path: string, init?: RequestInit): Promise { const res = await fetch(`${API}${path}`, { // Never serve a cached registry/ref read — a stale sha would 409 the commit. cache: "no-store", + signal: AbortSignal.timeout(GH_TIMEOUT_MS), ...init, headers: { Authorization: `Bearer ${token}`, diff --git a/lib/self-serve-account.ts b/lib/self-serve-account.ts index e72c3b8..e3b82bc 100644 --- a/lib/self-serve-account.ts +++ b/lib/self-serve-account.ts @@ -71,6 +71,10 @@ export async function createSelfServeAccount(input: { restaurantName: string; slug: string; amountCents: number; + /** The lead this plan is minted from. Carries the configurator answers that the + * payment-triggered proposal reads (O3) — without it the only join back to them + * is `desiredSlug`, which several leads can share. */ + signupRequestId: string; }): Promise { try { const minted = await db.$transaction(async (tx) => { @@ -103,6 +107,7 @@ export async function createSelfServeAccount(input: { // Owner flow: the payer IS the user, and there is no reseller Client // (the clientId XOR payerUserId shape `defineTenantPlan` asserts). payerUserId: user.id, + signupRequestId: input.signupRequestId, }, }); diff --git a/lib/tenant-health.ts b/lib/tenant-health.ts new file mode 100644 index 0000000..b42817f --- /dev/null +++ b/lib/tenant-health.ts @@ -0,0 +1,64 @@ +// Does a tenant's app actually answer? The single piece of EVIDENCE behind the +// owner dashboard's "your app is ready" claim (SOFRA-ONBOARDING-PLAN O4). +// +// Split from `tenant-liveness.ts` because this half touches the network: the pure +// classifier belongs in the coverage floor, this does not (CLAUDE.md §7 — modules with +// network branches stay out of scope rather than acquire mocks). + +import { unstable_cache } from "next/cache"; +import { tenantOrigin } from "@/lib/tenant-liveness"; + +/** + * The `/api/health` payload's own name for itself (backend Program.cs). Asserted on + * because **a bare 200 proves nothing here** — the same lesson O5 learned from a bare + * 404. A wildcard-caught subdomain, a Caddy default page, a parked domain and the + * marketing site all answer 200 to a host that has no tenant behind it, and any of + * them would promote a tenant that does not exist yet to "ready". + */ +const HEALTH_SERVICE_ID = "restaurant-system-api"; + +/** How long a tenant's health answer is reused. Provisioning takes ~15 min, so an + * owner refreshing twice in a minute does not need two round-trips to a box. */ +const HEALTH_TTL_SECONDS = 60; + +/** Budget for the probe — a dashboard render must not hang on an unreachable box. */ +const HEALTH_TIMEOUT_MS = 3000; + +async function fetchHealth(domain: string): Promise { + const origin = tenantOrigin(domain); + if (!origin) return false; + try { + const res = await fetch(`${origin}/api/health`, { + signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS), + redirect: "error", + cache: "no-store", + }); + if (!res.ok) return false; + const body: unknown = await res.json(); + return ( + typeof body === "object" && + body !== null && + (body as { service?: unknown }).service === HEALTH_SERVICE_ID + ); + } catch { + // FAILS CLOSED — the opposite of the tenant frontend's getTenantModules, and the + // difference is worth stating because the two look alike. There, a network blip + // must not take features away from a working app, so unknown means "everything". + // Here, unknown means "we have not seen it serve", and the only thing riding on it + // is a claim we make to a paying customer plus a link we tell them to click. Never + // promote uncertainty to ready. + return false; + } +} + +/** + * Has this tenant's app answered? Cached per domain for {@link HEALTH_TTL_SECONDS}. + * + * `unstable_cache` rather than `fetch`'s own `next.revalidate` because the request + * carries an `AbortSignal`, which opts a fetch out of Next's data cache entirely — the + * timeout and the cache cannot both live on the same call. + */ +export const probeTenantHealthy = unstable_cache(fetchHealth, ["tenant-health"], { + revalidate: HEALTH_TTL_SECONDS, + tags: ["tenant-health"], +}); diff --git a/lib/tenant-liveness.ts b/lib/tenant-liveness.ts new file mode 100644 index 0000000..fca9f8a --- /dev/null +++ b/lib/tenant-liveness.ts @@ -0,0 +1,118 @@ +// "Where is my restaurant app?" — the owner-facing stage of a paid tenant +// (SOFRA-ONBOARDING-PLAN O4, inheriting O3's credential handover). +// +// O3 built the mechanism that means no password ever leaves the box: the tenant +// frontend now has /forgot-password + /reset-password, so the owner sets their own +// admin password. What it did not build is the sentence that tells them so. This +// module answers the one question that sentence depends on — *is their app actually +// up yet?* — and the answer has to be EARNED, because the panel it drives sends the +// owner to a URL. Telling someone "your app is ready" and landing them on a +// connection error is worse than telling them nothing. +// +// There is no clean "is the tenant live" signal in this system today, and this file +// is where that was decided rather than assumed: +// +// - `TenantBilling.liveSince` is admin-entered and display-only. It is a date the +// founder typed, not an observation, and on the self-serve path nobody types it. +// - the registry's `status: active` is a manual follow-up commit that nothing +// automatic writes and nothing automatic reads. `provision-on-registry-merge.yml` +// does not flip it. +// - `provisioningPrUrl` proves a proposal was OPENED, not merged, built or booted. +// - a registry ENTRY proves the founder merged it — which under the O3 merge chain +// does start the build — but not that the build and provision succeeded. +// +// So the only evidence that the app is serving is asking it. `probeTenantHealthy` +// (lib/tenant-health.ts) does, and everything below it degrades to a weaker, honest +// claim. +// +// This file is PURE — no network, no DB — so the precedence above is unit-testable and +// sits inside the coverage floor, the same split `billing-display.ts` keeps from +// `billing.ts`. The probe lives next door. + +export type TenantStage = + /** Not paid yet — the pay button / activating panel owns this moment, say nothing. */ + | "none" + /** Paid, nothing proposed yet. */ + | "preparing" + /** A registry proposal is open, awaiting the founder's merge. */ + | "settingUp" + /** In the registry, but the app has not answered. NEVER claim ready from here. */ + | "almostReady" + /** Observed serving. The only stage that hands out a link. */ + | "ready"; + +export interface TenantStageFacts { + /** A `first` payment has settled. */ + readonly paid: boolean; + /** `TenantBilling.provisioningPrUrl` — the proposal was opened. */ + readonly provisioningPrUrl: string | null; + /** The tenant's registry `domain`, or null when it has no entry *or the registry + * could not be read at all*. Both collapse to "no evidence", deliberately. */ + readonly registryDomain: string | null; + /** `probeTenantHealthy` said yes. */ + readonly healthy: boolean; +} + +/** + * Which claim the evidence supports. First match wins, strongest evidence first. + * + * Every fall-through is a WEAKER claim, never a stronger one: an unreadable registry, + * a timed-out probe and a tenant that genuinely is not built yet all land somewhere + * that promises nothing. Under-claiming shows a live owner "almost ready" for up to a + * minute; over-claiming sends a customer to a dead link and makes the product look + * broken on the first thing they were ever told to do. + * + * `healthy` is guarded by `registryDomain` even though a caller can only obtain one by + * probing the other — so the function is total, and a future caller that keeps a stale + * `healthy` around cannot resurrect "ready" for a tenant with no entry. + */ +export function tenantStage(facts: TenantStageFacts): TenantStage { + if (!facts.paid) return "none"; + if (facts.registryDomain && facts.healthy) return "ready"; + if (facts.registryDomain) return "almostReady"; + if (facts.provisioningPrUrl) return "settingUp"; + return "preparing"; +} + +/** + * Which stage to actually SHOW, given what the plan status is already saying. + * + * In the mandate-lag window (`planState` "processing") `` is on + * screen spelling out the whole wait — paid, bank confirming, then your app is + * prepared. "We are preparing your app" underneath it is the same sentence twice, and + * the argument the old `liveSinceLine` made still holds: a line that restates the line + * above it is worse than no line. + * + * `almostReady` and `ready` survive that window, because they are NEW information — + * the app exists now, which `ActivatingPanel` cannot know and does not claim. + */ +export function visibleTenantStage(stage: TenantStage, planIsProcessing: boolean): TenantStage { + if (!planIsProcessing) return stage; + return stage === "preparing" || stage === "settingUp" ? "none" : stage; +} + +/** + * `https://` for a registry domain, or null if the value is not a bare host. + * + * The registry is founder-reviewed YAML, so this is not the security boundary — but + * the domain ORIGINATES as a customer-typed `desiredSlug`, and the panel turns it into + * a link the owner is told to click and a request this server makes. Rejecting + * anything carrying a scheme, credentials, port, path or query keeps a malformed entry + * from aiming either one somewhere unintended. + * + * The final label must be alphabetic, which is what rules out an IP literal — + * `127.0.0.1` is otherwise a well-formed sequence of dot-separated alphanumeric labels + * and would sail through, pointing a server-side fetch at the loopback interface of + * the container. Single-label names (`localhost`, `backend`) are refused by the same + * rule needing at least one dot. + */ +export function tenantOrigin(domain: string): string | null { + if (!/^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(domain)) return null; + return `https://${domain}`; +} + +/** Where the owner sets their own admin password — the O3 page, on their own app. */ +export function tenantForgotPasswordUrl(domain: string): string | null { + const origin = tenantOrigin(domain); + return origin ? `${origin}/forgot-password` : null; +} diff --git a/lib/validation.ts b/lib/validation.ts index 5839ba7..1cef40a 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -9,6 +9,22 @@ export const splitCsvLower = (raw: string): string[] => .map((s) => s.trim().toLowerCase()) .filter(Boolean); +/** No line breaks or control characters. + * + * A tenant's display name reaches three formats where a newline changes meaning: the + * registry YAML (safe on its own — `yaml.stringify` quotes it), the provisioning PR body + * (a newline breaks the fence around the fallback shell command), and — through the + * registry — `build-tenant-image.yml`'s `build-args:`, which is a NEWLINE-DELIMITED list, + * so a second line there injects a build arg into the tenant's own bundle. + * + * It lived only on `provisionSchema` while the founder form was the only way in. O3's + * payment-triggered proposal reaches `openProvisioningPr` from the PUBLIC intake without + * passing through that form, so the guard has to be at the intake edge too or the + * unattended path is the one place nothing checks. `trim()` is not enough — it strips + * only the ends. */ +const noControlChars = >(schema: T) => + schema.refine((v) => !/[\u0000-\u001f\u007f]/.test(v), "no line breaks or control characters"); + export const applySchema = z.object({ name: z.string().trim().min(1).max(200), email: z.string().trim().max(200).email(), @@ -23,7 +39,8 @@ export const applySchema = z.object({ // given, must match the registry grammar (same as billing/onboard) so we don't // capture garbage the founder then has to clean up. export const signupSchema = z.object({ - restaurantName: z.string().trim().min(1).max(200), + // Guarded because this becomes the registry `name:` for a self-serve tenant (O3). + restaurantName: noControlChars(z.string().trim().min(1).max(200)), contactName: z.string().trim().min(1).max(200), email: z.string().trim().max(200).email(), phone: z.string().trim().max(50).optional().or(z.literal("")), @@ -137,7 +154,7 @@ export const provisionSchema = z.object({ .string() .trim() .regex(/^[a-z0-9][a-z0-9-]{1,30}$/, "lowercase slug, 2-31 chars"), - name: z.string().trim().min(1).max(200), + name: noControlChars(z.string().trim().min(1).max(200)), adminEmail: z.string().trim().max(200).email(), template: z.enum(["classic", "craft"]), currency: z.string().trim().regex(/^[A-Z]{3}$/, "3-letter ISO code, e.g. EUR"), diff --git a/messages/ar.json b/messages/ar.json index 26c8416..3985f40 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -494,7 +494,6 @@ "ownerIntro": "خطة SofraPiwas والفوترة الخاصة بك في مكان واحد.", "addClient": "إضافة عميل", "empty": "لا عملاء بعد — أضف أول مطعم أعلاه.", - "ownerAllSet": "اشتراكك نشط — لا شيء لتفعله هنا الآن.", "updated": "آخر تحديث {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "جارٍ إعداد {restaurant}", "stepPaid": "تم استلام دفعتك الأولى — شكرًا لك.", "stepMandate": "يقوم مصرفك بتأكيد الاشتراك الشهري. يستغرق ذلك دقيقة عادةً، وأحيانًا أطول.", - "stepProvision": "بعد ذلك يُجهَّز تطبيق مطعمك ونرسل إليك بيانات الدخول.", + "stepProvision": "بعد ذلك يُجهَّز تطبيق مطعمك، وتوضّح لك هذه الصفحة كيفية تعيين كلمة مرور المدير بنفسك.", "noSecondPayment": "لا شيء آخر عليك فعله، ولا مبلغ إضافي للدفع — تُحدَّث هذه الصفحة تلقائيًا." }, - "notLiveYet": "لم يتم إعداد {restaurant} بعد — ابدأ اشتراكك وسنُجهّز تطبيقك." + "sequence": { + "first": "الدفعة الأولى", + "recurring": "رسوم الاشتراك", + "oneoff": "دفعة لمرة واحدة" + }, + "paymentStatus": { + "paid": "مدفوعة", + "pending": "قيد المعالجة", + "failed": "لم تكتمل", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "تطبيقك جاهز", + "readyTitle": "تطبيق مطعمك يعمل الآن", + "readyBody": "يعمل على {domain}. عيّن كلمة مرور المدير لتسجيل الدخول لأول مرة.", + "setPassword": "عيّن كلمة مرور المدير", + "openApp": "فتح {domain}", + "setPasswordWhy": "لا نرى كلمة مرورك أبدًا ولا نرسلها إليك بالبريد الإلكتروني — أنت من يعيّنها بنفسك، من داخل تطبيقك.", + "waitingKicker": "قيد الإعداد", + "preparingTitle": "نُجهِّز تطبيقك", + "preparingBody": "تم استلام دفعتك. نُجهِّز التطبيق الخاص بمطعمك — وستُعلمك هذه الصفحة فور أن يصبح جاهزًا.", + "settingUpTitle": "جارٍ بناء تطبيقك", + "settingUpBody": "إعداداتك في انتظار مراجعة أخيرة، ثم يُبنى تطبيقك ويُشغَّل. يستغرق ذلك عادةً خمس عشرة دقيقة تقريبًا.", + "almostReadyTitle": "أوشكنا على الانتهاء", + "almostReadyBody": "جارٍ تشغيل تطبيقك. وفور أن يستجيب، ستوضّح لك هذه الصفحة كيفية تسجيل الدخول." } }, "signup": { diff --git a/messages/de.json b/messages/de.json index 396fee7..cddd740 100644 --- a/messages/de.json +++ b/messages/de.json @@ -494,7 +494,6 @@ "ownerIntro": "Ihr SofraPiwas-Tarif und Ihre Abrechnung an einem Ort.", "addClient": "Kunden hinzufügen", "empty": "Noch keine Kunden — fügen Sie oben das erste Restaurant hinzu.", - "ownerAllSet": "Ihr Abonnement ist aktiv — hier gibt es gerade nichts zu tun.", "updated": "aktualisiert am {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} wird eingerichtet", "stepPaid": "Ihre erste Zahlung ist eingegangen — vielen Dank.", "stepMandate": "Ihre Bank bestätigt das monatliche Abonnement. Das dauert meist eine Minute, gelegentlich länger.", - "stepProvision": "Danach wird die App Ihres Restaurants vorbereitet und wir senden Ihre Zugangsdaten.", + "stepProvision": "Danach wird die App Ihres Restaurants vorbereitet, und diese Seite zeigt Ihnen, wie Sie Ihr Administrator-Passwort selbst festlegen.", "noSecondPayment": "Sie müssen nichts weiter tun und nichts weiter zahlen — diese Seite aktualisiert sich selbst." }, - "notLiveYet": "{restaurant} ist noch nicht eingerichtet — starten Sie Ihr Abonnement und wir bereiten Ihre App vor." + "sequence": { + "first": "Erste Zahlung", + "recurring": "Abo-Abbuchung", + "oneoff": "Einmalzahlung" + }, + "paymentStatus": { + "paid": "bezahlt", + "pending": "ausstehend", + "failed": "nicht abgeschlossen", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Ihre App ist bereit", + "readyTitle": "Die App Ihres Restaurants ist live", + "readyBody": "Sie läuft unter {domain}. Legen Sie Ihr Administrator-Passwort fest, um sich zum ersten Mal anzumelden.", + "setPassword": "Administrator-Passwort festlegen", + "openApp": "{domain} öffnen", + "setPasswordWhy": "Wir sehen Ihr Passwort nie und senden es Ihnen nie per E-Mail — Sie legen es selbst fest, in Ihrer eigenen App.", + "waitingKicker": "Wird eingerichtet", + "preparingTitle": "Wir bereiten Ihre App vor", + "preparingBody": "Ihre Zahlung ist eingegangen. Wir bereiten die eigene App Ihres Restaurants vor — diese Seite meldet sich, sobald sie live ist.", + "settingUpTitle": "Ihre App wird erstellt", + "settingUpBody": "Ihre Konfiguration wartet auf eine letzte Prüfung, danach wird Ihre App erstellt und gestartet. Das dauert in der Regel etwa fünfzehn Minuten.", + "almostReadyTitle": "Fast geschafft", + "almostReadyBody": "Ihre App wird gestartet. Sobald sie antwortet, zeigt Ihnen diese Seite, wie Sie sich anmelden." } }, "signup": { diff --git a/messages/en.json b/messages/en.json index 265230c..fb07a9a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -494,7 +494,6 @@ "ownerIntro": "Your SofraPiwas plan and billing, in one place.", "addClient": "Add a client", "empty": "No clients yet — add the first restaurant above.", - "ownerAllSet": "Your subscription is active — nothing to do here right now.", "updated": "updated {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} is being set up", "stepPaid": "Your first payment went through — thank you.", "stepMandate": "Your bank is confirming the monthly subscription. This usually takes a minute, occasionally longer.", - "stepProvision": "Then your restaurant's app is prepared and we send your login details.", + "stepProvision": "Then your restaurant's app is prepared, and this page shows you how to set your own admin password.", "noSecondPayment": "Nothing more to do, and nothing more to pay — this page updates itself." }, - "notLiveYet": "{restaurant} isn't set up yet — start your subscription and we'll prepare your app." + "sequence": { + "first": "first payment", + "recurring": "subscription charge", + "oneoff": "one-off payment" + }, + "paymentStatus": { + "paid": "paid", + "pending": "pending", + "failed": "not completed", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Your app is ready", + "readyTitle": "Your restaurant app is live", + "readyBody": "It is running at {domain}. Set your admin password to sign in for the first time.", + "setPassword": "Set your admin password", + "openApp": "Open {domain}", + "setPasswordWhy": "We never see your password and we never email you one — you set it yourself, on your own app.", + "waitingKicker": "Setting up", + "preparingTitle": "We are preparing your app", + "preparingBody": "Your payment is in. We are getting your restaurant's own app ready — this page will tell you the moment it is live.", + "settingUpTitle": "Your app is being built", + "settingUpBody": "Your setup is queued for a final check, then your app is built and started. This usually takes about fifteen minutes.", + "almostReadyTitle": "Almost there", + "almostReadyBody": "Your app is starting up. As soon as it answers, this page will show you how to sign in." } }, "signup": { diff --git a/messages/fr.json b/messages/fr.json index 79a3926..156c68a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -494,7 +494,6 @@ "ownerIntro": "Votre offre SofraPiwas et votre facturation, au même endroit.", "addClient": "Ajouter un client", "empty": "Pas encore de clients — ajoutez le premier restaurant ci-dessus.", - "ownerAllSet": "Votre abonnement est actif — rien à faire ici pour le moment.", "updated": "mis à jour le {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} est en cours d'installation", "stepPaid": "Votre premier paiement a été accepté — merci.", "stepMandate": "Votre banque confirme l'abonnement mensuel. Cela prend généralement une minute, parfois un peu plus.", - "stepProvision": "Ensuite, l'application de votre restaurant est préparée et nous vous envoyons vos identifiants.", + "stepProvision": "Ensuite, l'application de votre restaurant est préparée, et cette page vous indique comment définir vous-même votre mot de passe administrateur.", "noSecondPayment": "Rien d'autre à faire, et rien de plus à payer — cette page se met à jour d'elle-même." }, - "notLiveYet": "{restaurant} n'est pas encore installé — démarrez votre abonnement et nous préparerons votre application." + "sequence": { + "first": "premier paiement", + "recurring": "prélèvement d'abonnement", + "oneoff": "paiement unique" + }, + "paymentStatus": { + "paid": "payé", + "pending": "en attente", + "failed": "non abouti", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Votre application est prête", + "readyTitle": "L'application de votre restaurant est en ligne", + "readyBody": "Elle fonctionne sur {domain}. Définissez votre mot de passe administrateur pour vous connecter la première fois.", + "setPassword": "Définir votre mot de passe administrateur", + "openApp": "Ouvrir {domain}", + "setPasswordWhy": "Nous ne voyons jamais votre mot de passe et ne vous en envoyons jamais par e-mail — vous le définissez vous-même, depuis votre propre application.", + "waitingKicker": "Installation en cours", + "preparingTitle": "Nous préparons votre application", + "preparingBody": "Votre paiement est bien arrivé. Nous préparons l'application de votre restaurant — cette page vous préviendra dès qu'elle sera en ligne.", + "settingUpTitle": "Votre application est en cours de création", + "settingUpBody": "Votre configuration attend une dernière vérification, puis votre application est créée et démarrée. Cela prend généralement une quinzaine de minutes.", + "almostReadyTitle": "Presque terminé", + "almostReadyBody": "Votre application démarre. Dès qu'elle répond, cette page vous indiquera comment vous connecter." } }, "signup": { diff --git a/messages/nl.json b/messages/nl.json index 50907a2..952db24 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -494,7 +494,6 @@ "ownerIntro": "Je SofraPiwas-abonnement en facturering op één plek.", "addClient": "Klant toevoegen", "empty": "Nog geen klanten — voeg hierboven het eerste restaurant toe.", - "ownerAllSet": "Je abonnement is actief — hier is nu niets te doen.", "updated": "bijgewerkt {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} wordt ingericht", "stepPaid": "Je eerste betaling is gelukt — bedankt.", "stepMandate": "Je bank bevestigt het maandabonnement. Dit duurt meestal een minuut, soms langer.", - "stepProvision": "Daarna wordt de app van je restaurant klaargezet en sturen we je inloggegevens.", + "stepProvision": "Daarna wordt de app van je restaurant klaargezet en laat deze pagina zien hoe je zelf je beheerderswachtwoord instelt.", "noSecondPayment": "Je hoeft niets meer te doen en niets extra te betalen — deze pagina werkt zichzelf bij." }, - "notLiveYet": "{restaurant} is nog niet ingericht — start je abonnement en we zetten je app klaar." + "sequence": { + "first": "eerste betaling", + "recurring": "abonnementskosten", + "oneoff": "eenmalige betaling" + }, + "paymentStatus": { + "paid": "betaald", + "pending": "in behandeling", + "failed": "niet voltooid", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Je app is klaar", + "readyTitle": "De app van je restaurant is live", + "readyBody": "Hij draait op {domain}. Stel je beheerderswachtwoord in om voor het eerst in te loggen.", + "setPassword": "Stel je beheerderswachtwoord in", + "openApp": "Open {domain}", + "setPasswordWhy": "Wij zien je wachtwoord nooit en mailen het je nooit — je stelt het zelf in, in je eigen app.", + "waitingKicker": "Wordt ingericht", + "preparingTitle": "We maken je app klaar", + "preparingBody": "Je betaling is binnen. We maken de eigen app van je restaurant gereed — deze pagina laat het weten zodra hij live is.", + "settingUpTitle": "Je app wordt gebouwd", + "settingUpBody": "Je configuratie staat klaar voor een laatste controle; daarna wordt je app gebouwd en gestart. Dit duurt meestal zo'n vijftien minuten.", + "almostReadyTitle": "Bijna klaar", + "almostReadyBody": "Je app wordt opgestart. Zodra hij antwoordt, laat deze pagina zien hoe je inlogt." } }, "signup": { diff --git a/messages/tr.json b/messages/tr.json index 8439e6a..e2999bb 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -494,7 +494,6 @@ "ownerIntro": "SofraPiwas planınız ve faturalandırmanız tek bir yerde.", "addClient": "Müşteri ekle", "empty": "Henüz müşteri yok — ilk restoranı yukarıdan ekleyin.", - "ownerAllSet": "Aboneliğiniz etkin — şu anda burada yapılacak bir şey yok.", "updated": "güncelleme: {date}" }, "client": { @@ -837,10 +836,35 @@ "title": "{restaurant} kuruluyor", "stepPaid": "İlk ödemeniz alındı — teşekkürler.", "stepMandate": "Bankanız aylık aboneliği onaylıyor. Bu genellikle bir dakika, bazen daha uzun sürer.", - "stepProvision": "Ardından restoranınızın uygulaması hazırlanır ve giriş bilgilerinizi göndeririz.", + "stepProvision": "Ardından restoranınızın uygulaması hazırlanır ve bu sayfa yönetici parolanızı nasıl kendiniz belirleyeceğinizi gösterir.", "noSecondPayment": "Yapacak başka bir şey yok, ödenecek başka bir tutar da yok — bu sayfa kendini günceller." }, - "notLiveYet": "{restaurant} henüz kurulmadı — aboneliğinizi başlatın, uygulamanızı hazırlayalım." + "sequence": { + "first": "ilk ödeme", + "recurring": "abonelik tahsilatı", + "oneoff": "tek seferlik ödeme" + }, + "paymentStatus": { + "paid": "ödendi", + "pending": "beklemede", + "failed": "tamamlanmadı", + "other": "{status}" + } + }, + "tenantReady": { + "readyKicker": "Uygulamanız hazır", + "readyTitle": "Restoranınızın uygulaması yayında", + "readyBody": "{domain} adresinde çalışıyor. İlk kez giriş yapmak için yönetici parolanızı belirleyin.", + "setPassword": "Yönetici parolanızı belirleyin", + "openApp": "{domain} adresini aç", + "setPasswordWhy": "Parolanızı hiçbir zaman görmeyiz ve size e-postayla göndermeyiz — onu kendi uygulamanızda kendiniz belirlersiniz.", + "waitingKicker": "Kuruluyor", + "preparingTitle": "Uygulamanızı hazırlıyoruz", + "preparingBody": "Ödemeniz alındı. Restoranınıza ait uygulamayı hazırlıyoruz — yayına girdiği anda bu sayfa size haber verecek.", + "settingUpTitle": "Uygulamanız oluşturuluyor", + "settingUpBody": "Yapılandırmanız son bir kontrol için sırada; ardından uygulamanız oluşturulup başlatılır. Bu genellikle on beş dakika kadar sürer.", + "almostReadyTitle": "Neredeyse hazır", + "almostReadyBody": "Uygulamanız başlatılıyor. Yanıt verdiği anda bu sayfa nasıl giriş yapacağınızı gösterecek." } }, "signup": { diff --git a/prisma/migrations/20260730060000_billing_signup_link/migration.sql b/prisma/migrations/20260730060000_billing_signup_link/migration.sql new file mode 100644 index 0000000..0953f67 --- /dev/null +++ b/prisma/migrations/20260730060000_billing_signup_link/migration.sql @@ -0,0 +1,51 @@ +-- Payment-triggered provisioning (workspace docs/plans/SOFRA-ONBOARDING-PLAN.md, O3). +-- +-- O3's remaining half is: when a self-serve tenant's first payment settles, open the +-- registry PR automatically instead of waiting for the founder to click +-- /admin/provision. That needs the configurator answers (modules/template/currency/ +-- languages, on SignupRequest since O1) reachable FROM the billing row — and until now +-- they were not, in either direction. +-- +-- The plan assumed they were. What actually existed was a SOFT join, +-- SignupRequest.desiredSlug = TenantBilling.tenantSlug, and it is not safe to provision +-- from: leads accumulate (every leadOnly outcome writes one), so two SignupRequest rows +-- can carry the same desiredSlug while only one of them minted the account. Matching on +-- it could hand a paying customer another lead's MODULE LIST. Today a human supplies the +-- id explicitly (/admin/provision?from=); an unattended path has no human. +-- +-- signupRequestId is therefore the durable link, written at intake by +-- createSelfServeAccount. NULL for every founder-created plan (/admin/onboard, the +-- reseller flow, and RUMI, which predates all of this), which is exactly the same +-- signal the payment gate already keys on: no lead ⇒ not self-serve ⇒ not our business +-- to automate. +-- +-- Deliberately NOT unique, and the honest reason is the second one below, not the first: +-- * "tenantSlug is already @unique" does NOT cover this. That prevents two plans per +-- SLUG; a unique signupRequestId would prevent two plans per LEAD, which is a +-- different claim. +-- * What actually decides it: the state is unreachable (the id comes from a row created +-- in the same request), and adding a unique constraint on the signup path would add a +-- fresh P2002 surface inside the money-adjacent transaction — where the existing catch +-- is narrowed on `tenantSlug` (O2 fix #5), so a different violation would fall through +-- as an unexplained 500 mid-signup. A constraint whose only effect is a worse failure +-- mode for an impossible state is not worth having. +-- +-- ON DELETE SET NULL: nothing prunes SignupRequest today (retention does not cover it), +-- but a dangling FK would be a worse way to find that out than a null. +-- +-- provisioningPrUrl records the proposal that was opened, and doubles as the auto-open's +-- idempotency record: Mollie redelivers webhooks, so "have I already proposed this +-- tenant?" has to be answerable from our own rows and not only from GitHub refusing a +-- duplicate branch. +-- +-- Both columns additive and nullable ⇒ safe on existing rows. + +ALTER TABLE "TenantBilling" ADD COLUMN "signupRequestId" TEXT; +ALTER TABLE "TenantBilling" ADD COLUMN "provisioningPrUrl" TEXT; + +CREATE INDEX "TenantBilling_signupRequestId_idx" ON "TenantBilling"("signupRequestId"); + +ALTER TABLE "TenantBilling" + ADD CONSTRAINT "TenantBilling_signupRequestId_fkey" + FOREIGN KEY ("signupRequestId") REFERENCES "SignupRequest"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d05e37d..d030c55 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,15 +56,15 @@ model User { status UserStatus @default(INVITED) createdAt DateTime @default(now()) - profile PartnerProfile? - clients Client[] - notes ClientNote[] - commissions CommissionEntry[] @relation("PartnerCommissions") + profile PartnerProfile? + clients Client[] + notes ClientNote[] + commissions CommissionEntry[] @relation("PartnerCommissions") createdEntries CommissionEntry[] @relation("EntryAuthor") - inviteTokens InviteToken[] - auditLogs AuditLog[] + inviteTokens InviteToken[] + auditLogs AuditLog[] // Tenants this user pays for directly (OWNER self-serve; ADR-004). - billingsPaid TenantBilling[] @relation("BillingPayer") + billingsPaid TenantBilling[] @relation("BillingPayer") } model PartnerApplication { @@ -107,13 +107,17 @@ model SignupRequest { // queried by element, and every consumer on the path speaks CSV. // All nullable — leads captured before the configurator shipped have none, and a // null here means "the founder still chooses", exactly as before. - modules String? - languages String? - template String? - currency String? + modules String? + languages String? + template String? + currency String? /// Monthly total in EUR integer cents as quoted at signup. A record of what they /// were shown, NOT a price that binds — always re-quote at onboarding. - quotedCents Int? + quotedCents Int? + + // Back-reference for the O3 link. A list because Prisma requires it on the + // non-owning side; in practice at most one plan is minted per lead. + billing TenantBilling[] @@index([status, createdAt]) } @@ -198,33 +202,48 @@ enum SubscriptionStatus { } model TenantBilling { - id String @id @default(cuid()) + id String @id @default(cuid()) // Registry slug (deploy repo tenants/registry.yml) — the billing anchor; // not a FK on purpose (the registry graduates to a table only at >3 // tenants, ADR-007). - tenantSlug String @unique + tenantSlug String @unique name String email String // Null until the payer starts the first payment: an admin can define a // PENDING plan (partner onboarding) before any Mollie customer exists. The // @unique still holds — Postgres allows multiple NULLs. - mollieCustomerId String? @unique - clientId String? @unique - client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull) + mollieCustomerId String? @unique + clientId String? @unique + client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull) // Explicit payer for the direct-owner flow (ADR-004): set when there is no // reseller Client. The reseller flow leaves this null and derives the payer // from client.partner. Exactly one of clientId / payerUserId is set in practice. payerUserId String? - payer User? @relation("BillingPayer", fields: [payerUserId], references: [id], onDelete: SetNull) + payer User? @relation("BillingPayer", fields: [payerUserId], references: [id], onDelete: SetNull) // Display-only: when the tenant's app went live (admin-entered at // onboarding), shown on the partner's welcome panel. liveSince DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) + + // The lead this plan was minted from (O3). Set only on the SELF-SERVE path, so + // null means founder-created — the same signal the payment gate already keys on. + // It exists because the configurator answers live on SignupRequest and the + // payment-triggered proposal needs them; the only alternative join + // (desiredSlug = tenantSlug) is soft and can select another lead's module list. + // Not unique on purpose — the state is unreachable and the constraint would only add + // a P2002 surface to the signup transaction. See the migration for the full reasoning. + signupRequestId String? + signupRequest SignupRequest? @relation(fields: [signupRequestId], references: [id], onDelete: SetNull) + // The registry proposal opened for this tenant. Also the auto-open's idempotency + // record: Mollie redelivers, so "already proposed?" must be answerable from our + // own rows, not only from GitHub refusing a duplicate branch. + provisioningPrUrl String? subscriptions BillingSubscription[] payments BillingPayment[] @@index([payerUserId]) + @@index([signupRequestId]) } model BillingSubscription { diff --git a/scripts/file-length-baseline.txt b/scripts/file-length-baseline.txt index ca8261c..c1d85b8 100644 --- a/scripts/file-length-baseline.txt +++ b/scripts/file-length-baseline.txt @@ -3,6 +3,9 @@ # Remove a line once its file is refactored under the limit. # # lib/billing.ts — Mollie subscription state machine (PENDING→ACTIVATING→ACTIVE, -# atomic claim, idempotency, mandate-race 503). 285 LOC; live-billing code, -# splitting it is its own risk-managed PR, not this test-infra one. +# atomic claim, idempotency, mandate-race 503). ~304 LOC; live-billing code, +# splitting the state machine is its own risk-managed PR. +# O3 (2026-07-30) added the payment-triggered proposal here and took the founder +# EMAIL out (-> lib/billing-notify.ts) so the growth stayed in the machine rather +# than in prose about it. Keep new concerns out of this file. lib/billing.ts diff --git a/tests/e2e/billing-mollie.spec.ts b/tests/e2e/billing-mollie.spec.ts index 339f926..aca5f64 100644 --- a/tests/e2e/billing-mollie.spec.ts +++ b/tests/e2e/billing-mollie.spec.ts @@ -181,8 +181,15 @@ test.describe("Mollie first payment and activation", () => { // against this same page while claiming to check a billing view the owner // cannot reach, on a `/active/i` match loose enough to hit "Not active." // and "Activating" too. + // + // Since O4 the owner sees the plan itself rather than the one-line "your + // subscription is active — nothing to do here right now" this used to assert. + // "Active — next charge on " is the stronger check anyway: it can only + // render from an ACTIVE subscription that carries a real startDate, which is + // exactly what a completed Mollie activation writes. await page.goto("/dashboard"); - await expect(page.getByText(/your subscription is active/i)).toBeVisible(); + await expect(page.getByText(/active — next charge on/i)).toBeVisible(); + await expect(page.getByText(/nothing to do here right now/i)).toHaveCount(0); await expect(page.getByRole("button", { name: /start auto-monthly payment/i })).toHaveCount(0); // The mandate-lag panel must be gone now that it really is active. await expect(page.getByText(/is being set up/i)).toHaveCount(0); diff --git a/tests/e2e/helpers/db.ts b/tests/e2e/helpers/db.ts index 5292d41..c1e3619 100644 --- a/tests/e2e/helpers/db.ts +++ b/tests/e2e/helpers/db.ts @@ -220,3 +220,95 @@ export async function findFirstPayment( ? { molliePaymentId: r.mollie_payment_id, status: r.status, checkoutUrl: r.checkout_url } : null; } + +/** + * Put a plan into the steady state a paying customer lives in for years: an ACTIVE + * subscription that has already taken its first recurring charge. + * + * Like `arrangeMandateLag`, this ARRANGES a state rather than faking a behaviour — and + * getting that right matters more here than it looks. `startDate` is deliberately set + * in the PAST, because that is the only thing production can produce once a recurring + * charge exists: `subscriptionStartDate` writes it as activation + one interval and + * nothing ever advances it, so a plan with a `recurring` payment necessarily has a + * `startDate` behind it. An arrangement with a FUTURE `startDate` plus a recurring + * charge is a state no code path can reach, and a "next charge on " assertion + * against it proves only that the helper's own date round-trips to the DOM — the whole + * point is that the app must DERIVE the next charge rather than print that column. + */ +export async function arrangeActivePlan( + tenantSlug: string, + opts: { firstChargeIso: string }, +): Promise { + const updated = await query<{ id: string }>( + `UPDATE "BillingSubscription" s + SET status = 'ACTIVE', + "startDate" = $2::timestamptz, + "mollieSubscriptionId" = 'sub_e2e_' || substr(md5(random()::text), 1, 16) + FROM "TenantBilling" b + WHERE s."billingId" = b.id AND b."tenantSlug" = $1 + RETURNING s.id`, + [tenantSlug, opts.firstChargeIso], + ); + if (updated.length !== 1) { + throw new Error(`arrangeActivePlan: no subscription for slug "${tenantSlug}"`); + } + // A `first` payment (what planState reads) and a `recurring` one — which is what + // makes `startDate` stale, and what makes the history list more than a single row. + for (const [sequence, status] of [ + ["first", "paid"], + ["recurring", "paid"], + ]) { + // Same reason arrangeMandateLag checks: INSERT…SELECT writes zero rows for an + // unknown slug and the caller would then fail on a visibility assertion, sending + // the reader to the dashboard instead of to the arrangement. + const inserted = await query<{ id: string }>( + `INSERT INTO "BillingPayment" + (id, "billingId", "molliePaymentId", "amountCents", currency, description, + status, "sequenceType", method, "paidAt", "createdAt", "updatedAt") + SELECT gen_random_uuid()::text, b.id, 'tr_e2e_' || substr(md5(random()::text), 1, 16), + COALESCE(s."amountCents", 0), 'EUR', 'E2E arranged payment', + $2, $3, 'ideal', now(), now(), now() + FROM "TenantBilling" b + LEFT JOIN "BillingSubscription" s ON s."billingId" = b.id + WHERE b."tenantSlug" = $1 + LIMIT 1 + RETURNING id`, + [tenantSlug, status, sequence], + ); + if (inserted.length !== 1) { + throw new Error(`arrangeActivePlan: no billing row for slug "${tenantSlug}"`); + } + } +} + +/** + * Move a plan onto a slug the registry fixture knows about. + * + * The signup refuses a taken slug (correctly), so a self-serve plan can never START + * life pointing at a registry entry — which is exactly the state the owner dashboard + * has to handle once the founder merges the entry. Repointing the row afterwards is + * the only way to reach it in a suite with no provisioning. + * + * `tenantSlug` is `@unique`, and the target is a fixed fixture slug, so a Playwright + * RETRY would otherwise hit a 23505 from the row its own first attempt left behind — + * a Postgres error masking whatever the original assertion failure was. Clearing the + * target first makes the helper idempotent across attempts. Safe only because this is + * a throwaway database whose rows exist for one test. + */ +export async function repointBillingSlug(fromSlug: string, toSlug: string): Promise { + await query(`DELETE FROM "TenantBilling" WHERE "tenantSlug" = $1`, [toSlug]); + const rows = await query<{ id: string }>( + `UPDATE "TenantBilling" SET "tenantSlug" = $2 WHERE "tenantSlug" = $1 RETURNING id`, + [fromSlug, toSlug], + ); + if (rows.length !== 1) throw new Error(`repointBillingSlug: no billing row for "${fromSlug}"`); +} + +/** Record that a registry proposal was opened, without opening one. */ +export async function arrangeProposalOpened(tenantSlug: string, url: string): Promise { + const rows = await query<{ id: string }>( + `UPDATE "TenantBilling" SET "provisioningPrUrl" = $2 WHERE "tenantSlug" = $1 RETURNING id`, + [tenantSlug, url], + ); + if (rows.length !== 1) throw new Error(`arrangeProposalOpened: no billing row for "${tenantSlug}"`); +} diff --git a/tests/e2e/owner-dashboard.spec.ts b/tests/e2e/owner-dashboard.spec.ts new file mode 100644 index 0000000..4d96406 --- /dev/null +++ b/tests/e2e/owner-dashboard.spec.ts @@ -0,0 +1,152 @@ +import { expect, test } from "./helpers/fixtures"; +import { activateAndLogin, mintInviteLink, submitSignup, uniq } from "./helpers/flows"; +import { + arrangeActivePlan, + arrangeMandateLag, + arrangeProposalOpened, + findPlan, + findUser, + repointBillingSlug, +} from "./helpers/db"; + +// The owner's dashboard (SOFRA-ONBOARDING-PLAN O4): the two things it now has to say +// that it did not before — *where is my app and how do I get in* (inherited from O3), +// and *what am I paying, and when next* (the gap O2 named and left open). +// +// Nothing is mocked. The health probe is real: the registry fixture's domains are +// `.example.test`, which resolves nowhere, so a probed tenant genuinely fails to +// answer. That is the assertion that matters most here — a merged registry entry must +// NOT be enough to tell a customer their app is ready, because the merge only STARTS +// the build. The "ready" branch itself is covered by the unit tests over `tenantStage`, +// which is where it can be exercised without standing up a tenant. + +/** A signed-up, activated, logged-in owner sitting on their dashboard. */ +async function anOwner(page: Parameters[0], name: string) { + const slug = uniq.slug(name); + const email = uniq.email(name); + await submitSignup(page, { slug, email, restaurantName: `Chez ${name}` }); + await expect(page.getByText(/check your email/i)).toBeVisible(); + const user = await findUser(email); + await activateAndLogin(page, { + inviteLink: await mintInviteLink(user!.id), + email, + password: `e2e-${name}-pass-${Date.now()}`, + }); + return { slug, email }; +} + +test.describe("the owner is told where their app is — and only what is true", () => { + test("before paying, the app panel says nothing at all", async ({ page }) => { + await anOwner(page, "quiet"); + // The pay button owns this moment. A "we are preparing your app" line beside + // "start your subscription" would be a promise made before any money moved. + await expect(page.getByRole("button", { name: /start auto-monthly payment/i })).toBeVisible(); + await expect(page.getByText(/preparing your app/i)).toHaveCount(0); + await expect(page.getByRole("link", { name: /set your admin password/i })).toHaveCount(0); + }); + + test("paid with nothing proposed yet says 'preparing', and hands out no link", async ({ + page, + }) => { + const { slug } = await anOwner(page, "prep"); + await arrangeActivePlan(slug, { firstChargeIso: "2026-01-15T00:00:00Z" }); + + await page.goto("/dashboard"); + await expect(page.getByText(/preparing your app/i)).toBeVisible(); + await expect(page.getByRole("link", { name: /set your admin password/i })).toHaveCount(0); + await expect(page.getByText(/your restaurant app is live/i)).toHaveCount(0); + }); + + test("an open proposal says 'being built', and still hands out no link", async ({ page }) => { + const { slug } = await anOwner(page, "propose"); + await arrangeActivePlan(slug, { firstChargeIso: "2026-01-15T00:00:00Z" }); + await arrangeProposalOpened(slug, "https://github.com/piwas-21/restaurant-app-deploy/pull/999"); + + await page.goto("/dashboard"); + await expect(page.getByText(/your app is being built/i)).toBeVisible(); + await expect(page.getByRole("link", { name: /set your admin password/i })).toHaveCount(0); + }); + + test("the mandate-lag window is not told the same thing twice", async ({ page }) => { + // While `` is up it already spells out the whole wait, ending + // with "then your app is prepared". "We are preparing your app" directly beneath it + // is the same sentence again, and a line that restates the line above it is worse + // than no line. `visibleTenantStage` silences exactly those two stages here. + const { slug } = await anOwner(page, "twice"); + await arrangeMandateLag(slug); + + await page.goto("/dashboard"); + await expect(page.getByText(/your first payment went through/i)).toBeVisible(); + await expect(page.getByText(/preparing your app/i)).toHaveCount(0); + await expect(page.getByText(/your app is being built/i)).toHaveCount(0); + }); + + test("a MERGED registry entry is still not 'ready' — the app has to answer", async ({ page }) => { + // The single most important assertion in this file. Under the O3 merge chain the + // founder's merge starts a build that takes ~15 minutes and can fail; a dashboard + // that read the entry as "live" would send a customer to a connection error on the + // very first thing the product ever asked them to do. + // + // Arranged in the mandate-lag window on purpose: `almostReady` is one of the two + // stages that must SURVIVE the suppression above, because unlike "preparing" it is + // information `ActivatingPanel` does not have. + const { slug } = await anOwner(page, "merged"); + await arrangeMandateLag(slug); + await repointBillingSlug(slug, "e2e-occupied"); + + await page.goto("/dashboard"); + await expect(page.getByText(/almost there/i)).toBeVisible(); + await expect(page.getByRole("link", { name: /set your admin password/i })).toHaveCount(0); + await expect(page.getByText(/your restaurant app is live/i)).toHaveCount(0); + // And no half-built link leaks into the page either. + await expect(page.locator('a[href*="e2e-occupied.example.test"]')).toHaveCount(0); + }); +}); + +test.describe("an owner with an active plan is shown their plan", () => { + test("amount, next charge and payment history — not 'nothing to do here'", async ({ page }) => { + const { slug } = await anOwner(page, "active"); + // A plan whose FIRST recurring charge was 15 Jan 2026 and which has billed monthly + // since — i.e. `startDate` is in the past, the only shape production can produce + // once a recurring payment exists. + await arrangeActivePlan(slug, { firstChargeIso: "2026-01-15T00:00:00Z" }); + + await page.goto("/dashboard"); + + // What they pay, re-read from their own subscription row. Matched with the + // interval attached: the bare amount now also appears on every history row, and a + // loose match would pass on a page that had lost the plan line entirely. + await expect(page.getByText("€ 43,00 / month")).toBeVisible(); + // When it is taken next — the single most-asked billing question, and the one this + // page could not answer at all before O4. It must be DERIVED: `startDate` is the + // FIRST recurring charge and is never advanced, so printing that column puts a date + // months in the past in front of a paying customer. The suite runs well after + // 15 Jan 2026, so the right answer is a 15th still ahead of today — and the stale + // one is precisely what the second assertion refuses. + await expect(page.getByText(/next charge on 15 /i)).toBeVisible(); + await expect(page.getByText(/next charge on 15 Jan 2026/i)).toHaveCount(0); + // Their history, in their words rather than Mollie's: `first`/`recurring` and + // `paid` are our vendor's vocabulary, not a restaurant owner's. + await expect(page.getByText(/payments/i).first()).toBeVisible(); + await expect(page.getByText(/first payment/i)).toBeVisible(); + await expect(page.getByText(/subscription charge/i)).toBeVisible(); + + // The defaulted "nothing to do" this replaced must be gone, not merely pushed + // below the fold. + await expect(page.getByText(/nothing to do here right now/i)).toHaveCount(0); + // An owner is still not a reseller. + await expect(page.getByText(/add a client/i)).toHaveCount(0); + + expect((await findPlan(slug))!.subStatus).toBe("ACTIVE"); + }); + + test("no pay button once the plan is active — that is the double-charge trap", async ({ + page, + }) => { + const { slug } = await anOwner(page, "nopay"); + await arrangeActivePlan(slug, { firstChargeIso: "2026-02-01T00:00:00Z" }); + + await page.goto("/dashboard"); + await expect(page.getByRole("button", { name: /start auto-monthly payment/i })).toHaveCount(0); + }); +}); diff --git a/tests/unit/auto-provision-policy.test.ts b/tests/unit/auto-provision-policy.test.ts new file mode 100644 index 0000000..7b59e3b --- /dev/null +++ b/tests/unit/auto-provision-policy.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { + AUTO_PROPOSE_NOTES, + classifyProvisioningRefusal, + decideAutoPropose, + type AutoProposeConfig, + type AutoProposeFacts, + type AutoProposeSkip, +} from "@/lib/auto-provision-policy"; + +const config = (over: Partial = {}): AutoProposeConfig => ({ + slug: "bistro-nova", + billingSlug: "bistro-nova", + name: "Bistro Nova", + template: "craft", + currency: "EUR", + modules: ["core", "reservations"], + languages: ["en", "nl"], + ...over, +}); + +const facts = (over: Partial = {}): AutoProposeFacts => ({ + existingPrUrl: null, + config: config(), + settled: true, + provisioningConfigured: true, + ...over, +}); + +describe("decideAutoPropose", () => { + it("proposes for a settled self-serve plan with a full configuration", () => { + expect(decideAutoPropose(facts())).toEqual({ kind: "propose" }); + }); + + it("reports an existing proposal instead of opening a second one", () => { + // The ordinary repeat case, not an edge one: Mollie redelivers webhooks. The URL + // rides along so a redelivery still tells the founder something useful. + const url = "https://github.com/piwas-21/restaurant-app-deploy/pull/99"; + expect(decideAutoPropose(facts({ existingPrUrl: url }))).toEqual({ + kind: "alreadyProposed", + prUrl: url, + }); + }); + + it("lets an existing proposal outrank every other verdict", () => { + // If this ordering ever inverts, a redelivery for an unpaid or badly configured plan + // would report a fresh skip about a tenant that has already been proposed. + const url = "https://example.test/pr/1"; + for (const over of [ + { config: null }, + { settled: false }, + { provisioningConfigured: false }, + { config: config({ template: undefined }) }, + ] as Partial[]) { + expect(decideAutoPropose(facts({ ...over, existingPrUrl: url })).kind).toBe("alreadyProposed"); + } + }); + + it("skips a plan with no lead — that is the founder path, not a failure", () => { + // No lead is exactly the signal the payment gate keys on: /admin/onboard, the + // reseller flow, and RUMI all have none. + expect(decideAutoPropose(facts({ config: null }))).toEqual({ + kind: "skipped", + reason: "notSelfServe", + }); + }); + + it("refuses an unpaid plan before it looks at the configuration", () => { + // A badly configured unpaid plan must report as unpaid: the gate is the security + // property, and "fix your template" would be the wrong instruction. + expect( + decideAutoPropose(facts({ settled: false, config: config({ currency: undefined }) })), + ).toEqual({ kind: "skipped", reason: "awaitingPayment" }); + }); + + it("treats a slug mismatch as its own answer, not as incomplete", () => { + // Conflating the two would send the founder to fill in a form when what they need to + // do is find out why a plan bills against a slug its lead never asked for. + expect(decideAutoPropose(facts({ config: config({ slug: "someone-else" }) }))).toEqual({ + kind: "skipped", + reason: "slugMismatch", + }); + }); + + it("never guesses a missing choice", () => { + // Template is baked into the tenant's image and currency prices their menu; neither + // has a safe default for someone who has paid. + for (const over of [ + { template: undefined }, + { currency: undefined }, + { modules: [] }, + { languages: [] }, + ] as Partial[]) { + expect(decideAutoPropose(facts({ config: config(over) }))).toEqual({ + kind: "skipped", + reason: "incompleteConfiguration", + }); + } + }); + + it("FAILS on a missing token rather than skipping — and only once eligible", () => { + // PROVISION_GITHUB_TOKEN expires silently and /admin/provision degrades to a banner + // nobody is looking at on this path, so it has to be loud. + expect(decideAutoPropose(facts({ provisioningConfigured: false }))).toEqual({ + kind: "failed", + detail: "PROVISION_GITHUB_TOKEN is unset or expired", + }); + // ...but a plan that was never eligible must not raise a token alarm. + expect( + decideAutoPropose(facts({ provisioningConfigured: false, settled: false })).kind, + ).toBe("skipped"); + expect(decideAutoPropose(facts({ provisioningConfigured: false, config: null })).kind).toBe( + "skipped", + ); + }); + + it("refuses a name that cannot survive a Docker build arg", () => { + // `signupSchema` guards this at intake now, but that guard is new — rows captured + // before it can still hold a newline, and this name reaches + // build-tenant-image.yml's NEWLINE-DELIMITED `build-args:`. The deploy chain also + // rejects it, but only after the entry is merged, which would leave a paying + // customer with a merged registry entry that never provisions. + for (const name of ["Bistro\nNEXT_PUBLIC_API_URL=https://evil.test", "A\tB", "A\u0000B"]) { + expect(decideAutoPropose(facts({ config: config({ name }) }))).toEqual({ + kind: "skipped", + reason: "unsafeName", + }); + } + // An ordinary name with punctuation and non-ASCII is untouched. + for (const name of ["Chez L'Ami", "Nova: Café — Bar", "北京饭店"]) { + expect(decideAutoPropose(facts({ config: config({ name }) })).kind).toBe("propose"); + } + }); + + it("has a founder-facing note for EVERY skip reason, enumerated explicitly", () => { + // Iterating AUTO_PROPOSE_NOTES would be vacuous — it can only contain what it + // contains. Listing the union members is what makes adding a reason without a note + // fail, here and at compile time. + const reasons: AutoProposeSkip[] = [ + "notSelfServe", + "awaitingPayment", + "incompleteConfiguration", + "slugMismatch", + "unsafeName", + "proposalExists", + ]; + expect(Object.keys(AUTO_PROPOSE_NOTES).sort()).toEqual([...reasons].sort()); + for (const reason of reasons) { + expect(AUTO_PROPOSE_NOTES[reason], reason).toMatch(/^No automatic proposal/); + expect(AUTO_PROPOSE_NOTES[reason].length, reason).toBeGreaterThan(20); + } + }); +}); + +describe("classifyProvisioningRefusal", () => { + it("tells a LIVE tenant apart from an open proposal", () => { + // The first version of this lived in the shell, untested, and matched both with one + // regex — so "that slug is already a live tenant" (money taken for a subdomain + // someone else owns) was reported to the founder as "nothing to do". + expect(classifyProvisioningRefusal("registry already has a 'demo' entry")).toBe("slugLive"); + expect( + classifyProvisioningRefusal( + "a provisioning proposal for 'demo' is already open (branch provision/demo exists)", + ), + ).toBe("proposalOpen"); + }); + + it("does not guess at anything else", () => { + for (const msg of ["GitHub POST /repos/x/y → 401: Bad credentials", "", "already"]) { + expect(classifyProvisioningRefusal(msg)).toBe("other"); + } + }); +}); diff --git a/tests/unit/billing-display.test.ts b/tests/unit/billing-display.test.ts index d97b979..fd96bbb 100644 --- a/tests/unit/billing-display.test.ts +++ b/tests/unit/billing-display.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { intervalKeyOf, planState } from "@/lib/billing-display"; +import { + intervalKeyOf, + nextChargeDate, + paymentStatusKey, + planState, + sequenceKey, +} from "@/lib/billing-display"; const first = (status: string) => ({ sequenceType: "first", status }); const recurring = (status: string) => ({ sequenceType: "recurring", status }); @@ -44,3 +50,84 @@ describe("planState (partner billing view / double-charge guard)", () => { expect(planState({ status: "SUSPENDED" }, [])).toBe("inactive"); }); }); + +describe("sequenceKey", () => { + it("maps Mollie's sequence vocabulary to owner-facing keys", () => { + expect(sequenceKey("first")).toBe("first"); + expect(sequenceKey("recurring")).toBe("recurring"); + expect(sequenceKey("oneoff")).toBe("oneoff"); + }); + + it("buckets anything unrecognised as recurring, never as `first`", () => { + // `first` is the one value the payer reads as "this is the charge that started my + // subscription". An unknown sequence type must not borrow that meaning. + expect(sequenceKey("")).toBe("recurring"); + expect(sequenceKey("something-mollie-added")).toBe("recurring"); + }); +}); + +describe("paymentStatusKey", () => { + it("collapses the seven Mollie statuses into what a payer acts on", () => { + expect(paymentStatusKey("paid")).toBe("paid"); + expect(paymentStatusKey("authorized")).toBe("paid"); + expect(paymentStatusKey("open")).toBe("pending"); + expect(paymentStatusKey("pending")).toBe("pending"); + expect(paymentStatusKey("failed")).toBe("failed"); + expect(paymentStatusKey("canceled")).toBe("failed"); + expect(paymentStatusKey("expired")).toBe("failed"); + }); + + it("never promotes an unknown status to paid", () => { + // A status Mollie adds later renders as its own literal text via the `other` + // bucket. Reading it as "paid" would tell an owner money arrived when it did not. + expect(paymentStatusKey("chargeback")).toBe("other"); + expect(paymentStatusKey("")).toBe("other"); + }); +}); + +describe("nextChargeDate", () => { + const NOW = new Date("2026-07-30T12:00:00Z"); + + it("returns the first recurring charge while it is still ahead", () => { + // Month one: `startDate` IS the next charge, and no stepping should happen. + expect(nextChargeDate(new Date("2026-08-15T00:00:00Z"), "1 month", NOW)).toEqual( + new Date("2026-08-15T00:00:00Z"), + ); + }); + + it("steps a stale anchor forward instead of reporting a date in the past", () => { + // The defect this exists for: `startDate` is written once at activation and never + // advanced, so from month two on it names a charge that already happened. + const next = nextChargeDate(new Date("2026-01-15T00:00:00Z"), "1 month", NOW); + expect(next).toEqual(new Date("2026-08-15T00:00:00Z")); + expect(next!.getTime()).toBeGreaterThan(NOW.getTime()); + }); + + it("keeps the subscription's own day of month rather than drifting to today", () => { + // Stepping from `now` instead of from the anchor would answer "30 Aug" here. + expect(nextChargeDate(new Date("2025-03-02T00:00:00Z"), "1 month", NOW)).toEqual( + new Date("2026-08-02T00:00:00Z"), + ); + }); + + it("honours quarterly and yearly intervals", () => { + expect(nextChargeDate(new Date("2026-01-15T00:00:00Z"), "3 months", NOW)).toEqual( + new Date("2026-10-15T00:00:00Z"), + ); + expect(nextChargeDate(new Date("2024-05-20T00:00:00Z"), "12 months", NOW)).toEqual( + new Date("2027-05-20T00:00:00Z"), + ); + }); + + it("returns null rather than a guess when it cannot compute one", () => { + // The caller falls back to a plain "Active." — better than inventing a date. + expect(nextChargeDate(null, "1 month", NOW)).toBeNull(); + expect(nextChargeDate(new Date("nope"), "1 month", NOW)).toBeNull(); + expect(nextChargeDate(new Date("2026-01-15T00:00:00Z"), "2 weeks", NOW)).toBeNull(); + }); + + it("advances past an anchor exactly equal to now", () => { + // A charge due this instant is not the NEXT one — `<=` in the loop, not `<`. + expect(nextChargeDate(NOW, "1 month", NOW)).toEqual(new Date("2026-08-30T12:00:00Z")); + }); +}); diff --git a/tests/unit/provisioning-registry.test.ts b/tests/unit/provisioning-registry.test.ts index c334476..39c1491 100644 --- a/tests/unit/provisioning-registry.test.ts +++ b/tests/unit/provisioning-registry.test.ts @@ -112,31 +112,56 @@ describe("buildProvisioningPrBody", () => { city: "Rotterdam", }; - it("carries the post-merge commands with the tenant's own values", () => { + it("tells a STAGING entry that merging provisions it", () => { const body = buildProvisioningPrBody(input); - // The image build is the step that is easy to skip and fatal to skip. - expect(body).toContain( - "gh workflow run build-tenant-image.yml --repo piwas-21/restaurant-app-frontend", - ); - expect(body).toContain("-f tenant_domain=bistro-nova.sofrapiwas.com"); - expect(body).toContain("-f image_tag=tenant-bistro-nova"); - expect(body).toContain("-f template=craft"); - expect(body).toContain("-f currency=EUR"); - expect(body).toContain( - "gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=bistro-nova", - ); - // Ordering is the point: build the image before provisioning. - expect(body.indexOf("build-tenant-image.yml")).toBeLessThan( - body.indexOf("provision-tenant.yml"), - ); + expect(body).toContain("Merging this PR provisions the tenant"); + // The slug is the one field that cannot be renegotiated afterwards, so the + // checklist has to name the actual value rather than talk about slugs. + expect(body).toContain("`bistro-nova` is what the customer should live on forever"); + expect(body).toContain("### If the chain fails"); }); - it("summarises the proposed entry", () => { - const body = buildProvisioningPrBody(input); - expect(body).toContain("`bistro-nova.sofrapiwas.com`"); - expect(body).toContain("`en, nl`"); - expect(body).toContain("`core, reservations`"); - expect(body).toContain("`staging`"); // default box + it("tells a PROD entry the opposite, because the chain is staging-only", () => { + // The chain follows sync-registry-to-staging and skips any other box. A prod body + // promising hands-off provisioning would leave the founder waiting on nothing. + const body = buildProvisioningPrBody({ ...input, box: "prod" }); + expect(body).toContain("does **not** provision"); + expect(body).not.toContain("Merging this PR provisions the tenant"); + expect(body).not.toContain("hands-off"); + // ...and the commands stop being a fallback. + expect(body).toContain("### Run these after merging"); + }); + + it("flags the backend_tag risk that actually exists for each box", () => { + // buildTenantRegistryEntry pins backend_tag FROM the box, so "a staging tenant might + // be on :latest" is impossible by construction — warning about it would be an + // unfalsifiable checkbox on every real PR. The live risk is the reverse: a staging-box + // tenant rides the develop build, which is wrong for someone paying. + const staging = buildProvisioningPrBody(input); + expect(staging).toContain("rides the *develop* build"); + expect(staging).toContain("unreleased backend code"); + expect(staging).not.toContain("staging-box tenant on `:latest`"); + + const prod = buildProvisioningPrBody({ ...input, box: "prod" }); + expect(prod).toContain("released code"); + expect(prod).not.toContain("unreleased backend code"); + }); + + it("keeps a newline in the tenant name from breaking the fence or the command", () => { + // provisionSchema refuses control characters, but this body is a pure function that + // embeds `name` inside a ``` fence AND a shell command. An interior newline would + // close the fence early, render the rest as prose, and hand the founder a command + // with an unterminated quote. + const body = buildProvisioningPrBody({ ...input, name: "Bistro\n```\n## PWNED" }); + const lines = body.split("\n"); + // Markdown only closes a fence at the START of a line, so counting every ``` in the + // document would fail on a harmless mid-line one. The invariant that matters is that + // the fence delimiters are exactly the two we wrote. + expect(lines.filter((l) => l.trimStart().startsWith("```"))).toEqual(["```bash", "```"]); + // ...and the whole command stays on one line, so it is still copy-pasteable. + expect(lines.filter((l) => l.includes("-f restaurant_name="))).toEqual([ + " -f restaurant_name='Bistro ``` ## PWNED' \\", + ]); }); it("shell-quotes the tenant name so an apostrophe cannot break the command", () => { diff --git a/tests/unit/tenant-liveness.test.ts b/tests/unit/tenant-liveness.test.ts new file mode 100644 index 0000000..22df6e0 --- /dev/null +++ b/tests/unit/tenant-liveness.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + tenantForgotPasswordUrl, + tenantOrigin, + tenantStage, + visibleTenantStage, + type TenantStageFacts, +} from "@/lib/tenant-liveness"; + +const facts = (over: Partial = {}): TenantStageFacts => ({ + paid: true, + provisioningPrUrl: null, + registryDomain: null, + healthy: false, + ...over, +}); + +describe("tenantStage", () => { + it("says nothing before the first payment settles", () => { + // The pay button and ActivatingPanel own this moment; a third voice about the app + // next to "pay now" is noise. + expect(tenantStage(facts({ paid: false }))).toBe("none"); + expect( + tenantStage(facts({ paid: false, registryDomain: "x.sofrapiwas.com", healthy: true })), + ).toBe("none"); + }); + + it("is 'ready' only when the app was observed serving", () => { + expect(tenantStage(facts({ registryDomain: "x.sofrapiwas.com", healthy: true }))).toBe("ready"); + }); + + it("does NOT claim ready from a registry entry alone", () => { + // A merged entry means the chain started, not that the build and provision + // finished. This is the whole reason the probe exists. + expect(tenantStage(facts({ registryDomain: "x.sofrapiwas.com", healthy: false }))).toBe( + "almostReady", + ); + }); + + it("does NOT claim ready from a stale `healthy` with no registry entry", () => { + // Guards against a caller that keeps a health answer around after the entry is + // gone. `healthy` alone must never be sufficient. + expect(tenantStage(facts({ healthy: true }))).toBe("preparing"); + expect(tenantStage(facts({ healthy: true, provisioningPrUrl: "https://gh/pr/1" }))).toBe( + "settingUp", + ); + }); + + it("degrades to 'settingUp' on an open proposal, 'preparing' on nothing", () => { + expect(tenantStage(facts({ provisioningPrUrl: "https://gh/pr/1" }))).toBe("settingUp"); + expect(tenantStage(facts())).toBe("preparing"); + }); + + it("treats an unreadable registry exactly like no entry — never like a live tenant", () => { + // registryDomains() collapses a registry read failure to `null`, so this is the + // shape that reaches the classifier during an ops outage. It must fall BACKWARD. + expect( + tenantStage(facts({ registryDomain: null, provisioningPrUrl: "https://gh/pr/1", healthy: true })), + ).toBe("settingUp"); + }); +}); + +describe("tenantOrigin", () => { + it("accepts a bare host", () => { + expect(tenantOrigin("demo.sofrapiwas.com")).toBe("https://demo.sofrapiwas.com"); + expect(tenantOrigin("www.rumirestaurant.ch")).toBe("https://www.rumirestaurant.ch"); + }); + + it("rejects anything that is not a bare host", () => { + // The value reaches here from a registry entry whose slug half was typed by a + // customer. It becomes both a link we tell the owner to click and a request this + // server makes, so a scheme, credentials, port, path or query is refused outright + // rather than normalised into something that still resolves somewhere. + for (const bad of [ + "https://demo.sofrapiwas.com", + "evil.example.com/path", + "user:pass@evil.example.com", + "demo.sofrapiwas.com:8080", + "demo.sofrapiwas.com?x=1", + "localhost", + "127.0.0.1", + "10.0.0.5", + "backend", + "-leading.example.com", + "trailing-.example.com", + "", + " demo.sofrapiwas.com", + ]) { + expect(tenantOrigin(bad), bad).toBeNull(); + } + }); +}); + +describe("tenantForgotPasswordUrl", () => { + it("points at the tenant's own reset page — the O3 mechanism, on their box", () => { + expect(tenantForgotPasswordUrl("demo.sofrapiwas.com")).toBe( + "https://demo.sofrapiwas.com/forgot-password", + ); + }); + + it("is null when the origin is refused, so the panel cannot render a broken link", () => { + expect(tenantForgotPasswordUrl("evil.example.com/path")).toBeNull(); + }); +}); + +describe("visibleTenantStage", () => { + it("is a pass-through outside the mandate-lag window", () => { + for (const s of ["none", "preparing", "settingUp", "almostReady", "ready"] as const) { + expect(visibleTenantStage(s, false)).toBe(s); + } + }); + + it("silences the waiting copy that ActivatingPanel is already saying", () => { + // "We are preparing your app" under a panel that opens with "your first payment + // went through … then your app is prepared" is the same sentence twice. + expect(visibleTenantStage("preparing", true)).toBe("none"); + expect(visibleTenantStage("settingUp", true)).toBe("none"); + }); + + it("keeps the stages that are NEW information mid-activation", () => { + // ActivatingPanel cannot know the app exists; these two do, so they still speak. + expect(visibleTenantStage("almostReady", true)).toBe("almostReady"); + expect(visibleTenantStage("ready", true)).toBe("ready"); + }); +}); diff --git a/tests/unit/validation.test.ts b/tests/unit/validation.test.ts index b1124db..d00b1dc 100644 --- a/tests/unit/validation.test.ts +++ b/tests/unit/validation.test.ts @@ -292,6 +292,20 @@ describe("provisionSchema (ADR-012 tenant proposal)", () => { it("still rejects an empty module list", () => { expect(provisionSchema.safeParse({ ...base, modules: "" }).success).toBe(false); }); + + it("rejects a line break inside the tenant name", () => { + // `trim()` only strips the ENDS, so an interior newline used to survive — and the + // name is forwarded into build-tenant-image.yml's `build-args:`, which is a + // newline-delimited list. A second line there injects a build arg (e.g. a different + // NEXT_PUBLIC_API_URL) into the tenant's own bundle. + for (const name of ["Bistro\nNova", "Bistro\r\nNova", "Bistro\tNova", "Bistro\u0000Nova"]) { + expect(provisionSchema.safeParse({ ...base, name }).success).toBe(false); + } + // Ordinary names, including non-ASCII and punctuation, are untouched. + for (const name of ["Chez L'Ami", "Nova: Café — Bar", "北京饭店"]) { + expect(provisionSchema.safeParse({ ...base, name }).success).toBe(true); + } + }); }); describe("splitCsvLower", () => { diff --git a/vitest.config.ts b/vitest.config.ts index e5e9edf..9b51351 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -41,6 +41,8 @@ export default defineConfig({ "lib/billing-display.ts", "lib/self-serve-signup.ts", "lib/provisioning-payment-gate.ts", + "lib/auto-provision-policy.ts", + "lib/tenant-liveness.ts", ], reporter: ["text-summary", "text"], // Floors sit a few points under the current 100/95/100/100 so a trivial