Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s

This commit is contained in:
2026-05-24 13:51:38 +02:00
parent 6baca1a459
commit c8ed27157f
29 changed files with 4465 additions and 11 deletions

651
src/lib/billing-pdf.tsx Normal file
View 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 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} />);
}