468 lines
16 KiB
TypeScript
468 lines
16 KiB
TypeScript
/**
|
|
* Credit-note PDF rendering via @react-pdf/renderer.
|
|
*
|
|
* Phase 7. Renders the same brand identity as the invoice PDF
|
|
* (hexagon logo, issuer block, layout) with one accent override:
|
|
* red instead of emerald. That difference is enough to make voids
|
|
* and refunds visually unmistakable from an invoice at a glance,
|
|
* while keeping every other element (logo shape, fonts, structure,
|
|
* issuer info, page footer) identical so the document family reads
|
|
* as one brand.
|
|
*
|
|
* Brand + Logo come from lib/pdf-brand. Edit there to change
|
|
* issuer info, colours, or the logo glyph — both invoice and
|
|
* credit-note PDFs pick the changes up.
|
|
*/
|
|
|
|
import React from "react";
|
|
import {
|
|
Document,
|
|
Page,
|
|
Text,
|
|
View,
|
|
StyleSheet,
|
|
renderToBuffer,
|
|
} from "@react-pdf/renderer";
|
|
import type { CreditNote, Invoice } from "@/types";
|
|
import { BRAND, Logo } from "./pdf-brand";
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Localized strings
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CreditNoteStrings {
|
|
creditNote: string;
|
|
creditNoteNumber: string;
|
|
issueDate: string;
|
|
billTo: string;
|
|
attentionPrefix: string;
|
|
referenceInvoice: string;
|
|
reason: string;
|
|
voidLineLabel: string;
|
|
refundLineLabel: string;
|
|
subtotal: string;
|
|
vatLabel: string;
|
|
totalCredited: string;
|
|
footerVoidNote: string;
|
|
footerRefundNote: string;
|
|
vatNoteSwiss: string;
|
|
vatNoteReverseCharge: string;
|
|
vatNoteOutOfScope: string;
|
|
}
|
|
|
|
const MESSAGES: Record<string, CreditNoteStrings> = {
|
|
de: {
|
|
creditNote: "Gutschrift",
|
|
creditNoteNumber: "Gutschrift-Nr.",
|
|
issueDate: "Ausstellungsdatum",
|
|
billTo: "Empfänger",
|
|
attentionPrefix: "z.Hd.",
|
|
referenceInvoice: "Bezug Rechnung",
|
|
reason: "Begründung",
|
|
voidLineLabel: "Stornierung Rechnung {number}",
|
|
refundLineLabel: "Rückerstattung Rechnung {number}",
|
|
subtotal: "Zwischensumme",
|
|
vatLabel: "MWST",
|
|
totalCredited: "Gesamtbetrag Gutschrift",
|
|
footerVoidNote:
|
|
"Diese Gutschrift storniert die oben referenzierte Rechnung. Ein Zahlungsausgleich ist nicht erforderlich.",
|
|
footerRefundNote:
|
|
"Diese Gutschrift dokumentiert die Rückerstattung des oben genannten Betrags. Die Auszahlung erfolgt über den ursprünglichen Zahlungsweg.",
|
|
vatNoteSwiss:
|
|
"MWST gemäss schweizerischem Mehrwertsteuergesetz (MWSTG).",
|
|
vatNoteReverseCharge:
|
|
"Reverse Charge: Steuerschuldnerschaft des Leistungsempfängers nach Art. 196 EU-MwStSyst-RL bzw. nationaler Umsetzung.",
|
|
vatNoteOutOfScope:
|
|
"Leistung ausserhalb des Geltungsbereichs der schweizerischen MWST.",
|
|
},
|
|
en: {
|
|
creditNote: "Credit note",
|
|
creditNoteNumber: "Credit note no.",
|
|
issueDate: "Issue date",
|
|
billTo: "Bill to",
|
|
attentionPrefix: "Attn:",
|
|
referenceInvoice: "Reference invoice",
|
|
reason: "Reason",
|
|
voidLineLabel: "Void of invoice {number}",
|
|
refundLineLabel: "Refund for invoice {number}",
|
|
subtotal: "Subtotal",
|
|
vatLabel: "VAT",
|
|
totalCredited: "Total credited",
|
|
footerVoidNote:
|
|
"This credit note voids the referenced invoice. No payment is required.",
|
|
footerRefundNote:
|
|
"This credit note documents the refund of the amount above. Settlement occurs via the original payment method.",
|
|
vatNoteSwiss:
|
|
"VAT charged in accordance with Swiss VAT law (MWSTG).",
|
|
vatNoteReverseCharge:
|
|
"Reverse charge: VAT to be accounted for by the recipient per Art. 196 EU VAT Directive or national implementation.",
|
|
vatNoteOutOfScope:
|
|
"Service supplied outside the scope of Swiss VAT.",
|
|
},
|
|
fr: {
|
|
creditNote: "Note de crédit",
|
|
creditNoteNumber: "N° de note de crédit",
|
|
issueDate: "Date d'émission",
|
|
billTo: "Destinataire",
|
|
attentionPrefix: "À l'attention de",
|
|
referenceInvoice: "Facture de référence",
|
|
reason: "Motif",
|
|
voidLineLabel: "Annulation de la facture {number}",
|
|
refundLineLabel: "Remboursement de la facture {number}",
|
|
subtotal: "Sous-total",
|
|
vatLabel: "TVA",
|
|
totalCredited: "Total du crédit",
|
|
footerVoidNote:
|
|
"Cette note de crédit annule la facture référencée ci-dessus. Aucun paiement n'est requis.",
|
|
footerRefundNote:
|
|
"Cette note de crédit documente le remboursement du montant ci-dessus. Le règlement s'effectue via le moyen de paiement initial.",
|
|
vatNoteSwiss:
|
|
"TVA facturée conformément à la loi suisse sur la TVA (LTVA).",
|
|
vatNoteReverseCharge:
|
|
"Autoliquidation : TVA à acquitter par le destinataire selon l'art. 196 de la directive TVA UE ou sa mise en œuvre nationale.",
|
|
vatNoteOutOfScope:
|
|
"Prestation hors du champ d'application de la TVA suisse.",
|
|
},
|
|
it: {
|
|
creditNote: "Nota di credito",
|
|
creditNoteNumber: "N. nota di credito",
|
|
issueDate: "Data di emissione",
|
|
billTo: "Destinatario",
|
|
attentionPrefix: "c.a.",
|
|
referenceInvoice: "Fattura di riferimento",
|
|
reason: "Motivo",
|
|
voidLineLabel: "Annullamento della fattura {number}",
|
|
refundLineLabel: "Rimborso della fattura {number}",
|
|
subtotal: "Subtotale",
|
|
vatLabel: "IVA",
|
|
totalCredited: "Totale accreditato",
|
|
footerVoidNote:
|
|
"Questa nota di credito annulla la fattura sopra indicata. Non è richiesto alcun pagamento.",
|
|
footerRefundNote:
|
|
"Questa nota di credito documenta il rimborso dell'importo sopra indicato. Il regolamento avviene tramite il metodo di pagamento originale.",
|
|
vatNoteSwiss:
|
|
"IVA addebitata in conformità alla legge svizzera sull'IVA (LIVA).",
|
|
vatNoteReverseCharge:
|
|
"Inversione contabile: IVA dovuta dal destinatario ai sensi dell'art. 196 della direttiva IVA UE o della sua attuazione nazionale.",
|
|
vatNoteOutOfScope:
|
|
"Prestazione fuori dal campo di applicazione dell'IVA svizzera.",
|
|
},
|
|
};
|
|
|
|
function pickStrings(locale: string): CreditNoteStrings {
|
|
return MESSAGES[locale] ?? MESSAGES.de;
|
|
}
|
|
|
|
// Swiss number formatting — matches billing-pdf for consistency
|
|
function fmtChf(n: number): string {
|
|
const fixed = n.toFixed(2);
|
|
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 {
|
|
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
|
|
if (locale === "en") {
|
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
|
|
}
|
|
|
|
function pickVatNote(
|
|
invoice: Invoice,
|
|
strings: CreditNoteStrings
|
|
): string | null {
|
|
const country = invoice.billingSnapshot.country?.toUpperCase();
|
|
const hasVat = invoice.billingSnapshot.vatNumber?.trim();
|
|
if (country === "CH" || country === "LI") return strings.vatNoteSwiss;
|
|
if (hasVat) return strings.vatNoteReverseCharge;
|
|
return strings.vatNoteOutOfScope;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const styles = StyleSheet.create({
|
|
page: {
|
|
paddingTop: 36,
|
|
paddingBottom: 50,
|
|
paddingHorizontal: 50,
|
|
fontSize: 10,
|
|
fontFamily: "Helvetica",
|
|
color: BRAND.textColor,
|
|
},
|
|
headerRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginBottom: 32,
|
|
},
|
|
logoBlock: { flexDirection: "row", alignItems: "center" },
|
|
brandName: {
|
|
fontSize: 16,
|
|
color: BRAND.primaryDark,
|
|
marginLeft: 8,
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
|
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
|
|
docTitle: {
|
|
fontSize: 22,
|
|
color: BRAND.primaryDark,
|
|
marginBottom: 8,
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
metaTable: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginBottom: 20,
|
|
},
|
|
metaCol: { flexDirection: "column", minWidth: 140 },
|
|
metaLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 2 },
|
|
metaValue: { fontSize: 10 },
|
|
billTo: {
|
|
marginBottom: 24,
|
|
padding: 8,
|
|
backgroundColor: "#f7f7f5",
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: BRAND.primary,
|
|
},
|
|
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
|
billToName: { fontSize: 11, marginBottom: 2 },
|
|
amountTable: {
|
|
borderTopWidth: 1,
|
|
borderTopColor: BRAND.borderColor,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: BRAND.borderColor,
|
|
marginBottom: 16,
|
|
},
|
|
amountHeader: {
|
|
flexDirection: "row",
|
|
backgroundColor: BRAND.primaryDark,
|
|
color: "#ffffff",
|
|
paddingVertical: 5,
|
|
paddingHorizontal: 6,
|
|
fontSize: 9,
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
amountRow: {
|
|
flexDirection: "row",
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 6,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: "#f0f0f0",
|
|
},
|
|
amountDesc: { flex: 1 },
|
|
amountValue: { width: 90, textAlign: "right" },
|
|
totals: { marginLeft: "auto", width: 220, marginBottom: 20 },
|
|
totalsRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
paddingVertical: 3,
|
|
},
|
|
totalsLabel: { color: BRAND.mutedColor, fontSize: 10 },
|
|
totalsValue: { fontSize: 10 },
|
|
totalsGrand: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
borderTopWidth: 1,
|
|
borderTopColor: BRAND.primaryDark,
|
|
paddingTop: 6,
|
|
marginTop: 4,
|
|
},
|
|
totalsGrandLabel: {
|
|
color: BRAND.primaryDark,
|
|
fontSize: 11,
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
totalsGrandValue: {
|
|
color: BRAND.primaryDark,
|
|
fontSize: 11,
|
|
textAlign: "right",
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
reasonBox: {
|
|
marginTop: 4,
|
|
marginBottom: 18,
|
|
padding: 8,
|
|
backgroundColor: "#fafafa",
|
|
borderLeftWidth: 2,
|
|
borderLeftColor: BRAND.borderColor,
|
|
},
|
|
reasonLabel: {
|
|
fontSize: 8,
|
|
color: BRAND.mutedColor,
|
|
marginBottom: 2,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.5,
|
|
},
|
|
reasonText: { fontSize: 9.5, color: BRAND.textColor },
|
|
noteBox: {
|
|
marginTop: 12,
|
|
padding: 8,
|
|
fontSize: 8.5,
|
|
color: BRAND.mutedColor,
|
|
lineHeight: 1.5,
|
|
},
|
|
footer: {
|
|
position: "absolute",
|
|
bottom: 24,
|
|
left: 50,
|
|
right: 50,
|
|
fontSize: 7.5,
|
|
color: BRAND.mutedColor,
|
|
textAlign: "center",
|
|
borderTopWidth: 0.5,
|
|
borderTopColor: BRAND.borderColor,
|
|
paddingTop: 6,
|
|
},
|
|
});
|
|
|
|
interface CreditNotePdfProps {
|
|
creditNote: CreditNote;
|
|
invoice: Invoice;
|
|
}
|
|
|
|
function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
|
const strings = pickStrings(creditNote.locale);
|
|
const snap = creditNote.billingSnapshot;
|
|
const vatNote = pickVatNote(invoice, strings);
|
|
const amountLabelTemplate =
|
|
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
|
|
const amountLabel = amountLabelTemplate.replace(
|
|
"{number}",
|
|
invoice.invoiceNumber
|
|
);
|
|
const footerNote =
|
|
creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote;
|
|
// Stored convention: amount_chf is gross (incl. VAT),
|
|
// vat_amount_chf is the VAT portion. Subtotal computed for
|
|
// display.
|
|
const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
|
|
return (
|
|
<Document>
|
|
<Page size="A4" style={styles.page}>
|
|
{/* Header — SAME hexagon logo as the invoice, tinted red.
|
|
Issuer block from BRAND.issuer (shared with invoice). */}
|
|
<View style={styles.headerRow}>
|
|
<View style={styles.logoBlock}>
|
|
<Logo size={42} color={BRAND.primary} />
|
|
<Text style={styles.brandName}>{BRAND.name}</Text>
|
|
</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>
|
|
<Text>{BRAND.issuer.web}</Text>
|
|
{BRAND.issuer.vatNumber && (
|
|
<Text>MWST-Nr. {BRAND.issuer.vatNumber}</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
<Text style={styles.docTitle}>{strings.creditNote}</Text>
|
|
|
|
<View style={styles.metaTable}>
|
|
<View style={styles.metaCol}>
|
|
<Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text>
|
|
<Text style={styles.metaValue}>{creditNote.creditNoteNumber}</Text>
|
|
</View>
|
|
<View style={styles.metaCol}>
|
|
<Text style={styles.metaLabel}>{strings.issueDate}</Text>
|
|
<Text style={styles.metaValue}>
|
|
{fmtDate(creditNote.issuedAt, creditNote.locale)}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.metaCol}>
|
|
<Text style={styles.metaLabel}>{strings.referenceInvoice}</Text>
|
|
<Text style={styles.metaValue}>{invoice.invoiceNumber}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.billTo}>
|
|
<Text style={styles.billToLabel}>{strings.billTo}</Text>
|
|
<Text style={styles.billToName}>{snap.companyName}</Text>
|
|
{snap.contactName && snap.contactName.trim().length > 0 && (
|
|
<Text>
|
|
{strings.attentionPrefix} {snap.contactName}
|
|
</Text>
|
|
)}
|
|
<Text>{snap.streetAddress}</Text>
|
|
<Text>
|
|
{snap.postalCode} {snap.city}
|
|
</Text>
|
|
<Text>{snap.country}</Text>
|
|
{snap.vatNumber && <Text>MWST/VAT: {snap.vatNumber}</Text>}
|
|
</View>
|
|
|
|
<View style={styles.amountTable}>
|
|
<View style={styles.amountHeader}>
|
|
<Text style={styles.amountDesc}> </Text>
|
|
<Text style={styles.amountValue}>CHF</Text>
|
|
</View>
|
|
<View style={styles.amountRow}>
|
|
<Text style={styles.amountDesc}>{amountLabel}</Text>
|
|
<Text style={styles.amountValue}>{fmtChf(subtotal)}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.totals}>
|
|
<View style={styles.totalsRow}>
|
|
<Text style={styles.totalsLabel}>{strings.subtotal}</Text>
|
|
<Text style={styles.totalsValue}>CHF {fmtChf(subtotal)}</Text>
|
|
</View>
|
|
{creditNote.vatAmountChf > 0 && (
|
|
<View style={styles.totalsRow}>
|
|
<Text style={styles.totalsLabel}>
|
|
{strings.vatLabel} ({Number(invoice.vatRate).toFixed(1)}%)
|
|
</Text>
|
|
<Text style={styles.totalsValue}>
|
|
CHF {fmtChf(creditNote.vatAmountChf)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
<View style={styles.totalsGrand}>
|
|
<Text style={styles.totalsGrandLabel}>{strings.totalCredited}</Text>
|
|
<Text style={styles.totalsGrandValue}>
|
|
CHF {fmtChf(creditNote.amountChf)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{creditNote.reason && creditNote.reason.trim().length > 0 && (
|
|
<View style={styles.reasonBox}>
|
|
<Text style={styles.reasonLabel}>{strings.reason}</Text>
|
|
<Text style={styles.reasonText}>{creditNote.reason}</Text>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.noteBox}>
|
|
<Text>{footerNote}</Text>
|
|
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
|
|
</View>
|
|
|
|
<Text style={styles.footer} fixed>
|
|
{BRAND.issuer.legalName} · {creditNote.creditNoteNumber}
|
|
</Text>
|
|
</Page>
|
|
</Document>
|
|
);
|
|
}
|
|
|
|
export async function renderCreditNotePdf(
|
|
creditNote: CreditNote,
|
|
invoice: Invoice
|
|
): Promise<Buffer> {
|
|
const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />;
|
|
return renderToBuffer(doc) as unknown as Buffer;
|
|
}
|