Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
All checks were successful
Build and Push / build (push) Successful in 1m34s
All checks were successful
Build and Push / build (push) Successful in 1m34s
This commit is contained in:
157
src/lib/billing-i18n.ts
Normal file
157
src/lib/billing-i18n.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
ai_usage: "KI-Nutzung",
|
||||
threema_messages: "Threema-Nachrichten",
|
||||
skill_usage: "Skill-Nutzung",
|
||||
skill_setup: "Einrichtungsgebühr Skill",
|
||||
adjustment: "Anpassung",
|
||||
},
|
||||
reverseCharge:
|
||||
@@ -156,6 +157,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
ai_usage: "AI usage",
|
||||
threema_messages: "Threema messages",
|
||||
skill_usage: "Skill usage",
|
||||
skill_setup: "Skill setup fee",
|
||||
adjustment: "Adjustment",
|
||||
},
|
||||
reverseCharge:
|
||||
@@ -187,6 +189,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
ai_usage: "Utilisation IA",
|
||||
threema_messages: "Messages Threema",
|
||||
skill_usage: "Utilisation Skill",
|
||||
skill_setup: "Frais de configuration skill",
|
||||
adjustment: "Ajustement",
|
||||
},
|
||||
reverseCharge:
|
||||
@@ -218,6 +221,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
ai_usage: "Utilizzo IA",
|
||||
threema_messages: "Messaggi Threema",
|
||||
skill_usage: "Utilizzo Skill",
|
||||
skill_setup: "Spese di attivazione skill",
|
||||
adjustment: "Rettifica",
|
||||
},
|
||||
reverseCharge:
|
||||
|
||||
@@ -54,12 +54,14 @@ import {
|
||||
listSkillPricing,
|
||||
listSuspensionEventsForTenant,
|
||||
tenantHasSetupFeeBilled,
|
||||
tenantSkillHasBeenBilled,
|
||||
updateInvoicePdf,
|
||||
} from "./db";
|
||||
import { listTenants } from "./k8s";
|
||||
import { getTeamSpendLogsV2 } from "./litellm";
|
||||
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||
import { renderInvoicePdf } from "./billing-pdf";
|
||||
import { formatLineDescription } from "./billing-i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Period helpers
|
||||
@@ -370,6 +372,7 @@ async function buildTenantLines(opts: {
|
||||
daysInMonth: number;
|
||||
platformPricing: PlatformPricing;
|
||||
skillPricing: SkillPricing[];
|
||||
locale: string;
|
||||
warnings: string[];
|
||||
displayOrderOffset: number;
|
||||
}): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> {
|
||||
@@ -380,6 +383,7 @@ async function buildTenantLines(opts: {
|
||||
daysInMonth,
|
||||
platformPricing,
|
||||
skillPricing,
|
||||
locale,
|
||||
warnings,
|
||||
} = opts;
|
||||
let displayOrder = opts.displayOrderOffset;
|
||||
@@ -428,19 +432,23 @@ async function buildTenantLines(opts: {
|
||||
if (billableDays > 0) {
|
||||
const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth;
|
||||
const amount = round2(unit * billableDays);
|
||||
const metadata = {
|
||||
billable_days: billableDays,
|
||||
suspended_days: suspendedDays,
|
||||
days_in_month: daysInMonth,
|
||||
};
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "tenant_monthly",
|
||||
description: `Monthly fee for ${tenantName} (${billableDays}/${daysInMonth} days)`,
|
||||
description: formatLineDescription(
|
||||
{ kind: "tenant_monthly", tenantName, metadata },
|
||||
locale
|
||||
),
|
||||
quantity: billableDays,
|
||||
unitLabel: "days",
|
||||
unitPriceChf: round2(unit * 1e5) / 1e5,
|
||||
amountChf: amount,
|
||||
metadata: {
|
||||
billable_days: billableDays,
|
||||
suspended_days: suspendedDays,
|
||||
days_in_month: daysInMonth,
|
||||
},
|
||||
metadata,
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
@@ -453,7 +461,10 @@ async function buildTenantLines(opts: {
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "tenant_setup",
|
||||
description: `Setup fee for ${tenantName}`,
|
||||
description: formatLineDescription(
|
||||
{ kind: "tenant_setup", tenantName, metadata: null },
|
||||
locale
|
||||
),
|
||||
quantity: 1,
|
||||
unitLabel: null,
|
||||
unitPriceChf: platformPricing.tenantSetupFeeChf,
|
||||
@@ -480,19 +491,23 @@ async function buildTenantLines(opts: {
|
||||
`Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.`
|
||||
);
|
||||
} else if (aiUsage.spendChf > 0) {
|
||||
const aiMetadata = {
|
||||
litellm_key_alias: tenantName,
|
||||
spend_chf: aiUsage.spendChf,
|
||||
requests: aiUsage.requestCount,
|
||||
};
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "ai_usage",
|
||||
description: `AI inference usage (${aiUsage.requestCount} requests)`,
|
||||
description: formatLineDescription(
|
||||
{ kind: "ai_usage", tenantName, metadata: aiMetadata },
|
||||
locale
|
||||
),
|
||||
quantity: 1,
|
||||
unitLabel: null,
|
||||
unitPriceChf: aiUsage.spendChf,
|
||||
amountChf: aiUsage.spendChf,
|
||||
metadata: {
|
||||
litellm_key_alias: tenantName,
|
||||
spend_chf: aiUsage.spendChf,
|
||||
requests: aiUsage.requestCount,
|
||||
},
|
||||
metadata: aiMetadata,
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
@@ -502,19 +517,23 @@ async function buildTenantLines(opts: {
|
||||
const threema = await collectThreemaUsage(tenant, periodStart, periodEnd);
|
||||
if (threema && (threema.inCount + threema.outCount) > 0) {
|
||||
const total = threema.inCount + threema.outCount;
|
||||
const threemaMetadata = {
|
||||
in_count: threema.inCount,
|
||||
out_count: threema.outCount,
|
||||
total_count: total,
|
||||
};
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "threema_messages",
|
||||
description: `Threema messages (${threema.inCount} in + ${threema.outCount} out)`,
|
||||
description: formatLineDescription(
|
||||
{ kind: "threema_messages", tenantName, metadata: threemaMetadata },
|
||||
locale
|
||||
),
|
||||
quantity: total,
|
||||
unitLabel: "msgs",
|
||||
unitPriceChf: platformPricing.threemaMessageChf,
|
||||
amountChf: round2(total * platformPricing.threemaMessageChf),
|
||||
metadata: {
|
||||
in_count: threema.inCount,
|
||||
out_count: threema.outCount,
|
||||
total_count: total,
|
||||
},
|
||||
metadata: threemaMetadata,
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
@@ -548,19 +567,48 @@ async function buildTenantLines(opts: {
|
||||
}
|
||||
}
|
||||
if (billableDays > 0) {
|
||||
// Setup fee fires once per (tenant, skill) — before the
|
||||
// usage line so it appears above it on the PDF.
|
||||
if (sp.setupFeeChf > 0) {
|
||||
const alreadyBilled = await tenantSkillHasBeenBilled(
|
||||
tenantName,
|
||||
sp.skillId
|
||||
);
|
||||
if (!alreadyBilled) {
|
||||
const setupMetadata = { skill_id: sp.skillId };
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "skill_setup",
|
||||
description: formatLineDescription(
|
||||
{ kind: "skill_setup", tenantName, metadata: setupMetadata },
|
||||
locale
|
||||
),
|
||||
quantity: 1,
|
||||
unitLabel: null,
|
||||
unitPriceChf: sp.setupFeeChf,
|
||||
amountChf: round2(sp.setupFeeChf),
|
||||
metadata: setupMetadata,
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
}
|
||||
const skillMetadata = {
|
||||
skill_id: sp.skillId,
|
||||
billable_days: billableDays,
|
||||
event_count: skillEvents.length,
|
||||
};
|
||||
lines.push({
|
||||
tenantName,
|
||||
kind: "skill_usage",
|
||||
description: `Skill: ${sp.skillId} (${billableDays} day${billableDays === 1 ? "" : "s"})`,
|
||||
description: formatLineDescription(
|
||||
{ kind: "skill_usage", tenantName, metadata: skillMetadata },
|
||||
locale
|
||||
),
|
||||
quantity: billableDays,
|
||||
unitLabel: "days",
|
||||
unitPriceChf: sp.dailyPriceChf,
|
||||
amountChf: round2(billableDays * sp.dailyPriceChf),
|
||||
metadata: {
|
||||
skill_id: sp.skillId,
|
||||
billable_days: billableDays,
|
||||
event_count: skillEvents.length,
|
||||
},
|
||||
metadata: skillMetadata,
|
||||
displayOrder: displayOrder++,
|
||||
});
|
||||
}
|
||||
@@ -620,6 +668,9 @@ export async function computeInvoiceDraft(opts: {
|
||||
}
|
||||
|
||||
// 4. Build lines, grouped per tenant (display order preserved).
|
||||
// Locale must be resolved before line construction since the
|
||||
// descriptions are localized at compute time.
|
||||
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
|
||||
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
|
||||
let nextDisplayOrder = 0;
|
||||
// Sort tenants by name for stable line ordering across regenerations.
|
||||
@@ -632,6 +683,7 @@ export async function computeInvoiceDraft(opts: {
|
||||
daysInMonth,
|
||||
platformPricing,
|
||||
skillPricing,
|
||||
locale,
|
||||
warnings,
|
||||
displayOrderOffset: nextDisplayOrder,
|
||||
});
|
||||
@@ -653,9 +705,6 @@ export async function computeInvoiceDraft(opts: {
|
||||
const paymentMethod: InvoicePaymentMethod =
|
||||
opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice");
|
||||
|
||||
// 7. Locale resolution
|
||||
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
|
||||
|
||||
return {
|
||||
zitadelOrgId,
|
||||
periodStart,
|
||||
|
||||
@@ -331,6 +331,12 @@ const MIGRATION_SQL = `
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Phase 2 addition: per-skill one-time setup fee. Charged the
|
||||
-- first time a given (tenant, skill) appears on an invoice line.
|
||||
-- Default 0 so pricing rows created before this column exists
|
||||
-- stay free until the admin sets a fee.
|
||||
ALTER TABLE skill_pricing
|
||||
ADD COLUMN IF NOT EXISTS setup_fee_chf NUMERIC(10,2) NOT NULL DEFAULT 0;
|
||||
|
||||
-- One row per tenant. created_at anchors first-month proration;
|
||||
-- deleted_at (nullable, stamped on delete) anchors last-month
|
||||
@@ -1699,6 +1705,7 @@ function rowToSkillPricing(row: any): SkillPricing {
|
||||
return {
|
||||
skillId: row.skill_id,
|
||||
dailyPriceChf: Number(row.daily_price_chf),
|
||||
setupFeeChf: Number(row.setup_fee_chf ?? 0),
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
@@ -1724,24 +1731,30 @@ export async function getSkillPricing(
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a daily price for a package. Setting a price activates
|
||||
* usage-based billing for the (tenant, skill) pair: every UTC day
|
||||
* the package was enabled in the billing month is one unit on the
|
||||
* invoice.
|
||||
* Upsert pricing for a package. `dailyPriceChf` activates
|
||||
* usage-based billing (one billable unit per UTC day the package
|
||||
* was enabled). `setupFeeChf` is a one-time charge emitted on the
|
||||
* first invoice line for any given (tenant, skill).
|
||||
*
|
||||
* Both fields are required so admin must consciously set 0 to mean
|
||||
* "no setup fee" rather than accidentally inheriting an old value
|
||||
* from a partial update.
|
||||
*/
|
||||
export async function setSkillPricing(
|
||||
skillId: string,
|
||||
dailyPriceChf: number
|
||||
dailyPriceChf: number,
|
||||
setupFeeChf: number
|
||||
): Promise<SkillPricing> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`INSERT INTO skill_pricing (skill_id, daily_price_chf)
|
||||
VALUES ($1, $2)
|
||||
`INSERT INTO skill_pricing (skill_id, daily_price_chf, setup_fee_chf)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (skill_id) DO UPDATE SET
|
||||
daily_price_chf = EXCLUDED.daily_price_chf,
|
||||
setup_fee_chf = EXCLUDED.setup_fee_chf,
|
||||
updated_at = now()
|
||||
RETURNING *`,
|
||||
[skillId, dailyPriceChf]
|
||||
[skillId, dailyPriceChf, setupFeeChf]
|
||||
);
|
||||
return rowToSkillPricing(result.rows[0]);
|
||||
}
|
||||
@@ -2500,6 +2513,32 @@ export async function tenantHasSetupFeeBilled(
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this (tenant, skill) pair already appeared on any prior
|
||||
* invoice line — either as setup or usage? Drives the per-skill
|
||||
* setup-fee gate. Same "first appearance" semantics as the tenant
|
||||
* setup fee: a previously-free skill that newly gets a setup fee
|
||||
* configured will trigger the fee on its next billed period.
|
||||
*
|
||||
* Uses metadata->>'skill_id' (which is what both skill_setup and
|
||||
* skill_usage lines store) rather than parsing description.
|
||||
*/
|
||||
export async function tenantSkillHasBeenBilled(
|
||||
tenantName: string,
|
||||
skillId: string
|
||||
): Promise<boolean> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT 1 FROM invoice_lines
|
||||
WHERE tenant_name = $1
|
||||
AND kind IN ('skill_setup', 'skill_usage')
|
||||
AND metadata->>'skill_id' = $2
|
||||
LIMIT 1`,
|
||||
[tenantName, skillId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate open balance per org for the admin overview. Returns
|
||||
* orgs with at least one open or overdue invoice; orgs in good
|
||||
|
||||
Reference in New Issue
Block a user