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..40f5b82
--- /dev/null
+++ b/app/(control)/dashboard/brand/page.tsx
@@ -0,0 +1,71 @@
+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).
+ // Read whether or not there is a brand already, because it now answers TWO
+ // questions and only the first is about prefilling. The second is D-B1a: a
+ // display name that IS the legal name will not be published, so the form has to
+ // know the legal name to be able to say so — and a partner whose record has no
+ // TRADE name is the one who needs telling before they type, since the only other
+ // name we hold is their own. Nothing from this record is stored by loading the
+ // page, and none of it is rendered: `legalName` reaches the client as a
+ // comparison input, which is a value the partner typed into their own billing
+ // record and is being shown their own copy of.
+ const identity = await db.billingIdentity.findUnique({
+ where: { userId: partner.id },
+ select: { legalName: true, tradeName: true },
+ });
+ const prefill = brand ? null : prefillFromBillingIdentity(identity);
+
+ 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..ef64625
--- /dev/null
+++ b/components/control/PartnerBrandForm.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import { useTranslations } from "next-intl";
+import {
+ savePartnerBrandAction,
+ type PartnerBrandState,
+} from "@/lib/actions/partner-brand-actions";
+import { isLegalNameEcho } from "@/lib/partner-brand";
+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,
+ legalName,
+ hasTradeName,
+}: Readonly<{ defaults?: BrandDefaults; legalName?: string | null; hasTradeName?: boolean }>) {
+ const t = useTranslations("control.brand");
+ const [state, action, pending] = useActionState(
+ savePartnerBrandAction,
+ {},
+ );
+
+ // Watched, so the partner is told BEFORE they save rather than after — and the
+ // same predicate the publish choke point uses (lib/partner-brand.ts), so the UI
+ // cannot promise something `renderableBrand` would then refuse. Saving is still
+ // allowed: it is their record, and refusing the write would leave them with no way
+ // to record their own name. What is refused is PUBLISHING it, and the note says so
+ // rather than the form accepting it silently and dropping it later (D-B1a).
+ const [displayName, setDisplayName] = useState(defaults?.displayName ?? "");
+ const echoesLegalName = isLegalNameEcho(displayName, legalName);
+
+ 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-lookup.ts b/lib/partner-brand-lookup.ts
new file mode 100644
index 0000000..1a4fa93
--- /dev/null
+++ b/lib/partner-brand-lookup.ts
@@ -0,0 +1,54 @@
+// Which partner, if any, may be credited in a tenant's footer (SOFRA-PARTNER-PLAN
+// §11e). The DB half of the feature, deliberately separate from the pure rules in
+// lib/partner-brand.ts: this file only GATHERS the two records, and the decision
+// stays behind `renderableBrand`, which is the one door.
+//
+// Out of the vitest coverage floor for the reason `vies.ts` is — it queries — while
+// everything it can get wrong about publishing is decided in a module that is in.
+
+import { db } from "@/lib/db";
+import { renderableBrand, type RenderableBrand } from "@/lib/partner-brand";
+
+/**
+ * The publishable brand of the partner who sold this tenant, or `undefined`.
+ *
+ * `undefined`, not `null`, because that is what `TenantProvisionInput.partnerBrand`
+ * wants: absence is the contract there, and an entry with no credit must be
+ * byte-identical to one generated before the field existed.
+ *
+ * The slug is matched two ways on purpose. `Client.tenantSlug` is set by an ADMIN
+ * *after* provisioning, so at the moment a registry PR is proposed it is usually
+ * still null; the reseller's PLAN, however, already names the slug. Looking only at
+ * `Client.tenantSlug` would therefore have credited nobody on exactly the path this
+ * feature exists for, and the failure would have been silent — an entry with no
+ * partner keys reads the same as a partner who never opted in.
+ *
+ * Fails OPEN, to no credit: a database hiccup here must not stop a paid customer's
+ * tenant being proposed, and the cost of the safe direction is a missing footer line
+ * that a re-provision restores. The opposite direction is unrecoverable in kind —
+ * publishing a name because a lookup misfired.
+ */
+export async function tenantPartnerBrand(tenantSlug: string): Promise {
+ try {
+ const client = await db.client.findFirst({
+ where: { OR: [{ tenantSlug }, { billing: { tenantSlug } }] },
+ select: { partnerId: true },
+ });
+ if (!client) return undefined;
+
+ // The legal name is fetched for ONE purpose — the D-B1a refusal — and never
+ // travels further: `renderableBrand` compares with it and does not return it.
+ const [brand, identity] = await Promise.all([
+ db.partnerBrand.findUnique({ where: { partnerId: client.partnerId } }),
+ db.billingIdentity.findUnique({
+ where: { userId: client.partnerId },
+ select: { legalName: true },
+ }),
+ ]);
+ return renderableBrand(brand, { legalName: identity?.legalName }) ?? undefined;
+ } catch (e) {
+ // Slug only: a brand is a company's, and for a sole trader a person's (§5.8).
+ console.error("tenantPartnerBrand failed; proposing without a credit", tenantSlug, e);
+ return undefined;
+ }
+}
diff --git a/lib/partner-brand-publish.ts b/lib/partner-brand-publish.ts
new file mode 100644
index 0000000..9195908
--- /dev/null
+++ b/lib/partner-brand-publish.ts
@@ -0,0 +1,117 @@
+// A partner's PUBLIC brand — the PUBLISH half (SOFRA-PARTNER-PLAN §11e).
+//
+// Split out of lib/partner-brand.ts when the pair outgrew one file's LOC limit
+// (CLAUDE.md §4), along the line the feature already had: that file says what a
+// partner may STORE, this one says what may be SHOWN. Both stay pure — no `db`,
+// no I/O, nothing that can read a session — and both stay in the coverage floor,
+// listed explicitly in vitest.config.ts so the split moved no code out of scope.
+//
+// `@/lib/partner-brand` re-exports everything here, so it remains the one door
+// callers need to know about.
+
+/**
+ * `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.
+ */
+export function isHttpsUrl(value: string): boolean {
+ try {
+ return new URL(value).protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+/** 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 what a public surface may render — an ATTRIBUTION, not a contact block
+ * (SOFRA-PARTNER-PLAN D-B1): a name and, when there is one, a link — *"Site by
+ * "*. The address, phone, email and tagline this used to carry are
+ * deliberately GONE. Dropping fields is the self-announcing direction (the
+ * assertions naming them go red); widening back to a contact block is the silent
+ * one and needs its own controls plus a `pii-inventory.md` row (D-B4) — a sole
+ * trader's business email and phone are still personal data.
+ *
+ * `websiteUrl` is ABSENT rather than null, so this is already the shape the
+ * registry generator consumes: a key it does not emit.
+ */
+export type RenderableBrand = { displayName: string; websiteUrl?: string };
+
+/** trim · case-fold · collapse internal whitespace. Comparison only — nothing
+ * normalised here is ever stored or shown. */
+const normalizeName = (value: string): string =>
+ value.trim().toLowerCase().replaceAll(/\s+/g, " ");
+
+/**
+ * Is this "brand" just the legal name typed again? (D-B1a)
+ *
+ * NORMALISED, because the trap is a PREFILLED value saved with a stray space or a
+ * different case: " mustafa VURAL " is the same person as "Mustafa Vural", and
+ * an exact-string check would publish them onto a restaurant's public page.
+ *
+ * Exported so the FORM can say so, in the partner's own language, before they
+ * save. One predicate, two readers — a second copy in the UI is the one that
+ * drifts, and it drifts towards permissive.
+ */
+export function isLegalNameEcho(
+ displayName: string | null | undefined,
+ legalName: string | null | undefined,
+): boolean {
+ const brand = normalizeName(displayName ?? "");
+ return brand.length > 0 && brand === normalizeName(legalName ?? "");
+}
+
+/**
+ * The ONLY way a brand reaches a public surface — and the only place it is
+ * refused. Returns null when: there is no brand · `publishToTenants` is false ·
+ * `displayName` is blank · `displayName` IS the legal name. **A missing brand
+ * renders NOTHING; it never falls back.**
+ *
+ * A single choke point on purpose: the alternative — every future caller
+ * remembering the flag and the legal-name comparison — holds until the first
+ * caller that forgets, and that failure is silent, a name nobody consented to
+ * publish sitting in a stranger's footer with nothing going red.
+ *
+ * It takes the LEGAL name because D-B1a is a rule about the RELATIONSHIP between
+ * the two records, so the one function allowed to publish must know both. A named
+ * option, not a positional string: a caller that omits it disarms the echo rule
+ * visibly, instead of sliding a name into the wrong slot. And it RE-PROJECTS
+ * rather than spreading the row, so a column added to `PartnerBrand` later is not
+ * published by the mere fact of having been added.
+ */
+export function renderableBrand(
+ brand: StoredBrand | null | undefined,
+ opts: { legalName?: string | null } = {},
+): RenderableBrand | null {
+ if (!brand?.publishToTenants) return null;
+ const displayName = brand.displayName.trim();
+ if (!displayName) return null;
+ if (isLegalNameEcho(displayName, opts.legalName)) return null;
+ // Re-checked though the write schema already refuses anything but https:
+ // defence in depth on the one field that becomes an `href` on a public page. A
+ // row predating the schema, or written by a future admin tool, must not publish
+ // a `javascript:` link — and dropping the LINK is the right refusal, since the
+ // name is still what the partner asked to show.
+ const websiteUrl = brand.websiteUrl?.trim();
+ return { displayName, ...(websiteUrl && isHttpsUrl(websiteUrl) ? { websiteUrl } : {}) };
+}
diff --git a/lib/partner-brand.ts b/lib/partner-brand.ts
new file mode 100644
index 0000000..02c0297
--- /dev/null
+++ b/lib/partner-brand.ts
@@ -0,0 +1,126 @@
+// A partner's PUBLIC brand — the WRITE rules about it (SOFRA-PARTNER-PLAN §11).
+//
+// What may be SHOWN moved to lib/partner-brand-publish.ts when the pair outgrew
+// one file's LOC limit (CLAUDE.md §4) and is re-exported at the foot of this
+// file, so `@/lib/partner-brand` is still the only import path anyone needs.
+//
+// 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";
+import { isHttpsUrl } from "@/lib/partner-brand-publish";
+
+/**
+ * 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());
+
+/**
+ * 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;
+}
+
+// The PUBLISH half, re-exported so `@/lib/partner-brand` stays the one import
+// path for this feature. It lives in its own file only because the pair outgrew
+// the 200-LOC limit (CLAUDE.md §4); `renderableBrand` is still the single door.
+export {
+ isHttpsUrl,
+ isLegalNameEcho,
+ renderableBrand,
+ type RenderableBrand,
+ type StoredBrand,
+} from "@/lib/partner-brand-publish";
diff --git a/lib/provisioning-pr-blocks.ts b/lib/provisioning-pr-blocks.ts
new file mode 100644
index 0000000..ddf5440
--- /dev/null
+++ b/lib/provisioning-pr-blocks.ts
@@ -0,0 +1,127 @@
+// The CONDITIONAL sections of an ADR-012 provisioning PR body — the parts that
+// appear only when the entry carries something the founder has to look at before
+// merging (a withheld module, a partner's own zone, a partner credit).
+//
+// Split out of lib/provisioning-pr-body.ts when the pair outgrew one file's LOC
+// limit (CLAUDE.md §4), the same split that file records having had from
+// provisioning-registry.ts. Each function returns markdown LINES and an empty
+// array when its condition does not hold, so the body composes them by spreading
+// — a section that does not apply contributes nothing, not a blank heading.
+//
+// Pure: no GitHub API, no secrets, no env.
+
+import type { TenantProvisionInput } from "./provisioning-registry";
+
+/**
+ * Bought but deliberately NOT in this entry.
+ *
+ * Named, not silent. The entry omits a module they PAID for, so the body has to say
+ * so where the founder is already reading — otherwise the checklist quietly
+ * contradicts the receipt, and the gap is discovered by a customer asking why card
+ * payment does not work. Empty for every tenant that bought nothing deferred.
+ */
+export function deferredSection(
+ slug: string,
+ granted: string[],
+ deferred: string[],
+): string[] {
+ if (!deferred.length) return [];
+ return [
+ "",
+ `### ⚠️ Bought but deliberately NOT in this entry: \`${deferred.join(", ")}\``,
+ "",
+ `They paid for \`${deferred.join(", ")}\` and they keep it — the plan, the price and the`,
+ "subscription are unchanged. It is out of **this** entry because `provision-tenant.sh`",
+ "refuses the pair `online-payments` without `stripe_account:`, and refuses it *before*",
+ "the database, the compose project or the image — so proposing both here would not give",
+ "them a restaurant lacking card payment, it would give them **no restaurant at all**.",
+ "",
+ "No account was supplied with this proposal. If you are the founder and you already",
+ "hold their `acct_` — runbook §2b has you create it *before* proposing, exactly so",
+ "this does not happen — the fix is one shot, not two: add both fields in **Files",
+ "changed** before merging and delete this section's premise. Otherwise the account",
+ "genuinely cannot exist yet, because only the restaurant can create it, through",
+ "Stripe's hosted onboarding, which cannot be pre-filled. In that case provision them",
+ "now on everything else, then, once they have finished Stripe and you have their id, open a",
+ "**second** registry PR that adds BOTH halves together — one without the other trips",
+ "the same guard. Only these two fields change; the rest of the entry stays as merged:",
+ "",
+ "```yaml",
+ ` ${slug}:`,
+ " stripe_account: acct_XXXXXXXXXXXX",
+ ` modules: [${[...granted, ...deferred].join(", ")}]`,
+ "```",
+ "",
+ `…then re-run provisioning (\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`)`,
+ "and restart the tenant so it picks up the Stripe env. Full recipe — account creation,",
+ "the KYC sitting, TWINT, the box env — workspace `docs/runbooks/signup-to-live-tenant.md`",
+ "**§2b**, which is written to be followed BEFORE this second PR.",
+ ];
+}
+
+/**
+ * A partner's own zone.
+ *
+ * The one thing that can go wrong with a partner-zone entry and cannot be fixed after
+ * the merge: the name has to RESOLVE before provisioning, because the certificate is
+ * issued per hostname over HTTP-01 and there is no way to pre-issue one. A tenant
+ * provisioned ahead of its A record sits without TLS, which looks exactly like a
+ * broken product to the restaurant that was just handed a link.
+ */
+export function baseDomainSection(baseDomain: string | undefined, domain: string): string[] {
+ if (!baseDomain) return [];
+ return [
+ "",
+ `### ⚠️ Partner zone — \`base_domain: ${baseDomain}\`, so the wildcard does NOT cover it`,
+ "",
+ `\`\`\`bash\ndig +short ${domain} # must already answer with this box's IP\n\`\`\``,
+ "",
+ "The partner publishes that A record in their own zone (plan §D1a: per-client A record —",
+ "a delegated subzone is impossible, not merely worse). An empty answer means merging will",
+ "stand the tenant up and then fail to get a certificate, which are issued per hostname over",
+ "HTTP-01 and cannot be pre-issued: **wait for the record rather than merge and retry.** And",
+ "note `NEXT_PUBLIC_*` are baked per domain, so changing this domain later is a rebuild plus",
+ "a re-provision, not a registry edit.",
+ ];
+}
+
+/**
+ * A partner credit is about to become PUBLIC (SOFRA-PARTNER-PLAN §11e).
+ *
+ * This section exists because the registry PR is the founder's review checkpoint
+ * (ADR-012), and this is the moment someone should notice that a name and a link are
+ * about to appear on a restaurant's page — a page belonging to a third party. It is
+ * the last cheap place to say "no": after the merge the entry is provisioned, and
+ * un-publishing means another registry PR and another re-provision.
+ *
+ * It also names the field the RESTAURANT can be given (D-B2), because the founder is
+ * the only party who can add it on their behalf, and the entry deliberately does not
+ * carry it by default: absent means attribution is on.
+ */
+export function partnerSection(
+ slug: string,
+ brand: NonNullable,
+): string[] {
+ return [
+ "",
+ `### 👤 This entry credits a partner in the tenant's footer: \`${brand.displayName}\``,
+ "",
+ `The footer of every page on this restaurant's site will read *"Site by ${brand.displayName}"*${
+ brand.websiteUrl ? `, linked to \`${brand.websiteUrl}\`` : " (no link — the partner recorded no website)"
+ }.`,
+ "It is here because that partner asked for it on `/dashboard/brand`, and only a name and",
+ "a URL cross: no address, no phone, and never the legal name — a sole trader's legal name",
+ "is a private individual's, which is why the control plane refuses to publish it at all.",
+ "",
+ "**Check the restaurant is content to be credited.** If they are not, add one line to this",
+ "entry before merging — the default is on, so the key only ever appears to turn it off:",
+ "",
+ "```yaml",
+ ` ${slug}:`,
+ " partner_attribution: false",
+ "```",
+ "",
+ "It is resolved during provisioning, so the tenant's env carries only what to display.",
+ "Changing it later is a registry edit plus a re-provision (a `restart` re-reads nothing).",
+ ];
+}
diff --git a/lib/provisioning-pr-body.ts b/lib/provisioning-pr-body.ts
index 6a0ac3f..bf832e1 100644
--- a/lib/provisioning-pr-body.ts
+++ b/lib/provisioning-pr-body.ts
@@ -8,6 +8,9 @@
// checklist against it.
import { splitDeferredModules, tenantDomain, type TenantProvisionInput } from "./provisioning-registry";
+// The conditional sections live next door (LOC limit, CLAUDE.md §4). Each returns an
+// empty array when it does not apply, so this file spreads them unconditionally.
+import { baseDomainSection, deferredSection, partnerSection } from "./provisioning-pr-blocks";
// Close the quote, emit an escaped apostrophe, reopen: the only way to get a
// literal ' inside a POSIX single-quoted argument.
@@ -77,65 +80,6 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string {
"two commands below are **required**, not a fallback. Still check the entry first:",
];
- // Named, not silent. The entry omits a module they PAID for, so the body has to say
- // so where the founder is already reading — otherwise the checklist above quietly
- // contradicts the receipt, and the gap is discovered by a customer asking why card
- // payment does not work. Empty for every tenant that bought nothing deferred.
- const deferredBlock = deferred.length
- ? [
- "",
- `### ⚠️ Bought but deliberately NOT in this entry: \`${deferred.join(", ")}\``,
- "",
- `They paid for \`${deferred.join(", ")}\` and they keep it — the plan, the price and the`,
- "subscription are unchanged. It is out of **this** entry because `provision-tenant.sh`",
- "refuses the pair `online-payments` without `stripe_account:`, and refuses it *before*",
- "the database, the compose project or the image — so proposing both here would not give",
- "them a restaurant lacking card payment, it would give them **no restaurant at all**.",
- "",
- "No account was supplied with this proposal. If you are the founder and you already",
- "hold their `acct_` — runbook §2b has you create it *before* proposing, exactly so",
- "this does not happen — the fix is one shot, not two: add both fields in **Files",
- "changed** before merging and delete this section's premise. Otherwise the account",
- "genuinely cannot exist yet, because only the restaurant can create it, through",
- "Stripe's hosted onboarding, which cannot be pre-filled. In that case provision them",
- "now on everything else, then, once they have finished Stripe and you have their id, open a",
- "**second** registry PR that adds BOTH halves together — one without the other trips",
- "the same guard. Only these two fields change; the rest of the entry stays as merged:",
- "",
- "```yaml",
- ` ${slug}:`,
- " stripe_account: acct_XXXXXXXXXXXX",
- ` modules: [${[...granted, ...deferred].join(", ")}]`,
- "```",
- "",
- `…then re-run provisioning (\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`)`,
- "and restart the tenant so it picks up the Stripe env. Full recipe — account creation,",
- "the KYC sitting, TWINT, the box env — workspace `docs/runbooks/signup-to-live-tenant.md`",
- "**§2b**, which is written to be followed BEFORE this second PR.",
- ]
- : [];
-
- // The one thing that can go wrong with a partner-zone entry and cannot be fixed after
- // the merge: the name has to RESOLVE before provisioning, because the certificate is
- // issued per hostname over HTTP-01 and there is no way to pre-issue one. A tenant
- // provisioned ahead of its A record sits without TLS, which looks exactly like a
- // broken product to the restaurant that was just handed a link.
- const baseDomainBlock = input.baseDomain
- ? [
- "",
- `### ⚠️ Partner zone — \`base_domain: ${input.baseDomain}\`, so the wildcard does NOT cover it`,
- "",
- `\`\`\`bash\ndig +short ${domain} # must already answer with this box's IP\n\`\`\``,
- "",
- "The partner publishes that A record in their own zone (plan §D1a: per-client A record —",
- "a delegated subzone is impossible, not merely worse). An empty answer means merging will",
- "stand the tenant up and then fail to get a certificate, which are issued per hostname over",
- "HTTP-01 and cannot be pre-issued: **wait for the record rather than merge and retry.** And",
- "note `NEXT_PUBLIC_*` are baked per domain, so changing this domain later is a rebuild plus",
- "a re-provision, not a registry edit.",
- ]
- : [];
-
const after = chained
? [
"The chain provisions **first-time only**, and reports back on this PR when it is done —",
@@ -159,6 +103,11 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string {
}`,
`- **box** \`${box}\` · status starts at \`provisioning\`${
input.baseDomain ? ` · **base_domain** \`${input.baseDomain}\` (a partner's own zone)` : ""
+ }${
+ // In the summary as well as its own section below: this is the line a founder
+ // skims, and a name becoming public is the one thing in the diff that is about
+ // a third party rather than about the tenant.
+ input.partnerBrand ? ` · **partner credit** \`${input.partnerBrand.displayName}\` (public)` : ""
}`,
"",
...header,
@@ -172,8 +121,11 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string {
: `- [ ] **modules** \`${granted.join(", ")}\` match what they actually paid for — they are enforced at runtime now, so a missing id is a feature they bought and will not get`,
tagCheck,
`- [ ] **template** \`${input.template}\` and **currency** \`${input.currency}\` are right — the template is baked into the image at build time, so changing it later is a rebuild`,
- ...deferredBlock,
- ...baseDomainBlock,
+ ...deferredSection(slug, granted, deferred),
+ ...baseDomainSection(input.baseDomain, domain),
+ // Only when a publishable partner brand reached the entry — `renderableBrand`
+ // decided that, not this file.
+ ...(input.partnerBrand ? partnerSection(slug, input.partnerBrand) : []),
"",
...after,
"",
diff --git a/lib/provisioning-registry.ts b/lib/provisioning-registry.ts
index 7787f5d..f19a720 100644
--- a/lib/provisioning-registry.ts
+++ b/lib/provisioning-registry.ts
@@ -84,6 +84,19 @@ export interface TenantProvisionInput {
/** The tenant's Stripe connected account (`acct_…`), when they already have one.
* Absent on the self-serve path; present when the founder followed runbook §2b. */
stripeAccount?: string;
+ /**
+ * The reseller credit this tenant's footer may carry (§11e) — and it is typed as
+ * the OUTPUT of `renderableBrand`, not as a partner id or a brand row, on purpose.
+ * That choke point is where `publishToTenants` and the D-B1a legal-name refusal
+ * are decided, so a caller cannot reach this field without having passed them:
+ * the only way to obtain a value of this shape is to have been allowed one.
+ *
+ * Optional, and ABSENCE IS THE CONTRACT, exactly as `baseDomain` above: without it
+ * the generator emits precisely what it emitted before this field existed — no
+ * `partner_name:`, no `partner_url:` — which is every entry in the registry today.
+ * The proof is that every pre-existing test of this function passes unchanged.
+ */
+ partnerBrand?: { displayName: string; websiteUrl?: string };
city?: string;
/** Which box the tenant belongs on; provision-tenant.sh refuses a mismatch. */
box?: string;
@@ -163,6 +176,17 @@ export function buildTenantRegistryEntry(input: TenantProvisionInput): {
...(stripeAccount ? { stripe_account: stripeAccount } : {}),
// Only emit city when set — the registry field is optional.
...(input.city ? { city: input.city } : {}),
+ // The partner credit, emitted only when there is a publishable one. NOT
+ // `partner_attribution:` — absent means true (D-B2), so writing it on every
+ // entry would be a no-op line on all of them and a diff on all of them. It is
+ // the RESTAURANT's switch, hand-added on their behalf when they ask for it, and
+ // resolved in `provision-tenant.sh` so the tenant env carries one meaning only.
+ ...(input.partnerBrand
+ ? {
+ partner_name: input.partnerBrand.displayName,
+ ...(input.partnerBrand.websiteUrl ? { partner_url: input.partnerBrand.websiteUrl } : {}),
+ }
+ : {}),
},
};
const block = stringify(entry)
diff --git a/lib/provisioning.ts b/lib/provisioning.ts
index 8348f58..16af099 100644
--- a/lib/provisioning.ts
+++ b/lib/provisioning.ts
@@ -7,6 +7,7 @@
// (invariant 2).
import { buildProvisioningPrBody } from "@/lib/provisioning-pr-body";
+import { tenantPartnerBrand } from "@/lib/partner-brand-lookup";
import { buildTenantRegistryEntry, type TenantProvisionInput } from "@/lib/provisioning-registry";
const OWNER = "piwas-21";
@@ -82,11 +83,20 @@ export async function openProvisioningPr(
throw new ProvisioningApiError(`registry already has a '${input.slug}' entry`);
}
+ // The reseller credit for the tenant's footer (SOFRA-PARTNER-PLAN §11e), resolved
+ // HERE rather than accepted from a caller. It is DERIVED from the slug — nobody
+ // types it, and it is not the founder's to type — so filling it at the one place
+ // that writes a registry entry means no caller can forget it, no caller can inject
+ // one, and a third path added later inherits it. Whatever the input carried is
+ // deliberately overwritten: `renderableBrand` behind this lookup is the only thing
+ // allowed to decide that a name may be published (D-B1/D-B1a).
+ const partnerBrand = await tenantPartnerBrand(input.slug);
+
// `deferred` is returned to the caller rather than only rendered into the PR body: a
// deferral means a customer is being BILLED for a module their tenant will not have
// until a second registry PR lands, and a prose section in one PR is not a record
// anyone can query later. The callers put it in the audit trail.
- const { entry, deferred } = buildTenantRegistryEntry(input);
+ const { entry, deferred } = buildTenantRegistryEntry({ ...input, partnerBrand });
// trimEnd() (no regex) drops any trailing whitespace/newlines, then we re-add
// exactly two — avoids the ReDoS-prone `\n*$`, and the blank line keeps the
// new tenant from butting against the previous one's trailing comment, which
@@ -132,7 +142,10 @@ export async function openProvisioningPr(
title: `Provision tenant: ${input.slug}`,
head: branch,
base: BASE,
- body: buildProvisioningPrBody(input),
+ // The SAME resolved credit the entry carries: a body that describes a partner
+ // the diff does not name (or omits one it does) is worse than no body — the
+ // founder ticks the checklist against it.
+ body: buildProvisioningPrBody({ ...input, partnerBrand }),
}),
});
return { prUrl: pr.html_url, deferred };
diff --git a/messages/ar.json b/messages/ar.json
index 4144db0..6a85831 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,35 @@
"sending": "جارٍ الإرسال…",
"sent": "تم الإرسال — سنعود إليك."
},
+ "brand": {
+ "title": "بياناتك العامة",
+ "intro": "الاسم وبيانات الاتصال التي قد يراها ضيوف المطعم بوصفها الشركة التي تقف خلف موقعه. تكتبها هنا حتى لا يُنشر سوى ما تختاره أنت.",
+ "privacyNote": "هذه البيانات منفصلة عمداً عن بيانات الفوترة. يبقى عنوان الفاتورة ورقم السجل خاصين ولا يُعرضان لأحد غيرنا.",
+ "prefillNote": "ملأنا اسمك التجاري للبدء. لا يُنسخ أي شيء آخر من بيانات الفوترة — اكتب ما توافق على أن يراه الضيوف.",
+ "noTradeNameNote": "تتضمن بيانات الفوترة اسماً قانونياً دون اسم تجاري. لا يمكننا نشر اسم شخص على موقع مطعم، فاكتب من فضلك العلامة التجارية التي يجب أن يراها ضيوف عملائك.",
+ "legalNameNotPublished": "هذا هو اسمك القانوني، ولن يُعرض على موقع أي مطعم. تُحفظ بياناتك على أي حال — اكتب اسم علامة تجارية لتُنسب إليك المواقع.",
+ "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..d447d0f 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,35 @@
"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.",
+ "noTradeNameNote": "Ihre Rechnungsdaten enthalten einen amtlichen Namen, aber keinen Handelsnamen. Einen Personennamen dürfen wir nicht auf der Website eines Restaurants veröffentlichen — bitte geben Sie die Marke ein, die die Gäste Ihrer Kunden sehen sollen.",
+ "legalNameNotPublished": "Das ist Ihr amtlicher Name; er wird auf keiner Restaurant-Website angezeigt. Ihre Angaben werden trotzdem gespeichert — geben Sie einen Markennamen ein, um genannt zu werden.",
+ "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..0755dd0 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,35 @@
"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.",
+ "noTradeNameNote": "Your billing details give a legal name but no trading name. We cannot publish a personal name on a restaurant's website, so please enter the brand your clients' guests should see.",
+ "legalNameNotPublished": "This is the same as your legal name, so it will not be shown on any restaurant's site. Your details are still saved — enter a brand name to be credited.",
+ "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..9cec320 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,35 @@
"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.",
+ "noTradeNameNote": "Vos données de facturation indiquent une raison sociale mais aucun nom commercial. Nous ne pouvons pas publier un nom de personne sur le site d'un restaurant : saisissez la marque que les clients de vos établissements doivent voir.",
+ "legalNameNotPublished": "C'est votre raison sociale : elle ne sera affichée sur le site d'aucun restaurant. Vos informations sont tout de même enregistrées — saisissez un nom de marque pour être crédité.",
+ "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..8bf2a36 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,35 @@
"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.",
+ "noTradeNameNote": "Uw factuurgegevens bevatten wel een juridische naam maar geen handelsnaam. Een persoonsnaam mogen we niet op de website van een restaurant publiceren — vul het merk in dat de gasten van uw klanten moeten zien.",
+ "legalNameNotPublished": "Dit is uw juridische naam; die wordt op geen enkele restaurantsite getoond. Uw gegevens worden wel bewaard — vul een merknaam in om vermeld te worden.",
+ "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..863ca3b 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,35 @@
"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.",
+ "noTradeNameNote": "Fatura bilgilerinizde yasal ad var ama ticari ad yok. Bir kişinin adını restoran web sitesinde yayımlayamayız; lütfen müşterilerinizin misafirlerinin göreceği marka adını girin.",
+ "legalNameNotPublished": "Bu, yasal adınızla aynı; hiçbir restoran sitesinde gösterilmeyecek. Bilgileriniz yine de kaydedilir — adınızın anılması için bir marka adı girin.",
+ "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/package-lock.json b/package-lock.json
index 3cadab9..7c808d8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5487,9 +5487,9 @@
}
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.42",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
- "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
+ "version": "2.11.20",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
+ "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -5540,9 +5540,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.4",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
- "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"funding": [
{
"type": "opencollective",
@@ -5559,11 +5559,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.10.38",
- "caniuse-lite": "^1.0.30001799",
- "electron-to-chromium": "^1.5.376",
- "node-releases": "^2.0.48",
- "update-browserslist-db": "^1.2.3"
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -5669,9 +5669,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001800",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
- "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"funding": [
{
"type": "opencollective",
@@ -5975,16 +5975,6 @@
"devOptional": true,
"license": "MIT"
},
- "node_modules/denque": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
- "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
- "devOptional": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=0.10"
- }
- },
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@@ -6054,9 +6044,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.387",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
- "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
+ "version": "1.5.420",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
+ "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -6887,9 +6877,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
- "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
@@ -8789,24 +8779,25 @@
"license": "MIT"
},
"node_modules/mysql2": {
- "version": "3.15.3",
- "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
- "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
+ "version": "3.24.3",
+ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz",
+ "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
- "aws-ssl-profiles": "^1.1.1",
- "denque": "^2.1.0",
+ "aws-ssl-profiles": "^1.1.2",
"generate-function": "^2.3.1",
- "iconv-lite": "^0.7.0",
- "long": "^5.2.1",
- "lru.min": "^1.0.0",
- "named-placeholders": "^1.1.3",
- "seq-queue": "^0.0.5",
- "sqlstring": "^2.3.2"
+ "iconv-lite": "^0.7.3",
+ "long": "^5.3.2",
+ "lru.min": "^1.1.4",
+ "named-placeholders": "^1.1.6",
+ "sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
+ },
+ "peerDependencies": {
+ "@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
@@ -9102,9 +9093,9 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.50",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
- "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
+ "version": "2.0.54",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
+ "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -10215,12 +10206,6 @@
"node": ">=10"
}
},
- "node_modules/seq-queue": {
- "version": "0.0.5",
- "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
- "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==",
- "devOptional": true
- },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -10477,14 +10462,20 @@
"node": ">= 10.x"
}
},
- "node_modules/sqlstring": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
- "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
+ "node_modules/sql-escaper": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
+ "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==",
"devOptional": true,
"license": "MIT",
"engines": {
- "node": ">= 0.6"
+ "bun": ">=1.0.0",
+ "deno": ">=2.0.0",
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stable-hash": {
@@ -11039,9 +11030,9 @@
}
},
"node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
+ "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"funding": [
{
"type": "opencollective",
diff --git a/package.json b/package.json
index 94bf1a6..277780c 100644
--- a/package.json
+++ b/package.json
@@ -52,10 +52,12 @@
"deepmerge-ts": "^8.0.1",
"postcss": "^8.5.23",
"@hono/node-server": "^2.0.5",
- "fast-uri": "^3.1.4",
+ "fast-uri": "^3.1.6",
"sharp": "^0.35.0",
"brace-expansion": "^5.0.8",
"@auth/core": "^0.41.3",
- "valibot": "^1.4.2"
+ "valibot": "^1.4.2",
+ "mysql2": "^3.22.0",
+ "browserslist": "^4.28.7"
}
}
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..8627cd1
--- /dev/null
+++ b/tests/e2e/partner-brand.spec.ts
@@ -0,0 +1,152 @@
+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" });
+});
+
+test("a sole trader is told why their own name cannot be published (D-B1a)", async ({ page }) => {
+ const email = uniq.email("brandsole");
+ const partnerId = await arrangePartnerUser(email, PASSWORD);
+ // The case §11b is about: a legal record naming a PERSON, with no trade name. The
+ // prefill therefore offers that person's own name — correct for a field they are
+ // about to read and edit, and catastrophic if it were quietly published.
+ await arrangeBillingIdentityFor(partnerId, {
+ legalName: "Mustafa Vural",
+ tradeName: null,
+ addressLine1: "Chemin Privé 7",
+ });
+
+ await login(page, { email, password: PASSWORD });
+ await page.waitForURL("**/dashboard");
+ await page.goto("/dashboard/brand");
+
+ // Said before they type, not after they save.
+ await expect(page.getByText(/no trading name/i)).toBeVisible();
+ await expect(page.getByText(/will not be shown on any restaurant/i)).toBeVisible();
+
+ // The save still SUCCEEDS — it is their record, and refusing the write would leave
+ // them unable to record anything at all. What is refused is publishing it, and the
+ // page says so rather than accepting the value and dropping it out of sight.
+ await page.getByRole("button", { name: /^save$/i }).click();
+ await expect(page.getByText(/^saved\.$/i)).toBeVisible();
+ expect(await findPartnerBrand(partnerId)).toMatchObject({ displayName: "Mustafa Vural" });
+
+ // Type a real brand and the warning goes; type the legal name back, in another
+ // case and with stray spaces, and it returns — the comparison is normalised.
+ await page.getByLabel(/display name/i).fill("Solution Eva");
+ await expect(page.getByText(/will not be shown on any restaurant/i)).toHaveCount(0);
+ await page.getByLabel(/display name/i).fill(" mustafa VURAL ");
+ await expect(page.getByText(/will not be shown on any restaurant/i)).toBeVisible();
+});
diff --git a/tests/unit/partner-brand.test.ts b/tests/unit/partner-brand.test.ts
new file mode 100644
index 0000000..bd0337f
--- /dev/null
+++ b/tests/unit/partner-brand.test.ts
@@ -0,0 +1,287 @@
+import { describe, expect, it } from "vitest";
+import {
+ checkboxOn,
+ isLegalNameEcho,
+ 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", () => {
+ // S3a TIGHTENED this projection to D-B1: `displayName` + `websiteUrl` and nothing
+ // else. The assertions that named tagline/email/phone/address/postcode/city/country
+ // were deliberately rewritten, which is the point of the direction — a footer that
+ // stops carrying a partner's postal address announces itself in red here, whereas
+ // adding those fields back would be silent and needs its own controls (D-B4).
+ it("publishes an ATTRIBUTION: a name and a link, and nothing else", () => {
+ expect(renderableBrand(full)).toEqual({
+ displayName: "Solution Eva",
+ websiteUrl: "https://solutioneva.com",
+ });
+ });
+ it("carries no contact details at all, however full the record", () => {
+ const out = renderableBrand(full) as Record;
+ for (const key of ["tagline", "email", "phone", "addressLine1", "postalCode", "city", "countryCode"]) {
+ expect(out, `${key} must not be published`).not.toHaveProperty(key);
+ }
+ expect(Object.keys(out).sort()).toEqual(["displayName", "websiteUrl"]);
+ });
+ it("never leaks the flag itself into the projection", () => {
+ expect(renderableBrand(full)).not.toHaveProperty("publishToTenants");
+ });
+ it("omits websiteUrl entirely when there is none — absent, never null", () => {
+ const out = renderableBrand({ ...full, websiteUrl: null });
+ expect(out).toEqual({ displayName: "Solution Eva" });
+ expect(out).not.toHaveProperty("websiteUrl");
+ });
+ // Defence in depth: the write schema refuses these, so a row carrying one came from
+ // somewhere else (an older row, a future admin tool, a hand-edited database). The
+ // NAME still publishes — the partner asked for that — only the href is dropped.
+ it("drops a non-https link rather than the whole credit", () => {
+ for (const bad of ["javascript:alert(1)", "http://solutioneva.com", "solutioneva.com"]) {
+ expect(renderableBrand({ ...full, websiteUrl: bad })).toEqual({ displayName: "Solution Eva" });
+ }
+ });
+
+ // 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();
+ });
+ it("returns null for a blank display name, whatever else is set", () => {
+ expect(renderableBrand({ ...full, displayName: " " })).toBeNull();
+ });
+});
+
+describe("renderableBrand — D-B1a: it refuses the legal name", () => {
+ // The catastrophic path this exists to close: `prefillFromBillingIdentity` offers
+ // `tradeName ?? legalName`, so a sole trader with no trade name is offered their own
+ // name, saves the form untouched, and would otherwise be published on a restaurant's
+ // page by a convenience nobody decided on.
+ const sole: StoredBrand = { ...full, displayName: "Mustafa Vural", websiteUrl: null };
+
+ it("refuses an exact match", () => {
+ expect(renderableBrand(sole, { legalName: "Mustafa Vural" })).toBeNull();
+ });
+ // The normalisation cases, and they are the realistic ones: a prefilled value that
+ // was retyped, pasted with a trailing space, or shouted.
+ it("refuses a match that differs only by whitespace and case", () => {
+ expect(
+ renderableBrand({ ...sole, displayName: " mustafa VURAL " }, { legalName: "Mustafa Vural" }),
+ ).toBeNull();
+ });
+ it("refuses when the LEGAL name is the untidy one", () => {
+ expect(renderableBrand(sole, { legalName: " MUSTAFA vural " })).toBeNull();
+ });
+ it("refuses the registry's own spelling of that partner", () => {
+ // SOFRA-BILLING-IDENTITY-PLAN §6 records the one live reseller as `VURAL Mustafa`
+ // trading as `SOLUTIONEVA` — the order is not the same, so this one must PASS.
+ expect(renderableBrand(sole, { legalName: "VURAL Mustafa" })).toEqual({
+ displayName: "Mustafa Vural",
+ });
+ });
+ it("publishes a real brand belonging to the same person", () => {
+ expect(renderableBrand(full, { legalName: "Mustafa Vural" })).toEqual({
+ displayName: "Solution Eva",
+ websiteUrl: "https://solutioneva.com",
+ });
+ });
+ it("does not fire when no legal name is known — the rule needs both records", () => {
+ expect(renderableBrand(sole)).toEqual({ displayName: "Mustafa Vural" });
+ expect(renderableBrand(sole, { legalName: null })).toEqual({ displayName: "Mustafa Vural" });
+ expect(renderableBrand(sole, { legalName: " " })).toEqual({ displayName: "Mustafa Vural" });
+ });
+});
+
+describe("isLegalNameEcho — the predicate the FORM shares with the choke point", () => {
+ it("is true for the same name written differently", () => {
+ expect(isLegalNameEcho(" mustafa VURAL ", "Mustafa Vural")).toBe(true);
+ });
+ it("is false for a different name", () => {
+ expect(isLegalNameEcho("Solution Eva", "Mustafa Vural")).toBe(false);
+ });
+ // Both halves blank would compare equal as "" — and would then hide a brand for a
+ // reason that has nothing to do with the legal record.
+ it("is false when either side is empty, rather than trivially true", () => {
+ expect(isLegalNameEcho("", "")).toBe(false);
+ expect(isLegalNameEcho(" ", null)).toBe(false);
+ expect(isLegalNameEcho(null, "Mustafa Vural")).toBe(false);
+ expect(isLegalNameEcho("Mustafa Vural", undefined)).toBe(false);
+ });
+});
diff --git a/tests/unit/provisioning-registry.test.ts b/tests/unit/provisioning-registry.test.ts
index 292233c..bd1fe2a 100644
--- a/tests/unit/provisioning-registry.test.ts
+++ b/tests/unit/provisioning-registry.test.ts
@@ -480,3 +480,94 @@ describe("the PR body describes the entry it ships with", () => {
expect(body).not.toContain("dig +short");
});
});
+
+describe("a partner credit in the generated entry (§11e, S3a)", () => {
+ // Same regression proof as `base_domain` above, and it is the important half of this
+ // block: every test in this file that predates `partnerBrand` still passes UNCHANGED.
+ // Absence is the contract — an entry with no credit is byte-identical to one built
+ // before the field existed, which is what every entry in the live registry is.
+ const withBrand = (partnerBrand?: { displayName: string; websiteUrl?: string }) =>
+ asTenant(
+ buildTenantRegistryEntry({
+ slug: "obresse",
+ name: "O'Bresse",
+ adminEmail: "chef@obresse.example",
+ template: "craft",
+ currency: "CHF",
+ languages: ["fr"],
+ modules: ["core"],
+ partnerBrand,
+ }),
+ "obresse",
+ ) as Record;
+
+ it("emits partner_name and partner_url as flat keys", () => {
+ // Flat, not a nested `partner:` map: `provision-tenant.sh` reads a fixed key list
+ // and flattens each value with `str(v)`, so a map would arrive in the shell as a
+ // stringified Python dict (plan §11d2).
+ const t = withBrand({ displayName: "Solution Eva", websiteUrl: "https://solutioneva.com" });
+ expect(t.partner_name).toBe("Solution Eva");
+ expect(t.partner_url).toBe("https://solutioneva.com");
+ });
+
+ it("emits the name alone when the partner recorded no website", () => {
+ const t = withBrand({ displayName: "Solution Eva" });
+ expect(t.partner_name).toBe("Solution Eva");
+ expect(t).not.toHaveProperty("partner_url");
+ });
+
+ // THE absence case. Not "partner_name is empty" — the key must not be there at all,
+ // because an empty value is a thing someone set and an absent key is the default the
+ // deploy script already implements.
+ it("emits NEITHER key when there is no publishable brand", () => {
+ const t = withBrand();
+ expect(t).not.toHaveProperty("partner_name");
+ expect(t).not.toHaveProperty("partner_url");
+ });
+
+ // D-B2: absent means attribution is ON, so writing the key on every entry would be a
+ // no-op line on all of them. It is the RESTAURANT's switch, hand-added by the founder
+ // on their behalf, and `provision-tenant.sh` is where the boolean is resolved.
+ it("never emits partner_attribution, credited or not", () => {
+ expect(withBrand({ displayName: "Solution Eva" })).not.toHaveProperty("partner_attribution");
+ expect(withBrand()).not.toHaveProperty("partner_attribution");
+ });
+
+ it("escapes a brand name that would otherwise break the YAML", () => {
+ const t = withBrand({ displayName: "Eva: #1 \"partner\"\nname: pwned" });
+ expect(t.partner_name).toBe("Eva: #1 \"partner\"\nname: pwned");
+ expect(t.name).toBe("O'Bresse");
+ });
+
+ it("says so in the PR body — the founder's review checkpoint (ADR-012)", () => {
+ const input = {
+ slug: "obresse",
+ name: "O'Bresse",
+ adminEmail: "chef@obresse.example",
+ template: "craft" as const,
+ currency: "CHF",
+ languages: ["fr"],
+ modules: ["core"],
+ partnerBrand: { displayName: "Solution Eva", websiteUrl: "https://solutioneva.com" },
+ };
+ const body = buildProvisioningPrBody(input);
+ expect(body).toContain("Solution Eva");
+ expect(body).toContain("https://solutioneva.com");
+ // The line that makes the section actionable: the founder is the only party who can
+ // turn it off on the restaurant's behalf, and the key only ever appears to do that.
+ expect(body).toContain("partner_attribution: false");
+ // …and stays silent otherwise, rather than printing an empty section.
+ const quiet = buildProvisioningPrBody({ ...input, partnerBrand: undefined });
+ expect(quiet).not.toContain("partner_attribution");
+ expect(quiet).not.toContain("Solution Eva");
+
+ // A credit with no link says so, rather than describing a link the entry has
+ // not got: the founder is being asked what the footer will read.
+ const unlinked = buildProvisioningPrBody({
+ ...input,
+ partnerBrand: { displayName: "Solution Eva" },
+ });
+ expect(unlinked).toContain("no link");
+ expect(unlinked).not.toContain("https://solutioneva.com");
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 7862516..92728af 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -44,6 +44,10 @@ export default defineConfig({
// have quietly moved already-covered code out of the floor's scope, which reads
// as a passing gate rather than as lost coverage.
"lib/provisioning-pr-body.ts",
+ // Its conditional sections, split out for the same limit and listed for the
+ // same reason. Every branch here is a warning the founder either gets or does
+ // not get at the one reviewable moment before a tenant is stood up.
+ "lib/provisioning-pr-blocks.ts",
"lib/module-catalog.ts",
"lib/tenant-options.ts",
"lib/signup-configuration.ts",
@@ -109,6 +113,17 @@ 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",
+ // The publish half, split out of it for the LOC limit. Listed explicitly for
+ // the reason provisioning-pr-body.ts is: leaving it off would quietly move
+ // already-covered code OUT of the floor's scope, which reads as a passing gate
+ // rather than as lost coverage — and this is the file holding the refusals.
+ "lib/partner-brand-publish.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