Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s

This commit is contained in:
2026-05-24 13:51:38 +02:00
parent 6baca1a459
commit c8ed27157f
29 changed files with 4465 additions and 11 deletions

737
src/lib/billing.ts Normal file
View File

@@ -0,0 +1,737 @@
/**
* Billing computation pipeline.
*
* Public entry points:
* - computeInvoiceDraft({ zitadelOrgId, year, month, locale? })
* Builds an in-memory InvoiceDraft from the live signals
* (LiteLLM spend, Threema relay usage, tenant skill events,
* lifecycle, suspension). Does NOT persist or render the PDF.
*
* - generateInvoice({ zitadelOrgId, year, month, locale?, dryRun? })
* Calls computeInvoiceDraft, renders the PDF, persists the
* invoice transactionally. Returns the persisted Invoice
* (or the draft if dryRun=true).
*
* Design choices:
*
* - All compute is over UTC calendar days. "Active during day D"
* means the tenant existed and was not fully suspended at some
* moment in [D 00:00 UTC, D+1 00:00 UTC). This matches the
* skill billing rule ("same-day toggle = 1 day") for monthly
* fee proration too.
*
* - Computation is independent of persistence. Callers can preview
* without committing (the admin generate form does this on first
* click), and the same compute path is reused when committing.
*
* - The compute path collects warnings rather than throwing on
* recoverable issues (missing LiteLLM team for a tenant, etc.).
* The UI surfaces these to the admin before they confirm.
*/
import type {
Invoice,
InvoiceBillingSnapshot,
InvoiceDraft,
InvoiceLine,
InvoiceLineKind,
InvoicePaymentMethod,
PiecedTenant,
PlatformPricing,
SkillPricing,
TenantBillingLifecycle,
TenantSkillEvent,
TenantSuspensionEvent,
} from "@/types";
import {
createInvoice,
getInvoiceById,
getOrgBilling,
getOrgBillingConfig,
getPlatformPricing,
getTenantBillingLifecycle,
listSkillEventsForTenant,
listSkillPricing,
listSuspensionEventsForTenant,
tenantHasSetupFeeBilled,
updateInvoicePdf,
} from "./db";
import { listTenants } from "./k8s";
import { getTeamSpendLogsV2 } from "./litellm";
import { getUsage as getThreemaUsage } from "./threema-relay";
import { renderInvoicePdf } from "./billing-pdf";
// ---------------------------------------------------------------------------
// Period helpers
// ---------------------------------------------------------------------------
/**
* Returns the [periodStart, periodEnd] inclusive calendar dates for
* the given month, plus the count of days in the month.
*
* Dates returned as ISO `YYYY-MM-DD` strings (no time). Convertible
* to UTC midnight via `new Date(`${date}T00:00:00Z`)`.
*/
export function monthBounds(year: number, month: number): {
periodStart: string;
periodEnd: string;
daysInMonth: number;
} {
if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`);
const start = new Date(Date.UTC(year, month - 1, 1));
// Day 0 of next month = last day of this month
const end = new Date(Date.UTC(year, month, 0));
return {
periodStart: start.toISOString().split("T")[0],
periodEnd: end.toISOString().split("T")[0],
daysInMonth: end.getUTCDate(),
};
}
function isoDate(d: Date): string {
return d.toISOString().split("T")[0];
}
function dueDate(periodEnd: string, netDays: number = 30): string {
// due_at = period_end + netDays
const d = new Date(`${periodEnd}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + netDays);
return isoDate(d);
}
// ---------------------------------------------------------------------------
// Day-set computation (calendar-day model, UTC)
// ---------------------------------------------------------------------------
/**
* Iterates UTC calendar days in [periodStart, periodEnd] inclusive.
* Yields { date: 'YYYY-MM-DD', dayStartMs, dayEndMs } where dayEnd
* is exclusive (next-day-midnight UTC).
*/
function* iterDays(periodStart: string, periodEnd: string) {
const start = new Date(`${periodStart}T00:00:00Z`).getTime();
const end = new Date(`${periodEnd}T00:00:00Z`).getTime();
for (let t = start; t <= end; t += 86_400_000) {
yield {
date: isoDate(new Date(t)),
dayStartMs: t,
dayEndMs: t + 86_400_000,
};
}
}
/**
* Was the tenant "running" (created, not deleted, not suspended) at
* any moment in the half-open interval [dayStartMs, dayEndMs)?
*
* Inputs: tenant lifecycle and the timeline of suspension events
* sorted ascending by occurredAt.
*
* The state-at-day-start is reconstructed from suspension events
* BEFORE the day. If the count of suspension events before the day
* is odd, the tenant was suspended at day start (because we record
* suspend then resume, so an odd prefix-count means the last
* recorded transition is "suspended"). This is robust as long as
* events are correctly ordered.
*
* Actually we use the actual event kinds from the events list,
* not the parity heuristic — the heuristic is documentation for
* intuition.
*/
function activeDuringDay(
lifecycle: TenantBillingLifecycle,
suspensionEvents: TenantSuspensionEvent[],
dayStartMs: number,
dayEndMs: number
): boolean {
// Lifecycle gate: tenant must have existed during some part of the day.
const createdMs = new Date(lifecycle.createdAt).getTime();
const deletedMs = lifecycle.deletedAt
? new Date(lifecycle.deletedAt).getTime()
: Infinity;
if (createdMs >= dayEndMs) return false;
if (deletedMs <= dayStartMs) return false;
// Effective existence window within this day
const existsFrom = Math.max(createdMs, dayStartMs);
const existsTo = Math.min(deletedMs, dayEndMs);
if (existsFrom >= existsTo) return false;
// Determine suspended state at existsFrom by replaying events.
// Initial state at lifecycle.createdAt is 'running' (we don't
// record an explicit 'created → running' event; this is the
// implicit baseline).
let suspended = false;
for (const e of suspensionEvents) {
const ts = new Date(e.occurredAt).getTime();
if (ts > existsFrom) break;
suspended = e.eventKind === "suspended";
}
// Walk events from existsFrom to existsTo. If at any moment the
// tenant is running, the day counts.
if (!suspended) return true;
for (const e of suspensionEvents) {
const ts = new Date(e.occurredAt).getTime();
if (ts <= existsFrom) continue;
if (ts >= existsTo) break;
if (e.eventKind === "resumed") return true;
}
return false;
}
/**
* Was the skill 'enabled' at any moment in the day?
*
* Same shape as activeDuringDay but driven by skill events instead
* of suspension events.
*
* Important: callers must include events from before periodStart in
* `prevState` (state at day start), since a skill enabled three
* months ago and never disabled has no events in the billing
* window but is still enabled.
*/
function skillActiveDuringDay(
events: TenantSkillEvent[],
initiallyEnabled: boolean,
dayStartMs: number,
dayEndMs: number
): boolean {
let enabled = initiallyEnabled;
// First, replay events that occurred AT OR BEFORE dayStartMs to
// get the state at day start.
for (const e of events) {
const ts = new Date(e.occurredAt).getTime();
if (ts > dayStartMs) break;
enabled = e.eventKind === "enabled";
}
if (enabled) return true;
// Walk events in [dayStart, dayEnd). If any 'enabled' event
// appears, the day counts.
for (const e of events) {
const ts = new Date(e.occurredAt).getTime();
if (ts <= dayStartMs) continue;
if (ts >= dayEndMs) break;
if (e.eventKind === "enabled") return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Rounding
// ---------------------------------------------------------------------------
/** Round to 2dp, half-up. */
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
// ---------------------------------------------------------------------------
// VAT logic
// ---------------------------------------------------------------------------
const EU_COUNTRIES = new Set([
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
"PL", "PT", "RO", "SK", "SI", "ES", "SE",
]);
/**
* Determine VAT rate from billing address and the platform default.
* See README for the legal interpretation; this implements the
* defaults you confirmed:
*
* - CH or LI: platform_pricing.vat_rate_chli (default 8.10)
* - EU + VAT number: 0% (reverse charge — B2B)
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
* - other: 0% (export of services)
*/
function vatRateForAddress(
snapshot: InvoiceBillingSnapshot,
platformPricing: PlatformPricing
): { rate: number; note: string | null } {
const country = snapshot.country?.toUpperCase().trim() ?? "";
if (country === "CH" || country === "LI") {
return { rate: platformPricing.vatRateChli, note: null };
}
if (EU_COUNTRIES.has(country)) {
if (snapshot.vatNumber && snapshot.vatNumber.trim().length > 0) {
return {
rate: 0,
note:
"Steuerschuldnerschaft des Leistungsempfängers / Reverse charge — VAT to be accounted for by the recipient.",
};
}
return { rate: platformPricing.vatRateChli, note: null };
}
return { rate: 0, note: "Export of services — VAT not applicable." };
}
// ---------------------------------------------------------------------------
// Locale default
// ---------------------------------------------------------------------------
/**
* Pick a default invoice locale from the billing country. Admins
* can override at generation time. We default to German for
* CH/LI/AT/DE; French for FR/BE/LU; Italian for IT; English
* otherwise.
*/
export function defaultLocaleForCountry(country: string): string {
const c = (country || "").toUpperCase().trim();
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
if (["FR", "BE", "LU"].includes(c)) return "fr";
if (c === "IT") return "it";
return "en";
}
// ---------------------------------------------------------------------------
// Tenant signal collectors
// ---------------------------------------------------------------------------
/**
* Sum AI usage spend for a tenant over the billing period via
* LiteLLM. Returns the CHF total (already in CHF — LiteLLM stores
* costs after the platform's USD→CHF conversion) and the request
* count for the metadata.
*
* Tolerates missing litellmTeamId on the tenant: such tenants are
* skipped and the warning is surfaced upstream.
*/
async function collectAiUsage(
tenant: PiecedTenant,
periodStart: string,
periodEnd: string
): Promise<{ spendChf: number; requestCount: number } | null> {
const teamId = tenant.status?.litellmTeamId;
if (!teamId) return null;
const keyAlias = tenant.metadata.name;
let spendChf = 0;
let requestCount = 0;
let page = 1;
// 50-page cap matches the existing usage route's defensive cap.
while (page <= 50) {
const result = await getTeamSpendLogsV2(
teamId,
periodStart,
periodEnd,
page,
100,
keyAlias
);
const rows: any[] = result.data ?? [];
for (const r of rows) {
spendChf += Number(r.spend ?? 0);
requestCount += 1;
}
if (page >= (result.total_pages || 1)) break;
page++;
}
return { spendChf: round2(spendChf), requestCount };
}
/**
* Sum Threema messages (in + out) for the tenant over the period.
* Returns null if the relay refuses or the tenant has no Threema
* package — billing is skipped silently in that case.
*/
async function collectThreemaUsage(
tenant: PiecedTenant,
periodStart: string,
periodEnd: string
): Promise<{ inCount: number; outCount: number } | null> {
const packages = tenant.spec.packages ?? [];
if (!packages.includes("threema")) return null;
const usage = await getThreemaUsage(
tenant.metadata.name,
periodStart,
periodEnd
).catch(() => null);
if (!usage) return null;
return {
inCount: Number(usage.totals?.in ?? 0),
outCount: Number(usage.totals?.out ?? 0),
};
}
// ---------------------------------------------------------------------------
// Per-tenant line builders
// ---------------------------------------------------------------------------
async function buildTenantLines(opts: {
tenant: PiecedTenant;
periodStart: string;
periodEnd: string;
daysInMonth: number;
platformPricing: PlatformPricing;
skillPricing: SkillPricing[];
warnings: string[];
displayOrderOffset: number;
}): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> {
const {
tenant,
periodStart,
periodEnd,
daysInMonth,
platformPricing,
skillPricing,
warnings,
} = opts;
let displayOrder = opts.displayOrderOffset;
const tenantName = tenant.metadata.name;
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
// Lifecycle & suspension events — required for monthly proration.
const lifecycle = await getTenantBillingLifecycle(tenantName);
if (!lifecycle) {
warnings.push(
`Tenant "${tenantName}" has no billing lifecycle row — run the Phase 1 backfill.`
);
return lines;
}
// Period interval in millis (extended by one day on each side as
// buffer for events that occur at month boundaries).
const periodStartMs = new Date(`${periodStart}T00:00:00Z`).getTime();
const periodEndMs = new Date(`${periodEnd}T00:00:00Z`).getTime() + 86_400_000;
const suspensionEvents = await listSuspensionEventsForTenant(
tenantName,
new Date(periodStartMs - 365 * 86_400_000), // look back a year for state-at-start
new Date(periodEndMs)
);
// --- tenant_monthly (prorated, suspended days excluded) -------------------
if (platformPricing.tenantMonthlyFeeChf > 0) {
let billableDays = 0;
let suspendedDays = 0;
for (const day of iterDays(periodStart, periodEnd)) {
if (activeDuringDay(lifecycle, suspensionEvents, day.dayStartMs, day.dayEndMs)) {
billableDays++;
} else {
// Distinguish "not yet existed / deleted" from "suspended"
// for the metadata audit trail. Cheap re-check.
const createdMs = new Date(lifecycle.createdAt).getTime();
const deletedMs = lifecycle.deletedAt
? new Date(lifecycle.deletedAt).getTime()
: Infinity;
if (createdMs < day.dayEndMs && deletedMs > day.dayStartMs) {
suspendedDays++;
}
}
}
if (billableDays > 0) {
const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth;
const amount = round2(unit * billableDays);
lines.push({
tenantName,
kind: "tenant_monthly",
description: `Monthly fee for ${tenantName} (${billableDays}/${daysInMonth} days)`,
quantity: billableDays,
unitLabel: "days",
unitPriceChf: round2(unit * 1e5) / 1e5,
amountChf: amount,
metadata: {
billable_days: billableDays,
suspended_days: suspendedDays,
days_in_month: daysInMonth,
},
displayOrder: displayOrder++,
});
}
}
// --- tenant_setup (first invoice only) -----------------------------------
if (platformPricing.tenantSetupFeeChf > 0) {
const alreadyBilled = await tenantHasSetupFeeBilled(tenantName);
if (!alreadyBilled) {
lines.push({
tenantName,
kind: "tenant_setup",
description: `Setup fee for ${tenantName}`,
quantity: 1,
unitLabel: null,
unitPriceChf: platformPricing.tenantSetupFeeChf,
amountChf: round2(platformPricing.tenantSetupFeeChf),
metadata: null,
displayOrder: displayOrder++,
});
}
}
// --- ai_usage --------------------------------------------------------------
const aiUsage = await collectAiUsage(tenant, periodStart, periodEnd).catch(
(e) => {
warnings.push(
`AI usage fetch failed for ${tenantName}: ${e instanceof Error ? e.message : String(e)}`
);
return null;
}
);
if (aiUsage === null && tenant.status?.litellmTeamId) {
// teamId exists but fetch returned null — already warned above
} else if (aiUsage === null) {
warnings.push(
`Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.`
);
} else if (aiUsage.spendChf > 0) {
lines.push({
tenantName,
kind: "ai_usage",
description: `AI inference usage (${aiUsage.requestCount} requests)`,
quantity: 1,
unitLabel: null,
unitPriceChf: aiUsage.spendChf,
amountChf: aiUsage.spendChf,
metadata: {
litellm_key_alias: tenantName,
spend_chf: aiUsage.spendChf,
requests: aiUsage.requestCount,
},
displayOrder: displayOrder++,
});
}
// --- threema_messages -----------------------------------------------------
if (platformPricing.threemaMessageChf > 0) {
const threema = await collectThreemaUsage(tenant, periodStart, periodEnd);
if (threema && (threema.inCount + threema.outCount) > 0) {
const total = threema.inCount + threema.outCount;
lines.push({
tenantName,
kind: "threema_messages",
description: `Threema messages (${threema.inCount} in + ${threema.outCount} out)`,
quantity: total,
unitLabel: "msgs",
unitPriceChf: platformPricing.threemaMessageChf,
amountChf: round2(total * platformPricing.threemaMessageChf),
metadata: {
in_count: threema.inCount,
out_count: threema.outCount,
total_count: total,
},
displayOrder: displayOrder++,
});
}
}
// --- skill_usage ----------------------------------------------------------
// For each priced skill, count distinct UTC days the skill was
// enabled during the period.
if (skillPricing.length > 0) {
// Fetch all skill events for the tenant within the period plus
// a long lookback so we can determine state-at-period-start.
// The state-at-day-start logic in skillActiveDuringDay walks
// these events forward.
const allEvents = await listSkillEventsForTenant(
tenantName,
new Date(0),
new Date(periodEndMs)
);
for (const sp of skillPricing) {
const skillEvents = allEvents.filter((e) => e.skillId === sp.skillId);
// Skip cheaply if no events ever existed for this skill on
// this tenant.
if (skillEvents.length === 0) continue;
// Initial state assumption: false. The very first event is
// always 'enabled' (we only record toggles, and the implicit
// pre-toggle state for a never-seen skill is 'disabled').
let billableDays = 0;
for (const day of iterDays(periodStart, periodEnd)) {
if (skillActiveDuringDay(skillEvents, false, day.dayStartMs, day.dayEndMs)) {
billableDays++;
}
}
if (billableDays > 0) {
lines.push({
tenantName,
kind: "skill_usage",
description: `Skill: ${sp.skillId} (${billableDays} day${billableDays === 1 ? "" : "s"})`,
quantity: billableDays,
unitLabel: "days",
unitPriceChf: sp.dailyPriceChf,
amountChf: round2(billableDays * sp.dailyPriceChf),
metadata: {
skill_id: sp.skillId,
billable_days: billableDays,
event_count: skillEvents.length,
},
displayOrder: displayOrder++,
});
}
}
}
return lines;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function computeInvoiceDraft(opts: {
zitadelOrgId: string;
year: number;
month: number;
locale?: string;
paymentMethod?: InvoicePaymentMethod;
}): Promise<InvoiceDraft> {
const { zitadelOrgId, year, month } = opts;
const { periodStart, periodEnd, daysInMonth } = monthBounds(year, month);
const warnings: string[] = [];
// 1. Billing address. Required — without it we can't produce a
// valid invoice.
const orgBilling = await getOrgBilling(zitadelOrgId);
if (!orgBilling) {
throw new Error(
`Org ${zitadelOrgId} has no billing address on file. ` +
`The customer must complete /settings/billing before an invoice can be issued.`
);
}
const snapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName,
streetAddress: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,
country: orgBilling.country,
vatNumber: orgBilling.vatNumber ?? null,
billingEmail: orgBilling.billingEmail,
notes: orgBilling.notes ?? null,
};
// 2. Platform pricing + skill prices.
const platformPricing = await getPlatformPricing();
const skillPricing = await listSkillPricing();
// 3. Find all tenants for this org. We list from K8s (source of
// truth) and filter by the zitadel-org-id label.
const allTenants = await listTenants();
const orgTenants = allTenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === zitadelOrgId
);
if (orgTenants.length === 0) {
warnings.push(`No tenants found for org ${zitadelOrgId}.`);
}
// 4. Build lines, grouped per tenant (display order preserved).
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
let nextDisplayOrder = 0;
// Sort tenants by name for stable line ordering across regenerations.
orgTenants.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
for (const tenant of orgTenants) {
const tenantLines = await buildTenantLines({
tenant,
periodStart,
periodEnd,
daysInMonth,
platformPricing,
skillPricing,
warnings,
displayOrderOffset: nextDisplayOrder,
});
lines.push(...tenantLines);
nextDisplayOrder += tenantLines.length;
}
// 5. Subtotal & VAT.
const subtotal = round2(lines.reduce((acc, l) => acc + l.amountChf, 0));
const vat = vatRateForAddress(snapshot, platformPricing);
const vatAmount = round2((subtotal * vat.rate) / 100);
const total = round2(subtotal + vatAmount);
if (vat.note) warnings.push(vat.note);
// 6. Payment method: prefer pay-by-invoice if the admin enabled
// it for the org, otherwise default to invoice. Card payment
// is wired in Phase 4 — for Phase 2 every invoice is 'invoice'.
const orgConfig = await getOrgBillingConfig(zitadelOrgId);
const paymentMethod: InvoicePaymentMethod =
opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice");
// 7. Locale resolution
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
return {
zitadelOrgId,
periodStart,
periodEnd,
dueAt: dueDate(periodEnd, 30),
locale,
paymentMethod,
billingSnapshot: snapshot,
lines,
subtotalChf: subtotal,
vatRate: vat.rate,
vatAmountChf: vatAmount,
totalChf: total,
warnings,
};
}
/**
* Compute + render + persist in one step. If dryRun is true, the
* draft is returned without persisting and no PDF is rendered (the
* preview UI hits this).
*/
export async function generateInvoice(opts: {
zitadelOrgId: string;
year: number;
month: number;
locale?: string;
dryRun?: boolean;
}): Promise<{ draft: InvoiceDraft; invoice: Invoice | null }> {
const draft = await computeInvoiceDraft(opts);
if (opts.dryRun) {
return { draft, invoice: null };
}
// Render the PDF first — if it fails, we never touch the DB.
// The PDF render needs the invoice number, which is allocated
// inside createInvoice's transaction. To keep the PDF rendering
// outside the DB transaction (it can be slow), we render with a
// placeholder number, allocate the real number inside the tx,
// then re-render? No — instead we generate a temporary draft
// number for the PDF and accept that the displayed number on
// the PDF matches what we'll persist (because the allocator is
// serialized).
//
// Practical approach: render the PDF inside createInvoice's tx,
// immediately after allocation. This is fine because react-pdf
// is reasonably fast (~50200 ms for a typical invoice) and
// happens once per invoice.
//
// To avoid restructuring createInvoice, we do this in two
// passes: (1) reserve a number via createInvoice with a
// placeholder PDF; (2) render with the real number; (3) UPDATE
// pdf_data. The trade-off is two write trips but keeps the code
// shape simple. We accept it.
//
// Reasoning behind two-pass: if PDF render is moved inside the
// tx and fails (font missing, etc.), the allocated counter rolls
// back — good. But it also means the connection is held during
// render. At v1 scale that's fine; the choice is reversible.
// Pass 1: allocate number + persist with empty PDF.
const placeholder = await createInvoice(draft, null, null);
try {
const pdfBuffer = await renderInvoicePdf(
placeholder,
draft.lines.map((l, i) => ({
...l,
id: `tmp-${i}`,
invoiceId: placeholder.id,
}))
);
const filename = `${placeholder.invoiceNumber}.pdf`;
// Pass 2: store the PDF bytes.
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
const finalInvoice = await getInvoiceById(placeholder.id);
return { draft, invoice: finalInvoice ?? placeholder };
} catch (e) {
// Render failed — leave the persisted row in place so admin can
// inspect it, but surface the error.
throw new Error(
`Invoice ${placeholder.invoiceNumber} persisted but PDF rendering failed: ${
e instanceof Error ? e.message : String(e)
}. Use the admin "delete invoice" tool to clean up if needed.`
);
}
}