From 12421b2a922b695efde48f1cb3661241868ad576 Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:39:44 +0200 Subject: [PATCH 1/3] fix(billing): a country code must name a country, not merely be two letters (#201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `billingIdentitySchema` pinned `countryCode` to `/^[A-Z]{2}$/`, and the live control plane held the proof that a shape is not a country: the one reseller identity on it was stored as `SW`, which ISO assigns to nothing (Switzerland is CH, Sweden is SE), and every surface read it as a country for nine days. It matters because that field decides the whole tax treatment. An unassigned code is not "in the EU" by any test, so `determineTaxTreatment` fell through to OUTSIDE_SCOPE and answered 0% with a confident reason naming a country that does not exist. For the Swiss buyer it was stored on, the verdict was right and the evidence was wrong — a correct answer hiding wrong data. Mistype an EU country into an unassigned code and the same path issues an IMMUTABLE 0% invoice to a customer who owed 21% or a reverse charge, with no gate anywhere saying so. `lib/country-code.ts` holds the 249 officially assigned ISO 3166-1 alpha-2 codes and two predicates: a forgiving one for input (normalises case) and a canonical one for stored rows, which `isInvoiceable` uses — a lowercase value in the database means the row did not come through the schema that uppercases. `EL` stays out of the country list and stays accepted by the tax rule: it is Greece's VAT prefix rather than a country code, and callers have always been allowed to spell Greece either way there. Putting it in the list would have made it a country; special-casing it in the readability check keeps the contract exactly where it was. The live row was corrected to CH before this landed, since the new rule would otherwise block the next invoice on an ACTIVE subscription. The two invoices already issued keep `SW` in their immutable buyer snapshots — a correction is a credit note (ADR-013), and their OUTSIDE_SCOPE verdict and amounts are right. --- lib/billing-identity.ts | 18 ++++- lib/country-code.ts | 95 ++++++++++++++++++++++++ lib/tax-treatment.ts | 21 +++++- lib/vat-number.ts | 7 +- tests/unit/billing-identity.test.ts | 25 +++++++ tests/unit/country-code.test.ts | 111 ++++++++++++++++++++++++++++ tests/unit/tax-treatment.test.ts | 28 +++++++ vitest.config.ts | 4 + 8 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 lib/country-code.ts create mode 100644 tests/unit/country-code.test.ts diff --git a/lib/billing-identity.ts b/lib/billing-identity.ts index 6806caa..f58a231 100644 --- a/lib/billing-identity.ts +++ b/lib/billing-identity.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import type { BuyerVatStatus } from "@/lib/tax-treatment"; +import { isAssignedCountryCode, isCanonicalCountryCode } from "@/lib/country-code"; /** * What a form must supply to record an identity. @@ -17,8 +18,12 @@ import type { BuyerVatStatus } from "@/lib/tax-treatment"; * later. Same file, same list, one place to change. * * `countryCode` is the load-bearing field — it decides the entire tax treatment - * (lib/tax-treatment.ts) — so it is pinned to the ISO-3166-1 alpha-2 shape rather - * than left as free text. `vatNumber` is optional and NOT format-checked: a Swiss + * (lib/tax-treatment.ts) — so it is checked for MEMBERSHIP of ISO 3166-1 alpha-2, + * not merely for its two-letter shape. The shape check is what let `SW` (assigned + * to nothing; Switzerland is `CH`) sit on the live reseller identity and be read + * as a country by every surface — see lib/country-code.ts for why an unassigned + * code is more dangerous than an empty one. `vatNumber` is optional and NOT + * format-checked: a Swiss * or British customer has a real national number that is simply not an EU VAT id, * and refusing to record it would lose true data. Its EU-shape check happens * where it matters — before a VIES call, and before a reverse charge. @@ -36,7 +41,7 @@ export const billingIdentitySchema = z.object({ .string() .trim() .toUpperCase() - .regex(/^[A-Z]{2}$/, "2-letter ISO country code, e.g. FR"), + .refine(isAssignedCountryCode, "2-letter ISO 3166-1 country code, e.g. FR (CH for Switzerland)"), billingEmail: z.string().trim().max(200).email(), vatNumber: z.string().trim().max(30).optional().or(z.literal("")), }); @@ -69,7 +74,12 @@ export function isInvoiceable(identity: IdentityFacts | null | undefined): boole identity.city, identity.billingEmail, ]; - return required.every((f) => f.trim().length > 0) && /^[A-Z]{2}$/.test(identity.countryCode); + // The country must be a COUNTRY, not just two letters: it is the only field + // here that decides money, and an invoice addressed to a code no register + // knows is one nobody can defend at an audit. The CANONICAL test, not the + // forgiving one — this reads a stored row, and a lowercase value there means + // the row did not come through the schema that uppercases it. + return required.every((f) => f.trim().length > 0) && isCanonicalCountryCode(identity.countryCode); } /** diff --git a/lib/country-code.ts b/lib/country-code.ts new file mode 100644 index 0000000..039b048 --- /dev/null +++ b/lib/country-code.ts @@ -0,0 +1,95 @@ +// Is this two-letter code actually a COUNTRY? (SOFRA-BILLING-IDENTITY-PLAN B1/B3.) +// +// The billing schema pinned `countryCode` to `/^[A-Z]{2}$/` — a SHAPE check, and +// a shape is not a country. The live control plane carried the proof: the one +// reseller identity on it was stored as `SW`, which is not assigned to anything +// (Switzerland is `CH`, Sweden is `SE`), and every surface treated it as a +// perfectly good country for nine days. +// +// It mattered because `countryCode` decides the entire tax treatment. An +// unassigned code is not "in the EU" by any test, so `determineTaxTreatment` +// fell straight through to OUTSIDE_SCOPE and answered **0% with a confident +// reason** — "buyer established outside the EU (SW)". For a Swiss buyer that +// verdict happened to be right, which is the dangerous part: a correct answer +// was hiding wrong data. Mistype an EU country into something unassigned — `SW` +// for `SE`, `UK`... — and the same path issues an immutable 0% invoice to a +// customer who owed 21% or a reverse charge, with no gate anywhere saying so. +// +// So membership is checked, not shape, and an unrecognised code stops rather +// than being interpreted. It is the same rule the module already applies to the +// seller: only NL is modelled, and anything else refuses to guess. +// +// SCOPE, deliberately: ISO 3166-1 alpha-2 ASSIGNED codes only, from the officially +// assigned list. NOT included, each for a reason: +// * `EL` — Greece's VAT prefix, not its country code (`GR` is). A VAT prefix is +// not a country and this field is a country; `lib/vat-number.ts` owns prefixes. +// * `XI` — Northern Ireland's VAT prefix under the Windsor Framework. A business +// there is established in `GB`; the distinction belongs to the VAT number. +// * `UK` — the common mistake for `GB`, and exactly the kind of thing that must +// be refused rather than accepted as a synonym. +// * User-assigned ranges (`AA`, `QM`–`QZ`, `XA`–`XZ`, `ZZ`) — private use, and +// accepting them would defeat the point of the list. +// +// This module says nothing about VAT territories: `ES` covers the Canaries, `FR` +// covers the DOM, and both are outside the EU VAT area. `tax-treatment.ts` +// already states that limitation; a country list cannot fix it. + +/** ISO 3166-1 alpha-2, officially assigned. Source: ISO 3166-1 (2024). */ +const ASSIGNED = new Set([ + "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", + "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", + "BT", "BV", "BW", "BY", "BZ", + "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", + "CX", "CY", "CZ", + "DE", "DJ", "DK", "DM", "DO", "DZ", + "EC", "EE", "EG", "EH", "ER", "ES", "ET", + "FI", "FJ", "FK", "FM", "FO", "FR", + "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", + "GU", "GW", "GY", + "HK", "HM", "HN", "HR", "HT", "HU", + "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", + "JE", "JM", "JO", "JP", + "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", + "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", + "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", + "MT", "MU", "MV", "MW", "MX", "MY", "MZ", + "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", + "OM", + "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", + "QA", + "RE", "RO", "RS", "RU", "RW", + "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", + "ST", "SV", "SX", "SY", "SZ", + "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", + "UA", "UG", "UM", "US", "UY", "UZ", + "VA", "VC", "VE", "VG", "VI", "VN", "VU", + "WF", "WS", + "YE", "YT", + "ZA", "ZM", "ZW", +]); + +/** Uppercased and trimmed, so a form's `ch ` and a snapshot's `CH` compare equal. */ +export function normalizeCountryCode(code: string | null | undefined): string { + return (code ?? "").trim().toUpperCase(); +} + +/** True only for a code ISO 3166-1 actually assigns to a country or territory. */ +export function isAssignedCountryCode(code: string | null | undefined): boolean { + return ASSIGNED.has(normalizeCountryCode(code)); +} + +/** + * Assigned AND already in canonical form — uppercase, no padding. + * + * The write schema uppercases, so a stored value that is not canonical did not + * come through it. `isInvoiceable` uses this rather than the forgiving test: it + * judges a row that already exists, and "this row went around the schema" is + * worth stopping on, not worth normalising away at read time. + */ +export function isCanonicalCountryCode(code: string | null | undefined): boolean { + return typeof code === "string" && code === normalizeCountryCode(code) && ASSIGNED.has(code); +} + +/** Count of assigned codes — pins the list against an accidental deletion in a + * future edit, which is otherwise a silent narrowing of who may be invoiced. */ +export const ASSIGNED_COUNTRY_COUNT = ASSIGNED.size; diff --git a/lib/tax-treatment.ts b/lib/tax-treatment.ts index e3c0488..a0cfbea 100644 --- a/lib/tax-treatment.ts +++ b/lib/tax-treatment.ts @@ -23,6 +23,7 @@ // `nlVat` — rather than a guess or a permanent stall. See it below. import { isEuVatPrefix } from "@/lib/vat-number"; +import { isAssignedCountryCode } from "@/lib/country-code"; import { OUTSIDE_SCOPE_NOTE, REVERSE_CHARGE_NOTE } from "@/lib/tax-notes"; import { euUnverifiedTreatment } from "@/lib/eu-no-vat"; @@ -115,8 +116,24 @@ export function determineTaxTreatment(input: TaxTreatmentInput): TaxTreatmentRes if (seller !== "NL") { return needsReview(`seller country ${seller || "(unset)"} is not modelled — only NL is`); } - if (!/^[A-Z]{2}$/.test(buyer)) { - return needsReview("buyer country is missing or not a 2-letter ISO code"); + // MEMBERSHIP, not shape. A two-letter code that ISO assigns to nothing is not + // "outside the EU" — it is unreadable, and the difference is the whole point: + // the shape test let `SW` (a live row, meant as Switzerland) fall past every + // EU branch below and be answered OUTSIDE_SCOPE at 0% with a confident reason. + // For a Swiss buyer that verdict is right and the evidence is wrong; mistype an + // EU country into an unassigned code and the same path issues an immutable 0% + // invoice to a customer who owed 21% or a reverse charge. Stopping is the only + // safe reading of a country we cannot identify. + // `EL` is the exception and stays one: it is Greece's VAT prefix rather than an + // ISO country code, and callers have always been allowed to spell Greece either + // way here (see the EU test below). Accepting it in the readability check keeps + // that contract; adding it to the country LIST would have made it a country. + if (!isAssignedCountryCode(buyer) && buyer !== "EL") { + return needsReview( + buyer + ? `buyer country ${buyer} is not an assigned ISO 3166-1 code` + : "buyer country is missing", + ); } if (buyer === "NL") { diff --git a/lib/vat-number.ts b/lib/vat-number.ts index 9293868..5593450 100644 --- a/lib/vat-number.ts +++ b/lib/vat-number.ts @@ -17,8 +17,11 @@ // Deliberately NOT here: a checksum for every country. Two reasons, and both are // failure modes rather than laziness — see `checksumOk` below. -/** EU member states, by VAT prefix. Greece trades as `EL`, not `GR`. */ -const EU_VAT_PREFIXES = [ +/** EU member states, by VAT prefix. Greece trades as `EL`, not `GR`. + * Exported so the country-code list can be checked AGAINST it: the two are keyed + * differently (prefix vs ISO country), and an EU state present here but missing + * there would silently turn a priceable customer into a NEEDS_REVIEW. */ +export const EU_VAT_PREFIXES = [ "AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "EL", "ES", "FI", "FR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK", diff --git a/tests/unit/billing-identity.test.ts b/tests/unit/billing-identity.test.ts index 214cf65..128a2ce 100644 --- a/tests/unit/billing-identity.test.ts +++ b/tests/unit/billing-identity.test.ts @@ -58,6 +58,15 @@ describe("isInvoiceable", () => { expect(isInvoiceable(facts({ countryCode })), countryCode).toBe(false); } }); + + it("refuses two letters that name no country, however well-formed", () => { + // The stored value that prompted this: `SW` for Switzerland. It passed the + // shape test, so the row was invoiceable and the invoice printed a country + // code that no register knows. + for (const countryCode of ["SW", "UK", "XX", "EL"]) { + expect(isInvoiceable(facts({ countryCode })), countryCode).toBe(false); + } + }); }); describe("nextVatStatus — an outage must never erase a proven VALID", () => { @@ -209,6 +218,22 @@ describe("billingIdentitySchema", () => { } }); + it("rejects two letters that are not a country — the live SW defect", () => { + // `SW` sat on the one reseller identity in production for nine days: it has + // the right shape, it is assigned to nothing, and it decides the tax + // treatment. The old `/^[A-Z]{2}$/` accepted it. + for (const countryCode of ["SW", "UK", "XX", "QQ", "EL"]) { + expect(billingIdentitySchema.safeParse({ ...valid, countryCode }).success, countryCode).toBe( + false, + ); + } + }); + + it("accepts the country that one was meant to be", () => { + const parsed = billingIdentitySchema.safeParse({ ...valid, countryCode: "ch" }); + expect(parsed.success && parsed.data.countryCode).toBe("CH"); + }); + it("does NOT format-check the VAT number", () => { // A Swiss or British registration is real and simply not an EU VAT id; // refusing to record it would lose data. The EU-shape check happens where it diff --git a/tests/unit/country-code.test.ts b/tests/unit/country-code.test.ts new file mode 100644 index 0000000..2dfc57f --- /dev/null +++ b/tests/unit/country-code.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + ASSIGNED_COUNTRY_COUNT, + isAssignedCountryCode, + isCanonicalCountryCode, + normalizeCountryCode, +} from "@/lib/country-code"; +import { EU_VAT_PREFIXES } from "@/lib/vat-number"; + +// The list itself is data; what these cases pin is the JUDGEMENT around it — +// which near-misses must be refused, and which codes the rest of the system +// already depends on being present. + +describe("isAssignedCountryCode", () => { + it("accepts the country the live defect was meant to be", () => { + expect(isAssignedCountryCode("CH")).toBe(true); + }); + + it("refuses SW — the code that was actually stored, assigned to nothing", () => { + // Nine days on a live billing identity, read as a country by every surface. + expect(isAssignedCountryCode("SW")).toBe(false); + }); + + it("refuses UK, the common mistake for GB, rather than treating it as a synonym", () => { + expect(isAssignedCountryCode("UK")).toBe(false); + expect(isAssignedCountryCode("GB")).toBe(true); + }); + + it("refuses EL and XI — VAT prefixes, not countries", () => { + // Greece is GR here; EL belongs to lib/vat-number.ts. Northern Ireland's XI + // describes a VAT registration, and the business is established in GB. + expect(isAssignedCountryCode("EL")).toBe(false); + expect(isAssignedCountryCode("XI")).toBe(false); + expect(isAssignedCountryCode("GR")).toBe(true); + }); + + it("refuses the user-assigned private ranges", () => { + for (const code of ["AA", "QM", "QZ", "XA", "XZ", "ZZ"]) { + expect(isAssignedCountryCode(code), code).toBe(false); + } + }); + + it("refuses anything that is not two letters at all", () => { + for (const bad of ["", " ", "C", "CHE", "C1", "12", "N/A", "---"]) { + expect(isAssignedCountryCode(bad), JSON.stringify(bad)).toBe(false); + } + expect(isAssignedCountryCode(null)).toBe(false); + expect(isAssignedCountryCode(undefined)).toBe(false); + }); + + it("normalises case and whitespace before deciding", () => { + expect(isAssignedCountryCode(" ch ")).toBe(true); + expect(isAssignedCountryCode("nL")).toBe(true); + }); + + it("holds every EU member state the VAT module knows, by COUNTRY code", () => { + // The two lists are keyed differently on purpose (EL vs GR), and this is the + // seam where that difference has to be handled rather than assumed. If an EU + // country were missing here, tax-treatment would answer NEEDS_REVIEW for a + // customer it can price perfectly. + for (const prefix of EU_VAT_PREFIXES) { + const country = prefix === "EL" ? "GR" : prefix; + expect(isAssignedCountryCode(country), country).toBe(true); + } + }); + + it("holds the seller's own country, without which nothing invoices at all", () => { + expect(isAssignedCountryCode("NL")).toBe(true); + }); + + it("carries the full ISO 3166-1 alpha-2 assigned list", () => { + // A count, not a spot check: the failure this guards is a future edit + // deleting a line, which silently narrows who may be invoiced and shows up + // as one customer being unable to save their address. + expect(ASSIGNED_COUNTRY_COUNT).toBe(249); + }); +}); + +describe("normalizeCountryCode", () => { + it("uppercases and trims", () => { + expect(normalizeCountryCode(" ch ")).toBe("CH"); + }); + + it("maps absent input to the empty string rather than throwing", () => { + expect(normalizeCountryCode(null)).toBe(""); + expect(normalizeCountryCode(undefined)).toBe(""); + }); +}); + +describe("isCanonicalCountryCode", () => { + it("accepts an already-canonical code", () => { + expect(isCanonicalCountryCode("CH")).toBe(true); + }); + + it("refuses a lowercase or padded code, unlike the forgiving test", () => { + // A stored row in this shape did not come through the write schema, which + // uppercases. That is worth stopping on rather than normalising at read time. + expect(isAssignedCountryCode("ch")).toBe(true); + expect(isCanonicalCountryCode("ch")).toBe(false); + expect(isCanonicalCountryCode(" CH")).toBe(false); + }); + + it("still refuses an unassigned code however it is written", () => { + expect(isCanonicalCountryCode("SW")).toBe(false); + }); + + it("refuses absent input", () => { + expect(isCanonicalCountryCode(null)).toBe(false); + expect(isCanonicalCountryCode(undefined)).toBe(false); + }); +}); diff --git a/tests/unit/tax-treatment.test.ts b/tests/unit/tax-treatment.test.ts index f326531..d2c41ed 100644 --- a/tests/unit/tax-treatment.test.ts +++ b/tests/unit/tax-treatment.test.ts @@ -165,6 +165,34 @@ describe("determineTaxTreatment — country handling", () => { } }); + it("stops on a well-formed code that is not a country, instead of zero-rating it", () => { + // The defect this rule was written for. `SW` is not assigned to anything, so + // every EU test below it is false and the old code answered OUTSIDE_SCOPE at + // 0% — with a confident reason naming a country that does not exist. For the + // Swiss buyer it was stored on, the verdict was right and the evidence was + // wrong; for a mistyped EU country it would be an immutable under-charge. + for (const buyerCountry of ["SW", "UK", "XX", "QQ"]) { + const result = determineTaxTreatment(sale({ buyerCountry })); + expect(result.treatment, buyerCountry).toBe("NEEDS_REVIEW"); + expect(result.rateBps, buyerCountry).toBeNull(); + expect(result.reason, buyerCountry).toContain("not an assigned ISO 3166-1 code"); + } + }); + + it("keeps Switzerland outside the scope once it is spelled CH", () => { + // The same customer, correctly recorded: still 0%, but now on a country the + // reason can name and an auditor can check. + const result = determineTaxTreatment(sale({ buyerCountry: "CH" })); + expect(result.treatment).toBe("OUTSIDE_SCOPE"); + expect(result.rateBps).toBe(0); + }); + + it("says a missing country is missing, rather than calling it unassigned", () => { + expect(determineTaxTreatment(sale({ buyerCountry: "" })).reason).toBe( + "buyer country is missing", + ); + }); + it("stops if the seller is not NL — the whole matrix is Dutch-establishment law", () => { const result = determineTaxTreatment(sale({ sellerCountry: "BE" })); expect(result.treatment).toBe("NEEDS_REVIEW"); diff --git a/vitest.config.ts b/vitest.config.ts index 2b1215e..8e3079f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -72,6 +72,10 @@ export default defineConfig({ // halves only: `lib/vies.ts` owns the fetch and stays out of scope, which // is exactly why the judgement it depends on was split into // `vies-result.ts` — the part that is easy to get wrong is measurable here. + // B1/B3 — whether a country code names a country. In scope because the + // branch it adds is the one that decides between "0%, outside the EU" and + // "stop, we cannot read this", on an invoice that is immutable once issued. + "lib/country-code.ts", "lib/vat-number.ts", "lib/vies-result.ts", "lib/vies-retry.ts", From f03e772099aab76275e838c9093223cc155a3c41 Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:16:58 +0200 Subject: [PATCH 2/3] fix(email): keep recipient addresses out of the logs, and rate-limit the contact intake (G15, G17) (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(email): tag the recipient in logs instead of printing it (G15) Both of sendEmail's refusal paths wrote `to=
` to the container log, so every send made without a key or without a verified sender put a customer's address where CLAUDE.md §5.8 says none may go. Resend's own error body quotes the recipient back at us as well (the sandbox-sender 403 names it outright), and that was logged verbatim. `lib/log-recipient.ts` replaces both with a salted digest tag, stable within a process so two lines about one recipient still read as one recipient. `LOG_HASH_SALT` pins it across containers when an operator wants that; unset, a per-process random salt makes the tags non-reversible, which is the safer default for the common case of reading one container's log. It is pseudonymisation, not anonymisation, and the module says so. The file-length checker's PII heuristic could not have caught this: it looks for an email-SHAPED literal on a console line, and an interpolated variable has no shape until runtime. It now also flags a console line that interpolates a value NAMED like a person, with the redaction helpers stripped first so the fixed code does not warn about itself. Verified against the whole tree (clean) and against a deliberately leaky file (caught). * fix(api): rate-limit the contact intake, which is unauthenticated and sends mail (G17) `/api/waitlist` never called `guardIntake`, so the one public endpoint that mails the founder on every accepted call had no limit at all — a way to burn the Resend quota that the invite and reset mails share, and to flood the only inbox that reads it. The honeypot stops a naive bot, not a loop. It now runs the same guard the partner-apply and signup intakes use (5 POSTs per IP per 15 min). Its own `company` honeypot is kept beside the shared `company_website` one: this form has sent that field since the waitlist days and every deployed client still does. The e2e spec found a trap worth keeping: `page.request` does NOT inherit the fixture's per-test `x-forwarded-for`, so all three tests landed in the single "unknown" bucket and the last two were refused by the first test's own calls. `apiClientHeaders()` makes the identity explicit for API calls, and the fixture now documents the limit of the browser-header approach. * fix(email): make the redaction scan linear, and split the checker's regex Three Sonar findings on the first push, all worth taking rather than accepting. S8786 was the real one. `redactAddresses` ran an address-SHAPED regex over text a third party controls — a provider's error body — and any such pattern backtracks quadratically on a long run with no `@`: the leading class eats the run, then gives back one character at a time, from every offset. Measured at ~1.9s for a 100 KB token, and nothing bounds the size of a body we did not write. The scan is now over whitespace-separated tokens (`\S+` cannot backtrack, nothing follows it) with the address judgement in code: exactly one `@`, content on both sides, a dotted TLD. Single-digit ms on the same input, pinned by a timing test that states both numbers. Two smaller ones: the digest is its own statement rather than a nested template literal (S4624), and the checker's PII rule is two small patterns — an interpolation, and a name inside it — rather than one 23-complexity regex (S5843). The split also fixed a real hole: it now tests EVERY interpolation on the line, so `${slug}` followed by `${user.email}` is caught, which the single combined pattern would have decided on the first match alone. * fix(email): peel punctuation with an index walk, not an anchored regex Sonar's second pass found the same class of defect one layer down. `[…]+$` is super-linear for exactly the reason the pattern it replaced was: unanchored at the start, it retries from every offset, so a token that is 50 KB of quotes and commas is quadratic — and it is reached from a provider's error body, which we neither write nor bound. Two character sets and two while-loops instead, which need no argument about backtracking, plus a timing test on the punctuation shape specifically. The duplicate-`@` guard also moves to `lastIndexOf`, which says what it means (S7765). * test(email): use a fixture address in the Resend 403 sample The sample body quoted the owner real mailbox. The sentence shape is what is under test, so a fixture address proves exactly the same thing without putting a real address in the repo. --- .github/workflows/ci.yml | 19 ++-- app/api/waitlist/route.ts | 20 +++-- lib/email.ts | 14 ++- lib/log-recipient.ts | 115 ++++++++++++++++++++++++ scripts/check-single-file.mjs | 34 ++++++- tests/e2e/contact-intake.spec.ts | 66 ++++++++++++++ tests/e2e/helpers/fixtures.ts | 16 ++++ tests/unit/log-recipient.test.ts | 148 +++++++++++++++++++++++++++++++ vitest.config.ts | 5 ++ 9 files changed, 419 insertions(+), 18 deletions(-) create mode 100644 lib/log-recipient.ts create mode 100644 tests/e2e/contact-intake.spec.ts create mode 100644 tests/unit/log-recipient.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 553b08e..e48fbf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -368,14 +368,19 @@ jobs: # SEPARATE runners in parallel, so the floor is paid concurrently, not # serially, and the arithmetic is 128 + ~42 + an aggregate job, not # 2 x 213. Playwright splits at file granularity for these specs (verified - # with `playwright test --shard=i/2 --list`). Re-listed 2026-08-21, when the - # backup-alert spec joined: shard 1 = admin-backups + admin-tenants + - # backup-alert-cron + billing-mollie (skips without a Mollie key) + - # control-auth + health + login-timing + owner-dashboard + - # owner-payments-pending + partner-base-domain = 32 tests, shard 2 = - # partner-client-domain + partner-tenant-panel + partner-trial + - # self-serve-signup + trial-warning-cron = 32 tests. The seconds above are + # with `playwright test --shard=i/2 --list`). Re-listed 2026-08-22, when the + # contact-intake spec joined (and the invite-resend / partner-tenant-dns / + # backup-agent-box-binding specs that had landed since the previous listing + # were found missing from it): shard 1 = admin-backups + admin-tenants + + # backup-agent-box-binding + backup-alert-cron + billing-mollie (skips + # without a Mollie key) + contact-intake + control-auth + health + + # invite-resend + login-timing + owner-dashboard + owner-payments-pending + + # partner-base-domain = 41 tests, shard 2 = partner-client-domain + + # partner-tenant-dns + partner-tenant-panel + partner-trial + + # self-serve-signup + trial-warning-cron = 34 tests. The seconds above are # the 2026-08-17 run's, not this split's — the balance is what was re-checked. + # The count imbalance is smaller than it reads: shard 2 carries + # self-serve-signup, which is 15 of its 34 and the slowest file in the suite. # Do NOT raise the shard count without re-measuring: at 3 shards the # residual per-shard test time (~27s) is a fifth of the floor, so the win # collapses while the compute cost keeps rising. diff --git a/app/api/waitlist/route.ts b/app/api/waitlist/route.ts index 151c9f9..61f3294 100644 --- a/app/api/waitlist/route.ts +++ b/app/api/waitlist/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { sendEmail, founderInbox } from "@/lib/email"; import { craftEmail, detailRows } from "@/lib/email-templates"; +import { guardIntake } from "@/lib/intake"; /** * Contact intake (demo / call / quote — the waitlist was retired 2026-07-06: @@ -14,6 +15,13 @@ import { craftEmail, detailRows } from "@/lib/email-templates"; * WAITLIST_FROM — sender on a Resend-VERIFIED domain. REQUIRED: there is no * sandbox fallback, because that sender reaches only the Resend * account owner and 403s everyone else. Unset = nothing sends. + * + * RATE LIMITED (G17), through the same `guardIntake` the partner-apply and signup + * intakes use — 5 POSTs per IP per 15 minutes. It is unauthenticated and it SENDS + * MAIL on every accepted call, which makes an unlimited version two things at once: + * a way to burn the Resend quota (and with it the invite and reset mails that share + * it) and a way to flood the only inbox that reads these. The honeypot below stops + * a naive bot, not a loop. */ const INTENTS: Record = { demo: { subject: "Demo request", kicker: "New request", title: "Someone wants a demo" }, @@ -22,12 +30,12 @@ const INTENTS: Record; - try { - body = await request.json(); - } catch { - return NextResponse.json({ ok: false }, { status: 400 }); - } + // Rate limit → JSON parse → the shared `company_website` honeypot. Its own + // `company` honeypot is checked below and kept: this form has shipped with that + // field name since the waitlist days, and every deployed client still sends it. + const guarded = await guardIntake(request, "contact"); + if ("response" in guarded) return guarded.response; + const body = guarded.body; const name = String(body.name ?? "").slice(0, 200).trim(); const email = String(body.email ?? "").slice(0, 200).trim(); diff --git a/lib/email.ts b/lib/email.ts index 9f216cb..79e41ae 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -3,6 +3,8 @@ // certainty (e.g. invite links) must surface the link in the UI as well. const RESEND_URL = "https://api.resend.com/emails"; +import { recipientTag, redactAddresses } from "@/lib/log-recipient"; + export const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); @@ -15,7 +17,10 @@ export async function sendEmail(opts: { const apiKey = process.env.RESEND_API_KEY; const from = process.env.WAITLIST_FROM; if (!apiKey) { - console.warn(`email (not sent, no RESEND_API_KEY): to=${opts.to} subject=${opts.subject}`); + // The recipient is TAGGED, never printed: this line fires on every send in + // any environment without a key, so a plain `to=` here is the whole + // customer list in the log (CLAUDE.md §5.8). See lib/log-recipient.ts. + console.warn(`email (not sent, no RESEND_API_KEY): to=${recipientTag(opts.to)} subject=${opts.subject}`); return { sent: false }; } // No sandbox fallback. `onboarding@resend.dev` used to be the default here, and it does @@ -31,7 +36,7 @@ export async function sendEmail(opts: { // back false — so the failure stays visible without taking the money path down with it. if (!from) { console.error( - `email (not sent, no WAITLIST_FROM): to=${opts.to} subject=${opts.subject} — ` + + `email (not sent, no WAITLIST_FROM): to=${recipientTag(opts.to)} subject=${opts.subject} — ` + `set WAITLIST_FROM to a sender on a Resend-VERIFIED domain, e.g. "Sofra "`, ); return { sent: false }; @@ -55,7 +60,10 @@ export async function sendEmail(opts: { }), }); if (!res.ok) { - console.error("email: resend failed", res.status, await res.text()); + // Redacted, not verbatim: Resend quotes the recipient back in its own error + // text (the sandbox-sender 403 names the address outright), so a raw body + // reintroduces from the provider's side exactly what the tag removes above. + console.error("email: resend failed", res.status, redactAddresses(await res.text())); return { sent: false }; } return { sent: true }; diff --git a/lib/log-recipient.ts b/lib/log-recipient.ts new file mode 100644 index 0000000..278c91e --- /dev/null +++ b/lib/log-recipient.ts @@ -0,0 +1,115 @@ +// Keeping recipient addresses OUT of the logs (CLAUDE.md §5.8, EMAIL-SPEC-CONTROL-PLANE G15). +// +// `lib/email.ts` used to write `to=${opts.to}` on both of its refusal paths, so +// every send made without a key or without a verified sender put a customer's +// address into the container log — the one place this repo says PII may not go. +// The file-length checker's PII heuristic did not catch it because that rule +// looks for an email-SHAPED literal on a `console.*` line, and an interpolated +// variable has no shape until runtime. +// +// What replaces the address is a TAG: a short digest that is stable within a +// process, so two failures for the same recipient are still recognisably the +// same recipient, and useless as an address. +// +// The salt is why this is not just "hash the email and call it anonymous". An +// email address is a low-entropy, guessable value: an unsalted digest of one is +// reversible by anyone holding a list of addresses, which is what a leaked log +// would be handed to. `LOG_HASH_SALT` pins the tag across restarts and +// containers when an operator wants that; unset, a per-process random salt is +// generated, and the tags are then correlatable only within one container's +// lifetime — deliberately the safer default, because the common case (reading +// one container's log after a failed send) needs nothing more. +// +// It is pseudonymisation, not anonymisation, and the doc comment says so on +// purpose: with the salt in hand the tag is reversible again. The claim is +// narrow and true — an address is no longer sitting in the log. + +import { createHash, randomBytes } from "node:crypto"; + +/** Emitted instead of a tag when there is no address at all. A digest here would + * assert a recipient that never existed. */ +export const NO_RECIPIENT = "(none)"; + +/** + * The scan is over WHITESPACE-SEPARATED TOKENS, and the address test is written + * in code rather than in the pattern. That is the whole design of this half. + * + * A regex that describes an address — even `[^\s@]+@[^\s@]+` — backtracks + * quadratically on a long run containing no `@`, because the leading class + * consumes the whole run and then gives back one character at a time, from every + * starting offset. This regex is pointed at text a THIRD PARTY controls (a + * provider's error body), so that is not an academic property: measured at ~1.9s + * for a 100 KB token, and provider bodies have no size limit we set. + * + * `\S+` cannot backtrack (nothing follows it), so the scan is linear and the + * judgement happens on a bounded token. + */ +const TOKEN = /\S+/g; +/** Wrapping punctuation a provider is likely to quote an address inside. Peeled + * with an index walk rather than an anchored regex: `[…]+$` is itself + * super-linear on a long run of punctuation (it retries from every offset), and + * the whole point of the token scan above is that a hostile body cannot make + * this expensive. Two character sets and two while-loops are provably linear and + * need no argument. */ +const LEADING_PUNCTUATION = `<("'[`; +const TRAILING_PUNCTUATION = `>)"'],;:.`; +/** A dotted TLD is what separates an address from `@mention` noise. */ +const LOOKS_LIKE_ADDRESS = /\.[a-z]{2,}$/i; + +/** `[start, end)` of `token` with wrapping punctuation removed. */ +function unwrapped(token: string): { start: number; end: number } { + let start = 0; + let end = token.length; + while (start < end && LEADING_PUNCTUATION.includes(token[start])) start += 1; + while (end > start && TRAILING_PUNCTUATION.includes(token[end - 1])) end -= 1; + return { start, end }; +} + +let processSalt: string | null = null; + +function salt(): string { + const configured = process.env.LOG_HASH_SALT; + if (configured) return configured; + // Lazily generated, once, and never logged. Not a secret worth managing — its + // only job is to make the digests in one log file non-reversible. + processSalt ??= randomBytes(16).toString("hex"); + return processSalt; +} + +/** + * A stable, non-address stand-in for one recipient, e.g. `#3f1a9c22b0`. + * + * Normalised (trimmed + lower-cased) so `Owner@Example.com` and + * `owner@example.com` tag identically — an operator comparing two log lines is + * asking about the person, not about the capitalisation. + */ +export function recipientTag(address: string | null | undefined): string { + const normalized = (address ?? "").trim().toLowerCase(); + if (!normalized) return NO_RECIPIENT; + // Not a nested template literal, on purpose (Sonar S4624): the digest is its + // own step, which is also where anyone reading this looks first. + const digest = createHash("sha256").update(`${salt()}:${normalized}`).digest("hex"); + return `#${digest.slice(0, 10)}`; +} + +/** + * The same substitution, applied to text we did not write. + * + * Resend's error bodies quote the recipient back at us — the sandbox-sender 403 + * is literally *"You can only send testing emails to your own email address + * (...)"* — so logging a provider response verbatim reintroduces exactly what + * `recipientTag` removes, from a direction nobody thinks to check. + */ +export function redactAddresses(text: string): string { + return text.replace(TOKEN, (token) => { + const { start, end } = unwrapped(token); + const core = token.slice(start, end); + // Exactly one `@`, with something on each side, and a dotted TLD. Anything + // else is left alone: turning `@here` — or a token carrying two `@` — into a + // digest would make the line harder to read and buy no privacy at all. + const at = core.indexOf("@"); + if (at <= 0 || at === core.length - 1 || core.lastIndexOf("@") !== at) return token; + if (!LOOKS_LIKE_ADDRESS.test(core)) return token; + return `${token.slice(0, start)}${recipientTag(core)}${token.slice(end)}`; + }); +} diff --git a/scripts/check-single-file.mjs b/scripts/check-single-file.mjs index 3dc6657..659c551 100644 --- a/scripts/check-single-file.mjs +++ b/scripts/check-single-file.mjs @@ -24,6 +24,23 @@ const BASELINE = join(ROOT, "scripts", "file-length-baseline.txt"); // inner `)` in the console args doesn't defeat the match. const CONSOLE_CALL = /console\.(log|error|warn|info)\(/; const PII_SHAPE = /@[\w.-]+\.\w{2,}|\+?\d[\d\s().-]{7,}\d/; +// The shape rule alone cannot see the common case, and missed the real one: a +// warn line that interpolated `opts.to` printed every customer's address for +// months (EMAIL-SPEC-CONTROL-PLANE G15), because an INTERPOLATED value has no +// shape until runtime. So also flag a console line that interpolates something +// NAMED like a person: `email`, `mail`, `phone`, `recipient`, or a bare `to`. +// Names, not types — which is why this stays a warning: such a value may well be +// a slug or a boolean. A false warning costs a glance; the miss it replaces cost +// a live PII leak that every gate reported as clean. +// Two small patterns rather than one clever one: an interpolation, and a name +// inside it. Sonar flagged the combined version for complexity, and splitting it +// is the better code anyway — each half is readable on its own line. +const INTERPOLATION = /\$\{[^}]*\}/g; +const PII_NAME = /\b(e?mail|phone|recipient|to)\b/i; +// …except where the value is ALREADY passed through the redaction helpers. They +// are stripped rather than allow-listed per line, so a line that tags one value +// and prints another raw is still caught — the mixed case is the likely one. +const PII_REDACTED_CALL = /\b(recipientTag|redactAddresses)\s*\([^)]*\)/g; function limitFor(rel) { // rel is repo-relative (no leading slash), e.g. "lib/billing.ts", @@ -65,9 +82,22 @@ function checkFile(abs, { blocking } = {}) { process.stderr.write(`${rel}: file-length: ${kind} ~${loc} LOC (limit ${lim}) — extract per CLAUDE.md §4\n`); if (blocking) violated = true; } - if (src.split("\n").some((line) => CONSOLE_CALL.test(line) && PII_SHAPE.test(line))) { + if ( + src + .split("\n") + .map((line) => line.replace(PII_REDACTED_CALL, "TAGGED")) + .some((line) => { + if (!CONSOLE_CALL.test(line)) return false; + if (PII_SHAPE.test(line)) return true; + // EVERY interpolation on the line, not the first: `${slug}` followed by + // `${user.email}` is the shape a careful-looking line actually has. + return [...line.matchAll(INTERPOLATION)].some((m) => PII_NAME.test(m[0])); + }) + ) { // Always a warning, never fails CI (heuristic — may be a false positive). - process.stderr.write(`${rel}: pii-in-log: console.* appears to log an email/phone — log ids, not PII (CLAUDE.md §5)\n`); + process.stderr.write( + `${rel}: pii-in-log: console.* appears to log an email/phone/recipient — log an id or a tag (lib/log-recipient.ts), not PII (CLAUDE.md §5)\n`, + ); } return violated; } diff --git a/tests/e2e/contact-intake.spec.ts b/tests/e2e/contact-intake.spec.ts new file mode 100644 index 0000000..1124029 --- /dev/null +++ b/tests/e2e/contact-intake.spec.ts @@ -0,0 +1,66 @@ +import { apiClientHeaders, expect, test } from "./helpers/fixtures"; + +// The public contact intake (`/api/waitlist`, kept at that path since the waitlist +// was retired) — G17: it is unauthenticated and it SENDS MAIL, so it must be +// rate-limited like the other two intakes. +// +// Why an e2e and not a unit test: the limit lives in `guardIntake`, which is +// already unit-tested through `lib/rate-limit.ts`. What was missing was not the +// rule but the WIRING — this route simply never called it — and only a real +// request through the real handler can show that it does now. +// +// Every test takes ONE apparent client address (`apiClientHeaders`) and reuses it +// for all of its own calls, which is what makes counting to the limit possible +// without throttling the rest of the suite. It is passed explicitly because +// `page.request` does not inherit the fixture's browser headers — see the note on +// `apiClientHeaders`, which exists because this spec found that out the hard way. +// +// The send itself comes back `{sent:false}` in this suite (`RESEND_API_KEY` is +// blank), so an accepted POST answers 502 or 503 rather than 200. That is +// deliberate here: what is asserted is "not refused as rate-limited", never a +// successful delivery, so the spec cannot silently start passing for the wrong +// reason. + +const CONTACT_URL = "/api/waitlist"; + +const body = (n: number) => ({ + intent: "demo", + name: `Contact ${n}`, + restaurant: `Restaurant ${n}`, + email: `contact${n}@example.com`, + city: "Geneva", + locale: "en", +}); + +test("the sixth contact POST from one client is refused", async ({ page }) => { + const headers = apiClientHeaders(); + // Five is the shared intake allowance (guardIntake: 5 per IP per 15 min). + for (let i = 1; i <= 5; i += 1) { + const res = await page.request.post(CONTACT_URL, { headers, data: body(i) }); + expect(res.status(), `POST ${i} must not be rate-limited`).not.toBe(429); + } + + const sixth = await page.request.post(CONTACT_URL, { headers, data: body(6) }); + expect(sixth.status()).toBe(429); +}); + +test("a honeypot-filled contact POST is still dropped silently", async ({ page }) => { + // The route's own `company` honeypot predates the shared `company_website` one + // and every deployed marketing client still sends it, so routing through + // guardIntake must not have retired it. A bot must read success, not a 400 that + // tells it which field gave it away. + const res = await page.request.post(CONTACT_URL, { + headers: apiClientHeaders(), + data: { ...body(1), company: "definitely-a-bot" }, + }); + expect(res.status()).toBe(200); + expect(await res.json()).toEqual({ ok: true }); +}); + +test("a malformed contact POST is a 400, not a 500", async ({ page }) => { + const res = await page.request.post(CONTACT_URL, { + headers: { ...apiClientHeaders(), "content-type": "application/json" }, + data: "not json at all", + }); + expect(res.status()).toBe(400); +}); diff --git a/tests/e2e/helpers/fixtures.ts b/tests/e2e/helpers/fixtures.ts index c2581bc..b8c663c 100644 --- a/tests/e2e/helpers/fixtures.ts +++ b/tests/e2e/helpers/fixtures.ts @@ -41,6 +41,22 @@ function nextClientIp(parallelIndex: number): string { return `198.18.${parallelIndex % 256}.${(seq % 254) + 1}`; } +/** + * The same per-test identity, for `page.request` calls. + * + * `page.setExtraHTTPHeaders` below covers requests the BROWSER makes; it does + * NOT reach `page.request`, Playwright's API client. Measured, not assumed: the + * contact-intake spec's three tests all landed in ONE rate-limit bucket + * (`clientIp()` falls back to the literal "unknown" with no `x-forwarded-for`), + * so the second and third tests were refused 429 by the first test's own calls. + * + * Call it ONCE per test and reuse the result across that test's requests — the + * point is a bucket per test, not per request. + */ +export function apiClientHeaders(): Record { + return { "x-forwarded-for": nextClientIp(base.info().parallelIndex) }; +} + // The second fixture argument is positional, so it is NOT named `use`: eslint's // react-hooks/rules-of-hooks reads a call to `use(...)` inside a function called // `page` as a misplaced React hook and errors. Renaming is cheaper than an diff --git a/tests/unit/log-recipient.test.ts b/tests/unit/log-recipient.test.ts new file mode 100644 index 0000000..6089dbe --- /dev/null +++ b/tests/unit/log-recipient.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NO_RECIPIENT, recipientTag, redactAddresses } from "@/lib/log-recipient"; + +// Pure module (node:crypto only). The env key is saved/restored per case, as in +// email.test.ts / tenant-registry.test.ts — never reassign process.env wholesale. + +const savedSalt = process.env.LOG_HASH_SALT; + +describe("recipientTag", () => { + beforeEach(() => { + process.env.LOG_HASH_SALT = "test-salt"; + }); + afterEach(() => { + if (savedSalt === undefined) delete process.env.LOG_HASH_SALT; + else process.env.LOG_HASH_SALT = savedSalt; + }); + + it("never contains the address, the local part, or the domain", () => { + const tag = recipientTag("owner@kebabhouse.ch"); + expect(tag).not.toContain("owner"); + expect(tag).not.toContain("kebabhouse"); + expect(tag).not.toContain("@"); + }); + + it("is stable for the same address, so two log lines are recognisably one person", () => { + expect(recipientTag("owner@kebabhouse.ch")).toBe(recipientTag("owner@kebabhouse.ch")); + }); + + it("normalises case and surrounding whitespace", () => { + expect(recipientTag(" Owner@KebabHouse.CH ")).toBe(recipientTag("owner@kebabhouse.ch")); + }); + + it("distinguishes two addresses at the same domain", () => { + expect(recipientTag("a@example.com")).not.toBe(recipientTag("b@example.com")); + }); + + it("depends on the salt — the same address tags differently under another one", () => { + const withTest = recipientTag("owner@kebabhouse.ch"); + process.env.LOG_HASH_SALT = "another-salt"; + expect(recipientTag("owner@kebabhouse.ch")).not.toBe(withTest); + }); + + it("says (none) for an absent address rather than digesting the empty string", () => { + // A tag here would assert a recipient that never existed, which is the one + // thing worse than a missing one when reading back a failed send. + expect(recipientTag("")).toBe(NO_RECIPIENT); + expect(recipientTag(" ")).toBe(NO_RECIPIENT); + expect(recipientTag(null)).toBe(NO_RECIPIENT); + expect(recipientTag(undefined)).toBe(NO_RECIPIENT); + }); + + it("still tags a value that is not a well-formed address", () => { + // sendEmail is not a validator; whatever it was handed is what must not be logged. + expect(recipientTag("not-an-address")).not.toBe(NO_RECIPIENT); + expect(recipientTag("not-an-address")).not.toContain("not-an-address"); + }); +}); + +describe("redactAddresses (provider text we did not write)", () => { + beforeEach(() => { + process.env.LOG_HASH_SALT = "test-salt"; + }); + afterEach(() => { + if (savedSalt === undefined) delete process.env.LOG_HASH_SALT; + else process.env.LOG_HASH_SALT = savedSalt; + }); + + it("removes the address from Resend's real sandbox-sender 403", () => { + const body = JSON.stringify({ + statusCode: 403, + // Resend's real sandbox-sender sentence, with the address it quotes back + // replaced by a fixture one: the shape is what is under test, and a real + // mailbox does not belong in a test file (CLAUDE.md §5.8). + message: + "You can only send testing emails to your own email address (owner@example.test). " + + "To send emails to other recipients, please verify a domain.", + }); + const out = redactAddresses(body); + expect(out).not.toContain("owner@example.test"); + expect(out).toContain(recipientTag("owner@example.test")); + // The diagnosis survives — the whole point of logging the body at all. + expect(out).toContain("please verify a domain"); + expect(out).toContain("403"); + }); + + it("redacts every address, not only the first", () => { + const out = redactAddresses("to a@example.com and b@example.org failed"); + expect(out).not.toContain("a@example.com"); + expect(out).not.toContain("b@example.org"); + expect(out).toContain(recipientTag("a@example.com")); + expect(out).toContain(recipientTag("b@example.org")); + }); + + it("gives the same address the same tag as recipientTag does", () => { + // Otherwise the refusal line and the provider line describe one recipient + // with two different tags, which is worse than not tagging at all. + expect(redactAddresses("owner@kebabhouse.ch")).toBe(recipientTag("owner@kebabhouse.ch")); + }); + + it("leaves text with no address untouched", () => { + expect(redactAddresses("rate limit exceeded")).toBe("rate limit exceeded"); + expect(redactAddresses("")).toBe(""); + }); + + it("does not eat the punctuation an address is quoted inside", () => { + const out = redactAddresses('address "owner@kebabhouse.ch", rejected'); + expect(out).toContain('"'); + expect(out).toContain(", rejected"); + expect(out).not.toContain("kebabhouse"); + }); +}); + +describe("redactAddresses — the regex is pointed at text we do not control", () => { + it("does not backtrack catastrophically on a long adversarial run", () => { + // The pattern this replaced was super-linear (Sonar S8786) and ran on a + // provider's response body. A pathological input must stay fast, so this is + // an assertion about TIME, which is the only way that property is visible. + // The measured numbers, for whoever changes this next: an address-shaped + // regex took ~1.9s on these two inputs; the token scan takes single-digit ms. + const hostile = `${"a".repeat(50_000)}!`; + const started = Date.now(); + redactAddresses(hostile); + redactAddresses(`${hostile}@${hostile}`); + expect(Date.now() - started).toBeLessThan(250); + }); + +it("stays fast on a token that is nothing but punctuation", async () => { + // The anchored `[…]+$` this replaced retried from every offset, so a long run + // of quotes and commas was quadratic — reachable from a provider body, which + // is text we do not write and do not bound. + const punctuation = '"'.repeat(50_000); + const started = Date.now(); + redactAddresses(punctuation); + redactAddresses(`${punctuation}owner@example.com${punctuation}`); + expect(Date.now() - started).toBeLessThan(250); + }); + + it("leaves an @mention alone — it is not an address and a digest would only hurt", () => { + expect(redactAddresses("cc @here about the 403")).toBe("cc @here about the 403"); + }); + + it("redacts an address wrapped in angle brackets, keeping the brackets", () => { + const out = redactAddresses("From: Sofra "); + expect(out).toContain("<"); + expect(out).toContain(">"); + expect(out).not.toContain("send.sofrapiwas.com"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8e3079f..7862516 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -85,6 +85,11 @@ export default defineConfig({ "lib/icp.ts", "lib/plan-deletion.ts", "lib/tax-notes.ts", + // G15 — what a log line may say about a recipient. In scope because the + // failure it prevents is silent by construction: a leak here is only ever + // discovered by reading months of container logs, and the module is pure + // apart from one env read. + "lib/log-recipient.ts", // G16 — the delivery-verdict rule. Its query wrapper (`email-delivery.ts`) stays out, same // split as vies/vies-result above. "lib/email-delivery-verdicts.ts", From eb35068299ed46a77d0f1194f98a0fdb5534cf0f Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:25:33 +0200 Subject: [PATCH 3/3] feat(email): write to a customer in their own language (G9), and stop calling everyone a partner (G10) (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(email): write to a customer in their own language, from the account (G9, G10) Five customer-facing mails were hardcoded English in a product that ships six languages and sells in Geneva: the invite, the partner approval, the invite re-send, the password reset and both invoice mails. Only the trial warning was localized (sofra #167), and it got there by looking the payer's address up in `PartnerApplication` at send time — a join by lower-cased email that silently downgrades a francophone partner to English the moment an admin typed the address in a different case. The missing piece was never the pattern; it was that the control plane held a locale for every INTAKE and none for a PERSON. `User.locale` is that column. Seeded from the intake that created the account, backfilled from the same intakes for the rows that exist, and refreshed when they set their password — the one moment a customer is certainly present with a language chosen. The backfill matters today: the one live reseller applied in FRENCH, so without it his account would have defaulted to English and he would have received one mail in French and every other one in English. G10 rides along, because translating a wrong persona would have baked it into six languages: the reset mail no longer calls every recipient a "partner". A restaurant owner locked out of their own dashboard was being told about a partner password they have never had, which is what a phishing mail reads like. The founder's own notices stay English on purpose — one reader, and an operational mail that is grepped rather than read. * refactor(email): resolve copy before the template, not inside it Sonar S4624 on the reset mail, and the same shape in the invite: a translator call nested inside a template literal. Hoisting the two lines of copy above the HTML removes the nesting and reads better — the body becomes two placeholders rather than two expressions. * refactor(email): hoist the last two nested translator calls Same S4624 shape in the partner-approval and billing-details templates. Sonar flagged one of the four; leaving the other three would mean the next reader copies whichever they happen to open. --- app/api/signup/route.ts | 13 ++- lib/actions/admin-actions.ts | 24 +++-- lib/actions/auth-actions.ts | 50 +++++++---- lib/actions/onboarding-actions.ts | 10 ++- lib/invoice-blocked.ts | 4 + lib/invoice-email.ts | 53 ++++++----- lib/invoicing.ts | 13 ++- lib/payer-contact.ts | 27 +++++- lib/reset-email.ts | 55 ++++++++++++ lib/self-serve-account.ts | 5 ++ lib/self-serve-email.ts | 58 ++++++++---- lib/trial-warning-candidates.ts | 7 +- lib/trial-warning-notify.ts | 31 +------ lib/trial-warning-send.ts | 14 ++- messages/ar.json | 65 ++++++++++++++ messages/de.json | 65 ++++++++++++++ messages/en.json | 65 ++++++++++++++ messages/fr.json | 65 ++++++++++++++ messages/nl.json | 65 ++++++++++++++ messages/tr.json | 79 ++++++++++++++-- .../20260822000000_user_locale/migration.sql | 68 ++++++++++++++ prisma/schema.prisma | 70 ++++++++------- tests/e2e/helpers/db.ts | 14 ++- tests/e2e/helpers/flows.ts | 23 ++++- tests/e2e/self-serve-signup.spec.ts | 22 +++++ tests/unit/email-locale.test.ts | 89 +++++++++++++++++++ tests/unit/payer-contact.test.ts | 42 ++++++++- 27 files changed, 945 insertions(+), 151 deletions(-) create mode 100644 lib/reset-email.ts create mode 100644 prisma/migrations/20260822000000_user_locale/migration.sql diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts index c666970..c557d07 100644 --- a/app/api/signup/route.ts +++ b/app/api/signup/route.ts @@ -45,7 +45,7 @@ const FOUNDER_FALLBACK_NOTES: Record = { */ async function mintAccount( outcome: Extract, - who: { email: string; contactName: string; restaurantName: string }, + who: { email: string; contactName: string; restaurantName: string; locale: string }, signupRequestId: string, ): Promise<{ account: boolean; emailed: boolean; founderOutcome: string }> { let minted; @@ -83,6 +83,10 @@ async function mintAccount( slug: outcome.slug, amountCents: outcome.amountCents, inviteToken: minted.inviteToken, + // The language the visitor filled the form in (G9) — the same value stored on + // the lead and now on the account itself, so the welcome mail and everything + // that follows it speak one language. + locale: who.locale, }).catch(() => ({ sent: false })); // Durable, because the founder notice that carries this news is itself an @@ -209,7 +213,12 @@ export async function POST(request: Request) { outcome.kind === "account" ? await mintAccount( outcome, - { email, contactName: data.contactName, restaurantName: data.restaurantName }, + { + email, + contactName: data.contactName, + restaurantName: data.restaurantName, + locale: data.locale, + }, signup.id, ) : { account: false, emailed: false, founderOutcome: FOUNDER_FALLBACK_NOTES[outcome.reason] }; diff --git a/lib/actions/admin-actions.ts b/lib/actions/admin-actions.ts index 7a7e2d7..f28fc72 100644 --- a/lib/actions/admin-actions.ts +++ b/lib/actions/admin-actions.ts @@ -7,6 +7,7 @@ import { audit } from "@/lib/audit"; import { sendEmail, escapeHtml, siteUrl } from "@/lib/email"; import { craftEmail } from "@/lib/email-templates"; import { createToken } from "@/lib/tokens"; +import { emailTranslator } from "@/lib/email-locale"; import { commissionSchema } from "@/lib/validation"; /** `error` is a message key in the `control.errors` namespace, translated at @@ -39,6 +40,9 @@ export async function approveApplicationAction( name: application.name, role: "PARTNER", status: "INVITED", + // The language they applied in (G9). The application row is a lead and gets + // left behind; the account is what every later mail is addressed to. + locale: application.locale, profile: { create: { company: application.company, city: application.city }, }, @@ -56,16 +60,22 @@ export async function approveApplicationAction( // without recording it, an approval that mailed nobody looks exactly like one that worked. The // link is returned to this very screen, so the founder can still hand it over; they just have to // be told they need to. + // In the language they APPLIED in (G9): the application row holds it, and this + // is the first thing we ever send them. + const t = await emailTranslator(user.locale, "emails.partnerApproved"); + // Hoisted out of the HTML for the same reason as the other templates: no + // translator call nested inside a template literal (Sonar S4624). + const greeting = t("greeting", { name: escapeHtml(user.name) }); const invite = await sendEmail({ to: user.email, - subject: "Welcome to the SofraPiwas partner program", + subject: t("subject"), html: craftEmail({ - kicker: "Partner program", - title: "Welcome aboard 🎉", - bodyHtml: `

Hi ${escapeHtml(user.name)},

-

Your SofraPiwas partner application is approved. Set your password to open your partner dashboard — afiyet olsun.

`, - cta: { label: "Set your password", url: inviteLink }, - footerNote: "The link works once and expires in 24 hours.", + kicker: t("kicker"), + title: t("title"), + bodyHtml: `

${greeting}

+

${t("lead")}

`, + cta: { label: t("cta"), url: inviteLink }, + footerNote: t("footerNote"), }), // `sendEmail` swallows a non-2xx into {sent:false}, but `fetch` itself REJECTS on a DNS or // connect failure — and letting that escape would throw AFTER the account and the token exist, diff --git a/lib/actions/auth-actions.ts b/lib/actions/auth-actions.ts index 4897112..c7f24f2 100644 --- a/lib/actions/auth-actions.ts +++ b/lib/actions/auth-actions.ts @@ -6,10 +6,11 @@ import { redirect } from "next/navigation"; import { signIn, signOut } from "@/lib/auth"; import { db } from "@/lib/db"; import { audit } from "@/lib/audit"; -import { sendEmail, escapeHtml, siteUrl } from "@/lib/email"; -import { craftEmail } from "@/lib/email-templates"; +import { siteUrl } from "@/lib/email"; import { createToken, findValidToken } from "@/lib/tokens"; import { resendPlan } from "@/lib/invite-resend"; +import { controlLocale } from "@/lib/control-locale"; +import { sendPasswordResetEmail } from "@/lib/reset-email"; import { sendInviteEmail } from "@/lib/self-serve-email"; import { formEmail, limited, type FormState } from "@/lib/auth-form"; @@ -58,12 +59,24 @@ export async function setPasswordAction(_prev: FormState, formData: FormData): P } const passwordHash = await hash(password, 12); + // The one moment a customer is CERTAINLY present with a language chosen (G9): + // they are on our page, having followed a link from a mail, with the site's own + // locale cookie set. The account's stored locale came from an intake row that + // may be months old — or, for a founder-created account, from nothing at all — + // so this is where it stops being a guess. Written inside the same transaction + // as the password: a locale that lands only when a separate write succeeds is a + // locale that silently disagrees with what the person just read. + const locale = await controlLocale(); await db.$transaction([ db.user.update({ where: { id: token.userId }, // Only the INVITED→ACTIVE transition; an already-ACTIVE user resetting // their password keeps their current status. - data: { passwordHash, ...(token.user.status === "INVITED" ? { status: "ACTIVE" as const } : {}) }, + data: { + passwordHash, + locale, + ...(token.user.status === "INVITED" ? { status: "ACTIVE" as const } : {}), + }, }), db.inviteToken.update({ where: { id: token.id }, data: { usedAt: new Date() } }), ]); @@ -88,22 +101,21 @@ export async function forgotPasswordAction(_prev: FormState, formData: FormData) // success to everyone, on purpose, so it cannot be used to probe which addresses exist — which is // exactly why the failure has to land somewhere a human will see it. A locked-out owner who is // told "check your email" for a mail that never left has no next move at all. - const reset = await sendEmail({ + // The template lives in lib/reset-email.ts — in the reader's own language (G9) + // and no longer calling every recipient a PARTNER (G10). + // + // Caught for the anti-enumeration property this form exists to have: `fetch` + // REJECTS on a DNS/connect failure, so an unreachable transport would throw for + // an address that HAS an account while an address that does not still answers + // the generic success above — a live "is this registered" oracle, during exactly + // the incident nobody is watching. It also saves the `emailed: false` row, which + // is the failure most worth recording. + const reset = await sendPasswordResetEmail({ to: user.email, - subject: "SofraPiwas — reset your password", - html: craftEmail({ - kicker: "Partner area", - title: "Reset your password", - bodyHtml: `

Hi ${escapeHtml(user.name)},

-

Someone (hopefully you) asked to reset your SofraPiwas partner password. If this wasn't you, you can safely ignore this email.

`, - cta: { label: "Set a new password", url: link }, - footerNote: "The link works once and expires in 24 hours.", - }), - // Caught for the anti-enumeration property this form exists to have: `fetch` REJECTS on a - // DNS/connect failure, so an unreachable transport would throw for an address that HAS an - // account while an address that does not still answers the generic success above — a live - // "is this registered" oracle, during exactly the incident nobody is watching. It also saves - // the `emailed: false` row, which is the failure most worth recording. + name: user.name, + role: user.role, + locale: user.locale, + url: link, }).catch(() => ({ sent: false })); await audit(user.id, "password.reset.requested", "User", user.id, { emailed: reset.sent }); return generic; @@ -162,7 +174,7 @@ export async function resendInviteAction(_prev: FormState, formData: FormData): name: user.name, restaurantName: user.billingsPaid[0]?.tenantSlug ?? "Your restaurant", inviteToken: token, - kicker: "Welcome to SofraPiwas", + locale: user.locale, // Caught for the same reason `forgotPasswordAction` catches: `fetch` REJECTS on // a DNS/connect failure, so an unreachable transport would throw for an address // that HAS an account while an unknown address still got the generic success — diff --git a/lib/actions/onboarding-actions.ts b/lib/actions/onboarding-actions.ts index 627a710..3bc3cf4 100644 --- a/lib/actions/onboarding-actions.ts +++ b/lib/actions/onboarding-actions.ts @@ -45,10 +45,9 @@ export type OnboardActionState = { * returned regardless of whether the send succeeded, because this flow's whole * point is that the founder can hand it over manually. */ async function emailOnboardInvite( - user: { id: string; name: string; status: string }, + user: { id: string; name: string; status: string; locale: string }, email: string, restaurantName: string, - ownerFlow: boolean, actorId: string, ): Promise { const needsPassword = user.status === "INVITED"; @@ -58,7 +57,10 @@ async function emailOnboardInvite( name: user.name, restaurantName, inviteToken, - kicker: ownerFlow ? "Welcome to SofraPiwas" : "Partner program", + // The account's own language (G9). A founder-created account has no intake to + // inherit one from, so it is `en` until they set their password — which is + // exactly when we learn it. + locale: user.locale, }); // This flow does not LIE when the send fails — the link comes back and the page @@ -148,7 +150,7 @@ export async function onboardPartnerAction( actorId: admin.id, }); - const inviteLink = await emailOnboardInvite(user, email, input.restaurantName, ownerFlow, admin.id); + const inviteLink = await emailOnboardInvite(user, email, input.restaurantName, admin.id); await audit(admin.id, ownerFlow ? "owner.onboarded" : "partner.onboarded", "User", user.id, { tenantSlug, clientId, diff --git a/lib/invoice-blocked.ts b/lib/invoice-blocked.ts index 990577d..9acb05a 100644 --- a/lib/invoice-blocked.ts +++ b/lib/invoice-blocked.ts @@ -19,6 +19,9 @@ export async function recordBlockedInvoice(opts: { reason: IssueBlocker | "taxNeedsReview"; tenantSlug: string; payerEmail: string; + /** The language to ask in (G9). The caller resolves it the same way it resolves + * the address, so the request and the invoice that follows it are one voice. */ + payerLocale: string; grossCents: number; }): Promise { let notified = false; @@ -51,6 +54,7 @@ export async function recordBlockedInvoice(opts: { to: opts.payerEmail, tenantSlug: opts.tenantSlug, grossCents: opts.grossCents, + locale: opts.payerLocale, }).catch(() => ({ sent: false })) ).sent; } diff --git a/lib/invoice-email.ts b/lib/invoice-email.ts index cf4115e..2cb6276 100644 --- a/lib/invoice-email.ts +++ b/lib/invoice-email.ts @@ -13,8 +13,9 @@ // Resend plan upgrade plus a verified sofrapiwas.com sending domain — and when it // lands, these start working with no code change. -import { sendEmail, siteUrl } from "@/lib/email"; +import { sendEmail, escapeHtml, siteUrl } from "@/lib/email"; import { craftEmail, detailRows } from "@/lib/email-templates"; +import { emailTranslator } from "@/lib/email-locale"; import { eur } from "@/lib/format"; /** @@ -30,24 +31,32 @@ export async function sendInvoiceIssued(opts: { number: string; tenantSlug: string; grossCents: number; + /** The language the payer is written to in (`User.locale`). */ + locale: string; /** The sentence from lib/tax-treatment.ts, when there is one. Printed verbatim * so the email and the invoice cannot disagree about the VAT treatment. */ taxNote: string | null; }): Promise<{ sent: boolean }> { + const t = await emailTranslator(opts.locale, "emails.invoice"); return sendEmail({ to: opts.to, - subject: `Invoice ${opts.number} — SofraPiwas`, + subject: t("subject", { number: opts.number }), html: craftEmail({ - kicker: "Billing", - title: `Invoice ${opts.number}`, + kicker: t("kicker"), + title: t("title", { number: opts.number }), bodyHtml: detailRows([ - ["Invoice", opts.number], - ["Service", opts.tenantSlug], - ["Total", eur(opts.grossCents)], + [t("rowInvoice"), opts.number], + [t("rowService"), opts.tenantSlug], + [t("rowTotal"), eur(opts.grossCents)], ]) + - (opts.taxNote ? `

${opts.taxNote}

` : ""), - cta: { label: "View your invoice", url: `${siteUrl()}/invoices/${opts.invoiceId}` }, + // The tax note stays in the language the INVOICE carries it in, which is + // the language it was stored in at issue time (lib/tax-notes.ts). It is a + // legal sentence that substantiates a reverse charge, the document shows + // exactly this string, and translating one of the two copies would make + // the mail and the invoice disagree about the VAT treatment. + (opts.taxNote ? `

${escapeHtml(opts.taxNote)}

` : ""), + cta: { label: t("cta"), url: `${siteUrl()}/invoices/${opts.invoiceId}` }, }), }); } @@ -68,22 +77,26 @@ export async function sendBillingDetailsNeeded(opts: { to: string; tenantSlug: string; grossCents: number; + /** The language the payer is written to in (`User.locale`). */ + locale: string; }): Promise<{ sent: boolean }> { + const t = await emailTranslator(opts.locale, "emails.billingDetails"); + // Hoisted for the same reason as the other templates (Sonar S4624). + const received = t("received", { + amount: eur(opts.grossCents), + restaurant: escapeHtml(opts.tenantSlug), + }); return sendEmail({ to: opts.to, - subject: "We need your billing details to issue your invoice — SofraPiwas", + subject: t("subject"), html: craftEmail({ - kicker: "Billing", - title: "Your invoice is waiting on a few details", + kicker: t("kicker"), + title: t("title"), bodyHtml: - `

We received your payment of ${eur(opts.grossCents)} for ` + - `${opts.tenantSlug}. Thank you.

` + - `

To send you a proper invoice we need the company it should ` + - `be addressed to — the legal name, address and country. It takes a minute, and the ` + - `invoice is issued as soon as you have saved them.

` + - `

If your company has an EU VAT number, add it too and we will ` + - `reverse-charge the VAT instead of charging it.

`, - cta: { label: "Add your billing details", url: `${siteUrl()}/dashboard/billing/details` }, + `

${received}

` + + `

${t("ask")}

` + + `

${t("vat")}

`, + cta: { label: t("cta"), url: `${siteUrl()}/dashboard/billing/details` }, }), }); } diff --git a/lib/invoicing.ts b/lib/invoicing.ts index 0a973c5..097733c 100644 --- a/lib/invoicing.ts +++ b/lib/invoicing.ts @@ -17,6 +17,8 @@ import { euNoVatFallback, sellerIdentity } from "@/lib/seller-identity"; import { buyerSnapshot, sellerSnapshot } from "@/lib/invoice-snapshot"; import { resolveIdentityForPlan } from "@/lib/identity-upsert"; import { sendInvoiceIssued } from "@/lib/invoice-email"; +import { emailLocale } from "@/lib/email-locale"; +import { payerLocale } from "@/lib/payer-contact"; import { recordBlockedInvoice } from "@/lib/invoice-blocked"; import type { Prisma } from "@/lib/generated/prisma/client"; @@ -78,7 +80,14 @@ export async function issueInvoiceForPayment(molliePaymentId: string): Promise ({ sent: false })) ).sent; diff --git a/lib/payer-contact.ts b/lib/payer-contact.ts index 9969705..5b4aa12 100644 --- a/lib/payer-contact.ts +++ b/lib/payer-contact.ts @@ -8,8 +8,10 @@ // wholesale price into the partner's own client relationship — the exact thing // white-label resale sells against. -/** Whatever a `User` selection carries. `name` optional so callers may omit it. */ -type Named = { name?: string | null; email: string }; +/** Whatever a `User` selection carries. `name` and `locale` optional so callers + * may omit them — a mail that only needs an address should not have to select + * three columns to type-check. */ +type Named = { name?: string | null; email: string; locale?: string | null }; export type PayerContact = { /** `TenantBilling.email` — free text an admin typed; the last resort. */ @@ -49,3 +51,24 @@ export function payerAddress(billing: PayerContact): string | null { export function payerGreetingName(billing: PayerContact): string { return billing.client?.partner?.name || billing.payer?.name || billing.name; } + +/** Just enough of a plan to answer "in what language?" — deliberately narrower + * than `PayerContact`, so a caller that needs only the language selects only the + * language instead of pulling an address it does not use. */ +export type PayerLocaleSource = { + client?: { partner?: { locale?: string | null } | null } | null; + payer?: { locale?: string | null } | null; +}; + +/** + * The language to write in — resolved in the SAME order as the address (G9). + * + * That order is the point: on a reseller plan the person who reads the bill is the + * PARTNER, so their language is the one the bill is written in, not the + * restaurant's. Returns null when the plan carries no user at all (an admin-typed + * `TenantBilling.email` and nothing else), which `emailLocale` turns into English — + * a `BillingIdentity` records a country, and a country is not a language. + */ +export function payerLocale(billing: PayerLocaleSource): string | null { + return billing.client?.partner?.locale ?? billing.payer?.locale ?? null; +} diff --git a/lib/reset-email.ts b/lib/reset-email.ts new file mode 100644 index 0000000..ddef3ed --- /dev/null +++ b/lib/reset-email.ts @@ -0,0 +1,55 @@ +// "Reset your password" — the mail (M4). +// +// Its own module for the reason `trial-warning-email.ts` and `self-serve-email.ts` +// are: the action should read as the flow it is (rate limit → resolve → mint token +// → send → audit), not as a template interleaved with one. +// +// Two things about it are load-bearing and were both wrong until G9/G10. +// +// It is written in the RECIPIENT's language, from `User.locale` — a person locked +// out of their account is the last person who should have to read instructions in a +// language they did not choose. +// +// And it no longer calls everyone a PARTNER. The old copy said "your SofraPiwas +// partner password", written when partners were the only accounts there were; a +// restaurant owner locked out of their own dashboard was reading about a +// relationship they have never had, which is exactly what a phishing mail looks +// like. The kicker and the sentence follow `User.role`. + +import { sendEmail, escapeHtml } from "@/lib/email"; +import { craftEmail } from "@/lib/email-templates"; +import { emailTranslator } from "@/lib/email-locale"; + +/** The persona the copy addresses. Not the role enum: ADMIN and OWNER read the + * same neutral sentence, and only a reseller is told about a partner account. */ +type Persona = "partner" | "account"; + +export async function sendPasswordResetEmail(opts: { + to: string; + name: string; + role: string; + locale: string; + /** The single-use link. Built by the caller, which owns the token. */ + url: string; +}): Promise<{ sent: boolean }> { + const t = await emailTranslator(opts.locale, "emails.reset"); + const persona: Persona = opts.role === "PARTNER" ? "partner" : "account"; + // Resolved before the template, so the HTML below carries no nested template + // literal (Sonar S4624) and each line reads as one sentence. + const greeting = t("greeting", { name: escapeHtml(opts.name) }); + const lead = t(`lead.${persona}`); + return sendEmail({ + to: opts.to, + subject: t("subject"), + html: craftEmail({ + kicker: t(`kicker.${persona}`), + title: t("title"), + // Escaped BEFORE interpolation (lib/email-templates.ts's contract): the + // catalogue is ours, the name on the account is not. + bodyHtml: `

${greeting}

+

${lead}

`, + cta: { label: t("cta"), url: opts.url }, + footerNote: t("footerNote"), + }), + }); +} diff --git a/lib/self-serve-account.ts b/lib/self-serve-account.ts index c5ded09..0eee010 100644 --- a/lib/self-serve-account.ts +++ b/lib/self-serve-account.ts @@ -75,6 +75,10 @@ export async function createSelfServeAccount(input: { * payment-triggered proposal reads (O3) — without it the only join back to them * is `desiredSlug`, which several leads can share. */ signupRequestId: string; + /** The language the visitor filled the signup form in. Stored on the ACCOUNT, + * not only on the lead (G9): the lead row is consumed and left behind, and an + * owner who later holds a second tenant has no path back to it. */ + locale: string; }): Promise { try { const minted = await db.$transaction(async (tx) => { @@ -91,6 +95,7 @@ export async function createSelfServeAccount(input: { email: input.email, name: input.contactName, role: "OWNER", + locale: input.locale, // No password: the invite link sets it, and until it does // lib/auth.ts refuses the login. This IS the email verification. status: "INVITED", diff --git a/lib/self-serve-email.ts b/lib/self-serve-email.ts index 8609789..f06f0c8 100644 --- a/lib/self-serve-email.ts +++ b/lib/self-serve-email.ts @@ -1,4 +1,5 @@ -// The two emails a self-serve signup sends (SOFRA-ONBOARDING-PLAN O2). +// The two emails a self-serve signup sends (SOFRA-ONBOARDING-PLAN O2), and the +// invite template the founder's own onboarding path shares with it. // // Split out of the intake route so the route reads as the flow it is (guard → // validate → decide → mint → notify) rather than interleaving two templates with @@ -8,9 +9,15 @@ // committed by the time these run, so a Resend outage must not turn a paid-up // customer into a 500. The founder notification is the backstop — it names the // restaurant and the plan, so a missed welcome email is recoverable by hand. +// +// LANGUAGE (G9). The customer-facing half is localized and the founder-facing +// half is not, and that asymmetry is the whole rule: the founder is one person who +// reads English, while the invite is the FIRST thing a restaurant owner in Geneva +// ever receives from us and the only route into an account with no password. import { sendEmail, escapeHtml, siteUrl } from "@/lib/email"; import { craftEmail, detailRows } from "@/lib/email-templates"; +import { emailTranslator } from "@/lib/email-locale"; import { eur } from "@/lib/format"; /** @@ -39,49 +46,63 @@ export async function sendInviteEmail(opts: { name: string; restaurantName: string; inviteToken: string | null; - kicker: string; + /** The language this person is written to in — `User.locale`, or the intake's + * own locale for an account that does not exist yet. */ + locale: string; /** Extra `detailRows` beneath the copy; omit for the founder path. */ rows?: [string, string][]; }): Promise<{ sent: boolean }> { + const t = await emailTranslator(opts.locale, "emails.invite"); const needsPassword = opts.inviteToken !== null; + // ONE variant key, used for the subject, the title, the lead and the button, so + // the four cannot drift into describing different situations to one reader. + const variant = needsPassword ? "setPassword" : "ready"; const link = needsPassword ? `${siteUrl()}/invite/${opts.inviteToken}` : `${siteUrl()}/login`; + // Escaped BEFORE interpolation (lib/email-templates.ts's contract): the message + // catalogue is ours and trusted, a restaurant name typed into a form is not. + const restaurant = escapeHtml(opts.restaurantName); + // Resolved before the template for the same reason `reset-email.ts` does it: no + // nested template literals inside the HTML (Sonar S4624), and each line of copy + // reads as one sentence rather than as an expression. + const greeting = t("greeting", { name: escapeHtml(opts.name) }); + const lead = t(`lead.${variant}`, { restaurant }); return sendEmail({ to: opts.to, - subject: needsPassword - ? "Welcome to SofraPiwas — set your password" - : `SofraPiwas — ${opts.restaurantName} is ready for your subscription`, + subject: t(`subject.${variant}`, { restaurant: opts.restaurantName }), html: craftEmail({ - kicker: opts.kicker, - title: needsPassword ? "Welcome aboard 🎉" : "A new plan is waiting", - bodyHtml: `

Hi ${escapeHtml(opts.name)},

-

${escapeHtml(opts.restaurantName)} is set up on SofraPiwas. ${ - needsPassword ? "Set your password to open your dashboard" : "Sign in to your dashboard" - } and start the monthly subscription — afiyet olsun.

+ kicker: t("kicker"), + title: t(`title.${variant}`), + bodyHtml: `

${greeting}

+

${lead}

${opts.rows ? detailRows(opts.rows) : ""}`, - cta: { label: needsPassword ? "Set your password" : "Open your dashboard", url: link }, - footerNote: needsPassword ? "The link works once and expires in 24 hours." : undefined, + cta: { label: t(`cta.${variant}`), url: link }, + footerNote: needsPassword ? t("footerNote") : undefined, }), }); } /** The self-serve caller's shape: same template, plus the address and the total. */ -export function sendOwnerWelcome(opts: { +export async function sendOwnerWelcome(opts: { to: string; contactName: string; restaurantName: string; slug: string; amountCents: number; inviteToken: string | null; + locale: string; }): Promise<{ sent: boolean }> { + const t = await emailTranslator(opts.locale, "emails.invite"); return sendInviteEmail({ to: opts.to, name: opts.contactName, restaurantName: opts.restaurantName, inviteToken: opts.inviteToken, - kicker: "Welcome to SofraPiwas", + locale: opts.locale, rows: [ - ["Your web address", `${opts.slug}.sofrapiwas.com`], - ["Your plan", `${eur(opts.amountCents)}/month`], + [t("rowAddress"), `${opts.slug}.sofrapiwas.com`], + // The amount stays as `eur()` formats it — the price is the same number in + // every language, and `Intl` already localizes the separator inside it. + [t("rowPlan"), `${eur(opts.amountCents)}/month`], ], }); } @@ -91,6 +112,9 @@ export function sendOwnerWelcome(opts: { * account was created. Self-serve means nobody is watching the queue for a lead * that quietly failed to become one, so the outcome is stated rather than * inferred from the presence of a row. + * + * English, deliberately: it goes to the founder's own inbox (M5/M8 are the same), + * and translating an operational notice would only make it harder to grep. */ export async function sendFounderSignupNotice(opts: { to: string; diff --git a/lib/trial-warning-candidates.ts b/lib/trial-warning-candidates.ts index 42d9105..0b93f8b 100644 --- a/lib/trial-warning-candidates.ts +++ b/lib/trial-warning-candidates.ts @@ -86,8 +86,11 @@ function plansInWindow(now: Date) { }, payments: { where: { sequenceType: "first" }, select: { sequenceType: true, status: true } }, billingIdentity: { select: { billingEmail: true } }, - client: { select: { partner: { select: { name: true, email: true } } } }, - payer: { select: { name: true, email: true } }, + // `locale` since G9: the account itself now holds the language, so the + // sweep no longer has to look the payer's address up in the intake table it + // was captured on. + client: { select: { partner: { select: { name: true, email: true, locale: true } } } }, + payer: { select: { name: true, email: true, locale: true } }, signupRequest: { select: { locale: true } }, }, }); diff --git a/lib/trial-warning-notify.ts b/lib/trial-warning-notify.ts index 61ca27f..79e37ff 100644 --- a/lib/trial-warning-notify.ts +++ b/lib/trial-warning-notify.ts @@ -21,7 +21,6 @@ // window, so a marker cannot age out from under a trial that is still running. import { audit } from "@/lib/audit"; -import { db } from "@/lib/db"; import { founderInbox } from "@/lib/email"; import { payerAddress } from "@/lib/payer-contact"; import type { TrialWarning } from "@/lib/trial-warning-policy"; @@ -48,40 +47,18 @@ export interface TrialWarningSweepResult { /** What one attempted milestone did: two of these are successes, the rest reasons. */ type Outcome = "founder" | "payer" | "noRecipient" | "noFounderInbox" | "sendFailed"; -/** - * The language the control plane HOLDS for each payer: the partner application their - * address was captured on. One query for the batch; the self-serve fallback - * (`signupRequest.locale`) rides along on the plan already. - */ -async function heldLocales(todo: TrialWarningTodo[]): Promise> { - const applications = await db.partnerApplication.findMany({ - // Lowercased on both sides: the intake stores `data.email.toLowerCase()` while a - // plan's address may be admin-typed in any case, and a mismatch here silently - // downgrades a francophone partner to English. - where: { email: { in: todo.map((t) => payerAddress(t.plan)?.toLowerCase() ?? "") } }, - orderBy: { createdAt: "desc" }, - select: { email: true, locale: true }, - }); - const held = new Map(); - for (const a of applications) { - if (!held.has(a.email.toLowerCase())) held.set(a.email.toLowerCase(), a.locale); - } - return held; -} - /** The send itself. `null` means there was nobody to write to — the one case that * must NOT leave a marker behind, because it is not a thing we have said. */ async function attempt( item: TrialWarningTodo, warning: TrialWarning, inbox: string | undefined, - locales: Map, to: string | null, ): Promise<{ sent: boolean } | null> { const { plan, sub, verdict } = item; if (warning === "founder") return sendFounderNotice(inbox, plan, sub, verdict); if (!to) return null; - return sendPayerWarning(to, plan, sub, verdict, locales.get(to.toLowerCase())); + return sendPayerWarning(to, plan, sub, verdict); } /** @@ -95,10 +72,9 @@ async function deliver( item: TrialWarningTodo, warning: TrialWarning, inbox: string | undefined, - locales: Map, ): Promise { const { plan, verdict } = item; - const result = await attempt(item, warning, inbox, locales, payerAddress(plan)); + const result = await attempt(item, warning, inbox, payerAddress(plan)); if (!result) return "noRecipient"; await audit(null, TRIAL_WARNING_ACTIONS[warning], "TenantBilling", plan.id, { @@ -121,7 +97,6 @@ export async function runTrialWarningSweep( return { considered: 0, founderNotices: 0, payerWarnings: 0, skipped }; } - const locales = await heldLocales(todo); const inbox = founderInbox(); let founderNotices = 0; let payerWarnings = 0; @@ -134,7 +109,7 @@ export async function runTrialWarningSweep( // `due` is ordered founder-first, and this loop is why: the owner made the // partner's warning conditional on his own chance to extend, so even on the // one run where both come due he is still told first. - const outcome = await deliver(item, warning, inbox, locales); + const outcome = await deliver(item, warning, inbox); if (outcome === "founder") founderNotices += 1; else if (outcome === "payer") payerWarnings += 1; else bump(skipped, outcome); diff --git a/lib/trial-warning-send.ts b/lib/trial-warning-send.ts index 7f360af..379bd88 100644 --- a/lib/trial-warning-send.ts +++ b/lib/trial-warning-send.ts @@ -8,7 +8,7 @@ // mailing the same partner again. import { emailLocale } from "@/lib/email-locale"; -import { payerGreetingName, type PayerContact } from "@/lib/payer-contact"; +import { payerGreetingName, payerLocale, type PayerContact } from "@/lib/payer-contact"; import { sendTrialEndingEmail, sendTrialEndingFounderEmail } from "@/lib/trial-warning-email"; import type { TrialWarningVerdict } from "@/lib/trial-warning-policy"; @@ -52,17 +52,23 @@ export async function sendFounderNotice( }).catch(() => caught); } -/** The payer's own warning, in the language the control plane holds for them. */ +/** The payer's own warning, in the language the control plane holds for them. + * + * Since G9 that language is on the ACCOUNT (`User.locale`, resolved by + * `payerLocale` in the same order as the address), so this no longer depends on + * matching the payer's address back to the intake row it was captured on — a join + * by lower-cased email that silently downgraded a francophone partner to English + * whenever an admin had typed the address in a different case. The lead's own + * locale stays as the fallback for a plan whose payer is not a user at all. */ export async function sendPayerWarning( to: string, plan: WarnablePlan, sub: WarnableSubscription, verdict: DueWarning, - heldLocale: string | undefined, ): Promise<{ sent: boolean }> { return sendTrialEndingEmail({ to, - locale: emailLocale(heldLocale, plan.signupRequest?.locale), + locale: emailLocale(payerLocale(plan), plan.signupRequest?.locale), contactName: payerGreetingName(plan), restaurantName: plan.name, phase: verdict.phase, diff --git a/messages/ar.json b/messages/ar.json index eb106f7..4144db0 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -1491,6 +1491,71 @@ "rowPlan": "شهريًا", "cta": "افتح صفحة الفوترة", "footerNote": "هل تحتاج إلى وقت إضافي أو لديك سؤال عن السعر؟ يكفي أن ترد على هذه الرسالة — يقرأها شخص حقيقي." + }, + "invite": { + "kicker": "مرحبًا بك في SofraPiwas", + "subject": { + "setPassword": "مرحبًا بك في SofraPiwas — عيّن كلمة المرور", + "ready": "أصبح {restaurant} جاهزًا لاشتراكك — SofraPiwas" + }, + "title": { + "setPassword": "أهلًا بك معنا 🎉", + "ready": "اشتراك جديد بانتظارك" + }, + "greeting": "مرحبًا {name}،", + "lead": { + "setPassword": "تم إعداد {restaurant} على SofraPiwas. عيّن كلمة المرور لفتح لوحة التحكم وبدء الاشتراك الشهري — afiyet olsun.", + "ready": "تم إعداد {restaurant} على SofraPiwas. سجّل الدخول إلى لوحة التحكم وابدأ الاشتراك الشهري — afiyet olsun." + }, + "cta": { + "setPassword": "عيّن كلمة المرور", + "ready": "افتح لوحة التحكم" + }, + "footerNote": "يعمل الرابط مرة واحدة وتنتهي صلاحيته خلال 24 ساعة.", + "rowAddress": "عنوان موقعك", + "rowPlan": "اشتراكك" + }, + "partnerApproved": { + "kicker": "برنامج الشركاء", + "subject": "مرحبًا بك في برنامج شركاء SofraPiwas", + "title": "أهلًا بك معنا 🎉", + "greeting": "مرحبًا {name}،", + "lead": "تمت الموافقة على طلبك لتصبح شريكًا في SofraPiwas. عيّن كلمة المرور لفتح لوحة الشريك — afiyet olsun.", + "cta": "عيّن كلمة المرور", + "footerNote": "يعمل الرابط مرة واحدة وتنتهي صلاحيته خلال 24 ساعة." + }, + "reset": { + "kicker": { + "partner": "منطقة الشركاء", + "account": "حسابك" + }, + "subject": "إعادة تعيين كلمة المرور — SofraPiwas", + "title": "إعادة تعيين كلمة المرور", + "greeting": "مرحبًا {name}،", + "lead": { + "partner": "طلب أحدهم (نأمل أن تكون أنت) إعادة تعيين كلمة مرور حساب الشريك الخاص بك في SofraPiwas. إذا لم تكن أنت، فيمكنك تجاهل هذه الرسالة بأمان.", + "account": "طلب أحدهم (نأمل أن تكون أنت) إعادة تعيين كلمة مرورك في SofraPiwas. إذا لم تكن أنت، فيمكنك تجاهل هذه الرسالة بأمان." + }, + "cta": "تعيين كلمة مرور جديدة", + "footerNote": "يعمل الرابط مرة واحدة وتنتهي صلاحيته خلال 24 ساعة." + }, + "invoice": { + "kicker": "الفوترة", + "subject": "الفاتورة {number} — SofraPiwas", + "title": "الفاتورة {number}", + "rowInvoice": "رقم الفاتورة", + "rowService": "المطعم", + "rowTotal": "الإجمالي", + "cta": "عرض فاتورتك" + }, + "billingDetails": { + "kicker": "الفوترة", + "subject": "نحتاج بيانات الفوترة لإصدار فاتورتك — SofraPiwas", + "title": "فاتورتك بانتظار بعض البيانات", + "received": "استلمنا دفعتك البالغة {amount} عن {restaurant}. شكرًا لك.", + "ask": "لكي نرسل إليك فاتورة نظامية نحتاج إلى الشركة التي ستُوجَّه إليها الفاتورة — الاسم القانوني والعنوان والبلد. يستغرق ذلك دقيقة، وتُصدَر الفاتورة فور حفظك لهذه البيانات.", + "vat": "إذا كان لشركتك رقم ضريبة قيمة مضافة في الاتحاد الأوروبي، فأضفه أيضًا وسنطبّق الاحتساب العكسي للضريبة بدلًا من تحصيلها منك.", + "cta": "أضف بيانات الفوترة" } } } diff --git a/messages/de.json b/messages/de.json index f42e6a1..ab6fd5b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1491,6 +1491,71 @@ "rowPlan": "Pro Monat", "cta": "Zur Abrechnungsseite", "footerNote": "Mehr Zeit nötig oder eine Frage zum Preis? Antworten Sie einfach auf diese E-Mail — ein Mensch liest sie." + }, + "invite": { + "kicker": "Willkommen bei SofraPiwas", + "subject": { + "setPassword": "Willkommen bei SofraPiwas — Passwort festlegen", + "ready": "SofraPiwas — {restaurant} ist bereit für Ihr Abo" + }, + "title": { + "setPassword": "Willkommen an Bord 🎉", + "ready": "Ein neues Abo wartet auf Sie" + }, + "greeting": "Hallo {name},", + "lead": { + "setPassword": "{restaurant} ist auf SofraPiwas eingerichtet. Legen Sie Ihr Passwort fest, um Ihr Dashboard zu öffnen und das Monatsabo zu starten — afiyet olsun.", + "ready": "{restaurant} ist auf SofraPiwas eingerichtet. Melden Sie sich in Ihrem Dashboard an und starten Sie das Monatsabo — afiyet olsun." + }, + "cta": { + "setPassword": "Passwort festlegen", + "ready": "Dashboard öffnen" + }, + "footerNote": "Der Link funktioniert einmal und läuft nach 24 Stunden ab.", + "rowAddress": "Ihre Webadresse", + "rowPlan": "Ihr Abo" + }, + "partnerApproved": { + "kicker": "Partnerprogramm", + "subject": "Willkommen im SofraPiwas-Partnerprogramm", + "title": "Willkommen an Bord 🎉", + "greeting": "Hallo {name},", + "lead": "Ihre Bewerbung als SofraPiwas-Partner ist angenommen. Legen Sie Ihr Passwort fest, um Ihr Partner-Dashboard zu öffnen — afiyet olsun.", + "cta": "Passwort festlegen", + "footerNote": "Der Link funktioniert einmal und läuft nach 24 Stunden ab." + }, + "reset": { + "kicker": { + "partner": "Partnerbereich", + "account": "Ihr Konto" + }, + "subject": "SofraPiwas — Passwort zurücksetzen", + "title": "Passwort zurücksetzen", + "greeting": "Hallo {name},", + "lead": { + "partner": "Jemand (hoffentlich Sie) hat angefragt, Ihr SofraPiwas-Partnerpasswort zurückzusetzen. Falls Sie das nicht waren, können Sie diese E-Mail einfach ignorieren.", + "account": "Jemand (hoffentlich Sie) hat angefragt, Ihr SofraPiwas-Passwort zurückzusetzen. Falls Sie das nicht waren, können Sie diese E-Mail einfach ignorieren." + }, + "cta": "Neues Passwort festlegen", + "footerNote": "Der Link funktioniert einmal und läuft nach 24 Stunden ab." + }, + "invoice": { + "kicker": "Abrechnung", + "subject": "Rechnung {number} — SofraPiwas", + "title": "Rechnung {number}", + "rowInvoice": "Rechnungsnummer", + "rowService": "Restaurant", + "rowTotal": "Gesamt", + "cta": "Rechnung ansehen" + }, + "billingDetails": { + "kicker": "Abrechnung", + "subject": "Wir brauchen Ihre Rechnungsdaten — SofraPiwas", + "title": "Ihre Rechnung wartet noch auf ein paar Angaben", + "received": "Wir haben Ihre Zahlung über {amount} für {restaurant} erhalten. Vielen Dank.", + "ask": "Damit wir Ihnen eine ordentliche Rechnung schicken können, brauchen wir das Unternehmen, an das sie gehen soll — Firmenname, Adresse und Land. Das dauert eine Minute, und die Rechnung wird ausgestellt, sobald Sie die Angaben gespeichert haben.", + "vat": "Wenn Ihr Unternehmen eine EU-USt-IdNr. hat, tragen Sie sie ebenfalls ein — dann wenden wir das Reverse-Charge-Verfahren an, statt Ihnen die Umsatzsteuer zu berechnen.", + "cta": "Rechnungsdaten hinzufügen" } } } diff --git a/messages/en.json b/messages/en.json index e0d05ad..4800792 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1491,6 +1491,71 @@ "rowPlan": "Monthly", "cta": "Open your billing page", "footerNote": "Need more time, or have a question about the price? Just reply to this email — a person reads it." + }, + "invite": { + "kicker": "Welcome to SofraPiwas", + "subject": { + "setPassword": "Welcome to SofraPiwas — set your password", + "ready": "SofraPiwas — {restaurant} is ready for your subscription" + }, + "title": { + "setPassword": "Welcome aboard 🎉", + "ready": "A new plan is waiting" + }, + "greeting": "Hi {name},", + "lead": { + "setPassword": "{restaurant} is set up on SofraPiwas. Set your password to open your dashboard and start the monthly subscription — afiyet olsun.", + "ready": "{restaurant} is set up on SofraPiwas. Sign in to your dashboard and start the monthly subscription — afiyet olsun." + }, + "cta": { + "setPassword": "Set your password", + "ready": "Open your dashboard" + }, + "footerNote": "The link works once and expires in 24 hours.", + "rowAddress": "Your web address", + "rowPlan": "Your plan" + }, + "partnerApproved": { + "kicker": "Partner program", + "subject": "Welcome to the SofraPiwas partner program", + "title": "Welcome aboard 🎉", + "greeting": "Hi {name},", + "lead": "Your SofraPiwas partner application is approved. Set your password to open your partner dashboard — afiyet olsun.", + "cta": "Set your password", + "footerNote": "The link works once and expires in 24 hours." + }, + "reset": { + "kicker": { + "partner": "Partner area", + "account": "Your account" + }, + "subject": "SofraPiwas — reset your password", + "title": "Reset your password", + "greeting": "Hi {name},", + "lead": { + "partner": "Someone (hopefully you) asked to reset your SofraPiwas partner password. If this wasn't you, you can safely ignore this email.", + "account": "Someone (hopefully you) asked to reset your SofraPiwas password. If this wasn't you, you can safely ignore this email." + }, + "cta": "Set a new password", + "footerNote": "The link works once and expires in 24 hours." + }, + "invoice": { + "kicker": "Billing", + "subject": "Invoice {number} — SofraPiwas", + "title": "Invoice {number}", + "rowInvoice": "Invoice number", + "rowService": "Restaurant", + "rowTotal": "Total", + "cta": "View your invoice" + }, + "billingDetails": { + "kicker": "Billing", + "subject": "We need your billing details — SofraPiwas", + "title": "Your invoice is waiting on a few details", + "received": "We received your payment of {amount} for {restaurant}. Thank you.", + "ask": "To send you a proper invoice we need the company it should be addressed to — the legal name, address and country. It takes a minute, and the invoice is issued as soon as you have saved them.", + "vat": "If your company has an EU VAT number, add it too and we will reverse-charge the VAT instead of charging it.", + "cta": "Add your billing details" } } } diff --git a/messages/fr.json b/messages/fr.json index 0178174..201d2e5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1491,6 +1491,71 @@ "rowPlan": "Par mois", "cta": "Ouvrir votre page de facturation", "footerNote": "Besoin de plus de temps ou une question sur le prix ? Répondez simplement à cet e-mail — une personne le lit." + }, + "invite": { + "kicker": "Bienvenue chez SofraPiwas", + "subject": { + "setPassword": "Bienvenue chez SofraPiwas — définissez votre mot de passe", + "ready": "SofraPiwas — {restaurant} est prêt pour votre abonnement" + }, + "title": { + "setPassword": "Bienvenue à bord 🎉", + "ready": "Un nouvel abonnement vous attend" + }, + "greeting": "Bonjour {name},", + "lead": { + "setPassword": "{restaurant} est configuré sur SofraPiwas. Définissez votre mot de passe pour ouvrir votre tableau de bord et démarrer l'abonnement mensuel — afiyet olsun.", + "ready": "{restaurant} est configuré sur SofraPiwas. Connectez-vous à votre tableau de bord et démarrez l'abonnement mensuel — afiyet olsun." + }, + "cta": { + "setPassword": "Définir votre mot de passe", + "ready": "Ouvrir votre tableau de bord" + }, + "footerNote": "Le lien fonctionne une seule fois et expire dans 24 heures.", + "rowAddress": "Votre adresse web", + "rowPlan": "Votre abonnement" + }, + "partnerApproved": { + "kicker": "Programme partenaires", + "subject": "Bienvenue dans le programme partenaires SofraPiwas", + "title": "Bienvenue à bord 🎉", + "greeting": "Bonjour {name},", + "lead": "Votre candidature de partenaire SofraPiwas est approuvée. Définissez votre mot de passe pour ouvrir votre tableau de bord partenaire — afiyet olsun.", + "cta": "Définir votre mot de passe", + "footerNote": "Le lien fonctionne une seule fois et expire dans 24 heures." + }, + "reset": { + "kicker": { + "partner": "Espace partenaires", + "account": "Votre compte" + }, + "subject": "SofraPiwas — réinitialisez votre mot de passe", + "title": "Réinitialiser votre mot de passe", + "greeting": "Bonjour {name},", + "lead": { + "partner": "Quelqu'un (vous, on l'espère) a demandé la réinitialisation du mot de passe de votre compte partenaire SofraPiwas. Si ce n'est pas vous, vous pouvez ignorer cet e-mail sans souci.", + "account": "Quelqu'un (vous, on l'espère) a demandé la réinitialisation de votre mot de passe SofraPiwas. Si ce n'est pas vous, vous pouvez ignorer cet e-mail sans souci." + }, + "cta": "Définir un nouveau mot de passe", + "footerNote": "Le lien fonctionne une seule fois et expire dans 24 heures." + }, + "invoice": { + "kicker": "Facturation", + "subject": "Facture {number} — SofraPiwas", + "title": "Facture {number}", + "rowInvoice": "Numéro de facture", + "rowService": "Restaurant", + "rowTotal": "Total", + "cta": "Voir votre facture" + }, + "billingDetails": { + "kicker": "Facturation", + "subject": "Il nous manque vos informations de facturation — SofraPiwas", + "title": "Votre facture attend quelques informations", + "received": "Nous avons bien reçu votre paiement de {amount} pour {restaurant}. Merci.", + "ask": "Pour vous envoyer une facture en bonne et due forme, nous avons besoin de la société à laquelle l'adresser — la raison sociale, l'adresse et le pays. Cela prend une minute, et la facture est émise dès que vous les avez enregistrées.", + "vat": "Si votre société a un numéro de TVA intracommunautaire, ajoutez-le : nous appliquerons l'autoliquidation de la TVA au lieu de la facturer.", + "cta": "Ajouter vos informations de facturation" } } } diff --git a/messages/nl.json b/messages/nl.json index 920d289..9978180 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -1491,6 +1491,71 @@ "rowPlan": "Per maand", "cta": "Open uw factuurpagina", "footerNote": "Meer tijd nodig of een vraag over de prijs? Beantwoord deze e-mail gewoon — een mens leest hem." + }, + "invite": { + "kicker": "Welkom bij SofraPiwas", + "subject": { + "setPassword": "Welkom bij SofraPiwas — stel uw wachtwoord in", + "ready": "SofraPiwas — {restaurant} is klaar voor uw abonnement" + }, + "title": { + "setPassword": "Welkom aan boord 🎉", + "ready": "Er staat een abonnement klaar" + }, + "greeting": "Hallo {name},", + "lead": { + "setPassword": "{restaurant} staat klaar op SofraPiwas. Stel uw wachtwoord in om uw dashboard te openen en het maandabonnement te starten — afiyet olsun.", + "ready": "{restaurant} staat klaar op SofraPiwas. Log in op uw dashboard en start het maandabonnement — afiyet olsun." + }, + "cta": { + "setPassword": "Wachtwoord instellen", + "ready": "Open uw dashboard" + }, + "footerNote": "De link werkt één keer en verloopt na 24 uur.", + "rowAddress": "Uw webadres", + "rowPlan": "Uw abonnement" + }, + "partnerApproved": { + "kicker": "Partnerprogramma", + "subject": "Welkom bij het SofraPiwas-partnerprogramma", + "title": "Welkom aan boord 🎉", + "greeting": "Hallo {name},", + "lead": "Uw aanmelding als SofraPiwas-partner is goedgekeurd. Stel uw wachtwoord in om uw partnerdashboard te openen — afiyet olsun.", + "cta": "Wachtwoord instellen", + "footerNote": "De link werkt één keer en verloopt na 24 uur." + }, + "reset": { + "kicker": { + "partner": "Partneromgeving", + "account": "Uw account" + }, + "subject": "SofraPiwas — wachtwoord opnieuw instellen", + "title": "Wachtwoord opnieuw instellen", + "greeting": "Hallo {name},", + "lead": { + "partner": "Iemand (hopelijk u) heeft gevraagd om het wachtwoord van uw SofraPiwas-partneraccount opnieuw in te stellen. Was u dit niet, dan kunt u deze e-mail gerust negeren.", + "account": "Iemand (hopelijk u) heeft gevraagd om uw SofraPiwas-wachtwoord opnieuw in te stellen. Was u dit niet, dan kunt u deze e-mail gerust negeren." + }, + "cta": "Nieuw wachtwoord instellen", + "footerNote": "De link werkt één keer en verloopt na 24 uur." + }, + "invoice": { + "kicker": "Facturatie", + "subject": "Factuur {number} — SofraPiwas", + "title": "Factuur {number}", + "rowInvoice": "Factuurnummer", + "rowService": "Restaurant", + "rowTotal": "Totaal", + "cta": "Bekijk uw factuur" + }, + "billingDetails": { + "kicker": "Facturatie", + "subject": "We hebben uw factuurgegevens nodig — SofraPiwas", + "title": "Uw factuur wacht nog op een paar gegevens", + "received": "We hebben uw betaling van {amount} voor {restaurant} ontvangen. Bedankt.", + "ask": "Om u een correcte factuur te sturen hebben we het bedrijf nodig waaraan die gericht moet worden — de statutaire naam, het adres en het land. Het kost een minuut, en de factuur wordt opgemaakt zodra u ze hebt opgeslagen.", + "vat": "Heeft uw bedrijf een EU-btw-nummer? Vul dat er ook bij in, dan verleggen we de btw in plaats van die in rekening te brengen.", + "cta": "Vul uw factuurgegevens in" } } } diff --git a/messages/tr.json b/messages/tr.json index 54e4ec3..e5ce338 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -275,7 +275,7 @@ } }, "runs": { - "title": "RUMI, SofraPiwas'da neleri çalıştırıyor", + "title": "RUMI, SofraPiwas'ta neleri çalıştırıyor", "intro": "Ne pilot ne de kısmi kullanım — restoranın günlük servisi platform üzerinde dönüyor:", "items": { "ordering": { @@ -429,8 +429,8 @@ }, "changelog": { "meta": { - "title": "SofraPiwas'da neler yeni — değişiklik günlüğü", - "description": "SofraPiwas'daki yeniliklere dair tarihli notlar: özellikler, diller, faturalama, güvenlik ve güvenilirlik. Kısa ve dürüst — burada yazıyorsa, yayında." + "title": "SofraPiwas'ta neler yeni — değişiklik günlüğü", + "description": "SofraPiwas'taki yeniliklere dair tarihli notlar: özellikler, diller, faturalama, güvenlik ve güvenilirlik. Kısa ve dürüst — burada yazıyorsa, yayında." }, "label": "Mutfaktan taze", "title": "Neler yeni", @@ -438,7 +438,7 @@ "entries": { "currency": { "title": "Para biriminiz, uçtan uca", - "body": "SofraPiwas'daki her restoran artık kendi para birimiyle çalışıyor — menü, siparişler, fişler ve makbuzlar aynı dili konuşuyor. Ayrıca restorana özel görünümlerin temelini attık: klasik ve zanaatkâr olmak üzere iki şablon yolda." + "body": "SofraPiwas'taki her restoran artık kendi para birimiyle çalışıyor — menü, siparişler, fişler ve makbuzlar aynı dili konuşuyor. Ayrıca restorana özel görünümlerin temelini attık: klasik ve zanaatkâr olmak üzere iki şablon yolda." }, "hardening": { "title": "Güvenlik ve güvenilirlik haftası", @@ -1091,10 +1091,10 @@ "title": "Aboneliğiniz", "intro": "SofraPiwas aboneliğiniz ve ödeme geçmişiniz.", "empty": "Henüz abonelik yok — sizinle iletişime geçeceğiz.", - "welcomeKicker": "SofraPiwas'ya hoş geldiniz", + "welcomeKicker": "SofraPiwas'a hoş geldiniz", "welcomeTitle": "Hoş geldiniz, {name} 🎉", - "liveSince": "{restaurant} {date} tarihinden beri SofraPiwas'da yayında.", - "liveSinceUnknown": "{restaurant} SofraPiwas'da yayında.", + "liveSince": "{restaurant} {date} tarihinden beri SofraPiwas'ta yayında.", + "liveSinceUnknown": "{restaurant} SofraPiwas'ta yayında.", "amountLine": "{amount} / {interval}", "firstChargeNote": "İlk ayı şimdi ödersiniz, ardından otomatik olarak yenilenir. İstediğiniz zaman iptal edebilirsiniz.", "startPayment": "Otomatik aylık ödemeyi başlat", @@ -1491,6 +1491,71 @@ "rowPlan": "Aylık", "cta": "Fatura sayfanızı açın", "footerNote": "Daha fazla süreye mi ihtiyacınız var ya da fiyatla ilgili bir sorunuz mu var? Bu e-postayı yanıtlamanız yeterli — mesajı bir insan okuyor." + }, + "invite": { + "kicker": "SofraPiwas'a hoş geldiniz", + "subject": { + "setPassword": "SofraPiwas'a hoş geldiniz — şifrenizi belirleyin", + "ready": "SofraPiwas — {restaurant} aboneliğiniz için hazır" + }, + "title": { + "setPassword": "Aramıza hoş geldiniz 🎉", + "ready": "Yeni bir abonelik sizi bekliyor" + }, + "greeting": "Merhaba {name},", + "lead": { + "setPassword": "{restaurant} SofraPiwas'ta kuruldu. Panelinizi açmak ve aylık aboneliği başlatmak için şifrenizi belirleyin — afiyet olsun.", + "ready": "{restaurant} SofraPiwas'ta kuruldu. Panelinize giriş yapın ve aylık aboneliği başlatın — afiyet olsun." + }, + "cta": { + "setPassword": "Şifrenizi belirleyin", + "ready": "Panelinizi açın" + }, + "footerNote": "Bağlantı tek kullanımlıktır ve 24 saat sonra geçersiz olur.", + "rowAddress": "Web adresiniz", + "rowPlan": "Aboneliğiniz" + }, + "partnerApproved": { + "kicker": "Partner programı", + "subject": "SofraPiwas partner programına hoş geldiniz", + "title": "Aramıza hoş geldiniz 🎉", + "greeting": "Merhaba {name},", + "lead": "SofraPiwas partner başvurunuz onaylandı. Partner panelinizi açmak için şifrenizi belirleyin — afiyet olsun.", + "cta": "Şifrenizi belirleyin", + "footerNote": "Bağlantı tek kullanımlıktır ve 24 saat sonra geçersiz olur." + }, + "reset": { + "kicker": { + "partner": "Partner alanı", + "account": "Hesabınız" + }, + "subject": "SofraPiwas — şifrenizi sıfırlayın", + "title": "Şifrenizi sıfırlayın", + "greeting": "Merhaba {name},", + "lead": { + "partner": "Biri (umarız sizsiniz) SofraPiwas partner şifrenizin sıfırlanmasını istedi. Bu siz değilseniz bu e-postayı dikkate almayabilirsiniz.", + "account": "Biri (umarız sizsiniz) SofraPiwas şifrenizin sıfırlanmasını istedi. Bu siz değilseniz bu e-postayı dikkate almayabilirsiniz." + }, + "cta": "Yeni şifre belirle", + "footerNote": "Bağlantı tek kullanımlıktır ve 24 saat sonra geçersiz olur." + }, + "invoice": { + "kicker": "Faturalandırma", + "subject": "Fatura {number} — SofraPiwas", + "title": "Fatura {number}", + "rowInvoice": "Fatura numarası", + "rowService": "Restoran", + "rowTotal": "Toplam", + "cta": "Faturanızı görüntüleyin" + }, + "billingDetails": { + "kicker": "Faturalandırma", + "subject": "Faturanız için fatura bilgileriniz gerekiyor — SofraPiwas", + "title": "Faturanız birkaç bilgi için bekliyor", + "received": "{restaurant} için {amount} tutarındaki ödemenizi aldık. Teşekkürler.", + "ask": "Size usulüne uygun bir fatura gönderebilmemiz için faturanın düzenleneceği şirketi bilmemiz gerekiyor — ticari unvan, adres ve ülke. Bir dakika sürer; bilgileri kaydeder kaydetmez fatura düzenlenir.", + "vat": "Şirketinizin AB KDV numarası varsa onu da ekleyin; KDV'yi sizden tahsil etmek yerine tersine tahsilat (reverse charge) uygularız.", + "cta": "Fatura bilgilerinizi ekleyin" } } } diff --git a/prisma/migrations/20260822000000_user_locale/migration.sql b/prisma/migrations/20260822000000_user_locale/migration.sql new file mode 100644 index 0000000..6acf846 --- /dev/null +++ b/prisma/migrations/20260822000000_user_locale/migration.sql @@ -0,0 +1,68 @@ +-- What language do we write to this customer in? (EMAIL-SPEC-CONTROL-PLANE G9.) +-- +-- The control plane has held a locale for every INTAKE since the day the intakes +-- were built — `SignupRequest.locale`, `PartnerApplication.locale` — and the +-- trial-ending mail (sofra #167) proved the sending pattern that reads one. What +-- it has never held is a locale for a PERSON. An intake row is consumed and left +-- behind: it belongs to the lead, not to the account the lead became, and nothing +-- links it back once a partner holds a second tenant or an owner is invited by the +-- founder rather than through the funnel. +-- +-- So every mail addressed to a USER — the invite, the re-send, the password reset, +-- the invoice — had no locale to read and stayed English, in a product that sells +-- in Geneva and ships six languages. This column is that locale. +-- +-- NOT NULL with a default of 'en', deliberately, and it is the honest reading of +-- the rows that exist: they were all written English mail, so 'en' is what they +-- actually received. A nullable column would add a third state ("unknown") that +-- every reader would have to collapse to 'en' anyway — `emailLocale()` already +-- falls back to the default locale for a value it does not ship, which covers a +-- locale we later drop without a data migration. +-- +-- No CHECK constraint on the value: the set of locales is a product decision that +-- changes in `i18n/routing.ts`, and a database constraint that has to be migrated +-- in lockstep with a TypeScript array is one that will eventually disagree with +-- it. The read path is total (`emailLocale`), which is where the guarantee belongs. +-- +-- No index: it is read by id, never selected on. + +ALTER TABLE "User" ADD COLUMN "locale" TEXT NOT NULL DEFAULT 'en'; + +-- BACKFILL from the intakes, which is where the control plane has been keeping this +-- all along. Not cosmetic: the one live reseller applied in FRENCH +-- (`PartnerApplication.locale = 'fr'`, 2026-08-14), and the trial-ending sweep +-- already writes to him in French by looking his address up in that table at send +-- time. Without this, his account would default to 'en' and every OTHER mail — +-- invite re-send, password reset, invoice — would arrive in a language he did not +-- choose, while one mail arrived in the language he did. Two answers to the same +-- question is worse than one wrong one. +-- +-- `DISTINCT ON (lower(email)) … ORDER BY … "createdAt" DESC` takes the MOST RECENT +-- intake per address: someone who applied twice means the second one. +-- +-- Role-matched on both sides: an application makes a PARTNER and a signup makes an +-- OWNER (ADR-004), and matching on the address alone would let a founder's own test +-- signup rewrite an admin account's language. +-- +-- No locale-value filter here on purpose. The set of shipped locales lives in +-- `i18n/routing.ts`, and `emailLocale()` already falls back to the default for a +-- value it does not recognise — so a locale we later drop degrades to English at +-- READ time rather than needing a data migration on the day it is dropped. + +UPDATE "User" u +SET "locale" = a."locale" +FROM ( + SELECT DISTINCT ON (lower("email")) lower("email") AS "email", "locale" + FROM "PartnerApplication" + ORDER BY lower("email"), "createdAt" DESC +) a +WHERE lower(u."email") = a."email" AND u."role" = 'PARTNER'; + +UPDATE "User" u +SET "locale" = s."locale" +FROM ( + SELECT DISTINCT ON (lower("email")) lower("email") AS "email", "locale" + FROM "SignupRequest" + ORDER BY lower("email"), "createdAt" DESC +) s +WHERE lower(u."email") = s."email" AND u."role" = 'OWNER'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8bdb7c2..a5abf3b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,18 +72,24 @@ model User { role UserRole @default(PARTNER) status UserStatus @default(INVITED) createdAt DateTime @default(now()) - - profile PartnerProfile? - clients Client[] - notes ClientNote[] - commissions CommissionEntry[] @relation("PartnerCommissions") - createdEntries CommissionEntry[] @relation("EntryAuthor") - inviteTokens InviteToken[] - auditLogs AuditLog[] + /// The language this person is written to in (G9). Seeded from the intake that + /// created the account (`SignupRequest.locale` / `PartnerApplication.locale`) + /// and refreshed when they set their password, which is the one moment they are + /// certainly present with a language chosen. Defaults to `en`, which is what + /// every account created before this column actually received. + locale String @default("en") + + profile PartnerProfile? + clients Client[] + notes ClientNote[] + commissions CommissionEntry[] @relation("PartnerCommissions") + createdEntries CommissionEntry[] @relation("EntryAuthor") + inviteTokens InviteToken[] + auditLogs AuditLog[] // Tenants this user pays for directly (OWNER self-serve; ADR-004). - billingsPaid TenantBilling[] @relation("BillingPayer") + billingsPaid TenantBilling[] @relation("BillingPayer") // Base domains a PARTNER has claimed for their own clients (D1). - baseDomains PartnerDomain[] + baseDomains PartnerDomain[] // The legal entity this user is invoiced as (B1). One per user: a party has // one set of registration details at a time. billingIdentity BillingIdentity? @@ -237,12 +243,12 @@ model Invoice { tenantSlug String - currency String @default("EUR") - netCents Int - vatCents Int - grossCents Int + currency String @default("EUR") + netCents Int + vatCents Int + grossCents Int /// Basis points: 2100 = 21%, 0 = reverse charge or outside scope. - vatRateBps Int + vatRateBps Int /// The lib/tax-treatment.ts verdict, stored as the string it decided. taxTreatment String /// The exact sentence printed on the document, e.g. "BTW verlegd …". Stored @@ -267,11 +273,11 @@ model Invoice { } model InvoiceLine { - id String @id @default(cuid()) + id String @id @default(cuid()) invoiceId String - invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) description String - quantity Int @default(1) + quantity Int @default(1) unitCents Int netCents Int /// The service period this line covers, when it is a subscription charge. @@ -328,7 +334,7 @@ model PartnerDomain { /// When control was last PROVEN. Null = claimed but unusable — every surface /// treats an unverified base domain as if it were not there. - verifiedAt DateTime? + verifiedAt DateTime? /// When we last looked, whatever the answer. Separates "never checked" from /// "checked, still not published", which is the difference between a partner who /// has not started and one who is stuck. @@ -454,7 +460,7 @@ model TenantBilling { // Not unique on purpose — the state is unreachable and the constraint would only add // a P2002 surface to the signup transaction. See the migration for the full reasoning. signupRequestId String? - signupRequest SignupRequest? @relation(fields: [signupRequestId], references: [id], onDelete: SetNull) + signupRequest SignupRequest? @relation(fields: [signupRequestId], references: [id], onDelete: SetNull) // Who this subscription is INVOICED to (B1). Nullable because every row that // existed before this column has no identity and none can be invented for it — // an absent identity is the honest state, and it is what blocks invoicing. @@ -649,22 +655,22 @@ model BackupInventory { /// longer have a registry entry, and a FK would delete exactly the departed /// customer's backup this feature exists to preserve. model BackupArtifact { - id String @id @default(cuid()) - box String - tenantSlug String - kind BackupKind - location BackupLocation - ref String // restic snapshot id, or a dump filename - takenAt DateTime - sizeBytes BigInt // BigInt, not Int: Int caps at 2.1 GB, which a tenant dump will pass - sha256 String? - firstSeenAt DateTime @default(now()) + id String @id @default(cuid()) + box String + tenantSlug String + kind BackupKind + location BackupLocation + ref String // restic snapshot id, or a dump filename + takenAt DateTime + sizeBytes BigInt // BigInt, not Int: Int caps at 2.1 GB, which a tenant dump will pass + sha256 String? + firstSeenAt DateTime @default(now()) /// Mark-and-sweep marker: set to the ingest time on EVERY push that still /// lists this artifact. The whole-box push then deletes this box's rows that /// were not re-marked — which is how an artifact the box no longer holds /// disappears here without the ingest having to name what is missing. - lastSeenAt DateTime @default(now()) - updatedAt DateTime @updatedAt + lastSeenAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Identity of an artifact on the wire. `ref` alone is not unique — a local // filename can repeat across boxes, and the same restic id can appear in two diff --git a/tests/e2e/helpers/db.ts b/tests/e2e/helpers/db.ts index eac424a..ecd13df 100644 --- a/tests/e2e/helpers/db.ts +++ b/tests/e2e/helpers/db.ts @@ -40,6 +40,8 @@ export type OwnerRow = { role: string; status: string; hasPassword: boolean; + /** The language this person is written to in (G9). */ + locale: string; }; export async function findUser(email: string): Promise { @@ -49,14 +51,22 @@ export async function findUser(email: string): Promise { role: string; status: string; has_password: boolean; + locale: string; }>( - `SELECT id, email, role, status, ("passwordHash" IS NOT NULL) AS has_password + `SELECT id, email, role, status, locale, ("passwordHash" IS NOT NULL) AS has_password FROM "User" WHERE email = $1`, [email.toLowerCase()], ); const r = rows[0]; return r - ? { id: r.id, email: r.email, role: r.role, status: r.status, hasPassword: r.has_password } + ? { + id: r.id, + email: r.email, + role: r.role, + status: r.status, + hasPassword: r.has_password, + locale: r.locale, + } : null; } diff --git a/tests/e2e/helpers/flows.ts b/tests/e2e/helpers/flows.ts index ed4fe12..1c090e1 100644 --- a/tests/e2e/helpers/flows.ts +++ b/tests/e2e/helpers/flows.ts @@ -39,9 +39,13 @@ export async function submitSignup( contactName?: string; modules?: string[]; city?: string; + /** Which language the visitor fills the form in. The form carries it, the lead + * stores it, and since G9 so does the ACCOUNT — which is what decides the + * language of every mail that follows. */ + locale?: string; }, ): Promise { - await page.goto("/en/signup", { waitUntil: "domcontentloaded" }); + await page.goto(`/${opts.locale ?? "en"}/signup`, { waitUntil: "domcontentloaded" }); await page.fill('input[name="restaurantName"]', opts.restaurantName ?? "E2E Restaurant"); await page.fill('input[name="contactName"]', opts.contactName ?? "E2E Owner"); await page.fill('input[name="email"]', opts.email); @@ -65,8 +69,21 @@ export async function submitSignup( * regression test: restore the old unconditional message and every one of these * specs fails. */ -export async function expectAccountCreated(page: Page): Promise { - await expect(page.getByText(/our welcome email didn't get through/i)).toBeVisible(); +export async function expectAccountCreated( + page: Page, + opts: { locale?: string } = {}, +): Promise { + // The same sentence, in the language the visitor filled the form in. Matched per + // locale rather than loosened to a shared substring: a signup submitted on + // `/fr/signup` that answered in English would be the exact G9 defect this suite + // is here to catch, and a laxer matcher would pass through it. + const SUCCESS_NO_EMAIL: Record = { + en: /our welcome email didn't get through/i, + fr: /notre e-mail de bienvenue n'est pas parti/i, + }; + const pattern = SUCCESS_NO_EMAIL[opts.locale ?? "en"]; + if (!pattern) throw new Error(`no success-copy matcher for locale ${opts.locale}`); + await expect(page.getByText(pattern)).toBeVisible(); } /** diff --git a/tests/e2e/self-serve-signup.spec.ts b/tests/e2e/self-serve-signup.spec.ts index 7bcd7ef..966d6d8 100644 --- a/tests/e2e/self-serve-signup.spec.ts +++ b/tests/e2e/self-serve-signup.spec.ts @@ -61,6 +61,8 @@ test.describe("the public signup creates a payable account", () => { expect(user!.status).toBe("INVITED"); expect(user!.hasPassword).toBe(false); expect(await countInviteTokens(user!.id)).toBe(1); + // Filled in English, so English it is (G9). The interesting half is below. + expect(user!.locale).toBe("en"); const plan = await findPlan(slug); expect(plan, "the signup must have created a plan").not.toBeNull(); @@ -365,3 +367,23 @@ test.describe("the founder can see which mails did not get delivered (G16)", () await expect(lead).toContainText(/admin\/onboard/i); }); }); + +test.describe("the account remembers which language the customer speaks (G9)", () => { + test("a signup filled in French mints a French account", async ({ page }) => { + // Not a cosmetic assertion: `User.locale` is what the invite, the re-send, the + // password reset and the invoice are written in. Before it existed, the lead row + // held the language and nothing addressed to the PERSON could reach it — so a + // francophone owner in Geneva got one mail in French (the trial warning, which + // looked its language up by email in the intake table) and every other mail in + // English. Two answers to the same question, from one control plane. + const slug = uniq.slug("fr"); + const email = uniq.email("fr"); + + await submitSignup(page, { slug, email, restaurantName: "Chez E2E", locale: "fr" }); + await expectAccountCreated(page, { locale: "fr" }); + + const user = await findUser(email); + expect(user, "the signup must have created a user").not.toBeNull(); + expect(user!.locale).toBe("fr"); + }); +}); diff --git a/tests/unit/email-locale.test.ts b/tests/unit/email-locale.test.ts index 24321aa..3b1fd03 100644 --- a/tests/unit/email-locale.test.ts +++ b/tests/unit/email-locale.test.ts @@ -2,6 +2,15 @@ import { describe, expect, it } from "vitest"; import { emailLocale, emailTranslator } from "@/lib/email-locale"; import { routing } from "@/i18n/routing"; +/** Every leaf key under a message block, dotted — the same walk the parity gate does. */ +function leafKeys(obj: Record, prefix = ""): string[] { + return Object.entries(obj).flatMap(([k, v]) => + v && typeof v === "object" && !Array.isArray(v) + ? leafKeys(v as Record, `${prefix}${k}.`) + : [`${prefix}${k}`], + ); +} + describe("emailLocale", () => { it("takes the first candidate we actually ship", () => { expect(emailLocale("fr")).toBe("fr"); @@ -51,3 +60,83 @@ describe("emailTranslator", () => { expect(t("interval.month")).toBe("maand"); }); }); + +describe("emailTranslator — every customer-facing mail, in every locale we ship (G9)", () => { + // The parity gate proves the KEYS exist in all six files. This proves they + // RESOLVE and INTERPOLATE: an ICU syntax error — a stray brace, or an apostrophe + // ICU reads as quoting, which French and Turkish copy is full of — is a mail that + // THROWS at send time, in the one language nobody on the team reads. The invite is + // the worst place for that: it is the only route into an account with no password. + const NAMESPACES = [ + "emails.invite", + "emails.partnerApproved", + "emails.reset", + "emails.invoice", + "emails.billingDetails", + ] as const; + + const VALUES = { + name: "Amara", + restaurant: "Chez Amara", + number: "SP-2026-0007", + amount: "€85.00", + }; + + it("renders every key of every namespace with no key left raw", async () => { + for (const locale of routing.locales) { + for (const namespace of NAMESPACES) { + const messages = (await import(`../../messages/${locale}.json`)).default; + const block = namespace + .split(".") + .reduce>((o, k) => o[k] as Record, messages); + const t = await emailTranslator(locale, namespace); + for (const key of leafKeys(block)) { + const rendered = t(key, VALUES); + expect(rendered, `${locale} ${namespace}.${key}`).not.toContain("{"); + expect(rendered.trim(), `${locale} ${namespace}.${key}`).not.toHaveLength(0); + } + } + } + }); + + it("keeps the values in the sentence, in every language", async () => { + for (const locale of routing.locales) { + const invite = await emailTranslator(locale, "emails.invite"); + expect(invite("lead.setPassword", VALUES), locale).toContain("Chez Amara"); + expect(invite("greeting", VALUES), locale).toContain("Amara"); + const invoice = await emailTranslator(locale, "emails.invoice"); + expect(invoice("subject", VALUES), locale).toContain("SP-2026-0007"); + const billing = await emailTranslator(locale, "emails.billingDetails"); + expect(billing("received", VALUES), locale).toContain("€85.00"); + } + }); + + it("keeps the brand in Latin script and the sign-off intact", async () => { + // "SofraPiwas" is a name, including inside the Arabic sentence, and + // "afiyet olsun" is the brand's sign-off rather than a phrase to translate. + for (const locale of routing.locales) { + const invite = await emailTranslator(locale, "emails.invite"); + expect(invite("kicker"), locale).toContain("SofraPiwas"); + expect(invite("lead.setPassword", VALUES), locale).toContain("afiyet olsun"); + } + }); + + it("keeps every subject short enough to survive an inbox list", async () => { + // A subject that is truncated at the interesting word is a subject nobody + // reads. 70 characters is the narrowest common mobile client. + for (const locale of routing.locales) { + const t = await emailTranslator(locale, "emails.billingDetails"); + expect(t("subject").length, `${locale} billingDetails.subject`).toBeLessThanOrEqual(70); + const invite = await emailTranslator(locale, "emails.invite"); + expect(invite("subject.setPassword").length, `${locale} invite.subject`).toBeLessThanOrEqual(70); + } + }); + + it("distinguishes the partner's reset sentence from everyone else's (G10)", async () => { + for (const locale of routing.locales) { + const t = await emailTranslator(locale, "emails.reset"); + expect(t("lead.partner"), locale).not.toBe(t("lead.account")); + expect(t("kicker.partner"), locale).not.toBe(t("kicker.account")); + } + }); +}); diff --git a/tests/unit/payer-contact.test.ts b/tests/unit/payer-contact.test.ts index 439a939..c132662 100644 --- a/tests/unit/payer-contact.test.ts +++ b/tests/unit/payer-contact.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { payerAddress, payerGreetingName, type PayerContact } from "@/lib/payer-contact"; +import { + payerAddress, + payerGreetingName, + payerLocale, + type PayerContact, +} from "@/lib/payer-contact"; const base = (over: Partial = {}): PayerContact => ({ email: "restaurant@example.com", @@ -54,3 +59,38 @@ describe("payerGreetingName", () => { expect(payerGreetingName(base({ payer: { name: null, email: "a@e.com" } }))).toBe("Chez Amara"); }); }); + +describe("payerLocale — the language a bill is written in (G9)", () => { + it("follows the PARTNER on a reseller plan, in the same order as the address", () => { + // The partner is who pays and who reads it. The restaurant's own language is + // not consulted at all, for the same reason its address is not. + expect( + payerLocale( + base({ + client: { partner: { name: "Mustafa", email: "m@partner.example", locale: "fr" } }, + payer: { name: "Amara", email: "amara@example.com", locale: "nl" }, + }), + ), + ).toBe("fr"); + }); + + it("follows the direct owner when there is no reseller", () => { + expect( + payerLocale(base({ payer: { name: "Amara", email: "amara@example.com", locale: "tr" } })), + ).toBe("tr"); + }); + + it("returns null when the plan carries no user at all", () => { + // An admin-typed address and nothing else. `emailLocale` turns this into + // English — a BillingIdentity records a country, and a country is not a + // language: a Belgian company may read French, Dutch or English. + expect(payerLocale(base({ client: null, payer: null }))).toBeNull(); + expect(payerLocale(base({ billingIdentity: { billingEmail: "a@b.example" } }))).toBeNull(); + }); + + it("is null rather than empty when the selection omitted the column", () => { + // A caller that selects only `email` must not silently resolve to "" and then + // fail `hasLocale`, which would read as a deliberate choice of English. + expect(payerLocale(base({ payer: { name: "Amara", email: "amara@example.com" } }))).toBeNull(); + }); +});