diff --git a/.env.example b/.env.example index c759ac7..ee717b5 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,29 @@ NEXTAUTH_URL=http://localhost:3000 # test_ key ONLY (CLAUDE.md §9) — never exercise billing against the live key. MOLLIE_API_KEY= +# --- Stripe platform webhooks (POST /api/webhooks/stripe) --- +# ADR-011 amendment. This app never creates Stripe charges (that's the backend, +# Job B); it returns an application fee when a connected account refunds a +# charge, and it records the fees it earns. +# Platform secret key (sk_test_/sk_live_). Unset -> the webhook 503s. +STRIPE_API_KEY= +# ONE url, TWO Stripe endpoints, one handler — because the two event scopes are +# ORTHOGONAL and Stripe will not merge them. Each endpoint has its own whsec_; +# either one alone is a working configuration, and the route 503s only when +# BOTH are unset. +# +# Signing secret for the platform's CONNECT (`connect: true`) endpoint — +# `charge.refunded` for every connected account. Stripe refuses to let a +# platform register a webhook ON a connected account, so these arrive here. +STRIPE_CONNECT_WEBHOOK_SECRET= +# Signing secret for the ACCOUNT-scoped (non-Connect) endpoint at the SAME url — +# `application_fee.created`. An ApplicationFee is a PLATFORM-owned object, so its +# event carries `account: null` and a Connect endpoint NEVER receives it +# (measured 2026-09-04, with a control). Stripe accepts a `connect: true` +# endpoint that lists this event with HTTP 200 and then never fires it, so the +# wrong configuration reads exactly like "no commission earned yet". +STRIPE_ACCOUNT_WEBHOOK_SECRET= + # --- Fleet telemetry roll-up (POST /api/telemetry/fleet) --- # Shared bearer secret each tenant backend's FleetSummaryPushService must present # (openssl rand -hex 32). Must match the same secret on the backend side. Unset -> the route 503s. diff --git a/app/(control)/admin/billing/[id]/page.tsx b/app/(control)/admin/billing/[id]/page.tsx index d1ca936..b7629c5 100644 --- a/app/(control)/admin/billing/[id]/page.tsx +++ b/app/(control)/admin/billing/[id]/page.tsx @@ -7,7 +7,6 @@ import { db } from "@/lib/db"; import { eur, shortDate } from "@/lib/format"; import { BILLING_INTERVALS } from "@/lib/billing"; import CancelSubscriptionButton from "@/components/control/CancelSubscriptionButton"; -import CopyField from "@/components/control/CopyField"; import BillingIdentityForm from "@/components/control/BillingIdentityForm"; import RecheckVatButton from "@/components/control/RecheckVatButton"; import { isInvoiceable } from "@/lib/billing-identity"; @@ -16,6 +15,11 @@ import { planDeletionVerdict, settledOrInFlight } from "@/lib/plan-deletion"; import DeletePlanForm from "@/components/control/DeletePlanForm"; import TrialPanel from "@/components/control/TrialPanel"; import PlanPaymentsList from "@/components/control/PlanPaymentsList"; +import AdminPaymentsModePanel from "@/components/control/AdminPaymentsModePanel"; +import CommissionEarningsPanel from "@/components/control/CommissionEarningsPanel"; +import OpenCheckoutPanel from "@/components/control/OpenCheckoutPanel"; +import { asPaymentsMode } from "@/lib/payments-pricing"; +import { loadTenantRegistry } from "@/lib/tenant-registry"; // Mollie interval string → control.admin.intervals key (display only). const intervalKey = (mollie: string) => @@ -61,9 +65,9 @@ export default async function AdminBillingDetailPage({ hasMollieCustomer: Boolean(billing.mollieCustomerId), }); - const openCheckout = billing.payments.find( - (p) => p.checkoutUrl && (p.status === "open" || p.status === "pending"), - ); + // Read-only seam (ADR-007) — shows what the box actually enforces, never writes it. + const registry = await loadTenantRegistry(); + const registryTenant = registry.ok ? registry.tenants.find((t) => t.slug === billing.tenantSlug) : undefined; return (
@@ -84,17 +88,7 @@ export default async function AdminBillingDetailPage({

- {openCheckout && ( -
-

{t("billingDetail.checkoutTitle")}

-

- {t("billingDetail.checkoutIntro")} -

-
- -
-
- )} +

{t("identity.title")}

@@ -123,6 +117,19 @@ export default async function AdminBillingDetailPage({ subscriptions on purpose: whether money is owed comes before what it costs. */} + + + {/* Directly below the rate control: what the rate collected belongs beside + what the rate IS — that adjacency is what makes a wrong rate visible. */} + +

{t("billingDetail.subscriptions")}

    diff --git a/app/(control)/admin/signups/page.tsx b/app/(control)/admin/signups/page.tsx index 6f78398..f699c79 100644 --- a/app/(control)/admin/signups/page.tsx +++ b/app/(control)/admin/signups/page.tsx @@ -6,6 +6,7 @@ import { eur } from "@/lib/format"; import { loadTenantRegistry } from "@/lib/tenant-registry"; import { checkSlug } from "@/lib/slug-availability"; import { failedByAction } from "@/lib/email-delivery"; +import { formatCommissionPercent } from "@/lib/payments-pricing"; import SignupActions from "@/components/control/SignupActions"; // Direct-restaurant signup pipeline (ADR-004). Leads land here via POST @@ -103,6 +104,20 @@ export default async function AdminSignupsPage() {
    {eur(s.quotedCents)}
    )} + {/* S3: absent on every lead captured before the payments pricing + mode choice shipped, same reasoning as the fields above. */} + {s.paymentsMode !== null && ( + <> +
    {t("chosenPaymentsMode")}
    +
    + {s.paymentsMode === "commission" + ? t("paymentsModeCommission", { + percent: formatCommissionPercent(s.paymentsCommissionBps ?? 0), + }) + : t("paymentsModeFlat")} +
    + + )} )} {s.message && ( diff --git a/app/(control)/admin/tenants/page.tsx b/app/(control)/admin/tenants/page.tsx index 397b085..745e91e 100644 --- a/app/(control)/admin/tenants/page.tsx +++ b/app/(control)/admin/tenants/page.tsx @@ -10,6 +10,8 @@ import { missingPairedStripeAccount, type RegistryTenant, } from "@/lib/tenant-registry"; +import { effectivePaymentsMode } from "@/lib/payments-mode-effective"; +import { asPaymentsMode, formatCommissionPercent } from "@/lib/payments-pricing"; // The registry file changes underneath us (rsync on deploy-repo push) — always // re-read instead of serving a build-time snapshot. @@ -19,6 +21,8 @@ export const dynamic = "force-dynamic"; type BillingSummary = { id: string; subscriptions: { status: string; amountCents: number; interval: string }[]; + paymentsMode: string; + paymentsCommissionBps: number; }; // Shape of the "control.admin" translator handed down to the row helpers. @@ -55,6 +59,27 @@ function BillingCell({ billing, t }: { billing?: BillingSummary; t: Translator } ); } +/** + * Read-only payments-mode label (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2b) — the + * EFFECTIVE mode, derived the same way the billing page's own panel derives it, + * never `billing.paymentsMode` alone: this page's whole reason to exist is + * showing what the box actually enforces, and the control that CHANGES it lives + * on `/admin/billing/[id]`, not here. + */ +function paymentsModeLabel(tenant: RegistryTenant, billing: BillingSummary | undefined, t: Translator) { + const intended = billing ? asPaymentsMode(billing.paymentsMode) : "flat"; + const effective = effectivePaymentsMode({ + intended, + registryBps: tenant.payments_commission_bps, + registryReadable: true, // this page only ever renders inside the registry.ok branch + }); + const base = + effective.mode === "commission" + ? t("tenants.paymentsModeCommission", { percent: formatCommissionPercent(tenant.payments_commission_bps ?? 0) }) + : t("tenants.paymentsModeFlat"); + return effective.pending ? `${base} ${t("tenants.paymentsModePending")}` : base; +} + function TenantCard({ tenant, billing, @@ -96,6 +121,7 @@ function TenantCard({ {/* classic/craft is a technical identifier — rendered raw like status */} {t("tenants.template", { template: tenant.template ?? "classic" })} + {paymentsModeLabel(tenant, billing, t)} {/* An `acct_…` is a Stripe identifier, not a secret, and it is the one fact that says whether this tenant can take a card at all. */} {tenant.stripe_account && ( @@ -124,6 +150,8 @@ export default async function AdminTenantsPage() { id: true, tenantSlug: true, subscriptions: { select: { status: true, amountCents: true, interval: true } }, + paymentsMode: true, + paymentsCommissionBps: true, }, }); const billingBySlug = new Map(billings.map((b) => [b.tenantSlug, b])); diff --git a/app/(control)/dashboard/clients/[id]/page.tsx b/app/(control)/dashboard/clients/[id]/page.tsx index 96bb96c..2e45c9e 100644 --- a/app/(control)/dashboard/clients/[id]/page.tsx +++ b/app/(control)/dashboard/clients/[id]/page.tsx @@ -18,6 +18,7 @@ import TenantDnsPanel from "@/components/control/TenantDnsPanel"; import { tenantDnsRecords } from "@/lib/tenant-dns-record"; import { checkDnsRecord } from "@/lib/tenant-dns-check"; import ClientPlanPanel from "@/components/control/ClientPlanPanel"; +import ClientPaymentsModePanel from "@/components/control/ClientPaymentsModePanel"; import ClientChangeRequestForm from "@/components/control/ClientChangeRequestForm"; import NoteForm from "@/components/control/NoteForm"; @@ -138,6 +139,14 @@ export default async function ClientDetailPage({ {view.kind !== "none" && ( <> + {/* What Sofra charges for online payments, and the switch between the two + ways of charging it (S4). Posts the CLIENT id — never a tenant slug. */} +

    {t("changeRequest")}

    {t("changeRequestIntro")}

    diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts index c557d07..39dfbfd 100644 --- a/app/api/signup/route.ts +++ b/app/api/signup/route.ts @@ -4,7 +4,10 @@ import { founderInbox, escapeHtml } from "@/lib/email"; import { guardIntake } from "@/lib/intake"; import { signupSchema } from "@/lib/validation"; import { audit } from "@/lib/audit"; -import { sanitizeSignupConfiguration } from "@/lib/signup-configuration"; +import { + sanitizeSignupConfiguration, + type StoredSignupConfiguration, +} from "@/lib/signup-configuration"; import { eur } from "@/lib/format"; import { loadTenantRegistry } from "@/lib/tenant-registry"; import { checkSlug } from "@/lib/slug-availability"; @@ -132,6 +135,19 @@ async function mintAccount( * customer is still at the keyboard and one field away from succeeding, so asking * is better than banking a lead nobody can act on until the slug is renegotiated. */ +/** + * The founder's new-lead mail lists the quote, and under `commission` that total + * EXCLUDES the online-payments module — so the number alone reads as a cheaper + * plan with no visible reason for it. This row is what explains it, and it lives + * outside POST so the handler stays under its cognitive-complexity limit. + */ +function paymentsRow(config: StoredSignupConfiguration): string { + if (config.paymentsMode === "commission") { + return `commission (${config.paymentsCommissionBps ?? 0} bps)`; + } + return config.paymentsMode ?? "—"; +} + export async function POST(request: Request) { const guard = await guardIntake(request, "signup"); if ("response" in guard) return guard.response; @@ -252,6 +268,7 @@ export async function POST(request: Request) { ["Tenant languages", config.languages ?? "—"], ["Currency", config.currency ?? "—"], ["Quoted", config.quotedCents === null ? "—" : `${eur(config.quotedCents)}/mo`], + ["Payments", paymentsRow(config)], ], }).catch(() => undefined); } diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..fd7ef08 --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,154 @@ +// Stripe webhook — ONE url, TWO Stripe endpoints, one handler. +// +// 1. The `connect: true` endpoint (ADR-011 amendment, consequence 1 — "fee +// follows the refund"): Stripe REFUSES to let a platform register a webhook +// ON a connected account (measured), so connected-account events arrive +// platform-side and name their account via `event.account`. +// 2. An ACCOUNT-scoped (non-Connect) endpoint, for `application_fee.created`. +// An ApplicationFee is a PLATFORM-owned object, so its event carries +// `account: null` and a Connect endpoint NEVER receives it — measured, with +// a control, in lib/stripe-webhook-secrets.ts. Stripe accepts the wrong +// configuration (HTTP 200) and then silently never fires, which would make +// "no earnings recorded" indistinguishable from "no commission earned yet". +// +// Each endpoint has its own `whsec_`, so a delivery is verified against every +// configured secret and the scope that verified it is what the logs name. +// +// Deliberately NOT handled: `application_fee.refunded` / +// `application_fee.refund.updated`. The refunded side is already recorded by +// our own write path (lib/stripe-fee-refund.ts); a second source for the same +// fact is a reconciliation problem, not a feature. And `charge.refunded` can +// be processed BEFORE `application_fee.created` for a fast refund (the fee is +// created asynchronously — the runbook measured "within 5s"), which is safe +// only because the two tables are independent writers joined at read time. The +// natural next change — "look up the earned row while writing a refund" — +// would break exactly that. +// +// Every other Connect event type is deliberately left unhandled (ack 200, do +// nothing), not merely unimplemented: +// - `charge.dispute.*` is OUT OF SCOPE on purpose. For a Direct charge on a +// Standard connected account, dispute LIABILITY sits with the connected +// account, and whether Stripe reverses the application fee on a dispute +// is UNVERIFIED. Guessing here risks Sofra money on an untested branch; +// not handling it costs nothing today (the dispute is still visible in +// the connected account's own Stripe dashboard) and can be added once the +// behaviour is actually measured. +// - everything else Connect can send (account.updated, payout.*, …) is +// simply not this endpoint's job. +import { NextResponse } from "next/server"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { stripeConfigured, StripeError } from "@/lib/stripe"; +import { verifyingScope, webhookSecrets, type WebhookScope } from "@/lib/stripe-webhook-secrets"; +import { refundApplicationFeeForCharge } from "@/lib/stripe-fee-refund"; +import { recordApplicationFee } from "@/lib/stripe-fee-earned"; + +/** + * The ONE error taxonomy this endpoint has, shared by both branches rather than + * mirrored in each: a 404 from Stripe is ACKNOWLEDGED (a forged or unknown id, + * or a database restored across environments — there is nothing to do and a + * retry would not help), and anything else is treated as transient and answered + * 5xx so Stripe retries later. Swallowing that second case is silently lost + * revenue on the earned side and a fee never returned on the refunded one. + * + * Shared so the two can never drift apart, and so this file keeps ONE + * vocabulary for "what happened" — `what` and `scope` are what make a log line + * name the branch and the endpoint that produced it. + */ +async function acknowledge( + what: string, + scope: WebhookScope, + eventId: string, + run: () => Promise, +): Promise { + try { + await run(); + } catch (e) { + if (e instanceof StripeError && e.status === 404) { + return NextResponse.json({ ok: true }); + } + console.error(`stripe webhook: ${what} failed`, scope, eventId, e); + return NextResponse.json({ error: "processing failed" }, { status: 500 }); + } + return NextResponse.json({ ok: true }); +} + +type StripeEvent = { + id: string; + type: string; + // Present only on events scoped to a connected account. A PLATFORM-level + // event omits it entirely — `application_fee.created` is one, which is + // exactly why its branch runs before the `!event.account` guard. + account?: string; + data: { object: { id: string } }; +}; + +export async function POST(request: Request) { + // BOTH endpoints post here. Either secret alone is a working configuration — + // one rail on, the other silent — so this 503s only when NEITHER is set. + const secrets = webhookSecrets({ + connect: process.env.STRIPE_CONNECT_WEBHOOK_SECRET, + account: process.env.STRIPE_ACCOUNT_WEBHOOK_SECRET, + }); + if (!stripeConfigured() || secrets.length === 0) { + return NextResponse.json({ error: "billing not configured" }, { status: 503 }); + } + // Generous — Stripe retries per event; this only guards against floods + // (mirrors the Mollie webhook's own limit). + if (!rateLimit(`stripe-webhook:${clientIp(request)}`, 120, 60_000)) { + return NextResponse.json({ error: "rate limited" }, { status: 429 }); + } + + // RAW bytes only, read BEFORE any parsing — the signature is computed over + // the exact body Stripe sent, and `request.json()` would re-serialize it + // and silently break every verification. + const rawBody = await request.text(); + const header = request.headers.get("stripe-signature") ?? ""; + const scope = verifyingScope({ + rawBody, + header, + secrets, + nowSeconds: Math.floor(Date.now() / 1000), + }); + if (!scope) { + // Bad or missing signature: process nothing past this point. The log names + // the secrets that were actually tried, so "the account endpoint's secret is + // wrong" stays distinguishable from "only the Connect secret is configured + // at all" — the second is a box .env omission and looks identical otherwise. + console.warn( + "stripe webhook: no configured secret verified this delivery; tried", + secrets.map((s) => s.scope).join(","), + ); + return NextResponse.json({ error: "invalid signature" }, { status: 400 }); + } + + let event: StripeEvent; + try { + event = JSON.parse(rawBody) as StripeEvent; + } catch { + return NextResponse.json({ error: "bad body" }, { status: 400 }); + } + + // ABOVE the account guard on purpose: `application_fee.created` has NO + // `event.account` (measured), so the guard below would discard it. The + // connected account is read from the FEE object instead, which is also what + // makes this branch independent of which endpoint delivered the event. + if (event.type === "application_fee.created") { + return acknowledge("fee record", scope, event.id, () => + recordApplicationFee(event.data.object.id), + ); + } + + const account = event.account; + if (!account) { + // A platform-level event we do not act on — acknowledge and ignore rather + // than guess at what it might mean. + return NextResponse.json({ ok: true }); + } + if (event.type !== "charge.refunded") { + return NextResponse.json({ ok: true }); + } + + return acknowledge("fee refund", scope, event.id, () => + refundApplicationFeeForCharge(account, event.data.object.id), + ); +} diff --git a/components/PaymentsModeChoice.tsx b/components/PaymentsModeChoice.tsx new file mode 100644 index 0000000..73739c3 --- /dev/null +++ b/components/PaymentsModeChoice.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { + DEFAULT_COMMISSION_BPS, + ONLINE_PAYMENTS_PRICE_CENTS, + crossoverCentsPerMonth, + formatCommissionPercent, + type PaymentsMode, +} from "@/lib/payments-pricing"; +import { eur } from "@/lib/format"; + +/** + * How to be charged for `online-payments`, offered on /signup + * (SOFRA-PAYMENTS-PRICING-MODE-PLAN S3) — a flat monthly fee, or €0/mo plus a + * per-transaction rate. + * + * Extracted out of `SignupConfigurator`, which sits at the CLAUDE.md §4 + * component limit and cannot grow — the same split `PaymentsModePanel` / + * `PaymentsModeForm` already use for the equivalent `/admin/billing/[id]` + * control (S2b). The parent still owns the `mode` state and folds it into the + * running total via `paymentsModeQuote`, because that total lives beside the + * OTHER modules' prices, not here. + * + * Renders NOTHING when `online-payments` is not selected: the choice is + * meaningless without the module, and an always-visible control would imply + * the module is included when it is not — the same reading + * `sanitizeSignupConfiguration` gives it server-side (a mode with no module + * degrades to `flat`). + * + * The rate shown is always {@link DEFAULT_COMMISSION_BPS} — a buyer picks the + * MODE, never a number, same as the crossover sentence on `/admin/billing/[id]` + * quotes the tenant's actual rate rather than letting an admin free-type one + * that provisioning would refuse. + * + * One thing this control has to be honest about: a self-serve buyer has no + * Stripe account yet — only the restaurant can create one, through Stripe's + * own hosted onboarding, which cannot be pre-filled here. `provision-tenant.sh` + * refuses `online-payments` without a `stripe_account`, so whatever is picked + * below is a PREFERENCE recorded now; the module (and any rate with it) is + * deferred to a second registry PR after that onboarding completes + * (`splitDeferredModules`, mirrored on the admin side by `PaymentsPendingPanel` + * / `isPaymentsPending`). `deferredNote` says that in the buyer's own words. + */ +export default function PaymentsModeChoice({ + hasOnlinePayments, + mode, + onChange, +}: Readonly<{ + hasOnlinePayments: boolean; + mode: PaymentsMode; + onChange: (mode: PaymentsMode) => void; +}>) { + const t = useTranslations("signup.configurator.paymentsMode"); + if (!hasOnlinePayments) return null; + + // Render nothing numeric at 0 bps — commission would cost nothing no matter + // the turnover, which `crossoverCentsPerMonth` distinguishes from "very + // high" by returning null. DEFAULT_COMMISSION_BPS is never 0, so this is + // reached in practice, but the guard stays the same shape as every other + // caller of this function. + const crossover = crossoverCentsPerMonth(DEFAULT_COMMISSION_BPS, ONLINE_PAYMENTS_PRICE_CENTS); + const percent = formatCommissionPercent(DEFAULT_COMMISSION_BPS); + + return ( +
    + {t("title")} +
    + {/* htmlFor + a hint OUTSIDE the label, rather than a wrapping
    + {crossover !== null && ( +

    + {t("crossover", { percent, amount: eur(crossover) })} +

    + )} +

    {t("deferredNote")}

    +
    + ); +} diff --git a/components/SignupConfigurator.tsx b/components/SignupConfigurator.tsx index 1877163..196a64a 100644 --- a/components/SignupConfigurator.tsx +++ b/components/SignupConfigurator.tsx @@ -5,6 +5,8 @@ import { useTranslations } from "next-intl"; import { MODULES, BUNDLES, extraLanguageCount, quoteModules, type ModuleId } from "@/lib/module-catalog"; import { TEMPLATES, TENANT_CURRENCIES, TENANT_LANGUAGES } from "@/lib/tenant-options"; import { eur } from "@/lib/format"; +import { paymentsModeQuote, type PaymentsMode } from "@/lib/payments-pricing"; +import PaymentsModeChoice from "./PaymentsModeChoice"; /** * Public product configurator on /signup (SOFRA-ONBOARDING-PLAN O1). @@ -22,6 +24,7 @@ export default function SignupConfigurator() { const t = useTranslations("signup.configurator"); const [modules, setModules] = useState([]); const [languages, setLanguages] = useState(["en"]); + const [paymentsMode, setPaymentsMode] = useState("flat"); const toggle = (list: T[], value: T, on: boolean): T[] => on ? [...list, value] : list.filter((v) => v !== value); @@ -33,6 +36,10 @@ export default function SignupConfigurator() { const quote = quoteModules(selection); const bundle = BUNDLES.find((b) => b.id === quote.bundle); const saving = quote.aLaCarteCents - quote.monthlyCents; + // Meaningless without the module — PaymentsModeChoice reads this to decide + // whether to render at all, same reading sanitizeSignupConfiguration gives it. + const hasOnlinePayments = modules.includes("online-payments"); + const total = paymentsModeQuote(quote.monthlyCents, paymentsMode, hasOnlinePayments); const optionalModules = MODULES.filter( (m) => m.id !== "core" && m.id !== "extra-languages" && m.sellable !== false, @@ -82,6 +89,12 @@ export default function SignupConfigurator() { + +
    {t("theme")}
    @@ -180,13 +193,12 @@ export default function SignupConfigurator() {
    {/* Carried so the founder sees what the lead was actually shown; the server - re-computes it from the catalog and never trusts this value. */} - + re-computes it from the catalog AND the payments mode, and never + trusts this value. */} + - - {eur(quote.monthlyCents)} - + {eur(total)} {t("perMonth")} {bundle && saving > 0 && (

    diff --git a/components/control/AdminPaymentsModePanel.tsx b/components/control/AdminPaymentsModePanel.tsx new file mode 100644 index 0000000..edb4ed9 --- /dev/null +++ b/components/control/AdminPaymentsModePanel.tsx @@ -0,0 +1,44 @@ +import type { RegistryTenant } from "@/lib/tenant-registry"; +import { updatePaymentsModeAction } from "@/lib/actions/provisioning-actions"; +import type { PaymentsMode } from "@/lib/payments-pricing"; +import PaymentsModePanel from "./PaymentsModePanel"; + +/** + * The OWNER's binding of the shared payments-mode panel + * (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2b) — `/admin/billing/[id]`. + * + * The binding is what differs between the two surfaces, so it is what gets a file + * of its own: which action the form posts to, which field names the tenant, and + * which vocabulary the reader gets. The founder may name ANY tenant, so the field + * is the slug itself and `requireAdmin()` in the action is the whole authorization + * story. Its partner counterpart (`ClientPaymentsModePanel`) cannot do that, and + * the two files sitting side by side is how that difference stays visible. + */ +export default function AdminPaymentsModePanel({ + locale, + tenantSlug, + billingMode, + billingBps, + registryTenant, + registryReadable, +}: { + readonly locale: string; + readonly tenantSlug: string; + readonly billingMode: PaymentsMode; + readonly billingBps: number; + readonly registryTenant: RegistryTenant | undefined; + readonly registryReadable: boolean; +}) { + return ( + + ); +} diff --git a/components/control/ClientPaymentsModePanel.tsx b/components/control/ClientPaymentsModePanel.tsx new file mode 100644 index 0000000..2b4aa9c --- /dev/null +++ b/components/control/ClientPaymentsModePanel.tsx @@ -0,0 +1,57 @@ +import type { ClientTenantView } from "@/lib/client-tenant"; +import { asPaymentsMode } from "@/lib/payments-pricing"; +import { updateClientPaymentsModeAction } from "@/lib/actions/partner-payments-actions"; +import PaymentsModePanel from "./PaymentsModePanel"; + +/** The plan fields this panel reads — `TenantBilling`, narrowed to two columns. */ +interface BillingModeRow { + readonly paymentsMode: string; + readonly paymentsCommissionBps: number; +} + +/** + * The PARTNER's binding of the shared payments-mode panel + * (SOFRA-PAYMENTS-PRICING-MODE-PLAN S4) — `/dashboard/clients/[id]`. + * + * The field it posts is the CLIENT ID, never the tenant slug: a slug is a global + * name in the deploy repo's registry, and a form that carried one would be handing + * a partner the identifier of somebody else's restaurant to submit. + * `updateClientPaymentsModeAction` re-loads the client scoped by `partnerId` and + * takes the slug off that row — this component's job is only to make sure a slug + * is never in the browser's hands to begin with. + * + * Renders NOTHING unless the tenant is `live` in the registry and the client has a + * plan. Both are fail-quiet refusals, not oversights: with no registry entry there + * is nothing to amend and no eligibility to check (a rate without + * `online-payments` + `stripe_account` is refused by `provision-tenant.sh` before + * the database), and with no plan there is no billing intent for a mode to be a + * property OF. The unreadable-registry case takes the same direction the rest of + * this dashboard does — say nothing, rather than make a claim about a tenant's + * money out of our own outage. + */ +export default function ClientPaymentsModePanel({ + locale, + clientId, + view, + billing, +}: { + readonly locale: string; + readonly clientId: string; + readonly view: ClientTenantView; + readonly billing: BillingModeRow | null | undefined; +}) { + if (view.kind !== "live" || !billing) return null; + + return ( + + ); +} diff --git a/components/control/CommissionEarningsPanel.tsx b/components/control/CommissionEarningsPanel.tsx new file mode 100644 index 0000000..af38d09 --- /dev/null +++ b/components/control/CommissionEarningsPanel.tsx @@ -0,0 +1,131 @@ +import { getTranslations } from "next-intl/server"; +import { db } from "@/lib/db"; +import { money, shortDate } from "@/lib/format"; +import { commissionEarnings, type FeeMovement } from "@/lib/commission-earnings"; + +/** + * What this tenant's commission actually earned, net of what was returned + * (workspace docs/plans/BACKLOG.md, the second commission blocker). + * + * It lives on `/admin/billing/[id]` next to the payments-mode control on + * purpose: "what rate is this tenant on" and "what did that rate collect" + * belong side by side, and that adjacency is what makes a wrong rate visible. + * NOT on `/admin/tenants` (that is the registry/enforcement view) and NOT on + * `/dashboard/*` (partner-facing — this is Sofra's own revenue). + * + * A component rather than page code because the page is at its §4 limit, and a + * server component because the only inputs are two indexed reads and a pure + * function. Out of scope for this slice, deliberately: a roll-up across all + * tenants, and any period control. The per-tenant number is what invoicing needs. + */ +export default async function CommissionEarningsPanel({ + locale, + stripeAccount, + registryReadable, + now = new Date(), +}: { + readonly locale: string; + /** The registry entry's `stripe_account`, the only join key these tables have. */ + readonly stripeAccount: string | undefined; + readonly registryReadable: boolean; + /** Injected so the window is decidable in a test rather than raced. */ + readonly now?: Date; +}) { + const t = await getTranslations({ locale, namespace: "control.admin.commissionEarnings" }); + + // A fixed, server-computed window: the previous calendar month and the current + // one, UTC (Stripe's own clock is UTC epoch seconds — mixing in a box-local + // month boundary would move fees between periods depending on where the + // container runs). Half-open: `>= from`, `< to`. + const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)); + const to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)); + + const account = stripeAccount?.trim(); + // The SQL window is an index-using pre-filter only; `commissionEarnings` + // re-applies the same bounds and is the authority on the boundary rule. The + // redundancy is deliberate — a wrong query can then only ever hand it a + // superset, never a silently truncated set. + const [earnedRows, refundedRows] = account + ? await Promise.all([ + db.stripeApplicationFee.findMany({ + where: { connectedAccountId: account, feeCreatedAt: { gte: from, lt: to } }, + select: { amount: true, currency: true, feeCreatedAt: true, chargeId: true }, + }), + db.stripeFeeRefund.findMany({ + where: { connectedAccountId: account, createdAt: { gte: from, lt: to } }, + select: { + amount: true, + currency: true, + createdAt: true, + feeRefundedAt: true, + chargeId: true, + }, + }), + ]) + : [[], []]; + + const earned: FeeMovement[] = earnedRows.map((r) => ({ + amount: r.amount, + currency: r.currency, + at: r.feeCreatedAt, + chargeId: r.chargeId, + })); + const refunded: FeeMovement[] = refundedRows.map((r) => ({ + amount: r.amount, + currency: r.currency, + // Stripe's clock when we have it, ours when we do not — the rows the + // fee-refund runbook created on staging predate the column and cannot be + // backfilled. + at: r.feeRefundedAt ?? r.createdAt, + chargeId: r.chargeId, + })); + + const result = commissionEarnings({ registryReadable, stripeAccount, earned, refunded, from, to }); + + return ( +

    +

    {t("title")}

    +

    {t("intro")}

    + {result.kind === "unavailable" ? ( + // No number at all, in EITHER reason — an unreadable registry is our + // outage and must never be published as a claim about a tenant's money. +

    + {result.reason === "registryUnavailable" ? t("registryUnavailable") : t("noAccount")} +

    + ) : ( + <> +

    + {t("period", { from: shortDate(from), to: shortDate(to) })} +

    + {result.totals.length === 0 ? ( + // A DIFFERENT state from the two above, and it must read differently: + // "we are watching this account and it collected nothing" is a fact. +

    {t("empty")}

    + ) : ( +
      + {result.totals.map((total) => ( +
    • + + {t("net")}: {money(total.netMinor, total.currency)} + + + {t("earned")}: {money(total.earnedMinor, total.currency)} ·{" "} + {t("refunded")}: {money(total.refundedMinor, total.currency)} ·{" "} + {t("fees", { count: total.feeCount })} + +
    • + ))} +
    + )} + {result.unmatchedRefundCount > 0 && ( + // Explains a negative net instead of clamping it away. Real on day + // one: staging holds refund rows whose fees predate this table. +

    + {t("unmatchedRefunds", { count: result.unmatchedRefundCount })} +

    + )} + + )} +
    + ); +} diff --git a/components/control/OpenCheckoutPanel.tsx b/components/control/OpenCheckoutPanel.tsx new file mode 100644 index 0000000..4cceb01 --- /dev/null +++ b/components/control/OpenCheckoutPanel.tsx @@ -0,0 +1,41 @@ +import { getTranslations } from "next-intl/server"; +import CopyField from "@/components/control/CopyField"; + +/** + * The copyable link for a plan's still-open first checkout, on the founder's + * billing detail page. + * + * Extracted for the reason `PlanPaymentsList` was — the page reached its §4 + * length limit — and this section was the next most mechanical one on it: it + * needs the payment list and nothing else the page computes, and it decides one + * thing (is there an unpaid checkout still worth sending someone). + * + * "Open" is `open` OR `pending`: Mollie reports a checkout the payer has landed + * on but not completed as `pending`, and that link is still the one to resend. + * Renders nothing at all when there is none — an absent link is not a state + * worth a sentence. + */ +export default async function OpenCheckoutPanel({ + locale, + payments, +}: { + readonly locale: string; + readonly payments: ReadonlyArray<{ + readonly checkoutUrl: string | null; + readonly status: string; + }>; +}) { + const open = payments.find((p) => p.checkoutUrl && (p.status === "open" || p.status === "pending")); + if (!open?.checkoutUrl) return null; + + const t = await getTranslations({ locale, namespace: "control.admin.billingDetail" }); + return ( +
    +

    {t("checkoutTitle")}

    +

    {t("checkoutIntro")}

    +
    + +
    +
    + ); +} diff --git a/components/control/PaymentsModeForm.tsx b/components/control/PaymentsModeForm.tsx new file mode 100644 index 0000000..1144bfd --- /dev/null +++ b/components/control/PaymentsModeForm.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { useTranslations } from "next-intl"; +import type { + PaymentsModeActionState, + PaymentsModeTarget, +} from "@/lib/actions/payments-mode-change"; +import { + DEFAULT_COMMISSION_BPS, + MAX_COMMISSION_BPS, + ONLINE_PAYMENTS_PRICE_CENTS, + crossoverCentsPerMonth, + formatCommissionPercent, + type PaymentsMode, +} from "@/lib/payments-pricing"; +import { eur } from "@/lib/format"; +import type { CommissionEligibility } from "@/lib/commission-eligibility"; +import ActionError from "./ActionError"; + +/** + * Set a tenant's payments mode + rate (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2b, S4) — + * which opens/updates a registry PR, it does not flip anything live (see the + * pending note above this form). + * + * ONE form for both surfaces. The `action` and the identifying `target` field are + * props rather than an import, because the owner and the partner name a tenant in + * two DIFFERENT ways and that difference is the security boundary: the owner posts + * a `tenantSlug` (they may name any tenant), a partner posts a `clientId` and the + * server reads the slug off the row it loaded scoped by `partnerId`. A partner + * form that carried a slug field would be a slug a partner can choose, so this + * component never invents the field name — it renders the one it is given. + * + * A plain `
    `, same as every other server-action form in this + * app: it works as an ordinary POST with no JavaScript. The mode/rate state here + * is a CONVENIENCE on top of that, not a replacement — an admin with no + * JavaScript sees the same two fields, pre-filled with the tenant's current + * intent, and can still type a new mode/rate directly; JS only adds the live + * crossover preview and a sensible default when switching TO commission. + * The rate field is never `disabled`: a disabled input is dropped from the + * submitted `FormData` entirely, which would break `mode=flat` submissions the + * moment the rate field was left at a stale non-zero value — so ineligibility + * disables only the COMMISSION RADIO itself, never the rate field. + */ +export default function PaymentsModeForm({ + target, + submitAction, + namespace, + currentMode, + currentBps, + eligibility, +}: Readonly<{ + target: PaymentsModeTarget; + submitAction: (prev: PaymentsModeActionState, formData: FormData) => Promise; + namespace: string; + currentMode: PaymentsMode; + currentBps: number; + eligibility: CommissionEligibility; +}>) { + const t = useTranslations(namespace); + const [state, action, pending] = useActionState( + submitAction, + {}, + ); + const [mode, setMode] = useState(currentMode); + // The RAW current rate, never defaulted here — a no-op resubmit (admin clicks + // Save without changing anything) must post exactly what is already stored, or + // a flat tenant (bps 0, the overwhelming common case) would fail the server's + // own "flat mode carries no commission rate" check on every unchanged save. + // DEFAULT_COMMISSION_BPS only ever applies inside `handleModeChange`, on an + // ACTIVE switch to commission — matching the plan's own instruction. + const [bps, setBps] = useState(currentBps); + + // A radio the admin cannot currently pick MAY still be the one already in + // effect (an entry can become ineligible AFTER being switched — its account + // could be removed by hand) — so only a NEW selection is refused, never the + // tenant's own current mode, or there would be no way left to move it back. + const commissionDisabled = !eligibility.eligible && currentMode !== "commission"; + + const handleModeChange = (next: PaymentsMode) => { + setMode(next); + if (next === "flat") setBps(0); + else if (bps === 0) setBps(DEFAULT_COMMISSION_BPS); + }; + + const preview = mode === "commission" ? crossoverCentsPerMonth(bps, ONLINE_PAYMENTS_PRICE_CENTS) : null; + + return ( + + +
    + {t("modeLabel")} + + + {!eligibility.eligible && ( +

    + {t( + eligibility.reason === "registryUnavailable" + ? "notEligibleRegistryUnavailable" + : "notEligibleNotPaired", + )} +

    + )} +
    + + {preview !== null && ( +

    + {t("crossover", { percent: formatCommissionPercent(bps), amount: eur(preview) })} +

    + )} +
    + +
    + + {/* The acknowledgement is unconditional on success; the LINK is not. The PR + lives in a private repo — the founder can open it, a partner cannot, and a + 404 is worse news than no link at all — so the partner action returns no + `prUrl` and this renders the sentence alone. */} + {state.ok && !state.alreadySet && ( +
    + + {t("prOpened")} + + {state.prUrl && ( + + {state.prUrl} + + )} +
    + )} + {state.ok && state.alreadySet && ( + + {t("alreadySet")} + + )} + + ); +} diff --git a/components/control/PaymentsModePanel.tsx b/components/control/PaymentsModePanel.tsx new file mode 100644 index 0000000..8539834 --- /dev/null +++ b/components/control/PaymentsModePanel.tsx @@ -0,0 +1,112 @@ +import { getTranslations } from "next-intl/server"; +import { eur } from "@/lib/format"; +import { + ONLINE_PAYMENTS_PRICE_CENTS, + crossoverCentsPerMonth, + formatCommissionPercent, + type PaymentsMode, +} from "@/lib/payments-pricing"; +import { effectivePaymentsMode } from "@/lib/payments-mode-effective"; +import type { + PaymentsModeActionState, + PaymentsModeTarget, +} from "@/lib/actions/payments-mode-change"; +import { commissionEligibility } from "@/lib/commission-eligibility"; +import type { RegistryTenant } from "@/lib/tenant-registry"; +import PaymentsModeForm from "./PaymentsModeForm"; + +/** + * One tenant's payments pricing mode (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2b, S4) — + * the owner's `/admin/billing/[id]` control AND, with a different `target`, + * `action` and vocabulary, the partner's control for a client they sold. Server + * component: it renders the facts the page already has and decides nothing about + * submission; `PaymentsModeForm` is the only client code here. + * + * Shared rather than mirrored on purpose. The plan (§2) requires EVERY surface + * that offers the switch to state the crossover turnover, and a second copy of + * this panel is how one of them would quietly stop doing that. The two audiences + * still read different words — `namespace` selects the vocabulary — but they + * cannot be shown different FACTS. + * + * The headline figure is the EFFECTIVE mode (`effectivePaymentsMode`), never + * `billingMode`/`billingBps` directly — those are only what we INTEND to bill, + * and the plan is explicit that billing must follow what the box actually + * enforces, not what Prisma alone says. `billingMode`/`billingBps` still drive + * the FORM's defaults, because that is the value `updatePaymentsModeAction` + * compares a new submission against. + */ +export default async function PaymentsModePanel({ + locale, + namespace, + target, + submitAction, + billingMode, + billingBps, + registryTenant, + registryReadable, +}: { + readonly locale: string; + /** Which message block this audience reads — the panel and its form share it. */ + readonly namespace: string; + readonly target: PaymentsModeTarget; + readonly submitAction: ( + prev: PaymentsModeActionState, + formData: FormData, + ) => Promise; + readonly billingMode: PaymentsMode; + readonly billingBps: number; + readonly registryTenant: RegistryTenant | undefined; + readonly registryReadable: boolean; +}) { + const t = await getTranslations({ locale, namespace }); + + const effective = effectivePaymentsMode({ + intended: billingMode, + registryBps: registryTenant?.payments_commission_bps, + registryReadable, + }); + // The rate actually enforced, not necessarily the one we intend — mirrors how + // `effectivePaymentsMode` itself falls back to the intent when the registry + // cannot be read at all, so this can never disagree with `effective.mode`. + const effectiveBps = registryReadable ? (registryTenant?.payments_commission_bps ?? 0) : billingBps; + const crossover = crossoverCentsPerMonth(effectiveBps, ONLINE_PAYMENTS_PRICE_CENTS); + const eligibility = commissionEligibility({ registryReadable, tenant: registryTenant }); + + return ( +
    +

    {t("title")}

    +

    + {effective.mode === "commission" + ? t("commissionSummary", { percent: formatCommissionPercent(effectiveBps) }) + : t("flatSummary")} +

    + {effective.pending && ( + // , not

    : it carries the same implicit ARIA role + // while being the element browsers and assistive tech already understand + // (Sonar S6819). `block` because is inline by default and this is + // a paragraph-shaped notice. + + {t("pendingNote")} + + )} + {/* Render nothing numeric at 0 bps — commission costs nothing no matter the + turnover, which is a different statement from "the crossover is very + high" and must never be printed as one. */} + {crossover !== null && ( +

    + {t("crossover", { percent: formatCommissionPercent(effectiveBps), amount: eur(crossover) })} +

    + )} +
    + +
    +
    + ); +} diff --git a/lib/actions/partner-payments-actions.ts b/lib/actions/partner-payments-actions.ts new file mode 100644 index 0000000..0f54ad6 --- /dev/null +++ b/lib/actions/partner-payments-actions.ts @@ -0,0 +1,90 @@ +"use server"; + +// The partner's payments-mode control for their OWN clients +// (SOFRA-PAYMENTS-PRICING-MODE-PLAN S4) — the same amendment the founder makes on +// /admin/billing/[id], reachable by the person who actually owns the commercial +// relationship, and reachable for nothing else. +// +// THE WHOLE RISK OF THIS FILE IS THE AUTHORIZATION BOUNDARY, so it is built to make +// the dangerous shape impossible rather than merely absent: +// +// 1. The form submits a CLIENT ID, never a tenant slug. A slug is a global name in +// the deploy repo's registry — a partner who could post one would be posting the +// identifier of somebody else's restaurant into a registry editor. +// 2. `requirePartner()` first, then `ownClient(partner.id, id)` — the one query +// scoped by `partnerId`, shared with `partner-change-actions.ts`. +// 3. The `tenantSlug` handed onward is read OFF THAT ROW. There is no code path +// here in which a partner-supplied string reaches `openCommissionChangePr`. +// +// Everything after that point is `applyPaymentsModeChange`, the same sequence the +// owner's action runs (registry PR first, Prisma intent second, then the audit row) — +// one implementation so the two surfaces cannot drift apart. +// +// This does NOT cross the ADR-003/007 boundary the change-request form respects. The +// registry is still only ever edited in the deploy repo, in a PR, merged by a human: +// what a partner gets here is the ability to PROPOSE that PR for a tenant they sold, +// not to write to a box. + +import { revalidatePath } from "next/cache"; +import { requirePartner } from "@/lib/rbac"; +import { ownClient } from "@/lib/client-access"; +import { provisioningConfigured } from "@/lib/provisioning"; +import { paymentsModeChangeSchema } from "@/lib/validation"; +import { + applyPaymentsModeChange, + type PaymentsModeActionState, +} from "./payments-mode-change"; + +/** + * Set one of the calling partner's clients onto flat or commission pricing. + * + * Refuses, in order and before touching anything: a caller who is not a partner + * (`requirePartner()` throws), a client that is not theirs (`clientNotFound` — the + * SAME answer another partner's real client gets, so this cannot be used to learn + * which restaurants exist), and a client with no tenant yet (`clientNotProvisioned`: + * there is no registry entry to amend, and inventing a slug from the client's name + * is exactly the mistake this action is shaped to prevent). + */ +export async function updateClientPaymentsModeAction( + _prev: PaymentsModeActionState, + formData: FormData, +): Promise { + const partner = await requirePartner(); + if (!provisioningConfigured()) return { error: "provisioningNotConfigured" }; + + // Read as a string rather than `String(...)`: a FormData value can be a File, and + // stringifying one yields "[object Object]" — a lookup that would then miss rather + // than refuse (Sonar S6551, same shape as `requestClientChangeAction`). + const raw = formData.get("clientId"); + const clientId = typeof raw === "string" ? raw : ""; + const client = await ownClient(partner.id, clientId); + if (!client) return { error: "clientNotFound" }; + if (!client.tenantSlug) return { error: "clientNotProvisioned" }; + + const parsed = paymentsModeChangeSchema.safeParse({ + // FROM THE ROW. The form has no slug field at all, and if it grew one it would + // be ignored here — which is the property this line exists to hold. + tenantSlug: client.tenantSlug, + mode: formData.get("mode"), + commissionBps: formData.get("commissionBps"), + }); + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "invalidInput" }; + + const outcome = await applyPaymentsModeChange({ + actorId: partner.id, + initiator: "partner", + tenantSlug: client.tenantSlug, + mode: parsed.data.mode, + commissionBps: parsed.data.commissionBps, + // The client is the partner's own name for this tenant, and the join the audit + // row would otherwise have to be reconstructed through by hand. + meta: { clientId: client.id }, + }); + + revalidatePath(`/dashboard/clients/${client.id}`); + // The PR URL is withheld from this surface on purpose. It points into the PRIVATE + // deploy repo, which a partner cannot open — a link that 404s is worse news than no + // link — and the URL is not lost: it is on the audit row and in the plan's + // `TenantBilling` record. The partner gets the acknowledgement, not our plumbing. + return outcome.state.prUrl ? { ok: true } : outcome.state; +} diff --git a/lib/actions/payments-mode-change.ts b/lib/actions/payments-mode-change.ts new file mode 100644 index 0000000..4a349ff --- /dev/null +++ b/lib/actions/payments-mode-change.ts @@ -0,0 +1,181 @@ +import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; +import { openCommissionChangePr } from "@/lib/registry-commission-pr"; +import { ProvisioningApiError, ProvisioningNotConfiguredError } from "@/lib/provisioning"; +import type { PaymentsMode } from "@/lib/payments-pricing"; + +// A payments-mode change, in the three pieces every surface that offers one shares: +// propose (registry PR), record (Prisma intent), and the sequence that runs them in +// that order and audits the result. Lifted out of the server actions so each action +// reads as what it uniquely is — an AUTHORIZATION decision plus a form parse — rather +// than as two nested error funnels, and so `provisioning-actions.ts` stays under the +// §4 limit. +// +// Deliberately NOT a "use server" module: exporting `applyPaymentsModeChange` from one +// would publish "change any tenant's money configuration" as a callable endpoint whose +// first argument is the actor id. Same reason `lib/client-access.ts` is not one. + +type CommissionProposal = + | { ok: true; prUrl: string | null } + | { ok: false; error: string }; + +/** + * Open the registry PR, mapping every failure onto an action-state error. + * `prUrl` is null when the registry already carried this rate — there is nothing + * to propose, so no empty PR is opened, and the caller still records the intent. + */ +export async function proposeCommissionChange( + tenantSlug: string, + commissionBps: number, +): Promise { + try { + const outcome = await openCommissionChangePr(tenantSlug, commissionBps); + return { ok: true, prUrl: outcome.alreadySet ? null : outcome.prUrl }; + } catch (e) { + if (e instanceof ProvisioningNotConfiguredError) { + return { ok: false, error: "provisioningNotConfigured" }; + } + if (e instanceof ProvisioningApiError) return { ok: false, error: e.message }; + console.error("updatePaymentsModeAction: openCommissionChangePr failed", e); + return { ok: false, error: "paymentsModeChangeFailed" }; + } +} + +/** + * Record what we asked for, AFTER the proposal exists. + * + * THROWS rather than returning an error state, deliberately. By this point the PR + * is open (or the rate already matched), so the failure leaves an orphan PR — not + * an orphan CHARGE — and a swallowed action-state error would lose the PR URL, + * which is the one thing a human needs to reconcile this by hand. + */ +export async function recordPaymentsModeIntent(args: { + tenantSlug: string; + mode: string; + commissionBps: number; + prUrl: string | null; +}): Promise { + const { tenantSlug, mode, commissionBps, prUrl } = args; + try { + await db.tenantBilling.update({ + where: { tenantSlug }, + data: { paymentsMode: mode, paymentsCommissionBps: commissionBps }, + }); + } catch (e) { + const where = prUrl ? ` (${prUrl})` : " (rate already matched, no PR opened)"; + throw new Error( + `payments mode change for '${tenantSlug}' was proposed${where} but recording ` + + `the intent failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } +} + +/** `error` is a message key in `control.errors` (rendered by ); + * GitHub API errors pass through raw. `prUrl` when a PR was opened; + * `alreadySet` when the registry already carried the requested rate and no PR + * was needed. Declared here rather than beside either action: BOTH surfaces + * (owner `/admin`, partner `/dashboard`) return exactly this shape, and one + * form component renders it for both. */ +export type PaymentsModeActionState = { + error?: string; + ok?: boolean; + prUrl?: string; + alreadySet?: boolean; +}; + +/** + * How a payments-mode form names the tenant it is about — the hidden field it + * posts, and the value in it. + * + * Two shapes because the difference IS the authorization boundary (S4): the owner + * posts a `tenantSlug` and may name any tenant; a partner posts a `clientId` and + * the server reads the slug off the row it loaded scoped by `partnerId`. Modelled + * as a union rather than a free `name`/`value` pair so a third, invented field + * name cannot be rendered by mistake. + */ +export type PaymentsModeTarget = + | { field: "tenantSlug"; value: string } + | { field: "clientId"; value: string }; + +/** + * Who asked for the change (S4). + * + * Recorded on the audit row as its own FIELD rather than inferred at read time + * from the actor's role: a user's role can change after the fact, and the + * question the row has to answer years later is "who decided this", not "what is + * that account today". The actor id is the human either way — a partner-initiated + * change is audited against the PARTNER, never against the founder. + */ +export type PaymentsModeInitiator = "owner" | "partner"; + +export interface PaymentsModeChangeOutcome { + state: PaymentsModeActionState; + /** `TenantBilling.id` — the admin surface's own route parameter, returned so a + * caller can revalidate its page without a second query. Null when nothing was + * changed (no billing row, or the request was a no-op). */ + billingId: string | null; +} + +/** + * The whole change, once the CALLER has established that its actor may make it: + * find the plan, refuse a no-op, open the registry PR, record the intent, audit. + * + * ONE function for both surfaces on purpose (S4). The owner's `/admin` action and + * the partner's `/dashboard` action differ only in how they arrive at a + * `tenantSlug` they are allowed to touch — everything after that point is the same + * sequence, and two copies of it would be two things to keep in step, one of which + * would eventually stop opening the PR before writing Prisma. + * + * It is NOT an authorization boundary and cannot be one: it is handed a slug and + * cannot tell whose it is. Callers guard first (`requireAdmin()`, or + * `requirePartner()` + `ownClient()` and the slug read OFF that row) — the same + * split, and for the same reason, as `lib/client-tenant.ts`. + */ +export async function applyPaymentsModeChange(args: { + actorId: string; + initiator: PaymentsModeInitiator; + tenantSlug: string; + mode: PaymentsMode; + commissionBps: number; + /** Extra audit context, e.g. the `clientId` a partner acted through. */ + meta?: Record; +}): Promise { + const { actorId, initiator, tenantSlug, mode, commissionBps } = args; + + const billing = await db.tenantBilling.findUnique({ where: { tenantSlug } }); + if (!billing) return { state: { error: "billingNotFound" }, billingId: null }; + if (billing.paymentsMode === mode && billing.paymentsCommissionBps === commissionBps) { + return { state: { error: "paymentsModeUnchanged" }, billingId: null }; + } + const { paymentsMode: oldMode, paymentsCommissionBps: oldBps } = billing; + + // ORDER MATTERS, and is commented because it is easy to get backwards: open + // the registry PR FIRST, write the Prisma intent SECOND. The PR is the + // proposal that reaches a human; `TenantBilling` is our own record of what + // we asked for. Writing Prisma first and having the PR call fail afterwards + // would record an intent we never actually proposed to anyone. This order's + // failure mode is the recoverable one instead — an open PR the intent + // doesn't point at yet — never the other way round. + const proposal = await proposeCommissionChange(tenantSlug, commissionBps); + if (!proposal.ok) return { state: { error: proposal.error }, billingId: billing.id }; + const { prUrl } = proposal; + + await recordPaymentsModeIntent({ tenantSlug, mode, commissionBps, prUrl }); + + await audit(actorId, "tenant.paymentsMode.changed", "TenantBilling", billing.id, { + tenantSlug, + initiator, + oldMode, + oldBps, + newMode: mode, + newBps: commissionBps, + prUrl, + ...args.meta, + }); + + // prUrl is null exactly when the registry already carried this rate, so no PR was opened. + return { + state: prUrl ? { ok: true, prUrl } : { ok: true, alreadySet: true }, + billingId: billing.id, + }; +} diff --git a/lib/actions/provisioning-actions.ts b/lib/actions/provisioning-actions.ts index b4f49a9..ffdd199 100644 --- a/lib/actions/provisioning-actions.ts +++ b/lib/actions/provisioning-actions.ts @@ -3,7 +3,12 @@ // Admin-only: propose a NEW tenant by opening a registry PR on the deploy repo // (ADR-012, git-native trigger). Returns the PR URL; a founder reviews + merges, // the change syncs to the box, then the provision-tenant Action runs the script. +// +// Also holds `updatePaymentsModeAction` (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2a) — +// the AMENDMENT counterpart to `openProvisioningPrAction` below: same registry-PR +// mechanism, applied to a tenant that must already exist rather than a new one. +import { revalidatePath } from "next/cache"; import { requireAdmin } from "@/lib/rbac"; import { audit } from "@/lib/audit"; import { db } from "@/lib/db"; @@ -11,12 +16,14 @@ import { slugProvisionVerdict } from "@/lib/provisioning-facts"; import { readProvisionForm } from "@/lib/provision-form-input"; import { loadTenantRegistry } from "@/lib/tenant-registry"; import { checkSlug } from "@/lib/slug-availability"; +import { paymentsModeChangeSchema } from "@/lib/validation"; import { openProvisioningPr, provisioningConfigured, ProvisioningNotConfiguredError, ProvisioningApiError, } from "@/lib/provisioning"; +import { applyPaymentsModeChange, type PaymentsModeActionState } from "./payments-mode-change"; /** `error` is a message key in `control.errors` (rendered by ); * GitHub API errors pass through raw. `prUrl` on success. */ @@ -91,3 +98,41 @@ export async function openProvisioningPrAction( return { error: "provisionFailed" }; } } + +/** + * Amend an EXISTING tenant's payments mode + commission rate as the OWNER + * (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2a/S2b). + * + * The founder may name any tenant, so the slug is read straight from the form and + * `requireAdmin()` is the whole authorization story. The partner counterpart + * (S4, `lib/actions/partner-payments-actions.ts`) may not, and takes the slug off + * a row it loaded scoped by `partnerId` instead — everything AFTER that point is + * the shared `applyPaymentsModeChange`. + */ +export async function updatePaymentsModeAction( + _prev: PaymentsModeActionState, + formData: FormData, +): Promise { + const admin = await requireAdmin(); + if (!provisioningConfigured()) return { error: "provisioningNotConfigured" }; + + const parsed = paymentsModeChangeSchema.safeParse({ + tenantSlug: formData.get("tenantSlug"), + mode: formData.get("mode"), + commissionBps: formData.get("commissionBps"), + }); + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "invalidInput" }; + const { tenantSlug, mode, commissionBps } = parsed.data; + + const { state, billingId } = await applyPaymentsModeChange({ + actorId: admin.id, + initiator: "owner", + tenantSlug, + mode, + commissionBps, + }); + + revalidatePath("/admin/billing"); + if (billingId) revalidatePath(`/admin/billing/${billingId}`); + return state; +} diff --git a/lib/commission-earnings.ts b/lib/commission-earnings.ts new file mode 100644 index 0000000..c2ba161 --- /dev/null +++ b/lib/commission-earnings.ts @@ -0,0 +1,153 @@ +// What a tenant's commission actually EARNED, net of what was returned +// (workspace docs/plans/BACKLOG.md, the second commission blocker; ADR-011's +// second recorded consequence, "no commission reporting surface"). +// +// Two tables, one join key, no foreign key: `StripeApplicationFee` (money +// earned) and `StripeFeeRefund` (money returned) both carry a bare +// `connectedAccountId`, and the registry maps a tenant's `stripe_account` back +// to a slug at READ time (ADR-007). This module is the arithmetic between the +// rows and the panel. +// +// Pure: no DB, no network, no env, no clock — the window is always passed in, +// same discipline as lib/trial.ts and lib/stripe-signature.ts. + +/** One recorded movement — a fee earned, or a fee returned. */ +export type FeeMovement = { + /** Minor units, in `currency`. */ + amount: number; + currency: string; + /** Stripe's own clock for this movement. */ + at: Date; + chargeId: string; +}; + +export type CurrencyTotal = { + currency: string; + earnedMinor: number; + refundedMinor: number; + /** NOT clamped at zero. See `commissionEarnings`. */ + netMinor: number; + feeCount: number; + refundCount: number; +}; + +export type CommissionEarnings = + | { kind: "unavailable"; reason: "registryUnavailable" | "noStripeAccount" } + | { + kind: "ready"; + /** One entry per currency present. Never a mixed sum — there is + * deliberately no scalar field to add a CHF fee to a EUR one in. */ + totals: readonly CurrencyTotal[]; + /** Refunds in this window whose charge has no earned row in it. Reported, + * never netted away. */ + unmatchedRefundCount: number; + }; + +/** + * Groups a tenant's fee movements by currency over a HALF-OPEN window + * (`at >= from && at < to`). + * + * Three properties the type itself enforces, each of them a failure mode: + * + * 1. **`totals` is a list, not a scalar.** A connected account can take charges + * in more than one currency (the registry's per-tenant `currency` is the + * normal case, not a guarantee), and Stripe denominates the fee per CHARGE. + * Summing a CHF fee into a EUR one is silent and wrong by the FX rate. + * 2. **`unavailable` carries a reason and NO numbers.** A caller physically + * cannot render `0` from an unreadable registry. This is the fail-quiet + * direction `effectivePaymentsMode`, `isPaymentsPending` and + * `commissionEligibility` all take: our outage must never be published as a + * claim about a tenant's money. + * 3. **`netMinor` is NOT clamped at zero.** A refund whose fee predates fee + * recording produces a negative net, and that is REAL on day one: staging + * already holds two `StripeFeeRefund` rows written by the fee-refund runbook + * and will hold zero `StripeApplicationFee` rows for them, because that table + * did not exist when they were written. Clamping would hide the pre-history + * in the one direction that costs money. `unmatchedRefundCount` is what lets + * the panel EXPLAIN the negative instead of tidying it away. + * + * "Account present, zero rows" is a third state, distinct from both unavailable + * reasons: `kind: "ready"` with empty `totals` says "we are watching this + * account and it has collected nothing", which is a fact. "We cannot see this + * account" is not. + */ +export function commissionEarnings(args: { + registryReadable: boolean; + stripeAccount: string | undefined; + earned: readonly FeeMovement[]; + refunded: readonly FeeMovement[]; + from: Date; + to: Date; +}): CommissionEarnings { + if (!args.registryReadable) return { kind: "unavailable", reason: "registryUnavailable" }; + // `.trim()` for the reason `missingPairedStripeAccount` does it: the box tests + // `-z`, which a whitespace-only value passes, so " " is not an account. + if (!args.stripeAccount?.trim()) return { kind: "unavailable", reason: "noStripeAccount" }; + + const inWindow = (m: FeeMovement) => + m.at.getTime() >= args.from.getTime() && m.at.getTime() < args.to.getTime(); + const earned = args.earned.filter(inWindow); + const refunded = args.refunded.filter(inWindow); + + const totals = new Map(); + const bucket = (currency: string): CurrencyTotal => { + // Lower-cased on both sides so "CHF" and "chf" can never be two rows. The + // write path pins the case already; this is what stops that from silently + // mattering if a row ever arrives from elsewhere. + const key = currency.toLowerCase(); + const existing = totals.get(key); + if (existing) return existing; + const fresh: CurrencyTotal = { + currency: key, + earnedMinor: 0, + refundedMinor: 0, + netMinor: 0, + feeCount: 0, + refundCount: 0, + }; + totals.set(key, fresh); + return fresh; + }; + + for (const fee of earned) { + const t = bucket(fee.currency); + t.earnedMinor += fee.amount; + t.feeCount += 1; + } + for (const refund of refunded) { + const t = bucket(refund.currency); + t.refundedMinor += refund.amount; + t.refundCount += 1; + } + for (const t of totals.values()) t.netMinor = t.earnedMinor - t.refundedMinor; + + const earnedCharges = new Set(earned.map((f) => f.chargeId)); + const unmatchedRefundCount = refunded.filter((r) => !earnedCharges.has(r.chargeId)).length; + + return { + kind: "ready", + // Sorted so the panel's row order is a property of the data, not of Map + // insertion — two tenants with the same currencies read the same way. + totals: [...totals.values()].sort((a, b) => a.currency.localeCompare(b.currency)), + unmatchedRefundCount, + }; +} + +/** + * Connected accounts that have recorded fees and that NO registry entry names — + * real revenue that appears on nobody's per-tenant page. + * + * Realistic causes: a tenant onboarded to Stripe before its registry entry was + * merged, a hand-edited entry, or a database restored across environments. + * + * Stripe ids are CASE-SENSITIVE, so unlike `currency` they are compared as-is: + * `Acct_A` is not `acct_A` and must not be reported as mapped. Whitespace-only + * registry values are dropped for the `-z` reason above. + */ +export function unmappedFeeAccounts( + accountsWithFees: readonly string[], + registryAccounts: readonly (string | undefined)[], +): string[] { + const known = new Set(registryAccounts.map((a) => a?.trim()).filter((a): a is string => Boolean(a))); + return [...new Set(accountsWithFees.filter((a) => !known.has(a)))].sort((a, b) => a.localeCompare(b)); +} diff --git a/lib/commission-eligibility.ts b/lib/commission-eligibility.ts new file mode 100644 index 0000000..7e48520 --- /dev/null +++ b/lib/commission-eligibility.ts @@ -0,0 +1,55 @@ +// Whether a tenant's registry entry may be switched to per-transaction +// commission AT ALL (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2b) — the admin form's +// own gate, checked BEFORE a proposal ever reaches `openCommissionChangePr`. +// +// `provision-tenant.sh` refuses a non-zero `payments_commission_bps` unless the +// SAME entry ALSO carries `online-payments` in `modules` AND a `stripe_account` +// (`lib/provisioning-module-pairing.ts`'s pairing rule, one field over) — and it +// refuses BEFORE the database, so a bad proposal here would not yield a tenant +// billed the wrong way, it would yield NO tenant at all on the next +// re-provision. The common case today is that no tenant carries a +// `stripe_account` yet, so this returns `false` for nearly every tenant — that +// is the correct, unsurprising answer, not a defect in this check. +// +// Reuses `missingPairedStripeAccount` for the account half rather than a second +// truthiness check on `stripe_account` — that function already knows a +// whitespace-only account counts as absent, the same way `provision-tenant.sh`'s +// `-z` test does. It answers a different question on its own (an +// ALREADY-inconsistent entry: the module bought with no account), so it is +// combined with an explicit membership check here rather than negated alone — +// a tenant that never bought `online-payments` at all is not "not missing" its +// account, it is simply not eligible, and the two must not collapse into the +// same `true`. +// +// Pure: no DB, no network, no env. + +import { missingPairedStripeAccount } from "./tenant-registry"; + +export type CommissionEligibility = + | { eligible: true } + | { eligible: false; reason: "registryUnavailable" | "notPaired" }; + +/** + * @param registryReadable False when the registry could not be read at all — + * the pairing cannot be checked, so commission stays refused rather than + * guessed at (the same fail-quiet direction `effectivePaymentsMode` takes). + * @param tenant The tenant's registry entry, or `undefined` when this slug has + * no entry yet (a billing plan can exist before its tenant is provisioned) — + * treated the same as an unreadable registry: there is nothing here to check + * a pairing against. + */ +export function commissionEligibility(args: { + registryReadable: boolean; + tenant: { modules: string[]; stripe_account?: string } | undefined; +}): CommissionEligibility { + if (!args.registryReadable || !args.tenant) { + return { eligible: false, reason: "registryUnavailable" }; + } + if (!args.tenant.modules.includes("online-payments")) { + return { eligible: false, reason: "notPaired" }; + } + if (missingPairedStripeAccount(args.tenant)) { + return { eligible: false, reason: "notPaired" }; + } + return { eligible: true }; +} diff --git a/lib/format.ts b/lib/format.ts index b4b060e..23cce6b 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -5,6 +5,27 @@ export function eur(cents: number): string { return new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR" }).format(cents / 100); } +/** + * Minor units in an ARBITRARY currency — for money that is NOT Sofra's EUR books. + * + * A Stripe application fee is denominated in the CHARGE's currency (`chf` for a + * Swiss tenant), so rendering one with `eur()` prints "€ 0,60" for a CHF 0.60 + * fee: a wrong symbol over a wrong number, and nothing goes red. `eur()` stays + * the formatter for the ledger, subscriptions and invoices. + * + * STATED LIMITATION: `/100` assumes a two-decimal currency. Every market Sofra + * sells to (CHF, EUR) is two-decimal; a zero-decimal one (JPY) would be wrong by + * 100x. That is a smaller lie than an exponent table for a currency the product + * cannot reach — and the reason lib/commission-earnings.ts returns minor units + * plus a code, so this stays a display-layer concern. + */ +export function money(minor: number, currency: string): string { + return new Intl.NumberFormat("nl-NL", { + style: "currency", + currency: currency.toUpperCase(), + }).format(minor / 100); +} + export function shortDate(d: Date): string { return new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", year: "numeric" }).format(d); } diff --git a/lib/payments-mode-effective.ts b/lib/payments-mode-effective.ts new file mode 100644 index 0000000..5787548 --- /dev/null +++ b/lib/payments-mode-effective.ts @@ -0,0 +1,43 @@ +// What a tenant's payments mode ACTUALLY is, versus what it is INTENDED to be +// (SOFRA-PAYMENTS-PRICING-MODE-PLAN §3, S2a). +// +// `TenantBilling.paymentsMode`/`.paymentsCommissionBps` (Prisma) is the BILLING +// intent — what a Mollie subscription is computed from. `payments_commission_bps` +// in the deploy repo's `tenants/registry.yml` is the ENFORCEMENT truth — what +// actually reaches the tenant's backend and Stripe. They can disagree, and the +// window is real: this app proposes a registry PR, it never writes to a box +// (ADR-003/007, `lib/registry-commission-pr.ts`), so a mode change is +// `set in Prisma -> registry PR -> merge -> re-provision`, and until the last +// step the tenant is billed one way while enforced the other. +// +// Billing must read the EFFECTIVE mode computed here, never `TenantBilling`'s +// intent directly — reading the intent would bill a tenant for a mode their box +// is not actually running. +// +// This mirrors `lib/payments-pending.ts`'s shape and, in particular, its +// fail-quiet direction: an unreadable registry is OUR ops failure, not the +// tenant's, so it must report the intent with `pending: false` rather than +// invent a claim about a tenant's money from it. Pure: no DB, no network, no env. + +import type { PaymentsMode } from "./payments-pricing"; + +export function effectivePaymentsMode(args: { + /** `TenantBilling.paymentsMode` — what we intend to bill for. */ + intended: PaymentsMode; + /** + * The registry entry's `payments_commission_bps`, or `undefined` when the + * entry has no such key (or no entry at all) — which means 0, the same + * convention `lib/registry-commission-edit.ts` uses. + */ + registryBps: number | undefined; + /** False when the registry could not be read at all. */ + registryReadable: boolean; +}): { mode: PaymentsMode; pending: boolean } { + // Fail-quiet: an unreadable registry tells us nothing about enforcement, so + // the only honest answer is the intent itself, reported as settled — never a + // manufactured "still switching" claim built from our own outage. + if (!args.registryReadable) return { mode: args.intended, pending: false }; + + const registryMode: PaymentsMode = (args.registryBps ?? 0) > 0 ? "commission" : "flat"; + return { mode: registryMode, pending: registryMode !== args.intended }; +} diff --git a/lib/payments-pricing.ts b/lib/payments-pricing.ts new file mode 100644 index 0000000..f9eb88b --- /dev/null +++ b/lib/payments-pricing.ts @@ -0,0 +1,179 @@ +// Payments pricing mode — flat fee vs per-transaction commission +// (workspace docs/plans/SOFRA-PAYMENTS-PRICING-MODE-PLAN.md, S1). +// +// The MECHANISM (Stripe `application_fee_amount` on the existing Connect direct +// charge) already shipped and is live — see the ADR-011 amendment referenced from +// `module-catalog.ts`. This module is only about the two ways a tenant can be +// billed for using it, and the pure arithmetic both billing and every future UI +// surface (S2 `/admin`, S3 signup, S4 partner dashboard) need to agree on. +// +// Pure by design — no DB, no network, no env — for the same reason +// `module-catalog.ts` and `payments-pending.ts` are: the numbers stay +// unit-testable and identical everywhere they are quoted. Money stays EUR/CHF +// **integer cents** throughout (CLAUDE.md §5.7); never a float. + +import { MODULES } from "./module-catalog"; + +/** + * `flat` — the tenant pays the `online-payments` module's list price and keeps + * 100% of every online order (minus Stripe's own fee). + * + * `commission` — the module itself is free and Sofra takes a per-transaction cut + * instead (`payments_commission_bps` in the registry, applied as Stripe's + * `application_fee_amount`). + * + * `flat` is what every tenant is on today and stays the default (plan §1) — this + * type exists so a mode is one of exactly two strings, never a third value that + * both the quote and the registry would have to guess a meaning for. + */ +export type PaymentsMode = "flat" | "commission"; + +/** + * The default per-transaction rate offered when a tenant switches to `commission` + * — 150 basis points, 1.50%. + * + * Chosen, not arbitrary (plan §1): it puts the crossover against the + * `online-payments` module's flat €19/mo at roughly **CHF 1,270 of monthly + * online turnover** — about 30 orders at a CHF 40 average, which is close to "is + * this channel real at all". Below that a switched tenant is paying MORE than + * they would on `flat`; above it, less. And 150 bps is around **one-seventeenth** + * of what a food-delivery aggregator takes (Uber Eats / Just Eat / Deliveroo run + * 14–30%), which is the sentence that actually sells the switch to a restaurant + * comparing the two. + */ +export const DEFAULT_COMMISSION_BPS = 150; + +/** + * The highest rate any tenant may be configured with — 1000 basis points, 10%. + * + * Re-stated here from `provision-tenant.sh` (deploy repo) and the backend, which + * each enforce their own copy of this same number — this is not the one place it + * lives, it is one of three that must agree, because each layer can be reached + * without going through the other two (a hand-edited registry entry never + * touches this file at all). + * + * WHY 1000, specifically: **measured 2026-09-04**, Stripe does NOT reject an + * `application_fee_amount` larger than the charge it is attached to — it + * silently CAPS it at 100% of the order instead. A requested fee of 5000 cents + * on a 4000-cent charge produced an actual fee of 4000, with no error anywhere + * in the response. So a fat-fingered or malicious rate above 100% would not + * surface as a Stripe error for anyone to notice — it would just take the whole + * order, silently, on every payment. The ceiling exists to make that + * unreachable long before 100%, and it is a safety guard rather than a pricing + * preference — which is exactly why it is re-declared at every layer that can + * write a rate, instead of trusted to have been checked upstream. + */ +export const MAX_COMMISSION_BPS = 1000; + +/** + * Whether `value` is a rate this system will accept anywhere: a non-negative + * integer no larger than {@link MAX_COMMISSION_BPS}. + * + * Basis points are always whole numbers here — `provision-tenant.sh` parses the + * registry field with a `^[0-9]+$` regex, so a fractional bps could never survive + * the round trip through the registry even if this check let it through earlier. + */ +export function isCommissionBps(value: number): boolean { + return Number.isInteger(value) && value >= 0 && value <= MAX_COMMISSION_BPS; +} + +// The `online-payments` module's list price, read from the ONE catalog rather +// than hardcoded here — a price change in module-catalog.ts must not require a +// second edit in this file to stay correct. `.find` rather than a literal index +// because MODULES is declared as a plain array (module-catalog.ts's own +// PRICE_CENTS lookup exists for exactly this reason, but it is not exported — +// duplicating that lookup here would be the second copy DRY forbids, so this +// reads the public array instead). +// +// The non-null assertion is safe, not merely convenient: `module-catalog.test.ts` +// ("prices every module id exactly once") asserts MODULES carries every id in +// MODULE_IDS, `online-payments` included, so this can only fail if that test is +// also failing — at which point CI is already red for the right reason. +// +// Exported (S2b): the admin form needs the same price to quote a LIVE crossover +// preview as the admin types a rate, and `registry-commission-pr.ts` already reads +// this exact lookup for its own PR-body crossover — a third private copy would be +// the duplication DRY forbids, not less of it. +export const ONLINE_PAYMENTS_PRICE_CENTS = MODULES.find((m) => m.id === "online-payments")!.priceCents; + +/** + * Adjust a tenant's monthly module quote for its payments mode. + * + * `flat` changes nothing — the `online-payments` line (if the tenant has it) is + * charged at its normal list price, same as every other module. + * + * `commission` zeroes that line: the module becomes €0/mo because Sofra is paid + * per transaction instead (via `payments_commission_bps`, not this quote). A + * tenant that does not have the module at all is unaffected either way — there + * is nothing to subtract, and `commission` mode is meaningless without the + * module actually being on. + * + * @param baseQuoteCents The tenant's normal `quoteModules(...).monthlyCents`, + * computed the usual way (this function does not re-price anything else). + * @param hasOnlinePayments Whether the tenant's module selection includes + * `online-payments` — passed in rather than re-derived, because the caller + * already has the selection this quote was built from and a second parse of + * it here could disagree with the one that produced `baseQuoteCents`. + */ +export function paymentsModeQuote( + baseQuoteCents: number, + mode: PaymentsMode, + hasOnlinePayments: boolean, +): number { + if (mode !== "commission" || !hasOnlinePayments) return baseQuoteCents; + return baseQuoteCents - ONLINE_PAYMENTS_PRICE_CENTS; +} + +/** + * The monthly online turnover, in minor units (cents) of the tenant's own + * currency, at which `commission` costs exactly what `flat` costs — the number + * the plan (§2) requires every switching surface to show, so an owner switching + * a busy tenant to commission is doing it knowingly rather than by a policy that + * quietly costs Sofra more than flat would have. + * + * Derivation: commission cost equals flat cost when + * `turnover * (bps / 10000) = flatCents`, i.e. `turnover = flatCents / (bps / + * 10000)` — rearranged below to keep the arithmetic in integers as long as + * possible before the one unavoidable division. + * + * @param bps The tenant's rate. `0` returns `null`: at a 0% rate commission + * costs nothing no matter how much turns over, so there is no turnover figure + * at which the two modes cross — commission is free forever, which is a + * different statement from "the crossover is very high" and must not be + * rendered as a number. + * @param flatCents The flat module price being compared against — the caller's + * `online-payments` price, not hardcoded here for the same reason + * {@link paymentsModeQuote} does not hardcode it. + * @returns The crossover turnover rounded to the nearest cent (`Math.round`). + * This is a figure for a sentence a human reads ("free up to about + * CHF 1,267/mo"), not a billing amount computed FROM it, so a one-cent + * rounding choice has no downstream effect — nearest-cent was picked over a + * ceiling/floor because it needs no argument for which direction is "safe". + */ +export function crossoverCentsPerMonth(bps: number, flatCents: number): number | null { + if (bps === 0) return null; + return Math.round((flatCents * 10000) / bps); +} + +/** + * `bps` as the percentage string every UI surface quotes it with — `150` -> + * `"1.50%"`. Two decimal places always: a rate can be as fine as 1 basis point + * (0.01%), and rounding to one decimal would silently collapse it to `"0.0%"`. + */ +export function formatCommissionPercent(bps: number): string { + return `${(bps / 100).toFixed(2)}%`; +} + +/** + * Narrow `TenantBilling.paymentsMode` (S2b) — a plain Prisma `String` column, + * per this repo's handwritten-migration workflow (§5.2), not an enum — to + * {@link PaymentsMode}. Anything other than the literal `"commission"` reads as + * `"flat"`: the value every row defaulted to before this column existed, and + * the safe reading of a value that should never occur outside a hand-edited + * row. Every admin surface that reads the column goes through this rather than + * an inline cast, so a typo in a future caller fails a type check instead of + * silently widening to `string`. + */ +export function asPaymentsMode(value: string): PaymentsMode { + return value === "commission" ? "commission" : "flat"; +} diff --git a/lib/provisioning-module-pairing.ts b/lib/provisioning-module-pairing.ts new file mode 100644 index 0000000..cb16c7c --- /dev/null +++ b/lib/provisioning-module-pairing.ts @@ -0,0 +1,89 @@ +// The account-pairing rule for ADR-012 registry entries: which fields must travel +// together with a tenant's Stripe connected account, and are withheld from an +// entry that does not have one yet. +// +// Split out of lib/provisioning-registry.ts (SOFRA-PAYMENTS-PRICING-MODE-PLAN S1) +// when a second paired field (`payments_commission_bps`) pushed that file over +// CLAUDE.md §4's LOC limit — the same split that file's own history records having +// had before, for provisioning-pr-body.ts/provisioning-pr-blocks.ts. This is the +// one purely-relational piece of the entry builder; everything left in +// provisioning-registry.ts is YAML shaping. `splitDeferredModules` is re-exported +// from there so no existing importer needed to change. + +import type { ModuleId } from "./module-catalog"; + +/** + * Modules `provision-tenant.sh` refuses unless the SAME entry also records a + * `stripe_account:`. That guard `exit 1`s *before* the database, the compose project + * and the image, so proposing the module without the account does not yield a tenant + * lacking card payment — it yields no tenant at all. + * + * Hence the pairing rule below: the module ships only alongside an account, never on + * its own. Which half is missing depends on the path in, and BOTH paths reach this one + * generator: + * + * - **Self-serve.** The buyer has no `acct_` and cannot be given one — only the + * restaurant can create it, through Stripe's hosted onboarding, which cannot be + * pre-filled (`oauth_not_supported` on a Standard account, SOFRA-PAYMENTS-PLAN §3). + * So the module is deferred to a second registry PR and the PR body says so. + * - **Founder.** `docs/runbooks/signup-to-live-tenant.md` §2b has the founder create + * the account BEFORE proposing, precisely because of this guard — so they arrive + * holding the `acct_`, and the entry carries both halves in one shot. + * + * Deferring unconditionally would have been wrong for the second path: it would make + * the founder's documented order pointless and tell them, falsely, that no account can + * exist yet. + */ +const ACCOUNT_PAIRED_MODULE_IDS: readonly ModuleId[] = ["online-payments"]; + +/** + * Split a purchased module list into what this entry may carry now and what must wait + * for a second registry PR. Pure and shared, so the entry and the PR body describing it + * cannot disagree about which is which. + * + * `stripeAccount` is the whole hinge: with one, nothing is deferred; without one, the + * account-paired ids are held back. + */ +export function splitDeferredModules( + modules: string[], + stripeAccount?: string, +): { granted: string[]; deferred: string[] } { + // Whitespace-only is not an account: `provision-tenant.sh` tests `-z`, which a blank + // string passes and " " does not — so a stray space would sail past the guard here and + // then fail on the box, which is the one place this must never be discovered. + if (stripeAccount?.trim()) return { granted: modules, deferred: [] }; + const isPaired = (id: string) => (ACCOUNT_PAIRED_MODULE_IDS as readonly string[]).includes(id); + return { + granted: modules.filter((id) => !isPaired(id)), + deferred: modules.filter(isPaired), + }; +} + +/** + * Whether a requested commission rate belongs in a registry entry, and what to + * write if so (SOFRA-PAYMENTS-PRICING-MODE-PLAN S1) — the SAME pairing rule as + * `splitDeferredModules`, one field over: `provision-tenant.sh` refuses a + * non-zero `payments_commission_bps` unless the SAME entry also carries + * `online-payments` in `modules` AND a `stripe_account`. So the rate is only + * ever emitted when `online-payments` itself survived the split above into + * `granted` — writing it against a DEFERRED module would just move that exact + * refusal onto this field instead of preventing it. + * + * Returns `undefined` — meaning "omit the key" — for two different reasons a + * caller must not conflate: there is genuinely no rate (`0`/absent, which is + * every entry before this field existed), or a rate WAS requested but + * `online-payments` is deferred. The founder-facing explanation of both + * branches lives in `lib/provisioning-pr-blocks.ts`'s `commissionSection`, + * which takes the same two facts so the entry and the PR body describing it + * cannot disagree. + * + * @param bps The requested rate, or `undefined`/`0` for none. + * @param granted The entry's `modules` AFTER the split above — i.e. what + * `splitDeferredModules(...).granted` returned, not the raw purchase list. + */ +export function grantedCommissionBps( + bps: number | undefined, + granted: readonly string[], +): number | undefined { + return (bps ?? 0) > 0 && granted.includes("online-payments") ? bps : undefined; +} diff --git a/lib/provisioning-pr-blocks.ts b/lib/provisioning-pr-blocks.ts index ddf5440..c2a6b54 100644 --- a/lib/provisioning-pr-blocks.ts +++ b/lib/provisioning-pr-blocks.ts @@ -1,6 +1,7 @@ // The CONDITIONAL sections of an ADR-012 provisioning PR body — the parts that // appear only when the entry carries something the founder has to look at before -// merging (a withheld module, a partner's own zone, a partner credit). +// merging (a withheld module, a partner's own zone, a partner credit, a +// per-transaction commission rate). // // Split out of lib/provisioning-pr-body.ts when the pair outgrew one file's LOC // limit (CLAUDE.md §4), the same split that file records having had from @@ -59,6 +60,53 @@ export function deferredSection( ]; } +/** + * The commission rate this entry carries — or, when a rate was requested but + * `online-payments` itself is deferred, why the entry carries NEITHER + * (SOFRA-PAYMENTS-PRICING-MODE-PLAN, S1). + * + * `provision-tenant.sh` refuses a non-zero `payments_commission_bps` unless the + * SAME entry also carries `online-payments` in `modules` AND a `stripe_account` + * — the identical shape as the deferred-module guard above, one field over. + * `buildTenantRegistryEntry` already drops the rate rather than write half of + * that condition; this section is the only place a founder is told a number + * they requested is silently missing from the diff, because nothing else in + * the body would say so. + */ +export function commissionSection( + slug: string, + paymentsCommissionBps: number | undefined, + grantsOnlinePayments: boolean, +): string[] { + if (!paymentsCommissionBps) return []; + const pct = (paymentsCommissionBps / 100).toFixed(2); + if (grantsOnlinePayments) { + return [ + "", + `### 💳 Per-transaction commission: \`${paymentsCommissionBps}\` bps (${pct}%)`, + "", + "This tenant is on the `commission` payments mode: `online-payments` is billed at", + "€0/mo and Sofra takes this rate instead, sent to Stripe as", + "`application_fee_amount` on each online order. Same as any other field in this", + "entry, it only takes effect once this PR merges and the tenant is (re-)provisioned —", + "until then the billing record and the registry can disagree, same as any other", + "registry-PR window.", + ]; + } + return [ + "", + `### ⚠️ Requested commission rate \`${paymentsCommissionBps}\` bps (${pct}%) is NOT in this entry`, + "", + "`provision-tenant.sh` refuses a non-zero `payments_commission_bps` unless the SAME", + "entry also carries `online-payments` in `modules` AND a `stripe_account` — the exact", + "pair deferred in the section above. Writing the rate here without them would only move", + "the same refusal onto this field, so it is held back alongside `online-payments` and", + `must be added in the SAME follow-up PR: add \`payments_commission_bps: ${paymentsCommissionBps}\``, + "next to `stripe_account` and `online-payments` in that one commit — not before, and not", + "in a separate PR of its own, or the guard trips on whichever field lands first.", + ]; +} + /** * A partner's own zone. * diff --git a/lib/provisioning-pr-body.ts b/lib/provisioning-pr-body.ts index bf832e1..f29dc84 100644 --- a/lib/provisioning-pr-body.ts +++ b/lib/provisioning-pr-body.ts @@ -10,7 +10,12 @@ import { splitDeferredModules, tenantDomain, type TenantProvisionInput } from "./provisioning-registry"; // The conditional sections live next door (LOC limit, CLAUDE.md §4). Each returns an // empty array when it does not apply, so this file spreads them unconditionally. -import { baseDomainSection, deferredSection, partnerSection } from "./provisioning-pr-blocks"; +import { + baseDomainSection, + commissionSection, + deferredSection, + partnerSection, +} from "./provisioning-pr-blocks"; // Close the quote, emit an escaped apostrophe, reopen: the only way to get a // literal ' inside a POSIX single-quoted argument. @@ -58,6 +63,10 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { // Same helper the entry generator uses, so the body cannot describe a split the diff // does not have. const { granted, deferred } = splitDeferredModules(input.modules, input.stripeAccount); + // Same condition `buildTenantRegistryEntry` uses to decide whether a requested + // commission rate may be written at all — computed once here so the summary + // bullet and commissionSection cannot disagree about which case applies. + const grantsOnlinePayments = granted.includes("online-payments"); // One line, naming the one field in the diff the founder may need to change. It used to // branch on the box and warn that a staging-box tenant rides develop; the generator no @@ -94,13 +103,30 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { "per-box boundary), so run the commands from a machine that has it.", ]; + // Built as statements rather than inline: nesting a conditional inside a template + // literal that is itself inside a conditional is the shape Sonar rejects (S3358), + // and it is genuinely hard to read — the "(not written)" caveat below belongs to the + // commission, not to the deferred module list it would otherwise sit beside. + const deferredSuffix = deferred.length + ? ` · **deferred** \`${deferred.join(", ")}\` (see below)` + : ""; + + // A rate is only WRITTEN into the entry when online-payments actually survives the + // grant/defer split — provision-tenant.sh refuses a non-zero rate without the module + // and a stripe_account, and refuses it before the database. So when the module is + // deferred the number is still worth showing (it is what the second PR will carry) + // but must be labelled as not yet written, or a reviewer reads it as live. + let commissionSuffix = ""; + if (input.paymentsCommissionBps) { + const caveat = grantsOnlinePayments ? "" : " (not written — see below)"; + commissionSuffix = ` · **commission** \`${input.paymentsCommissionBps} bps\`${caveat}`; + } + return [ `Adds the \`${slug}\` tenant to \`tenants/registry.yml\`, proposed by the control plane (sofra ADR-012).`, "", `- **domain** \`${domain}\` · **template** \`${input.template}\` · **currency** \`${input.currency}\``, - `- **languages** \`${input.languages.join(", ")}\` · **modules** \`${granted.join(", ")}\`${ - deferred.length ? ` · **deferred** \`${deferred.join(", ")}\` (see below)` : "" - }`, + `- **languages** \`${input.languages.join(", ")}\` · **modules** \`${granted.join(", ")}\`${deferredSuffix}${commissionSuffix}`, `- **box** \`${box}\` · status starts at \`provisioning\`${ input.baseDomain ? ` · **base_domain** \`${input.baseDomain}\` (a partner's own zone)` : "" }${ @@ -122,6 +148,7 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { 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`, ...deferredSection(slug, granted, deferred), + ...commissionSection(slug, input.paymentsCommissionBps, grantsOnlinePayments), ...baseDomainSection(input.baseDomain, domain), // Only when a publishable partner brand reached the entry — `renderableBrand` // decided that, not this file. diff --git a/lib/provisioning-registry.ts b/lib/provisioning-registry.ts index f19a720..9120183 100644 --- a/lib/provisioning-registry.ts +++ b/lib/provisioning-registry.ts @@ -7,59 +7,19 @@ import { stringify } from "yaml"; import { tenantHostname } from "./base-domain"; -import type { ModuleId } from "./module-catalog"; +// The account-pairing rule (which fields must travel with a Stripe connected +// account) moved to its own file when a second paired field pushed this one +// over CLAUDE.md §4's LOC limit (SOFRA-PAYMENTS-PRICING-MODE-PLAN S1). Both are +// used here AND re-exported, so no existing importer of `splitDeferredModules` +// from this module had to change. +import { grantedCommissionBps, splitDeferredModules } from "./provisioning-module-pairing"; + +export { splitDeferredModules } from "./provisioning-module-pairing"; /** The zone every tenant lived under until D1. An absent `base_domain:` means exactly * this, both here and in `provision-tenant.sh`. */ const DEFAULT_BASE_DOMAIN = "sofrapiwas.com"; -/** - * Modules `provision-tenant.sh` refuses unless the SAME entry also records a - * `stripe_account:`. That guard `exit 1`s *before* the database, the compose project - * and the image, so proposing the module without the account does not yield a tenant - * lacking card payment — it yields no tenant at all. - * - * Hence the pairing rule below: the module ships only alongside an account, never on - * its own. Which half is missing depends on the path in, and BOTH paths reach this one - * generator: - * - * - **Self-serve.** The buyer has no `acct_` and cannot be given one — only the - * restaurant can create it, through Stripe's hosted onboarding, which cannot be - * pre-filled (`oauth_not_supported` on a Standard account, SOFRA-PAYMENTS-PLAN §3). - * So the module is deferred to a second registry PR and the PR body says so. - * - **Founder.** `docs/runbooks/signup-to-live-tenant.md` §2b has the founder create - * the account BEFORE proposing, precisely because of this guard — so they arrive - * holding the `acct_`, and the entry carries both halves in one shot. - * - * Deferring unconditionally would have been wrong for the second path: it would make - * the founder's documented order pointless and tell them, falsely, that no account can - * exist yet. - */ -const ACCOUNT_PAIRED_MODULE_IDS: readonly ModuleId[] = ["online-payments"]; - -/** - * Split a purchased module list into what this entry may carry now and what must wait - * for a second registry PR. Pure and shared, so the entry and the PR body describing it - * cannot disagree about which is which. - * - * `stripeAccount` is the whole hinge: with one, nothing is deferred; without one, the - * account-paired ids are held back. - */ -export function splitDeferredModules( - modules: string[], - stripeAccount?: string, -): { granted: string[]; deferred: string[] } { - // Whitespace-only is not an account: `provision-tenant.sh` tests `-z`, which a blank - // string passes and " " does not — so a stray space would sail past the guard here and - // then fail on the box, which is the one place this must never be discovered. - if (stripeAccount?.trim()) return { granted: modules, deferred: [] }; - const isPaired = (id: string) => (ACCOUNT_PAIRED_MODULE_IDS as readonly string[]).includes(id); - return { - granted: modules.filter((id) => !isPaired(id)), - deferred: modules.filter(isPaired), - }; -} - export interface TenantProvisionInput { /** Registry key + derivation seed. Must already match the slug grammar. */ slug: string; @@ -84,6 +44,15 @@ export interface TenantProvisionInput { /** The tenant's Stripe connected account (`acct_…`), when they already have one. * Absent on the self-serve path; present when the founder followed runbook §2b. */ stripeAccount?: string; + /** + * The tenant's per-transaction commission rate, in basis points + * (SOFRA-PAYMENTS-PRICING-MODE-PLAN S1; range governed by `lib/payments-pricing.ts`, + * not re-validated here). Whether it is actually WRITTEN into the entry is decided + * by `grantedCommissionBps` (`./provisioning-module-pairing`) — absent or `0` is + * the same statement `stripeAccount` above makes: every entry emitted before this + * field existed, and every tenant alive today, is `flat`/0. + */ + paymentsCommissionBps?: number; /** * The reseller credit this tenant's footer may carry (§11e) — and it is typed as * the OUTPUT of `renderableBrand`, not as a partner id or a brand row, on purpose. @@ -135,6 +104,7 @@ export function buildTenantRegistryEntry(input: TenantProvisionInput): { const box = input.box ?? "staging"; const stripeAccount = input.stripeAccount?.trim(); const { granted, deferred } = splitDeferredModules(input.modules, stripeAccount); + const commissionBps = grantedCommissionBps(input.paymentsCommissionBps, granted); const entry = { [slug]: { name: input.name, @@ -166,7 +136,7 @@ export function buildTenantRegistryEntry(input: TenantProvisionInput): { frontend_tag: `tenant-${slug}`, currency: input.currency, languages: input.languages, - // NOT `input.modules` — see ACCOUNT_PAIRED_MODULE_IDS. + // NOT `input.modules` — see splitDeferredModules (./provisioning-module-pairing). modules: granted, template: input.template, admin_email: input.adminEmail, @@ -174,6 +144,9 @@ export function buildTenantRegistryEntry(input: TenantProvisionInput): { // it — the two are written from the same `granted`/`stripeAccount` pair so the // entry can never carry one half of the guard's condition. ...(stripeAccount ? { stripe_account: stripeAccount } : {}), + // Whether the rate belongs in THIS entry: grantedCommissionBps (same pairing + // rule as stripe_account above, applied to a second field). + ...(commissionBps !== undefined ? { payments_commission_bps: commissionBps } : {}), // Only emit city when set — the registry field is optional. ...(input.city ? { city: input.city } : {}), // The partner credit, emitted only when there is a publishable one. NOT diff --git a/lib/provisioning.ts b/lib/provisioning.ts index 16af099..6df7ea6 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -10,10 +10,14 @@ import { buildProvisioningPrBody } from "@/lib/provisioning-pr-body"; import { tenantPartnerBrand } from "@/lib/partner-brand-lookup"; import { buildTenantRegistryEntry, type TenantProvisionInput } from "@/lib/provisioning-registry"; -const OWNER = "piwas-21"; -const REPO = "restaurant-app-deploy"; -const BASE = "develop"; // deploy repo default/integration branch (GitFlow) -const REGISTRY_PATH = "tenants/registry.yml"; +// Exported so lib/registry-commission-pr.ts (the amendment counterpart to +// openProvisioningPr below, split into its own file for CLAUDE.md §4's line +// limit) shares this ONE GitHub client and these ONE repo constants, rather +// than a second copy of either drifting from this one. +export const OWNER = "piwas-21"; +export const REPO = "restaurant-app-deploy"; +export const BASE = "develop"; // deploy repo default/integration branch (GitFlow) +export const REGISTRY_PATH = "tenants/registry.yml"; const API = "https://api.github.com"; /** Provisioning is not configured (no token) — surfaced like the Mollie banner. */ @@ -38,7 +42,7 @@ export function provisioningConfigured(): boolean { * timeout, so it needs an explicit one. */ const GH_TIMEOUT_MS = 15_000; -async function gh(token: string, path: string, init?: RequestInit): Promise { +export 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", diff --git a/lib/registry-commission-edit.ts b/lib/registry-commission-edit.ts new file mode 100644 index 0000000..c119fdb --- /dev/null +++ b/lib/registry-commission-edit.ts @@ -0,0 +1,169 @@ +// Surgical single-tenant edits to `tenants/registry.yml`'s +// `payments_commission_bps` field (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2a) — the +// AMENDMENT counterpart to `lib/provisioning-registry.ts`, which only builds a +// brand-new entry. Consumed by `lib/registry-commission-pr.ts`, which does the +// GitHub side; this file stays pure so the dangerous part (rewriting a file a +// human reviews for content they hand-wrote) is unit-testable in isolation. +// +// A YAML parse-and-restringify is FORBIDDEN here on purpose: the registry +// carries extensive hand-written documentation as comments (see the deploy +// repo's tenants/registry.yml header), and round-tripping it through a YAML +// library would silently delete every one of them. So this is LINE editing +// against the exact shape `provisioning-registry.ts` emits and the deploy +// repo's real file confirms: a tenant header at 2 spaces (` slug:`), its +// fields at 4 (` name: ...`), and comments appearing BOTH between blocks at +// 2 spaces and inside a block at 4 — which is also what makes "blank, or +// indented >= 4 spaces" the right definition of "still inside this block": a +// 2-space comment between two tenants correctly ends the first block rather +// than being read as its tail. +// +// Pure: no GitHub API, no fs, no env — registryYaml in, registryYaml out. + +import { isCommissionBps } from "./payments-pricing"; +import { + UnknownRegistryTenantError, + InvalidCommissionBpsError, + MissingStripeAccountError, +} from "./registry-commission-errors"; + +// Re-exported so every existing importer keeps its single import site. +export { UnknownRegistryTenantError, InvalidCommissionBpsError, MissingStripeAccountError }; + +/** No `slug` entry exists in the registry at all. */ +const escapeForRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + +/** + * The `[headerIdx, endIdx)` line range `slug`'s block occupies in `lines` — + * `headerIdx` is the header line itself (` slug:`), `endIdx` is exclusive, so + * callers scan the body as `[headerIdx + 1, endIdx)`. + * + * The header must match `^ :\s*$` exactly, anchored at both ends, so a + * slug can never match a longer sibling (`demo` must not match `demo2:`). + */ +function findBlock(lines: string[], slug: string): { headerIdx: number; endIdx: number } | null { + const headerRe = new RegExp(String.raw`^ {2}${escapeForRegex(slug)}:\s*$`); + const headerIdx = lines.findIndex((line) => headerRe.test(line)); + if (headerIdx === -1) return null; + + let endIdx = lines.length; + for (let i = headerIdx + 1; i < lines.length; i++) { + if (lines[i].trim() === "" || /^ {4,}/.test(lines[i])) continue; + endIdx = i; + break; + } + return { headerIdx, endIdx }; +} + +const BPS_LINE = /^( +)payments_commission_bps:\s*(\d+)/; +const STRIPE_LINE = /^( +)stripe_account:/; + +/** + * The rate `slug`'s block currently carries, or `undefined` when the key is + * absent or the slug is unknown — both of which mean 0, the same convention + * `setRegistryCommissionBps` below uses. Exported only so + * `lib/registry-commission-pr.ts` can report a "before" figure in the PR body + * without a second, independent parse of the block that could disagree with + * the one this file actually acts on. + */ +export function currentRegistryCommissionBps(registryYaml: string, slug: string): number | undefined { + const lines = registryYaml.split("\n"); + const block = findBlock(lines, slug); + if (!block) return undefined; + for (let i = block.headerIdx + 1; i < block.endIdx; i++) { + const match = BPS_LINE.exec(lines[i]); + if (match) return Number(match[2]); + } + return undefined; +} + +/** + * The block already carries the key: rewrite that one line, or drop it at 0. + * + * Dropping rather than writing `: 0` is the registry's own convention — absent means + * zero and the schema comment says so — so the line would be noise in a file humans + * review. The original indentation is reused rather than assumed; nothing here should + * be what decides a file's formatting. + */ +function rewriteExisting(lines: string[], bpsIdx: number, bps: number): string[] { + if (bps === 0) return [...lines.slice(0, bpsIdx), ...lines.slice(bpsIdx + 1)]; + const indent = BPS_LINE.exec(lines[bpsIdx])![1]; + const next = [...lines]; + next[bpsIdx] = `${indent}payments_commission_bps: ${bps}`; + return next; +} + +/** + * The block has no key yet. At 0 there is nothing to write — absent already means + * zero — so the input comes back untouched and the caller reports `changed: false` + * rather than opening an empty PR. + * + * Otherwise the line goes immediately after `stripe_account:`. That anchor exists + * exactly when a non-zero rate is legal at all, and using it avoids having to decide + * where a block "ends" among trailing comments. + */ +function insertAfterStripeAccount( + lines: string[], + stripeIdx: number, + bps: number, + slug: string, +): string[] { + if (bps === 0) return lines; + if (stripeIdx === -1) throw new MissingStripeAccountError(slug); + const indent = STRIPE_LINE.exec(lines[stripeIdx])![1]; + return [ + ...lines.slice(0, stripeIdx + 1), + `${indent}payments_commission_bps: ${bps}`, + ...lines.slice(stripeIdx + 1), + ]; +} + +/** + * Set (or clear) `slug`'s `payments_commission_bps` in `registryYaml`. + * + * - `bps` failing {@link isCommissionBps} → {@link InvalidCommissionBpsError}. + * - `slug` not present → {@link UnknownRegistryTenantError}. + * - An existing `payments_commission_bps:` line in the block: `bps > 0` + * replaces it in place (preserving its original indentation); `bps === 0` + * deletes the line entirely — absent means 0 (the registry schema documents + * that), so leaving `: 0` behind would be redundant noise in a file humans + * review. + * - No existing line: `bps === 0` is a no-op (`changed: false`); `bps > 0` is + * inserted immediately after the block's `stripe_account:` line, matching + * its indentation — or {@link MissingStripeAccountError} when the block has + * none. Anchoring to `stripe_account:` rather than appending at the block's + * end is deliberate: that anchor is guaranteed to exist exactly when a + * non-zero rate is legal, which sidesteps having to decide where a block + * "ends" in the presence of trailing comments. + * + * Idempotent: applying the same value twice returns `changed: false` the + * second time, with byte-identical YAML — `changed` is derived from a plain + * string comparison of the whole file, not tracked case by case, so a + * "replace with the same value" can never disagree with that comparison. + */ +export function setRegistryCommissionBps( + registryYaml: string, + slug: string, + bps: number, +): { yaml: string; changed: boolean } { + if (!isCommissionBps(bps)) throw new InvalidCommissionBpsError(bps); + + const lines = registryYaml.split("\n"); + const block = findBlock(lines, slug); + if (!block) throw new UnknownRegistryTenantError(slug); + const { headerIdx, endIdx } = block; + + let bpsIdx = -1; + let stripeIdx = -1; + for (let i = headerIdx + 1; i < endIdx; i++) { + if (bpsIdx === -1 && BPS_LINE.test(lines[i])) bpsIdx = i; + if (stripeIdx === -1 && STRIPE_LINE.test(lines[i])) stripeIdx = i; + } + + const next = + bpsIdx !== -1 + ? rewriteExisting(lines, bpsIdx, bps) + : insertAfterStripeAccount(lines, stripeIdx, bps, slug); + + const yaml = next.join("\n"); + return { yaml, changed: yaml !== registryYaml }; +} diff --git a/lib/registry-commission-errors.ts b/lib/registry-commission-errors.ts new file mode 100644 index 0000000..63c0cd6 --- /dev/null +++ b/lib/registry-commission-errors.ts @@ -0,0 +1,47 @@ +// Errors the registry commission editor can raise. Their own module because +// `lib/registry-commission-pr.ts` and `lib/actions/provisioning-actions.ts` both +// discriminate on them, and because lifting them here is what keeps the editor +// itself under the §4 length limit without compressing the comments that explain +// WHY each refusal exists. + +export class UnknownRegistryTenantError extends Error { + constructor(slug: string) { + super(`registry has no '${slug}' entry`); + this.name = "UnknownRegistryTenantError"; + } +} + +/** `bps` failed `isCommissionBps` — negative, fractional, or above the ceiling. */ +export class InvalidCommissionBpsError extends Error { + constructor(bps: number) { + super(`${bps} is not a valid commission rate`); + this.name = "InvalidCommissionBpsError"; + } +} + +/** + * A non-zero rate was requested for a tenant whose block has no + * `stripe_account:` line. `provision-tenant.sh` refuses a non-zero + * `payments_commission_bps` unless the SAME entry also carries + * `online-payments` in `modules` AND a `stripe_account` — and refuses it + * BEFORE the database, the compose project or the image. So writing the rate + * here without the account would not give this tenant a restaurant without + * commission on the next re-provision; it would give them no tenant at all. + * + * Unlike a brand-new entry (`splitDeferredModules` in + * `provisioning-module-pairing.ts`), there is no "defer it to a second PR" + * available here: this call amends a tenant that already exists, so the only + * honest answer is to refuse outright rather than propose a change that would + * brick the next re-provision. + */ +export class MissingStripeAccountError extends Error { + constructor(slug: string) { + super( + `'${slug}' has no stripe_account: — provision-tenant.sh refuses a non-zero ` + + "payments_commission_bps without online-payments + stripe_account, and refuses it " + + "BEFORE the database, so proposing this rate would yield no tenant at all rather " + + "than a tenant without commission", + ); + this.name = "MissingStripeAccountError"; + } +} diff --git a/lib/registry-commission-pr.ts b/lib/registry-commission-pr.ts new file mode 100644 index 0000000..1a6c38d --- /dev/null +++ b/lib/registry-commission-pr.ts @@ -0,0 +1,138 @@ +// Amend an EXISTING tenant's payments commission rate by proposing a registry +// PR (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2a) — the AMENDMENT counterpart to +// `lib/provisioning.ts`'s `openProvisioningPr`, which only APPENDS a brand-new +// entry and refuses a slug that already exists. Split into its own file rather +// than folded into `provisioning.ts`, which already sits close to CLAUDE.md +// §4's line limit — the same split that file's own history records having had +// for provisioning-pr-body.ts / provisioning-pr-blocks.ts / +// provisioning-module-pairing.ts. Shares `provisioning.ts`'s GitHub client +// (`gh`) and repo constants rather than a second copy of either. + +import { + gh, + OWNER, + REPO, + BASE, + REGISTRY_PATH, + ProvisioningNotConfiguredError, + ProvisioningApiError, +} from "./provisioning"; +import { currentRegistryCommissionBps, setRegistryCommissionBps } from "./registry-commission-edit"; +import { crossoverCentsPerMonth } from "./payments-pricing"; +import { MODULES } from "./module-catalog"; + +// The same lookup `payments-pricing.ts` uses, for the identical reason: reading +// the ONE catalog rather than a second hardcoded price that could drift from it. +const ONLINE_PAYMENTS_PRICE_CENTS = MODULES.find((m) => m.id === "online-payments")!.priceCents; + +export type CommissionChangeResult = { alreadySet: true } | { alreadySet: false; prUrl: string }; + +/** + * The PR body: tenant, old → new rate, the crossover (so a reviewer sees the + * commercial consequence, plan §2), and — the fact easiest to miss — that + * merging changes ENFORCEMENT only. The billing intent already moved the + * moment the caller wrote `TenantBilling`; this PR is what makes the box agree + * with it, and only a re-provision (never a `restart`, which re-reads nothing) + * makes that happen. + */ +function commissionChangePrBody(slug: string, oldBps: number, newBps: number): string { + const crossover = crossoverCentsPerMonth(newBps, ONLINE_PAYMENTS_PRICE_CENTS); + return [ + `Updates \`${slug}\`'s per-transaction commission rate in \`tenants/registry.yml\`, proposed by the control plane's \`/admin\` (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2a).`, + "", + `- **tenant** \`${slug}\``, + `- **rate** \`${oldBps}\` bps → \`${newBps}\` bps`, + crossover !== null + ? `- **crossover** ~\`${(crossover / 100).toFixed(2)}\` of monthly online turnover (this tenant's own billing currency, major units) — below that figure \`flat\` would have cost this tenant less; above it, \`commission\` does` + : "- **crossover** none at 0 bps — commission costs nothing no matter the turnover", + "", + "### Merging this changes ENFORCEMENT only", + "", + "This edits what is sent to Stripe as `application_fee_amount` once the tenant is", + "next provisioned — merging alone does **not** flip anything live, and a", + "`docker compose restart` re-reads nothing (the tenant's env is baked at", + "provisioning). The billing intent (`TenantBilling`) already reflects the new rate;", + "until the re-provision below runs, the tenant is billed the new rate while still", + "being enforced at the old one.", + "", + "```bash", + `gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}`, + "```", + "", + "Idempotent and safe to re-run.", + ].join("\n"); +} + +/** + * Open a PR amending `slug`'s `payments_commission_bps` to `bps`. Mirrors + * `openProvisioningPr`'s steps — read the registry (content+sha) on BASE, + * apply the edit, branch off BASE, commit, open the PR — but on a slug that + * must ALREADY be in the registry; `setRegistryCommissionBps` throws when it + * is not (there is no "append" fallback here, unlike a new tenant's entry). + * + * Returns `{ alreadySet: true }` without opening anything when the registry + * already carries this exact rate — `setRegistryCommissionBps` reports that + * as `changed: false`, and a PR with an empty diff is worse than no PR: it is + * a checklist item a founder ticks for nothing. + */ +export async function openCommissionChangePr(slug: string, bps: number): Promise { + const token = process.env.PROVISION_GITHUB_TOKEN; + if (!token) throw new ProvisioningNotConfiguredError(); + + const file = await gh<{ content: string; sha: string }>( + token, + `/repos/${OWNER}/${REPO}/contents/${REGISTRY_PATH}?ref=${BASE}`, + ); + const current = Buffer.from(file.content, "base64").toString("utf8"); + // Read BEFORE the edit — the only place the "old" figure in the PR body can + // come from without a second, independent parse that could disagree with + // the one setRegistryCommissionBps actually acted on. + const oldBps = currentRegistryCommissionBps(current, slug) ?? 0; + + const { yaml: updated, changed } = setRegistryCommissionBps(current, slug, bps); + if (!changed) return { alreadySet: true }; + + const baseRef = await gh<{ object: { sha: string } }>( + token, + `/repos/${OWNER}/${REPO}/git/ref/heads/${BASE}`, + ); + // Distinct prefix from provisioning's `provision/`: this amends a + // tenant that already exists, and the two flows must never collide on a + // branch name for the same slug. + const branch = `payments/${slug}`; + try { + await gh(token, `/repos/${OWNER}/${REPO}/git/refs`, { + method: "POST", + body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: baseRef.object.sha }), + }); + } catch (e) { + // An open proposal for this same tenant already holds the branch. + if (e instanceof ProvisioningApiError && /Reference already exists/i.test(e.message)) { + throw new ProvisioningApiError( + `a commission-rate change for '${slug}' is already open (branch ${branch} exists)`, + ); + } + throw e; + } + + await gh(token, `/repos/${OWNER}/${REPO}/contents/${REGISTRY_PATH}`, { + method: "PUT", + body: JSON.stringify({ + message: `chore(registry): update '${slug}' payments_commission_bps to ${bps}`, + content: Buffer.from(updated, "utf8").toString("base64"), + sha: file.sha, + branch, + }), + }); + + const pr = await gh<{ html_url: string }>(token, `/repos/${OWNER}/${REPO}/pulls`, { + method: "POST", + body: JSON.stringify({ + title: `Update payments commission: ${slug} (${oldBps} → ${bps} bps)`, + head: branch, + base: BASE, + body: commissionChangePrBody(slug, oldBps, bps), + }), + }); + return { alreadySet: false, prUrl: pr.html_url }; +} diff --git a/lib/signup-configuration.ts b/lib/signup-configuration.ts index a93b91c..34070a2 100644 --- a/lib/signup-configuration.ts +++ b/lib/signup-configuration.ts @@ -11,7 +11,12 @@ // bad value is a loud failure rather than a silent tenant misconfiguration. // 2. RE-QUOTE, never trust. The posted price is ignored entirely; the total is // recomputed from the catalog so a crafted POST cannot make the founder read -// a number the lead was never actually shown. +// a number the lead was never actually shown. Since S3 this covers the +// payments pricing mode too (workspace SOFRA-PAYMENTS-PRICING-MODE-PLAN): +// `commission` re-prices through `paymentsModeQuote`, and — same DROP rule +// as rule 1 — is only honoured when the selection actually carries +// `online-payments`; otherwise it degrades to `flat`, same as an +// unrecognised mode string. // // Pure — no DB, no network — so it is unit-testable on its own. @@ -24,6 +29,12 @@ import { TEMPLATES, TENANT_CURRENCIES, } from "./tenant-options"; +import { + asPaymentsMode, + DEFAULT_COMMISSION_BPS, + paymentsModeQuote, + type PaymentsMode, +} from "./payments-pricing"; /** Raw configurator fields as they come off the wire (all optional). */ export type RawSignupConfiguration = { @@ -31,6 +42,7 @@ export type RawSignupConfiguration = { languages?: string; template?: string; currency?: string; + paymentsMode?: string; }; /** What gets written to SignupRequest — CSV in the registry grammar, or null. */ @@ -40,6 +52,8 @@ export type StoredSignupConfiguration = { template: string | null; currency: string | null; quotedCents: number | null; + paymentsMode: PaymentsMode | null; + paymentsCommissionBps: number | null; }; /** Set when the lead chose nothing at all, so the founder still decides. */ @@ -49,6 +63,8 @@ const NOTHING_CHOSEN: StoredSignupConfiguration = { template: null, currency: null, quotedCents: null, + paymentsMode: null, + paymentsCommissionBps: null, }; export function sanitizeSignupConfiguration( @@ -79,6 +95,21 @@ export function sanitizeSignupConfiguration( ? [...withCore.filter((m) => m !== "extra-languages"), "extra-languages"] : withCore.filter((m) => m !== "extra-languages"); + // A mode with no module is not a state anything downstream can honour — the + // buyer cannot be "on commission" for a module they did not buy — so it + // collapses to `flat` exactly like an unrecognised mode string does + // (`asPaymentsMode`, rule 1). Checked against the FINAL module list, not the + // raw one, so a `paymentsMode=commission` riding a POST that also drops + // `online-payments` cannot record a mode nothing enforces. + const hasOnlinePayments = finalModules.includes("online-payments"); + const paymentsMode: PaymentsMode = hasOnlinePayments + ? asPaymentsMode(raw.paymentsMode ?? "") + : "flat"; + // The rate the buyer was SHOWN, not one they typed — see the migration + // comment and payments-pricing.ts DEFAULT_COMMISSION_BPS for why this is + // recorded even though it is never a free-form input. + const paymentsCommissionBps = paymentsMode === "commission" ? DEFAULT_COMMISSION_BPS : 0; + return { modules: finalModules.join(","), languages: withEnglish.join(","), @@ -87,7 +118,15 @@ export function sanitizeSignupConfiguration( template: raw.template && isTemplateId(raw.template) ? raw.template : TEMPLATES[0].id, currency: raw.currency && isTenantCurrency(raw.currency) ? raw.currency : TENANT_CURRENCIES[0], - // Recomputed, never the posted value. - quotedCents: quoteModules(finalModules).monthlyCents, + // Rule 2, extended: recomputed from the catalog AND mode-adjusted, so a + // buyer who chose commission is recorded at the total they were actually + // shown — never the posted price, and never the flat total either. + quotedCents: paymentsModeQuote( + quoteModules(finalModules).monthlyCents, + paymentsMode, + hasOnlinePayments, + ), + paymentsMode, + paymentsCommissionBps, }; } diff --git a/lib/stripe-fee-earned.ts b/lib/stripe-fee-earned.ts new file mode 100644 index 0000000..a83edea --- /dev/null +++ b/lib/stripe-fee-earned.ts @@ -0,0 +1,109 @@ +// Application fees Sofra EARNED (workspace docs/plans/BACKLOG.md, the SECOND +// blocker before any tenant goes on a non-zero payment commission: "nothing +// reports what the commission earned"). The mirror image of +// lib/stripe-fee-refund.ts, which records only fees RETURNED. +// +// Written by the `application_fee.created` branch of +// app/api/webhooks/stripe/route.ts. That event is a PLATFORM event +// (`event.account` is null — measured; see lib/stripe-webhook-secrets.ts for +// the measurement and its control), which is why the branch sits ABOVE the +// route's `!event.account` guard and why the connected account is read from +// the FEE's own `account` field rather than from the event's. +// +// Deliberately NOT handled here: `application_fee.refunded` / +// `application_fee.refund.updated`. The refunded side is already recorded by +// our own write path (lib/stripe-fee-refund.ts) and a second source for the +// same fact is a reconciliation problem, not a feature. +import { db } from "@/lib/db"; +import { stripeGet } from "@/lib/stripe"; +import type { StripeApplicationFee } from "@/lib/stripe-fee-refund"; + +/** One row of `StripeApplicationFee`, exactly as the database takes it. */ +export type FeeEarnedRow = { + applicationFeeId: string; + connectedAccountId: string; + chargeId: string; + amount: number; + currency: string; + feeCreatedAt: Date; +}; + +/** + * The whole Stripe-object -> row mapping. PURE, so both of the conversions that + * are easy to get wrong are unit-testable with no DB and no network. + * + * `fee.account`, NOT the event's `account`: an ApplicationFee is a + * platform-owned object and its event carries no account at all, but the fee + * object itself names the connected account it was taken from. This is the + * single field the whole per-tenant readout joins on. + */ +export function feeEarnedRow(fee: StripeApplicationFee): FeeEarnedRow { + return { + applicationFeeId: fee.id, + connectedAccountId: fee.account, + chargeId: fee.charge, + amount: fee.amount, + // Stripe returns lower case (measured: `"chf"`). Pinned here so a future + // upper-case value could never split one tenant's CHF total in two. + currency: fee.currency.toLowerCase(), + // Stripe's `created` is epoch SECONDS (measured: 1788558359 -> 2026-09-04), + // the classic off-by-1000. Getting it wrong puts every fee in 1970, which a + // month-scoped readout renders as an empty period rather than as an error. + feeCreatedAt: new Date(fee.created * 1000), + }; +} + +/** + * The WRITE, described as data so the idempotency anchor is assertable without + * a database. + * + * `where` is keyed on `applicationFeeId` and on nothing else, because that is + * the column the migration makes UNIQUE. This carries more weight here than the + * same pattern does on the refund side: `feeRefundAmount` neutralises a + * redelivery arithmetically (it recomputes `due = 0` from the already-updated + * fee), whereas a "record what happened" write has no arithmetic at all. On + * this table the unique constraint plus this upsert are the ONLY thing between + * a Stripe redelivery and double-counted revenue. + * + * `update` is empty ON PURPOSE: an ApplicationFee's `amount` is immutable in + * Stripe (a later refund moves `amount_refunded`, never `amount`), so a + * redelivery has nothing new to say and must not be able to restate the row. + */ +export type FeeEarnedUpsert = { + where: { applicationFeeId: string }; + create: FeeEarnedRow; + update: Record; +}; + +export function feeEarnedUpsert(fee: StripeApplicationFee): FeeEarnedUpsert { + const row = feeEarnedRow(fee); + return { where: { applicationFeeId: row.applicationFeeId }, create: row, update: {} }; +} + +export type FeeEarnedResult = { kind: "recorded"; applicationFeeId: string }; + +/** + * Records one application fee, taking ONLY its id from the webhook body and + * re-reading it from Stripe (CLAUDE.md §5.3, fetch-and-verify — the same + * discipline lib/stripe-fee-refund.ts follows even though the signature is + * already verified). + * + * PLATFORM lookup, no `Stripe-Account` header: the fee is Sofra's own money, + * already transferred off the connected account. Sending the header here is the + * obvious way to build this wrong and yields a 404 for a fee that plainly exists. + * + * A fee whose connected account no registry entry names is recorded anyway. The + * money was earned whether or not we can name the tenant yet, and the join back + * to a slug happens at READ time (ADR-007) — `lib/commission-earnings.ts` + * reports such accounts rather than dropping them. + * + * The return is a single-member union rather than "recorded | already-known": + * Prisma's upsert does not report which branch ran, so the second value could + * not be produced honestly without paying for an extra read that no caller wants. + */ +export async function recordApplicationFee(applicationFeeId: string): Promise { + const fee = await stripeGet(`/v1/application_fees/${applicationFeeId}`); + const write = feeEarnedUpsert(fee); + await db.stripeApplicationFee.upsert(write); + return { kind: "recorded", applicationFeeId: write.where.applicationFeeId }; +} diff --git a/lib/stripe-fee-refund.ts b/lib/stripe-fee-refund.ts new file mode 100644 index 0000000..4f7f685 --- /dev/null +++ b/lib/stripe-fee-refund.ts @@ -0,0 +1,133 @@ +// "Fee follows the refund" (ADR-011 amendment, consequence 1). Stripe never +// auto-refunds an application fee when a connected account refunds a charge +// — in Stripe's own words the connected account "loses that amount". Sofra +// is the platform, so it is the only party that CAN return it. This module +// is the arithmetic (pure) and the two-call orchestration +// (app/api/webhooks/stripe/route.ts calls only the latter) that does it. +import { db } from "@/lib/db"; +import { stripeGet, stripePost } from "@/lib/stripe"; + +export type StripeCharge = { + id: string; + amount: number; + amount_refunded: number; + application_fee: string | null; +}; + +export type StripeApplicationFee = { + id: string; + account: string; + amount: number; + // `refunded` is true ONLY on a FULL refund — never branch on it. A partial + // refund leaves it false with a non-zero amount_refunded, which is exactly + // the case this module exists to handle. + refunded: boolean; + amount_refunded: number; + currency: string; + charge: string; + /// Epoch SECONDS (measured against the API, not assumed). Unused by the + /// refund path; it is the period anchor lib/stripe-fee-earned.ts records, and + /// it lives on the shared type because both paths read the SAME Stripe object. + created: number; +}; + +/** + * How much of an application fee is still owed back, given the charge's + * CURRENT refund state. + * + * `target = round_half_away_from_zero(feeAmount * chargeAmountRefunded / chargeAmount)`, + * `due = target - feeAmountRefunded`, clamped to `[0, feeAmount - feeAmountRefunded]`. + * + * ROUNDING IS DELIBERATE: half away from zero, so a tie rounds AGAINST Sofra + * and the restaurant keeps the extra minor unit rather than Sofra keeping it. + * `Math.round` in JS rounds half UP (toward +Infinity) — for these values, + * which are never negative, that IS half-away-from-zero. State this + * explicitly so nobody "fixes" it into banker's rounding later. + * + * Naturally idempotent: a webhook redelivery recomputes the same `target` + * from the same (by-then-updated) charge/fee state and gets `due = 0`. + */ +export function feeRefundAmount(args: { + chargeAmount: number; + chargeAmountRefunded: number; + feeAmount: number; + feeAmountRefunded: number; +}): number { + const { chargeAmount, chargeAmountRefunded, feeAmount, feeAmountRefunded } = args; + if (chargeAmount <= 0) return 0; // never divide by zero + const target = Math.round((feeAmount * chargeAmountRefunded) / chargeAmount); + const due = target - feeAmountRefunded; + return Math.max(0, Math.min(due, feeAmount - feeAmountRefunded)); +} + +export type FeeRefundResult = + | { kind: "no-fee" } + | { kind: "nothing-due" } + | { kind: "refunded"; amount: number; stripeRefundId: string }; + +/** + * Refunds whatever portion of `chargeId`'s application fee is now owed. + * + * Two Stripe resources, two different callers of "who this request is to" — + * getting this pair backwards is the obvious way to build this wrong: + * - the CHARGE lives on the CONNECTED ACCOUNT -> needs `Stripe-Account`. + * - the APPLICATION FEE belongs to the PLATFORM (it is Sofra's own money, + * already transferred off the connected account) -> NO `Stripe-Account`. + */ +export async function refundApplicationFeeForCharge( + connectedAccountId: string, + chargeId: string, +): Promise { + const charge = await stripeGet(`/v1/charges/${chargeId}`, { + account: connectedAccountId, + }); + if (!charge.application_fee) return { kind: "no-fee" }; + + // PLATFORM lookup — no `account` option. + const fee = await stripeGet( + `/v1/application_fees/${charge.application_fee}`, + ); + + const due = feeRefundAmount({ + chargeAmount: charge.amount, + chargeAmountRefunded: charge.amount_refunded, + feeAmount: fee.amount, + feeAmountRefunded: fee.amount_refunded, + }); + if (due === 0) return { kind: "nothing-due" }; + + // Keyed on the TARGET, not `due`: two concurrent deliveries that read the + // fee before either write lands compute the same target from the same + // stale state, so they share one Idempotency-Key and Stripe hands both the + // SAME refund back rather than creating two. + const target = fee.amount_refunded + due; + // `created` is Stripe's own clock for the refund, in epoch SECONDS (measured; + // the API returns it on a fee_refund, it was simply never typed here). It is + // recorded so that "earned minus refunded over a period" periodises BOTH + // halves on Stripe's clock instead of comparing Stripe's timestamp against + // our insert time — see the 20260905000000 migration. + const refund = await stripePost<{ id: string; amount: number; created: number }>( + `/v1/application_fees/${fee.id}/refunds`, + { amount: String(due) }, + { idempotencyKey: `feerefund:${fee.id}:${target}` }, + ); // PLATFORM — no `account` option. + + // Upsert, not create: the race above can hand this function the SAME + // `refund.id` twice (Stripe dedupes the POST; the local record still needs + // to dedupe too), and `stripeRefundId` is the unique idempotency anchor. + await db.stripeFeeRefund.upsert({ + where: { stripeRefundId: refund.id }, + create: { + stripeRefundId: refund.id, + applicationFeeId: fee.id, + connectedAccountId, + chargeId, + amount: refund.amount, + currency: fee.currency, + feeRefundedAt: new Date(refund.created * 1000), + }, + update: {}, + }); + + return { kind: "refunded", amount: refund.amount, stripeRefundId: refund.id }; +} diff --git a/lib/stripe-signature.ts b/lib/stripe-signature.ts new file mode 100644 index 0000000..c4dcdba --- /dev/null +++ b/lib/stripe-signature.ts @@ -0,0 +1,64 @@ +// Verifies Stripe's `Stripe-Signature` webhook header. +// +// Header format: `t=,v1=[,v1=]`. +// A SECOND `v1` occurs during a webhook secret ROTATION — Stripe signs with +// both the old and new secret for the overlap window — so this accepts when +// ANY `v1` value matches, not only the first. +// +// Pure and clock-free by design, same reason as lib/trial.ts: `nowSeconds` is +// always passed in, never read from `Date.now()` inside here, so a test can +// pin the clock and assert the replay window deterministically instead of +// racing a live one. +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +// Stripe's own webhook library defaults to this tolerance. Named so it reads +// as a stated policy at the call site, not a mystery literal. +export const SIGNATURE_TOLERANCE_SECONDS = 300; + +/** + * Constant-time hex comparison. Both sides are SHA-256'd to a fixed 32 bytes + * first — same trick as lib/cron-auth.ts — so a `v1` value of the wrong + * length can never make `timingSafeEqual` throw (which would 500 instead of + * "signature invalid" and leak the real digest's length via the error). + */ +function safeEqualHex(a: string, b: string): boolean { + const hash = (s: string) => createHash("sha256").update(s).digest(); + return timingSafeEqual(hash(a), hash(b)); +} + +/** + * True iff `header` is a well-formed, unexpired `Stripe-Signature` whose + * signed payload — `` `${t}.${rawBody}` `` — matches an HMAC-SHA256 of that + * payload under `secret` for at least one `v1` candidate. + * + * `rawBody` MUST be the exact bytes Stripe sent (as text, not a re-serialized + * JSON.parse output) — the signature is computed over those bytes, so a + * round-tripped body would fail verification even when genuine. + */ +export function verifyStripeSignature( + rawBody: string, + header: string, + secret: string, + nowSeconds: number, +): boolean { + let t: string | undefined; + const v1s: string[] = []; + for (const part of header.split(",")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + const key = part.slice(0, eq).trim(); + const value = part.slice(eq + 1).trim(); + if (key === "t" && value) t = value; + else if (key === "v1" && value) v1s.push(value); + } + if (!t || v1s.length === 0) return false; + + const timestamp = Number(t); + if (!Number.isFinite(timestamp)) return false; + // Both directions: a header from the future is as suspect as a stale one, + // and this is a REPLAY guard, not merely an expiry check. + if (Math.abs(nowSeconds - timestamp) > SIGNATURE_TOLERANCE_SECONDS) return false; + + const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); + return v1s.some((v1) => safeEqualHex(v1, expected)); +} diff --git a/lib/stripe-webhook-secrets.ts b/lib/stripe-webhook-secrets.ts new file mode 100644 index 0000000..f2b3268 --- /dev/null +++ b/lib/stripe-webhook-secrets.ts @@ -0,0 +1,86 @@ +// WHICH webhook secret verifies a Stripe delivery — and why this endpoint has +// TWO of them (workspace docs/plans/BACKLOG.md, the second commission blocker). +// +// The two Stripe event scopes are ORTHOGONAL, and that is not a design choice +// of ours, it is Stripe's. MEASURED 2026-09-04 against the API in test mode: +// +// queried with NO Stripe-Account header (the PLATFORM scope): +// application_fee.created = 5 charge.refunded = 0 +// queried with `Stripe-Account: acct_1UC065FfnKu8VnLM` (a CONNECTED account): +// application_fee.created = 0 charge.refunded = 2 +// +// The `charge.refunded` row is the CONTROL that makes the zeros trustworthy: +// those two are the halves of the verified fee-refund rail run +// (docs/runbooks/verify-the-fee-refund-rail.md §5), and they are ABSENT from +// the platform scope — so the platform list demonstrably excludes +// connected-account events rather than being broken. All five fee events carry +// `account: null`. +// +// An ApplicationFee is a PLATFORM-owned object (lib/stripe-fee-refund.ts states +// this, and the refund rail depends on it), so `application_fee.created` can +// NEVER arrive at the `connect: true` endpoint that carries `charge.refunded`. +// It needs a second, ACCOUNT-scoped (non-Connect) endpoint at the SAME URL, and +// Stripe gives every endpoint its own `whsec_` — hence two secrets, one handler. +// +// AND STRIPE DOES NOT REFUSE THE WRONG CONFIGURATION: creating a `connect: true` +// endpoint whose `enabled_events` is `[application_fee.created]` returns HTTP 200 +// and then simply never fires (probe created and deleted 2026-09-04). "No +// earnings recorded" would be indistinguishable from "no commission earned yet", +// which is the same silent shape as the fee-refund gap itself. +// +// Pure and clock-free, same discipline as lib/stripe-signature.ts, which this +// only ever calls: `nowSeconds` is passed in, never read here. +import { verifyStripeSignature } from "./stripe-signature"; + +/** Which Stripe endpoint a delivery came from. Named, not indexed, so a log + * line says WHICH secret was in play rather than "secret 0". */ +export type WebhookScope = "connect" | "account"; + +export type WebhookSecret = { scope: WebhookScope; secret: string }; + +/** + * The configured secrets, in the order they are tried. + * + * Trimmed and dropped when empty for the same reason `missingPairedStripeAccount` + * tests more than truthiness: a box `.env` line left as `SECRET= ` yields `" "`, + * which is truthy in JS and would make an unconfigured endpoint look configured — + * it would answer 400 "invalid signature" instead of 503 "not configured", and + * the runbook's step 2 uses exactly that distinction to prove the handler is set + * up before spending a charge on it. + * + * Connect first because it is the older, busier scope; the order is otherwise + * immaterial — a secret either verifies a given body or it does not. + */ +export function webhookSecrets(env: { + connect: string | undefined; + account: string | undefined; +}): WebhookSecret[] { + const secrets: WebhookSecret[] = []; + const connect = env.connect?.trim(); + const account = env.account?.trim(); + if (connect) secrets.push({ scope: "connect", secret: connect }); + if (account) secrets.push({ scope: "account", secret: account }); + return secrets; +} + +/** + * The scope whose secret verifies this delivery, or `null` when none does. + * + * EVERY configured secret is tried, not just the first: the two endpoints post + * to the same URL and nothing in the request says which one sent it. Returning + * the scope rather than a boolean is what lets the caller log a rejection + * against the set of secrets that were actually in play, so "the account + * endpoint's secret is wrong" is distinguishable from "we only have the Connect + * one configured at all". + */ +export function verifyingScope(args: { + rawBody: string; + header: string; + secrets: readonly WebhookSecret[]; + nowSeconds: number; +}): WebhookScope | null { + for (const { scope, secret } of args.secrets) { + if (verifyStripeSignature(args.rawBody, args.header, secret, args.nowSeconds)) return scope; + } + return null; +} diff --git a/lib/stripe.ts b/lib/stripe.ts new file mode 100644 index 0000000..8b45faf --- /dev/null +++ b/lib/stripe.ts @@ -0,0 +1,81 @@ +// Minimal typed Stripe client (fetch-based, no SDK dep — mirrors the Mollie +// pattern in lib/mollie.ts, which itself mirrors the Resend pattern in +// lib/email.ts). Tenant-facing payments (ADR-011 Job B) run entirely in the +// backend against the tenant's own Stripe Connect setup; the ONE thing this +// app owns is the platform-level Connect webhook +// (app/api/webhooks/stripe/route.ts) and the two calls it makes to return an +// application fee when a connected account refunds a charge +// (lib/stripe-fee-refund.ts). +// +// Stripe does NOT accept JSON — request bodies are +// application/x-www-form-urlencoded, form-encoded via URLSearchParams. + +const STRIPE_API = "https://api.stripe.com"; + +export function stripeConfigured(): boolean { + return Boolean(process.env.STRIPE_API_KEY); +} + +export class StripeError extends Error { + status: number; + code: string; + + constructor(status: number, code: string, message: string) { + super(`Stripe ${status} (${code}): ${message}`); + this.status = status; + this.code = code; + } +} + +async function stripe( + method: "GET" | "POST", + path: string, + form?: Record, + opts?: { account?: string; idempotencyKey?: string }, +): Promise { + const key = process.env.STRIPE_API_KEY; + if (!key) throw new StripeError(503, "not_configured", "STRIPE_API_KEY is not configured"); + const headers: Record = { + Authorization: `Bearer ${key}`, + "Content-Type": "application/x-www-form-urlencoded", + // Present ONLY when a caller passes an account: routes the request AT a + // connected account instead of the platform. Omitting it entirely (not + // sending it empty) is what makes a platform-owned resource reachable at + // all — see lib/stripe-fee-refund.ts for which of the two each call needs. + ...(opts?.account ? { "Stripe-Account": opts.account } : {}), + // Stripe dedupes retried POSTs on this header — callers pass a stable key + // so a webhook retry (or a race between two deliveries) can never create + // a second resource. + ...(opts?.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {}), + }; + const res = await fetch(`${STRIPE_API}${path}`, { + method, + headers, + body: form ? new URLSearchParams(form).toString() : undefined, + }); + if (!res.ok) { + let code = "unknown"; + let message = res.statusText; + try { + const err = (await res.json()) as { error?: { code?: string; type?: string; message?: string } }; + code = err.error?.code ?? err.error?.type ?? code; + message = err.error?.message ?? message; + } catch { + // non-JSON error body — keep statusText + } + throw new StripeError(res.status, code, message); + } + return (await res.json()) as T; +} + +export function stripeGet(path: string, opts?: { account?: string }): Promise { + return stripe("GET", path, undefined, opts); +} + +export function stripePost( + path: string, + form: Record, + opts?: { account?: string; idempotencyKey?: string }, +): Promise { + return stripe("POST", path, form, opts); +} diff --git a/lib/tenant-registry.ts b/lib/tenant-registry.ts index aabf166..5cecea6 100644 --- a/lib/tenant-registry.ts +++ b/lib/tenant-registry.ts @@ -57,6 +57,17 @@ const tenantSchema = z.object({ // hand-edited account we do not recognise must still render, not blank the // whole page. stripe_account: z.string().optional(), + // The per-transaction commission rate (bps) this entry enforces + // (SOFRA-PAYMENTS-PRICING-MODE-PLAN §3) — absent or `0` means `flat`, the same + // convention `lib/registry-commission-edit.ts` and `effectivePaymentsMode` both + // already use. Optional and unvalidated beyond `number`: the registry is the + // source of truth (ADR-003/007) — but it has to be listed here at all because + // zod STRIPS unknown keys, the SAME trap `stripe_account` fell into just above. + // Without this line, `effectivePaymentsMode` would read `undefined` for every + // tenant regardless of what the registry actually says, and every tenant whose + // billing intent is `commission` would render as permanently "pending" against + // a registry state this app could never actually observe. + payments_commission_bps: z.number().optional(), city: z.string().optional(), // Go-live date (YYYY-MM-DD), optional — the durable source for the onboard // form's "Live since" pre-fill (deploy repo owns the value; read-only here). diff --git a/lib/validation.ts b/lib/validation.ts index 45bfff6..fbf01d7 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isCommissionBps } from "./payments-pricing"; /** Split a comma-separated form field into trimmed, lowercased, non-empty values. * Shared so the schema validates exactly the list the action goes on to send. */ @@ -67,6 +68,12 @@ export const signupSchema = z.object({ // Coerced because it rides the form as a string. Never trusted — the route // re-quotes from the catalog and stores its own number. quotedCents: z.coerce.number().int().min(0).max(1_000_000).optional(), + // Payments pricing mode (SOFRA-PAYMENTS-PRICING-MODE-PLAN S3). Bounded like + // its neighbours above, not `z.enum(["flat","commission"])`: the *contents* + // are validated against `PaymentsMode` in `sanitizeSignupConfiguration` + // (`asPaymentsMode`), which is where an unrecognised value is DROPPED to + // `flat` rather than 400ing a real lead over a stale client bundle. + paymentsMode: z.string().trim().max(20).optional().or(z.literal("")), }); export const clientSchema = z.object({ @@ -144,3 +151,27 @@ export const onboardSchema = z.object({ .optional() .or(z.literal("")), }); + +// Amending an EXISTING tenant's payments mode/rate (SOFRA-PAYMENTS-PRICING-MODE-PLAN +// S2a). `commissionBps` reuses `isCommissionBps` rather than restating the 0-1000 +// ceiling a second time. The two refinements below keep the pair internally +// consistent — `flat` carrying a rate, or `commission` carrying none, are both +// states `effectivePaymentsMode`/the registry writer would have to guess a meaning +// for, so they are refused here instead. +export const paymentsModeChangeSchema = z + .object({ + tenantSlug: z + .string() + .trim() + .regex(/^[a-z0-9][a-z0-9-]{1,30}$/, "lowercase slug, 2-31 chars"), + mode: z.enum(["flat", "commission"]), + commissionBps: z.coerce.number().refine(isCommissionBps, "invalid commission rate"), + }) + .refine((v) => v.mode !== "flat" || v.commissionBps === 0, { + message: "flat mode carries no commission rate", + path: ["commissionBps"], + }) + .refine((v) => v.mode !== "commission" || v.commissionBps > 0, { + message: "commission mode needs a rate above zero", + path: ["commissionBps"], + }); diff --git a/messages/ar.json b/messages/ar.json index 9db6c04..5d80cb6 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -701,7 +701,10 @@ "modules": "الوحدات: {list}", "template": "القالب: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "يشتري وحدة المدفوعات عبر الإنترنت، لكن هذا السجل لا يتضمّن stripe_account — التجهيز يرفض هذا الاقتران ويتوقّف قبل قاعدة البيانات، فإعادة تجهيز هذا المستأجر لا تفعل شيئًا على الإطلاق. أضف الحساب إلى السجل، أو احذف الوحدة." + "stripeAccountMissing": "يشتري وحدة المدفوعات عبر الإنترنت، لكن هذا السجل لا يتضمّن stripe_account — التجهيز يرفض هذا الاقتران ويتوقّف قبل قاعدة البيانات، فإعادة تجهيز هذا المستأجر لا تفعل شيئًا على الإطلاق. أضف الحساب إلى السجل، أو احذف الوحدة.", + "paymentsModeFlat": "المدفوعات: سعر ثابت", + "paymentsModeCommission": "المدفوعات: عمولة ({percent})", + "paymentsModePending": "(معلّق — لم يُحدَّث السجل بعد)" }, "onboard": { "title": "إعداد شريك", @@ -749,6 +752,9 @@ "chosenLanguages": "اللغات", "chosenCurrency": "العملة", "quoted": "عرض السعر", + "chosenPaymentsMode": "وضع الدفع", + "paymentsModeFlat": "سعر ثابت", + "paymentsModeCommission": "عمولة ({percent})", "openProvisioning": "فتح التجهيز ←", "slugVerdict": { "invalid": "(غير صالح)", @@ -995,6 +1001,37 @@ "saving": "جارٍ الحفظ…", "saved": "تم الحفظ." }, + "paymentsMode": { + "title": "وضع تسعير المدفوعات", + "flatSummary": "يُفوَّتر بسعر ثابت — 19 يورو/شهريًا مقابل المدفوعات عبر الإنترنت.", + "commissionSummary": "يُفوَّتر بعمولة — {percent} من كل طلب عبر الإنترنت، دون رسوم وحدة شهرية.", + "pendingNote": "لم يواكب السجل هذا بعد — يجب دمج طلب سحب للسجل وإعادة تجهيز المستأجر قبل أن يصبح ساري المفعول. إعادة التشغيل العادية لا تعيد قراءة شيء.", + "crossover": "بمعدل {percent}، تكلف العمولة مثل الوحدة ذات السعر الثابت (19 يورو/شهريًا) تمامًا عند نحو {amount} من الإيرادات الشهرية عبر الإنترنت. تحت هذا الرقم يدفع هذا المستأجر أقل مع العمولة مقارنة بالسعر الثابت؛ وفوقه، أكثر.", + "modeLabel": "الوضع", + "modeFlat": "سعر ثابت (19 يورو/شهريًا)", + "modeCommission": "عمولة", + "rateLabel": "المعدل (نقاط أساس، 100 = 1٪)", + "rateHint": "مثال: 150 = 1.50٪. الحد الأقصى {max} نقطة أساس.", + "notEligibleRegistryUnavailable": "تعذّرت قراءة سجل المستأجرين، لذا لا يمكن التحقق من أهلية العمولة الآن.", + "notEligibleNotPaired": "سجل هذا المستأجر لا يتضمّن online-payments وstripe_account معًا — يرفض التجهيز أي معدل أكبر من صفر بدون هذا الاقتران، قبل قاعدة البيانات. تبقى العمولة معطّلة حتى يتوفّر الاثنان.", + "submit": "حفظ", + "saving": "جارٍ الحفظ…", + "prOpened": "تم فتح طلب سحب للسجل — ادمجه، ثم أعد تجهيز المستأجر.", + "alreadySet": "كان السجل يحمل هذا المعدل بالفعل. لم يلزم فتح طلب سحب." + }, + "commissionEarnings": { + "title": "العمولة المحصَّلة", + "intro": "ما حصّله حساب هذا المستأجر المرتبط فعليًا من رسوم المنصة، بجانب ما تمت إعادته. ساعة Stripe نفسها وعملة عملية الدفع — وليست دفاتر Sofra باليورو.", + "period": "من {from} إلى {to} (الشهر التقويمي السابق والحالي، بتوقيت UTC).", + "earned": "المحصَّل", + "refunded": "المُعاد", + "net": "الصافي", + "fees": "{count} رسوم", + "empty": "هذا الحساب تحت المراقبة ولم يحصّل شيئًا في هذه الفترة.", + "noAccount": "لا يحمل سجل هذا المستأجر أي stripe_account، لذا لا يوجد حساب تُعرض رسومه. هذه هي الحالة الطبيعية إلى أن يُكمل المطعم تسجيله لدى Stripe.", + "registryUnavailable": "تعذّرت قراءة سجل المستأجرين، لذا فإن الحساب المرتبط غير معروف الآن. لا يُعرض أي رقم بدلًا من عرض رقم خاطئ.", + "unmatchedRefunds": "{count} من عمليات الاسترداد في هذه الفترة ليس لها رسوم مسجَّلة — فرسومها سابقة لبدء التسجيل، لذا فالصافي أعلاه أقل من الحقيقة." + }, "cron": { "title": "Scheduled sweeps", "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", @@ -1017,6 +1054,7 @@ "errors": { "invalidClient": "اسم المطعم مطلوب (ويجب أن يكون البريد الإلكتروني صالحًا).", "clientNotFound": "العميل غير موجود.", + "clientNotProvisioned": "هذا العميل ليس لديه تطبيق خاص به بعد، لذا لا يوجد تسعير لتغييره.", "statusManaged": "حالة هذا العميل تديرها سفرة الآن.", "invalidStatus": "حالة غير صالحة.", "invalidNote": "لا يمكن أن تكون الملاحظة فارغة (2000 حرف كحد أقصى).", @@ -1046,6 +1084,9 @@ "provisionFailed": "تعذّر فتح طلب سحب التزويد. تحقق من السجلات.", "slugReserved": "معرّف المستأجر هذا محجوز ولا يمكن استخدامه.", "awaitingFirstPayment": "لا توجد بعد دفعة أولى مكتملة لخطة هذا المستأجر. لا يُجهَّز المستأجر ذاتي الخدمة إلا بعد الدفع.", + "billingNotFound": "لم يُعثر على خطة فوترة لهذا المستأجر.", + "paymentsModeUnchanged": "هذا المستأجر بالفعل على هذا الوضع وبهذا المعدل.", + "paymentsModeChangeFailed": "تعذّر اقتراح تغيير وضع الدفع. تحقق من السجلات.", "identityNotFound": "لم تعد هوية الفوترة هذه موجودة.", "noVatNumber": "لا يوجد رقم ضريبة قيمة مضافة للتحقق منه.", "invoice": { @@ -1170,6 +1211,24 @@ "almostReadyTitle": "أوشكنا على الانتهاء", "almostReadyBody": "جارٍ تشغيل تطبيقك. وفور أن يستجيب، ستوضّح لك هذه الصفحة كيفية تسجيل الدخول." }, + "clientPaymentsMode": { + "title": "تسعير المدفوعات عبر الإنترنت", + "flatSummary": "سعر ثابت — 19 يورو/شهريًا مقابل المدفوعات عبر الإنترنت، ويحتفظ عميلك بكل سنت من كل طلب (بعد خصم رسوم Stripe).", + "commissionSummary": "عمولة — {percent} من كل طلب عبر الإنترنت، ودون رسوم شهرية لوحدة المدفوعات عبر الإنترنت.", + "pendingNote": "هذا طلب لم يُطبَّق بعد. على SofraPiwas دمج التغيير وإعادة تجهيز تطبيق عميلك قبل أن يصبح ساري المفعول — وحتى ذلك الحين تبقى الفوترة على حالها السابق.", + "crossover": "بمعدل {percent}، تكلف العمولة مثل الوحدة ذات السعر الثابت (19 يورو/شهريًا) تمامًا عند نحو {amount} من الإيرادات الشهرية عبر الإنترنت. تحت هذا الرقم يدفع عميلك أقل مع العمولة مقارنة بالسعر الثابت؛ وفوقه، أكثر.", + "modeLabel": "التسعير", + "modeFlat": "سعر ثابت (19 يورو/شهريًا)", + "modeCommission": "عمولة", + "rateLabel": "المعدل (نقاط أساس، 100 = 1٪)", + "rateHint": "مثال: 150 = 1.50٪. الحد الأقصى {max} نقطة أساس.", + "notEligibleRegistryUnavailable": "لا يمكننا التحقق من إعداد هذا العميل الآن، لذا تبقى العمولة معطّلة في الوقت الحالي.", + "notEligibleNotPaired": "تتطلب العمولة أن يمتلك هذا العميل وحدة المدفوعات عبر الإنترنت وحساب Stripe خاصًا به. اطلب منّا الاثنين، وسيُفتح هذا الخيار.", + "submit": "حفظ", + "saving": "جارٍ الحفظ…", + "prOpened": "أُرسل إلى SofraPiwas. سنطبّقه على تطبيق عميلك ونؤكد لك — لم يتغيّر شيء في صفحة الدفع بعد.", + "alreadySet": "تطبيق عميلك يعمل بهذا المعدل بالفعل. لا شيء لإرساله." + }, "paymentsPending": { "kicker": "الدفع بالبطاقة", "title": "الدفع بالبطاقة في طريقه إليك", @@ -1455,6 +1514,15 @@ "bundle": { "counter": "الكاونتر", "full-service": "خدمة كاملة" + }, + "paymentsMode": { + "title": "كيف تُحاسَب على المدفوعات عبر الإنترنت", + "flatLabel": "رسم ثابت — {price} شهريًا", + "flatHint": "سعر ثابت واحد، مهما بلغت مبيعاتك عبر الإنترنت.", + "commissionLabel": "بلا رسوم شهرية — {percent} لكل طلب", + "commissionHint": "لا شيء حتى يدفع أحد الضيوف بالفعل عبر الإنترنت.", + "crossover": "دون نحو {amount} من المبيعات الشهرية عبر الإنترنت، يكلفك معدل {percent} أقل من الرسم الثابت؛ وفوق ذلك يصبح الرسم الثابت أوفر.", + "deferredNote": "يحتاج الدفع عبر الإنترنت إلى حساب Stripe خاص بك، لا يمكن لغيرك إنشاؤه — سنرسل لك الرابط بمجرد أن يصبح مطعمك مباشرًا. إلى ذلك الحين، هذا مجرد تفضيلك المسجَّل؛ يُفعَّل مباشرة بعد ذلك." } } }, diff --git a/messages/de.json b/messages/de.json index 33229cb..84ba191 100644 --- a/messages/de.json +++ b/messages/de.json @@ -701,7 +701,10 @@ "modules": "Module: {list}", "template": "Template: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "Kauft Online-Zahlungen, aber dieser Eintrag enthält kein stripe_account — die Provisionierung lehnt das Paar ab und bricht vor der Datenbank ab, ein erneutes Provisionieren dieses Tenants bewirkt also gar nichts. Konto zum Eintrag hinzufügen oder das Modul entfernen." + "stripeAccountMissing": "Kauft Online-Zahlungen, aber dieser Eintrag enthält kein stripe_account — die Provisionierung lehnt das Paar ab und bricht vor der Datenbank ab, ein erneutes Provisionieren dieses Tenants bewirkt also gar nichts. Konto zum Eintrag hinzufügen oder das Modul entfernen.", + "paymentsModeFlat": "Zahlungen: pauschal", + "paymentsModeCommission": "Zahlungen: Provision ({percent})", + "paymentsModePending": "(ausstehend — Registry noch nicht aktualisiert)" }, "onboard": { "title": "Partner onboarden", @@ -749,6 +752,9 @@ "chosenLanguages": "Sprachen", "chosenCurrency": "Währung", "quoted": "Angebot", + "chosenPaymentsMode": "Zahlungsmodus", + "paymentsModeFlat": "Pauschal", + "paymentsModeCommission": "Provision ({percent})", "openProvisioning": "Bereitstellung öffnen →", "slugVerdict": { "invalid": "(ungültig)", @@ -995,6 +1001,37 @@ "saving": "Wird gespeichert…", "saved": "Gespeichert." }, + "paymentsMode": { + "title": "Zahlungspreismodus", + "flatSummary": "Pauschal abgerechnet — 19 €/Monat für Online-Zahlungen.", + "commissionSummary": "Nach Provision abgerechnet — {percent} von jeder Online-Bestellung, keine monatliche Modulgebühr.", + "pendingNote": "Die Registry hat das noch nicht nachvollzogen — ein Registry-PR muss zusammengeführt und der Tenant erneut bereitgestellt werden, bevor es wirksam wird. Ein einfacher Neustart liest nichts neu ein.", + "crossover": "Bei {percent} kostet die Provision genauso viel wie das pauschale Modul (19 €/Monat), bei etwa {amount} monatlichem Online-Umsatz. Darunter zahlt dieser Tenant bei Provision weniger als pauschal; darüber mehr.", + "modeLabel": "Modus", + "modeFlat": "Pauschal (19 €/Monat)", + "modeCommission": "Provision", + "rateLabel": "Satz (Basispunkte, 100 = 1 %)", + "rateHint": "z. B. 150 = 1,50 %. Obergrenze {max} Basispunkte.", + "notEligibleRegistryUnavailable": "Das Tenant-Registry konnte nicht gelesen werden, daher kann die Provisionsberechtigung gerade nicht geprüft werden.", + "notEligibleNotPaired": "Der Registry-Eintrag dieses Tenants enthält nicht beide, online-payments UND ein stripe_account — die Provisionierung lehnt einen Satz über null ohne dieses Paar ab, noch vor der Datenbank. Provision bleibt aus, bis beides vorhanden ist.", + "submit": "Speichern", + "saving": "Wird gespeichert…", + "prOpened": "Registry-PR geöffnet — zusammenführen, dann den Tenant erneut bereitstellen.", + "alreadySet": "Die Registry enthielt diesen Satz bereits. Kein PR war nötig." + }, + "commissionEarnings": { + "title": "Verdiente Kommission", + "intro": "Was das verbundene Konto dieses Mandanten tatsächlich an Plattformgebühren eingenommen hat, neben dem, was zurückgegeben wurde. Stripes eigene Uhr und die Währung der Zahlung — nicht Sofras EUR-Bücher.", + "period": "{from} bis {to} (der vorherige und der laufende Kalendermonat, UTC).", + "earned": "Eingenommen", + "refunded": "Zurückgegeben", + "net": "Netto", + "fees": "{count} Gebühren", + "empty": "Dieses Konto wird beobachtet und hat in diesem Zeitraum nichts eingenommen.", + "noAccount": "Der Registry-Eintrag dieses Mandanten trägt kein stripe_account, es gibt also kein Konto, für das Gebühren gemeldet werden könnten. Das ist der normale Zustand, solange das Restaurant das Stripe-Onboarding nicht abgeschlossen hat.", + "registryUnavailable": "Die Mandanten-Registry konnte nicht gelesen werden, das verbundene Konto ist daher derzeit unbekannt. Es wird keine Zahl angezeigt statt einer falschen.", + "unmatchedRefunds": "{count} Rückerstattungen in diesem Zeitraum haben keine erfasste Gebühr — ihre Gebühren liegen vor der Erfassung, das Netto oben ist also niedriger als die Wahrheit." + }, "cron": { "title": "Scheduled sweeps", "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", @@ -1017,6 +1054,7 @@ "errors": { "invalidClient": "Der Restaurantname ist erforderlich (die E-Mail muss gültig sein).", "clientNotFound": "Kunde nicht gefunden.", + "clientNotProvisioned": "Dieser Kunde hat noch keine eigene App, es gibt also keine Preisgestaltung zu ändern.", "statusManaged": "Der Status dieses Kunden wird jetzt von SofraPiwas verwaltet.", "invalidStatus": "Ungültiger Status.", "invalidNote": "Die Notiz darf nicht leer sein (max. 2000 Zeichen).", @@ -1046,6 +1084,9 @@ "provisionFailed": "Bereitstellungs-PR konnte nicht geöffnet werden. Prüfen Sie die Protokolle.", "slugReserved": "Dieser Tenant-Slug ist reserviert und kann nicht verwendet werden.", "awaitingFirstPayment": "Der Plan dieses Tenants hat noch keine abgeschlossene erste Zahlung. Ein Self-Service-Tenant wird erst nach Zahlung provisioniert.", + "billingNotFound": "Kein Abrechnungsplan für diesen Tenant gefunden.", + "paymentsModeUnchanged": "Dieser Tenant hat bereits diesen Modus und diesen Satz.", + "paymentsModeChangeFailed": "Die Änderung des Zahlungsmodus konnte nicht vorgeschlagen werden. Prüfen Sie die Protokolle.", "identityNotFound": "Diese Rechnungsidentität existiert nicht mehr.", "noVatNumber": "Es gibt keine USt-IdNr. zum Prüfen.", "invoice": { @@ -1170,6 +1211,24 @@ "almostReadyTitle": "Fast geschafft", "almostReadyBody": "Ihre App wird gestartet. Sobald sie antwortet, zeigt Ihnen diese Seite, wie Sie sich anmelden." }, + "clientPaymentsMode": { + "title": "Preise für Online-Zahlungen", + "flatSummary": "Pauschal — 19 €/Monat für Online-Zahlungen, und Ihr Kunde behält jeden Cent jeder Bestellung (abzüglich Stripe-Gebühr).", + "commissionSummary": "Provision — {percent} jeder Online-Bestellung, und keine monatliche Gebühr für das Online-Zahlungsmodul.", + "pendingNote": "Das ist beantragt, noch nicht angewendet. SofraPiwas muss die Änderung zusammenführen und die App Ihres Kunden erneut bereitstellen, bevor sie wirksam wird — bis dahin bleibt die alte Abrechnung.", + "crossover": "Bei {percent} kostet die Provision genauso viel wie das pauschale Modul (19 €/Monat), bei etwa {amount} monatlichem Online-Umsatz. Darunter zahlt Ihr Kunde bei Provision weniger als pauschal; darüber mehr.", + "modeLabel": "Preismodell", + "modeFlat": "Pauschal (19 €/Monat)", + "modeCommission": "Provision", + "rateLabel": "Satz (Basispunkte, 100 = 1 %)", + "rateHint": "z. B. 150 = 1,50 %. Obergrenze {max} Basispunkte.", + "notEligibleRegistryUnavailable": "Wir können die Einrichtung dieses Kunden gerade nicht prüfen, deshalb bleibt die Provision vorerst aus.", + "notEligibleNotPaired": "Für die Provision braucht dieser Kunde das Online-Zahlungsmodul und ein eigenes Stripe-Konto. Fragen Sie uns nach beidem, dann steht diese Wahl offen.", + "submit": "Speichern", + "saving": "Wird gespeichert…", + "prOpened": "An SofraPiwas gesendet. Wir wenden es auf die App Ihres Kunden an und bestätigen — an der Kasse hat sich noch nichts geändert.", + "alreadySet": "Die App Ihres Kunden läuft bereits mit diesem Satz. Nichts zu senden." + }, "paymentsPending": { "kicker": "Kartenzahlung", "title": "Ihre Kartenzahlung ist unterwegs", @@ -1455,6 +1514,15 @@ "bundle": { "counter": "Theke", "full-service": "Voller Service" + }, + "paymentsMode": { + "title": "So bezahlen Sie für Online-Zahlungen", + "flatLabel": "Festpreis — {price}/Monat", + "flatHint": "Ein fester Preis, unabhängig von Ihrem Online-Umsatz.", + "commissionLabel": "Keine monatliche Gebühr — {percent} pro Bestellung", + "commissionHint": "Nichts, bis ein Gast tatsächlich online bezahlt.", + "crossover": "Unter etwa {amount} Online-Umsatz im Monat kostet der Satz von {percent} weniger als der Festpreis; darüber ist der Festpreis günstiger.", + "deferredNote": "Online-Zahlungen benötigen Ihr eigenes Stripe-Konto, das nur Sie einrichten können — wir schicken Ihnen den Link, sobald Sie live sind. Bis dahin ist dies nur Ihre Präferenz; sie wird direkt danach aktiviert." } } }, diff --git a/messages/en.json b/messages/en.json index 48bd912..892fece 100644 --- a/messages/en.json +++ b/messages/en.json @@ -701,7 +701,10 @@ "modules": "modules: {list}", "template": "template: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "Buys online payments, but this entry records no stripe_account — provisioning refuses the pair and stops before the database, so re-provisioning this tenant does nothing at all. Add the account to the entry, or drop the module." + "stripeAccountMissing": "Buys online payments, but this entry records no stripe_account — provisioning refuses the pair and stops before the database, so re-provisioning this tenant does nothing at all. Add the account to the entry, or drop the module.", + "paymentsModeFlat": "payments: flat", + "paymentsModeCommission": "payments: commission ({percent})", + "paymentsModePending": "(pending — registry not yet updated)" }, "onboard": { "title": "Onboard a partner", @@ -749,6 +752,9 @@ "chosenLanguages": "Languages", "chosenCurrency": "Currency", "quoted": "Quoted", + "chosenPaymentsMode": "Payments mode", + "paymentsModeFlat": "Flat", + "paymentsModeCommission": "Commission ({percent})", "openProvisioning": "Open provisioning →", "slugVerdict": { "invalid": "(invalid)", @@ -995,6 +1001,37 @@ "saving": "Saving…", "saved": "Saved." }, + "paymentsMode": { + "title": "Payments pricing mode", + "flatSummary": "Billed flat — €19/mo for online payments.", + "commissionSummary": "Billed by commission — {percent} of every online order, no monthly module fee.", + "pendingNote": "The registry has not caught up with this yet — a registry PR has to merge and the tenant has to be re-provisioned before it takes effect. A plain restart re-reads nothing.", + "crossover": "At {percent}, commission costs the same as the flat module (€19/mo) at about {amount} of monthly online turnover. Below that this tenant pays less on commission than on flat; above it, more.", + "modeLabel": "Mode", + "modeFlat": "Flat (€19/mo)", + "modeCommission": "Commission", + "rateLabel": "Rate (basis points, 100 = 1%)", + "rateHint": "e.g. 150 = 1.50%. Ceiling {max} bps.", + "notEligibleRegistryUnavailable": "The tenant registry could not be read, so commission eligibility cannot be checked right now.", + "notEligibleNotPaired": "This tenant's registry entry does not carry both online-payments and a stripe_account — provisioning refuses a non-zero rate without that pair, before the database. Commission stays off until both are there.", + "submit": "Save", + "saving": "Saving…", + "prOpened": "Registry PR opened — merge it, then re-provision the tenant.", + "alreadySet": "The registry already carried that rate. No PR was needed." + }, + "commissionEarnings": { + "title": "Commission earned", + "intro": "What this tenant's connected account actually collected in application fees, next to what was returned. Stripe's own clock and the charge's currency — not Sofra's EUR books.", + "period": "{from} to {to} (the previous and current calendar months, UTC).", + "earned": "Earned", + "refunded": "Returned", + "net": "Net", + "fees": "{count} fees", + "empty": "This account is being watched and has collected nothing in this period.", + "noAccount": "This tenant's registry entry carries no stripe_account, so there is no account to report fees for. That is the normal state until the restaurant has completed Stripe onboarding.", + "registryUnavailable": "The tenant registry could not be read, so this tenant's connected account is unknown right now. No figure is shown rather than a wrong one.", + "unmatchedRefunds": "{count} refunds in this period have no recorded fee — their fees predate fee recording, so the net above is lower than the truth." + }, "cron": { "title": "Scheduled sweeps", "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", @@ -1017,6 +1054,7 @@ "errors": { "invalidClient": "Restaurant name is required (email must be valid).", "clientNotFound": "Client not found.", + "clientNotProvisioned": "That client doesn't have its own app yet, so there is no pricing to change.", "statusManaged": "This client's status is managed by SofraPiwas now.", "invalidStatus": "Invalid status.", "invalidNote": "Note can't be empty (max 2000 chars).", @@ -1046,6 +1084,9 @@ "provisionFailed": "Couldn’t open the provisioning PR. Check the logs.", "slugReserved": "That tenant slug is reserved and cannot be used.", "awaitingFirstPayment": "This tenant's plan has no settled first payment yet. A self-serve tenant is provisioned only after it has paid.", + "billingNotFound": "No billing plan found for that tenant.", + "paymentsModeUnchanged": "That tenant is already on this mode and rate.", + "paymentsModeChangeFailed": "Couldn’t propose the payments mode change. Check the logs.", "identityNotFound": "That billing identity no longer exists.", "noVatNumber": "There is no VAT number to check.", "invoice": { @@ -1170,6 +1211,24 @@ "almostReadyTitle": "Almost there", "almostReadyBody": "Your app is starting up. As soon as it answers, this page will show you how to sign in." }, + "clientPaymentsMode": { + "title": "Online payments pricing", + "flatSummary": "Flat — €19/mo for online payments, and your client keeps every cent of every order (minus Stripe's own fee).", + "commissionSummary": "Commission — {percent} of every online order, and no monthly fee for the online-payments module.", + "pendingNote": "This is asked for, not applied yet. SofraPiwas has to merge the change and re-provision your client's app before it takes effect — until then they are billed and charged the old way.", + "crossover": "At {percent}, commission costs the same as the flat module (€19/mo) at about {amount} of monthly online turnover. Below that your client pays less on commission than on flat; above it, more.", + "modeLabel": "Pricing", + "modeFlat": "Flat (€19/mo)", + "modeCommission": "Commission", + "rateLabel": "Rate (basis points, 100 = 1%)", + "rateHint": "e.g. 150 = 1.50%. Ceiling {max} bps.", + "notEligibleRegistryUnavailable": "We can't check this client's setup right now, so commission stays off for the moment.", + "notEligibleNotPaired": "Commission needs this client to have the online-payments module and their own Stripe account. Ask us for both, and this switch opens up.", + "submit": "Save", + "saving": "Saving…", + "prOpened": "Sent to SofraPiwas. We apply it to your client's app and confirm — nothing has changed at their checkout yet.", + "alreadySet": "Your client's app already runs that rate. Nothing to send." + }, "paymentsPending": { "kicker": "Card payments", "title": "Card payments are on their way", @@ -1455,6 +1514,15 @@ "bundle": { "counter": "Counter", "full-service": "Full service" + }, + "paymentsMode": { + "title": "How you pay for online payments", + "flatLabel": "Flat fee — {price}/month", + "flatHint": "One fixed price, whatever you take online.", + "commissionLabel": "No monthly fee — {percent} per order", + "commissionHint": "Nothing until a guest actually pays online.", + "crossover": "Below about {amount} a month of online orders, the {percent} rate costs less than the flat fee; above that, the flat fee is cheaper.", + "deferredNote": "Online payments needs your own Stripe account, which only you can set up — we'll send you the link once you're live. Until then this is just your preference; it switches on right after." } } }, diff --git a/messages/fr.json b/messages/fr.json index 8e9186e..cad0ffa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -701,7 +701,10 @@ "modules": "modules : {list}", "template": "modèle : {template}", "stripeAccount": "Stripe : {account}", - "stripeAccountMissing": "Achète les paiements en ligne, mais cette entrée n'indique aucun stripe_account — le provisionnement refuse ce couple et s'arrête avant la base de données : reprovisionner ce tenant ne fait donc rien du tout. Ajoutez le compte à l'entrée, ou retirez le module." + "stripeAccountMissing": "Achète les paiements en ligne, mais cette entrée n'indique aucun stripe_account — le provisionnement refuse ce couple et s'arrête avant la base de données : reprovisionner ce tenant ne fait donc rien du tout. Ajoutez le compte à l'entrée, ou retirez le module.", + "paymentsModeFlat": "paiements : forfait", + "paymentsModeCommission": "paiements : commission ({percent})", + "paymentsModePending": "(en attente — registre pas encore mis à jour)" }, "onboard": { "title": "Intégrer un partenaire", @@ -749,6 +752,9 @@ "chosenLanguages": "Langues", "chosenCurrency": "Devise", "quoted": "Devis", + "chosenPaymentsMode": "Mode de paiement", + "paymentsModeFlat": "Forfait", + "paymentsModeCommission": "Commission ({percent})", "openProvisioning": "Ouvrir le provisionnement →", "slugVerdict": { "invalid": "(invalide)", @@ -995,6 +1001,37 @@ "saving": "Enregistrement…", "saved": "Enregistré." }, + "paymentsMode": { + "title": "Mode de tarification des paiements", + "flatSummary": "Facturé au forfait — 19 €/mois pour les paiements en ligne.", + "commissionSummary": "Facturé à la commission — {percent} de chaque commande en ligne, sans frais de module mensuel.", + "pendingNote": "Le registre n'a pas encore intégré ce changement — une PR de registre doit être fusionnée et le tenant reprovisionné avant que cela prenne effet. Un simple redémarrage ne relit rien.", + "crossover": "À {percent}, la commission coûte autant que le module au forfait (19 €/mois) à environ {amount} de chiffre d'affaires en ligne mensuel. En dessous, ce tenant paie moins en commission qu'au forfait ; au-dessus, plus.", + "modeLabel": "Mode", + "modeFlat": "Forfait (19 €/mois)", + "modeCommission": "Commission", + "rateLabel": "Taux (points de base, 100 = 1 %)", + "rateHint": "ex. 150 = 1,50 %. Plafond {max} points de base.", + "notEligibleRegistryUnavailable": "Le registre des tenants n'a pas pu être lu, l'éligibilité à la commission ne peut donc pas être vérifiée pour l'instant.", + "notEligibleNotPaired": "L'entrée de registre de ce tenant ne comporte pas à la fois online-payments et un stripe_account — le provisionnement refuse un taux non nul sans ce couple, avant même la base de données. La commission reste désactivée tant que les deux ne sont pas présents.", + "submit": "Enregistrer", + "saving": "Enregistrement…", + "prOpened": "PR de registre ouverte — fusionnez-la, puis reprovisionnez le tenant.", + "alreadySet": "Le registre contenait déjà ce taux. Aucune PR n'était nécessaire." + }, + "commissionEarnings": { + "title": "Commission perçue", + "intro": "Ce que le compte connecté de ce client a réellement encaissé en frais de plateforme, à côté de ce qui a été restitué. Horloge de Stripe et devise de la transaction — pas les livres en EUR de Sofra.", + "period": "Du {from} au {to} (le mois calendaire précédent et le mois en cours, UTC).", + "earned": "Perçu", + "refunded": "Restitué", + "net": "Net", + "fees": "{count} frais", + "empty": "Ce compte est surveillé et n'a rien encaissé sur cette période.", + "noAccount": "L'entrée de registre de ce client ne porte aucun stripe_account : il n'y a donc aucun compte pour lequel rapporter des frais. C'est l'état normal tant que le restaurant n'a pas terminé son inscription Stripe.", + "registryUnavailable": "Le registre des clients n'a pas pu être lu : le compte connecté de ce client est donc inconnu pour l'instant. Aucun chiffre n'est affiché plutôt qu'un chiffre faux.", + "unmatchedRefunds": "{count} remboursements de cette période n'ont aucun frais enregistré — leurs frais sont antérieurs à l'enregistrement, le net ci-dessus est donc inférieur à la réalité." + }, "cron": { "title": "Scheduled sweeps", "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", @@ -1017,6 +1054,7 @@ "errors": { "invalidClient": "Le nom du restaurant est requis (l'e-mail doit être valide).", "clientNotFound": "Client introuvable.", + "clientNotProvisioned": "Ce client n'a pas encore sa propre application : il n'y a donc pas de tarification à modifier.", "statusManaged": "Le statut de ce client est désormais géré par SofraPiwas.", "invalidStatus": "Statut invalide.", "invalidNote": "La note ne peut pas être vide (2000 caractères max).", @@ -1046,6 +1084,9 @@ "provisionFailed": "Impossible d’ouvrir la PR de provisionnement. Consultez les journaux.", "slugReserved": "Ce slug de tenant est réservé et ne peut pas être utilisé.", "awaitingFirstPayment": "Le plan de ce tenant n'a pas encore de premier paiement encaissé. Un tenant en libre-service n'est provisionné qu'après paiement.", + "billingNotFound": "Aucun plan de facturation trouvé pour ce tenant.", + "paymentsModeUnchanged": "Ce tenant est déjà sur ce mode et ce taux.", + "paymentsModeChangeFailed": "Impossible de proposer le changement de mode de paiement. Consultez les journaux.", "identityNotFound": "Cette identité de facturation n'existe plus.", "noVatNumber": "Aucun numéro de TVA à vérifier.", "invoice": { @@ -1170,6 +1211,24 @@ "almostReadyTitle": "Presque terminé", "almostReadyBody": "Votre application démarre. Dès qu'elle répond, cette page vous indiquera comment vous connecter." }, + "clientPaymentsMode": { + "title": "Tarification des paiements en ligne", + "flatSummary": "Forfait — 19 €/mois pour les paiements en ligne, et votre client garde chaque centime de chaque commande (hors frais Stripe).", + "commissionSummary": "Commission — {percent} de chaque commande en ligne, sans frais mensuels pour le module de paiements en ligne.", + "pendingNote": "C'est demandé, pas encore appliqué. SofraPiwas doit fusionner le changement et reprovisionner l'application de votre client avant qu'il prenne effet — d'ici là, la facturation reste l'ancienne.", + "crossover": "À {percent}, la commission coûte autant que le module au forfait (19 €/mois) à environ {amount} de chiffre d'affaires en ligne mensuel. En dessous, votre client paie moins en commission qu'au forfait ; au-dessus, plus.", + "modeLabel": "Tarification", + "modeFlat": "Forfait (19 €/mois)", + "modeCommission": "Commission", + "rateLabel": "Taux (points de base, 100 = 1 %)", + "rateHint": "ex. 150 = 1,50 %. Plafond {max} points de base.", + "notEligibleRegistryUnavailable": "Nous ne pouvons pas vérifier la configuration de ce client pour l'instant : la commission reste donc désactivée.", + "notEligibleNotPaired": "La commission exige que ce client dispose du module de paiements en ligne et de son propre compte Stripe. Demandez-nous les deux et ce choix s'ouvrira.", + "submit": "Enregistrer", + "saving": "Enregistrement…", + "prOpened": "Envoyé à SofraPiwas. Nous l'appliquons à l'application de votre client et vous confirmons — rien n'a encore changé à son encaissement.", + "alreadySet": "L'application de votre client applique déjà ce taux. Rien à envoyer." + }, "paymentsPending": { "kicker": "Paiements par carte", "title": "Vos paiements par carte arrivent", @@ -1455,6 +1514,15 @@ "bundle": { "counter": "Comptoir", "full-service": "Service complet" + }, + "paymentsMode": { + "title": "Comment vous payez pour le paiement en ligne", + "flatLabel": "Forfait fixe — {price}/mois", + "flatHint": "Un prix fixe, quel que soit votre chiffre d'affaires en ligne.", + "commissionLabel": "Aucun abonnement — {percent} par commande", + "commissionHint": "Rien tant qu'un client ne paie pas réellement en ligne.", + "crossover": "En dessous d'environ {amount} de chiffre d'affaires en ligne par mois, le taux de {percent} coûte moins cher que le forfait fixe ; au-dessus, le forfait fixe est plus avantageux.", + "deferredNote": "Le paiement en ligne nécessite votre propre compte Stripe, que vous seul pouvez créer — nous vous enverrons le lien dès que vous serez en ligne. D'ici là, ceci n'est qu'une préférence enregistrée ; elle s'active juste après." } } }, diff --git a/messages/nl.json b/messages/nl.json index de10294..dd7ece2 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -701,7 +701,10 @@ "modules": "modules: {list}", "template": "sjabloon: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "Koopt online betalingen, maar deze entry bevat geen stripe_account — provisionering weigert dit paar en stopt vóór de database, dus dit tenant opnieuw provisioneren doet helemaal niets. Voeg het account toe aan de entry, of haal de module eruit." + "stripeAccountMissing": "Koopt online betalingen, maar deze entry bevat geen stripe_account — provisionering weigert dit paar en stopt vóór de database, dus dit tenant opnieuw provisioneren doet helemaal niets. Voeg het account toe aan de entry, of haal de module eruit.", + "paymentsModeFlat": "betalingen: vast", + "paymentsModeCommission": "betalingen: commissie ({percent})", + "paymentsModePending": "(in behandeling — register nog niet bijgewerkt)" }, "onboard": { "title": "Partner onboarden", @@ -749,6 +752,9 @@ "chosenLanguages": "Talen", "chosenCurrency": "Valuta", "quoted": "Offerte", + "chosenPaymentsMode": "Betaalmodus", + "paymentsModeFlat": "Vast", + "paymentsModeCommission": "Commissie ({percent})", "openProvisioning": "Provisioning openen →", "slugVerdict": { "invalid": "(ongeldig)", @@ -995,6 +1001,37 @@ "saving": "Opslaan…", "saved": "Opgeslagen." }, + "paymentsMode": { + "title": "Betalingsprijsmodus", + "flatSummary": "Vast tarief — €19/maand voor online betalingen.", + "commissionSummary": "Op commissie — {percent} van elke online bestelling, geen maandelijkse modulekosten.", + "pendingNote": "Het register loopt hier nog niet in mee — een registry-PR moet worden samengevoegd en het tenant moet opnieuw worden ingericht voordat dit ingaat. Een gewone herstart leest niets opnieuw in.", + "crossover": "Bij {percent} kost commissie evenveel als de vaste module (€19/maand), bij ongeveer {amount} maandelijkse online omzet. Daaronder betaalt dit tenant minder op commissie dan op vast; daarboven meer.", + "modeLabel": "Modus", + "modeFlat": "Vast (€19/maand)", + "modeCommission": "Commissie", + "rateLabel": "Tarief (basispunten, 100 = 1%)", + "rateHint": "bijv. 150 = 1,50%. Maximum {max} basispunten.", + "notEligibleRegistryUnavailable": "Het tenant-register kon niet worden gelezen, dus de commissie-geschiktheid kan nu niet worden gecontroleerd.", + "notEligibleNotPaired": "De registry-entry van dit tenant bevat niet zowel online-payments als een stripe_account — provisionering weigert een tarief boven nul zonder dat paar, vóór de database. Commissie blijft uit totdat beide aanwezig zijn.", + "submit": "Opslaan", + "saving": "Opslaan…", + "prOpened": "Registry-PR geopend — voeg samen en richt het tenant opnieuw in.", + "alreadySet": "Het register bevatte dat tarief al. Geen PR nodig." + }, + "commissionEarnings": { + "title": "Verdiende commissie", + "intro": "Wat het gekoppelde account van deze klant daadwerkelijk aan platformkosten heeft geïnd, naast wat is teruggegeven. Stripes eigen klok en de valuta van de betaling — niet Sofra's EUR-boeken.", + "period": "{from} tot {to} (de vorige en de huidige kalendermaand, UTC).", + "earned": "Geïnd", + "refunded": "Teruggegeven", + "net": "Netto", + "fees": "{count} kosten", + "empty": "Dit account wordt gevolgd en heeft in deze periode niets geïnd.", + "noAccount": "De registryvermelding van deze klant bevat geen stripe_account, dus er is geen account om kosten voor te rapporteren. Dat is de normale toestand zolang het restaurant de Stripe-onboarding niet heeft afgerond.", + "registryUnavailable": "Het klantenregister kon niet worden gelezen, dus het gekoppelde account is nu onbekend. Er wordt geen bedrag getoond in plaats van een verkeerd bedrag.", + "unmatchedRefunds": "{count} terugbetalingen in deze periode hebben geen geregistreerde kosten — hun kosten dateren van vóór de registratie, dus het netto hierboven is lager dan de waarheid." + }, "cron": { "title": "Scheduled sweeps", "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", @@ -1017,6 +1054,7 @@ "errors": { "invalidClient": "Restaurantnaam is verplicht (e-mail moet geldig zijn).", "clientNotFound": "Klant niet gevonden.", + "clientNotProvisioned": "Deze klant heeft nog geen eigen app, dus er is geen prijsstelling om te wijzigen.", "statusManaged": "De status van deze klant wordt nu door SofraPiwas beheerd.", "invalidStatus": "Ongeldige status.", "invalidNote": "De notitie mag niet leeg zijn (max. 2000 tekens).", @@ -1046,6 +1084,9 @@ "provisionFailed": "Kon de inricht-PR niet openen. Controleer de logs.", "slugReserved": "Deze tenant-slug is gereserveerd en kan niet worden gebruikt.", "awaitingFirstPayment": "Het plan van deze tenant heeft nog geen voltooide eerste betaling. Een selfservice-tenant wordt pas na betaling geprovisioneerd.", + "billingNotFound": "Geen facturatieplan gevonden voor die tenant.", + "paymentsModeUnchanged": "Die tenant staat al op deze modus en dit tarief.", + "paymentsModeChangeFailed": "Kon de wijziging van de betaalmodus niet voorstellen. Controleer de logs.", "identityNotFound": "Deze facturatie-identiteit bestaat niet meer.", "noVatNumber": "Er is geen btw-nummer om te controleren.", "invoice": { @@ -1170,6 +1211,24 @@ "almostReadyTitle": "Bijna klaar", "almostReadyBody": "Je app wordt opgestart. Zodra hij antwoordt, laat deze pagina zien hoe je inlogt." }, + "clientPaymentsMode": { + "title": "Prijs voor online betalingen", + "flatSummary": "Vast — €19/maand voor online betalingen, en uw klant houdt elke cent van elke bestelling (minus de kosten van Stripe).", + "commissionSummary": "Commissie — {percent} van elke online bestelling, en geen maandelijkse kosten voor de module online betalingen.", + "pendingNote": "Dit is aangevraagd, nog niet toegepast. SofraPiwas moet de wijziging samenvoegen en de app van uw klant opnieuw inrichten voordat die ingaat — tot dan blijft de oude facturering gelden.", + "crossover": "Bij {percent} kost commissie evenveel als de vaste module (€19/maand), bij ongeveer {amount} maandelijkse online omzet. Daaronder betaalt uw klant minder op commissie dan op vast; daarboven meer.", + "modeLabel": "Prijsvorm", + "modeFlat": "Vast (€19/maand)", + "modeCommission": "Commissie", + "rateLabel": "Tarief (basispunten, 100 = 1%)", + "rateHint": "bijv. 150 = 1,50%. Maximum {max} basispunten.", + "notEligibleRegistryUnavailable": "We kunnen de inrichting van deze klant nu niet controleren, dus commissie blijft voorlopig uit.", + "notEligibleNotPaired": "Voor commissie heeft deze klant de module online betalingen en een eigen Stripe-account nodig. Vraag ons om beide, dan gaat deze keuze open.", + "submit": "Opslaan", + "saving": "Opslaan…", + "prOpened": "Verstuurd naar SofraPiwas. Wij passen het toe op de app van uw klant en bevestigen — bij hun kassa is nog niets veranderd.", + "alreadySet": "De app van uw klant draait dat tarief al. Niets te versturen." + }, "paymentsPending": { "kicker": "Kaartbetalingen", "title": "Uw kaartbetalingen zijn onderweg", @@ -1455,6 +1514,15 @@ "bundle": { "counter": "Toonbank", "full-service": "Volledige bediening" + }, + "paymentsMode": { + "title": "Hoe je betaalt voor online betalen", + "flatLabel": "Vaste prijs — {price}/maand", + "flatHint": "Eén vast bedrag, ongeacht je omzet online.", + "commissionLabel": "Geen maandelijkse kosten — {percent} per bestelling", + "commissionHint": "Niets, tot een gast echt online betaalt.", + "crossover": "Onder ongeveer {amount} aan online omzet per maand kost {percent} minder dan de vaste prijs; daarboven is de vaste prijs voordeliger.", + "deferredNote": "Online betalen heeft je eigen Stripe-account nodig, die alleen jij kunt aanmaken — we sturen je de link zodra je live bent. Tot die tijd is dit alleen je voorkeur; die gaat direct daarna in." } } }, diff --git a/messages/tr.json b/messages/tr.json index 2e79eaf..0a5ecb6 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -701,7 +701,10 @@ "modules": "modüller: {list}", "template": "şablon: {template}", "stripeAccount": "Stripe: {account}", - "stripeAccountMissing": "Çevrimiçi ödeme satın alınmış ama bu kayıtta stripe_account yok — provizyon bu ikiliyi reddeder ve veritabanından önce durur, dolayısıyla bu tenant'ı yeniden provizyonlamak hiçbir şey yapmaz. Hesabı kayda ekleyin ya da modülü çıkarın." + "stripeAccountMissing": "Çevrimiçi ödeme satın alınmış ama bu kayıtta stripe_account yok — provizyon bu ikiliyi reddeder ve veritabanından önce durur, dolayısıyla bu tenant'ı yeniden provizyonlamak hiçbir şey yapmaz. Hesabı kayda ekleyin ya da modülü çıkarın.", + "paymentsModeFlat": "ödemeler: sabit ücret", + "paymentsModeCommission": "ödemeler: komisyon ({percent})", + "paymentsModePending": "(beklemede — kayıt henüz güncellenmedi)" }, "onboard": { "title": "İş ortağı kaydı", @@ -749,6 +752,9 @@ "chosenLanguages": "Diller", "chosenCurrency": "Para birimi", "quoted": "Teklif", + "chosenPaymentsMode": "Ödeme modu", + "paymentsModeFlat": "Sabit ücret", + "paymentsModeCommission": "Komisyon ({percent})", "openProvisioning": "Kurulumu aç →", "slugVerdict": { "invalid": "(geçersiz)", @@ -995,6 +1001,37 @@ "saving": "Kaydediliyor…", "saved": "Kaydedildi." }, + "paymentsMode": { + "title": "Ödeme fiyatlandırma modu", + "flatSummary": "Sabit ücretle faturalanır — çevrimiçi ödemeler için ayda 19 €.", + "commissionSummary": "Komisyonla faturalanır — her çevrimiçi siparişin {percent}'i, aylık modül ücreti yok.", + "pendingNote": "Kayıt dosyası bunu henüz yansıtmıyor — yürürlüğe girmeden önce bir kayıt PR'sinin birleştirilmesi ve tenant'ın yeniden sağlanması gerekir. Basit bir yeniden başlatma hiçbir şeyi yeniden okumaz.", + "crossover": "{percent} oranında, komisyon aylık yaklaşık {amount} çevrimiçi ciroda sabit modülle (ayda 19 €) aynı maliyeti taşır. Bunun altında bu tenant komisyonda sabitten daha az öder; üstünde daha fazla.", + "modeLabel": "Mod", + "modeFlat": "Sabit (ayda 19 €)", + "modeCommission": "Komisyon", + "rateLabel": "Oran (baz puan, 100 = %1)", + "rateHint": "örn. 150 = %1,50. Tavan {max} baz puan.", + "notEligibleRegistryUnavailable": "Tenant kaydı okunamadığı için komisyon uygunluğu şu anda kontrol edilemiyor.", + "notEligibleNotPaired": "Bu tenant'ın kayıt girişi hem online-payments hem de bir stripe_account içermiyor — provizyon, bu ikili olmadan sıfırın üzerindeki bir oranı veritabanından önce reddeder. Her ikisi de olmadan komisyon kapalı kalır.", + "submit": "Kaydet", + "saving": "Kaydediliyor…", + "prOpened": "Kayıt PR'si açıldı — birleştirin, ardından tenant'ı yeniden sağlayın.", + "alreadySet": "Kayıt zaten bu oranı taşıyordu. PR gerekmedi." + }, + "commissionEarnings": { + "title": "Kazanılan komisyon", + "intro": "Bu kiracının bağlı hesabının platform ücreti olarak gerçekte topladığı tutar ve yanında iade edilen tutar. Stripe'ın kendi saati ve ödemenin para birimi — Sofra'nın EUR defterleri değil.", + "period": "{from} - {to} (önceki ve içinde bulunulan takvim ayı, UTC).", + "earned": "Toplanan", + "refunded": "İade edilen", + "net": "Net", + "fees": "{count} ücret", + "empty": "Bu hesap izleniyor ve bu dönemde hiçbir tutar toplamadı.", + "noAccount": "Bu kiracının kayıt girdisinde stripe_account yok, dolayısıyla ücret raporlanacak bir hesap da yok. Restoran Stripe kaydını tamamlayana kadar bu normal durumdur.", + "registryUnavailable": "Kiracı kaydı okunamadı, bu yüzden bağlı hesap şu an bilinmiyor. Yanlış bir rakam yerine hiçbir rakam gösterilmiyor.", + "unmatchedRefunds": "Bu dönemdeki {count} iadenin kayıtlı ücreti yok — ücretleri kayıt tutulmadan öncesine ait, bu nedenle yukarıdaki net gerçekte olduğundan düşüktür." + }, "cron": { "title": "Scheduled sweeps", "intro": "When each background sweep last actually ran. GitHub refused every scheduled job on this repo for six days in August 2026 and nothing surfaced it — this page is the thing that would have.", @@ -1017,6 +1054,7 @@ "errors": { "invalidClient": "Restoran adı zorunludur (e-posta geçerli olmalı).", "clientNotFound": "Müşteri bulunamadı.", + "clientNotProvisioned": "Bu müşterinin henüz kendi uygulaması yok, bu yüzden değiştirilecek bir fiyatlandırma da yok.", "statusManaged": "Bu müşterinin durumu artık SofraPiwas tarafından yönetiliyor.", "invalidStatus": "Geçersiz durum.", "invalidNote": "Not boş olamaz (en fazla 2000 karakter).", @@ -1046,6 +1084,9 @@ "provisionFailed": "Sağlama PR’si açılamadı. Günlükleri kontrol edin.", "slugReserved": "Bu tenant slug'ı ayrılmıştır ve kullanılamaz.", "awaitingFirstPayment": "Bu kiracının planında henüz tamamlanmış bir ilk ödeme yok. Self servis kiracı yalnızca ödeme sonrası kurulur.", + "billingNotFound": "Bu kiracı için fatura planı bulunamadı.", + "paymentsModeUnchanged": "Bu kiracı zaten bu modda ve bu oranda.", + "paymentsModeChangeFailed": "Ödeme modu değişikliği önerilemedi. Günlükleri kontrol edin.", "identityNotFound": "Bu fatura kimliği artık mevcut değil.", "noVatNumber": "Doğrulanacak KDV numarası yok.", "invoice": { @@ -1170,6 +1211,24 @@ "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." }, + "clientPaymentsMode": { + "title": "Çevrimiçi ödeme fiyatlandırması", + "flatSummary": "Sabit — çevrimiçi ödemeler için ayda 19 €, ve müşteriniz her siparişin her kuruşunu alıkoyar (Stripe'ın kendi ücreti hariç).", + "commissionSummary": "Komisyon — her çevrimiçi siparişin {percent}'i, çevrimiçi ödeme modülü için aylık ücret yok.", + "pendingNote": "Bu talep edildi, henüz uygulanmadı. Yürürlüğe girmesi için SofraPiwas'ın değişikliği birleştirmesi ve müşterinizin uygulamasını yeniden sağlaması gerekir — o zamana kadar eski fiyatlandırma geçerlidir.", + "crossover": "{percent} oranında, komisyon aylık yaklaşık {amount} çevrimiçi ciroda sabit modülle (ayda 19 €) aynı maliyeti taşır. Bunun altında müşteriniz komisyonda sabitten daha az öder; üstünde daha fazla.", + "modeLabel": "Fiyatlandırma", + "modeFlat": "Sabit (ayda 19 €)", + "modeCommission": "Komisyon", + "rateLabel": "Oran (baz puan, 100 = %1)", + "rateHint": "örn. 150 = %1,50. Tavan {max} baz puan.", + "notEligibleRegistryUnavailable": "Bu müşterinin kurulumunu şu anda kontrol edemiyoruz, bu yüzden komisyon şimdilik kapalı kalıyor.", + "notEligibleNotPaired": "Komisyon için bu müşterinin çevrimiçi ödeme modülüne ve kendi Stripe hesabına sahip olması gerekir. İkisini de bizden isteyin, bu seçenek açılsın.", + "submit": "Kaydet", + "saving": "Kaydediliyor…", + "prOpened": "SofraPiwas'a gönderildi. Müşterinizin uygulamasına uygulayıp onaylayacağız — ödeme ekranlarında henüz bir şey değişmedi.", + "alreadySet": "Müşterinizin uygulaması zaten bu oranı kullanıyor. Gönderilecek bir şey yok." + }, "paymentsPending": { "kicker": "Kartlı ödeme", "title": "Kartlı ödemeniz yolda", @@ -1455,6 +1514,15 @@ "bundle": { "counter": "Tezgah", "full-service": "Tam servis" + }, + "paymentsMode": { + "title": "Online ödemeler için nasıl ücretlendirilirsiniz", + "flatLabel": "Sabit ücret — aylık {price}", + "flatHint": "Çevrimiçi cironuz ne olursa olsun tek bir sabit fiyat.", + "commissionLabel": "Aylık ücret yok — sipariş başına {percent}", + "commissionHint": "Bir misafir gerçekten online ödeme yapana kadar hiçbir ücret alınmaz.", + "crossover": "Aylık yaklaşık {amount} altındaki çevrimiçi ciroda {percent} oranı sabit ücretten daha ucuza gelir; bunun üzerinde sabit ücret daha avantajlıdır.", + "deferredNote": "Online ödemeler, yalnızca sizin oluşturabileceğiniz kendi Stripe hesabınızı gerektirir — yayına girer girmez bağlantıyı size göndereceğiz. O zamana kadar bu yalnızca tercihinizdir; hemen ardından devreye girer." } } }, diff --git a/prisma/migrations/20260904090000_payments_pricing_mode/migration.sql b/prisma/migrations/20260904090000_payments_pricing_mode/migration.sql new file mode 100644 index 0000000..b1223b8 --- /dev/null +++ b/prisma/migrations/20260904090000_payments_pricing_mode/migration.sql @@ -0,0 +1,30 @@ +-- Payments pricing mode: flat fee or per-transaction commission +-- (workspace docs/plans/SOFRA-PAYMENTS-PRICING-MODE-PLAN.md, S1). +-- +-- Two columns on TenantBilling — the BILLING truth for how a tenant is charged +-- for online payments. The registry's own `payments_commission_bps` +-- (deploy repo tenants/registry.yml, read by lib/provisioning-registry.ts) is +-- the separate ENFORCEMENT truth: what actually reaches the tenant's backend +-- and is sent to Stripe as `application_fee_amount`. The two are allowed to +-- disagree during the registry-PR window (this app proposes a PR, it never +-- writes to a box — ADR-003/007) and are reconciled only when that PR merges +-- and the tenant is re-provisioned. +-- +-- ADDITIVE AND SAFE ON A DB WITH LIVE ROWS: two new columns, both with +-- defaults, on an existing table — no rewrite of any other column, no +-- backfill, no data loss. `paymentsMode` defaults to 'flat' and +-- `paymentsCommissionBps` defaults to 0, which is what EVERY existing +-- TenantBilling row already means today (flat-fee, no commission) — so this +-- changes billing for precisely nobody until someone deliberately switches a +-- tenant. NOT NULL is safe with a default on Postgres because the column is +-- backfilled from the default for existing rows as part of adding it. +-- +-- No CHECK constraint on paymentsCommissionBps's range (0-1000, 10% ceiling): +-- that ceiling is enforced in application code (lib/payments-pricing.ts, +-- MAX_COMMISSION_BPS) and re-checked independently in provision-tenant.sh and +-- the backend before it can ever reach Stripe, so a DB-level constraint would +-- be a fourth copy of the same rule rather than the one place it is missing. + +ALTER TABLE "TenantBilling" + ADD COLUMN "paymentsMode" TEXT NOT NULL DEFAULT 'flat', + ADD COLUMN "paymentsCommissionBps" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20260904120000_stripe_fee_refund/migration.sql b/prisma/migrations/20260904120000_stripe_fee_refund/migration.sql new file mode 100644 index 0000000..6c6b98a --- /dev/null +++ b/prisma/migrations/20260904120000_stripe_fee_refund/migration.sql @@ -0,0 +1,52 @@ +-- Stripe Connect fee refunds (ADR-011 amendment, consequence 1 — "fee +-- follows the refund"). Stripe never auto-refunds an application fee when a +-- connected account refunds a charge — the connected account "loses that +-- amount", in Stripe's own words. Sofra is the platform, so it is the only +-- party that CAN return it, which is what the one platform-level Connect +-- webhook (app/api/webhooks/stripe/route.ts, lib/stripe-fee-refund.ts) now +-- does. This table is the record of every fee refund it has issued. +-- +-- ADDITIVE ONLY: one new table, no existing column, index or constraint +-- touched, nothing to backfill. +-- +-- `stripeRefundId` is UNIQUE and is the natural idempotency anchor — Stripe's +-- own refund id (`fr_...`), one row per refund Stripe actually created. A +-- webhook redelivery recomputes the same due amount from the CURRENT +-- (already-updated) fee state and gets 0 due before this table is ever +-- touched; the unique constraint is the second line of defence for the race +-- where two concurrent deliveries both read the fee before either write +-- landed — they share one Stripe idempotency key +-- (`feerefund:{feeId}:{target}`, keyed on the TARGET so both compute the +-- same one) and so get back the SAME `fr_...` id, and the write path upserts +-- on it rather than inserting twice. +-- +-- `connectedAccountId` and `chargeId` are NOT foreign keys — same seam as +-- "TenantBilling"."tenantSlug" and "BackupArtifact"."tenantSlug" (ADR-007): +-- the registry (which maps a tenant's `stripe_account` back to a slug) is +-- the source of truth and this app never writes to it, so the join back to a +-- tenant happens at READ time, not by an FK here. +-- +-- The columns beyond the idempotency anchor (amount, currency, +-- connectedAccountId, createdAt) are exactly what ADR-011's SECOND recorded +-- consequence — "no commission reporting surface" — will need: a future +-- per-tenant revenue readout groups these rows by connectedAccountId (joined +-- back to a tenant slug via the registry's `stripe_account` field at read +-- time) and sums `amount` as money RETURNED, next to whatever future table +-- records money EARNED. This table seeds that gap; it does not close it. + +CREATE TABLE "StripeFeeRefund" ( + "id" TEXT NOT NULL, + "stripeRefundId" TEXT NOT NULL, + "applicationFeeId" TEXT NOT NULL, + "connectedAccountId" TEXT NOT NULL, + "chargeId" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StripeFeeRefund_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "StripeFeeRefund_stripeRefundId_key" ON "StripeFeeRefund"("stripeRefundId"); +CREATE INDEX "StripeFeeRefund_connectedAccountId_idx" ON "StripeFeeRefund"("connectedAccountId"); +CREATE INDEX "StripeFeeRefund_chargeId_idx" ON "StripeFeeRefund"("chargeId"); diff --git a/prisma/migrations/20260904130000_signup_payments_mode/migration.sql b/prisma/migrations/20260904130000_signup_payments_mode/migration.sql new file mode 100644 index 0000000..a76182a --- /dev/null +++ b/prisma/migrations/20260904130000_signup_payments_mode/migration.sql @@ -0,0 +1,35 @@ +-- Record the payments pricing mode a signup was shown/chose (workspace +-- docs/plans/SOFRA-PAYMENTS-PRICING-MODE-PLAN.md, S3 — the public +-- configurator). Two columns on SignupRequest, alongside the existing +-- configurator answers (modules/languages/template/currency/quotedCents, +-- 20260729120000_signup_configurator). +-- +-- ADDITIVE AND SAFE ON A DB WITH LIVE ROWS: two new nullable columns, no +-- default, no backfill. Every row that exists today predates this choice +-- entirely, and NULL is the honest reading of that — not "flat", which would +-- claim the lead was shown a choice that did not exist yet. It is the exact +-- same reasoning the configurator columns next to it already use: null means +-- "the founder still chooses", not "chose the default". +-- +-- paymentsMode carries "flat" or "commission" (lib/payments-pricing.ts +-- PaymentsMode) — a plain TEXT column, not an enum, matching every other +-- column on this table and the repo's handwritten-migration workflow +-- (sofra/CLAUDE.md §5.2). No CHECK constraint for the same reason +-- TenantBilling.paymentsMode has none (20260904090000_payments_pricing_mode): +-- the read path (lib/payments-pricing.ts asPaymentsMode) is total and treats +-- anything other than the literal "commission" as "flat", so a constraint here +-- would be a second copy of a rule that already has exactly one place to live. +-- +-- paymentsCommissionBps carries the RATE, even though the buyer never picks a +-- number — DEFAULT_COMMISSION_BPS is applied on their behalf. It is stored +-- anyway because that default is not a constant of nature: it is a product +-- decision (lib/payments-pricing.ts) that can change, and a historical signup +-- whose meaning silently shifted the day the default moved would be a record +-- of nothing — nobody could tell, six months from now, what rate a buyer +-- actually saw on the page. So this column is the rate they were SHOWN, not a +-- rate that binds anything: onboarding still re-derives what a tenant is +-- actually billed from TenantBilling, exactly as quotedCents is a record of +-- what was shown and never a price that binds. + +ALTER TABLE "SignupRequest" ADD COLUMN "paymentsMode" TEXT; +ALTER TABLE "SignupRequest" ADD COLUMN "paymentsCommissionBps" INTEGER; diff --git a/prisma/migrations/20260905000000_stripe_application_fee/migration.sql b/prisma/migrations/20260905000000_stripe_application_fee/migration.sql new file mode 100644 index 0000000..e30c44a --- /dev/null +++ b/prisma/migrations/20260905000000_stripe_application_fee/migration.sql @@ -0,0 +1,80 @@ +-- Application fees EARNED (workspace docs/plans/BACKLOG.md, the SECOND blocker +-- before any tenant goes on a non-zero payment commission: "nothing reports +-- what the commission earned"). +-- +-- The mirror image of "StripeFeeRefund" (20260904120000), whose own header +-- names this table as the missing half: it records money RETURNED and says in +-- as many words that it "seeds that gap; it does not close it". Written by the +-- `application_fee.created` branch of app/api/webhooks/stripe/route.ts +-- (lib/stripe-fee-earned.ts). +-- +-- ADDITIVE ONLY, and safe on a DB with live rows: one new table plus one +-- NULLABLE column on "StripeFeeRefund". No existing column is retyped, no +-- constraint is dropped, nothing is backfilled, and no existing row changes +-- meaning. The new column stays NULL for the two rows the staging runbook +-- created, which is the honest state — Stripe's own refund timestamp was never +-- captured for them and cannot be invented after the fact. +-- +-- "applicationFeeId" is UNIQUE and is the idempotency anchor: Stripe's own fee +-- id (`fee_...`), one row per ApplicationFee Stripe actually created. It carries +-- MORE weight than "StripeFeeRefund"."stripeRefundId" does. The refund path is +-- idempotent three times over (the proration recomputes to zero on redelivery, +-- the Stripe Idempotency-Key is derived from the target, and only THEN the +-- unique index), whereas a "record what happened" write has no arithmetic that +-- neutralises a second delivery. Here the constraint is the ONLY thing standing +-- between a Stripe redelivery and double-counted revenue, and the write path +-- upserts on it rather than inserting. +-- +-- "connectedAccountId" and "chargeId" are NOT foreign keys — same seam as +-- "StripeFeeRefund" and "TenantBilling"."tenantSlug" (ADR-007): the registry +-- (which maps a tenant's `stripe_account` back to a slug) is the source of truth +-- and this app never writes to it, so the join back to a tenant happens at READ +-- time. A fee for an account no registry entry names is still recorded — the +-- money was earned whether or not we can name the tenant yet — and +-- lib/commission-earnings.ts reports such an account rather than dropping it. +-- +-- "amount" is the fee as CREATED and is never updated. Stripe leaves it +-- immutable and moves `amount_refunded` instead; storing that here would be a +-- snapshot that silently goes stale, and the refunded side already has its own +-- table. "currency" is the CHARGE's currency (chf for a Swiss tenant), NOT +-- Sofra's EUR books — the two must never be summed together, which is why the +-- readout groups by it instead of totalling. +-- +-- "feeCreatedAt" is Stripe's own clock (epoch seconds, converted at the write); +-- "createdAt" is ours. Periodisation uses Stripe's, so a redelivery days later +-- cannot move a fee into a later month. +-- +-- The event that fills this table is a PLATFORM event — MEASURED 2026-09-04 +-- against the API: `application_fee.created` carries `account: null` and never +-- reaches the `connect: true` endpoint that carries `charge.refunded`. It +-- arrives on a SECOND, account-scoped endpoint at the same URL, with its own +-- `whsec_` (STRIPE_ACCOUNT_WEBHOOK_SECRET). Until that endpoint exists at +-- Stripe this table stays correctly empty. + +CREATE TABLE "StripeApplicationFee" ( + "id" TEXT NOT NULL, + "applicationFeeId" TEXT NOT NULL, + "connectedAccountId" TEXT NOT NULL, + "chargeId" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL, + "feeCreatedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "StripeApplicationFee_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "StripeApplicationFee_applicationFeeId_key" + ON "StripeApplicationFee"("applicationFeeId"); +CREATE INDEX "StripeApplicationFee_connectedAccountId_feeCreatedAt_idx" + ON "StripeApplicationFee"("connectedAccountId", "feeCreatedAt"); +CREATE INDEX "StripeApplicationFee_chargeId_idx" + ON "StripeApplicationFee"("chargeId"); + +-- Give the refunded side Stripe's clock too, so "earned minus refunded over a +-- period" periodises BOTH halves on the same clock instead of comparing +-- Stripe's timestamp against our insert time. Nullable and un-backfilled, per +-- the paragraph above. Verified against the API before relying on it: a +-- `fee_refund` object does carry `created` (epoch seconds) — it was simply +-- never typed in lib/stripe-fee-refund.ts. +ALTER TABLE "StripeFeeRefund" ADD COLUMN "feeRefundedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cbfbe04..784420e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -149,6 +149,22 @@ model SignupRequest { /// were shown, NOT a price that binds — always re-quote at onboarding. quotedCents Int? + // --- Payments pricing mode choice (SOFRA-PAYMENTS-PRICING-MODE-PLAN S3) --- + // Mirrors TenantBilling.paymentsMode/paymentsCommissionBps — the same two + // columns, on the lead instead of the tenant, because the choice exists before + // either does. Both nullable for the same reason modules/languages/template/ + // currency above are: a lead captured before this shipped has none, and null + // means "the founder still chooses". + /// "flat" or "commission" (lib/payments-pricing.ts PaymentsMode), or null. + paymentsMode String? + /// The rate the buyer was actually SHOWN for commission — not a number they + /// pick, DEFAULT_COMMISSION_BPS applied on their behalf, but recorded anyway. + /// DEFAULT_COMMISSION_BPS can change later, and a historical signup whose + /// meaning silently shifted with a future default would be a record of + /// nothing. 0 alongside "flat", the shown default alongside "commission", + /// null exactly when paymentsMode is null. + paymentsCommissionBps 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[] @@ -537,6 +553,28 @@ model TenantBilling { // own rows, not only from GitHub refusing a duplicate branch. provisioningPrUrl String? + // Payments pricing mode (SOFRA-PAYMENTS-PRICING-MODE-PLAN, S1): "flat" (the + // `online-payments` module's list price) or "commission" (the module is free, + // Sofra takes a per-transaction cut instead). This pair is the BILLING truth — + // it is what the Mollie subscription amount is computed from. The registry's + // own `payments_commission_bps` (lib/provisioning-registry.ts) is the + // ENFORCEMENT truth — what actually reaches the tenant's backend and Stripe. + // The two CAN disagree, and the window is real: this app proposes a registry + // PR, it never writes to a box (ADR-003/007), so a mode change is + // `set here -> registry PR -> merge -> re-provision`, and until the last step + // the tenant is billed one way while enforced the other. + // + // Both default to what every tenant alive today already is: flat-fee, zero + // commission. Nothing is back-filled to commission — this migration changes + // billing for precisely nobody until someone deliberately switches a tenant. + paymentsMode String @default("flat") + // Basis points (100 = 1.00%), only meaningful when paymentsMode is + // "commission". Ceiling of 1000 (10%) enforced in lib/payments-pricing.ts + // (MAX_COMMISSION_BPS) — re-stated, not shared, because provision-tenant.sh + // and the backend each check their own copy too; see that constant's comment + // for why Stripe makes this a safety guard rather than a preference. + paymentsCommissionBps Int @default(0) + subscriptions BillingSubscription[] payments BillingPayment[] @@ -773,3 +811,108 @@ model BackupJob { @@index([box, status]) @@index([tenantSlug]) } + +/// Every application-fee refund the platform Connect webhook has issued +/// (ADR-011 amendment, consequence 1 — "fee follows the refund"; +/// lib/stripe-fee-refund.ts). Stripe never auto-refunds a fee when a +/// connected account refunds a charge; only the platform can, and this is +/// the record that it did. +/// +/// `connectedAccountId`/`chargeId` are NOT foreign keys — same seam as +/// TenantBilling.tenantSlug and BackupArtifact.tenantSlug (ADR-007): the +/// registry (which maps a tenant's `stripe_account` back to a slug) is the +/// source of truth and this app never writes to it, so the join back to a +/// tenant happens at READ time, not by an FK here. +/// +/// This table also seeds ADR-011's SECOND recorded consequence — "no +/// commission reporting surface": a future per-tenant revenue readout groups +/// these rows by `connectedAccountId` and sums `amount` as money RETURNED, +/// next to whatever future table records money EARNED. It does not close +/// that gap by itself. +model StripeFeeRefund { + id String @id @default(cuid()) + /// Stripe's own refund id (`fr_...`). @unique is the natural idempotency + /// anchor: a webhook redelivery recomputes zero due before ever reaching a + /// write, and the one race that DOES produce two callers for the same + /// refund (both read the fee before either write landed) shares one Stripe + /// idempotency key and so writes here via upsert, not insert. + stripeRefundId String @unique + applicationFeeId String + connectedAccountId String + chargeId String + /// Minor units, in `currency` below — the amount Sofra returned. + amount Int + currency String + createdAt DateTime @default(now()) + /// Stripe's OWN clock for the refund (epoch seconds, converted). Nullable + /// because it cannot be backfilled: the rows the staging runbook created + /// were written before this was captured, and inventing a timestamp for them + /// would be worse than admitting we do not have one. Readouts periodise on + /// `COALESCE(feeRefundedAt, createdAt)` so that "earned minus refunded over a + /// period" compares two Stripe timestamps rather than one Stripe timestamp + /// and one of ours — the skew is only seconds (measured "< 5s" in the + /// runbook), but it lands on the wrong side of a month boundary eventually. + feeRefundedAt DateTime? + + @@index([connectedAccountId]) + @@index([chargeId]) +} + +/// Application fees Sofra EARNED (workspace docs/plans/BACKLOG.md, the SECOND +/// blocker before any tenant goes on a non-zero payment commission: "nothing +/// reports what the commission earned"). The mirror image of StripeFeeRefund +/// just above, which records only fees RETURNED and whose own header names +/// this table as the missing half. +/// +/// One row per Stripe ApplicationFee, written by the `application_fee.created` +/// branch of app/api/webhooks/stripe/route.ts (lib/stripe-fee-earned.ts). That +/// event is a PLATFORM event — MEASURED: it carries `account: null` — so it +/// arrives on a SECOND, account-scoped endpoint at the same URL, not on the +/// `connect: true` one that carries charge.refunded. +model StripeApplicationFee { + id String @id @default(cuid()) + + /// Stripe's own fee id (`fee_...`). @unique is the idempotency anchor, the + /// same role StripeFeeRefund.stripeRefundId plays — but it carries MORE + /// weight here: the refund path is idempotent three ways over (the + /// arithmetic recomputes to zero, the Stripe Idempotency-Key, then this + /// constraint), whereas a "record what happened" write has no arithmetic to + /// neutralise a redelivery. Here the constraint is the ONLY thing between a + /// redelivery and double-counted revenue, and the write path upserts on it. + applicationFeeId String @unique + + /// `acct_...`, read from the FEE's own `account` field (the event has none). + /// NOT a foreign key — same seam as StripeFeeRefund.connectedAccountId and + /// TenantBilling.tenantSlug (ADR-007): the registry maps `stripe_account` + /// back to a slug and this app never writes it, so the join to a tenant + /// happens at READ time. A fee for an account no registry entry names is + /// still recorded; lib/commission-earnings.ts reports it as unmapped. + connectedAccountId String + + /// `ch_...` — the charge the fee was taken from, and the join key back to + /// StripeFeeRefund. + chargeId String + + /// Minor units, in `currency` — what Sofra took. Immutable in Stripe: a later + /// refund moves `amount_refunded`, never `amount`, which is why + /// `amount_refunded` is deliberately NOT stored here (it would be a snapshot + /// that goes stale, and the refunded side has its own table). + amount Int + + /// The CHARGE's currency (`chf` for a Swiss tenant), lower-case as Stripe + /// returns it — NOT Sofra's EUR books. Never render this with format.ts + /// `eur()`; `money()` exists for exactly this. + currency String + + /// Stripe's `created`, converted from epoch SECONDS. THIS, not `createdAt`, + /// is the period anchor: a redelivery days later must not move a fee into a + /// later month. + feeCreatedAt DateTime + + /// When WE recorded it — for the delivery-latency question ("did the rail + /// lag?"), never for periodisation. + createdAt DateTime @default(now()) + + @@index([connectedAccountId, feeCreatedAt]) + @@index([chargeId]) +} diff --git a/tests/unit/commission-earnings.test.ts b/tests/unit/commission-earnings.test.ts new file mode 100644 index 0000000..ed44669 --- /dev/null +++ b/tests/unit/commission-earnings.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import { + commissionEarnings, + unmappedFeeAccounts, + type FeeMovement, +} from "@/lib/commission-earnings"; + +const FROM = new Date("2026-08-01T00:00:00.000Z"); +const TO = new Date("2026-10-01T00:00:00.000Z"); +const AT = new Date("2026-09-04T21:45:59.000Z"); + +const fee = (over: Partial = {}): FeeMovement => ({ + amount: 60, + currency: "chf", + at: AT, + chargeId: "ch_1", + ...over, +}); + +const ready = (over: Partial[0]> = {}) => + commissionEarnings({ + registryReadable: true, + stripeAccount: "acct_1UC065FfnKu8VnLM", + earned: [], + refunded: [], + from: FROM, + to: TO, + ...over, + }); + +describe("commissionEarnings — fail quiet", () => { + it("an unreadable registry reports a reason and NO number", () => { + // The assertion that matters: not merely that `kind` is "unavailable", but + // that the returned object carries no numeric total at all. That is what + // stops a later refactor from degrading our own outage into a "0" printed + // beside a tenant's name. + const result = ready({ registryReadable: false }); + expect(result).toEqual({ kind: "unavailable", reason: "registryUnavailable" }); + expect(JSON.stringify(result)).not.toMatch(/\d/); + }); + + it("registry readable but no stripe_account is a DIFFERENT reason", () => { + // The common case today, and not a defect: no tenant carries an account yet. + expect(ready({ stripeAccount: undefined })).toEqual({ + kind: "unavailable", + reason: "noStripeAccount", + }); + }); + + it("a whitespace-only account is not an account", () => { + // The box tests `-z`, which " " passes — the same trap + // `missingPairedStripeAccount` exists to close. + expect(ready({ stripeAccount: " " })).toEqual({ + kind: "unavailable", + reason: "noStripeAccount", + }); + }); + + it("an account with no rows is READY and empty, not unavailable", () => { + // "We are watching this account and it collected nothing" is a fact. + // "We cannot see this account" is not. They must not render the same. + expect(ready()).toEqual({ kind: "ready", totals: [], unmatchedRefundCount: 0 }); + }); +}); + +describe("commissionEarnings — the net", () => { + it("nets a fully refunded fee to zero and still reports the tenant", () => { + const result = ready({ earned: [fee()], refunded: [fee()] }); + expect(result).toMatchObject({ kind: "ready", unmatchedRefundCount: 0 }); + expect(result.kind === "ready" && result.totals[0]).toMatchObject({ + currency: "chf", + earnedMinor: 60, + refundedMinor: 60, + netMinor: 0, + feeCount: 1, + refundCount: 1, + }); + }); + + it("goes NEGATIVE for a refund whose fee predates fee recording, and says how many", () => { + // Real on day one, not hypothetical: staging already holds two + // StripeFeeRefund rows written by the fee-refund runbook, and zero + // StripeApplicationFee rows for them — that table did not exist yet. A + // `Math.max(0, …)` "tidy-up" makes this 0 and hides the pre-history in the + // one direction that costs money. + const result = ready({ earned: [], refunded: [fee({ amount: 30 })] }); + expect(result).toMatchObject({ kind: "ready", unmatchedRefundCount: 1 }); + expect(result.kind === "ready" && result.totals[0].netMinor).toBe(-30); + }); + + it("two incremental partial refunds against one fee net to zero and are MATCHED", () => { + // The shape the runbook actually measured: 60 returned as 30 + 30. + const result = ready({ + earned: [fee({ amount: 60 })], + refunded: [fee({ amount: 30 }), fee({ amount: 30 })], + }); + expect(result).toMatchObject({ kind: "ready", unmatchedRefundCount: 0 }); + expect(result.kind === "ready" && result.totals[0]).toMatchObject({ + netMinor: 0, + refundCount: 2, + }); + }); +}); + +describe("commissionEarnings — the window is half-open", () => { + // Loosening a boundary is silent; this pair is the control on it. + it("a movement exactly at `from` is IN", () => { + const result = ready({ earned: [fee({ at: FROM })] }); + expect(result.kind === "ready" && result.totals[0].feeCount).toBe(1); + }); + + it("a movement exactly at `to` is OUT", () => { + expect(ready({ earned: [fee({ at: TO })] })).toEqual({ + kind: "ready", + totals: [], + unmatchedRefundCount: 0, + }); + }); + + it("a refund outside the window is neither counted nor called unmatched", () => { + const result = ready({ refunded: [fee({ at: new Date("2026-07-31T23:59:59.999Z") })] }); + expect(result).toEqual({ kind: "ready", totals: [], unmatchedRefundCount: 0 }); + }); +}); + +describe("commissionEarnings — currencies are never summed", () => { + it("keeps a CHF fee and a EUR fee apart", () => { + const result = ready({ + earned: [fee({ amount: 60, currency: "chf" }), fee({ amount: 40, currency: "eur" })], + }); + expect(result.kind === "ready" && result.totals).toHaveLength(2); + // 100 is the number a single mixed total would print, under one symbol. + expect( + result.kind === "ready" && result.totals.some((t) => t.earnedMinor === 100), + ).toBe(false); + }); + + it("a EUR refund does not reduce the CHF net — it gets its own row", () => { + const result = ready({ + earned: [fee({ amount: 60, currency: "chf" })], + refunded: [fee({ amount: 30, currency: "eur", chargeId: "ch_2" })], + }); + expect(result.kind === "ready" && result.totals).toEqual([ + { + currency: "chf", + earnedMinor: 60, + refundedMinor: 0, + netMinor: 60, + feeCount: 1, + refundCount: 0, + }, + { + currency: "eur", + earnedMinor: 0, + refundedMinor: 30, + netMinor: -30, + feeCount: 0, + refundCount: 1, + }, + ]); + }); + + it("'CHF' and 'chf' in one window collapse to a single total", () => { + // What stops someone deleting the lower-casing in `feeEarnedRow`. + const result = ready({ + earned: [fee({ amount: 60, currency: "CHF" }), fee({ amount: 40, currency: "chf" })], + }); + expect(result.kind === "ready" && result.totals).toHaveLength(1); + expect(result.kind === "ready" && result.totals[0]).toMatchObject({ + currency: "chf", + earnedMinor: 100, + }); + }); +}); + +describe("unmappedFeeAccounts", () => { + it("says nothing when the registry names the account", () => { + expect(unmappedFeeAccounts(["acct_A"], ["acct_A"])).toEqual([]); + }); + + it("names an account with real revenue that no registry entry claims", () => { + // The discriminating case: fees exist, and they appear on nobody's page. + expect(unmappedFeeAccounts(["acct_A"], ["acct_B"])).toEqual(["acct_A"]); + }); + + it("treats a whitespace-only registry value as no account at all", () => { + expect(unmappedFeeAccounts(["acct_A"], [" ", undefined])).toEqual(["acct_A"]); + }); + + it("is CASE-SENSITIVE — Stripe ids are, unlike currency codes", () => { + expect(unmappedFeeAccounts(["acct_A"], ["Acct_A"])).toEqual(["acct_A"]); + }); + + it("reports each unmapped account once", () => { + expect(unmappedFeeAccounts(["acct_A", "acct_A", "acct_B"], [])).toEqual(["acct_A", "acct_B"]); + }); +}); diff --git a/tests/unit/commission-eligibility.test.ts b/tests/unit/commission-eligibility.test.ts new file mode 100644 index 0000000..4a49fe9 --- /dev/null +++ b/tests/unit/commission-eligibility.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { commissionEligibility } from "@/lib/commission-eligibility"; + +// SOFRA-PAYMENTS-PRICING-MODE-PLAN S2b: the admin form's own gate — the common +// case is that a tenant is NOT eligible, because `provision-tenant.sh` refuses a +// non-zero rate without BOTH `online-payments` and a `stripe_account`, before the +// database. + +describe("commissionEligibility", () => { + it("is eligible when the entry carries the module AND the account", () => { + expect( + commissionEligibility({ + registryReadable: true, + tenant: { modules: ["core", "online-payments"], stripe_account: "acct_1Example" }, + }), + ).toEqual({ eligible: true }); + }); + + it("is registryUnavailable when the registry could not be read at all", () => { + expect( + commissionEligibility({ + registryReadable: false, + tenant: { modules: ["core", "online-payments"], stripe_account: "acct_1Example" }, + }), + ).toEqual({ eligible: false, reason: "registryUnavailable" }); + }); + + it("is registryUnavailable when the tenant has no registry entry at all", () => { + // A billing plan can exist before its tenant is provisioned — there is + // nothing here to check a pairing against, same as an unreadable registry. + expect(commissionEligibility({ registryReadable: true, tenant: undefined })).toEqual({ + eligible: false, + reason: "registryUnavailable", + }); + }); + + it("is notPaired when the entry never bought online-payments at all", () => { + expect( + commissionEligibility({ + registryReadable: true, + tenant: { modules: ["core"], stripe_account: "acct_1Example" }, + }), + ).toEqual({ eligible: false, reason: "notPaired" }); + }); + + it("is notPaired when the entry bought the module but carries no account", () => { + // The common case today: no tenant has a stripe_account yet. + expect( + commissionEligibility({ + registryReadable: true, + tenant: { modules: ["core", "online-payments"] }, + }), + ).toEqual({ eligible: false, reason: "notPaired" }); + }); + + it("treats a whitespace-only account as absent, same as provision-tenant.sh's -z test", () => { + expect( + commissionEligibility({ + registryReadable: true, + tenant: { modules: ["online-payments"], stripe_account: " " }, + }), + ).toEqual({ eligible: false, reason: "notPaired" }); + }); +}); diff --git a/tests/unit/fixtures/registry-valid.yml b/tests/unit/fixtures/registry-valid.yml index f620074..4a5769e 100644 --- a/tests/unit/fixtures/registry-valid.yml +++ b/tests/unit/fixtures/registry-valid.yml @@ -48,3 +48,66 @@ tenants: db: tenant_unpaired languages: [en] modules: [core, online-payments] + # S2a fixtures (SOFRA-PAYMENTS-PRICING-MODE-PLAN) — `commissioned` already carries + # a rate so a test can exercise REPLACE (bps > 0) and REMOVE (bps === 0) against a + # real existing line rather than a hand-built one. `demo2` exists ONLY to prove a + # slug that is a PREFIX of another (`demo`) cannot match it. + commissioned: + name: Commissioned Diner + status: active + managed: scripts + box: staging + domain: commissioned.sofrapiwas.com + domain_mode: subdomain + db: tenant_commissioned + languages: [en] + modules: [core, online-payments] + stripe_account: acct_1CommissionedExample + payments_commission_bps: 200 + demo2: + name: Demo Two + status: active + managed: scripts + box: staging + domain: demo2.sofrapiwas.com + domain_mode: subdomain + db: tenant_demo2 + languages: [en] + # HAS a stripe_account (unlike `demo`, right above it) so a prefix-matching + # bug — "demo" incorrectly reaching THIS block — is caught by an assertion + # rather than merely asserted away: if `demo`'s request ever landed here by + # mistake, setting it would wrongly SUCCEED instead of refusing. + modules: [core, online-payments] + stripe_account: acct_1DemoTwoExample + # Prefix-safety pair, deliberately ordered LONGER-FIRST — and that ordering is the + # whole point. `findBlock` uses `findIndex`, which returns the FIRST match, so with + # `demo` / `demo2` above (shorter first) a header regex that lost its trailing `:` + # would STILL land on the right block, by luck rather than by correctness. Proven by + # mutation: dropping the `:` from the header pattern left every other test in this + # file green. + # + # Here luck runs out. A loosened match for `zeta` hits `zeta2:` first and would edit + # the WRONG TENANT'S money configuration — which is the failure this whole property + # exists to prevent. + zeta2: + name: Zeta Two + status: active + managed: scripts + box: staging + domain: zeta2.sofrapiwas.com + domain_mode: subdomain + db: tenant_zeta2 + languages: [en] + modules: [core, online-payments] + stripe_account: acct_1Zeta2Example + zeta: + name: Zeta + status: active + managed: scripts + box: staging + domain: zeta.sofrapiwas.com + domain_mode: subdomain + db: tenant_zeta + languages: [en] + modules: [core, online-payments] + stripe_account: acct_1ZetaExample diff --git a/tests/unit/format.test.ts b/tests/unit/format.test.ts index a67d73e..7cb9cb7 100644 --- a/tests/unit/format.test.ts +++ b/tests/unit/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { eur, humanBytes, shortDate } from "@/lib/format"; +import { eur, humanBytes, money, shortDate } from "@/lib/format"; // nl-NL currency formatting uses a non-breaking space between € and the value. const NBSP = "\u00a0"; @@ -26,6 +26,32 @@ describe("eur (integer cents → nl-NL EUR string)", () => { }); }); +describe("money (minor units in an arbitrary currency)", () => { + it("renders CHF as CHF and not as euros — the whole reason it exists", () => { + // A Stripe application fee is in the CHARGE's currency. Rendering a 60-minor + // CHF fee with eur() prints "€ 0,60": a wrong symbol over a wrong number, + // and nothing goes red. This is the discriminating assertion. + expect(money(60, "chf")).toBe(`CHF${NBSP}0,60`); + expect(money(60, "chf")).not.toBe(eur(60)); + }); + + it("accepts Stripe's lower-case code", () => { + expect(money(4000, "chf")).toBe(money(4000, "CHF")); + }); + + it("still formats EUR, for a tenant whose charges are in euros", () => { + expect(money(4000, "eur")).toBe(eur(4000)); + }); + + it("formats a negative net — a refund whose fee predates fee recording", () => { + expect(money(-30, "chf")).toBe(`CHF${NBSP}-0,30`); + }); + + it("formats zero", () => { + expect(money(0, "chf")).toBe(`CHF${NBSP}0,00`); + }); +}); + describe("shortDate (en-GB dd MMM yyyy)", () => { // shortDate formats in the runner's LOCAL timezone (Intl with no timeZone), // so build the inputs from local Y/M/D components — a UTC instant could land diff --git a/tests/unit/partner-payments-mode.test.ts b/tests/unit/partner-payments-mode.test.ts new file mode 100644 index 0000000..9756015 --- /dev/null +++ b/tests/unit/partner-payments-mode.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// The partner's payments-mode action, at its ONE risky seam: the authorization +// boundary (SOFRA-PAYMENTS-PRICING-MODE-PLAN S4). +// +// The DB, the GitHub call and the audit sink are mocked; the code under test is the +// real action, the real `ownClient` query it goes through, and the real shared core +// underneath it. The question these tests answer is not "does it save" — it is +// "what does it do with a client that is not this partner's", and the only honest +// answer is one measured on the SIDE EFFECTS: no registry PR proposed, no Prisma +// write, no audit row. +// +// `db.client.findFirst` is a fake that HONOURS its `where` clause rather than +// returning a canned row, which is what makes the ownership scope testable at all: +// drop `partnerId` from the query and the fake starts returning another partner's +// client, exactly as Postgres would. + +const PARTNER = { id: "partner-1", email: "p@example.com", name: "P", role: "PARTNER" as const }; + +const CLIENTS = [ + { id: "c-mine", partnerId: "partner-1", tenantSlug: "zeta", restaurantName: "Mine" }, + { id: "c-theirs", partnerId: "partner-2", tenantSlug: "rival", restaurantName: "Theirs" }, + { id: "c-pipeline", partnerId: "partner-1", tenantSlug: null, restaurantName: "No tenant yet" }, +]; + +const BILLING: Record = { + zeta: { id: "bill-zeta", tenantSlug: "zeta", paymentsMode: "flat", paymentsCommissionBps: 0 }, + rival: { id: "bill-rival", tenantSlug: "rival", paymentsMode: "flat", paymentsCommissionBps: 0 }, +}; + +const clientFindFirst = vi.fn(async ({ where }: { where: Record }) => { + // Prisma semantics, reproduced: every PRESENT key narrows, an absent one does not. + const hit = CLIENTS.find((c) => + Object.entries(where).every(([k, v]) => v === undefined || c[k as keyof typeof c] === v), + ); + return hit ?? null; +}); +const billingFindUnique = vi.fn( + async ({ where }: { where: { tenantSlug: string } }) => BILLING[where.tenantSlug] ?? null, +); +const billingUpdate = vi.fn(async (args: { where: unknown; data: unknown }) => args); +const requirePartner = vi.fn(async () => PARTNER); +// The fake PR URL encodes what the editor was ASKED for, so an assertion on the +// audit row's `prUrl` also pins which tenant the proposal was actually about. +const prUrlFor = (slug: string, bps: number) => + `https://github.com/piwas-21/restaurant-app-deploy/pull/${slug}-${bps}`; +const openCommissionChangePr = vi.fn(async (slug: string, bps: number) => ({ + alreadySet: false, + prUrl: prUrlFor(slug, bps), +})); + +vi.mock("@/lib/db", () => ({ + db: { + client: { findFirst: clientFindFirst }, + tenantBilling: { findUnique: billingFindUnique, update: billingUpdate }, + }, +})); +vi.mock("@/lib/rbac", () => ({ requirePartner })); +vi.mock("@/lib/audit", () => ({ audit: vi.fn() })); +vi.mock("@/lib/registry-commission-pr", () => ({ openCommissionChangePr })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); + +// Not mocked: `provisioningConfigured()` only reads this env var, and the real +// `ProvisioningApiError` classes below have to be the ones the core catches. +process.env.PROVISION_GITHUB_TOKEN = "test-token"; + +const { updateClientPaymentsModeAction } = await import("@/lib/actions/partner-payments-actions"); +const { audit } = await import("@/lib/audit"); +const { ProvisioningApiError } = await import("@/lib/provisioning"); + +const form = (fields: Record) => { + const fd = new FormData(); + for (const [k, v] of Object.entries(fields)) fd.append(k, v); + return fd; +}; +const submit = (fields: Record) => updateClientPaymentsModeAction({}, form(fields)); + +/** Every side effect this action can have, in one assertion — the thing a refusal + * has to leave untouched. A refusal that still opened a PR would be a refusal on + * the screen and a change in the registry. */ +const expectNothingHappened = () => { + expect(openCommissionChangePr).not.toHaveBeenCalled(); + expect(billingUpdate).not.toHaveBeenCalled(); + expect(audit).not.toHaveBeenCalled(); +}; + +beforeEach(() => { + clientFindFirst.mockClear(); + billingFindUnique.mockClear(); + billingUpdate.mockClear(); + openCommissionChangePr.mockClear(); + openCommissionChangePr.mockImplementation(async (slug: string, bps: number) => ({ + alreadySet: false, + prUrl: prUrlFor(slug, bps), + })); + requirePartner.mockReset(); + requirePartner.mockResolvedValue(PARTNER); + vi.mocked(audit).mockClear(); +}); + +describe("partner payments-mode action — the authorization boundary", () => { + it("refuses ANOTHER partner's client, and proposes nothing and writes nothing", async () => { + const state = await submit({ clientId: "c-theirs", mode: "commission", commissionBps: "150" }); + + expect(state).toEqual({ error: "clientNotFound" }); + expectNothingHappened(); + // The row exists and is a real, provisioned tenant — the refusal is about + // OWNERSHIP, not about the client being unfindable. + expect(CLIENTS.find((c) => c.id === "c-theirs")?.tenantSlug).toBe("rival"); + }); + + it("scopes the lookup by partnerId — the query itself, not just its answer", async () => { + await submit({ clientId: "c-theirs", mode: "commission", commissionBps: "150" }); + + expect(clientFindFirst).toHaveBeenCalledWith({ + where: { id: "c-theirs", partnerId: PARTNER.id }, + }); + }); + + it("answers a non-existent client the SAME way as another partner's", async () => { + // The pair is the point: if the two answers differed, the form would be an + // oracle for "is this client id one of yours". + const theirs = await submit({ clientId: "c-theirs", mode: "flat", commissionBps: "0" }); + const nobody = await submit({ clientId: "no-such-client", mode: "flat", commissionBps: "0" }); + + expect(nobody).toEqual(theirs); + expectNothingHappened(); + }); + + it("IGNORES a tenant slug the partner posts — the slug comes off the row", async () => { + // The attack this action is shaped against: naming somebody else's tenant in a + // field the server might read. The form has no such field; posting one anyway + // must change nothing. + const state = await submit({ + clientId: "c-mine", + tenantSlug: "rival", + mode: "commission", + commissionBps: "150", + }); + + expect(state).toEqual({ ok: true }); + expect(openCommissionChangePr).toHaveBeenCalledWith("zeta", 150); + expect(openCommissionChangePr).not.toHaveBeenCalledWith("rival", expect.anything()); + expect(billingUpdate).toHaveBeenCalledWith({ + where: { tenantSlug: "zeta" }, + data: { paymentsMode: "commission", paymentsCommissionBps: 150 }, + }); + }); + + it("refuses a client that has no tenant yet", async () => { + const state = await submit({ clientId: "c-pipeline", mode: "commission", commissionBps: "150" }); + + expect(state).toEqual({ error: "clientNotProvisioned" }); + expectNothingHappened(); + }); + + it("refuses a session that is not a partner, before any lookup", async () => { + // What `redirect()` does inside a server action: it throws. Nothing after the + // guard may run, so the client query must not even be reached. + requirePartner.mockRejectedValue(new Error("NEXT_REDIRECT")); + + await expect(submit({ clientId: "c-mine", mode: "flat", commissionBps: "0" })).rejects.toThrow( + "NEXT_REDIRECT", + ); + expect(clientFindFirst).not.toHaveBeenCalled(); + expectNothingHappened(); + }); +}); + +describe("partner payments-mode action — the happy path", () => { + it("proposes the PR, records the intent, and audits the PARTNER as the actor", async () => { + const state = await submit({ clientId: "c-mine", mode: "commission", commissionBps: "150" }); + + expect(state).toEqual({ ok: true }); + expect(openCommissionChangePr).toHaveBeenCalledWith("zeta", 150); + expect(audit).toHaveBeenCalledWith( + PARTNER.id, + "tenant.paymentsMode.changed", + "TenantBilling", + "bill-zeta", + expect.objectContaining({ + tenantSlug: "zeta", + initiator: "partner", + clientId: "c-mine", + oldMode: "flat", + newMode: "commission", + newBps: 150, + prUrl: prUrlFor("zeta", 150), + }), + ); + }); + + it("does not hand the partner the deploy-repo PR URL", async () => { + // It points into a PRIVATE repo they cannot open, and a 404 is worse news than + // no link. The URL is not lost — the audit row above carries it. + const state = await submit({ clientId: "c-mine", mode: "commission", commissionBps: "150" }); + + expect(state.prUrl).toBeUndefined(); + expect(JSON.stringify(state)).not.toContain("github.com"); + }); + + it("reports the no-PR case rather than inventing one", async () => { + openCommissionChangePr.mockResolvedValue({ alreadySet: true, prUrl: "" }); + + const state = await submit({ clientId: "c-mine", mode: "commission", commissionBps: "150" }); + + expect(state).toEqual({ ok: true, alreadySet: true }); + expect(billingUpdate).toHaveBeenCalledTimes(1); + }); + + it("refuses a no-op instead of opening an empty PR", async () => { + const state = await submit({ clientId: "c-mine", mode: "flat", commissionBps: "0" }); + + expect(state).toEqual({ error: "paymentsModeUnchanged" }); + expectNothingHappened(); + }); + + it("refuses a rate above the ceiling", async () => { + const state = await submit({ clientId: "c-mine", mode: "commission", commissionBps: "5000" }); + + expect(state.error).toBeTruthy(); + expectNothingHappened(); + }); +}); + +describe("shared core — order of operations", () => { + it("records NO intent when the proposal fails (PR first, Prisma second)", async () => { + // The order the S2a comment exists for. Prisma-first would leave a billing row + // claiming a mode nobody was ever asked to approve. + openCommissionChangePr.mockRejectedValue(new ProvisioningApiError("GitHub said no")); + + const state = await submit({ clientId: "c-mine", mode: "commission", commissionBps: "150" }); + + expect(state).toEqual({ error: "GitHub said no" }); + expect(billingUpdate).not.toHaveBeenCalled(); + expect(audit).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/payments-mode-effective.test.ts b/tests/unit/payments-mode-effective.test.ts new file mode 100644 index 0000000..d06e09e --- /dev/null +++ b/tests/unit/payments-mode-effective.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { effectivePaymentsMode } from "@/lib/payments-mode-effective"; + +// SOFRA-PAYMENTS-PRICING-MODE-PLAN §3, S2a: the registry-PR window between what +// TenantBilling INTENDS and what tenants/registry.yml actually ENFORCES. + +describe("effectivePaymentsMode", () => { + it("is commission, not pending, when the registry agrees", () => { + expect( + effectivePaymentsMode({ intended: "commission", registryBps: 150, registryReadable: true }), + ).toEqual({ mode: "commission", pending: false }); + }); + + it("is flat, not pending, when the registry agrees at an explicit 0", () => { + expect( + effectivePaymentsMode({ intended: "flat", registryBps: 0, registryReadable: true }), + ).toEqual({ mode: "flat", pending: false }); + }); + + it("treats an absent registry key the same as an explicit 0 bps — flat, per the registry's own documented default", () => { + expect( + effectivePaymentsMode({ intended: "flat", registryBps: undefined, registryReadable: true }), + ).toEqual({ mode: "flat", pending: false }); + }); + + it("is pending — effective flat — while the registry PR for a fresh commission switch hasn't merged yet", () => { + // The exact window the plan describes: set in Prisma, registry PR still open. + expect( + effectivePaymentsMode({ intended: "commission", registryBps: 0, registryReadable: true }), + ).toEqual({ mode: "flat", pending: true }); + expect( + effectivePaymentsMode({ intended: "commission", registryBps: undefined, registryReadable: true }), + ).toEqual({ mode: "flat", pending: true }); + }); + + it("is pending the other direction too — a registry rate ahead of a stale intent", () => { + expect( + effectivePaymentsMode({ intended: "flat", registryBps: 150, registryReadable: true }), + ).toEqual({ mode: "commission", pending: true }); + }); + + it("is SILENT when the registry could not be read — reports the intent, never a manufactured pending claim", () => { + // The trap this exists for: our own ops failure must never render as "this + // tenant's money is still being switched over" — see lib/payments-pending.ts, + // whose fail-quiet direction this mirrors exactly. + expect( + effectivePaymentsMode({ intended: "commission", registryBps: undefined, registryReadable: false }), + ).toEqual({ mode: "commission", pending: false }); + expect( + effectivePaymentsMode({ intended: "flat", registryBps: undefined, registryReadable: false }), + ).toEqual({ mode: "flat", pending: false }); + // …even when a (stale) registry figure WAS available, unreadable still wins: + // an outage mid-read is not evidence the value in hand is current. + expect( + effectivePaymentsMode({ intended: "flat", registryBps: 150, registryReadable: false }), + ).toEqual({ mode: "flat", pending: false }); + }); +}); diff --git a/tests/unit/payments-pricing.test.ts b/tests/unit/payments-pricing.test.ts new file mode 100644 index 0000000..96abafb --- /dev/null +++ b/tests/unit/payments-pricing.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_COMMISSION_BPS, + MAX_COMMISSION_BPS, + asPaymentsMode, + crossoverCentsPerMonth, + formatCommissionPercent, + isCommissionBps, + paymentsModeQuote, +} from "@/lib/payments-pricing"; +import { MODULES } from "@/lib/module-catalog"; + +// The online-payments list price this whole file measures against — read out of +// the SAME catalog paymentsModeQuote reads, never hardcoded, so a future price +// change cannot make this suite pass against a number nobody actually charges. +const ONLINE_PAYMENTS_PRICE_CENTS = MODULES.find((m) => m.id === "online-payments")!.priceCents; + +describe("isCommissionBps", () => { + it("rejects negative rates — a negative fee is not a rate, it is a bug", () => { + expect(isCommissionBps(-1)).toBe(false); + expect(isCommissionBps(-150)).toBe(false); + }); + + it("rejects anything above the 1000 bps (10%) ceiling", () => { + expect(isCommissionBps(1001)).toBe(false); + expect(isCommissionBps(5000)).toBe(false); + }); + + // The boundary test: `>` vs `>=` at the ceiling is otherwise indistinguishable + // from any other passing case, so 1000 itself has to be asserted explicitly. + it("accepts exactly 1000 bps — the ceiling itself is a valid rate, not the first refusal", () => { + expect(isCommissionBps(MAX_COMMISSION_BPS)).toBe(true); + expect(MAX_COMMISSION_BPS).toBe(1000); + }); + + it("accepts 0 (no commission) and the shipped default", () => { + expect(isCommissionBps(0)).toBe(true); + expect(isCommissionBps(DEFAULT_COMMISSION_BPS)).toBe(true); + expect(DEFAULT_COMMISSION_BPS).toBe(150); + }); + + it("rejects a fractional rate — provision-tenant.sh parses the registry field with ^[0-9]+$", () => { + expect(isCommissionBps(1.5)).toBe(false); + expect(isCommissionBps(150.001)).toBe(false); + }); +}); + +describe("paymentsModeQuote", () => { + it("leaves a flat-mode quote unchanged, module present or not", () => { + expect(paymentsModeQuote(4500, "flat", true)).toBe(4500); + expect(paymentsModeQuote(4500, "flat", false)).toBe(4500); + }); + + it("zeroes the online-payments line under commission mode", () => { + const withModule = 1900 + ONLINE_PAYMENTS_PRICE_CENTS; + expect(paymentsModeQuote(withModule, "commission", true)).toBe( + withModule - ONLINE_PAYMENTS_PRICE_CENTS, + ); + }); + + it("leaves a tenant without the module unaffected by commission mode — nothing to subtract", () => { + // A tenant that never bought online-payments has no line to zero: subtracting + // the module's price anyway would UNDER-charge them for modules they do carry. + expect(paymentsModeQuote(3600, "commission", false)).toBe(3600); + }); +}); + +describe("crossoverCentsPerMonth", () => { + it("returns null at 0 bps — commission is free forever, not merely a high crossover", () => { + expect(crossoverCentsPerMonth(0, 1900)).toBeNull(); + }); + + // Hand-derived: turnover * (bps/10000) = flatCents => turnover = flatCents*10000/bps. + // At the shipped default (150 bps) against the online-payments list price (1900 + // cents/€19), that is 1900*10000/150 = 126666.67, rounded to the nearest cent — + // which is the plan's own "roughly CHF 1,270/mo" sentence (SOFRA-PAYMENTS-PRICING-MODE-PLAN §1). + it("computes the plan's own worked example: 150 bps against the €19 module", () => { + expect(crossoverCentsPerMonth(DEFAULT_COMMISSION_BPS, ONLINE_PAYMENTS_PRICE_CENTS)).toBe(126667); + }); + + // A second, exact (non-repeating) example so the formula itself — not just its + // rounding — is pinned: 100 bps (1%) of 100000 cents (€1000) is exactly 1000 + // cents (€10), so the crossover for a €10 flat fee at 1% is exactly €1000/mo. + it("computes an exact round-number crossover with no rounding involved", () => { + expect(crossoverCentsPerMonth(100, 1000)).toBe(100000); + }); +}); + +describe("formatCommissionPercent", () => { + it("formats the shipped default at two decimal places", () => { + expect(formatCommissionPercent(DEFAULT_COMMISSION_BPS)).toBe("1.50%"); + }); + + it("keeps two decimals even for a whole percent", () => { + expect(formatCommissionPercent(100)).toBe("1.00%"); + }); + + it("does not collapse the finest rate (1 bp) to 0.0%", () => { + expect(formatCommissionPercent(1)).toBe("0.01%"); + }); + + it("formats 0 as a real 0.00%, not an empty string", () => { + expect(formatCommissionPercent(0)).toBe("0.00%"); + }); +}); + +describe("asPaymentsMode", () => { + it("reads the literal 'commission' as commission", () => { + expect(asPaymentsMode("commission")).toBe("commission"); + }); + + it("reads 'flat' as flat", () => { + expect(asPaymentsMode("flat")).toBe("flat"); + }); + + it("reads anything else as flat — the safe default for a value that should never occur", () => { + expect(asPaymentsMode("")).toBe("flat"); + expect(asPaymentsMode("COMMISSION")).toBe("flat"); + expect(asPaymentsMode("garbage")).toBe("flat"); + }); +}); diff --git a/tests/unit/provisioning-registry.test.ts b/tests/unit/provisioning-registry.test.ts index bd1fe2a..8114346 100644 --- a/tests/unit/provisioning-registry.test.ts +++ b/tests/unit/provisioning-registry.test.ts @@ -571,3 +571,81 @@ describe("a partner credit in the generated entry (§11e, S3a)", () => { expect(unlinked).not.toContain("https://solutioneva.com"); }); }); + +// S1 — payments_commission_bps in the generated entry (SOFRA-PAYMENTS-PRICING-MODE-PLAN). +// Same pairing shape as the online-payments/stripe_account guard above, one field over: +// a non-zero rate must never reach an entry whose online-payments is deferred, or +// provision-tenant.sh refuses the whole tenant on re-provision. +describe("a per-transaction commission rate in the generated entry (S1)", () => { + const base = { + slug: "bistro-nova", + name: "Bistro Nova", + adminEmail: "owner@nova.example", + template: "craft" as const, + currency: "EUR", + languages: ["en", "nl"], + modules: ["core", "online-payments"], + stripeAccount: "acct_1AbCdEfGhIjKlMnO", + }; + + it("omits the key entirely when the rate is zero or absent — no no-op line on every entry", () => { + const zero = asTenant( + buildTenantRegistryEntry({ ...base, paymentsCommissionBps: 0 }), + base.slug, + ) as Record; + expect("payments_commission_bps" in zero).toBe(false); + + const absent = asTenant(buildTenantRegistryEntry(base), base.slug) as Record; + expect("payments_commission_bps" in absent).toBe(false); + }); + + it("emits the rate when online-payments actually survives the split into this entry", () => { + const t = asTenant( + buildTenantRegistryEntry({ ...base, paymentsCommissionBps: 150 }), + base.slug, + ) as Record; + expect(t.payments_commission_bps).toBe(150); + expect(t.modules).toEqual(["core", "online-payments"]); + expect(t.stripe_account).toBe("acct_1AbCdEfGhIjKlMnO"); + }); + + it("never emits the rate when online-payments is DEFERRED — provision-tenant.sh would refuse it", () => { + // No stripeAccount => splitDeferredModules holds online-payments back. Writing the + // rate here anyway would just move the module/account refusal onto this field + // instead of preventing it — the whole reason the pairing exists. + const built = buildTenantRegistryEntry({ + ...base, + stripeAccount: undefined, + paymentsCommissionBps: 150, + }); + const t = asTenant(built, base.slug) as Record; + expect("payments_commission_bps" in t).toBe(false); + expect(built.deferred).toEqual(["online-payments"]); + expect(t.modules).toEqual(["core"]); + }); + + it("says nothing about commission in the PR body when no rate was requested", () => { + const body = buildProvisioningPrBody(base); + expect(body).not.toContain("commission"); + }); + + it("explains the rate in the PR body when the entry carries it", () => { + const body = buildProvisioningPrBody({ ...base, paymentsCommissionBps: 150 }); + expect(body).toContain("**commission** `150 bps`"); + expect(body).toContain("Per-transaction commission: `150` bps (1.50%)"); + expect(body).not.toContain("NOT in this entry"); + }); + + it("explains the REFUSAL in the PR body when a rate was requested but the module is deferred", () => { + const body = buildProvisioningPrBody({ + ...base, + stripeAccount: undefined, + paymentsCommissionBps: 150, + }); + expect(body).toContain("(not written — see below)"); + expect(body).toContain("Requested commission rate `150` bps (1.50%) is NOT in this entry"); + expect(body).toContain("payments_commission_bps: 150"); + // The same guard's condition, named explicitly rather than left implicit. + expect(body).toContain("`stripe_account`"); + }); +}); diff --git a/tests/unit/registry-commission-edit.test.ts b/tests/unit/registry-commission-edit.test.ts new file mode 100644 index 0000000..e87f78e --- /dev/null +++ b/tests/unit/registry-commission-edit.test.ts @@ -0,0 +1,197 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + InvalidCommissionBpsError, + MissingStripeAccountError, + UnknownRegistryTenantError, + currentRegistryCommissionBps, + setRegistryCommissionBps, +} from "@/lib/registry-commission-edit"; + +// SOFRA-PAYMENTS-PRICING-MODE-PLAN S2a. This is a LINE-EDITING module by design +// (module comment) — a YAML parse-and-restringify would delete every hand-written +// comment in the real registry — so every test below reads the fixture as TEXT, +// never through a YAML parser, the same discipline the module itself follows. + +const FIXTURE_PATH = fileURLToPath(new URL("./fixtures/registry-valid.yml", import.meta.url)); +const fixture = (): string => readFileSync(FIXTURE_PATH, "utf8"); + +/** + * An INDEPENDENT block extractor — written from the same spec as + * `lib/registry-commission-edit.ts` (header at 2 spaces, body blank-or- + * indented->=4) but never calling into it. Used only to read the fixture back + * for assertions, so a bug in the module under test isn't also baked into the + * tool checking its output. + */ +function extractBlock(yaml: string, slug: string): string { + const lines = yaml.split("\n"); + const headerRe = new RegExp(`^ {2}${slug}:\\s*$`); + const start = lines.findIndex((line) => headerRe.test(line)); + if (start === -1) throw new Error(`fixture has no '${slug}' block`); + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (lines[i].trim() === "" || /^ {4,}/.test(lines[i])) continue; + end = i; + break; + } + return lines.slice(start, end).join("\n"); +} + +const ALL_SLUGS = ["rumi", "demo", "pays", "unpaired", "commissioned", "demo2"]; + +describe("currentRegistryCommissionBps", () => { + it("reads an existing rate", () => { + expect(currentRegistryCommissionBps(fixture(), "commissioned")).toBe(200); + }); + + it("is undefined when the key is absent — the same 'absent means 0' convention as the writer", () => { + expect(currentRegistryCommissionBps(fixture(), "pays")).toBeUndefined(); + }); + + it("is undefined for an unknown slug — reading never throws, only writing does", () => { + expect(currentRegistryCommissionBps(fixture(), "ghost")).toBeUndefined(); + }); +}); + +describe("setRegistryCommissionBps", () => { + it("inserts a rate right after stripe_account: on a tenant that has one", () => { + const { yaml, changed } = setRegistryCommissionBps(fixture(), "pays", 175); + expect(changed).toBe(true); + expect(extractBlock(yaml, "pays")).toBe( + [ + " pays:", + " name: Pays By Card", + " status: active", + " managed: scripts", + " box: staging", + " domain: pays.sofrapiwas.com", + " domain_mode: subdomain", + " db: tenant_pays", + " languages: [en]", + " modules: [core, online-payments]", + " stripe_account: acct_1PaysExample", + " payments_commission_bps: 175", + ].join("\n"), + ); + }); + + it("refuses a tenant with no stripe_account — provision-tenant.sh would refuse this before the database", () => { + // `unpaired` bought online-payments but has no account (the exact real-world + // shape the fixture's own P3 comment documents). + expect(() => setRegistryCommissionBps(fixture(), "unpaired", 150)).toThrow( + MissingStripeAccountError, + ); + }); + + it("replaces an existing value in place, preserving its indentation", () => { + const { yaml, changed } = setRegistryCommissionBps(fixture(), "commissioned", 300); + expect(changed).toBe(true); + const block = extractBlock(yaml, "commissioned"); + expect(block).toContain(" payments_commission_bps: 300"); + expect(block).not.toContain("payments_commission_bps: 200"); + // Exactly one line still — a replace must not duplicate the key. + expect(block.match(/payments_commission_bps:/g)).toHaveLength(1); + }); + + it("deletes the line entirely at 0 — absent means 0, so the line would be redundant noise", () => { + const { yaml, changed } = setRegistryCommissionBps(fixture(), "commissioned", 0); + expect(changed).toBe(true); + expect(extractBlock(yaml, "commissioned")).not.toContain("payments_commission_bps"); + // The rest of the block survives — this isn't a block-level rewrite. + expect(extractBlock(yaml, "commissioned")).toContain("stripe_account: acct_1CommissionedExample"); + }); + + it("is a no-op at 0 when the key was already absent — byte-identical output", () => { + const original = fixture(); + const { yaml, changed } = setRegistryCommissionBps(original, "pays", 0); + expect(changed).toBe(false); + expect(yaml).toBe(original); + }); + + it("refuses an unknown slug", () => { + expect(() => setRegistryCommissionBps(fixture(), "ghost", 100)).toThrow( + UnknownRegistryTenantError, + ); + }); + + it("refuses an out-of-range or non-integer rate", () => { + expect(() => setRegistryCommissionBps(fixture(), "pays", -1)).toThrow(InvalidCommissionBpsError); + expect(() => setRegistryCommissionBps(fixture(), "pays", 1001)).toThrow(InvalidCommissionBpsError); + expect(() => setRegistryCommissionBps(fixture(), "pays", 1.5)).toThrow(InvalidCommissionBpsError); + }); + + it("preserves every comment byte-identically", () => { + const original = fixture(); + const { yaml } = setRegistryCommissionBps(original, "pays", 175); + const p3Comment = [ + " # P3 — the two Stripe shapes the founder must be able to tell apart on", + " # /admin/tenants. `pays` bought the module and has the account (the pair", + " # `provision-tenant.sh:94` demands); `unpaired` bought it and has none, which", + " # only a hand-edit can produce and which makes every re-provision a no-op.", + ].join("\n"); + const s2aComment = [ + " # S2a fixtures (SOFRA-PAYMENTS-PRICING-MODE-PLAN) — `commissioned` already carries", + " # a rate so a test can exercise REPLACE (bps > 0) and REMOVE (bps === 0) against a", + " # real existing line rather than a hand-built one. `demo2` exists ONLY to prove a", + " # slug that is a PREFIX of another (`demo`) cannot match it.", + ].join("\n"); + // Present in the source fixture (sanity — a typo here would make the test vacuous)… + expect(original).toContain(p3Comment); + expect(original).toContain(s2aComment); + // …and untouched by an edit to an entry elsewhere in the file. + expect(yaml).toContain(p3Comment); + expect(yaml).toContain(s2aComment); + }); + + it("touches ONLY the edited tenant's block — every other block is byte-identical", () => { + const original = fixture(); + const { yaml } = setRegistryCommissionBps(original, "pays", 175); + for (const slug of ALL_SLUGS.filter((s) => s !== "pays")) { + expect(extractBlock(yaml, slug)).toBe(extractBlock(original, slug)); + } + }); + + it("a slug that is a PREFIX of another tenant's slug does not match it", () => { + const original = fixture(); + // `demo2` (unlike `demo`) has a stripe_account, so this is a discriminating + // check, not a coincidence: if `demo`'s request ever reached `demo2`'s block + // by a prefix-matching bug, setting `demo` would wrongly SUCCEED (demo2 has + // an account) instead of refusing. + expect(() => setRegistryCommissionBps(original, "demo", 150)).toThrow( + MissingStripeAccountError, + ); + // …and the reverse: demo2 (which DOES have an account) must actually accept + // the rate rather than being refused as if it were `demo`. + const { yaml, changed } = setRegistryCommissionBps(original, "demo2", 150); + expect(changed).toBe(true); + expect(extractBlock(yaml, "demo2")).toContain("payments_commission_bps: 150"); + // And demo itself must be completely untouched by that edit. + expect(extractBlock(yaml, "demo")).toBe(extractBlock(original, "demo")); + }); + + // The demo/demo2 case above is ordering-dependent: `findBlock` uses `findIndex`, + // which returns the FIRST match, and `demo` precedes `demo2` in the fixture — so a + // header regex that lost its trailing `:` would still find the right block by luck. + // Mutation proved exactly that: dropping the `:` left all 14 tests green. + // + // `zeta2` is deliberately placed BEFORE `zeta`, so the same bug lands on the wrong + // tenant. This is the assertion that actually pins the property. + it("matches the exact slug even when a LONGER slug sits earlier in the file", () => { + const original = fixture(); + const { yaml, changed } = setRegistryCommissionBps(original, "zeta", 150); + + expect(changed).toBe(true); + expect(extractBlock(yaml, "zeta")).toContain("payments_commission_bps: 150"); + // The one that would have been hit by a prefix bug must be untouched. + expect(extractBlock(yaml, "zeta2")).toBe(extractBlock(original, "zeta2")); + }); + + it("is idempotent — applying the same value twice is a no-op the second time, byte-identical", () => { + const first = setRegistryCommissionBps(fixture(), "pays", 175); + expect(first.changed).toBe(true); + const second = setRegistryCommissionBps(first.yaml, "pays", 175); + expect(second.changed).toBe(false); + expect(second.yaml).toBe(first.yaml); + }); +}); diff --git a/tests/unit/signup-configuration.test.ts b/tests/unit/signup-configuration.test.ts index a254e3c..2b0ecb4 100644 --- a/tests/unit/signup-configuration.test.ts +++ b/tests/unit/signup-configuration.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; -import { sanitizeSignupConfiguration } from "@/lib/signup-configuration"; +import { sanitizeSignupConfiguration, type RawSignupConfiguration } from "@/lib/signup-configuration"; import { quoteModules } from "@/lib/module-catalog"; import { isTemplateId, isTenantCurrency, parseCsv, TEMPLATES } from "@/lib/tenant-options"; +import { DEFAULT_COMMISSION_BPS, ONLINE_PAYMENTS_PRICE_CENTS, paymentsModeQuote } from "@/lib/payments-pricing"; describe("parseCsv", () => { it("drops blanks and duplicates, keeps order", () => { @@ -39,6 +40,8 @@ describe("sanitizeSignupConfiguration", () => { template: null, currency: null, quotedCents: null, + paymentsMode: null, + paymentsCommissionBps: null, }); }); @@ -130,3 +133,77 @@ describe("sanitizeSignupConfiguration", () => { expect(parseCsv(c.modules)).toEqual(["core"]); }); }); + +// Payments pricing mode (workspace SOFRA-PAYMENTS-PRICING-MODE-PLAN, S3 — the +// public configurator's choice between the flat online-payments price and a +// per-transaction commission). +describe("sanitizeSignupConfiguration — payments pricing mode", () => { + it("stores commission at the mode-adjusted quote when online-payments is selected", () => { + const modules = ["core", "online-payments"]; + const c = sanitizeSignupConfiguration({ modules: modules.join(","), paymentsMode: "commission" }); + expect(c.paymentsMode).toBe("commission"); + expect(c.paymentsCommissionBps).toBe(DEFAULT_COMMISSION_BPS); + // The adjusted total, never the flat one: online-payments drops to €0/mo. + expect(c.quotedCents).toBe(quoteModules(modules).monthlyCents - ONLINE_PAYMENTS_PRICE_CENTS); + }); + + // A mode with no module is not a state anything downstream can honour. + it("degrades commission to flat when the selection has no online-payments", () => { + const c = sanitizeSignupConfiguration({ modules: "core,loyalty", paymentsMode: "commission" }); + expect(c.paymentsMode).toBe("flat"); + expect(c.paymentsCommissionBps).toBe(0); + expect(c.quotedCents).toBe(quoteModules(["core", "loyalty"]).monthlyCents); + }); + + // Rule 1 (DROP, don't reject) extends to the mode string. + it("degrades an unrecognised mode string to flat rather than throwing", () => { + const c = sanitizeSignupConfiguration({ + modules: "core,online-payments", + paymentsMode: "give-it-all-away", + }); + expect(c.paymentsMode).toBe("flat"); + expect(c.paymentsCommissionBps).toBe(0); + }); + + // A stale cached bundle (or a plain-form/no-JS POST predating this field) + // never sends `paymentsMode` at all — the field is absent, not empty, which + // is a different branch from an explicit "flat". + it("defaults to flat when online-payments is selected but no mode was posted", () => { + const c = sanitizeSignupConfiguration({ modules: "core,online-payments" }); + expect(c.paymentsMode).toBe("flat"); + expect(c.paymentsCommissionBps).toBe(0); + }); + + it("stores 0 bps for flat mode, even with online-payments selected", () => { + const c = sanitizeSignupConfiguration({ + modules: "core,online-payments", + paymentsMode: "flat", + }); + expect(c.paymentsMode).toBe("flat"); + expect(c.paymentsCommissionBps).toBe(0); + expect(c.quotedCents).toBe(quoteModules(["core", "online-payments"]).monthlyCents); + }); + + // Rule 2 (RE-QUOTE, never trust), extended: a posted quotedCents is ignored + // under commission exactly as it is under flat — the function recomputes + // through paymentsModeQuote and never reads a caller-supplied price. Cast + // to simulate the real caller (app/api/signup/route.ts passes the whole + // parsed signupSchema payload, which DOES carry a quotedCents field — + // RawSignupConfiguration itself has no such field precisely so nothing here + // could read it even if it wanted to). + it("ignores a posted quotedCents under commission", () => { + const raw = { + modules: "core,online-payments", + paymentsMode: "commission", + quotedCents: 1, + } as RawSignupConfiguration; + const c = sanitizeSignupConfiguration(raw); + const expected = paymentsModeQuote( + quoteModules(["core", "online-payments"]).monthlyCents, + "commission", + true, + ); + expect(c.quotedCents).toBe(expected); + expect(c.quotedCents).not.toBe(1); + }); +}); diff --git a/tests/unit/stripe-fee-earned.test.ts b/tests/unit/stripe-fee-earned.test.ts new file mode 100644 index 0000000..408f2e7 --- /dev/null +++ b/tests/unit/stripe-fee-earned.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { feeEarnedRow, feeEarnedUpsert } from "@/lib/stripe-fee-earned"; +import type { StripeApplicationFee } from "@/lib/stripe-fee-refund"; + +// Shaped from a REAL `application_fee.created` payload read off the Stripe API +// in test mode 2026-09-04 (fee_1UC4vr…, the fee from the fee-refund runbook's +// own verified run), not from the documentation. +const fee = (over: Partial = {}): StripeApplicationFee => ({ + id: "fee_1UC4vrFfnKu8VnLMj2MIShQy", + account: "acct_1UC065FfnKu8VnLM", + amount: 60, + refunded: false, + amount_refunded: 0, + currency: "chf", + charge: "ch_3UC4voFfnKu8VnLM1XoHvmdB", + created: 1788558359, + ...over, +}); + +describe("feeEarnedRow", () => { + it("takes the connected account from the FEE, not from the charge or the event", () => { + // The load-bearing field: it is the ONLY join key back to a tenant, and the + // event that carries this fee has `account: null` (measured), so there is no + // second source to fall back on. Asserted against the charge id explicitly + // because "some acct_-shaped string" would pass a laxer check. + const row = feeEarnedRow(fee()); + expect(row.connectedAccountId).toBe("acct_1UC065FfnKu8VnLM"); + expect(row.connectedAccountId).not.toBe(row.chargeId); + expect(row.chargeId).toBe("ch_3UC4voFfnKu8VnLM1XoHvmdB"); + }); + + it("reads Stripe's `created` as epoch SECONDS", () => { + // The off-by-1000. Milliseconds would place this fee in January 1970, which + // a month-scoped readout renders as an empty period — not as an error. + expect(feeEarnedRow(fee()).feeCreatedAt.toISOString()).toBe("2026-09-04T21:45:59.000Z"); + }); + + it("lower-cases the currency so one tenant's CHF cannot become two totals", () => { + expect(feeEarnedRow(fee({ currency: "CHF" })).currency).toBe("chf"); + }); + + it("records the fee as created and never its refunded amount", () => { + // `amount` is immutable in Stripe; `amount_refunded` moves. Storing the + // second would be a snapshot that silently goes stale. + const row = feeEarnedRow(fee({ amount: 60, amount_refunded: 60, refunded: true })); + expect(row.amount).toBe(60); + expect(Object.keys(row)).not.toContain("amountRefunded"); + }); +}); + +describe("feeEarnedUpsert — the idempotency anchor", () => { + const write = () => feeEarnedUpsert(fee()); + + it("keys the write on applicationFeeId and on nothing else", () => { + // A redelivery must collide. If this key ever becomes the charge id, two + // fees on one charge would overwrite each other; if it becomes anything + // non-unique, a redelivery doubles recorded revenue. There is no arithmetic + // on this path to neutralise either. + const write = feeEarnedUpsert(fee()); + expect(write.where).toEqual({ applicationFeeId: "fee_1UC4vrFfnKu8VnLMj2MIShQy" }); + }); + + it("says nothing on the redelivery branch, so a second delivery cannot restate the row", () => { + expect(write().update).toEqual({}); + expect(Object.keys(write().update)).toHaveLength(0); + }); + + it("creates the same row feeEarnedRow computes", () => { + expect(write().create).toEqual(feeEarnedRow(fee())); + }); +}); + +// The constraint itself lives in SQL and in schema.prisma, not in TypeScript, so +// no test over the pure functions above can see it — and the upsert is only +// idempotent BECAUSE the column is unique. These two read the declarations off +// disk (no DB, no network, in keeping with §7) so that deleting the anchor is a +// red test rather than a silent behaviour change. What they prove is that the +// declaration is present; that Postgres enforces it is proven on staging by +// replaying the event and asserting one row, exactly as #217 proved its own. +const read = (path: string) => + readFileSync(fileURLToPath(new URL(`../../${path}`, import.meta.url)), "utf8"); + +describe("the StripeApplicationFee idempotency anchor is declared", () => { + it("schema.prisma marks applicationFeeId @unique", () => { + const model = /model StripeApplicationFee \{[\s\S]*?\n\}/.exec(read("prisma/schema.prisma"))?.[0]; + expect(model).toBeDefined(); + expect(model).toMatch(/applicationFeeId\s+String\s+@unique/); + }); + + it("the migration creates the unique index Prisma expects", () => { + const sql = read("prisma/migrations/20260905000000_stripe_application_fee/migration.sql"); + // The index NAME matters: Prisma derives `__key`, and CI's drift + // check compares the two. A differently-named index would enforce the same + // rule and still fail the build. + expect(sql).toMatch( + /CREATE UNIQUE INDEX "StripeApplicationFee_applicationFeeId_key"\s*\n?\s*ON "StripeApplicationFee"\("applicationFeeId"\)/, + ); + }); +}); diff --git a/tests/unit/stripe-fee-refund.test.ts b/tests/unit/stripe-fee-refund.test.ts new file mode 100644 index 0000000..5098608 --- /dev/null +++ b/tests/unit/stripe-fee-refund.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { feeRefundAmount } from "@/lib/stripe-fee-refund"; + +describe("feeRefundAmount", () => { + it("returns the whole fee on a full refund", () => { + expect( + feeRefundAmount({ chargeAmount: 4000, chargeAmountRefunded: 4000, feeAmount: 60, feeAmountRefunded: 0 }), + ).toBe(60); + }); + + it("owes nothing when nothing has been refunded", () => { + expect( + feeRefundAmount({ chargeAmount: 4000, chargeAmountRefunded: 0, feeAmount: 60, feeAmountRefunded: 0 }), + ).toBe(0); + }); + + it("prorates a half refund", () => { + expect( + feeRefundAmount({ chargeAmount: 4000, chargeAmountRefunded: 2000, feeAmount: 60, feeAmountRefunded: 0 }), + ).toBe(30); + }); + + it("is idempotent: a redelivery after the fee is already fully refunded owes 0", () => { + expect( + feeRefundAmount({ chargeAmount: 4000, chargeAmountRefunded: 4000, feeAmount: 60, feeAmountRefunded: 60 }), + ).toBe(0); + }); + + it("owes only the remainder when part of the fee was already returned", () => { + expect( + feeRefundAmount({ chargeAmount: 4000, chargeAmountRefunded: 4000, feeAmount: 60, feeAmountRefunded: 30 }), + ).toBe(30); + }); + + it("rounds the MIDPOINT half away from zero (the rounding-mode discriminator)", () => { + // 5 * 2000 / 4000 = 2.5 exactly — the one input where half-away-from-zero + // (3) and banker's/round-half-to-even (2) actually disagree. A value like + // 4.995 would NOT discriminate: every rounding mode returns 5 for it. + expect( + feeRefundAmount({ chargeAmount: 4000, chargeAmountRefunded: 2000, feeAmount: 5, feeAmountRefunded: 0 }), + ).toBe(3); + }); + + it("a quarter refund against a fee already refunded past that share owes 0, not negative", () => { + expect( + feeRefundAmount({ chargeAmount: 4000, chargeAmountRefunded: 1000, feeAmount: 60, feeAmountRefunded: 60 }), + ).toBe(0); + }); + + it("never divides by zero on a zero-amount charge", () => { + expect( + feeRefundAmount({ chargeAmount: 0, chargeAmountRefunded: 0, feeAmount: 60, feeAmountRefunded: 0 }), + ).toBe(0); + }); + + it("clamps due to what remains of the fee, even on a rounding overshoot", () => { + expect( + feeRefundAmount({ chargeAmount: 4000, chargeAmountRefunded: 4000, feeAmount: 60, feeAmountRefunded: 55 }), + ).toBe(5); + }); + + // The ceiling clamp is UNREACHABLE through Stripe: a charge cannot report more + // refunded than its own amount, so `target` can never exceed `feeAmount`. That + // makes it defensive code — and defensive code nothing exercises is a guard + // nobody can show works. Proven by mutation: deleting the `Math.min(...)` left + // every other case in this file green. + // + // So this feeds it the impossible input directly. Without the clamp the answer + // is 75, and we would ask Stripe to refund MORE than the fee it is refunding + // against — a request Stripe rejects, turning a silent upstream anomaly into a + // failing webhook that retries forever. + it("never asks to refund more than the fee, even if the charge reports the impossible", () => { + expect( + feeRefundAmount({ chargeAmount: 4000, chargeAmountRefunded: 5000, feeAmount: 60, feeAmountRefunded: 0 }), + ).toBe(60); + }); +}); diff --git a/tests/unit/stripe-signature.test.ts b/tests/unit/stripe-signature.test.ts new file mode 100644 index 0000000..b8fa672 --- /dev/null +++ b/tests/unit/stripe-signature.test.ts @@ -0,0 +1,80 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { SIGNATURE_TOLERANCE_SECONDS, verifyStripeSignature } from "@/lib/stripe-signature"; + +const SECRET = "whsec_test_secret"; +const BODY = '{"id":"evt_1","type":"charge.refunded"}'; +const NOW = 1_700_000_000; + +function sign(secret: string, timestamp: number, body: string): string { + return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex"); +} + +function header(timestamp: number, ...v1s: string[]): string { + return [`t=${timestamp}`, ...v1s.map((v1) => `v1=${v1}`)].join(","); +} + +describe("verifyStripeSignature", () => { + it("accepts a valid signature", () => { + const v1 = sign(SECRET, NOW, BODY); + expect(verifyStripeSignature(BODY, header(NOW, v1), SECRET, NOW)).toBe(true); + }); + + it("rejects a tampered body", () => { + const v1 = sign(SECRET, NOW, BODY); + const tampered = BODY.replace("charge.refunded", "charge.succeeded"); + expect(verifyStripeSignature(tampered, header(NOW, v1), SECRET, NOW)).toBe(false); + }); + + it("rejects an expired timestamp", () => { + const staleAt = NOW - SIGNATURE_TOLERANCE_SECONDS - 1; + const v1 = sign(SECRET, staleAt, BODY); + expect(verifyStripeSignature(BODY, header(staleAt, v1), SECRET, NOW)).toBe(false); + }); + + it("accepts a timestamp exactly at the tolerance boundary", () => { + const boundary = NOW - SIGNATURE_TOLERANCE_SECONDS; + const v1 = sign(SECRET, boundary, BODY); + expect(verifyStripeSignature(BODY, header(boundary, v1), SECRET, NOW)).toBe(true); + }); + + it("rejects a timestamp from the future beyond tolerance too — a replay guard, not just an expiry check", () => { + const future = NOW + SIGNATURE_TOLERANCE_SECONDS + 1; + const v1 = sign(SECRET, future, BODY); + expect(verifyStripeSignature(BODY, header(future, v1), SECRET, NOW)).toBe(false); + }); + + it("accepts when ANY of multiple v1 values matches — the secret-rotation case", () => { + const wrong = sign("whsec_previous_secret_wrong", NOW, BODY); + const right = sign(SECRET, NOW, BODY); + expect(verifyStripeSignature(BODY, header(NOW, wrong, right), SECRET, NOW)).toBe(true); + }); + + it("rejects when no v1 value matches", () => { + const wrongA = sign("whsec_a", NOW, BODY); + const wrongB = sign("whsec_b", NOW, BODY); + expect(verifyStripeSignature(BODY, header(NOW, wrongA, wrongB), SECRET, NOW)).toBe(false); + }); + + it("rejects a missing or malformed header without throwing", () => { + expect(verifyStripeSignature(BODY, "", SECRET, NOW)).toBe(false); + expect(verifyStripeSignature(BODY, "garbage", SECRET, NOW)).toBe(false); + expect(() => verifyStripeSignature(BODY, "t=notanumber,v1=abc", SECRET, NOW)).not.toThrow(); + }); + + it("does not throw on a v1 value of the wrong length", () => { + expect(() => verifyStripeSignature(BODY, header(NOW, "x".repeat(4096)), SECRET, NOW)).not.toThrow(); + expect(verifyStripeSignature(BODY, header(NOW, "x".repeat(4096)), SECRET, NOW)).toBe(false); + }); + + it("ignores an unrecognized key (Stripe's deprecated v0 scheme) and still validates on v1", () => { + const v1 = sign(SECRET, NOW, BODY); + const withV0 = `v0=${"deadbeef".repeat(8)},${header(NOW, v1)}`; + expect(verifyStripeSignature(BODY, withV0, SECRET, NOW)).toBe(true); + }); + + it("rejects an empty t or v1 value without throwing", () => { + expect(verifyStripeSignature(BODY, `t=,v1=${sign(SECRET, NOW, BODY)}`, SECRET, NOW)).toBe(false); + expect(verifyStripeSignature(BODY, `t=${NOW},v1=`, SECRET, NOW)).toBe(false); + }); +}); diff --git a/tests/unit/stripe-webhook-secrets.test.ts b/tests/unit/stripe-webhook-secrets.test.ts new file mode 100644 index 0000000..2d391a9 --- /dev/null +++ b/tests/unit/stripe-webhook-secrets.test.ts @@ -0,0 +1,104 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { verifyingScope, webhookSecrets } from "@/lib/stripe-webhook-secrets"; + +const CONNECT = "whsec_connect_endpoint"; +const ACCOUNT = "whsec_account_endpoint"; +const NOW = 1788558359; + +/** A genuine Stripe-Signature for `body` under `secret` — same construction the + * API uses, so a passing test is not a test of our own re-implementation. */ +const sign = (body: string, secret: string, t = NOW) => + `t=${t},v1=${createHmac("sha256", secret).update(`${t}.${body}`).digest("hex")}`; + +describe("webhookSecrets", () => { + it("offers both scopes when both are configured, connect first", () => { + expect(webhookSecrets({ connect: CONNECT, account: ACCOUNT })).toEqual([ + { scope: "connect", secret: CONNECT }, + { scope: "account", secret: ACCOUNT }, + ]); + }); + + it("either one alone is a working configuration", () => { + expect(webhookSecrets({ connect: undefined, account: ACCOUNT })).toEqual([ + { scope: "account", secret: ACCOUNT }, + ]); + expect(webhookSecrets({ connect: CONNECT, account: undefined })).toEqual([ + { scope: "connect", secret: CONNECT }, + ]); + }); + + it("neither configured yields an empty list — the route's 503", () => { + expect(webhookSecrets({ connect: undefined, account: undefined })).toEqual([]); + }); + + it("a whitespace-only value is NOT configured", () => { + // `SECRET= ` in a box .env is truthy in JS. Without the trim the endpoint + // would answer 400 "invalid signature" instead of 503 "not configured", and + // the fee-refund runbook's step 2 uses exactly that distinction to prove the + // handler is set up before a charge is spent on it. + expect(webhookSecrets({ connect: " ", account: "\t" })).toEqual([]); + }); +}); + +describe("verifyingScope", () => { + const body = '{"id":"evt_1","type":"application_fee.created"}'; + const secrets = webhookSecrets({ connect: CONNECT, account: ACCOUNT }); + + it("names the ACCOUNT scope for a delivery signed by the account endpoint", () => { + // The whole point of the second secret: this delivery is signed by neither + // the first secret tried nor a rotation of it, and it must still be accepted. + expect(verifyingScope({ rawBody: body, header: sign(body, ACCOUNT), secrets, nowSeconds: NOW })) + .toBe("account"); + }); + + it("names the CONNECT scope for a delivery signed by the connect endpoint", () => { + expect(verifyingScope({ rawBody: body, header: sign(body, CONNECT), secrets, nowSeconds: NOW })) + .toBe("connect"); + }); + + it("returns null for a signature under neither secret", () => { + expect( + verifyingScope({ rawBody: body, header: sign(body, "whsec_wrong"), secrets, nowSeconds: NOW }), + ).toBeNull(); + }); + + it("returns null when the body does not match its own signature", () => { + const header = sign(body, ACCOUNT); + expect( + verifyingScope({ rawBody: `${body} `, header, secrets, nowSeconds: NOW }), + ).toBeNull(); + }); + + it("does not accept an account-signed delivery when only connect is configured", () => { + // The negative control on the whole feature: with one secret this is exactly + // today's behaviour, so a green result above proves the SECOND secret did + // the work and not some accident of the verifier. + expect( + verifyingScope({ + rawBody: body, + header: sign(body, ACCOUNT), + secrets: webhookSecrets({ connect: CONNECT, account: undefined }), + nowSeconds: NOW, + }), + ).toBeNull(); + }); + + it("returns null with no secrets at all", () => { + expect(verifyingScope({ rawBody: body, header: sign(body, ACCOUNT), secrets: [], nowSeconds: NOW })) + .toBeNull(); + }); + + it("still enforces the replay window", () => { + // Delegated to verifyStripeSignature, but asserted here so a future "try + // every secret" refactor cannot quietly widen it. + expect( + verifyingScope({ + rawBody: body, + header: sign(body, ACCOUNT, NOW - 3600), + secrets, + nowSeconds: NOW, + }), + ).toBeNull(); + }); +}); diff --git a/tests/unit/tenant-registry.test.ts b/tests/unit/tenant-registry.test.ts index 5efe2b7..c6cad25 100644 --- a/tests/unit/tenant-registry.test.ts +++ b/tests/unit/tenant-registry.test.ts @@ -28,7 +28,17 @@ describe("loadTenantRegistry", () => { const res = await loadTenantRegistry(); expect(res.ok).toBe(true); if (res.ok) { - expect(res.tenants.map((t) => t.slug)).toEqual(["demo", "pays", "rumi", "unpaired"]); // sorted + // sorted; commissioned/demo2 are S2a fixtures (registry-commission-edit.test.ts) + expect(res.tenants.map((t) => t.slug)).toEqual([ + "commissioned", + "demo", + "demo2", + "pays", + "rumi", + "unpaired", + "zeta", + "zeta2", + ]); const rumi = res.tenants.find((t) => t.slug === "rumi")!; expect(rumi.name).toBe("Rumi Restaurant"); expect(rumi.languages).toHaveLength(10); @@ -62,6 +72,26 @@ describe("loadTenantRegistry", () => { } }); + it("surfaces payments_commission_bps, which zod silently stripped before S2b", async () => { + // The regression this guards is invisible by inspection, same as the + // stripe_account case above: the key IS in registry.yml and IS read by + // provision-tenant.sh, and `effectivePaymentsMode` reads it as `undefined` + // the moment the schema line is dropped — which renders EVERY tenant on + // `commission` as permanently "pending", not merely one field blank. + process.env.TENANT_REGISTRY_PATH = fixture("registry-valid.yml"); + const res = await loadTenantRegistry(); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.tenants.find((t) => t.slug === "commissioned")!.payments_commission_bps).toBe( + 200, + ); + // Optional — absent for every entry that never set a rate, same + // convention `currentRegistryCommissionBps` (registry-commission-edit.ts) + // and `effectivePaymentsMode` both already read `undefined` as. + expect(res.tenants.find((t) => t.slug === "rumi")!.payments_commission_bps).toBeUndefined(); + } + }); + it("returns ok:false when a template value is outside classic|craft", async () => { process.env.TENANT_REGISTRY_PATH = fixture("registry-bad-template.yml"); const res = await loadTenantRegistry(); diff --git a/tests/unit/validation.test.ts b/tests/unit/validation.test.ts index 4d01cfb..fb87c43 100644 --- a/tests/unit/validation.test.ts +++ b/tests/unit/validation.test.ts @@ -8,6 +8,7 @@ import { onboardSchema, partnerStatusSchema, PARTNER_STATUSES, + paymentsModeChangeSchema, splitCsvLower, signupSchema, signupStatusSchema, @@ -364,6 +365,74 @@ describe("provisionSchema (ADR-012 tenant proposal)", () => { }); }); +describe("paymentsModeChangeSchema (S2a — amending an existing tenant)", () => { + const base = { tenantSlug: "pays" }; + + it("accepts flat with a 0 rate", () => { + const parsed = paymentsModeChangeSchema.safeParse({ ...base, mode: "flat", commissionBps: "0" }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.commissionBps).toBe(0); + }); + + it("accepts commission with a positive rate, coerced from a form string", () => { + const parsed = paymentsModeChangeSchema.safeParse({ + ...base, + mode: "commission", + commissionBps: "150", + }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.commissionBps).toBe(150); + }); + + it("refuses flat carrying a non-zero rate — the pair would disagree about the tenant's mode", () => { + const result = paymentsModeChangeSchema.safeParse({ + ...base, + mode: "flat", + commissionBps: "150", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("flat mode carries no commission rate"); + } + }); + + it("refuses commission carrying a 0 rate — same disagreement, the other direction", () => { + const result = paymentsModeChangeSchema.safeParse({ + ...base, + mode: "commission", + commissionBps: "0", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("commission mode needs a rate above zero"); + } + }); + + it("rejects a rate outside isCommissionBps's range, without restating the ceiling", () => { + expect( + paymentsModeChangeSchema.safeParse({ ...base, mode: "commission", commissionBps: "1001" }) + .success, + ).toBe(false); + expect( + paymentsModeChangeSchema.safeParse({ ...base, mode: "commission", commissionBps: "-1" }) + .success, + ).toBe(false); + }); + + it("rejects an invalid tenant slug", () => { + expect( + paymentsModeChangeSchema.safeParse({ tenantSlug: "Not Valid", mode: "flat", commissionBps: "0" }) + .success, + ).toBe(false); + }); + + it("rejects an unknown mode", () => { + expect( + paymentsModeChangeSchema.safeParse({ ...base, mode: "premium", commissionBps: "0" }).success, + ).toBe(false); + }); +}); + describe("splitCsvLower", () => { it("trims, lowercases and drops empties", () => { expect(splitCsvLower(" EN , nl ,, ")).toEqual(["en", "nl"]); diff --git a/vitest.config.ts b/vitest.config.ts index 92728af..ec7e37f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,6 +39,10 @@ export default defineConfig({ "lib/provision-form-input.ts", "lib/slug-availability.ts", "lib/provisioning-registry.ts", + // The account-pairing rule, split out of provisioning-registry.ts (S1) for the + // same LOC-limit reason as provisioning-pr-body.ts below — listed explicitly so + // splitDeferredModules's branches don't quietly drop out of the floor's scope. + "lib/provisioning-module-pairing.ts", // Split out of provisioning-registry.ts (P1) when the pair outgrew the LOC limit. // Listed explicitly because this include list is explicit: leaving it off would // have quietly moved already-covered code out of the floor's scope, which reads @@ -49,6 +53,22 @@ export default defineConfig({ // not get at the one reviewable moment before a tenant is stood up. "lib/provisioning-pr-blocks.ts", "lib/module-catalog.ts", + // S1 — the flat/commission arithmetic (quote adjustment, crossover, the + // MAX_COMMISSION_BPS ceiling). Pure by construction, same as module-catalog.ts + // beside it, and the one place the crossover formula every switching surface + // will quote (S2-S4) is decidable in isolation. + "lib/payments-pricing.ts", + // S2a — the pure halves of the amendment mechanism. `registry-commission-edit.ts` + // is the dangerous one (surgical line edits to a file humans hand-annotate); its + // GitHub-calling sibling (`registry-commission-pr.ts`) stays out, same split as + // provisioning.ts/provisioning-registry.ts. `payments-mode-effective.ts` is the + // billing-vs-enforcement predicate, same shape and same reason as + // payments-pending.ts just below it. + "lib/registry-commission-edit.ts", + "lib/payments-mode-effective.ts", + // S2b — the admin form's own eligibility gate, same pure shape and same + // fail-quiet direction as `payments-mode-effective.ts` just above it. + "lib/commission-eligibility.ts", "lib/tenant-options.ts", "lib/signup-configuration.ts", "lib/checkout-window.ts", @@ -152,6 +172,26 @@ export default defineConfig({ // between a compromised staging box being contained and it being able to // erase the control plane's record of the paying tenant's backups. "lib/backup-agent-auth.ts", + // ADR-011 amendment consequence 1 — "fee follows the refund". Pure, + // clock-free (`nowSeconds` is always passed in, same discipline as + // trial.ts) verification of the `Stripe-Signature` header. Its + // sibling `lib/stripe-fee-refund.ts` stays OUT of scope on purpose, + // same split as vies.ts/vies-result.ts: the orchestration half calls + // Stripe over the network and writes to the DB, which §7 forbids + // mocking, so only the pure arithmetic half (`feeRefundAmount`) is + // unit-tested and it is measured by the test file, not by this list. + "lib/stripe-signature.ts", + // The SECOND blocker. `commission-earnings.ts` is the arithmetic between + // the two fee tables and the panel — pure, clock-free (the window is + // passed in), and the module where a clamp, a summed pair of currencies + // or a fail-quiet regression turns into a wrong number beside a tenant's + // name. `stripe-webhook-secrets.ts` is the pure half of the dual-endpoint + // verification. Their orchestration sibling `lib/stripe-fee-earned.ts` + // stays OUT for the reason `stripe-fee-refund.ts` does: it calls Stripe + // and writes to the DB, and its pure halves are measured by their test + // file rather than by this list. + "lib/commission-earnings.ts", + "lib/stripe-webhook-secrets.ts", ], reporter: ["text-summary", "text"], // Floors sit a few points under the current 100/95/100/100 so a trivial