174 lines
6.0 KiB
TypeScript
174 lines
6.0 KiB
TypeScript
/**
|
|
* 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<string, unknown> | 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<string, unknown>)[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<string, unknown>)["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<string, unknown>)["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<string, unknown>)["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];
|
|
}
|
|
}
|
|
}
|