Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions app/api/signup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const FOUNDER_FALLBACK_NOTES: Record<SelfServeFallback, string> = {
*/
async function mintAccount(
outcome: Extract<SelfServeOutcome, { kind: "account" }>,
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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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] };
Expand Down
20 changes: 14 additions & 6 deletions app/api/waitlist/route.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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<string, { subject: string; kicker: string; title: string }> = {
demo: { subject: "Demo request", kicker: "New request", title: "Someone wants a demo" },
Expand All @@ -22,12 +30,12 @@ const INTENTS: Record<string, { subject: string; kicker: string; title: string }
};

export async function POST(request: Request) {
let body: Record<string, unknown>;
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();
Expand Down
24 changes: 17 additions & 7 deletions lib/actions/admin-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
},
Expand All @@ -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: `<p style="margin:0 0 12px;">Hi ${escapeHtml(user.name)},</p>
<p style="margin:0;">Your SofraPiwas partner application is approved. Set your password to open your partner dashboard — afiyet olsun.</p>`,
cta: { label: "Set your password", url: inviteLink },
footerNote: "The link works once and expires in 24 hours.",
kicker: t("kicker"),
title: t("title"),
bodyHtml: `<p style="margin:0 0 12px;">${greeting}</p>
<p style="margin:0;">${t("lead")}</p>`,
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,
Expand Down
50 changes: 31 additions & 19 deletions lib/actions/auth-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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() } }),
]);
Expand All @@ -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: `<p style="margin:0 0 12px;">Hi ${escapeHtml(user.name)},</p>
<p style="margin:0;">Someone (hopefully you) asked to reset your SofraPiwas partner password. If this wasn't you, you can safely ignore this email.</p>`,
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;
Expand Down Expand Up @@ -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 —
Expand Down
10 changes: 6 additions & 4 deletions lib/actions/onboarding-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const needsPassword = user.status === "INVITED";
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions lib/billing-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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("")),
});
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Loading
Loading