diff --git a/src/app/api/settings/billing/route.ts b/src/app/api/settings/billing/route.ts index 9bca7f1..d04c3fd 100644 --- a/src/app/api/settings/billing/route.ts +++ b/src/app/api/settings/billing/route.ts @@ -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, diff --git a/src/components/settings/billing-form.tsx b/src/components/settings/billing-form.tsx index 821fa34..75ca199 100644 --- a/src/components/settings/billing-form.tsx +++ b/src/components/settings/billing-form.tsx @@ -37,6 +37,7 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) { const router = useRouter(); const [form, setForm] = useState({ companyName: initial?.companyName ?? "", + contactName: initial?.contactName ?? "", streetAddress: initial?.streetAddress ?? "", postalCode: initial?.postalCode ?? "", city: initial?.city ?? "", @@ -84,6 +85,10 @@ export function BillingSettingsForm({ initial, isPersonal }: 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(), @@ -124,6 +129,17 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) { className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm" /> + {!isPersonal && ( + + + + )} = { dueDate: "Zahlbar bis", period: "Abrechnungsperiode", billTo: "Rechnungsempfänger", + attentionPrefix: "z.Hd.", description: "Beschreibung", quantity: "Menge", unitPrice: "Einzelpreis", @@ -139,6 +145,7 @@ const MESSAGES: Record = { 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 = { 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 = { 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 = ({ invoice, lines }) => { {s.billTo} {snap.companyName} + {/* 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 && ( + + {s.attentionPrefix} {snap.contactName} + + )} {snap.streetAddress} {snap.postalCode} {snap.city} diff --git a/src/lib/billing.ts b/src/lib/billing.ts index 6adc501..6cf2fc2 100644 --- a/src/lib/billing.ts +++ b/src/lib/billing.ts @@ -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, diff --git a/src/lib/db.ts b/src/lib/db.ts index 33601c7..6be8555 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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, diff --git a/src/messages/de.json b/src/messages/de.json index 8759116..62ef822 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -504,7 +504,9 @@ "invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).", "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." + "subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.", + "contactNameLabel": "Ansprechperson (optional)", + "contactNameHint": "Erscheint als 'z.Hd. ' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen." }, "support": { "title": "Support", diff --git a/src/messages/en.json b/src/messages/en.json index 2291002..cd70134 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -504,7 +504,9 @@ "invalidCountry": "Country code must be 2 letters (e.g. CH).", "invalidEmail": "Please enter a valid email address.", "fullNameLabel": "Full name", - "subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued." + "subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.", + "contactNameLabel": "Contact person (optional)", + "contactNameHint": "Prints as 'Attn: ' on the invoice below the company name. Useful for AP routing in larger organizations." }, "support": { "title": "Support", diff --git a/src/messages/fr.json b/src/messages/fr.json index 2b4af1b..4f18caa 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -504,7 +504,9 @@ "invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).", "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." + "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 » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations." }, "support": { "title": "Support", diff --git a/src/messages/it.json b/src/messages/it.json index 8640f3f..1271295 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -504,7 +504,9 @@ "invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).", "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." + "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. ' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni." }, "support": { "title": "Supporto", diff --git a/src/types/index.ts b/src/types/index.ts index 46e598b..7a195c7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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;