Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m40s
All checks were successful
Build and Push / build (push) Successful in 1m40s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
|
||||
@@ -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: <name>' on the invoice below the company name. Useful for AP routing in larger organizations."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
|
||||
@@ -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 <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations."
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
|
||||
@@ -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. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni."
|
||||
},
|
||||
"support": {
|
||||
"title": "Supporto",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user