Files
pieced-portal/src/lib/billing-pdf.tsx
admin 522246e386
All checks were successful
Build and Push / build (push) Successful in 1m40s
Phase6c: Optional Company contact name
2026-05-25 12:54:12 +02:00

678 lines
22 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,
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;
// 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",
},
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",
},
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",
},
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",
},
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>
{/* 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} />);
}