/** * 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, ACCENT_CREDIT_NOTE } from "./pdf-brand"; const ACCENT = ACCENT_CREDIT_NOTE; // --------------------------------------------------------------------------- // 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 = { 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: ACCENT.primaryDark, marginLeft: 8, fontFamily: "Helvetica-Bold", }, issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor }, issuerName: { fontSize: 11, color: ACCENT.primaryDark, marginBottom: 2 }, docTitle: { fontSize: 22, color: ACCENT.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: "#fdf2f2", borderLeftWidth: 3, borderLeftColor: ACCENT.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: ACCENT.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: ACCENT.primaryDark, paddingTop: 6, marginTop: 4, }, totalsGrandLabel: { color: ACCENT.primaryDark, fontSize: 11, fontFamily: "Helvetica-Bold", }, totalsGrandValue: { color: ACCENT.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 ( {/* Header — SAME hexagon logo as the invoice, tinted red. Issuer block from BRAND.issuer (shared with invoice). */} {BRAND.name} {BRAND.issuer.legalName} {BRAND.issuer.addressLine1} {BRAND.issuer.addressLine2} {BRAND.issuer.postalCity} {BRAND.issuer.country} {BRAND.issuer.email} {BRAND.issuer.web} {BRAND.issuer.vatNumber && ( MWST-Nr. {BRAND.issuer.vatNumber} )} {strings.creditNote} {strings.creditNoteNumber} {creditNote.creditNoteNumber} {strings.issueDate} {fmtDate(creditNote.issuedAt, creditNote.locale)} {strings.referenceInvoice} {invoice.invoiceNumber} {strings.billTo} {snap.companyName} {snap.contactName && snap.contactName.trim().length > 0 && ( {strings.attentionPrefix} {snap.contactName} )} {snap.streetAddress} {snap.postalCode} {snap.city} {snap.country} {snap.vatNumber && MWST/VAT: {snap.vatNumber}} CHF {amountLabel} {fmtChf(subtotal)} {strings.subtotal} CHF {fmtChf(subtotal)} {creditNote.vatAmountChf > 0 && ( {strings.vatLabel} ({Number(invoice.vatRate).toFixed(1)}%) CHF {fmtChf(creditNote.vatAmountChf)} )} {strings.totalCredited} CHF {fmtChf(creditNote.amountChf)} {creditNote.reason && creditNote.reason.trim().length > 0 && ( {strings.reason} {creditNote.reason} )} {footerNote} {vatNote && {vatNote}} {BRAND.issuer.legalName} · {creditNote.creditNoteNumber} ); } export async function renderCreditNotePdf( creditNote: CreditNote, invoice: Invoice ): Promise { const doc = ; return renderToBuffer(doc) as unknown as Buffer; }