/** * 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 { CreditNote, CustomInvoiceDraftPayload, Invoice, InvoiceBillingSnapshot, InvoiceDraft, InvoiceDraftRecord, InvoiceLine, InvoiceLineKind, InvoicePaymentMethod, PiecedTenant, PlatformPricing, SkillPricing, TenantBillingLifecycle, TenantSkillEvent, TenantSuspensionEvent, } from "@/types"; import { attachCreditNotePdf, createCreditNote, createInvoice, deleteInvoiceDraft, getInvoiceById, getInvoiceDraftById, getOrgBilling, getOrgBillingConfig, getPlatformPricing, getTenantBillingLifecycle, listSkillEventsForTenant, listSkillPricing, listSuspensionEventsForTenant, markInvoicePaid, markInvoiceVoided, recordInvoiceRefund, setInvoiceStripePaymentIntent, 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 { renderCreditNotePdf } from "./credit-note-pdf"; import { sendAutoChargeFailedEmail, sendCreditNoteEmail, sendInvoiceIssuedEmail, } from "./email"; import { chargeInvoiceOffSession, createInvoiceRefund } from "./stripe"; 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. * Exported for reuse by the Phase 8 custom-invoice flow so both * pipelines (cron and custom) compute VAT identically. * * 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) */ export 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 — 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[]> { const { tenant, periodStart, periodEnd, daysInMonth, platformPricing, skillPricing, locale, warnings, } = opts; let displayOrder = opts.displayOrderOffset; const tenantName = tenant.metadata.name; const lines: Omit[] = []; // 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 { 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, contactName: orgBilling.contactName ?? null, 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[] = []; 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 (~50–200 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 9b-2: attempt off-session auto-charge BEFORE sending // any email. This drives which email goes out: // - Charge succeeded: skip the "your invoice is ready" email // (would be misleading — invoice is already paid). Stripe // sends an automated receipt to billingSnapshot.billingEmail. // - Charge failed: send the auto-charge-failed email instead // of the regular issued email (clear action: pay manually). // - Charge skipped (pay_by_invoice / no card / disabled): // send the regular "your invoice is ready" email — that's // the only signal the customer gets. const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id); const settled = chargeOutcome.kind === "succeeded" ? (await getInvoiceById(placeholder.id)) ?? finalInvoice ?? placeholder : finalInvoice ?? placeholder; const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [ "en", "de", "fr", "it", ]; const emailLocale = supportedLocales.includes(settled.locale as any) ? (settled.locale as "en" | "de" | "fr" | "it") : "de"; const snapshot = settled.billingSnapshot; if (chargeOutcome.kind === "succeeded") { console.log( `Invoice ${settled.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.` ); } else if (chargeOutcome.kind === "failed") { // Send the auto-charge-failed email (not the regular issued // email). The customer should be told the charge failed and // pointed to the manual-pay flow. try { if (snapshot.billingEmail) { await sendAutoChargeFailedEmail({ to: snapshot.billingEmail, contactName: snapshot.companyName, companyName: snapshot.companyName, invoiceNumber: settled.invoiceNumber, totalChf: settled.totalChf, currency: "CHF", dueAt: settled.dueAt, reasonForCustomer: chargeOutcome.reasonForCustomer, locale: emailLocale, }); } } catch (e) { console.error( `Invoice ${settled.invoiceNumber} auto-charge failed; failed-charge email also failed:`, e ); } } else { // Skipped — pay-by-invoice / disabled / no card. Send the // regular issued email so the customer knows there's // something to pay. try { if (snapshot.billingEmail) { await sendInvoiceIssuedEmail({ to: snapshot.billingEmail, contactName: snapshot.companyName, 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: emailLocale, }); } 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: settled }; } 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.` ); } } // --------------------------------------------------------------------------- // Phase 7 — void and refund orchestration // --------------------------------------------------------------------------- export class VoidNotAllowedError extends Error { constructor(message: string, public readonly currentStatus: string) { super(message); this.name = "VoidNotAllowedError"; } } export class RefundNotAllowedError extends Error { constructor(message: string, public readonly currentStatus: string) { super(message); this.name = "RefundNotAllowedError"; } } /** * Sanitize a locale string to the supported four. Used when picking * which translation block to render emails/PDFs with. We never * fall back to admin's locale here — the credit note inherits the * invoice's locale so both documents read consistently to the * customer. */ function pickSupportedLocale( locale: string | null | undefined ): "de" | "en" | "fr" | "it" { const supported = ["de", "en", "fr", "it"] as const; return (supported as readonly string[]).includes(locale ?? "") ? (locale as "de" | "en" | "fr" | "it") : "de"; } /** * Round a CHF amount to 2 decimal places. Used when proportionally * splitting VAT between subtotal and refund amount — avoids * accumulating fractional rappen across operations. */ function roundChf(amount: number): number { return Math.round(amount * 100) / 100; } /** * Void an unpaid invoice. State transition: open/overdue → void. * * Side effects, in order: * 1. Mark the invoice voided (status, void_reason, voided_at, voided_by) * 2. Insert credit_notes row (kind='void', amount=full invoice total) * 3. Render the credit-note PDF and attach it to the row * 4. Best-effort email to the billing contact * * Not allowed: * - status='paid' (use refundInvoice instead — voiding paid * invoices would create a record mismatch with the payment * processor) * - status='void' (already voided) * - status='draft' (drafts aren't issued; nothing to void) * - status='partially_refunded' / 'fully_refunded' (use refund * for the remaining amount instead) * * Throws VoidNotAllowedError if the invoice is in a non-voidable * state. Caller surfaces this as 409 Conflict to the admin. */ export async function voidInvoice(params: { invoiceId: string; reason: string; voidedBy: string; }): Promise { const invoice = await getInvoiceById(params.invoiceId); if (!invoice) { throw new Error(`Invoice not found: ${params.invoiceId}`); } // Only unpaid invoices can be voided. The state machine puts // paid invoices on the refund path; voiding them would skip the // payment reversal and leave the customer's money in our account // with no obligation showing in the portal. if (!["open", "overdue"].includes(invoice.status)) { throw new VoidNotAllowedError( `Cannot void invoice in status '${invoice.status}'. Voids are allowed only for open or overdue invoices; paid invoices must be refunded.`, invoice.status ); } const locale = pickSupportedLocale(invoice.locale); // The credit note matches the invoice 1:1 in amount and VAT. // We carry the same VAT breakdown so the PDF can render // "subtotal + VAT" the same way the original invoice did. const creditNote = await createCreditNote({ invoiceId: invoice.id, zitadelOrgId: invoice.zitadelOrgId, kind: "void", amountChf: invoice.totalChf, vatAmountChf: invoice.vatAmountChf, reason: params.reason || null, issuedBy: params.voidedBy, locale, billingSnapshot: invoice.billingSnapshot, }); // Mark invoice voided AFTER the credit note row exists, so the // status change has a credit note to point at. If anything below // here fails (PDF render, email), the invoice is still correctly // voided and the credit note row exists — just without a PDF // until manually re-rendered. await markInvoiceVoided({ invoiceId: invoice.id, reason: params.reason, voidedBy: params.voidedBy, }); // Render PDF + attach. PDF failure here doesn't undo the void — // the customer can be told their invoice is voided and the PDF // can be re-issued later. We surface the error in the response // so admin knows to retry, but the void itself stands. try { const pdfBuffer = await renderCreditNotePdf(creditNote, invoice); const filename = `${creditNote.creditNoteNumber}.pdf`; await attachCreditNotePdf(creditNote.id, pdfBuffer, filename); } catch (e) { console.error( `Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`, e ); } // Best-effort email. Same fail-soft pattern as invoice issuance. try { const snap = invoice.billingSnapshot; if (snap.billingEmail) { await sendCreditNoteEmail({ to: snap.billingEmail, contactName: snap.contactName || snap.companyName, companyName: snap.companyName, creditNoteNumber: creditNote.creditNoteNumber, invoiceNumber: invoice.invoiceNumber, amountChf: creditNote.amountChf, currency: "CHF", kind: "void", reason: params.reason || null, locale, }); } } catch (e) { console.error( `Credit note ${creditNote.creditNoteNumber} issued; email send failed.`, e ); } return creditNote; } /** * Refund a paid invoice (in part or in full). State transition: * paid → partially_refunded (if amount < remaining) * paid → fully_refunded (if amount >= remaining) * partially_refunded → fully_refunded (if cumulative >= total) * * Side effects, in order: * 1. If the invoice was Stripe-paid (payment_method='card' with a * stripe_payment_intent_id) AND no `existingStripeRefund` was * passed, call Stripe to issue the refund. Stripe is the source * of truth for actual money movement; we mirror its outcome * locally. * 2. Insert credit_notes row (kind='refund', amount=refund amount, * VAT proportional) * 3. Insert invoice_refunds row, linking to the credit note and to * the Stripe refund (if any). recordInvoiceRefund updates the * invoice's status atomically based on the new running total. * 4. Render PDF + attach * 5. Best-effort email * * `existingStripeRefund` is for the webhook path: when Stripe fires * `charge.refunded` for a refund that was initiated directly in the * Stripe Dashboard (not via this portal), the webhook needs to * mirror the refund into the DB and issue a credit note WITHOUT * calling Stripe again. Pass the refund id and status to skip the * Stripe call. * * Not allowed: * - status not in {paid, partially_refunded} — full refunds are * only meaningful against actual payment * - amount <= 0 or > remaining refundable * * For invoice-paid (non-Stripe) customers the Stripe step is * skipped; refund settlement happens out-of-band (bank transfer) * and admin records the action in the portal. */ export async function refundInvoice(params: { invoiceId: string; amountChf: number; reason: string; refundedBy: string; /** * Webhook path: a Stripe refund that has already been created * (in the Stripe Dashboard or via a prior API call) and now needs * to be mirrored into the portal. When set, the Stripe API call * is skipped and the provided id/status are recorded as-is. */ existingStripeRefund?: { id: string; status: "pending" | "succeeded" | "failed" | "canceled"; }; }): Promise { const invoice = await getInvoiceById(params.invoiceId); if (!invoice) { throw new Error(`Invoice not found: ${params.invoiceId}`); } if (!["paid", "partially_refunded"].includes(invoice.status)) { throw new RefundNotAllowedError( `Cannot refund invoice in status '${invoice.status}'. Refunds are allowed only for paid invoices.`, invoice.status ); } if (params.amountChf <= 0) { throw new RefundNotAllowedError( "Refund amount must be greater than zero.", invoice.status ); } const remaining = roundChf(invoice.totalChf - invoice.refundedTotalChf); if (params.amountChf - remaining > 0.005) { // Allow a 0.005 tolerance to account for floating-point dust; // anything genuinely larger is a real over-refund attempt. throw new RefundNotAllowedError( `Refund amount CHF ${params.amountChf.toFixed(2)} exceeds remaining refundable CHF ${remaining.toFixed(2)}.`, invoice.status ); } const locale = pickSupportedLocale(invoice.locale); // Proportional VAT split: refunded VAT / total VAT = refunded // amount / total amount. Keep the proportion explicit so the // credit note's "subtotal + VAT" lines reconcile to the same // VAT rate as the original invoice. const vatPortion = invoice.totalChf > 0 ? roundChf((params.amountChf * invoice.vatAmountChf) / invoice.totalChf) : 0; // Step 1: Stripe (only for card-paid invoices, and only when the // caller hasn't already created the refund). We do this BEFORE // any local DB writes for refund tracking — Stripe is the source // of truth for money movement, and if the Stripe call fails we // must NOT have recorded the refund locally (the customer would // see a credit note for money they never received). // // The charge.refunded webhook will also fire later, but we record // the refund here too so the admin gets immediate confirmation // and the credit note can be issued without waiting for the // webhook round-trip. The webhook is idempotent (dedups by // stripe_refund_id) so it's safe to do both. let stripeRefundId: string | null = null; let stripeStatus: "pending" | "succeeded" | "failed" | "canceled" = "succeeded"; const isStripePaid = invoice.paymentMethod === "card" && !!invoice.stripePaymentIntentId; if (params.existingStripeRefund) { // Webhook path: don't call Stripe again; trust the provided id. stripeRefundId = params.existingStripeRefund.id; stripeStatus = params.existingStripeRefund.status; } else if (isStripePaid) { try { const refund = await createInvoiceRefund({ paymentIntentId: invoice.stripePaymentIntentId!, amountChf: params.amountChf, reason: "requested_by_customer", metadata: { invoice_number: invoice.invoiceNumber, refunded_by: params.refundedBy, }, }); stripeRefundId = refund.id; // Map Stripe statuses to our enum. Anything other than // 'succeeded' or 'pending' is treated as a failure — we // don't record the credit note in that case (see below). if (refund.status === "succeeded") stripeStatus = "succeeded"; else if (refund.status === "pending") stripeStatus = "pending"; else if (refund.status === "canceled") stripeStatus = "canceled"; else stripeStatus = "failed"; } catch (e) { throw new Error( `Stripe refund failed: ${e instanceof Error ? e.message : String(e)}` ); } if (stripeStatus === "failed" || stripeStatus === "canceled") { throw new Error( `Stripe refund returned non-success status: ${stripeStatus}` ); } } // Step 2: insert credit note (PDF still null at this point). const creditNote = await createCreditNote({ invoiceId: invoice.id, zitadelOrgId: invoice.zitadelOrgId, kind: "refund", amountChf: params.amountChf, vatAmountChf: vatPortion, reason: params.reason || null, issuedBy: params.refundedBy, locale, billingSnapshot: invoice.billingSnapshot, }); // Step 3: record the refund event and bump invoice status. // recordInvoiceRefund handles status transitions and idempotency. await recordInvoiceRefund({ invoiceId: invoice.id, stripeRefundId, amountChf: params.amountChf, reason: params.reason || null, refundedBy: params.refundedBy, creditNoteId: creditNote.id, status: stripeStatus, }); // Step 4: render + attach PDF. As with voidInvoice, a PDF failure // here doesn't undo the refund — the refund happened (in Stripe // and the DB), only the document is missing. Admin can re-render. try { const pdfBuffer = await renderCreditNotePdf(creditNote, invoice); const filename = `${creditNote.creditNoteNumber}.pdf`; await attachCreditNotePdf(creditNote.id, pdfBuffer, filename); } catch (e) { console.error( `Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`, e ); } // Step 5: best-effort email. try { const snap = invoice.billingSnapshot; if (snap.billingEmail) { await sendCreditNoteEmail({ to: snap.billingEmail, contactName: snap.contactName || snap.companyName, companyName: snap.companyName, creditNoteNumber: creditNote.creditNoteNumber, invoiceNumber: invoice.invoiceNumber, amountChf: creditNote.amountChf, currency: "CHF", kind: "refund", reason: params.reason || null, locale, }); } } catch (e) { console.error( `Credit note ${creditNote.creditNoteNumber} issued; email send failed.`, e ); } return creditNote; } // --------------------------------------------------------------------------- // Phase 8 — custom invoices (admin-entered, ad-hoc) // --------------------------------------------------------------------------- export class CustomInvoiceValidationError extends Error { constructor(message: string) { super(message); this.name = "CustomInvoiceValidationError"; } } /** * Compute the totals for a custom-invoice draft payload, applying * the same VAT logic the auto cron uses (vatRateForAddress against * the org's billing snapshot). * * Returns the InvoiceDraft the createInvoice helper expects. * Throws CustomInvoiceValidationError on: * - no lines * - any line with empty description or zero quantity * - invalid date (issue or due) * - issue date in past beyond 1 year (probably a typo) * - due before issue * * Negative line amounts are intentionally allowed — they're the * Rabatt / discount mechanism (one row with a negative unitPriceChf). * The algebraic sum becomes the subtotal. */ export async function computeCustomInvoiceTotals(params: { zitadelOrgId: string; payload: CustomInvoiceDraftPayload; }): Promise { const { zitadelOrgId, payload } = params; // Validation if (!payload.lines || payload.lines.length === 0) { throw new CustomInvoiceValidationError( "Custom invoice must have at least one line." ); } for (let i = 0; i < payload.lines.length; i++) { const ln = payload.lines[i]; if (!ln.description || !ln.description.trim()) { throw new CustomInvoiceValidationError( `Line ${i + 1}: description is required.` ); } if ( typeof ln.quantity !== "number" || !isFinite(ln.quantity) || ln.quantity === 0 ) { throw new CustomInvoiceValidationError( `Line ${i + 1}: quantity must be a non-zero number.` ); } if (typeof ln.unitPriceChf !== "number" || !isFinite(ln.unitPriceChf)) { throw new CustomInvoiceValidationError( `Line ${i + 1}: unit price must be a number (use negative for discounts).` ); } } const issueDate = payload.issueDate; const dueDate = payload.dueDate; if (!/^\d{4}-\d{2}-\d{2}$/.test(issueDate)) { throw new CustomInvoiceValidationError( "Issue date must be a valid YYYY-MM-DD." ); } if (!/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) { throw new CustomInvoiceValidationError( "Due date must be a valid YYYY-MM-DD." ); } if (dueDate < issueDate) { throw new CustomInvoiceValidationError( "Due date cannot be before issue date." ); } // Billing snapshot — required for any invoice to render. const orgBilling = await getOrgBilling(zitadelOrgId); if (!orgBilling) { throw new CustomInvoiceValidationError( "Org has no billing configuration. Ask the customer to complete onboarding first, or set the billing info from the admin panel." ); } // Build the same snapshot shape the auto-cron freezes. Mirroring // the auto flow keeps the PDF renderer happy with one code path. const snapshot: InvoiceBillingSnapshot = { companyName: orgBilling.companyName, contactName: orgBilling.contactName ?? null, streetAddress: orgBilling.streetAddress, city: orgBilling.city, postalCode: orgBilling.postalCode, country: orgBilling.country, vatNumber: orgBilling.vatNumber ?? null, billingEmail: orgBilling.billingEmail, notes: orgBilling.notes ?? null, }; // VAT — same logic as auto. const platformPricing = await getPlatformPricing(); const vat = vatRateForAddress(snapshot, platformPricing); // Build invoice lines. quantity * unitPrice rounded to 2 decimals // (rappen precision). We carry the per-line amount on the row so // the PDF doesn't need to recompute and any rounding remains // identical between rendering passes. // // tenantName=null because custom invoices aren't bound to a // specific tenant. unitLabel=null because admin-entered lines are // free-form (the auto-cron lines use "day" / "request" / // "message" — for custom lines the quantity is just a number). // metadata.description preserves the admin's input so // formatLineDescription can read it via the metadata channel // (the row's description column also has it, redundantly, for // safety). displayOrder reflects the order the admin added the // rows so the PDF renders them top-to-bottom unchanged. const lines: Omit[] = payload.lines.map( (ln, idx) => { const amount = Math.round(ln.quantity * ln.unitPriceChf * 100) / 100; return { tenantName: null, kind: "custom_line" as InvoiceLineKind, description: ln.description.trim(), quantity: ln.quantity, unitLabel: null, unitPriceChf: ln.unitPriceChf, amountChf: amount, metadata: { description: ln.description.trim() }, displayOrder: idx, }; } ); // Subtotal is the algebraic sum (negative lines reduce it). const subtotalChf = Math.round( lines.reduce((s, l) => s + l.amountChf, 0) * 100 ) / 100; // VAT applies to the subtotal AFTER discounts (which is the // legal default in CH — discounts reduce the taxable base). const vatAmountChf = Math.round(subtotalChf * (vat.rate / 100) * 100) / 100; const totalChf = Math.round((subtotalChf + vatAmountChf) * 100) / 100; return { zitadelOrgId, source: "custom", periodStart: null, periodEnd: null, issuedAt: `${issueDate}T00:00:00Z`, dueAt: dueDate, locale: payload.locale, paymentMethod: payload.paymentMethod, billingSnapshot: snapshot, lines, subtotalChf, vatRate: vat.rate, vatAmountChf, totalChf, warnings: [], }; } /** * Issue a custom invoice from a draft. Three-step flow: * * 1. Compute totals + validate the payload (computeCustomInvoiceTotals) * 2. Persist via createInvoice (allocates the number, inserts the * row + lines, source='custom', issued_at honours the override) * 3. Render PDF, send email — best-effort each. PDF render failure * leaves the row in place with no PDF; admin can re-render. Email * failure is logged. * * After successful persistence, the draft row is deleted (its job * is done). If persistence fails, the draft stays so the admin can * fix the issue and try again. */ export async function issueCustomInvoiceDraft(params: { draftId: string; issuedBy: string; }): Promise { const draft = await getInvoiceDraftById(params.draftId); if (!draft) { throw new CustomInvoiceValidationError( `Draft not found: ${params.draftId}` ); } const invoiceDraft = await computeCustomInvoiceTotals({ zitadelOrgId: draft.zitadelOrgId, payload: draft.payload, }); // Two-pass: persist without PDF first, render against the canonical // row (now has a number), then attach. Same pattern as the auto // flow — keeps the PDF self-referential without juggling temporary // numbers. const placeholder = await createInvoice(invoiceDraft, null, null); let pdfBuffer: Buffer | null = null; try { pdfBuffer = await renderInvoicePdf( placeholder, // Same pattern as the auto-cron generateInvoice: synthesize // temporary ids for the PDF renderer. The real DB rows have // these populated post-insert, but the renderer only reads // them for React keys (display) and id-comparison-free // operations, so synthetic values are fine. invoiceDraft.lines.map((l, i) => ({ ...l, id: `tmp-${i}`, invoiceId: placeholder.id, })) ); const filename = `${placeholder.invoiceNumber}.pdf`; await updateInvoicePdf(placeholder.id, pdfBuffer, filename); } catch (e) { console.error( `Custom invoice ${placeholder.invoiceNumber} persisted but PDF render failed:`, e ); // Don't throw — the row exists. Admin can re-render via a // future tool (Phase 8.5 or just by deleting+reissuing). } // Phase 9b-2: same auto-charge + email branching as the cron // path. Custom invoices go through the same gate: pay_by_invoice // / auto_charge_enabled / saved card determine whether we attempt // the charge. const chargeOutcome = await chargeInvoiceIfPossible(placeholder.id); const settledCustom = chargeOutcome.kind === "succeeded" ? (await getInvoiceById(placeholder.id)) ?? placeholder : placeholder; if (chargeOutcome.kind === "succeeded") { console.log( `Custom invoice ${settledCustom.invoiceNumber} auto-charged successfully (intent ${chargeOutcome.paymentIntentId}); Stripe receipt handles customer email.` ); } else if (chargeOutcome.kind === "failed") { try { const snap = invoiceDraft.billingSnapshot; if (snap.billingEmail) { await sendAutoChargeFailedEmail({ to: snap.billingEmail, contactName: snap.contactName || snap.companyName, companyName: snap.companyName, invoiceNumber: settledCustom.invoiceNumber, totalChf: settledCustom.totalChf, currency: "CHF", dueAt: settledCustom.dueAt, reasonForCustomer: chargeOutcome.reasonForCustomer, locale: invoiceDraft.locale as "de" | "en" | "fr" | "it", }); } } catch (e) { console.error( `Custom invoice ${settledCustom.invoiceNumber} auto-charge failed; failed-charge email also failed:`, e ); } } else { // Skipped — send the regular issued email. try { const snap = invoiceDraft.billingSnapshot; if (snap.billingEmail) { await sendInvoiceIssuedEmail({ to: snap.billingEmail, contactName: snap.contactName || snap.companyName, companyName: snap.companyName, invoiceNumber: settledCustom.invoiceNumber, totalChf: settledCustom.totalChf, currency: "CHF", dueAt: settledCustom.dueAt, lineCount: invoiceDraft.lines.length, periodStart: null, periodEnd: null, locale: invoiceDraft.locale as "de" | "en" | "fr" | "it", }); } } catch (e) { console.error( `Custom invoice ${settledCustom.invoiceNumber} issued; email send failed.`, e ); } } // Draft did its job — remove it. If this fails the issuance // still stands (we already have a real invoice). Log and move on. try { await deleteInvoiceDraft(draft.id); } catch (e) { console.error( `Custom invoice ${placeholder.invoiceNumber} issued but draft ${draft.id} could not be deleted:`, e ); } return settledCustom; } /** * Preview a draft as a PDF without persisting an invoice. The PDF * is rendered with a placeholder number ("DRAFT") and not stored * anywhere — the caller streams the bytes back to the admin's * browser for review. * * Throws CustomInvoiceValidationError if the draft isn't ready to * issue (no lines, missing billing snapshot, etc.) so the editor * can surface the problem before any rendering work. */ export async function renderCustomDraftPreview( draftId: string ): Promise { const draft = await getInvoiceDraftById(draftId); if (!draft) { throw new CustomInvoiceValidationError(`Draft not found: ${draftId}`); } const invoiceDraft = await computeCustomInvoiceTotals({ zitadelOrgId: draft.zitadelOrgId, payload: draft.payload, }); // Render against a synthetic Invoice — same shape the persisted // row would have, but with a DRAFT placeholder number. No DB // writes. The PDF renderer doesn't care; it just consumes the // Invoice + lines. const fakeInvoice: Invoice = { id: "preview", invoiceNumber: "DRAFT", zitadelOrgId: draft.zitadelOrgId, source: "custom", periodStart: null, periodEnd: null, issuedAt: invoiceDraft.issuedAt ?? new Date().toISOString(), dueAt: invoiceDraft.dueAt, subtotalChf: invoiceDraft.subtotalChf, vatRate: invoiceDraft.vatRate, vatAmountChf: invoiceDraft.vatAmountChf, totalChf: invoiceDraft.totalChf, status: "draft", locale: invoiceDraft.locale, paymentMethod: invoiceDraft.paymentMethod, billingSnapshot: invoiceDraft.billingSnapshot, stripePaymentIntentId: null, pdfFilename: null, hasPdf: false, adminNotes: null, paidAt: null, paidBy: null, paidMethodDetail: null, voidReason: null, voidedAt: null, voidedBy: null, refundedTotalChf: 0, createdAt: new Date().toISOString(), }; return renderInvoicePdf( fakeInvoice, invoiceDraft.lines.map((l, i) => ({ ...l, id: `tmp-${i}`, invoiceId: fakeInvoice.id, })) ); } // --------------------------------------------------------------------------- // Phase 9b — tenant setup-fee invoice at order time // --------------------------------------------------------------------------- /** * Build and persist the one-line custom invoice that captures * the tenant setup fee at order time. The customer is then * redirected to Stripe Checkout to pay it. * * - source = 'custom' so the monthly cron's per-period uniqueness * guard (partial index WHERE source='auto') doesn't interfere * - line.kind = 'tenant_setup' so the monthly cron's setup-fee * dedup (tenantHasSetupFeeBilled) sees this as the setup fee * billing event for the future tenant * - line.tenant_name = the derived name (computed from request id * via deriveTenantName) so the dedup query finds the line * - period_start / period_end stay null (no billing period) * - issuedAt = now (no override) * - dueAt = same day (charge happens immediately via Checkout) * * VAT uses the same vatRateForAddress() logic as the monthly cron * and the admin custom-invoice flow. */ export async function createTenantSetupFeeInvoice(params: { zitadelOrgId: string; tenantName: string; billingSnapshot: InvoiceBillingSnapshot; locale: "de" | "en" | "fr" | "it"; paymentMethod: InvoicePaymentMethod; }): Promise { const platformPricing = await getPlatformPricing(); const setupFeeChf = platformPricing.tenantSetupFeeChf; if (setupFeeChf <= 0) { throw new Error( "createTenantSetupFeeInvoice called but tenant_setup_fee_chf is 0 — caller should skip the charge flow entirely." ); } const vat = vatRateForAddress(params.billingSnapshot, platformPricing); const subtotalChf = setupFeeChf; const vatAmountChf = Math.round(subtotalChf * (vat.rate / 100) * 100) / 100; const totalChf = Math.round((subtotalChf + vatAmountChf) * 100) / 100; // tenant_name on the line is the dedup anchor. metadata empty — // tenant_setup lines from the monthly cron also carry no metadata // beyond what billing-i18n needs, which is just the kind itself. const lines: Omit[] = [ { tenantName: params.tenantName, kind: "tenant_setup" as InvoiceLineKind, description: formatLineDescription( { kind: "tenant_setup", tenantName: params.tenantName, metadata: null }, params.locale ), quantity: 1, unitLabel: null, unitPriceChf: setupFeeChf, amountChf: setupFeeChf, metadata: null, displayOrder: 0, }, ]; const today = new Date().toISOString().slice(0, 10); const draft: InvoiceDraft = { zitadelOrgId: params.zitadelOrgId, source: "custom", periodStart: null, periodEnd: null, issuedAt: undefined, // let createInvoice default to now() dueAt: today, locale: params.locale, paymentMethod: params.paymentMethod, billingSnapshot: params.billingSnapshot, lines, subtotalChf, vatRate: vat.rate, vatAmountChf, totalChf, warnings: [], }; // Persist without PDF — the PDF render here would block the // Checkout redirect path and isn't needed for the customer's // payment step. Render lazily after payment succeeds (Phase 9c // candidate); for now the invoice carries no PDF until then. // It'll still appear on /billing for the customer; the download // button will be disabled (hasPdf = false) until a render lands. const invoice = await createInvoice(draft, null, null); // Best-effort: render the PDF asynchronously so the customer // has it on /billing soon after paying. The async fire-and- // forget pattern: failures only log, the invoice row stays // valid either way. renderInvoicePdf( invoice, lines.map((l, i) => ({ ...l, id: `tmp-${i}`, invoiceId: invoice.id, })) ) .then((pdf) => updateInvoicePdf(invoice.id, pdf, `${invoice.invoiceNumber}.pdf`) ) .catch((e) => console.error( `Setup-fee invoice ${invoice.invoiceNumber} PDF render failed (async):`, e ) ); return invoice; } // --------------------------------------------------------------------------- // Phase 9b-2 — recurring off-session auto-charge // --------------------------------------------------------------------------- export type AutoChargeOutcome = | { kind: "skipped"; reason: string } | { kind: "succeeded"; paymentIntentId: string } | { kind: "failed"; reasonForCustomer: string; code?: string }; /** * Reduce a Stripe decline code into a short, locale-neutral string * the customer can read. We never put the raw Stripe message in * an email (it can leak BIN, country, etc.); this maps known codes * to safe equivalents and falls back to a generic "card was * declined" string for unknown codes. * * Phase 9b-2 keeps this in English only — the email template * translates the surrounding copy, and the reason itself is short * enough that admin can decide later whether to localize it. */ function describeDeclineCode(code: string | undefined, fallback: string): string { if (!code) return fallback; const map: Record = { card_declined: "Card was declined by the issuer.", expired_card: "Card has expired.", insufficient_funds: "Insufficient funds.", incorrect_cvc: "Card security code (CVC) was incorrect.", processing_error: "Card processing error at the issuer.", authentication_required: "Authentication required (3D Secure).", do_not_honor: "Card was declined by the issuer (do not honor).", pickup_card: "Card cannot be used — please contact the issuer.", lost_card: "Card was reported lost.", stolen_card: "Card was reported stolen.", generic_decline: "Card was declined.", }; return map[code] ?? fallback; } /** * Decide whether an invoice can be auto-charged and attempt it. * * Gates (in order — first match wins): * 1. Invoice not in 'open' status → skip ("not_open") * 2. org_billing_config.pay_by_invoice = true → skip ("pay_by_invoice") * (admin override for bank-transfer customers) * 3. org_billing_config.auto_charge_enabled = false → skip ("disabled") * 4. No saved payment method id → skip ("no_card") * 5. No Stripe customer id → skip ("no_customer") — shouldn't happen * if PM is saved (the setup flow creates one) but defensive * * On charge attempt: * - succeeded: markInvoicePaid + return outcome * - declined / requires_action: leave invoice open, return reason * for the caller to send the auto-charge-failed email * * This function is idempotent on the invoice side (markInvoicePaid * is a no-op if already paid). Calling twice in rapid succession * may cause two Stripe charges if both attempts pass the gates — * the caller (generateInvoice / issueCustomInvoiceDraft) only * calls once per issuance and is the natural single-shot guard. */ export async function chargeInvoiceIfPossible( invoiceId: string ): Promise { const invoice = await getInvoiceById(invoiceId); if (!invoice) { return { kind: "skipped", reason: "invoice_not_found" }; } if (invoice.status !== "open") { return { kind: "skipped", reason: `not_open (status=${invoice.status})` }; } const cfg = await getOrgBillingConfig(invoice.zitadelOrgId); if (cfg.payByInvoice) { return { kind: "skipped", reason: "pay_by_invoice" }; } if (cfg.autoChargeEnabled === false) { return { kind: "skipped", reason: "disabled" }; } if (!cfg.stripeDefaultPaymentMethodId) { return { kind: "skipped", reason: "no_card" }; } if (!cfg.stripeCustomerId) { return { kind: "skipped", reason: "no_customer" }; } const outcome = await chargeInvoiceOffSession({ invoice, customerId: cfg.stripeCustomerId, paymentMethodId: cfg.stripeDefaultPaymentMethodId, receiptEmail: invoice.billingSnapshot.billingEmail ?? null, }); if (outcome.status === "succeeded") { // Persist the PI id + flip to paid in one shot. markInvoicePaid // is idempotent (returns null if already paid). await setInvoiceStripePaymentIntent(invoice.id, outcome.paymentIntentId); await markInvoicePaid(invoice.id, { paidBy: "stripe", paidMethodDetail: `Auto-charge (${outcome.paymentIntentId})`, }); return { kind: "succeeded", paymentIntentId: outcome.paymentIntentId }; } // Map outcome to a customer-safe reason string. if (outcome.status === "requires_action") { return { kind: "failed", reasonForCustomer: "Authentication required (3D Secure). Please pay manually so your bank can complete verification.", code: "authentication_required", }; } // declined return { kind: "failed", reasonForCustomer: describeDeclineCode(outcome.code, outcome.reason), code: outcome.code, }; }