Files
pieced-portal/src/lib/billing-pdf.tsx
admin ed915ec539
Some checks failed
Build and Push / build (push) Failing after 59s
Phase7b: Manual Invoice
2026-05-26 23:04:09 +02:00

607 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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,
renderToBuffer,
} from "@react-pdf/renderer";
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
import { BRAND, Logo } from "./pdf-brand";
// ---------------------------------------------------------------------------
// Brand: imported from lib/pdf-brand. Edit there to change issuer
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
// share the same source of truth so a brand change applies to every
// PDF the portal produces.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Localized strings
// ---------------------------------------------------------------------------
interface PdfStrings {
invoice: string;
invoiceNumber: string;
issueDate: string;
dueDate: string;
period: string;
billTo: string;
// Phase 6 fix: prefix shown before the optional contact-person
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
// invoice has no contactName on its snapshot.
attentionPrefix: 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",
attentionPrefix: "z.Hd.",
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",
skill_setup: "Einrichtungsgebühr Skill",
adjustment: "Anpassung",
custom_line: "Leistungen",
},
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",
attentionPrefix: "Attn:",
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",
skill_setup: "Skill setup fee",
adjustment: "Adjustment",
custom_line: "Services",
},
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",
attentionPrefix: "À l'attention de",
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",
skill_setup: "Frais de configuration skill",
adjustment: "Ajustement",
custom_line: "Services",
},
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",
attentionPrefix: "c.a.",
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",
skill_setup: "Spese di attivazione skill",
adjustment: "Rettifica",
custom_line: "Servizi",
},
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,
},
});
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// 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}>
{/* Phase 8: skip the billing-period block on custom
invoices (which aren't tied to a period). Due date
still renders. */}
{invoice.periodStart && invoice.periodEnd && (
<>
<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>
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
the printed invoice internally at the customer. Prints
between the company name and street address, in the
invoice's locale (frozen at issue time). */}
{snap.contactName && (
<Text>
{s.attentionPrefix} {snap.contactName}
</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 API quirks (verified against build errors):
- The `render` callback on <View> only exposes
`{ pageNumber, subPageNumber }` — no totalPages.
Only <Text> gets `{ pageNumber, totalPages,
subPageNumber, subPageTotalPages }`.
- <Text>'s render callback must return a STRING
(or array of strings), not JSX. */}
<View style={styles.footer} fixed>
<Text>
{BRAND.issuer.legalName} · {BRAND.issuer.web} · {BRAND.issuer.email}
</Text>
<Text
render={({ pageNumber, totalPages }) =>
`${s.page} ${pageNumber} ${s.of} ${totalPages}`
}
fixed
/>
</View>
</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 50200ms 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} />);
}