Compare commits

...

4 Commits

Author SHA1 Message Date
002867850d Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m38s
2026-05-25 13:28:56 +02:00
eea027b3b0 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m38s
2026-05-25 13:14:36 +02:00
522246e386 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 12:54:12 +02:00
b3131f7710 Phase6: Customer Billing details
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 12:15:48 +02:00
16 changed files with 259 additions and 61 deletions

View File

@@ -76,6 +76,7 @@ export default async function NewInstancePage() {
userName={user.name}
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/>
</div>
</div>

View File

@@ -317,6 +317,7 @@ export default async function DashboardPage() {
userName={user.name}
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/>
</div>
</div>

View File

@@ -33,10 +33,15 @@ export default async function BillingSettingsPage() {
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
<p className="text-sm text-text-secondary mt-3">
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
</p>
</div>
<div className="animate-in animate-in-delay-1">
<BillingSettingsForm initial={existing} />
<BillingSettingsForm
initial={existing}
isPersonal={user.isPersonal}
/>
</div>
</main>
);

View File

@@ -252,11 +252,24 @@ export async function POST(request: Request) {
}
}
// For follow-up instances, prefer the on-file company name and contact
// details; the user can't change those by re-typing them in the wizard.
// The audit copy of company name on this request stays inherited
// from the first request in the org — it's a historical snapshot
// of the company name at the time the request was created, and
// org_billing is now the canonical source for current values.
//
// Phase 6 fix4: contactName and contactEmail are NOT inherited.
// They identify whoever submitted THIS specific request (drives
// admin display, support ticket routing, and email greetings).
// The previous "prior?.contactName ?? user.name" pattern locked
// the contact to whoever first onboarded the org, which broke for
// any subsequent submission by a different user — admin saw the
// wrong name, support emails went to the wrong person, and the
// actual submitter had no way to correct it because the wizard
// doesn't expose a contact-name input. The fix is simply to use
// the current session user every time.
const companyName = prior?.companyName ?? user.orgName;
const contactName = prior?.contactName ?? user.name;
const contactEmail = prior?.contactEmail ?? user.email;
const contactName = user.name;
const contactEmail = user.email;
// Bug 35: org-scoped billing.
//

View File

@@ -22,6 +22,10 @@ import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
const upsertSchema = z.object({
companyName: z.string().trim().min(1).max(200),
// Phase 6 fix: optional "z.Hd." / "Attn:" line. Personal accounts
// never send this (the UI hides the field); orgs may set or leave
// it empty.
contactName: z.string().trim().max(200).optional().nullable(),
streetAddress: z.string().trim().min(1).max(200),
postalCode: z.string().trim().min(1).max(20),
city: z.string().trim().min(1).max(100),
@@ -73,6 +77,7 @@ export async function PUT(request: Request) {
const billing = await upsertOrgBilling({
zitadelOrgId: user.orgId,
companyName: data.companyName,
contactName: data.contactName ?? null,
streetAddress: data.streetAddress,
postalCode: data.postalCode,
city: data.city,

View File

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard";
import type { OrgBilling } from "@/types";
interface OnboardingFlowProps {
orgName: string;
@@ -19,6 +20,12 @@ interface OnboardingFlowProps {
* /settings/billing.
*/
hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record (or null). Drives
* the review-step "Billing to" rendering AND the confirm-step
* validation skip when the billing step was skipped.
*/
existingOrgBilling?: OrgBilling | null;
/**
* Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full
@@ -45,6 +52,7 @@ export function OnboardingFlow({
userName,
userEmail,
hasOrgBilling,
existingOrgBilling,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
@@ -55,6 +63,7 @@ export function OnboardingFlow({
userName={userName}
userEmail={userEmail}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling}
editingRequest={editingRequest}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -13,6 +13,7 @@ import {
SUPPORTED_COUNTRIES,
type SupportedCountry,
} from "@/lib/validation";
import type { OrgBilling } from "@/types";
type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -96,6 +97,17 @@ interface WizardProps {
* fix it before admin approves.
*/
hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record when one exists.
* Used to render real values on the review-step "Billing to" block
* (rather than the wizard's empty default config.billingAddress)
* AND to skip the confirm-step's client-side validation of
* billingAddress — same logic that already strips billingAddress
* at submit time. Null when no org_billing row exists yet.
* Ignored in edit mode (the editingRequest carries its own
* billingAddress snapshot).
*/
existingOrgBilling?: OrgBilling | null;
/**
* Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is
@@ -134,6 +146,7 @@ export function OnboardingWizard({
userName,
userEmail,
hasOrgBilling,
existingOrgBilling,
editingRequest,
onComplete,
}: WizardProps) {
@@ -319,7 +332,23 @@ export function OnboardingWizard({
}
// confirm: validate the union (defence in depth — submit handler
// also runs onboardingSchema before POST).
const r = onboardingSchema.safeParse(config);
//
// Phase 6 fix3: when hasOrgBilling=true AND not editing, the
// billing step was skipped and config.billingAddress is the
// empty default. zod's .optional() doesn't help here because the
// field IS present (empty object), so billingAddressSchema
// validates it and fails with required-field errors that the
// user has no way to fix — the form to enter the values was
// skipped on purpose. Strip the field for validation, matching
// the same strip we already do at submit time.
const configForValidation =
hasOrgBilling && !isEditing
? (() => {
const { billingAddress: _b, ...rest } = config;
return rest;
})()
: config;
const r = onboardingSchema.safeParse(configForValidation);
if (r.success) {
setErrors({});
return true;
@@ -1101,42 +1130,84 @@ export function OnboardingWizard({
<ReviewRow
label={t("reviewBillingTo")}
value={
<div className="text-text-primary text-right">
{/* For personal: skip the company line so the
invoice rendering matches what the user actually
entered. For company: include it as the first
line. */}
{!isPersonal &&
config.billingAddress.company &&
config.billingAddress.company.trim().length > 0 && (
<div>{config.billingAddress.company}</div>
)}
<div>{config.billingAddress.street}</div>
<div>
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</div>
<div className="text-text-muted">
{tCountries(
config.billingAddress.country as SupportedCountry
)}
</div>
</div>
(() => {
// Phase 6 fix3: when the org has billing on file
// and we're not editing, render the saved
// org_billing record (the authoritative source)
// rather than config.billingAddress, which is the
// wizard's empty default state because the billing
// step was skipped. In edit mode, fall back to
// config.billingAddress, which is pre-populated
// from the request being edited.
const useSaved =
hasOrgBilling && !isEditing && existingOrgBilling;
const company = useSaved
? existingOrgBilling!.companyName
: config.billingAddress.company;
const street = useSaved
? existingOrgBilling!.streetAddress
: config.billingAddress.street;
const postalCode = useSaved
? existingOrgBilling!.postalCode
: config.billingAddress.postalCode;
const city = useSaved
? existingOrgBilling!.city
: config.billingAddress.city;
const country = useSaved
? existingOrgBilling!.country
: config.billingAddress.country;
const contactName = useSaved
? existingOrgBilling!.contactName
: null;
return (
<div className="text-text-primary text-right">
{/* For personal: skip the company line so the
invoice rendering matches what the user actually
entered. For company: include it as the first
line. */}
{!isPersonal &&
company &&
company.trim().length > 0 && <div>{company}</div>}
{/* Phase 6 fix2: optional contact-person line
("z.Hd. <name>") only present when the saved
org_billing has it set. */}
{contactName && contactName.trim().length > 0 && (
<div className="text-text-muted">
{t("reviewContactPersonPrefix")} {contactName}
</div>
)}
<div>{street}</div>
<div>
{postalCode} {city}
</div>
<div className="text-text-muted">
{tCountries(country as SupportedCountry)}
</div>
</div>
);
})()
}
/>
{/* Bug 35: VAT review row. Company customers see this so
they can verify the VAT id they typed before submitting.
Personal customers never see it — they don't have a
VAT number, the form didn't ask, the review hides it. */}
VAT number, the form didn't ask, the review hides it.
Phase 6 fix3: when reading from existingOrgBilling,
the value comes from there too. */}
{!isPersonal &&
config.billingAddress.vatNumber &&
config.billingAddress.vatNumber.trim().length > 0 && (
<ReviewRow
label={t("billingVatNumber")}
value={config.billingAddress.vatNumber}
mono
/>
)}
(() => {
const vat =
hasOrgBilling && !isEditing && existingOrgBilling
? existingOrgBilling.vatNumber
: config.billingAddress.vatNumber;
return vat && vat.trim().length > 0 ? (
<ReviewRow
label={t("billingVatNumber")}
value={vat}
mono
/>
) : null;
})()}
<ReviewRow
label={t("reviewContactEmail")}
value={userEmail || ""}

View File

@@ -8,6 +8,16 @@ import type { OrgBilling } from "@/types";
interface Props {
initial: OrgBilling | null;
/**
* Personal-account (individual customer) flag from the session.
* Individuals get a "Full name" field instead of "Company name",
* and the VAT input is hidden entirely — they don't have one and
* showing the field would only confuse. The underlying column is
* still `company_name` in the DB and the invoice PDF; for an
* individual that field carries their full name, which is
* exactly what should print on the invoice.
*/
isPersonal: boolean;
}
/**
@@ -22,11 +32,12 @@ interface Props {
* On success we router.refresh() the page so the server component
* re-fetches and any "create now" -> "edit" wording flips.
*/
export function BillingSettingsForm({ initial }: Props) {
export function BillingSettingsForm({ initial, isPersonal }: Props) {
const t = useTranslations("settingsBilling");
const router = useRouter();
const [form, setForm] = useState({
companyName: initial?.companyName ?? "",
contactName: initial?.contactName ?? "",
streetAddress: initial?.streetAddress ?? "",
postalCode: initial?.postalCode ?? "",
city: initial?.city ?? "",
@@ -74,11 +85,18 @@ export function BillingSettingsForm({ initial }: Props) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyName: form.companyName.trim(),
// Personal accounts don't have a contact-name field
// (companyName IS their name); force null so stale state
// from a previously-org-flagged account can't carry over.
contactName: isPersonal ? null : form.contactName.trim() || null,
streetAddress: form.streetAddress.trim(),
postalCode: form.postalCode.trim(),
city: form.city.trim(),
country: form.country.trim().toUpperCase(),
vatNumber: form.vatNumber.trim() || null,
// Personal accounts never have a VAT number — force null
// regardless of stale state, in case a value was stored
// before the account got flagged as personal.
vatNumber: isPersonal ? null : form.vatNumber.trim() || null,
billingEmail: form.billingEmail.trim(),
notes: form.notes.trim() || null,
}),
@@ -99,7 +117,10 @@ export function BillingSettingsForm({ initial }: Props) {
return (
<Card>
<div className="space-y-4">
<Field label={t("companyNameLabel")} required>
<Field
label={isPersonal ? t("fullNameLabel") : t("companyNameLabel")}
required
>
<input
type="text"
value={form.companyName}
@@ -108,6 +129,17 @@ export function BillingSettingsForm({ initial }: Props) {
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
{!isPersonal && (
<Field label={t("contactNameLabel")} hint={t("contactNameHint")}>
<input
type="text"
value={form.contactName}
onChange={set("contactName")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
)}
<Field label={t("streetAddressLabel")} required>
<input
type="text"
@@ -155,16 +187,18 @@ export function BillingSettingsForm({ initial }: Props) {
/>
</Field>
</div>
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
<input
type="text"
value={form.vatNumber}
onChange={set("vatNumber")}
maxLength={40}
placeholder="CHE-123.456.789 MWST"
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
/>
</Field>
{!isPersonal && (
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
<input
type="text"
value={form.vatNumber}
onChange={set("vatNumber")}
maxLength={40}
placeholder="CHE-123.456.789 MWST"
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
/>
</Field>
)}
<Field label={t("billingEmailLabel")} required hint={t("billingEmailHint")}>
<input
type="email"

View File

@@ -80,6 +80,11 @@ interface PdfStrings {
dueDate: string;
period: string;
billTo: string;
// Phase 6 fix: prefix shown before the optional contact-person
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
// invoice has no contactName on its snapshot.
attentionPrefix: string;
description: string;
quantity: string;
unitPrice: string;
@@ -107,6 +112,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Zahlbar bis",
period: "Abrechnungsperiode",
billTo: "Rechnungsempfänger",
attentionPrefix: "z.Hd.",
description: "Beschreibung",
quantity: "Menge",
unitPrice: "Einzelpreis",
@@ -139,6 +145,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Due date",
period: "Billing period",
billTo: "Bill to",
attentionPrefix: "Attn:",
description: "Description",
quantity: "Qty",
unitPrice: "Unit price",
@@ -171,6 +178,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Échéance",
period: "Période de facturation",
billTo: "Destinataire",
attentionPrefix: "À l'attention de",
description: "Description",
quantity: "Qté",
unitPrice: "Prix unitaire",
@@ -203,6 +211,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Scadenza",
period: "Periodo di fatturazione",
billTo: "Destinatario",
attentionPrefix: "c.a.",
description: "Descrizione",
quantity: "Qtà",
unitPrice: "Prezzo unitario",
@@ -524,6 +533,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
<View style={styles.billToBlock}>
<Text style={styles.billToLabel}>{s.billTo}</Text>
<Text style={styles.billToName}>{snap.companyName}</Text>
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
the printed invoice internally at the customer. Prints
between the company name and street address, in the
invoice's locale (frozen at issue time). */}
{snap.contactName && (
<Text>
{s.attentionPrefix} {snap.contactName}
</Text>
)}
<Text>{snap.streetAddress}</Text>
<Text>
{snap.postalCode} {snap.city}

View File

@@ -645,6 +645,7 @@ export async function computeInvoiceDraft(opts: {
}
const snapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName,
contactName: orgBilling.contactName ?? null,
streetAddress: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,

View File

@@ -198,6 +198,12 @@ const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS org_billing (
zitadel_org_id TEXT PRIMARY KEY,
company_name TEXT NOT NULL,
-- Phase 6 fix: optional contact-person line shown on the
-- invoice PDF below the company name (e.g. "z.Hd. Herr Müller").
-- Not normally needed since invoices are delivered by email
-- link, but useful when customers forward the PDF internally
-- for AP routing in larger organizations.
contact_name TEXT,
street_address TEXT NOT NULL,
postal_code TEXT NOT NULL,
city TEXT NOT NULL,
@@ -208,6 +214,10 @@ const MIGRATION_SQL = `
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Phase 6 fix: ensure the column exists on databases that were
-- created before contact_name was added to the base schema above.
-- IF NOT EXISTS makes this safe to run repeatedly via ensureSchema.
ALTER TABLE org_billing ADD COLUMN IF NOT EXISTS contact_name TEXT;
-- Feature 5: lightweight customer support / feedback tickets.
-- Scoped strictly per-user (zitadel_user_id), not per-org —
@@ -1262,6 +1272,7 @@ function rowToOrgBilling(row: any): OrgBilling {
return {
zitadelOrgId: row.zitadel_org_id,
companyName: row.company_name,
contactName: row.contact_name ?? null,
streetAddress: row.street_address,
postalCode: row.postal_code,
city: row.city,
@@ -1306,12 +1317,13 @@ export async function upsertOrgBilling(
await ensureSchema();
const result = await getPool().query(
`INSERT INTO org_billing (
zitadel_org_id, company_name, street_address, postal_code,
city, country, vat_number, billing_email, notes
zitadel_org_id, company_name, contact_name, street_address,
postal_code, city, country, vat_number, billing_email, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (zitadel_org_id) DO UPDATE SET
company_name = EXCLUDED.company_name,
contact_name = EXCLUDED.contact_name,
street_address = EXCLUDED.street_address,
postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city,
@@ -1324,6 +1336,7 @@ export async function upsertOrgBilling(
[
data.zitadelOrgId,
data.companyName,
data.contactName ?? null,
data.streetAddress,
data.postalCode,
data.city,

View File

@@ -121,7 +121,8 @@
"saveChanges": "Änderungen speichern",
"billingVatNumber": "MWST-Nummer",
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
"reviewContactPersonPrefix": "z.Hd."
},
"dashboard": {
"title": "Dashboard",
@@ -502,7 +503,11 @@
"saved": "Gespeichert.",
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben."
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
"fullNameLabel": "Vor- und Nachname",
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
"contactNameLabel": "Ansprechperson (optional)",
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen."
},
"support": {
"title": "Support",

View File

@@ -121,7 +121,8 @@
"saveChanges": "Save changes",
"billingVatNumber": "VAT number",
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
"reviewContactPersonPrefix": "Attn:"
},
"dashboard": {
"title": "Dashboard",
@@ -502,7 +503,11 @@
"saved": "Saved.",
"missingRequired": "Please fill in all required fields.",
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
"invalidEmail": "Please enter a valid email address."
"invalidEmail": "Please enter a valid email address.",
"fullNameLabel": "Full name",
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
"contactNameLabel": "Contact person (optional)",
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations."
},
"support": {
"title": "Support",

View File

@@ -121,7 +121,8 @@
"saveChanges": "Enregistrer les modifications",
"billingVatNumber": "Numéro de TVA",
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
"reviewContactPersonPrefix": "À l'attention de"
},
"dashboard": {
"title": "Tableau de bord",
@@ -502,7 +503,11 @@
"saved": "Enregistré.",
"missingRequired": "Veuillez remplir tous les champs obligatoires.",
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
"invalidEmail": "Veuillez saisir une adresse e-mail valide."
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
"fullNameLabel": "Nom et prénom",
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
"contactNameLabel": "Personne à contacter (facultatif)",
"contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations."
},
"support": {
"title": "Support",

View File

@@ -121,7 +121,8 @@
"saveChanges": "Salva modifiche",
"billingVatNumber": "Partita IVA",
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc."
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
"reviewContactPersonPrefix": "c.a."
},
"dashboard": {
"title": "Dashboard",
@@ -502,7 +503,11 @@
"saved": "Salvato.",
"missingRequired": "Compila tutti i campi obbligatori.",
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
"invalidEmail": "Inserisci un indirizzo e-mail valido."
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
"fullNameLabel": "Nome e cognome",
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
"contactNameLabel": "Persona di contatto (facoltativa)",
"contactNameHint": "Stampato come 'c.a. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni."
},
"support": {
"title": "Supporto",

View File

@@ -234,6 +234,12 @@ export interface BillingAddress {
export interface OrgBilling {
zitadelOrgId: string;
companyName: string;
// Optional contact-person line ("z.Hd. / Attn:") shown on the
// invoice PDF below the company name. Useful when invoicing
// larger companies where the mailroom needs a name to route
// the document. Personal accounts don't expose this in the UI —
// their "Full name" already lives in companyName.
contactName?: string | null;
streetAddress: string;
postalCode: string;
city: string;
@@ -575,6 +581,7 @@ export type InvoiceLineKind =
*/
export interface InvoiceBillingSnapshot {
companyName: string;
contactName: string | null;
streetAddress: string;
postalCode: string;
city: string;