Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s
Some checks failed
Build and Push / build (push) Failing after 28s
This commit is contained in:
651
src/lib/billing-pdf.tsx
Normal file
651
src/lib/billing-pdf.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
/**
|
||||
* Invoice PDF rendering via @react-pdf/renderer.
|
||||
*
|
||||
* Design notes:
|
||||
*
|
||||
* - The template is a React component (JSX). Visual tweaks happen
|
||||
* here — colors, fonts, spacing, layout. To swap branding later,
|
||||
* edit BRAND_* constants below or replace the logo component.
|
||||
*
|
||||
* - All strings are pulled from MESSAGES[locale]. To add a new
|
||||
* language, copy the German block and translate. Locale is
|
||||
* frozen on the invoice at issue time (invoices.locale column);
|
||||
* re-rendering a historical invoice always uses the same locale.
|
||||
*
|
||||
* - The logo is inlined as React-PDF SVG primitives so no asset
|
||||
* loading or font-bundle wrangling is needed. It travels with
|
||||
* the code.
|
||||
*
|
||||
* - VAT note (reverse charge etc.) is appended below the totals
|
||||
* block. Notes are localized in the same MESSAGES map.
|
||||
*
|
||||
* - QR-bill (Swiss bank transfer) is intentionally NOT included
|
||||
* in v1 — it lands in Phase 7. We render plain bank instructions
|
||||
* as text.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Document,
|
||||
Page,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
Svg,
|
||||
Polygon,
|
||||
Polyline,
|
||||
renderToBuffer,
|
||||
} from "@react-pdf/renderer";
|
||||
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand constants — edit here to tweak look without touching layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BRAND = {
|
||||
name: "PieCed IT",
|
||||
// Primary emerald — matches the logo SVG fill (#10B981).
|
||||
primary: "#10B981",
|
||||
// Slightly darker emerald for headings.
|
||||
primaryDark: "#0a8060",
|
||||
textColor: "#1a1a1a",
|
||||
mutedColor: "#666",
|
||||
borderColor: "#d4d4d4",
|
||||
// Issuer block — change these to your real legal info.
|
||||
issuer: {
|
||||
legalName: "PieCed IT",
|
||||
addressLine1: "Cedric Mosimann",
|
||||
addressLine2: "[Strasse Nr.]",
|
||||
postalCity: "[PLZ] Basel",
|
||||
country: "Switzerland",
|
||||
email: "billing@pieced.ch",
|
||||
web: "pieced.ch",
|
||||
// Show "MWST-Nr. ..." on PDF when set.
|
||||
vatNumber: null as string | null,
|
||||
// Bank instructions — Phase 7 replaces with QR-bill.
|
||||
bankName: "[Bank name]",
|
||||
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
||||
bankBic: "[BIC]",
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Localized strings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PdfStrings {
|
||||
invoice: string;
|
||||
invoiceNumber: string;
|
||||
issueDate: string;
|
||||
dueDate: string;
|
||||
period: string;
|
||||
billTo: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
unitPrice: string;
|
||||
amount: string;
|
||||
subtotal: string;
|
||||
vat: string;
|
||||
total: string;
|
||||
paymentInstructions: string;
|
||||
paymentRefHint: string;
|
||||
thankYou: string;
|
||||
page: string;
|
||||
of: string;
|
||||
// Per-line-kind labels (used as section headers)
|
||||
kindLabels: Record<InvoiceLineKind, string>;
|
||||
// VAT compliance notes
|
||||
reverseCharge: string;
|
||||
exportNote: string;
|
||||
}
|
||||
|
||||
const MESSAGES: Record<string, PdfStrings> = {
|
||||
de: {
|
||||
invoice: "Rechnung",
|
||||
invoiceNumber: "Rechnungs-Nr.",
|
||||
issueDate: "Rechnungsdatum",
|
||||
dueDate: "Zahlbar bis",
|
||||
period: "Abrechnungsperiode",
|
||||
billTo: "Rechnungsempfänger",
|
||||
description: "Beschreibung",
|
||||
quantity: "Menge",
|
||||
unitPrice: "Einzelpreis",
|
||||
amount: "Betrag",
|
||||
subtotal: "Zwischensumme",
|
||||
vat: "MWST",
|
||||
total: "Total",
|
||||
paymentInstructions: "Zahlungsinformationen",
|
||||
paymentRefHint: "Bitte verwenden Sie die Rechnungsnummer als Referenz.",
|
||||
thankYou: "Vielen Dank für Ihr Vertrauen.",
|
||||
page: "Seite",
|
||||
of: "von",
|
||||
kindLabels: {
|
||||
tenant_monthly: "Monatliche Grundgebühr",
|
||||
tenant_setup: "Einrichtungsgebühr",
|
||||
ai_usage: "KI-Nutzung",
|
||||
threema_messages: "Threema-Nachrichten",
|
||||
skill_usage: "Skill-Nutzung",
|
||||
adjustment: "Anpassung",
|
||||
},
|
||||
reverseCharge:
|
||||
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||
exportNote: "Dienstleistungsexport — keine MWST in Rechnung gestellt.",
|
||||
},
|
||||
en: {
|
||||
invoice: "Invoice",
|
||||
invoiceNumber: "Invoice no.",
|
||||
issueDate: "Issue date",
|
||||
dueDate: "Due date",
|
||||
period: "Billing period",
|
||||
billTo: "Bill to",
|
||||
description: "Description",
|
||||
quantity: "Qty",
|
||||
unitPrice: "Unit price",
|
||||
amount: "Amount",
|
||||
subtotal: "Subtotal",
|
||||
vat: "VAT",
|
||||
total: "Total",
|
||||
paymentInstructions: "Payment instructions",
|
||||
paymentRefHint: "Please use the invoice number as the payment reference.",
|
||||
thankYou: "Thank you for your business.",
|
||||
page: "Page",
|
||||
of: "of",
|
||||
kindLabels: {
|
||||
tenant_monthly: "Monthly fee",
|
||||
tenant_setup: "Setup fee",
|
||||
ai_usage: "AI usage",
|
||||
threema_messages: "Threema messages",
|
||||
skill_usage: "Skill usage",
|
||||
adjustment: "Adjustment",
|
||||
},
|
||||
reverseCharge:
|
||||
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||
exportNote: "Export of services — VAT not applicable.",
|
||||
},
|
||||
fr: {
|
||||
invoice: "Facture",
|
||||
invoiceNumber: "N° facture",
|
||||
issueDate: "Date d'émission",
|
||||
dueDate: "Échéance",
|
||||
period: "Période de facturation",
|
||||
billTo: "Destinataire",
|
||||
description: "Description",
|
||||
quantity: "Qté",
|
||||
unitPrice: "Prix unitaire",
|
||||
amount: "Montant",
|
||||
subtotal: "Sous-total",
|
||||
vat: "TVA",
|
||||
total: "Total",
|
||||
paymentInstructions: "Informations de paiement",
|
||||
paymentRefHint: "Veuillez utiliser le n° de facture comme référence.",
|
||||
thankYou: "Merci de votre confiance.",
|
||||
page: "Page",
|
||||
of: "sur",
|
||||
kindLabels: {
|
||||
tenant_monthly: "Forfait mensuel",
|
||||
tenant_setup: "Frais de configuration",
|
||||
ai_usage: "Utilisation IA",
|
||||
threema_messages: "Messages Threema",
|
||||
skill_usage: "Utilisation Skill",
|
||||
adjustment: "Ajustement",
|
||||
},
|
||||
reverseCharge:
|
||||
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||
exportNote: "Exportation de services — TVA non applicable.",
|
||||
},
|
||||
it: {
|
||||
invoice: "Fattura",
|
||||
invoiceNumber: "N. fattura",
|
||||
issueDate: "Data di emissione",
|
||||
dueDate: "Scadenza",
|
||||
period: "Periodo di fatturazione",
|
||||
billTo: "Destinatario",
|
||||
description: "Descrizione",
|
||||
quantity: "Qtà",
|
||||
unitPrice: "Prezzo unitario",
|
||||
amount: "Importo",
|
||||
subtotal: "Subtotale",
|
||||
vat: "IVA",
|
||||
total: "Totale",
|
||||
paymentInstructions: "Istruzioni di pagamento",
|
||||
paymentRefHint: "Si prega di utilizzare il n. di fattura come riferimento.",
|
||||
thankYou: "Grazie per la fiducia.",
|
||||
page: "Pagina",
|
||||
of: "di",
|
||||
kindLabels: {
|
||||
tenant_monthly: "Canone mensile",
|
||||
tenant_setup: "Spese di attivazione",
|
||||
ai_usage: "Utilizzo IA",
|
||||
threema_messages: "Messaggi Threema",
|
||||
skill_usage: "Utilizzo Skill",
|
||||
adjustment: "Rettifica",
|
||||
},
|
||||
reverseCharge:
|
||||
"Inversione contabile — IVA a carico del destinatario.",
|
||||
exportNote: "Esportazione di servizi — IVA non applicabile.",
|
||||
},
|
||||
};
|
||||
|
||||
function getStrings(locale: string): PdfStrings {
|
||||
return MESSAGES[locale] ?? MESSAGES.de;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stylesheet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 40,
|
||||
paddingBottom: 60,
|
||||
paddingHorizontal: 40,
|
||||
fontSize: 9,
|
||||
color: BRAND.textColor,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 28,
|
||||
},
|
||||
logoWrap: { width: 60, height: 90 },
|
||||
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
||||
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
|
||||
invoiceTitle: { fontSize: 22, color: BRAND.primaryDark, marginBottom: 8 },
|
||||
metaTable: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 20,
|
||||
},
|
||||
metaCol: { flexGrow: 1, marginRight: 16 },
|
||||
metaLabel: { color: BRAND.mutedColor, fontSize: 8, marginBottom: 2 },
|
||||
metaValue: { fontSize: 10, marginBottom: 6 },
|
||||
billToBlock: {
|
||||
marginBottom: 24,
|
||||
padding: 10,
|
||||
backgroundColor: "#f7f7f5",
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: BRAND.primary,
|
||||
},
|
||||
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
||||
billToName: { fontSize: 11, marginBottom: 2 },
|
||||
table: { marginBottom: 14 },
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: BRAND.primaryDark,
|
||||
color: "#ffffff",
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 6,
|
||||
fontSize: 8.5,
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: BRAND.borderColor,
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
// Column widths (sum ≈ 100%)
|
||||
colDesc: { width: "52%" },
|
||||
colQty: { width: "12%", textAlign: "right" },
|
||||
colUnit: { width: "16%", textAlign: "right" },
|
||||
colAmt: { width: "20%", textAlign: "right" },
|
||||
totalsBlock: {
|
||||
alignSelf: "flex-end",
|
||||
width: "45%",
|
||||
marginTop: 8,
|
||||
},
|
||||
totalsRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 3,
|
||||
},
|
||||
totalsLabel: { color: BRAND.mutedColor },
|
||||
totalsValue: { textAlign: "right" },
|
||||
totalsGrand: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: BRAND.primaryDark,
|
||||
paddingTop: 6,
|
||||
marginTop: 4,
|
||||
},
|
||||
totalsGrandLabel: { color: BRAND.primaryDark, fontSize: 11 },
|
||||
totalsGrandValue: { color: BRAND.primaryDark, fontSize: 11, textAlign: "right" },
|
||||
noteBox: {
|
||||
marginTop: 18,
|
||||
padding: 8,
|
||||
backgroundColor: "#fff8e7",
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: "#d4a017",
|
||||
fontSize: 8.5,
|
||||
},
|
||||
paymentBlock: {
|
||||
marginTop: 24,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: BRAND.borderColor,
|
||||
},
|
||||
paymentTitle: { fontSize: 10, color: BRAND.primaryDark, marginBottom: 6 },
|
||||
paymentLine: { fontSize: 9, marginBottom: 1 },
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 24,
|
||||
left: 40,
|
||||
right: 40,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 7.5,
|
||||
color: BRAND.mutedColor,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: BRAND.borderColor,
|
||||
paddingTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logo — inlined SVG primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
|
||||
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
|
||||
* Width/height are independent of the original viewBox so we can
|
||||
* scale it without losing stroke quality.
|
||||
*/
|
||||
const Logo = ({ size = 60 }: { size?: number }) => (
|
||||
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
|
||||
{/* H1 solid */}
|
||||
<Polygon
|
||||
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||
fill="#10B981"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
{/* H2 outline */}
|
||||
<Polygon
|
||||
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H3 outline */}
|
||||
<Polygon
|
||||
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H4 solid */}
|
||||
<Polygon
|
||||
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
||||
fill="#10B981"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.6}
|
||||
/>
|
||||
{/* H5 partial */}
|
||||
<Polyline
|
||||
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
{/* H6 partial */}
|
||||
<Polyline
|
||||
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
||||
fill="none"
|
||||
stroke="#10B981"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtChf(n: number, decimals: number = 2): string {
|
||||
// Swiss thousands separator + decimal point: 1'234.56
|
||||
const fixed = n.toFixed(decimals);
|
||||
const [intPart, decPart] = fixed.split(".");
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||
return decPart ? `${withSep}.${decPart}` : withSep;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string, locale: string): string {
|
||||
// Parse YYYY-MM-DD as a calendar date (no timezone shifts).
|
||||
// For PDF rendering we want a stable representation regardless
|
||||
// of server timezone.
|
||||
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
|
||||
// Locale-specific date format
|
||||
if (locale === "en") {
|
||||
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
// DE/FR/IT default: DD.MM.YYYY
|
||||
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InvoicePdfProps {
|
||||
invoice: Invoice;
|
||||
lines: InvoiceLine[];
|
||||
}
|
||||
|
||||
const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
||||
const s = getStrings(invoice.locale);
|
||||
const snap = invoice.billingSnapshot;
|
||||
|
||||
// Group lines by tenant for visual separation. Lines without a
|
||||
// tenant_name (org-level adjustments) go to the end.
|
||||
const linesByTenant = new Map<string | null, InvoiceLine[]>();
|
||||
for (const ln of lines) {
|
||||
const key = ln.tenantName;
|
||||
if (!linesByTenant.has(key)) linesByTenant.set(key, []);
|
||||
linesByTenant.get(key)!.push(ln);
|
||||
}
|
||||
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
|
||||
if (a === null) return 1;
|
||||
if (b === null) return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// VAT note: pick the right localized note based on rate + address.
|
||||
// Zero rate + EU country = reverse charge; zero rate + other = export.
|
||||
let vatNote: string | null = null;
|
||||
if (invoice.vatRate === 0) {
|
||||
const country = (snap.country || "").toUpperCase();
|
||||
const isEu = [
|
||||
"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",
|
||||
].includes(country);
|
||||
vatNote = isEu ? s.reverseCharge : s.exportNote;
|
||||
}
|
||||
|
||||
return (
|
||||
<Document title={`${s.invoice} ${invoice.invoiceNumber}`}>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header: logo left, issuer right */}
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.logoWrap}>
|
||||
<Logo size={60} />
|
||||
</View>
|
||||
<View style={styles.issuerBlock}>
|
||||
<Text style={styles.issuerName}>{BRAND.issuer.legalName}</Text>
|
||||
<Text>{BRAND.issuer.addressLine1}</Text>
|
||||
<Text>{BRAND.issuer.addressLine2}</Text>
|
||||
<Text>{BRAND.issuer.postalCity}</Text>
|
||||
<Text>{BRAND.issuer.country}</Text>
|
||||
<Text>{BRAND.issuer.email}</Text>
|
||||
{BRAND.issuer.vatNumber && (
|
||||
<Text>MWST-Nr. {BRAND.issuer.vatNumber}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.invoiceTitle}>{s.invoice}</Text>
|
||||
|
||||
{/* Meta row: 3 columns */}
|
||||
<View style={styles.metaTable}>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{s.invoiceNumber}</Text>
|
||||
<Text style={styles.metaValue}>{invoice.invoiceNumber}</Text>
|
||||
<Text style={styles.metaLabel}>{s.issueDate}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.issuedAt, invoice.locale)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||
</Text>
|
||||
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bill-to */}
|
||||
<View style={styles.billToBlock}>
|
||||
<Text style={styles.billToLabel}>{s.billTo}</Text>
|
||||
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||
<Text>{snap.streetAddress}</Text>
|
||||
<Text>
|
||||
{snap.postalCode} {snap.city}
|
||||
</Text>
|
||||
<Text>{snap.country}</Text>
|
||||
{snap.vatNumber && <Text>VAT: {snap.vatNumber}</Text>}
|
||||
<Text>{snap.billingEmail}</Text>
|
||||
</View>
|
||||
|
||||
{/* Line items table */}
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={styles.colDesc}>{s.description}</Text>
|
||||
<Text style={styles.colQty}>{s.quantity}</Text>
|
||||
<Text style={styles.colUnit}>{s.unitPrice}</Text>
|
||||
<Text style={styles.colAmt}>{s.amount} (CHF)</Text>
|
||||
</View>
|
||||
{tenantOrder.map((tenantKey) => {
|
||||
const tenantLines = linesByTenant.get(tenantKey)!;
|
||||
return (
|
||||
<View key={tenantKey ?? "_org"}>
|
||||
{tenantKey && (
|
||||
<View
|
||||
style={{
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 6,
|
||||
backgroundColor: "#f0f9f4",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 9, color: BRAND.primaryDark }}>
|
||||
{tenantKey}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{tenantLines.map((ln) => (
|
||||
<View key={ln.id} style={styles.tableRow}>
|
||||
<Text style={styles.colDesc}>{ln.description}</Text>
|
||||
<Text style={styles.colQty}>
|
||||
{ln.quantity}
|
||||
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||
</Text>
|
||||
<Text style={styles.colUnit}>{fmtChf(ln.unitPriceChf, 5)}</Text>
|
||||
<Text style={styles.colAmt}>{fmtChf(ln.amountChf)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Totals */}
|
||||
<View style={styles.totalsBlock}>
|
||||
<View style={styles.totalsRow}>
|
||||
<Text style={styles.totalsLabel}>{s.subtotal}</Text>
|
||||
<Text style={styles.totalsValue}>{fmtChf(invoice.subtotalChf)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalsRow}>
|
||||
<Text style={styles.totalsLabel}>
|
||||
{s.vat} ({invoice.vatRate.toFixed(2)}%)
|
||||
</Text>
|
||||
<Text style={styles.totalsValue}>{fmtChf(invoice.vatAmountChf)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalsGrand}>
|
||||
<Text style={styles.totalsGrandLabel}>
|
||||
{s.total} (CHF)
|
||||
</Text>
|
||||
<Text style={styles.totalsGrandValue}>{fmtChf(invoice.totalChf)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{vatNote && (
|
||||
<View style={styles.noteBox}>
|
||||
<Text>{vatNote}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Payment instructions */}
|
||||
<View style={styles.paymentBlock}>
|
||||
<Text style={styles.paymentTitle}>{s.paymentInstructions}</Text>
|
||||
<Text style={styles.paymentLine}>{BRAND.issuer.legalName}</Text>
|
||||
<Text style={styles.paymentLine}>{BRAND.issuer.bankName}</Text>
|
||||
<Text style={styles.paymentLine}>IBAN: {BRAND.issuer.bankIban}</Text>
|
||||
<Text style={styles.paymentLine}>BIC: {BRAND.issuer.bankBic}</Text>
|
||||
<Text style={[styles.paymentLine, { marginTop: 6, color: BRAND.mutedColor }]}>
|
||||
{s.paymentRefHint}
|
||||
</Text>
|
||||
<Text style={[styles.paymentLine, { marginTop: 12, color: BRAND.primaryDark }]}>
|
||||
{s.thankYou}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Footer with page numbers — react-pdf supplies render fn args */}
|
||||
<View
|
||||
style={styles.footer}
|
||||
render={({ pageNumber, totalPages }) => (
|
||||
<>
|
||||
<Text>
|
||||
{BRAND.issuer.legalName} · {BRAND.issuer.web} · {BRAND.issuer.email}
|
||||
</Text>
|
||||
<Text>
|
||||
{s.page} {pageNumber} {s.of} {totalPages}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
fixed
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render an invoice to a PDF buffer. Caller stores the buffer in
|
||||
* `invoices.pdf_data` (bytea). Side-effect-free; can be called
|
||||
* outside a DB transaction.
|
||||
*
|
||||
* Typical runtime is 50–200ms on a typical invoice with a dozen
|
||||
* lines.
|
||||
*/
|
||||
export async function renderInvoicePdf(
|
||||
invoice: Invoice,
|
||||
lines: InvoiceLine[]
|
||||
): Promise<Buffer> {
|
||||
return renderToBuffer(<InvoicePdf invoice={invoice} lines={lines} />);
|
||||
}
|
||||
737
src/lib/billing.ts
Normal file
737
src/lib/billing.ts
Normal 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 (~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);
|
||||
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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
427
src/lib/db.ts
427
src/lib/db.ts
@@ -470,6 +470,12 @@ const MIGRATION_SQL = `
|
||||
paid_method_detail TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Phase 2 addition: PDF locale, frozen at issue time so re-rendering
|
||||
-- an old invoice produces an identical document. Defaults to 'de'
|
||||
-- since most pilot customers are Swiss B2B; the generator UI lets
|
||||
-- admin override at issue time.
|
||||
ALTER TABLE invoices
|
||||
ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'de';
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_org
|
||||
ON invoices(zitadel_org_id, issued_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status
|
||||
@@ -2124,3 +2130,424 @@ export async function backfillTenantBillingLifecycle(tenants: {
|
||||
}
|
||||
return { lifecycleInserted, eventsInserted, suspensionEventsInserted };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Billing — Phase 2: invoice persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Invoice creation is intentionally a single transaction: allocate
|
||||
// number, INSERT invoice, INSERT lines, store PDF — all-or-nothing.
|
||||
// The Postgres invoice_number_counters row lock serializes
|
||||
// concurrent allocators for the same year, producing gapless
|
||||
// numbering even under bursts.
|
||||
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceBillingSnapshot,
|
||||
InvoiceDetail,
|
||||
InvoiceDraft,
|
||||
InvoiceLine,
|
||||
InvoiceStatus,
|
||||
} from "@/types";
|
||||
|
||||
function rowToInvoice(row: any): Invoice {
|
||||
return {
|
||||
id: row.id,
|
||||
invoiceNumber: row.invoice_number,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
periodStart: typeof row.period_start === "string"
|
||||
? row.period_start
|
||||
: row.period_start.toISOString().split("T")[0],
|
||||
periodEnd: typeof row.period_end === "string"
|
||||
? row.period_end
|
||||
: row.period_end.toISOString().split("T")[0],
|
||||
issuedAt: row.issued_at?.toISOString?.() ?? row.issued_at,
|
||||
dueAt: typeof row.due_at === "string"
|
||||
? row.due_at
|
||||
: row.due_at.toISOString().split("T")[0],
|
||||
subtotalChf: Number(row.subtotal_chf),
|
||||
vatRate: Number(row.vat_rate),
|
||||
vatAmountChf: Number(row.vat_amount_chf),
|
||||
totalChf: Number(row.total_chf),
|
||||
status: row.status as InvoiceStatus,
|
||||
locale: row.locale ?? "de",
|
||||
paymentMethod: row.payment_method,
|
||||
billingSnapshot: row.billing_snapshot as InvoiceBillingSnapshot,
|
||||
stripePaymentIntentId: row.stripe_payment_intent_id ?? null,
|
||||
pdfFilename: row.pdf_filename ?? null,
|
||||
hasPdf: row.has_pdf ?? row.pdf_data !== null,
|
||||
adminNotes: row.admin_notes ?? null,
|
||||
paidAt: row.paid_at?.toISOString?.() ?? row.paid_at ?? null,
|
||||
paidBy: row.paid_by ?? null,
|
||||
paidMethodDetail: row.paid_method_detail ?? null,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToInvoiceLine(row: any): InvoiceLine {
|
||||
return {
|
||||
id: row.id,
|
||||
invoiceId: row.invoice_id,
|
||||
tenantName: row.tenant_name ?? null,
|
||||
kind: row.kind,
|
||||
description: row.description,
|
||||
quantity: Number(row.quantity),
|
||||
unitLabel: row.unit_label ?? null,
|
||||
unitPriceChf: Number(row.unit_price_chf),
|
||||
amountChf: Number(row.amount_chf),
|
||||
metadata: row.metadata ?? null,
|
||||
displayOrder: row.display_order,
|
||||
};
|
||||
}
|
||||
|
||||
// Standard SELECT projection that includes a cheap NOT-NULL probe of
|
||||
// pdf_data instead of pulling the bytes themselves. Crucial for list
|
||||
// endpoints — a few KB per row across hundreds of invoices is wasted
|
||||
// network and memory.
|
||||
const INVOICE_LIST_COLUMNS = `
|
||||
id, invoice_number, zitadel_org_id, period_start, period_end,
|
||||
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
|
||||
total_chf, status, locale, payment_method, billing_snapshot,
|
||||
stripe_payment_intent_id, pdf_filename, admin_notes, paid_at,
|
||||
paid_by, paid_method_detail, created_at,
|
||||
(pdf_data IS NOT NULL) AS has_pdf
|
||||
`;
|
||||
|
||||
/**
|
||||
* Persist a fully-computed invoice draft with its lines and PDF in
|
||||
* a single transaction. Allocates the year-scoped invoice number
|
||||
* inside the same transaction so a rollback restores the counter
|
||||
* (gapless guarantee).
|
||||
*
|
||||
* The caller is responsible for upstream validation:
|
||||
* - the (org, period) uniqueness (the unique index will reject
|
||||
* duplicates, but we return a clear error message rather than
|
||||
* leaking the constraint name)
|
||||
* - the draft's lines/totals are consistent (compute pipeline
|
||||
* ensures this)
|
||||
*
|
||||
* `pdfBuffer` is the rendered PDF bytes; pass null if PDF is
|
||||
* generated separately or stored in a side channel. For Phase 2 we
|
||||
* always render synchronously and pass the buffer here.
|
||||
*/
|
||||
export async function createInvoice(
|
||||
draft: InvoiceDraft,
|
||||
pdfBuffer: Buffer | null,
|
||||
pdfFilename: string | null
|
||||
): Promise<Invoice> {
|
||||
await ensureSchema();
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Allocate number for the year of period_start. Locking the
|
||||
// counter row prevents concurrent allocators from racing.
|
||||
const year = parseInt(draft.periodStart.slice(0, 4), 10);
|
||||
const counterResult = await client.query(
|
||||
`INSERT INTO invoice_number_counters (year, last_number)
|
||||
VALUES ($1, 1)
|
||||
ON CONFLICT (year) DO UPDATE SET
|
||||
last_number = invoice_number_counters.last_number + 1
|
||||
RETURNING last_number`,
|
||||
[year]
|
||||
);
|
||||
const seq = counterResult.rows[0].last_number;
|
||||
const invoiceNumber = `${year}-${String(seq).padStart(5, "0")}`;
|
||||
|
||||
// Insert invoice row. PDF goes inline as bytea for v1; we can
|
||||
// migrate to MinIO/S3 later if storage gets noisy.
|
||||
const inv = await client.query(
|
||||
`INSERT INTO invoices (
|
||||
invoice_number, zitadel_org_id, period_start, period_end,
|
||||
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
|
||||
total_chf, status, locale, payment_method, billing_snapshot,
|
||||
pdf_data, pdf_filename
|
||||
) VALUES (
|
||||
$1, $2, $3::date, $4::date, now(), $5::date, $6, $7, $8, $9,
|
||||
'open', $10, $11, $12::jsonb, $13, $14
|
||||
)
|
||||
RETURNING ${INVOICE_LIST_COLUMNS}`,
|
||||
[
|
||||
invoiceNumber,
|
||||
draft.zitadelOrgId,
|
||||
draft.periodStart,
|
||||
draft.periodEnd,
|
||||
draft.dueAt,
|
||||
draft.subtotalChf,
|
||||
draft.vatRate,
|
||||
draft.vatAmountChf,
|
||||
draft.totalChf,
|
||||
draft.locale,
|
||||
draft.paymentMethod,
|
||||
JSON.stringify(draft.billingSnapshot),
|
||||
pdfBuffer,
|
||||
pdfFilename,
|
||||
]
|
||||
);
|
||||
const invoiceId = inv.rows[0].id;
|
||||
|
||||
// Insert lines in batch — one INSERT statement is significantly
|
||||
// faster than per-line round-trips, which matters when an invoice
|
||||
// accumulates many ai_usage / skill_usage lines.
|
||||
if (draft.lines.length > 0) {
|
||||
const placeholders: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
for (const line of draft.lines) {
|
||||
placeholders.push(
|
||||
`($${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}::jsonb, $${idx++})`
|
||||
);
|
||||
values.push(
|
||||
invoiceId,
|
||||
line.tenantName,
|
||||
line.kind,
|
||||
line.description,
|
||||
line.quantity,
|
||||
line.unitLabel,
|
||||
line.unitPriceChf,
|
||||
line.amountChf,
|
||||
line.metadata ? JSON.stringify(line.metadata) : null,
|
||||
line.displayOrder
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO invoice_lines (
|
||||
invoice_id, tenant_name, kind, description, quantity,
|
||||
unit_label, unit_price_chf, amount_chf, metadata, display_order
|
||||
) VALUES ${placeholders.join(", ")}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return rowToInvoice(inv.rows[0]);
|
||||
} catch (e: any) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
// Translate the uniqueness violation into a user-friendly error.
|
||||
// 23505 = unique_violation in Postgres.
|
||||
if (e?.code === "23505" && /uniq_invoices_org_period/.test(e?.constraint ?? "")) {
|
||||
const month = draft.periodStart.slice(0, 7);
|
||||
throw new Error(
|
||||
`An invoice already exists for this org and billing period (${month}). ` +
|
||||
`Delete the existing invoice first if you want to regenerate.`
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInvoiceById(id: string): Promise<Invoice | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getInvoiceDetail(
|
||||
id: string
|
||||
): Promise<InvoiceDetail | null> {
|
||||
const invoice = await getInvoiceById(id);
|
||||
if (!invoice) return null;
|
||||
const lines = await getPool().query(
|
||||
`SELECT * FROM invoice_lines WHERE invoice_id = $1
|
||||
ORDER BY display_order, id`,
|
||||
[id]
|
||||
);
|
||||
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the PDF bytes for an invoice. Returns null if no PDF was
|
||||
* stored (shouldn't happen in v1; defensive against partial state).
|
||||
*/
|
||||
export async function getInvoicePdf(
|
||||
id: string
|
||||
): Promise<{ data: Buffer; filename: string } | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT pdf_data, pdf_filename, invoice_number FROM invoices WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
const row = result.rows[0];
|
||||
if (!row.pdf_data) return null;
|
||||
return {
|
||||
data: row.pdf_data,
|
||||
filename: row.pdf_filename ?? `${row.invoice_number}.pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List invoices, optionally filtered. Used by the admin invoice
|
||||
* list page and (Phase 3) the customer-facing /billing page.
|
||||
*
|
||||
* The customer-facing call site MUST pass `zitadelOrgId` to scope
|
||||
* results — this helper does not enforce that itself.
|
||||
*/
|
||||
export async function listInvoices(filters: {
|
||||
zitadelOrgId?: string;
|
||||
status?: InvoiceStatus;
|
||||
/** Inclusive YYYY-MM filter on period_start. */
|
||||
periodMonth?: string;
|
||||
limit?: number;
|
||||
} = {}): Promise<Invoice[]> {
|
||||
await ensureSchema();
|
||||
const where: string[] = [];
|
||||
const values: any[] = [];
|
||||
let idx = 1;
|
||||
if (filters.zitadelOrgId) {
|
||||
where.push(`zitadel_org_id = $${idx++}`);
|
||||
values.push(filters.zitadelOrgId);
|
||||
}
|
||||
if (filters.status) {
|
||||
where.push(`status = $${idx++}`);
|
||||
values.push(filters.status);
|
||||
}
|
||||
if (filters.periodMonth) {
|
||||
where.push(`to_char(period_start, 'YYYY-MM') = $${idx++}`);
|
||||
values.push(filters.periodMonth);
|
||||
}
|
||||
const limit = filters.limit ?? 200;
|
||||
const sql =
|
||||
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices ` +
|
||||
(where.length > 0 ? `WHERE ${where.join(" AND ")} ` : "") +
|
||||
`ORDER BY issued_at DESC LIMIT $${idx}`;
|
||||
values.push(limit);
|
||||
const result = await getPool().query(sql, values);
|
||||
return result.rows.map(rowToInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep open invoices past their due date to `overdue` status.
|
||||
* Cheap idempotent UPDATE; safe to call on every admin list view
|
||||
* to keep status fresh without a dedicated cron.
|
||||
*/
|
||||
export async function syncOverdueInvoices(): Promise<number> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`UPDATE invoices
|
||||
SET status = 'overdue'
|
||||
WHERE status = 'open'
|
||||
AND due_at < CURRENT_DATE`
|
||||
);
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
export async function markInvoicePaid(
|
||||
id: string,
|
||||
opts: { paidBy: string; paidMethodDetail?: string | null; paidAt?: Date }
|
||||
): Promise<Invoice | null> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`UPDATE invoices
|
||||
SET status = 'paid',
|
||||
paid_at = COALESCE($2::timestamptz, now()),
|
||||
paid_by = $3,
|
||||
paid_method_detail = $4
|
||||
WHERE id = $1
|
||||
AND status IN ('open', 'overdue')
|
||||
RETURNING ${INVOICE_LIST_COLUMNS}`,
|
||||
[
|
||||
id,
|
||||
opts.paidAt ?? null,
|
||||
opts.paidBy,
|
||||
opts.paidMethodDetail ?? null,
|
||||
]
|
||||
);
|
||||
return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete an invoice and its lines (CASCADE).
|
||||
*
|
||||
* This is the testing tool — Swiss bookkeeping requires immutable
|
||||
* invoices in production, but during pilot/testing we need to
|
||||
* iterate. The gap left in the invoice number sequence is
|
||||
* intentional and documented; no attempt to "recycle" numbers.
|
||||
*
|
||||
* Reminders (and their PDFs) cascade-delete via the FK.
|
||||
*/
|
||||
export async function deleteInvoice(id: string): Promise<boolean> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"DELETE FROM invoices WHERE id = $1 RETURNING id",
|
||||
[id]
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this tenant ever been billed a setup fee? Drives the
|
||||
* compute pipeline's "include setup line on first invoice"
|
||||
* decision. Looks at invoice_lines directly so it survives org
|
||||
* billing config edits.
|
||||
*/
|
||||
export async function tenantHasSetupFeeBilled(
|
||||
tenantName: string
|
||||
): Promise<boolean> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT 1 FROM invoice_lines
|
||||
WHERE tenant_name = $1 AND kind = 'tenant_setup'
|
||||
LIMIT 1`,
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate open balance per org for the admin overview. Returns
|
||||
* orgs with at least one open or overdue invoice; orgs in good
|
||||
* standing don't appear.
|
||||
*/
|
||||
export async function getOrgOpenBalances(): Promise<{
|
||||
zitadelOrgId: string;
|
||||
openCount: number;
|
||||
overdueCount: number;
|
||||
totalOpenChf: number;
|
||||
}[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
`SELECT
|
||||
zitadel_org_id,
|
||||
COUNT(*) FILTER (WHERE status = 'open') AS open_count,
|
||||
COUNT(*) FILTER (WHERE status = 'overdue') AS overdue_count,
|
||||
SUM(total_chf) FILTER (WHERE status IN ('open', 'overdue')) AS total_open
|
||||
FROM invoices
|
||||
WHERE status IN ('open', 'overdue')
|
||||
GROUP BY zitadel_org_id
|
||||
ORDER BY total_open DESC`
|
||||
);
|
||||
return result.rows.map((r) => ({
|
||||
zitadelOrgId: r.zitadel_org_id,
|
||||
openCount: Number(r.open_count),
|
||||
overdueCount: Number(r.overdue_count),
|
||||
totalOpenChf: Number(r.total_open),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stored PDF for an invoice. Used by the two-pass
|
||||
* compute pipeline: insert invoice with empty PDF → render PDF with
|
||||
* the allocated invoice number → write bytes back.
|
||||
*
|
||||
* Could be merged into createInvoice via a render callback in a
|
||||
* future cleanup, but two passes are simpler and the extra UPDATE
|
||||
* is cheap.
|
||||
*/
|
||||
export async function updateInvoicePdf(
|
||||
invoiceId: string,
|
||||
pdfBuffer: Buffer,
|
||||
filename: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"UPDATE invoices SET pdf_data = $2, pdf_filename = $3 WHERE id = $1",
|
||||
[invoiceId, pdfBuffer, filename]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user