Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
All checks were successful
Build and Push / build (push) Successful in 1m34s

This commit is contained in:
2026-05-24 16:38:41 +02:00
parent 11d7dbb06e
commit cd15b391ac
11 changed files with 369 additions and 52 deletions

View File

@@ -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,