diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts index 39dfbfd..e76caa8 100644 --- a/app/api/signup/route.ts +++ b/app/api/signup/route.ts @@ -137,9 +137,10 @@ async function mintAccount( */ /** * 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. + * prices the online-payments module at its reduced €9 floor rather than at the + * full list price — 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") { diff --git a/components/PaymentsModeChoice.tsx b/components/PaymentsModeChoice.tsx index 73739c3..847fd83 100644 --- a/components/PaymentsModeChoice.tsx +++ b/components/PaymentsModeChoice.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import { + COMMISSION_FLOOR_CENTS, DEFAULT_COMMISSION_BPS, ONLINE_PAYMENTS_PRICE_CENTS, crossoverCentsPerMonth, @@ -11,9 +12,8 @@ import { 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. + * How to be charged for `online-payments`, offered on /signup — the full flat + * monthly fee, or a REDUCED €9/mo floor 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` / @@ -59,8 +59,14 @@ export default function PaymentsModeChoice({ // 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); + // No price argument: the break-even is against what commission SAVES on the + // module (€19 - €9 = €10), which is the function's own default — the full list + // price would promise a buyer they are cheaper up to 1.9x more turnover than + // they actually are, on the page where they choose. + const crossover = crossoverCentsPerMonth(DEFAULT_COMMISSION_BPS); const percent = formatCommissionPercent(DEFAULT_COMMISSION_BPS); + const floor = eur(COMMISSION_FLOOR_CENTS); + const flatPrice = eur(ONLINE_PAYMENTS_PRICE_CENTS); return (
@@ -84,7 +90,7 @@ export default function PaymentsModeChoice({ />
@@ -105,7 +111,7 @@ export default function PaymentsModeChoice({ />
@@ -116,7 +122,7 @@ export default function PaymentsModeChoice({ {crossover !== null && (

- {t("crossover", { percent, amount: eur(crossover) })} + {t("crossover", { percent, amount: eur(crossover), floor, price: flatPrice })}

)}

{t("deferredNote")}

diff --git a/components/control/PaymentsModeForm.tsx b/components/control/PaymentsModeForm.tsx index 1144bfd..54b7110 100644 --- a/components/control/PaymentsModeForm.tsx +++ b/components/control/PaymentsModeForm.tsx @@ -7,6 +7,8 @@ import type { PaymentsModeTarget, } from "@/lib/actions/payments-mode-change"; import { + COMMISSION_FLOOR_CENTS, + COMMISSION_MODE_SAVING_CENTS, DEFAULT_COMMISSION_BPS, MAX_COMMISSION_BPS, ONLINE_PAYMENTS_PRICE_CENTS, @@ -83,7 +85,10 @@ export default function PaymentsModeForm({ else if (bps === 0) setBps(DEFAULT_COMMISSION_BPS); }; - const preview = mode === "commission" ? crossoverCentsPerMonth(bps, ONLINE_PAYMENTS_PRICE_CENTS) : null; + // No price argument — the live preview is the break-even against what + // `commission` SAVES on the module (€19 - €9), which is the function's own + // default. Passing the full list price here would overstate it by 1.9x. + const preview = mode === "commission" ? crossoverCentsPerMonth(bps) : null; return (
@@ -98,7 +103,7 @@ export default function PaymentsModeForm({ checked={mode === "flat"} onChange={() => handleModeChange("flat")} /> - {t("modeFlat")} + {t("modeFlat", { price: eur(ONLINE_PAYMENTS_PRICE_CENTS) })} {!eligibility.eligible && (

@@ -137,7 +142,13 @@ export default function PaymentsModeForm({ {preview !== null && (

- {t("crossover", { percent: formatCommissionPercent(bps), amount: eur(preview) })} + {t("crossover", { + percent: formatCommissionPercent(bps), + amount: eur(preview), + floor: eur(COMMISSION_FLOOR_CENTS), + price: eur(ONLINE_PAYMENTS_PRICE_CENTS), + saving: eur(COMMISSION_MODE_SAVING_CENTS), + })}

)}
diff --git a/components/control/PaymentsModePanel.tsx b/components/control/PaymentsModePanel.tsx index 8539834..de4cc5b 100644 --- a/components/control/PaymentsModePanel.tsx +++ b/components/control/PaymentsModePanel.tsx @@ -1,6 +1,8 @@ import { getTranslations } from "next-intl/server"; import { eur } from "@/lib/format"; import { + COMMISSION_FLOOR_CENTS, + COMMISSION_MODE_SAVING_CENTS, ONLINE_PAYMENTS_PRICE_CENTS, crossoverCentsPerMonth, formatCommissionPercent, @@ -69,7 +71,10 @@ export default async function PaymentsModePanel({ // `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); + // No price argument: the break-even is driven by what `commission` SAVES on + // the module (€19 - €9 = €10), never by its full list price — the default + // (COMMISSION_MODE_SAVING_CENTS) is the only basis that is ever correct here. + const crossover = crossoverCentsPerMonth(effectiveBps); const eligibility = commissionEligibility({ registryReadable, tenant: registryTenant }); return ( @@ -77,8 +82,11 @@ export default async function PaymentsModePanel({

{t("title")}

{effective.mode === "commission" - ? t("commissionSummary", { percent: formatCommissionPercent(effectiveBps) }) - : t("flatSummary")} + ? t("commissionSummary", { + percent: formatCommissionPercent(effectiveBps), + floor: eur(COMMISSION_FLOOR_CENTS), + }) + : t("flatSummary", { price: eur(ONLINE_PAYMENTS_PRICE_CENTS) })}

{effective.pending && ( // , not

: it carries the same implicit ARIA role @@ -94,7 +102,13 @@ export default async function PaymentsModePanel({ high" and must never be printed as one. */} {crossover !== null && (

- {t("crossover", { percent: formatCommissionPercent(effectiveBps), amount: eur(crossover) })} + {t("crossover", { + percent: formatCommissionPercent(effectiveBps), + amount: eur(crossover), + floor: eur(COMMISSION_FLOOR_CENTS), + price: eur(ONLINE_PAYMENTS_PRICE_CENTS), + saving: eur(COMMISSION_MODE_SAVING_CENTS), + })}

)}
diff --git a/lib/payments-pricing.ts b/lib/payments-pricing.ts index f9eb88b..3265d6f 100644 --- a/lib/payments-pricing.ts +++ b/lib/payments-pricing.ts @@ -1,11 +1,12 @@ -// Payments pricing mode — flat fee vs per-transaction commission -// (workspace docs/plans/SOFRA-PAYMENTS-PRICING-MODE-PLAN.md, S1). +// Payments pricing mode — flat fee vs per-transaction commission. Prices live in +// workspace `docs/plans/SOFRA-MODULE-CATALOG-AND-PRICING.md` §3a; the decision +// behind the commission floor is in `docs/plans/BACKLOG.md`. // // 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. +// billed for using it, and the pure arithmetic that `/admin`, the signup +// configurator and the partner dashboard must all 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 @@ -15,11 +16,12 @@ 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). + * `flat` — the tenant pays the `online-payments` module's full 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 + * `commission` — the module drops to a REDUCED FLOOR ({@link + * COMMISSION_FLOOR_CENTS}, not zero) and Sofra takes a per-transaction cut on + * top (`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 @@ -32,14 +34,11 @@ 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. + * Chosen, not arbitrary: it 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. It + * is deliberately UNCHANGED by the reduced floor below — the floor moved the + * crossover, the rate did not have to. */ export const DEFAULT_COMMISSION_BPS = 150; @@ -47,31 +46,28 @@ 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). + * each enforce their own copy — this is not the one place it lives, it is one of + * three that must agree, because each layer can be reached without 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. + * silently CAPS it at 100% of the order. A requested 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 surface as no Stripe error + * at all — it would just take the whole order, silently, on every payment. The + * ceiling makes that unreachable long before 100%: a safety guard rather than a + * pricing preference, which is why every layer that can write a rate re-declares + * it instead of trusting an upstream check. */ 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}. + * Whether `value` is a rate this system accepts 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. + * Basis points are always whole here — `provision-tenant.sh` parses the registry + * field with `^[0-9]+$`, so a fractional bps could never survive the round trip. */ export function isCommissionBps(value: number): boolean { return Number.isInteger(value) && value >= 0 && value <= MAX_COMMISSION_BPS; @@ -80,40 +76,62 @@ export function isCommissionBps(value: number): boolean { // 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). +// because MODULES is a plain array (module-catalog.ts's own PRICE_CENTS lookup +// exists for exactly this reason but is not exported, and a second copy of it +// here is the duplication DRY forbids). // // 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. +// Exported because both prices a switching surface shows are read from here: the +// flat option's own price, and (through COMMISSION_MODE_SAVING_CENTS) what the +// commission option takes off it. export const ONLINE_PAYMENTS_PRICE_CENTS = MODULES.find((m) => m.id === "online-payments")!.priceCents; +/** + * What `online-payments` costs per month under `commission` — €9, **not €0** + * (workspace `docs/plans/BACKLOG.md`, decided by the owner 2026-09-05). + * + * Offering `flat` and `commission` as a free peer choice collects + * `min(flat, commission)` from every tenant, since each picks whichever is + * cheaper at their own volume — worse than charging everybody the €19 flat + * module. A floor keeps the choice and caps its downside at €9/tenant/mo instead + * of €0. A FLOOR, not a discount: this is paid monthly PLUS the rate. + */ +export const COMMISSION_FLOOR_CENTS = 900; + +/** + * What choosing `commission` actually SAVES on the module — €19 − €9 = €10/mo — + * and therefore the only figure a crossover may be computed from. + * + * THE TRAP THE FLOOR INTRODUCED: before it, the saving WAS the full list price, + * so passing `ONLINE_PAYMENTS_PRICE_CENTS` was right by accident. With a floor + * that overstates the break-even by 1.9x (€1,267 against €667) on the exact page + * where someone commits money. Hence derived from both prices rather than + * written down, and {@link crossoverCentsPerMonth} DEFAULTS to it — a UI caller + * cannot pass the wrong basis because it passes none at all. + */ +export const COMMISSION_MODE_SAVING_CENTS = ONLINE_PAYMENTS_PRICE_CENTS - COMMISSION_FLOOR_CENTS; + /** * 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. + * `commission` reduces that line to {@link COMMISSION_FLOOR_CENTS} — it does NOT + * remove it — because Sofra is additionally paid per transaction (via + * `payments_commission_bps`, not this quote). A tenant without the module is + * unaffected either way: there is nothing to reduce, and `commission` mode is + * meaningless without the module 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`. + * @param hasOnlinePayments Whether the selection includes `online-payments` — + * passed in rather than re-derived, because a second parse here could disagree + * with the one that produced `baseQuoteCents`. */ export function paymentsModeQuote( baseQuoteCents: number, @@ -121,58 +139,60 @@ export function paymentsModeQuote( hasOnlinePayments: boolean, ): number { if (mode !== "commission" || !hasOnlinePayments) return baseQuoteCents; - return baseQuoteCents - ONLINE_PAYMENTS_PRICE_CENTS; + return baseQuoteCents - COMMISSION_MODE_SAVING_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. + * currency, at which `commission` costs exactly what `flat` costs — the sentence + * EVERY switching surface must show, so nobody moves a busy tenant onto a mode + * that quietly costs them (or Sofra) more than the one they left. * - * 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. + * Derivation: the modes cost the same when the commission equals what the module + * was REDUCED BY — `turnover * (bps / 10000) = savingCents`, so `turnover = + * savingCents * 10000 / bps`, rearranged below to stay in integers until the one + * unavoidable division. The SAVING, never the full list price: a `commission` + * tenant still pays {@link COMMISSION_FLOOR_CENTS}, so only the €10 difference + * has to be earned back. * * @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". + * at which the two modes cross — "free forever" is a different statement from + * "the crossover is very high" and must not be rendered as a number. + * @param savingCents What choosing `commission` takes off the monthly bill. + * Defaults to {@link COMMISSION_MODE_SAVING_CENTS} — what every UI caller + * wants and therefore what none of them passes; the parameter survives only so + * the arithmetic stays testable against worked examples that owe nothing to + * the current catalog. + * @returns The turnover, rounded to the nearest cent. A figure for a sentence a + * human reads, never a billing amount computed FROM it, so the rounding + * direction needs no argument for which way is "safe". */ -export function crossoverCentsPerMonth(bps: number, flatCents: number): number | null { +export function crossoverCentsPerMonth( + bps: number, + savingCents: number = COMMISSION_MODE_SAVING_CENTS, +): number | null { if (bps === 0) return null; - return Math.round((flatCents * 10000) / bps); + return Math.round((savingCents * 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%"`. + * `"1.50%"`. Always two decimals: a rate can be as fine as 1 basis point + * (0.01%), which one decimal would silently collapse 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`. + * Narrow `TenantBilling.paymentsMode` — a plain Prisma `String` column, per this + * repo's handwritten-migration workflow (§5.2), not an enum — to {@link + * PaymentsMode}. Anything but 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 goes through this rather than an inline cast, so a typo in a future + * caller fails a type check. */ export function asPaymentsMode(value: string): PaymentsMode { return value === "commission" ? "commission" : "flat"; diff --git a/lib/provisioning-pr-blocks.ts b/lib/provisioning-pr-blocks.ts index c2a6b54..1f9f2cb 100644 --- a/lib/provisioning-pr-blocks.ts +++ b/lib/provisioning-pr-blocks.ts @@ -12,6 +12,7 @@ // Pure: no GitHub API, no secrets, no env. import type { TenantProvisionInput } from "./provisioning-registry"; +import { COMMISSION_FLOOR_CENTS } from "./payments-pricing"; /** * Bought but deliberately NOT in this entry. @@ -86,8 +87,10 @@ export function commissionSection( `### 💳 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", + // Read from the constant rather than typed as prose — a body that hardcoded + // the floor would keep quoting the old number after it moved. + `the reduced €${(COMMISSION_FLOOR_CENTS / 100).toFixed(2)}/mo floor (NOT €0) and Sofra takes this rate on top,`, + "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.", diff --git a/lib/registry-commission-pr-body.ts b/lib/registry-commission-pr-body.ts new file mode 100644 index 0000000..8e88391 --- /dev/null +++ b/lib/registry-commission-pr-body.ts @@ -0,0 +1,71 @@ +// The PR body a commission-rate amendment opens with — the pure half of +// `registry-commission-pr.ts`, split out for the reason `provisioning.ts` split +// `provisioning-pr-body.ts` and `provisioning-pr-blocks.ts` out of itself: its +// GitHub-calling sibling cannot be unit-tested (it needs a token and the +// network), so anything left inside it is decided by reading, not by a test. +// +// That is not a hypothetical cost here. This body's crossover sentence said the +// OPPOSITE of the truth — "below that figure `flat` would have cost this tenant +// less", when below the crossover it is `commission` that is cheaper — from the +// day it was written until 2026-09-05, because no test could see it. It is the +// sentence a founder reads immediately before merging a live per-transaction +// rate. Pure by construction: no GitHub API, no env, no secrets. + +import { + COMMISSION_FLOOR_CENTS, + COMMISSION_MODE_SAVING_CENTS, + ONLINE_PAYMENTS_PRICE_CENTS, + crossoverCentsPerMonth, +} from "./payments-pricing"; + +// Integer cents as a plain major-units string for a MARKDOWN body — not +// `format.ts`'s `eur()`, whose nl-NL output ("€ 9,00") carries a currency symbol +// this text supplies itself and a figure the surrounding sentence explicitly +// says is in the TENANT's currency, not Sofra's EUR. +const majorUnits = (cents: number): string => (cents / 100).toFixed(2); + +/** + * 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. + */ +export function commissionChangePrBody(slug: string, oldBps: number, newBps: number): string { + // No second argument: the crossover is driven by what `commission` SAVES on + // the module, not by its full list price — see COMMISSION_MODE_SAVING_CENTS, + // which this deliberately defaults to. + const crossover = crossoverCentsPerMonth(newBps); + // Read from the constants, never typed as prose: this file deleted its own + // duplicated catalog lookup for exactly this reason, and a PR body that + // hardcodes "€9" is the same drift one layer further out — it would keep + // saying it after the floor moved, while every UI surface had already changed. + const floor = majorUnits(COMMISSION_FLOOR_CENTS); + const full = majorUnits(ONLINE_PAYMENTS_PRICE_CENTS); + const saving = majorUnits(COMMISSION_MODE_SAVING_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** ~\`${majorUnits(crossover)}\` of monthly online turnover (this tenant's own billing currency, major units) — below that figure \`commission\` costs this tenant LESS than \`flat\`; above it, more. Computed from the €${saving} the module drops by under \`commission\` (€${full} → the €${floor} floor), not from the full €${full}` + : "- **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"); +} diff --git a/lib/registry-commission-pr.ts b/lib/registry-commission-pr.ts index 1a6c38d..9a2ee72 100644 --- a/lib/registry-commission-pr.ts +++ b/lib/registry-commission-pr.ts @@ -18,51 +18,10 @@ import { 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; +import { commissionChangePrBody } from "./registry-commission-pr-body"; 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, diff --git a/lib/self-serve-signup.ts b/lib/self-serve-signup.ts index c93c88b..3bc284c 100644 --- a/lib/self-serve-signup.ts +++ b/lib/self-serve-signup.ts @@ -7,45 +7,42 @@ // // 1. is the wished subdomain actually usable? It stops being a wish the moment // it becomes `TenantBilling.tenantSlug` — the unique billing anchor. -// 2. what does the plan cost? Re-quoted from the catalog, never read back from -// the stored `quotedCents`. +// 2. what does the plan cost? Re-quoted from the catalog AND from the payments +// mode, never read back from the stored `quotedCents`. // // Three outcomes, because "no" has two very different meanings: // // • account — mint it. // • refuse — the customer can fix this themselves, at the keyboard, by // changing one field. Nothing is written; they resubmit. -// • leadOnly — the customer CANNOT fix this (the email already has an account; -// they configured nothing to price; the registry is unreadable). -// The lead is still captured and the founder takes it from there — -// exactly today's behaviour, which is why this is a degradation -// and not a rejection. +// • leadOnly — the customer CANNOT fix this (the email already has an account; they +// configured nothing to price; the registry is unreadable). The lead is +// still captured and the founder takes it — a degradation, not a refusal. // // Two residuals, both accepted on cost alone: -// // 1. `account: true` vs `account: false` is a weak oracle for "does this email // already have an account". Weak because each probe costs a distinct unused // slug, leaves a lead row the founder reads, and is capped at 5 per 15 min per -// IP by `guardIntake`. (This used to be justified by the alternative being a -// worse lie — telling a real customer "check your email" when none was sent. -// That lie is gone since G5, so the trade-off now rests on the cost above and -// nothing else.) +// IP by `guardIntake`. (It used to be justified by the alternative being a worse +// lie — "check your email" when none was sent. That lie is gone since G5, so the +// trade-off now rests on the cost above and nothing else.) // 2. `emailed: false` (G5) tells an unauthenticated caller that the welcome mail // did not go out. `sendEmail` collapses every Resend non-2xx into `{sent:false}`, -// and that set includes PER-RECIPIENT rejections — a suppressed address after a -// hard bounce, or the 403 a sandbox sender returns for everyone but the account -// owner. So a probe can learn something about an address at a third party that -// it could not otherwise. Same cost cap as (1), and the alternative — hiding a -// failed send from the one person it strands — is the gap this closed. If -// `sendEmail` ever returns a reason, echo only the non-recipient-specific ones. +// including PER-RECIPIENT rejections — a suppressed address after a hard bounce, +// or the 403 a sandbox sender returns for everyone but the account owner — so a +// probe can learn something about an address at a third party. Same cost cap as +// (1), and the alternative (hiding a failed send from the one person it strands) +// is the gap this closed. If `sendEmail` ever returns a reason, echo only the +// non-recipient-specific ones. // // That split is the honest reading of the plan's drop-don't-reject rule -// (lib/signup-configuration.ts): never lose a lead over something the customer -// has no way to resolve, but do ask them to change a subdomain that is taken. +// (lib/signup-configuration.ts): never lose a lead over something the customer has +// no way to resolve, but do ask them to change a subdomain that is taken. // // Pure — no DB, no network, no env — so every branch is unit-testable. import { isModuleId, quoteModules, type ModuleId } from "./module-catalog"; +import { paymentsModeQuote } from "./payments-pricing"; import { parseCsv } from "./tenant-options"; import type { SlugStatus } from "./slug-availability"; import type { StoredSignupConfiguration } from "./signup-configuration"; @@ -111,11 +108,16 @@ export type SelfServeInput = { * never leave a freshly minted user or a half-built plan behind. * * The price is recomputed here from the module list rather than read out of - * `config.quotedCents`. Those two are equal by construction today — the sanitizer - * writes the quote it computed — but reading the stored number would make a - * column the customer's POST can influence the input to a charge. Re-quoting - * makes the catalog the only thing that can set a price, which is the invariant - * worth protecting even when the shortcut would currently agree. + * `config.quotedCents`, because reading the stored number would make a column the + * customer's POST can influence the input to a charge. Re-quoting makes the + * catalog the only thing that can set a price. + * + * But a re-quote has to reproduce the WHOLE quote, and until 2026-09-05 this one + * skipped the payments mode: a buyer who chose `commission` was SHOWN the + * mode-adjusted total and BILLED the un-adjusted one. The doc here even claimed the + * two were "equal by construction" — they were equal only for `flat`. Both sides go + * through {@link paymentsModeQuote} now, and the test asserts the two PATHS agree + * rather than a number: two independent computations of one price IS the defect. */ export function decideSelfServe(input: SelfServeInput): SelfServeOutcome { // 0. No slug at all is NOT a refusal. The field was optional before O2 and the @@ -174,17 +176,24 @@ export function decideSelfServe(input: SelfServeInput): SelfServeOutcome { }; } - // 4. A plan needs something to price. The live form always posts `core` (a - // hidden input), so this is the plain-form / stale-bundle case that the - // sanitizer reports as NOTHING_CHOSEN. Inventing "core only" here would put - // words in the customer's mouth AND charge them for the guess. + // 4. A plan needs something to price. The live form always posts `core` (a hidden + // input), so this is the plain-form / stale-bundle case the sanitizer reports as + // NOTHING_CHOSEN. Inventing "core only" would put words in the customer's mouth + // AND charge them for the guess. const modules = parseCsv(input.config.modules).filter(isModuleId); if (modules.length === 0) return { kind: "leadOnly", reason: "nothingConfigured" }; return { kind: "account", slug: input.slug, - amountCents: quoteModules(modules).monthlyCents, + // Mode-adjusted, exactly as the sanitizer adjusts the number the buyer was SHOWN. + // `paymentsMode` is null only when nothing was chosen, which `nothingConfigured` + // above already returned on; `flat` is the reading every pre-configurator lead has. + amountCents: paymentsModeQuote( + quoteModules(modules).monthlyCents, + input.config.paymentsMode ?? "flat", + modules.includes("online-payments"), + ), modules, }; } diff --git a/lib/signup-configuration.ts b/lib/signup-configuration.ts index 34070a2..5232424 100644 --- a/lib/signup-configuration.ts +++ b/lib/signup-configuration.ts @@ -13,7 +13,8 @@ // recomputed from the catalog so a crafted POST cannot make the founder read // 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 +// `commission` re-prices through `paymentsModeQuote` (the module drops to its +// €9 floor, it is not removed), 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. diff --git a/messages/ar.json b/messages/ar.json index 5d80cb6..906834c 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -1003,13 +1003,13 @@ }, "paymentsMode": { "title": "وضع تسعير المدفوعات", - "flatSummary": "يُفوَّتر بسعر ثابت — 19 يورو/شهريًا مقابل المدفوعات عبر الإنترنت.", - "commissionSummary": "يُفوَّتر بعمولة — {percent} من كل طلب عبر الإنترنت، دون رسوم وحدة شهرية.", + "flatSummary": "يُفوَّتر بسعر ثابت — {price}/شهريًا للمدفوعات عبر الإنترنت.", + "commissionSummary": "يُفوَّتر بعمولة — {percent} من كل طلب عبر الإنترنت، بالإضافة إلى {floor}/شهريًا مخفّضة للوحدة.", "pendingNote": "لم يواكب السجل هذا بعد — يجب دمج طلب سحب للسجل وإعادة تجهيز المستأجر قبل أن يصبح ساري المفعول. إعادة التشغيل العادية لا تعيد قراءة شيء.", - "crossover": "بمعدل {percent}، تكلف العمولة مثل الوحدة ذات السعر الثابت (19 يورو/شهريًا) تمامًا عند نحو {amount} من الإيرادات الشهرية عبر الإنترنت. تحت هذا الرقم يدفع هذا المستأجر أقل مع العمولة مقارنة بالسعر الثابت؛ وفوقه، أكثر.", + "crossover": "مع العمولة تصبح الوحدة {floor}/شهريًا بدلًا من {price}، لذا على {percent} من المبيعات تعويض الفارق البالغ {saving} — يتساوى الوضعان عند نحو {amount} من الإيرادات الشهرية عبر الإنترنت. تحت هذا الرقم يدفع هذا المستأجر أقل مع العمولة مقارنة بالسعر الثابت؛ وفوقه، أكثر.", "modeLabel": "الوضع", - "modeFlat": "سعر ثابت (19 يورو/شهريًا)", - "modeCommission": "عمولة", + "modeFlat": "سعر ثابت ({price}/شهريًا)", + "modeCommission": "عمولة ({floor}/شهريًا + المعدل)", "rateLabel": "المعدل (نقاط أساس، 100 = 1٪)", "rateHint": "مثال: 150 = 1.50٪. الحد الأقصى {max} نقطة أساس.", "notEligibleRegistryUnavailable": "تعذّرت قراءة سجل المستأجرين، لذا لا يمكن التحقق من أهلية العمولة الآن.", @@ -1213,13 +1213,13 @@ }, "clientPaymentsMode": { "title": "تسعير المدفوعات عبر الإنترنت", - "flatSummary": "سعر ثابت — 19 يورو/شهريًا مقابل المدفوعات عبر الإنترنت، ويحتفظ عميلك بكل سنت من كل طلب (بعد خصم رسوم Stripe).", - "commissionSummary": "عمولة — {percent} من كل طلب عبر الإنترنت، ودون رسوم شهرية لوحدة المدفوعات عبر الإنترنت.", + "flatSummary": "سعر ثابت — {price}/شهريًا مقابل المدفوعات عبر الإنترنت، ويحتفظ عميلك بكل سنت من كل طلب (بعد خصم رسوم Stripe).", + "commissionSummary": "عمولة — {percent} من كل طلب عبر الإنترنت، بالإضافة إلى {floor}/شهريًا مخفّضة لوحدة المدفوعات عبر الإنترنت.", "pendingNote": "هذا طلب لم يُطبَّق بعد. على SofraPiwas دمج التغيير وإعادة تجهيز تطبيق عميلك قبل أن يصبح ساري المفعول — وحتى ذلك الحين تبقى الفوترة على حالها السابق.", - "crossover": "بمعدل {percent}، تكلف العمولة مثل الوحدة ذات السعر الثابت (19 يورو/شهريًا) تمامًا عند نحو {amount} من الإيرادات الشهرية عبر الإنترنت. تحت هذا الرقم يدفع عميلك أقل مع العمولة مقارنة بالسعر الثابت؛ وفوقه، أكثر.", + "crossover": "مع العمولة تصبح الوحدة {floor}/شهريًا بدلًا من {price}، لذا على {percent} من المبيعات تعويض الفارق البالغ {saving} — يتساوى الوضعان عند نحو {amount} من الإيرادات الشهرية عبر الإنترنت. تحت هذا الرقم يدفع عميلك أقل مع العمولة مقارنة بالسعر الثابت؛ وفوقه، أكثر.", "modeLabel": "التسعير", - "modeFlat": "سعر ثابت (19 يورو/شهريًا)", - "modeCommission": "عمولة", + "modeFlat": "سعر ثابت ({price}/شهريًا)", + "modeCommission": "عمولة ({floor}/شهريًا + المعدل)", "rateLabel": "المعدل (نقاط أساس، 100 = 1٪)", "rateHint": "مثال: 150 = 1.50٪. الحد الأقصى {max} نقطة أساس.", "notEligibleRegistryUnavailable": "لا يمكننا التحقق من إعداد هذا العميل الآن، لذا تبقى العمولة معطّلة في الوقت الحالي.", @@ -1519,9 +1519,9 @@ "title": "كيف تُحاسَب على المدفوعات عبر الإنترنت", "flatLabel": "رسم ثابت — {price} شهريًا", "flatHint": "سعر ثابت واحد، مهما بلغت مبيعاتك عبر الإنترنت.", - "commissionLabel": "بلا رسوم شهرية — {percent} لكل طلب", - "commissionHint": "لا شيء حتى يدفع أحد الضيوف بالفعل عبر الإنترنت.", - "crossover": "دون نحو {amount} من المبيعات الشهرية عبر الإنترنت، يكلفك معدل {percent} أقل من الرسم الثابت؛ وفوق ذلك يصبح الرسم الثابت أوفر.", + "commissionLabel": "{floor}/شهريًا + {percent} لكل طلب", + "commissionHint": "سعر ثابت أقل، والباقي فقط عندما يدفع ضيف عبر الإنترنت.", + "crossover": "دون نحو {amount} من المبيعات الشهرية عبر الإنترنت، يكلفك {floor}/شهريًا + {percent} أقل من الرسم الثابت {price}؛ وفوق ذلك يصبح الرسم الثابت أوفر.", "deferredNote": "يحتاج الدفع عبر الإنترنت إلى حساب Stripe خاص بك، لا يمكن لغيرك إنشاؤه — سنرسل لك الرابط بمجرد أن يصبح مطعمك مباشرًا. إلى ذلك الحين، هذا مجرد تفضيلك المسجَّل؛ يُفعَّل مباشرة بعد ذلك." } } diff --git a/messages/de.json b/messages/de.json index 84ba191..0109d40 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1003,13 +1003,13 @@ }, "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.", + "flatSummary": "Pauschal abgerechnet — {price}/Monat für Online-Zahlungen.", + "commissionSummary": "Nach Provision abgerechnet — {percent} von jeder Online-Bestellung, plus reduzierte {floor}/Monat für das Modul.", "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.", + "crossover": "Bei Provision kostet das Modul {floor}/Monat statt {price}, sodass {percent} des Umsatzes die Differenz von {saving} ausgleichen muss — beide Modi kosten bei etwa {amount} monatlichem Online-Umsatz gleich viel. Darunter zahlt dieser Tenant bei Provision weniger als pauschal; darüber mehr.", "modeLabel": "Modus", - "modeFlat": "Pauschal (19 €/Monat)", - "modeCommission": "Provision", + "modeFlat": "Pauschal ({price}/Monat)", + "modeCommission": "Provision ({floor}/Monat + Satz)", "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.", @@ -1213,13 +1213,13 @@ }, "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.", + "flatSummary": "Pauschal — {price}/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, plus reduzierte {floor}/Monat 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.", + "crossover": "Bei Provision kostet das Modul {floor}/Monat statt {price}, sodass {percent} des Umsatzes die Differenz von {saving} ausgleichen muss — beide Modi kosten bei etwa {amount} monatlichem Online-Umsatz gleich viel. Darunter zahlt Ihr Kunde bei Provision weniger als pauschal; darüber mehr.", "modeLabel": "Preismodell", - "modeFlat": "Pauschal (19 €/Monat)", - "modeCommission": "Provision", + "modeFlat": "Pauschal ({price}/Monat)", + "modeCommission": "Provision ({floor}/Monat + Satz)", "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.", @@ -1519,9 +1519,9 @@ "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.", + "commissionLabel": "{floor}/Monat + {percent} pro Bestellung", + "commissionHint": "Ein kleinerer Festpreis, der Rest nur, wenn ein Gast online bezahlt.", + "crossover": "Unter etwa {amount} Online-Umsatz im Monat kosten {floor}/Monat + {percent} weniger als der Festpreis von {price}; 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 892fece..b71e39f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1003,13 +1003,13 @@ }, "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.", + "flatSummary": "Billed flat — {price}/mo for online payments.", + "commissionSummary": "Billed by commission — {percent} of every online order, plus a reduced {floor}/mo for the module.", "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.", + "crossover": "Under commission the module is {floor}/mo instead of {price}, so {percent} of turnover has to make up the {saving} difference — the two modes cost the same 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", + "modeFlat": "Flat ({price}/mo)", + "modeCommission": "Commission ({floor}/mo + rate)", "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.", @@ -1213,13 +1213,13 @@ }, "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.", + "flatSummary": "Flat — {price}/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, plus a reduced {floor}/mo 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.", + "crossover": "Under commission the module is {floor}/mo instead of {price}, so {percent} of turnover has to make up the {saving} difference — the two modes cost the same 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", + "modeFlat": "Flat ({price}/mo)", + "modeCommission": "Commission ({floor}/mo + rate)", "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.", @@ -1519,9 +1519,9 @@ "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.", + "commissionLabel": "{floor}/month + {percent} per order", + "commissionHint": "A smaller fixed price, and the rest only when a guest pays online.", + "crossover": "Below about {amount} a month of online orders, {floor}/month + {percent} costs less than the flat {price}; 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 cad0ffa..365d9fb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1003,13 +1003,13 @@ }, "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.", + "flatSummary": "Facturé au forfait — {price}/mois pour les paiements en ligne.", + "commissionSummary": "Facturé à la commission — {percent} de chaque commande en ligne, plus {floor}/mois réduits pour le module.", "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.", + "crossover": "En commission, le module est à {floor}/mois au lieu de {price} : le taux de {percent} doit donc compenser l'écart de {saving} — les deux modes coûtent la même chose à 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", + "modeFlat": "Forfait ({price}/mois)", + "modeCommission": "Commission ({floor}/mois + taux)", "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.", @@ -1213,13 +1213,13 @@ }, "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.", + "flatSummary": "Forfait — {price}/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, plus {floor}/mois réduits 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.", + "crossover": "En commission, le module est à {floor}/mois au lieu de {price} : le taux de {percent} doit donc compenser l'écart de {saving} — les deux modes coûtent la même chose à 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", + "modeFlat": "Forfait ({price}/mois)", + "modeCommission": "Commission ({floor}/mois + taux)", "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.", @@ -1519,9 +1519,9 @@ "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.", + "commissionLabel": "{floor}/mois + {percent} par commande", + "commissionHint": "Un prix fixe réduit, et le reste seulement quand un client paie en ligne.", + "crossover": "En dessous d'environ {amount} de commandes en ligne par mois, {floor}/mois + {percent} coûte moins que le forfait de {price} ; au-dessus, le forfait 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 dd7ece2..9af9927 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -1003,13 +1003,13 @@ }, "paymentsMode": { "title": "Betalingsprijsmodus", - "flatSummary": "Vast tarief — €19/maand voor online betalingen.", - "commissionSummary": "Op commissie — {percent} van elke online bestelling, geen maandelijkse modulekosten.", + "flatSummary": "Vast gefactureerd — {price}/maand voor online betalingen.", + "commissionSummary": "Op commissie — {percent} van elke online bestelling, plus een verlaagde {floor}/maand voor de module.", "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.", + "crossover": "Op commissie kost de module {floor}/maand in plaats van {price}, dus {percent} van de omzet moet het verschil van {saving} goedmaken — beide modi kosten evenveel 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", + "modeFlat": "Vast ({price}/maand)", + "modeCommission": "Commissie ({floor}/maand + tarief)", "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.", @@ -1213,13 +1213,13 @@ }, "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.", + "flatSummary": "Vast — {price}/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, plus verlaagde {floor}/maand 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.", + "crossover": "Op commissie kost de module {floor}/maand in plaats van {price}, dus {percent} van de omzet moet het verschil van {saving} goedmaken — beide modi kosten evenveel 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", + "modeFlat": "Vast ({price}/maand)", + "modeCommission": "Commissie ({floor}/maand + tarief)", "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.", @@ -1519,9 +1519,9 @@ "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.", + "commissionLabel": "{floor}/maand + {percent} per bestelling", + "commissionHint": "Een lagere vaste prijs, en de rest alleen als een gast online betaalt.", + "crossover": "Onder ongeveer {amount} aan online omzet per maand kost {floor}/maand + {percent} minder dan de vaste {price}; 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 0a5ecb6..c399763 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -1003,13 +1003,13 @@ }, "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.", + "flatSummary": "Sabit faturalanır — çevrimiçi ödemeler için {price}/ay.", + "commissionSummary": "Komisyonla faturalanır — her çevrimiçi siparişin {percent}'i, artı modül için indirimli {floor}/ay.", "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.", + "crossover": "Komisyonda modül {price} yerine {floor}/ay olur; bu yüzden {percent} oranının {saving} farkı kapatması gerekir — iki mod, aylık yaklaşık {amount} çevrimiçi ciroda aynı maliyete gelir. Bunun altında bu tenant komisyonda sabitten daha az öder; üstünde daha fazla.", "modeLabel": "Mod", - "modeFlat": "Sabit (ayda 19 €)", - "modeCommission": "Komisyon", + "modeFlat": "Sabit ({price}/ay)", + "modeCommission": "Komisyon ({floor}/ay + oran)", "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.", @@ -1213,13 +1213,13 @@ }, "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.", + "flatSummary": "Sabit — çevrimiçi ödemeler için {price}/ay, 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, artı çevrimiçi ödeme modülü için indirimli {floor}/ay.", "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.", + "crossover": "Komisyonda modül {price} yerine {floor}/ay olur; bu yüzden {percent} oranının {saving} farkı kapatması gerekir — iki mod, aylık yaklaşık {amount} çevrimiçi ciroda aynı maliyete gelir. Bunun altında müşteriniz komisyonda sabitten daha az öder; üstünde daha fazla.", "modeLabel": "Fiyatlandırma", - "modeFlat": "Sabit (ayda 19 €)", - "modeCommission": "Komisyon", + "modeFlat": "Sabit ({price}/ay)", + "modeCommission": "Komisyon ({floor}/ay + oran)", "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.", @@ -1519,9 +1519,9 @@ "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.", + "commissionLabel": "{floor}/ay + sipariş başına {percent}", + "commissionHint": "Daha düşük bir sabit ücret, gerisi yalnızca bir misafir çevrimiçi ödeme yaptığında.", + "crossover": "Aylık yaklaşık {amount} altındaki çevrimiçi ciroda {floor}/ay + {percent} sabit {price} ü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/tests/unit/payments-pricing.test.ts b/tests/unit/payments-pricing.test.ts index 96abafb..b4a65d8 100644 --- a/tests/unit/payments-pricing.test.ts +++ b/tests/unit/payments-pricing.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + COMMISSION_FLOOR_CENTS, + COMMISSION_MODE_SAVING_CENTS, DEFAULT_COMMISSION_BPS, MAX_COMMISSION_BPS, asPaymentsMode, @@ -8,6 +10,8 @@ import { isCommissionBps, paymentsModeQuote, } from "@/lib/payments-pricing"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; import { MODULES } from "@/lib/module-catalog"; // The online-payments list price this whole file measures against — read out of @@ -45,17 +49,39 @@ describe("isCommissionBps", () => { }); }); +// The reduced FLOOR the owner decided on 2026-09-05 (workspace BACKLOG): under +// `commission` the module is €9/mo, not €0. Pinned as literals here on purpose — +// re-deriving them from the same constants the code uses would make this suite +// agree with any floor at all, including the €0 it replaced. +describe("the commission floor", () => { + it("prices the online-payments module at €9/mo under commission, not €0", () => { + expect(COMMISSION_FLOOR_CENTS).toBe(900); + }); + + it("saves a tenant exactly €10/mo — the €19 list price less the €9 floor", () => { + expect(COMMISSION_MODE_SAVING_CENTS).toBe(1000); + expect(COMMISSION_MODE_SAVING_CENTS).toBe(ONLINE_PAYMENTS_PRICE_CENTS - COMMISSION_FLOOR_CENTS); + // The floor is a REDUCTION, never a zeroing: the saving must not be the + // whole list price, which is exactly what it was before the floor existed. + expect(COMMISSION_MODE_SAVING_CENTS).not.toBe(ONLINE_PAYMENTS_PRICE_CENTS); + }); +}); + 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", () => { + it("reduces the online-payments line to the €9 floor under commission — it does not remove it", () => { const withModule = 1900 + ONLINE_PAYMENTS_PRICE_CENTS; expect(paymentsModeQuote(withModule, "commission", true)).toBe( - withModule - ONLINE_PAYMENTS_PRICE_CENTS, + withModule - ONLINE_PAYMENTS_PRICE_CENTS + COMMISSION_FLOOR_CENTS, ); + // Stated as an absolute figure too, so a mistake in BOTH the code and the + // arithmetic above cannot cancel out: core €19 + online-payments €19 = €38, + // and commission takes €10 off it, not €19. + expect(paymentsModeQuote(3800, "commission", true)).toBe(2800); }); it("leaves a tenant without the module unaffected by commission mode — nothing to subtract", () => { @@ -67,14 +93,31 @@ describe("paymentsModeQuote", () => { describe("crossoverCentsPerMonth", () => { it("returns null at 0 bps — commission is free forever, not merely a high crossover", () => { + expect(crossoverCentsPerMonth(0)).toBeNull(); 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", () => { + // THE number every switching surface prints, and the one the floor moved. + // Hand-derived: turnover * (bps/10000) = savingCents => turnover = + // savingCents*10000/bps. At the shipped default (150 bps) against the €10 the + // module actually drops by (€19 -> the €9 floor), that is 1000*10000/150 = + // 66666.67, rounded to the nearest cent: about €667/mo of online turnover. + it("computes the shipped figure: 150 bps against the €10 the floor leaves to earn back", () => { + expect(crossoverCentsPerMonth(DEFAULT_COMMISSION_BPS)).toBe(66667); + }); + + // The regression this whole slice exists to prevent. Against the FULL €19 list + // price the same rate reads €1,267 — a confident wrong number, 1.9x too high, + // on the page where a restaurant commits money. The default argument is what + // makes the wrong basis unreachable from a UI caller, so it is asserted + // rather than assumed. + it("defaults to the SAVING, never the full list price — €667, not €1,267", () => { + expect(crossoverCentsPerMonth(DEFAULT_COMMISSION_BPS)).toBe( + crossoverCentsPerMonth(DEFAULT_COMMISSION_BPS, COMMISSION_MODE_SAVING_CENTS), + ); + expect(crossoverCentsPerMonth(DEFAULT_COMMISSION_BPS)).not.toBe( + crossoverCentsPerMonth(DEFAULT_COMMISSION_BPS, ONLINE_PAYMENTS_PRICE_CENTS), + ); expect(crossoverCentsPerMonth(DEFAULT_COMMISSION_BPS, ONLINE_PAYMENTS_PRICE_CENTS)).toBe(126667); }); @@ -119,3 +162,44 @@ describe("asPaymentsMode", () => { expect(asPaymentsMode("garbage")).toBe("flat"); }); }); + +// A source-level guard, not an arithmetic one — and the only thing that can see +// the mistake this slice exists to prevent. `crossoverCentsPerMonth` defaults to +// COMMISSION_MODE_SAVING_CENTS precisely so no UI caller has to name a basis; +// passing ONLINE_PAYMENTS_PRICE_CENTS instead still type-checks, still renders, +// and prints €1,267 where €667 is true. There is no unit-testable value to +// assert on a rendered sentence here (this suite is scoped to pure modules), so +// the invariant is enforced where it lives: in the call sites. +describe("every production caller of crossoverCentsPerMonth", () => { + const CALL = /crossoverCentsPerMonth\(([^)]*)\)/g; + const roots = ["app", "components", "lib"]; + + const sources = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const full = join(dir, e.name); + if (e.isDirectory()) return e.name === "generated" ? [] : sources(full); + return /\.tsx?$/.test(e.name) ? [full] : []; + }); + + const calls = roots + .flatMap(sources) + // Its own declaration is the one legitimate two-parameter site. + .filter((file) => file !== join("lib", "payments-pricing.ts")) + .flatMap((file) => + [...readFileSync(file, "utf8").matchAll(CALL)].map((m) => ({ file, args: m[1].trim() })), + ); + + // POSITIVE CONTROL. An empty match list would make every assertion below pass + // while proving nothing — and it is the likely outcome of a rename, a moved + // directory, or a call split across lines. The four are: the signup + // configurator, the admin/partner panel, its form's live preview, and the + // registry PR body. + it("finds all four call sites — an empty scan would pass vacuously", () => { + expect(calls).toHaveLength(4); + }); + + it("passes the rate only, so none of them can name the wrong basis", () => { + const withABasis = calls.filter((c) => c.args.includes(",")); + expect(withABasis.map((c) => `${c.file}: ${c.args}`)).toEqual([]); + }); +}); diff --git a/tests/unit/registry-commission-pr-body.test.ts b/tests/unit/registry-commission-pr-body.test.ts new file mode 100644 index 0000000..08f8c4a --- /dev/null +++ b/tests/unit/registry-commission-pr-body.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { commissionChangePrBody } from "@/lib/registry-commission-pr-body"; +import { + COMMISSION_FLOOR_CENTS, + COMMISSION_MODE_SAVING_CENTS, + DEFAULT_COMMISSION_BPS, + ONLINE_PAYMENTS_PRICE_CENTS, +} from "@/lib/payments-pricing"; + +// The founder reads this body immediately before merging a live per-transaction +// rate into the deploy registry. It is prose, so nothing else can check it: the +// module that used to hold it needs a GitHub token and the network, which is why +// its crossover sentence stated the OPPOSITE of the truth from the day it was +// written until 2026-09-05. +describe("commissionChangePrBody", () => { + const body = commissionChangePrBody("rumi", 0, DEFAULT_COMMISSION_BPS); + + it("names the tenant and both ends of the rate change", () => { + expect(body).toContain("`rumi`"); + expect(body).toContain("`0` bps → `150` bps"); + }); + + // THE DIRECTION. Below the crossover the turnover is small, so `commission` + // (the €9 floor plus a small cut) costs LESS than the €19 flat module — the + // reading that was inverted here, and that the same sentence in messages/*.json + // has always stated correctly. Asserted as a sentence rather than a keyword + // because the failure mode is a true-looking sentence pointing the wrong way. + it("says commission is the CHEAPER mode below the crossover, not the dearer one", () => { + expect(body).toContain("below that figure `commission` costs this tenant LESS than `flat`"); + expect(body).not.toContain("below that figure `flat` would have cost this tenant less"); + }); + + // The number itself, and the basis it is computed from. 150 bps against the €10 + // the module drops by = €666.67/mo of turnover. Against the full €19 it would + // read 1266.67 — a confident wrong number, 1.9x too high. + it("quotes the crossover computed from the saving, never the full list price", () => { + expect(body).toContain("~`666.67` of monthly online turnover"); + expect(body).not.toContain("1266.67"); + }); + + // Every price in the prose is derived, so a floor or catalog change moves the + // body with it instead of leaving it quoting a number nobody charges. + it("derives every price it prints from the constants", () => { + expect(body).toContain(`€${(COMMISSION_MODE_SAVING_CENTS / 100).toFixed(2)} the module drops by`); + expect(body).toContain(`€${(ONLINE_PAYMENTS_PRICE_CENTS / 100).toFixed(2)} → the €${(COMMISSION_FLOOR_CENTS / 100).toFixed(2)} floor`); + }); + + // 0 bps is not "a very high crossover" — there is no turnover at which the two + // modes meet, and a number here would be a fabricated one. + it("prints no crossover figure at all at 0 bps", () => { + const zero = commissionChangePrBody("rumi", 150, 0); + expect(zero).toContain("none at 0 bps"); + expect(zero).not.toMatch(/of monthly online turnover/); + }); + + // The fact easiest to miss, and the reason the body exists at all. + it("states that merging changes enforcement only, and that a restart is not enough", () => { + expect(body).toContain("### Merging this changes ENFORCEMENT only"); + expect(body).toContain("`docker compose restart` re-reads nothing"); + expect(body).toContain("gh workflow run provision-tenant.yml"); + }); +}); diff --git a/tests/unit/self-serve-signup.test.ts b/tests/unit/self-serve-signup.test.ts index 4adcc05..6cd5d1b 100644 --- a/tests/unit/self-serve-signup.test.ts +++ b/tests/unit/self-serve-signup.test.ts @@ -130,3 +130,66 @@ describe("decideSelfServe — fallbacks the customer cannot fix", () => { }); }); }); + +// THE INVARIANT, not the value. Two code paths compute one price: the sanitizer +// writes `quotedCents` (what the buyer is SHOWN and what the founder's queue +// prints) and `decideSelfServe` computes `amountCents` (what the plan is BILLED — +// the Mollie subscription amount and the figure in the welcome email). They are +// meant to be the same number, and until 2026-09-05 they silently were not: the +// billing side re-quoted the modules and forgot the payments mode, so a buyer who +// chose `commission` was quoted EUR 10 less than they would be charged (EUR 19 less +// before the commission floor landed). +// +// Asserting the two AGREE is what pins that shut. A value assertion would pass just +// as happily with the two paths drifting apart again the next time either side +// learns a new adjustment — which is exactly how this defect arrived. +describe("the quoted price and the billed price cannot disagree", () => { + const shownAndBilled = (raw: Parameters[0]) => { + const config = sanitizeSignupConfiguration(raw); + const out = decideSelfServe(base({ config })); + if (out.kind !== "account") throw new Error(`expected an account, got ${out.kind}`); + return { shown: config.quotedCents, billed: out.amountCents, config }; + }; + + it.each([ + ["commission, with the module", { modules: "core,online-payments", paymentsMode: "commission" }], + ["flat, with the module", { modules: "core,online-payments", paymentsMode: "flat" }], + ["commission, no module (degrades to flat)", { modules: "core,loyalty", paymentsMode: "commission" }], + ["no mode posted at all", { modules: "core,online-payments" }], + // A bundle, because the mode adjustment is applied on top of bundle pricing and + // the two paths must agree there too — not only on a plain sum of list prices. + [ + "commission on top of a bundle", + { modules: "core,kitchen-board,cashier,printing,online-payments", paymentsMode: "commission" }, + ], + ])("agrees for %s", (_case, raw) => { + const { shown, billed } = shownAndBilled(raw); + expect(billed).toBe(shown); + }); + + // The case the defect was actually about, stated once as an absolute so a future + // reader can see the real numbers rather than only the equality: core EUR 19 + + // online-payments EUR 19 = EUR 38, less the EUR 10 the commission floor takes off. + it("bills a commission buyer the EUR 28 they were shown, not the EUR 38 flat total", () => { + const { shown, billed } = shownAndBilled({ + modules: "core,online-payments", + paymentsMode: "commission", + }); + expect(shown).toBe(2800); + expect(billed).toBe(2800); + expect(billed).not.toBe(quoteModules(["core", "online-payments"]).monthlyCents); + }); + + // The control: the pair above proves nothing unless the two modes actually differ. + // If commission and flat ever quoted the same total, every assertion here would + // pass while measuring nothing. + it("the two modes really do cost different amounts, or the tests above are vacuous", () => { + const commission = shownAndBilled({ + modules: "core,online-payments", + paymentsMode: "commission", + }); + const flat = shownAndBilled({ modules: "core,online-payments", paymentsMode: "flat" }); + expect(commission.billed).not.toBe(flat.billed); + expect(flat.billed - commission.billed).toBe(1000); + }); +}); diff --git a/tests/unit/signup-configuration.test.ts b/tests/unit/signup-configuration.test.ts index 2b0ecb4..a2842d9 100644 --- a/tests/unit/signup-configuration.test.ts +++ b/tests/unit/signup-configuration.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; 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"; +import { + COMMISSION_MODE_SAVING_CENTS, + DEFAULT_COMMISSION_BPS, + paymentsModeQuote, +} from "@/lib/payments-pricing"; describe("parseCsv", () => { it("drops blanks and duplicates, keeps order", () => { @@ -143,8 +147,11 @@ describe("sanitizeSignupConfiguration — payments pricing mode", () => { 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); + // The adjusted total, never the flat one: online-payments drops to its €9 + // floor, so the lead is quoted €10 less than flat — not €19 less. + expect(c.quotedCents).toBe(quoteModules(modules).monthlyCents - COMMISSION_MODE_SAVING_CENTS); + // core €19 + online-payments €19 = €38, less the €10 the floor leaves. + expect(c.quotedCents).toBe(2800); }); // A mode with no module is not a state anything downstream can honour. diff --git a/vitest.config.ts b/vitest.config.ts index ec7e37f..e1e7593 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -65,6 +65,12 @@ export default defineConfig({ // billing-vs-enforcement predicate, same shape and same reason as // payments-pending.ts just below it. "lib/registry-commission-edit.ts", + // The PR body that amendment opens with, split out of its GitHub-calling + // sibling on 2026-09-05 so the sentence a founder reads before merging a + // live rate is decidable by a test rather than by re-reading it. Listed + // explicitly for the same reason every entry above is: an omission reads + // as a passing gate rather than as lost coverage. + "lib/registry-commission-pr-body.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.