Files
pieced-portal/src/lib/billing.ts
admin cf190e5ac5
Some checks failed
Build and Push / build (push) Failing after 46s
Phase3: Billing Customerpage/Mailings
2026-05-24 21:44:10 +02:00

838 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
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 { sendInvoiceIssuedEmail } from "./email";
import { formatLineDescription } from "./billing-i18n";
// ---------------------------------------------------------------------------
// 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;
// threema-relay.getUsage takes Date params, not strings, and
// returns a discriminated RelayResult<UsageBreakdown> — the
// `ok` discriminant must be checked before reading the totals.
// Period end is exclusive in the relay's API; pass the next-day
// midnight UTC to capture the full last day of the period.
const from = new Date(`${periodStart}T00:00:00Z`);
const to = new Date(`${periodEnd}T00:00:00Z`);
to.setUTCDate(to.getUTCDate() + 1);
const result = await getThreemaUsage(tenant.metadata.name, from, to).catch(
() => null
);
if (!result || !result.ok) return null;
return {
inCount: Number(result.totals?.in ?? 0),
outCount: Number(result.totals?.out ?? 0),
};
}
// ---------------------------------------------------------------------------
// Per-tenant line builders
// ---------------------------------------------------------------------------
async function buildTenantLines(opts: {
tenant: PiecedTenant;
periodStart: string;
periodEnd: string;
daysInMonth: number;
platformPricing: PlatformPricing;
skillPricing: SkillPricing[];
locale: string;
warnings: string[];
displayOrderOffset: number;
}): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> {
const {
tenant,
periodStart,
periodEnd,
daysInMonth,
platformPricing,
skillPricing,
locale,
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);
const metadata = {
billable_days: billableDays,
suspended_days: suspendedDays,
days_in_month: daysInMonth,
};
lines.push({
tenantName,
kind: "tenant_monthly",
description: formatLineDescription(
{ kind: "tenant_monthly", tenantName, metadata },
locale
),
quantity: billableDays,
unitLabel: "days",
unitPriceChf: round2(unit * 1e5) / 1e5,
amountChf: amount,
metadata,
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: formatLineDescription(
{ kind: "tenant_setup", tenantName, metadata: null },
locale
),
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) {
const aiMetadata = {
litellm_key_alias: tenantName,
spend_chf: aiUsage.spendChf,
requests: aiUsage.requestCount,
};
lines.push({
tenantName,
kind: "ai_usage",
description: formatLineDescription(
{ kind: "ai_usage", tenantName, metadata: aiMetadata },
locale
),
quantity: 1,
unitLabel: null,
unitPriceChf: aiUsage.spendChf,
amountChf: aiUsage.spendChf,
metadata: aiMetadata,
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;
const threemaMetadata = {
in_count: threema.inCount,
out_count: threema.outCount,
total_count: total,
};
lines.push({
tenantName,
kind: "threema_messages",
description: formatLineDescription(
{ kind: "threema_messages", tenantName, metadata: threemaMetadata },
locale
),
quantity: total,
unitLabel: "msgs",
unitPriceChf: platformPricing.threemaMessageChf,
amountChf: round2(total * platformPricing.threemaMessageChf),
metadata: threemaMetadata,
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) {
// 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: formatLineDescription(
{ kind: "skill_usage", tenantName, metadata: skillMetadata },
locale
),
quantity: billableDays,
unitLabel: "days",
unitPriceChf: sp.dailyPriceChf,
amountChf: round2(billableDays * sp.dailyPriceChf),
metadata: skillMetadata,
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).
// 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.
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,
locale,
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");
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);
// Phase 3: best-effort notification to the billing contact.
// We send AFTER the PDF is fully persisted (so the deep link
// in the email immediately resolves to a downloadable PDF) but
// BEFORE returning, since the cron caller doesn't otherwise
// know to trigger this. Failure is logged, never thrown — a
// mail-server hiccup must not roll back an issued invoice.
// The recipient is the billing email captured in the invoice
// snapshot (immutable; reflects who was on file at issue time).
try {
const settled = finalInvoice ?? placeholder;
const snapshot = settled.billingSnapshot;
if (snapshot.billingEmail) {
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
"en", "de", "fr", "it",
];
const locale = supportedLocales.includes(settled.locale as any)
? (settled.locale as "en" | "de" | "fr" | "it")
: "de";
await sendInvoiceIssuedEmail({
to: snapshot.billingEmail,
contactName: snapshot.companyName, // no separate contact-name field
companyName: snapshot.companyName,
invoiceNumber: settled.invoiceNumber,
totalChf: settled.totalChf,
currency: "CHF",
dueAt: settled.dueAt,
lineCount: draft.lines.length,
periodStart: settled.periodStart,
periodEnd: settled.periodEnd,
locale,
});
} else {
console.warn(
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
);
}
} catch (e) {
console.error(
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
e
);
}
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.`
);
}
}