607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
/**
|
||
* 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 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} />);
|
||
}
|