/** * Shared billing localization. Used by: * - billing.ts (compute path) — pre-renders the localized * line description and stores it on the invoice line at issue * time. Descriptions are then frozen in the customer's locale. * - billing-pdf.tsx (render path) — can fall back to this if a * stored description is missing (e.g. legacy invoice from the * pre-i18n era) or if the PDF is re-rendered in a different * locale (Phase 7). * * Locale set matches the portal's next-intl locales: de, en, fr, it. * Unknown locales fall back to German (Swiss B2B default). */ import type { InvoiceLineKind } from "@/types"; export type BillingLocale = "de" | "en" | "fr" | "it"; function normaliseLocale(locale: string): BillingLocale { if (locale === "en" || locale === "fr" || locale === "it" || locale === "de") { return locale; } return "de"; } /** * Localized "N day(s)" — covers the only plural case in billing * line descriptions. Other plurals (months, requests, messages) * either don't change form in the supported languages or are * always >1 in practice. */ function days(n: number, locale: BillingLocale): string { const labels = { de: { one: "Tag", many: "Tage" }, en: { one: "day", many: "days" }, fr: { one: "jour", many: "jours" }, it: { one: "giorno", many: "giorni" }, } as const; const label = labels[locale]; return `${n} ${n === 1 ? label.one : label.many}`; } /** Subset of InvoiceLine needed for description formatting. */ export interface LineForDescription { kind: InvoiceLineKind; tenantName: string | null; metadata: Record | null; } /** * Build the localized line description from a line's kind + * metadata. Pure function — no DB/IO. Output mirrors what the * PDF and admin preview show in the description column. * * Metadata expectations per kind (must match what billing.ts * stores when emitting the line): * tenant_monthly: { billable_days, days_in_month } * tenant_setup: {} (uses tenantName only) * ai_usage: { requests } * threema_messages: { in_count, out_count } * skill_usage: { skill_id, billable_days } * skill_setup: { skill_id } * adjustment: { reason? } * * Missing fields fall back to "?" so a malformed line still * renders something readable rather than crashing the PDF. */ export function formatLineDescription( line: LineForDescription, locale: string ): string { const L = normaliseLocale(locale); const m = line.metadata ?? {}; const tenant = line.tenantName ?? "—"; // Helper to fetch a metadata field with a safe fallback. const f = (key: string): string | number => { const v = (m as Record)[key]; if (v === undefined || v === null) return "?"; return v as string | number; }; switch (line.kind) { case "tenant_monthly": { const bd = f("billable_days"); const dim = f("days_in_month"); return { de: `Monatliche Grundgebühr für ${tenant} (${bd}/${dim} Tage)`, en: `Monthly fee for ${tenant} (${bd}/${dim} days)`, fr: `Forfait mensuel pour ${tenant} (${bd}/${dim} jours)`, it: `Canone mensile per ${tenant} (${bd}/${dim} giorni)`, }[L]; } case "tenant_setup": return { de: `Einrichtungsgebühr für ${tenant}`, en: `Setup fee for ${tenant}`, fr: `Frais de configuration pour ${tenant}`, it: `Spese di attivazione per ${tenant}`, }[L]; case "ai_usage": { const r = f("requests"); return { de: `KI-Inferenz-Nutzung (${r} Anfragen)`, en: `AI inference usage (${r} requests)`, fr: `Utilisation IA (${r} requêtes)`, it: `Utilizzo IA (${r} richieste)`, }[L]; } case "threema_messages": { const inC = f("in_count"); const outC = f("out_count"); return { de: `Threema-Nachrichten (${inC} eingehend + ${outC} ausgehend)`, en: `Threema messages (${inC} in + ${outC} out)`, fr: `Messages Threema (${inC} entrants + ${outC} sortants)`, it: `Messaggi Threema (${inC} in entrata + ${outC} in uscita)`, }[L]; } case "skill_usage": { const skill = f("skill_id"); const bdRaw = (m as Record)["billable_days"]; const bd = typeof bdRaw === "number" ? bdRaw : 0; return { de: `Skill: ${skill} (${days(bd, "de")})`, en: `Skill: ${skill} (${days(bd, "en")})`, fr: `Skill: ${skill} (${days(bd, "fr")})`, it: `Skill: ${skill} (${days(bd, "it")})`, }[L]; } case "skill_setup": { const skill = f("skill_id"); return { de: `Einrichtungsgebühr Skill: ${skill}`, en: `Setup fee skill: ${skill}`, fr: `Frais de configuration skill: ${skill}`, it: `Spese di attivazione skill: ${skill}`, }[L]; } case "adjustment": { const reasonRaw = (m as Record)["reason"]; const reason = typeof reasonRaw === "string" ? reasonRaw : null; const base = { de: "Anpassung", en: "Adjustment", fr: "Ajustement", it: "Rettifica", }[L]; return reason ? `${base}: ${reason}` : base; } // Phase 8: custom invoice lines. The description is what the // admin typed in the editor — return it verbatim (no template, // no locale-specific formatting). billing.ts persists the // already-trimmed admin input into invoice_lines.description. case "custom_line": { const dRaw = (m as Record)["description"]; if (typeof dRaw === "string" && dRaw.trim().length > 0) return dRaw; // Fallback: the description column on the row itself. The // PDF renderer hands us the line so it can read it directly // — see how billing-pdf invokes formatLineDescription. const onRow = (line as unknown as { description?: string }).description; return onRow && onRow.trim().length > 0 ? onRow : { de: "Leistung", en: "Service", fr: "Service", it: "Servizio" }[L]; } } }