/** * 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; // VAT compliance notes reverseCharge: string; exportNote: string; } const MESSAGES: Record = { 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 = ({ 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(); 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 ( {/* Header: logo left, issuer right */} {BRAND.issuer.legalName} {BRAND.issuer.addressLine1} {BRAND.issuer.addressLine2} {BRAND.issuer.postalCity} {BRAND.issuer.country} {BRAND.issuer.email} {BRAND.issuer.vatNumber && ( MWST-Nr. {BRAND.issuer.vatNumber} )} {s.invoice} {/* Meta row: 3 columns */} {s.invoiceNumber} {invoice.invoiceNumber} {s.issueDate} {fmtDate(invoice.issuedAt, invoice.locale)} {/* 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 && ( <> {s.period} {fmtDate(invoice.periodStart, invoice.locale)} —{" "} {fmtDate(invoice.periodEnd, invoice.locale)} )} {s.dueDate} {fmtDate(invoice.dueAt, invoice.locale)} {/* Bill-to */} {s.billTo} {snap.companyName} {/* 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 && ( {s.attentionPrefix} {snap.contactName} )} {snap.streetAddress} {snap.postalCode} {snap.city} {snap.country} {snap.vatNumber && VAT: {snap.vatNumber}} {snap.billingEmail} {/* Line items table */} {s.description} {s.quantity} {s.unitPrice} {s.amount} (CHF) {tenantOrder.map((tenantKey) => { const tenantLines = linesByTenant.get(tenantKey)!; return ( {tenantKey && ( {tenantKey} )} {tenantLines.map((ln) => ( {ln.description} {ln.quantity} {ln.unitLabel ? ` ${ln.unitLabel}` : ""} {fmtChf(ln.unitPriceChf, 5)} {fmtChf(ln.amountChf)} ))} ); })} {/* Totals */} {s.subtotal} {fmtChf(invoice.subtotalChf)} {s.vat} ({invoice.vatRate.toFixed(2)}%) {fmtChf(invoice.vatAmountChf)} {s.total} (CHF) {fmtChf(invoice.totalChf)} {vatNote && ( {vatNote} )} {/* Payment instructions */} {s.paymentInstructions} {BRAND.issuer.legalName} {BRAND.issuer.bankName} IBAN: {BRAND.issuer.bankIban} BIC: {BRAND.issuer.bankBic} {s.paymentRefHint} {s.thankYou} {/* Footer with page numbers. react-pdf API quirks (verified against build errors): - The `render` callback on only exposes `{ pageNumber, subPageNumber }` — no totalPages. Only gets `{ pageNumber, totalPages, subPageNumber, subPageTotalPages }`. - 's render callback must return a STRING (or array of strings), not JSX. */} {BRAND.issuer.legalName} · {BRAND.issuer.web} · {BRAND.issuer.email} `${s.page} ${pageNumber} ${s.of} ${totalPages}` } fixed /> ); }; // --------------------------------------------------------------------------- // 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 { return renderToBuffer(); }