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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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(); + }); +}); 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..7862516 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", @@ -81,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",