From 87bac9d4d27b3c095ccbc1eeda41dd9888c53dfe Mon Sep 17 00:00:00 2001
From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com>
Date: Sun, 30 Aug 2026 17:24:37 +0200
Subject: [PATCH 1/3] feat(partner): record a partner's public brand, published
to nobody yet (S1) (#204)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(partner): record a partner's public brand, published to nobody yet (S1)
A PARTNER can enter and edit the public name and contact details their resold
restaurants could be credited to, and the founder can read them on /admin/partners/[id].
Nothing is published. No tenant site reads PartnerBrand; publishToTenants defaults
to false, is read by nothing, and the checkbox ships disabled with copy saying so —
the publishing half is gated on the owner decision in SOFRA-PARTNER-PLAN 11e.
The record is deliberately separate from BillingIdentity, which is the private legal
and tax record: for a sole trader its legalName is a natural person and its address
is a home address. prefillFromBillingIdentity carries across the trade name and
nothing else; renderableBrand() is the single choke point that returns null for an
unpublished brand.
* chore(partner): take the two SonarCloud smells on the brand slice
S7754: .filter(Boolean).length > 0 becomes a joined string tested for truthiness,
which the address line beside it already did.
S6582: !brand || !brand.publishToTenants becomes !brand?.publishToTenants — same
three cases (null, undefined, opted out), one expression.
---
app/(control)/admin/partners/[id]/page.tsx | 52 +++++
app/(control)/dashboard/brand/page.tsx | 61 ++++++
app/(control)/dashboard/layout.tsx | 5 +
components/control/PartnerBrandForm.tsx | 112 ++++++++++
lib/actions/partner-brand-actions.ts | 94 ++++++++
lib/partner-brand.ts | 178 +++++++++++++++
messages/ar.json | 30 ++-
messages/de.json | 30 ++-
messages/en.json | 30 ++-
messages/fr.json | 30 ++-
messages/nl.json | 30 ++-
messages/tr.json | 30 ++-
.../migration.sql | 52 +++++
prisma/schema.prisma | 64 ++++++
tests/e2e/helpers/db.ts | 42 ++++
tests/e2e/partner-brand.spec.ts | 117 ++++++++++
tests/unit/partner-brand.test.ts | 207 ++++++++++++++++++
vitest.config.ts | 6 +
18 files changed, 1164 insertions(+), 6 deletions(-)
create mode 100644 app/(control)/dashboard/brand/page.tsx
create mode 100644 components/control/PartnerBrandForm.tsx
create mode 100644 lib/actions/partner-brand-actions.ts
create mode 100644 lib/partner-brand.ts
create mode 100644 prisma/migrations/20260830120000_partner_brand/migration.sql
create mode 100644 tests/e2e/partner-brand.spec.ts
create mode 100644 tests/unit/partner-brand.test.ts
diff --git a/app/(control)/admin/partners/[id]/page.tsx b/app/(control)/admin/partners/[id]/page.tsx
index ef0b2df..f6b4bf8 100644
--- a/app/(control)/admin/partners/[id]/page.tsx
+++ b/app/(control)/admin/partners/[id]/page.tsx
@@ -17,6 +17,10 @@ export default async function AdminPartnerDetailPage({
await requireAdmin();
const locale = await controlLocale();
const t = await getTranslations({ locale, namespace: "control.admin.partnerDetail" });
+ // The partner-facing namespace, reused here on purpose: the founder should read
+ // the same words for these fields that the partner does, or the two pages drift
+ // into describing the same record differently (§11).
+ const tb = await getTranslations({ locale, namespace: "control.brand" });
const { id } = await params;
const partner = await db.user.findFirst({
where: { id, role: "PARTNER" },
@@ -29,11 +33,27 @@ export default async function AdminPartnerDetailPage({
// surfaced rather than acted on — nothing auto-revokes (lib/base-domain-
// verification.ts). This is where "is that still theirs?" gets asked.
baseDomains: { orderBy: [{ verifiedAt: "desc" }, { createdAt: "desc" }] },
+ // Read-only here (§11). The founder needs to see what a partner would be
+ // published AS — and, more to the point, whether they have ASKED to be
+ // published — before the owner decision in §11e is taken. Nothing on this
+ // page writes it: the record belongs to the partner.
+ brand: true,
},
});
if (!partner) notFound();
const balance = partner.commissions.reduce((s, c) => s + c.amountCents, 0);
+ // Joined once each so an all-empty line renders nothing at all rather than a row
+ // of separators.
+ const contact = [partner.brand?.email, partner.brand?.phone].filter(Boolean).join(" · ");
+ const address = [
+ partner.brand?.addressLine1,
+ partner.brand?.postalCode,
+ partner.brand?.city,
+ partner.brand?.countryCode,
+ ]
+ .filter(Boolean)
+ .join(" · ");
// One clock for the whole render, so two domains never disagree about staleness.
const now = new Date();
@@ -97,6 +117,38 @@ export default async function AdminPartnerDetailPage({
+
+
{tb("adminTitle")}
+ {partner.brand ? (
+
+ {partner.brand.displayName}
+ {partner.brand.tagline && (
+ {partner.brand.tagline}
+ )}
+ {partner.brand.websiteUrl && (
+ // `rel="noopener noreferrer"` because this is a partner-supplied
+ // address opened from an admin page; the https-only check at the write
+ // schema is the other half (lib/partner-brand.ts).
+
+ {partner.brand.websiteUrl}
+
+ )}
+ {contact && {contact}}
+ {address.length > 0 && {address}}
+
+ {tb(partner.brand.publishToTenants ? "adminPublishAsked" : "adminPublishOff")}
+
+
+ ) : (
+
{tb("adminEmpty")}
+ )}
+
+
{t("ledger")}
diff --git a/app/(control)/dashboard/brand/page.tsx b/app/(control)/dashboard/brand/page.tsx
new file mode 100644
index 0000000..6835036
--- /dev/null
+++ b/app/(control)/dashboard/brand/page.tsx
@@ -0,0 +1,61 @@
+import { getTranslations } from "next-intl/server";
+import { requirePartner } from "@/lib/rbac";
+import { controlLocale } from "@/lib/control-locale";
+import { db } from "@/lib/db";
+import { prefillFromBillingIdentity } from "@/lib/partner-brand";
+import PartnerBrandForm from "@/components/control/PartnerBrandForm";
+
+/**
+ * `/dashboard/brand` — the partner's own public details (SOFRA-PARTNER-PLAN §11).
+ *
+ * `requirePartner()`, not `requirePartnerOrOwner()`, for the reason
+ * `/dashboard/domains` states: a direct restaurant owner has one tenant and it is
+ * their own, so there is no third party for them to be credited as.
+ *
+ * Reads are scoped by `partner.id` on both queries — a partner can only ever load
+ * their own brand and their own billing identity.
+ *
+ * Never cached: it renders a row the same request may have just written, and the
+ * row is per-user.
+ */
+export const dynamic = "force-dynamic";
+
+export default async function PartnerBrandPage() {
+ const partner = await requirePartner();
+ const locale = await controlLocale();
+ const t = await getTranslations({ locale, namespace: "control.brand" });
+
+ const brand = await db.partnerBrand.findUnique({ where: { partnerId: partner.id } });
+
+ // Only consulted when there is no brand yet, and only for a NAME. Looked up by
+ // `userId` — the simple link, which is the right one here: this page is about a
+ // PARTY, not about one plan, so the plan-scoped `resolveIdentityForPlan` would be
+ // answering a question nobody asked. Nothing from this record is stored by
+ // loading the page; the partner sees the value in an editable field and saves it
+ // themselves (lib/partner-brand.ts explains why only this one field crosses).
+ const prefill = brand
+ ? null
+ : prefillFromBillingIdentity(
+ await db.billingIdentity.findUnique({
+ where: { userId: partner.id },
+ select: { legalName: true, tradeName: true },
+ }),
+ );
+
+ return (
+
+
+
{t("title")}
+
{t("intro")}
+
{t("privacyNote")}
+
+
+
+ {!brand && prefill && (
+
{t("prefillNote")}
+ )}
+
+
+
+ );
+}
diff --git a/app/(control)/dashboard/layout.tsx b/app/(control)/dashboard/layout.tsx
index c92b07f..0ab1396 100644
--- a/app/(control)/dashboard/layout.tsx
+++ b/app/(control)/dashboard/layout.tsx
@@ -48,6 +48,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
// let them claim their FIRST zone, so hiding it until they have one would hide
// the only way to get one (SOFRA-PARTNER-FLEXIBILITY-PLAN D1).
{ href: "/dashboard/domains", label: t("nav.domains") },
+ // Always present too, and for the same documented reason: the page's job is to
+ // let a partner enter their public details for the FIRST time, so gating the
+ // link on already having them would hide the only way to get them
+ // (SOFRA-PARTNER-PLAN §11).
+ { href: "/dashboard/brand", label: t("nav.brand") },
];
if (hasBilling) {
nav.push({ href: "/dashboard/billing", label: t("nav.plan") });
diff --git a/components/control/PartnerBrandForm.tsx b/components/control/PartnerBrandForm.tsx
new file mode 100644
index 0000000..ed134bc
--- /dev/null
+++ b/components/control/PartnerBrandForm.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { useActionState } from "react";
+import { useTranslations } from "next-intl";
+import {
+ savePartnerBrandAction,
+ type PartnerBrandState,
+} from "@/lib/actions/partner-brand-actions";
+import ActionError from "./ActionError";
+
+/** What the page hands in — either the stored row or, for a partner who has none
+ * yet, a prefill carrying nothing but a display name. */
+export type BrandDefaults = {
+ displayName: string;
+ tagline?: string | null;
+ websiteUrl?: string | null;
+ email?: string | null;
+ phone?: string | null;
+ addressLine1?: string | null;
+ postalCode?: string | null;
+ city?: string | null;
+ countryCode?: string | null;
+ publishToTenants?: boolean;
+};
+
+type TextField = Exclude;
+
+/**
+ * A partner's PUBLIC details (SOFRA-PARTNER-PLAN §11).
+ *
+ * Bound directly to the server action so it also works as a plain form POST with
+ * no JavaScript (CLAUDE.md §3).
+ *
+ * Everything here is typed by hand, including the address, and that is the
+ * design rather than an oversight: the control plane already holds an address for
+ * this partner, in `BillingIdentity`, and for a sole trader it is their home.
+ * Only `displayName` may be prefilled, from the TRADE name (lib/partner-brand.ts).
+ */
+export default function PartnerBrandForm({
+ defaults,
+}: Readonly<{ defaults?: BrandDefaults }>) {
+ const t = useTranslations("control.brand");
+ const [state, action, pending] = useActionState(
+ savePartnerBrandAction,
+ {},
+ );
+
+ const field = (
+ name: TextField,
+ opts: { required?: boolean; type?: string; maxLength?: number } = {},
+ ) => (
+
+ );
+
+ return (
+
+ );
+}
diff --git a/lib/actions/partner-brand-actions.ts b/lib/actions/partner-brand-actions.ts
new file mode 100644
index 0000000..a10849f
--- /dev/null
+++ b/lib/actions/partner-brand-actions.ts
@@ -0,0 +1,94 @@
+"use server";
+
+// A partner records the public brand their clients' guests may be shown
+// (SOFRA-PARTNER-PLAN §11).
+//
+// This slice STORES and EDITS. It publishes nothing: no tenant site reads
+// `PartnerBrand`, and `publishToTenants` is an intent flag whose only future
+// reader is `renderableBrand()` (§11e — the owner gate). The write path is built
+// now so that the day publishing is switched on, the details it publishes are
+// ones the partner typed knowing they were public — rather than the legal record,
+// which is a person's own name and home address for a sole trader.
+
+import { revalidatePath } from "next/cache";
+import { requirePartner } from "@/lib/rbac";
+import { db } from "@/lib/db";
+import { audit } from "@/lib/audit";
+import { rateLimit } from "@/lib/rate-limit";
+import { checkboxOn, partnerBrandSchema } from "@/lib/partner-brand";
+
+/** `error` is a message key in `control.errors` (rendered by );
+ * Zod issue messages pass through raw, as elsewhere in this directory. */
+export type PartnerBrandState = { error?: string; ok?: boolean };
+
+export async function savePartnerBrandAction(
+ _prev: PartnerBrandState,
+ formData: FormData,
+): Promise {
+ // `requirePartner()`, not `requirePartnerOrOwner()`: a brand is what a RESELLER
+ // shows on the restaurants they sell. A direct restaurant OWNER has one tenant,
+ // which is their own — there is no third party for them to be credited as, so
+ // this surface has nothing to offer them and they are bounced to /dashboard.
+ const partner = await requirePartner();
+
+ // Per `user.id` rather than per IP, matching the sibling partner actions: the
+ // actor is authenticated, so their id has no NAT collisions and no spoofable
+ // proxy header behind it.
+ if (!rateLimit(`partner-brand:${partner.id}`, 30, 15 * 60 * 1000)) {
+ return { error: "tooManyAttempts" };
+ }
+
+ const parsed = partnerBrandSchema.safeParse({
+ ...Object.fromEntries(formData),
+ // A checkbox is absent from FormData when unticked, so it can never be read
+ // straight off the payload as a boolean. Anything that is not "on"/"true" is
+ // off — the safe direction for a flag whose true value means "show this to
+ // the public".
+ publishToTenants: checkboxOn(formData.get("publishToTenants")),
+ });
+ if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "invalidInput" };
+ const input = parsed.data;
+
+ // An emptied field must be CLEARED, not left alone. The schema normalises a
+ // blank input to `undefined` (there is no "" state to store), and Prisma reads
+ // `undefined` in an update as "do not touch this column" — so passing the
+ // parsed object straight through would make deleting a tagline impossible: the
+ // partner would save, be told it worked, and reload to find the old line still
+ // there. `null` is the write that means "gone".
+ const data = {
+ displayName: input.displayName,
+ tagline: input.tagline ?? null,
+ websiteUrl: input.websiteUrl ?? null,
+ email: input.email ?? null,
+ phone: input.phone ?? null,
+ addressLine1: input.addressLine1 ?? null,
+ postalCode: input.postalCode ?? null,
+ city: input.city ?? null,
+ countryCode: input.countryCode ?? null,
+ publishToTenants: input.publishToTenants,
+ };
+
+ // The key comes from the SESSION and is never read from the payload. A
+ // `partnerId` field in the FormData would be an IDOR: any logged-in partner
+ // could rewrite another's public details, which is both a takeover of their
+ // identity and — once publishing exists — a way to put text on someone else's
+ // restaurant pages. `partnerId` is written LAST so that no spread can shadow
+ // it, even if the schema were later loosened to pass unknown keys through.
+ const row = await db.partnerBrand.upsert({
+ where: { partnerId: partner.id },
+ create: { ...data, partnerId: partner.id },
+ update: data,
+ });
+
+ // No brand FIELD is logged. They are contact details of a real company and, in
+ // the sole-trader case, of a real person (CLAUDE.md §5.8 — no PII in logs). What
+ // the audit row needs is that the record was written and whether the partner
+ // asked for it to be public; the values themselves are one `SELECT` away for
+ // anyone entitled to read them.
+ await audit(partner.id, "partner.brand.saved", "PartnerBrand", row.partnerId, {
+ publishToTenants: row.publishToTenants,
+ });
+
+ revalidatePath("/dashboard/brand");
+ return { ok: true };
+}
diff --git a/lib/partner-brand.ts b/lib/partner-brand.ts
new file mode 100644
index 0000000..77af13f
--- /dev/null
+++ b/lib/partner-brand.ts
@@ -0,0 +1,178 @@
+// A partner's PUBLIC brand — the pure rules about it (SOFRA-PARTNER-PLAN §11).
+//
+// Pure by construction: no `db` import, no I/O, nothing that can read a session.
+// Everything here is either "is this input acceptable" or "may this record be
+// shown", and both have to be answerable — and unit-testable — without a
+// database.
+//
+// The one idea the whole module exists to protect: **a public record and a legal
+// record are different records.** `BillingIdentity` holds the name a member state
+// registered and the address an invoice is posted to; for a sole trader those are
+// a person and their home. This holds what a diner may be shown. Nothing copies
+// the first into the second except `prefillFromBillingIdentity`, which carries one
+// field and says why.
+
+import { z } from "zod";
+import { isAssignedCountryCode } from "@/lib/country-code";
+
+/**
+ * An empty form field is ABSENT, not the empty string.
+ *
+ * A browser posts every text input it renders, so an untouched optional field
+ * arrives as `""`. Stored verbatim that would make `tagline: ""` and `tagline:
+ * null` two spellings of the same state, and every future reader would have to
+ * know both — the one that forgot would render an empty line in a footer.
+ */
+const blankToUndefined = (value: unknown): unknown =>
+ typeof value === "string" && value.trim() === "" ? undefined : value;
+
+const optionalText = (max: number) =>
+ z.preprocess(blankToUndefined, z.string().trim().max(max).optional());
+
+/**
+ * `https://` and nothing else.
+ *
+ * This value ends up in an `href` on a page served to the public, so the scheme
+ * is the security boundary, not a formatting preference. `javascript:` is script
+ * execution in the reader's page; `http:` is a downgrade we would be advertising
+ * on someone else's site. A bare host (`example.com`) is refused rather than
+ * silently prefixed: guessing a scheme for a partner is exactly how `http` gets
+ * published by accident, and the form asks for the full address.
+ *
+ * `URL` does the parsing, so no regex has to be right about hosts.
+ */
+function isHttpsUrl(value: string): boolean {
+ try {
+ return new URL(value).protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * What a partner must supply to record a public brand.
+ *
+ * `displayName` is the only required field, and deliberately so: it is the only
+ * one that has to exist for the record to mean anything, and demanding an address
+ * or a phone number would push a partner into typing their private ones back in.
+ *
+ * `countryCode` is checked for MEMBERSHIP of ISO 3166-1 alpha-2 through the SAME
+ * helper the billing schema uses (lib/country-code.ts) — one list, not two. It
+ * decides nothing about tax here (it is only ever displayed), but a second,
+ * drifting copy of the country list is the kind of thing that later gets consulted
+ * by something that does.
+ */
+export const partnerBrandSchema = z.object({
+ displayName: z.string().trim().min(1).max(80),
+ tagline: optionalText(120),
+ websiteUrl: z.preprocess(
+ blankToUndefined,
+ z
+ .string()
+ .trim()
+ .max(200)
+ .refine(isHttpsUrl, "Use a full address starting with https://")
+ .optional(),
+ ),
+ email: z.preprocess(blankToUndefined, z.string().trim().max(200).email().optional()),
+ phone: optionalText(40),
+ addressLine1: optionalText(200),
+ postalCode: optionalText(20),
+ city: optionalText(120),
+ countryCode: z.preprocess(
+ blankToUndefined,
+ z
+ .string()
+ .trim()
+ .toUpperCase()
+ .refine(isAssignedCountryCode, "2-letter ISO 3166-1 country code, e.g. CH")
+ .optional(),
+ ),
+ publishToTenants: z.boolean(),
+});
+
+export type PartnerBrandInput = z.infer;
+
+/** A checkbox is present-or-absent in FormData; anything else is off. Kept beside
+ * the schema so the form and the action cannot disagree about what "on" means. */
+export function checkboxOn(value: unknown): boolean {
+ return value === "on" || value === "true";
+}
+
+/** The subset of a billing identity a prefill may read. Structural rather than the
+ * Prisma type, so the tests need no generated client. */
+export type IdentityNames = { legalName: string; tradeName: string | null };
+
+/**
+ * The one field that may be carried from the legal record into the public one.
+ *
+ * `tradeName ?? legalName` and NOTHING ELSE, as a saving of typing on the field
+ * that is least likely to be private — a trade name is chosen to be shown. It is
+ * still only a DEFAULT in an editable input; the partner sees it before it is
+ * stored and can replace it.
+ *
+ * The address, the registration number and the VAT number are deliberately not
+ * here. Copying them would be the exact mistake the two-model split exists to
+ * prevent: for a sole trader the billing address is a home address and
+ * `legalName` is a natural person, and a prefilled field is one that gets saved
+ * without being read. The partner types the rest by hand, knowing it is public,
+ * and that act of typing IS the consent.
+ *
+ * The `legalName` fallback is the one concession, and it is bounded: a partner
+ * with no trade name sees their own name in a visible, editable field labelled
+ * as the public one, not silently published.
+ */
+export function prefillFromBillingIdentity(
+ identity: IdentityNames | null | undefined,
+): { displayName: string } | null {
+ const name = identity?.tradeName?.trim() || identity?.legalName?.trim();
+ return name ? { displayName: name } : null;
+}
+
+/** What is stored. Structural, for the same reason as `IdentityNames`. */
+export type StoredBrand = {
+ displayName: string;
+ tagline: string | null;
+ websiteUrl: string | null;
+ email: string | null;
+ phone: string | null;
+ addressLine1: string | null;
+ postalCode: string | null;
+ city: string | null;
+ countryCode: string | null;
+ publishToTenants: boolean;
+};
+
+/** Exactly the fields a public surface may render. */
+export type RenderableBrand = Omit;
+
+/**
+ * The ONLY way an unpublished brand can reach a public surface: it cannot.
+ *
+ * A single choke point on purpose. The alternative — every future caller
+ * remembering to test `publishToTenants` before it renders — is a rule that holds
+ * until the first caller that forgets, and the failure is silent: a partner who
+ * never opted in appears in a footer and nothing goes red. Here, forgetting means
+ * calling this function and getting `null`.
+ *
+ * It also RE-PROJECTS rather than spreading the row, so a column added to
+ * PartnerBrand later (a founder note, an internal flag) is not published by the
+ * mere fact of having been added.
+ *
+ * Nothing consumes this yet — the publishing half is gated on an owner decision.
+ * It ships now so that when something does, the gate is already the only door.
+ */
+export function renderableBrand(brand: StoredBrand | null | undefined): RenderableBrand | null {
+ if (!brand?.publishToTenants) return null;
+ return {
+ displayName: brand.displayName,
+ tagline: brand.tagline,
+ websiteUrl: brand.websiteUrl,
+ email: brand.email,
+ phone: brand.phone,
+ addressLine1: brand.addressLine1,
+ postalCode: brand.postalCode,
+ city: brand.city,
+ countryCode: brand.countryCode,
+ };
+}
diff --git a/messages/ar.json b/messages/ar.json
index 4144db0..b641dbf 100644
--- a/messages/ar.json
+++ b/messages/ar.json
@@ -482,7 +482,8 @@
"invoices": "الفواتير",
"icp": "ICP",
"billingDetails": "بيانات الفوترة",
- "domains": "النطاقات"
+ "domains": "النطاقات",
+ "brand": "العلامة"
},
"groups": {
"pipeline": "المسار",
@@ -1233,6 +1234,33 @@
"sending": "جارٍ الإرسال…",
"sent": "تم الإرسال — سنعود إليك."
},
+ "brand": {
+ "title": "بياناتك العامة",
+ "intro": "الاسم وبيانات الاتصال التي قد يراها ضيوف المطعم بوصفها الشركة التي تقف خلف موقعه. تكتبها هنا حتى لا يُنشر سوى ما تختاره أنت.",
+ "privacyNote": "هذه البيانات منفصلة عمداً عن بيانات الفوترة. يبقى عنوان الفاتورة ورقم السجل خاصين ولا يُعرضان لأحد غيرنا.",
+ "prefillNote": "ملأنا اسمك التجاري للبدء. لا يُنسخ أي شيء آخر من بيانات الفوترة — اكتب ما توافق على أن يراه الضيوف.",
+ "fields": {
+ "displayName": "الاسم المعروض",
+ "tagline": "شعار قصير (اختياري)",
+ "websiteUrl": "الموقع الإلكتروني (https://…)",
+ "email": "البريد الإلكتروني للتواصل",
+ "phone": "الهاتف",
+ "addressLine1": "العنوان",
+ "postalCode": "الرمز البريدي",
+ "city": "المدينة",
+ "countryCode": "الدولة (حرفان)"
+ },
+ "publishLabel": "اعرض هذه البيانات في تذييل كل موقع مطعم تبيعه.",
+ "publishMeaning": "سيراها ضيوف عملائك.",
+ "publishUnavailable": "غير متاح بعد — يبقى النشر معطّلاً إلى أن يفعّله مالك المنصة. تُحفظ بياناتك في كل الأحوال.",
+ "save": "حفظ",
+ "saving": "جارٍ الحفظ…",
+ "saved": "تم الحفظ.",
+ "adminTitle": "علامته التجارية العامة",
+ "adminEmpty": "لم تُدخل أي بيانات عامة.",
+ "adminPublishAsked": "طلب أن يظهر على مواقع مطاعمه.",
+ "adminPublishOff": "لم يطلب الظهور العلني."
+ },
"baseDomain": {
"title": "نطاقاتك الخاصة",
"intro": "ضَع المطاعم التي تبيعها تحت نطاق خاص بك — obresse.yourcompany.com بدلاً من obresse.sofrapiwas.com. أضِف النطاق هنا وأثبت ملكيته؛ بعدها يُعرض عليك مع كل عميل جديد.",
diff --git a/messages/de.json b/messages/de.json
index ab6fd5b..9e5d56f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -482,7 +482,8 @@
"invoices": "Rechnungen",
"icp": "ICP",
"billingDetails": "Rechnungsdaten",
- "domains": "Domains"
+ "domains": "Domains",
+ "brand": "Marke"
},
"groups": {
"pipeline": "Pipeline",
@@ -1233,6 +1234,33 @@
"sending": "Senden…",
"sent": "Gesendet — wir melden uns."
},
+ "brand": {
+ "title": "Ihre öffentlichen Angaben",
+ "intro": "Der Name und die Kontaktdaten, die Gäste eines Restaurants als das Unternehmen hinter der Website sehen könnten. Sie geben sie hier ein, damit nur das öffentlich wird, was Sie auswählen.",
+ "privacyNote": "Sie sind bewusst von Ihren Rechnungsdaten getrennt. Ihre Rechnungsadresse und Ihre Registernummer bleiben privat und werden niemandem außer uns gezeigt.",
+ "prefillNote": "Wir haben Ihren Handelsnamen als Anfang eingetragen. Sonst wird nichts aus Ihren Rechnungsdaten übernommen — bitte geben Sie ein, was Gäste sehen dürfen.",
+ "fields": {
+ "displayName": "Anzeigename",
+ "tagline": "Slogan (optional)",
+ "websiteUrl": "Website (https://…)",
+ "email": "Kontakt-E-Mail",
+ "phone": "Telefon",
+ "addressLine1": "Adresse",
+ "postalCode": "Postleitzahl",
+ "city": "Stadt",
+ "countryCode": "Land (2 Buchstaben)"
+ },
+ "publishLabel": "Diese Angaben in der Fußzeile jeder Restaurant-Website zeigen, die Sie weiterverkaufen.",
+ "publishMeaning": "Die Gäste Ihrer Kunden sehen sie.",
+ "publishUnavailable": "Noch nicht verfügbar — die Veröffentlichung bleibt aus, bis der Plattformbetreiber sie freigibt. Ihre Angaben werden in jedem Fall gespeichert.",
+ "save": "Speichern",
+ "saving": "Speichern…",
+ "saved": "Gespeichert.",
+ "adminTitle": "Ihre öffentliche Marke",
+ "adminEmpty": "Keine öffentlichen Angaben eingegeben.",
+ "adminPublishAsked": "Möchte auf den Websites der eigenen Restaurants gezeigt werden.",
+ "adminPublishOff": "Hat nicht darum gebeten, öffentlich gezeigt zu werden."
+ },
"baseDomain": {
"title": "Ihre eigenen Domains",
"intro": "Bringen Sie die Restaurants, die Sie verkaufen, unter eine eigene Domain — obresse.ihrefirma.com statt obresse.sofrapiwas.com. Domain hier hinzufügen und nachweisen, dass sie Ihnen gehört; danach wird sie bei jedem neuen Kunden angeboten.",
diff --git a/messages/en.json b/messages/en.json
index 4800792..1dcd5fb 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -482,7 +482,8 @@
"invoices": "Invoices",
"icp": "ICP",
"billingDetails": "Billing details",
- "domains": "Domains"
+ "domains": "Domains",
+ "brand": "Brand"
},
"groups": {
"pipeline": "Pipeline",
@@ -1233,6 +1234,33 @@
"sending": "Sending…",
"sent": "Sent — we'll come back to you."
},
+ "brand": {
+ "title": "Your public details",
+ "intro": "The name and contact details a restaurant's guests could be shown as the company behind their website. You type them here, so only what you choose is ever public.",
+ "privacyNote": "These are kept separate from your billing details on purpose. Your invoice address and registration number stay private and are never shown to anyone but us.",
+ "prefillNote": "We have filled in your trading name to start you off. Nothing else is copied from your billing details — please type what you are happy for guests to see.",
+ "fields": {
+ "displayName": "Display name",
+ "tagline": "Tagline (optional)",
+ "websiteUrl": "Website (https://…)",
+ "email": "Contact email",
+ "phone": "Phone",
+ "addressLine1": "Address",
+ "postalCode": "Postcode",
+ "city": "City",
+ "countryCode": "Country (2 letters)"
+ },
+ "publishLabel": "Show these details in the footer of every restaurant site you resell.",
+ "publishMeaning": "Your customers' guests will see them.",
+ "publishUnavailable": "Not available yet — publishing stays off until the platform owner enables it. Your details are saved either way.",
+ "save": "Save",
+ "saving": "Saving…",
+ "saved": "Saved.",
+ "adminTitle": "Their public brand",
+ "adminEmpty": "No public details entered.",
+ "adminPublishAsked": "Asked to be shown on their restaurants' sites.",
+ "adminPublishOff": "Has not asked to be shown publicly."
+ },
"baseDomain": {
"title": "Your own domains",
"intro": "Put the restaurants you sell under a domain of your own — obresse.yourcompany.com instead of obresse.sofrapiwas.com. Add the domain here and prove it is yours; from then on it is offered every time you set up a new client.",
diff --git a/messages/fr.json b/messages/fr.json
index 201d2e5..5e592a8 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -482,7 +482,8 @@
"invoices": "Factures",
"icp": "ICP",
"billingDetails": "Infos de facturation",
- "domains": "Domaines"
+ "domains": "Domaines",
+ "brand": "Marque"
},
"groups": {
"pipeline": "Pipeline",
@@ -1233,6 +1234,33 @@
"sending": "Envoi…",
"sent": "Envoyé — nous revenons vers vous."
},
+ "brand": {
+ "title": "Vos coordonnées publiques",
+ "intro": "Le nom et les coordonnées que les clients d'un restaurant pourraient voir comme l'entreprise derrière leur site. Vous les saisissez ici, afin que seul ce que vous choisissez soit public.",
+ "privacyNote": "Elles sont volontairement distinctes de vos données de facturation. Votre adresse de facturation et votre numéro d'enregistrement restent privés et ne sont montrés à personne d'autre que nous.",
+ "prefillNote": "Nous avons prérempli votre nom commercial pour commencer. Rien d'autre n'est copié depuis vos données de facturation — saisissez ce que vous acceptez de montrer aux clients.",
+ "fields": {
+ "displayName": "Nom affiché",
+ "tagline": "Slogan (facultatif)",
+ "websiteUrl": "Site web (https://…)",
+ "email": "E-mail de contact",
+ "phone": "Téléphone",
+ "addressLine1": "Adresse",
+ "postalCode": "Code postal",
+ "city": "Ville",
+ "countryCode": "Pays (2 lettres)"
+ },
+ "publishLabel": "Afficher ces coordonnées dans le pied de page de chaque site de restaurant que vous revendez.",
+ "publishMeaning": "Les clients de vos restaurants les verront.",
+ "publishUnavailable": "Pas encore disponible — la publication reste désactivée jusqu'à ce que le propriétaire de la plateforme l'active. Vos coordonnées sont enregistrées dans tous les cas.",
+ "save": "Enregistrer",
+ "saving": "Enregistrement…",
+ "saved": "Enregistré.",
+ "adminTitle": "Sa marque publique",
+ "adminEmpty": "Aucune coordonnée publique saisie.",
+ "adminPublishAsked": "A demandé à être affiché sur les sites de ses restaurants.",
+ "adminPublishOff": "N'a pas demandé à être affiché publiquement."
+ },
"baseDomain": {
"title": "Vos propres domaines",
"intro": "Placez les restaurants que vous vendez sous un domaine qui vous appartient — obresse.votresociete.com au lieu de obresse.sofrapiwas.com. Ajoutez le domaine ici et prouvez qu'il est à vous ; il vous sera ensuite proposé à chaque nouveau client.",
diff --git a/messages/nl.json b/messages/nl.json
index 9978180..9749f3c 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -482,7 +482,8 @@
"invoices": "Facturen",
"icp": "ICP",
"billingDetails": "Factuurgegevens",
- "domains": "Domeinen"
+ "domains": "Domeinen",
+ "brand": "Merk"
},
"groups": {
"pipeline": "Pijplijn",
@@ -1233,6 +1234,33 @@
"sending": "Versturen…",
"sent": "Verstuurd — we komen bij je terug."
},
+ "brand": {
+ "title": "Uw publieke gegevens",
+ "intro": "De naam en contactgegevens die gasten van een restaurant kunnen zien als het bedrijf achter hun website. U typt ze hier, zodat alleen wat u kiest openbaar wordt.",
+ "privacyNote": "Deze staan bewust los van uw factuurgegevens. Uw factuuradres en registratienummer blijven privé en worden aan niemand anders dan ons getoond.",
+ "prefillNote": "We hebben uw handelsnaam alvast ingevuld. Verder wordt er niets uit uw factuurgegevens overgenomen — typ wat u gasten wilt laten zien.",
+ "fields": {
+ "displayName": "Weergavenaam",
+ "tagline": "Slogan (optioneel)",
+ "websiteUrl": "Website (https://…)",
+ "email": "Contact-e-mail",
+ "phone": "Telefoon",
+ "addressLine1": "Adres",
+ "postalCode": "Postcode",
+ "city": "Plaats",
+ "countryCode": "Land (2 letters)"
+ },
+ "publishLabel": "Toon deze gegevens in de voettekst van elke restaurantsite die u doorverkoopt.",
+ "publishMeaning": "De gasten van uw klanten zien ze.",
+ "publishUnavailable": "Nog niet beschikbaar — publiceren blijft uit tot de eigenaar van het platform het inschakelt. Uw gegevens worden hoe dan ook bewaard.",
+ "save": "Opslaan",
+ "saving": "Opslaan…",
+ "saved": "Opgeslagen.",
+ "adminTitle": "Hun publieke merk",
+ "adminEmpty": "Geen publieke gegevens ingevuld.",
+ "adminPublishAsked": "Wil getoond worden op de sites van hun restaurants.",
+ "adminPublishOff": "Heeft niet gevraagd om publiek getoond te worden."
+ },
"baseDomain": {
"title": "Je eigen domeinen",
"intro": "Zet de restaurants die je verkoopt onder een eigen domein — obresse.jouwbedrijf.com in plaats van obresse.sofrapiwas.com. Voeg het domein hier toe en bewijs dat het van jou is; daarna wordt het aangeboden bij elke nieuwe klant.",
diff --git a/messages/tr.json b/messages/tr.json
index e5ce338..82bdf75 100644
--- a/messages/tr.json
+++ b/messages/tr.json
@@ -482,7 +482,8 @@
"invoices": "Faturalar",
"icp": "ICP",
"billingDetails": "Fatura bilgileri",
- "domains": "Alan adları"
+ "domains": "Alan adları",
+ "brand": "Marka"
},
"groups": {
"pipeline": "Süreç",
@@ -1233,6 +1234,33 @@
"sending": "Gönderiliyor…",
"sent": "Gönderildi — size döneceğiz."
},
+ "brand": {
+ "title": "Herkese açık bilgileriniz",
+ "intro": "Bir restoranın misafirlerinin, sitelerinin arkasındaki şirket olarak görebileceği ad ve iletişim bilgileri. Yalnızca seçtikleriniz herkese açık olsun diye bunları burada siz yazarsınız.",
+ "privacyNote": "Bunlar fatura bilgilerinizden bilerek ayrı tutulur. Fatura adresiniz ve sicil numaranız gizli kalır ve bizden başka kimseye gösterilmez.",
+ "prefillNote": "Başlangıç olarak ticari adınızı doldurduk. Fatura bilgilerinizden başka hiçbir şey kopyalanmaz — misafirlerin görmesini istediğiniz bilgileri yazın.",
+ "fields": {
+ "displayName": "Görünen ad",
+ "tagline": "Slogan (isteğe bağlı)",
+ "websiteUrl": "Web sitesi (https://…)",
+ "email": "İletişim e-postası",
+ "phone": "Telefon",
+ "addressLine1": "Adres",
+ "postalCode": "Posta kodu",
+ "city": "Şehir",
+ "countryCode": "Ülke (2 harf)"
+ },
+ "publishLabel": "Bu bilgileri, sattığınız her restoran sitesinin alt bilgisinde gösterin.",
+ "publishMeaning": "Müşterilerinizin misafirleri bunları görecek.",
+ "publishUnavailable": "Henüz kullanılamıyor — platform sahibi etkinleştirene kadar yayınlama kapalı kalır. Bilgileriniz yine de kaydedilir.",
+ "save": "Kaydet",
+ "saving": "Kaydediliyor…",
+ "saved": "Kaydedildi.",
+ "adminTitle": "Herkese açık markası",
+ "adminEmpty": "Herkese açık bilgi girilmemiş.",
+ "adminPublishAsked": "Restoranlarının sitelerinde gösterilmek istiyor.",
+ "adminPublishOff": "Herkese açık gösterilmeyi istemedi."
+ },
"baseDomain": {
"title": "Kendi alan adlarınız",
"intro": "Sattığınız restoranları kendi alan adınızın altına alın — obresse.sofrapiwas.com yerine obresse.sirketiniz.com. Alan adını buraya ekleyin ve size ait olduğunu kanıtlayın; sonrasında her yeni müşteride seçenek olarak sunulur.",
diff --git a/prisma/migrations/20260830120000_partner_brand/migration.sql b/prisma/migrations/20260830120000_partner_brand/migration.sql
new file mode 100644
index 0000000..21aa7d2
--- /dev/null
+++ b/prisma/migrations/20260830120000_partner_brand/migration.sql
@@ -0,0 +1,52 @@
+-- The public face of a partner (workspace docs/plans/SOFRA-PARTNER-PLAN.md §11).
+--
+-- A partner asked to appear in the footer of the restaurant sites they resell.
+-- The control plane already held company data about them — but ONLY as
+-- `BillingIdentity`, which is the private legal and tax record: the registered
+-- name, the invoice address, a registration number, a VAT number. For a sole
+-- trader (the shape of the first reseller) that name is a NATURAL PERSON and
+-- that address is where they live. Reusing it would have published someone's
+-- home address on a public website in order to save them typing.
+--
+-- So this is a SECOND, PUBLIC record, entered by hand, never joined to the
+-- first. Only what is typed here can ever be shown. The two tables stay apart so
+-- that publishing a brand cannot publish an invoice address.
+--
+-- `publishToTenants` DEFAULT FALSE, and nothing reads it yet: the publishing
+-- half is gated on an owner decision about what a diner may be told about a
+-- third party. The column records the partner's intent rather than leaving it to
+-- be inferred later, and the app renders the switch disabled until there is
+-- something to switch on.
+--
+-- ADDITIVE AND SAFE ON A DB WITH LIVE ROWS: one new table, no column added to,
+-- renamed on, or dropped from any existing one, no backfill, no data rewrite.
+-- Nothing reads it until the surfaces in this PR do, so applying it before
+-- rolling the app is a no-op for every running query plan.
+--
+-- PRIMARY KEY is `partnerId` itself, not a surrogate id: a partner has exactly
+-- one public brand, and the identity of the row IS the partner. That is also what
+-- makes the write an `upsert` on a key the server takes from the session, so no
+-- request can name the row it edits.
+
+CREATE TABLE "PartnerBrand" (
+ "partnerId" TEXT NOT NULL,
+ "displayName" TEXT NOT NULL,
+ "tagline" TEXT,
+ "websiteUrl" TEXT,
+ "email" TEXT,
+ "phone" TEXT,
+ "addressLine1" TEXT,
+ "postalCode" TEXT,
+ "city" TEXT,
+ "countryCode" TEXT,
+ "publishToTenants" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "PartnerBrand_pkey" PRIMARY KEY ("partnerId")
+);
+
+-- CASCADE: a brand is meaningless without the partner it belongs to, and a
+-- departed partner's public details must not outlive their account.
+ALTER TABLE "PartnerBrand" ADD CONSTRAINT "PartnerBrand_partnerId_fkey"
+ FOREIGN KEY ("partnerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a5abf3b..cbfbe04 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -90,6 +90,9 @@ model User {
billingsPaid TenantBilling[] @relation("BillingPayer")
// Base domains a PARTNER has claimed for their own clients (D1).
baseDomains PartnerDomain[]
+ // The PUBLIC brand a PARTNER may show to their clients' guests (§11). Kept
+ // apart from `billingIdentity` below on purpose — see PartnerBrand.
+ brand PartnerBrand?
// 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?
@@ -346,6 +349,67 @@ model PartnerDomain {
@@index([partnerId])
}
+// The PUBLIC face of a partner — what a diner may be shown
+// (workspace docs/plans/SOFRA-PARTNER-PLAN.md §11, slice S1).
+//
+// This is a DIFFERENT THING from `BillingIdentity`, which sits a hundred lines
+// above it, and the separation is the entire point of the model rather than a
+// tidiness preference. BillingIdentity is the PRIVATE legal and tax record: the
+// name a member state registered, the address an invoice is posted to, a
+// registration number, a VAT number. For a sole trader — the shape of the first
+// reseller — `legalName` is a NATURAL PERSON and `addressLine1` is where that
+// person lives. Publishing it because it happened to be the only company data we
+// held would put someone's home address in the footer of a restaurant website.
+//
+// So a partner types their public details a SECOND time, deliberately, and the
+// two records are never joined. A field can only ever become public by being
+// entered here. `prefillFromBillingIdentity` (lib/partner-brand.ts) carries
+// across exactly ONE field, the trade name, and documents why it carries nothing
+// else.
+//
+// Its own model rather than columns on PartnerProfile, for the reason
+// PartnerDomain states below: PartnerProfile is a founder-facing CRM row that
+// carries `notes`, which are never rendered to the partner — a founder-facing
+// row is the wrong home for a field whose audience is the public.
+//
+// `publishToTenants` defaults to FALSE and **NOTHING CONSUMES IT YET.** No tenant
+// site reads this table; the publishing half is gated on an owner decision about
+// what a restaurant's guests may be shown about a third party. The column exists
+// so the partner's intent is recorded rather than inferred later, and the single
+// reader that will ever exist is `renderableBrand()`.
+model PartnerBrand {
+ partnerId String @id
+ partner User @relation(fields: [partnerId], references: [id], onDelete: Cascade)
+
+ /// The brand as a DINER reads it ("Solution Eva"). Not BillingIdentity.legalName,
+ /// which for a sole trader is a person's own name.
+ displayName String
+ /// One line under the name. Optional; most partners will not want one.
+ tagline String?
+ /// `https://` only — see lib/partner-brand.ts. A public link that a tenant page
+ /// would render, so the scheme is validated rather than assumed.
+ websiteUrl String?
+
+ /// Contact a guest may be given. A WORK address by intent: the partner typed it
+ /// here knowing it is public, which is precisely what the billing record's
+ /// equivalents were never told.
+ email String?
+ phone String?
+ addressLine1 String?
+ postalCode String?
+ city String?
+ /// ISO 3166-1 alpha-2, UPPERCASE when present. Same membership check as the
+ /// billing record (lib/country-code.ts), but it decides nothing about tax here —
+ /// it is only ever displayed.
+ countryCode String?
+
+ /// Opt-in, off by default, and READ BY NOTHING TODAY. See the note above.
+ publishToTenants Boolean @default(false)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
model Client {
id String @id @default(cuid())
partnerId String
diff --git a/tests/e2e/helpers/db.ts b/tests/e2e/helpers/db.ts
index ecd13df..cbe87f2 100644
--- a/tests/e2e/helpers/db.ts
+++ b/tests/e2e/helpers/db.ts
@@ -706,3 +706,45 @@ export async function findBackupJobs(tenantSlug: string): Promise {
+ const rows = await query<{ displayName: string; city: string | null; publish: boolean }>(
+ `SELECT "displayName", city, "publishToTenants" AS publish
+ FROM "PartnerBrand" WHERE "partnerId" = $1`,
+ [partnerId],
+ );
+ return rows[0] ?? null;
+}
+
+/** An existing brand, so the edit path is exercised rather than the create path. */
+export async function arrangePartnerBrand(
+ partnerId: string,
+ displayName: string,
+): Promise {
+ await query(
+ `INSERT INTO "PartnerBrand" ("partnerId", "displayName", "updatedAt")
+ VALUES ($1, $2, now())
+ ON CONFLICT ("partnerId") DO UPDATE SET "displayName" = EXCLUDED."displayName"`,
+ [partnerId, displayName],
+ );
+}
+
+/** The legal record, so the prefill can be shown to carry the TRADE name and
+ * nothing else — the address below is what must never appear on the brand page. */
+export async function arrangeBillingIdentityFor(
+ userId: string,
+ opts: { legalName: string; tradeName?: string | null; addressLine1: string },
+): Promise {
+ await query(
+ `INSERT INTO "BillingIdentity"
+ (id, "userId", "legalName", "tradeName", "addressLine1", "postalCode", city,
+ "countryCode", "billingEmail", "updatedAt")
+ VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '1204', 'Genève', 'CH',
+ 'billing@example.com', now())`,
+ [userId, opts.legalName, opts.tradeName ?? null, opts.addressLine1],
+ );
+}
diff --git a/tests/e2e/partner-brand.spec.ts b/tests/e2e/partner-brand.spec.ts
new file mode 100644
index 0000000..038a536
--- /dev/null
+++ b/tests/e2e/partner-brand.spec.ts
@@ -0,0 +1,117 @@
+import { expect, test } from "./helpers/fixtures";
+import { RUN_ID, login, uniq } from "./helpers/flows";
+import {
+ arrangeBillingIdentityFor,
+ arrangePartnerBrand,
+ arrangePartnerUser,
+ findPartnerBrand,
+} from "./helpers/db";
+
+// A partner recording the PUBLIC details they may one day be shown by
+// (SOFRA-PARTNER-PLAN §11).
+//
+// What this suite proves: the details are stored, the prefill carries the trade
+// name and NOT the billing address, the publish switch is present but inert, and
+// one partner's save cannot reach another partner's row even when the payload
+// says it should.
+//
+// What it deliberately does NOT try to prove: that anything is published. Nothing
+// consumes `publishToTenants` yet — that is §11e, an open owner decision — so
+// there is no rendered footer to assert against, and a test that pretended
+// otherwise would be asserting a feature that does not exist.
+
+// Derived from the run id rather than written as a literal, for the reason
+// partner-trial.spec.ts documents: a `const PASSWORD = "…"` line is a gitleaks
+// `generic-api-key` hit (entropy, not meaning), and a scan that cries wolf on a
+// test fixture is a scan people learn to skip past.
+const PASSWORD = `e2e-brand-${RUN_ID}`;
+
+test("a partner saves their public details, and the publish switch is inert", async ({ page }) => {
+ const email = uniq.email("brandsave");
+ const partnerId = await arrangePartnerUser(email, PASSWORD);
+
+ await login(page, { email, password: PASSWORD });
+ await page.waitForURL("**/dashboard");
+ await page.getByRole("link", { name: /brand/i }).first().click();
+ await page.waitForURL("**/dashboard/brand");
+
+ await page.getByLabel(/display name/i).fill("Solution Eva");
+ await page.getByLabel(/^city$/i).fill("Genève");
+ await page.getByLabel(/website/i).fill("https://solutioneva.com");
+
+ // Present, so the partner can see what is being decided about them — and
+ // disabled, because nothing consumes it. An enabled switch that changed nothing
+ // would be a lie told to the one person entitled to decide this.
+ const publish = page.getByRole("checkbox");
+ await expect(publish).toBeDisabled();
+ await expect(page.getByText(/not available yet/i)).toBeVisible();
+
+ await page.getByRole("button", { name: /^save$/i }).click();
+ await expect(page.getByText(/^saved\.$/i)).toBeVisible();
+
+ expect(await findPartnerBrand(partnerId)).toMatchObject({
+ displayName: "Solution Eva",
+ city: "Genève",
+ // A disabled checkbox is not submitted, and the action reads an absent one as
+ // false. Nothing a partner can do on this page turns publishing on today.
+ publish: false,
+ });
+});
+
+test("the prefill carries the trade name and NOT the billing address", async ({ page }) => {
+ const email = uniq.email("brandprefill");
+ const partnerId = await arrangePartnerUser(email, PASSWORD);
+ await arrangeBillingIdentityFor(partnerId, {
+ legalName: "Eva Obresse",
+ tradeName: "Solution Eva",
+ // A home address, which is what a sole trader's billing record actually is.
+ // It must not appear on a page whose whole subject is what may become public.
+ addressLine1: "Chemin Privé 7",
+ });
+
+ await login(page, { email, password: PASSWORD });
+ await page.waitForURL("**/dashboard");
+ await page.goto("/dashboard/brand");
+
+ await expect(page.getByLabel(/display name/i)).toHaveValue("Solution Eva");
+ await expect(page.getByLabel(/^address$/i)).toHaveValue("");
+ await expect(page.getByLabel(/^postcode$/i)).toHaveValue("");
+ await expect(page.getByLabel(/^city$/i)).toHaveValue("");
+ // Not merely absent from the fields — absent from the page.
+ await expect(page.getByText("Chemin Privé 7")).toHaveCount(0);
+ // And nothing was stored by the mere act of looking: a prefill is a default in
+ // an editable box, not a write.
+ expect(await findPartnerBrand(partnerId)).toBeNull();
+});
+
+test("a partner cannot write another partner's brand, even by naming them", async ({ page }) => {
+ const mine = uniq.email("brandmine");
+ const theirs = uniq.email("brandtheirs");
+ const mineId = await arrangePartnerUser(mine, PASSWORD);
+ const theirsId = await arrangePartnerUser(theirs, PASSWORD);
+ await arrangePartnerBrand(theirsId, "Their Brand");
+
+ await login(page, { email: mine, password: PASSWORD });
+ await page.waitForURL("**/dashboard");
+ await page.goto("/dashboard/brand");
+
+ // Their id is INJECTED into the payload — the shape of the attack the action is
+ // written against. The key comes from the session, so the field is ignored; if
+ // it were ever read, this is the request that would take over another company's
+ // public identity.
+ await page.evaluate((victimId) => {
+ const form = document.querySelector("form");
+ const field = document.createElement("input");
+ field.type = "hidden";
+ field.name = "partnerId";
+ field.value = victimId;
+ form?.appendChild(field);
+ }, theirsId);
+
+ await page.getByLabel(/display name/i).fill("My Brand");
+ await page.getByRole("button", { name: /^save$/i }).click();
+ await expect(page.getByText(/^saved\.$/i)).toBeVisible();
+
+ expect(await findPartnerBrand(mineId)).toMatchObject({ displayName: "My Brand" });
+ expect(await findPartnerBrand(theirsId)).toMatchObject({ displayName: "Their Brand" });
+});
diff --git a/tests/unit/partner-brand.test.ts b/tests/unit/partner-brand.test.ts
new file mode 100644
index 0000000..ca54390
--- /dev/null
+++ b/tests/unit/partner-brand.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from "vitest";
+import {
+ checkboxOn,
+ partnerBrandSchema,
+ prefillFromBillingIdentity,
+ renderableBrand,
+ type StoredBrand,
+} from "@/lib/partner-brand";
+
+// The pure half of a partner's PUBLIC brand (SOFRA-PARTNER-PLAN §11).
+//
+// Two things are being defended here and they are different. The schema decides
+// what a partner is ALLOWED TO STORE — the interesting cases are the refusals,
+// because an accepted `javascript:` URL becomes an `href` on a stranger's
+// restaurant page. `renderableBrand` decides what may be SHOWN, and its whole
+// value is a negative: a complete, well-filled record that was never opted in
+// must come back as `null`.
+
+const parse = (over: Record = {}) =>
+ partnerBrandSchema.safeParse({ displayName: "Solution Eva", publishToTenants: false, ...over });
+
+const full: StoredBrand = {
+ displayName: "Solution Eva",
+ tagline: "Restaurant software, set up for you",
+ websiteUrl: "https://solutioneva.com",
+ email: "hello@solutioneva.com",
+ phone: "+41 22 000 00 00",
+ addressLine1: "Rue du Rhône 1",
+ postalCode: "1204",
+ city: "Genève",
+ countryCode: "CH",
+ publishToTenants: true,
+};
+
+describe("partnerBrandSchema — displayName", () => {
+ it("accepts a plain brand name", () => {
+ const r = parse();
+ expect(r.success && r.data.displayName).toBe("Solution Eva");
+ });
+ it("trims", () => {
+ const r = parse({ displayName: " Solution Eva " });
+ expect(r.success && r.data.displayName).toBe("Solution Eva");
+ });
+ it("refuses an empty name — the record means nothing without one", () => {
+ expect(parse({ displayName: "" }).success).toBe(false);
+ });
+ it("refuses whitespace posing as a name", () => {
+ expect(parse({ displayName: " " }).success).toBe(false);
+ });
+ it("refuses over 80 characters", () => {
+ expect(parse({ displayName: "a".repeat(81) }).success).toBe(false);
+ });
+});
+
+describe("partnerBrandSchema — websiteUrl is https or nothing", () => {
+ it("accepts an https address", () => {
+ const r = parse({ websiteUrl: "https://solutioneva.com" });
+ expect(r.success && r.data.websiteUrl).toBe("https://solutioneva.com");
+ });
+ it("accepts one with a path", () => {
+ expect(parse({ websiteUrl: "https://solutioneva.com/fr/contact" }).success).toBe(true);
+ });
+ // The three refusals that matter, and why each is not a style preference:
+ it("refuses http — we would be advertising a downgrade on someone else's site", () => {
+ expect(parse({ websiteUrl: "http://solutioneva.com" }).success).toBe(false);
+ });
+ it("refuses javascript: — that is script execution in a reader's page", () => {
+ expect(parse({ websiteUrl: "javascript:alert(1)" }).success).toBe(false);
+ });
+ it("refuses a bare host rather than guessing a scheme for it", () => {
+ expect(parse({ websiteUrl: "solutioneva.com" }).success).toBe(false);
+ });
+ it("refuses data: too", () => {
+ expect(parse({ websiteUrl: "data:text/html," }).success).toBe(false);
+ });
+});
+
+describe("partnerBrandSchema — countryCode is a COUNTRY, not two letters", () => {
+ it("accepts CH", () => {
+ const r = parse({ countryCode: "CH" });
+ expect(r.success && r.data.countryCode).toBe("CH");
+ });
+ it("uppercases and trims", () => {
+ const r = parse({ countryCode: " ch " });
+ expect(r.success && r.data.countryCode).toBe("CH");
+ });
+ // The same negative control `lib/country-code.ts` was written for: `SW` is
+ // assigned to nothing, and it sat on a live record for nine days looking fine.
+ it("refuses SW, which is not assigned to anything", () => {
+ expect(parse({ countryCode: "SW" }).success).toBe(false);
+ });
+ it("refuses UK, the common mistake for GB", () => {
+ expect(parse({ countryCode: "UK" }).success).toBe(false);
+ });
+ it("refuses a three-letter code", () => {
+ expect(parse({ countryCode: "CHE" }).success).toBe(false);
+ });
+});
+
+describe("partnerBrandSchema — an untouched field is ABSENT, not empty", () => {
+ it("turns every blank optional into undefined", () => {
+ const r = parse({
+ tagline: "",
+ websiteUrl: " ",
+ email: "",
+ phone: "",
+ addressLine1: "",
+ postalCode: "",
+ city: "",
+ countryCode: " ",
+ });
+ expect(r.success).toBe(true);
+ if (!r.success) return;
+ for (const key of [
+ "tagline",
+ "websiteUrl",
+ "email",
+ "phone",
+ "addressLine1",
+ "postalCode",
+ "city",
+ "countryCode",
+ ] as const) {
+ expect(r.data[key], `${key} should be undefined, not ""`).toBeUndefined();
+ }
+ });
+ it("still refuses a malformed email when one IS given", () => {
+ expect(parse({ email: "not-an-address" }).success).toBe(false);
+ });
+ it("caps an over-long optional field", () => {
+ expect(parse({ tagline: "a".repeat(121) }).success).toBe(false);
+ });
+ it("requires publishToTenants to be a boolean, never a form string", () => {
+ expect(parse({ publishToTenants: "on" }).success).toBe(false);
+ });
+});
+
+describe("checkboxOn", () => {
+ it("reads a ticked box", () => expect(checkboxOn("on")).toBe(true));
+ it("reads the explicit string", () => expect(checkboxOn("true")).toBe(true));
+ it("an absent box is off", () => expect(checkboxOn(null)).toBe(false));
+ it("anything else is off — the safe direction for a publish flag", () => {
+ expect(checkboxOn("yes")).toBe(false);
+ expect(checkboxOn("1")).toBe(false);
+ expect(checkboxOn(true)).toBe(false);
+ });
+});
+
+describe("prefillFromBillingIdentity — one field crosses, and only one", () => {
+ it("takes the trade name", () => {
+ expect(prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: "Solution Eva" })).toEqual(
+ { displayName: "Solution Eva" },
+ );
+ });
+ it("falls back to the legal name when there is no trade name", () => {
+ expect(prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: null })).toEqual({
+ displayName: "Eva Obresse",
+ });
+ });
+ it("ignores a blank trade name rather than prefilling an empty box", () => {
+ expect(prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: " " })).toEqual({
+ displayName: "Eva Obresse",
+ });
+ });
+ it("returns null when there is no identity at all", () => {
+ expect(prefillFromBillingIdentity(null)).toBeNull();
+ expect(prefillFromBillingIdentity(undefined)).toBeNull();
+ expect(prefillFromBillingIdentity({ legalName: " ", tradeName: null })).toBeNull();
+ });
+ // The load-bearing assertion of the whole two-model split: a prefill carries a
+ // NAME and cannot carry an address, because there is nowhere in its result for
+ // one to go.
+ it("carries exactly one key, so no address can ride along", () => {
+ const out = prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: "Solution Eva" });
+ expect(Object.keys(out ?? {})).toEqual(["displayName"]);
+ });
+});
+
+describe("renderableBrand — the single door", () => {
+ it("passes a published brand through, field for field", () => {
+ expect(renderableBrand(full)).toEqual({
+ displayName: full.displayName,
+ tagline: full.tagline,
+ websiteUrl: full.websiteUrl,
+ email: full.email,
+ phone: full.phone,
+ addressLine1: full.addressLine1,
+ postalCode: full.postalCode,
+ city: full.city,
+ countryCode: full.countryCode,
+ });
+ });
+ it("never leaks the flag itself into the projection", () => {
+ expect(renderableBrand(full)).not.toHaveProperty("publishToTenants");
+ });
+ // THE negative control. A record with every field filled in — the one a naive
+ // caller would happily render — comes back as null purely because nobody opted
+ // in. If this ever returns an object, a partner who never consented is in a
+ // stranger's footer and nothing else in the system goes red.
+ it("returns null for an UNPUBLISHED brand carrying full data", () => {
+ expect(renderableBrand({ ...full, publishToTenants: false })).toBeNull();
+ });
+ it("returns null when there is no brand", () => {
+ expect(renderableBrand(null)).toBeNull();
+ expect(renderableBrand(undefined)).toBeNull();
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 7862516..1d1eaac 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -109,6 +109,12 @@ export default defineConfig({
// and whether a resolver's answer actually proves control of it. The transport
// (`base-domain-dns.ts`) stays out, same split as vies/vies-result.
"lib/base-domain.ts",
+ // §11 — a partner's PUBLIC brand. In scope because its whole job is a
+ // negative: `renderableBrand` is the single door an unpublished record
+ // must not get through, and a leak there is silent by construction — it
+ // looks like a footer with a name in it. The https-only and ISO-country
+ // refusals are decidable here too, and nowhere cheaper.
+ "lib/partner-brand.ts",
"lib/base-domain-verification.ts",
// D2 — which of the four domain shapes a partner proposed, and what DNS it
// needs. Pure by construction: it cannot see whose base domain it was handed or
From 48091a55495c58308907bea34680bdff6d5f945e Mon Sep 17 00:00:00 2001
From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com>
Date: Sun, 30 Aug 2026 17:53:42 +0200
Subject: [PATCH 2/3] feat(partner): publish a partner's brand into the
registry entry, and refuse their legal name (S3a) (#205)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(partner): publish a partner's brand into the registry entry, and refuse their legal name (S3a)
`renderableBrand` is tightened to the D-B1 projection — `displayName` plus, when
there is one, `websiteUrl`, and nothing else — and now takes the partner's LEGAL
name as well, because D-B1a is a rule about the relationship between the two
records: a display name that IS the legal name is refused, compared normalised
(trim, case-fold, collapse whitespace). `prefillFromBillingIdentity` offers
`tradeName ?? legalName`, so a sole trader who saves the form untouched would
otherwise have their personal name published on a restaurant's public page by a
convenience nobody decided on.
Dropping tagline/email/phone/address/postcode/city/country from the projection is
a TIGHTENING, so it is self-announcing: the unit assertions that named those
fields went red and were rewritten deliberately. Widening back to a contact block
is the silent direction and needs its own controls plus a pii-inventory row.
`buildTenantRegistryEntry` emits `partner_name:` and `partner_url:` as flat keys
(§11d2 — provision-tenant.sh flattens each value with `str(v)`, so a nested map
would arrive in the shell as a stringified Python dict). It never emits
`partner_attribution:`: absent means true (D-B2), so an always-present key would
be a no-op line on every entry — it is the restaurant's switch, hand-added on
their behalf. ABSENCE IS THE CONTRACT, exactly as `base_domain` documents it, and
the proof is that every pre-existing test of that function passes unchanged.
The input field is typed as `renderableBrand`'s OUTPUT, so the choke point is the
only way a name can reach an entry, and it is filled in `openProvisioningPr`
rather than by each caller: the credit is derived from the slug — nobody types it
— so resolving it where the entry is written means no caller can forget it and
none can inject one. The lookup matches the slug through `Client.tenantSlug` OR
the reseller's plan, because the former is set by an admin only AFTER
provisioning; it fails open to no credit.
The form now says why, before the save: a billing record with no trade name gets
a note, and a display name echoing the legal name is called out as not
publishable while the save still succeeds — it is their record; what is refused
is publishing it. The provisioning PR body says when an entry carries a credit,
because that PR is the founder's review checkpoint (ADR-012) and it is the moment
someone should notice a name is about to become public.
Two files were split at the 200-LOC limit rather than baselined
(partner-brand-publish.ts, provisioning-pr-blocks.ts); both are in the coverage
floor's include list, so the split moved no code out of scope.
Plan: SOFRA-PARTNER-PLAN §11e, slice S3a.
* fix(a11y): use