Phase7: Void/Refund logic
Some checks failed
Build and Push / build (push) Failing after 52s

This commit is contained in:
2026-05-25 21:54:51 +02:00
parent 9cd9879a18
commit e15a668f8e
19 changed files with 2679 additions and 41 deletions

553
src/lib/credit-note-pdf.tsx Normal file
View File

@@ -0,0 +1,553 @@
/**
* Credit-note PDF rendering via @react-pdf/renderer.
*
* Phase 7. Mirrors billing-pdf.tsx in layout but with:
* - Title "Gutschrift" / "Credit note" / "Note de crédit" / "Nota di credito"
* - Red accent (vs invoice's emerald) so the document is visually
* unmistakable from an invoice
* - References the original invoice number prominently
* - One amount line ("Refund for invoice 2026-00042" or
* "Voided invoice 2026-00042") with VAT broken out
* - Optional reason text below the amount
* - No bank-transfer instructions (refunds flow the other way:
* either Stripe → customer's card, or PieCed → customer's bank
* for invoice-paid cases — neither requires the customer to
* do anything)
*
* Issuer block and brand constants are intentionally duplicated
* from billing-pdf.tsx for now. A future refactor can hoist them
* into lib/pdf-brand.ts; doing so today is out of scope.
*/
import React from "react";
import {
Document,
Page,
Text,
View,
StyleSheet,
Svg,
Polygon,
Polyline,
renderToBuffer,
} from "@react-pdf/renderer";
import type { CreditNote, Invoice } from "@/types";
// ---------------------------------------------------------------------------
// Brand constants — keep in sync with billing-pdf.tsx until the
// shared brand module lands.
// ---------------------------------------------------------------------------
const BRAND = {
name: "PieCed IT",
// Red accent for credit notes — visually distinct from the invoice
// emerald so customers can't confuse the two at a glance.
primary: "#DC2626",
primaryDark: "#991B1B",
textColor: "#1a1a1a",
mutedColor: "#666",
borderColor: "#d4d4d4",
issuer: {
legalName: "PieCed IT",
addressLine1: "Cedric Mosimann",
addressLine2: "[Strasse Nr.]",
postalCity: "[PLZ] Basel",
country: "Switzerland",
email: "billing@pieced.ch",
web: "pieced.ch",
vatNumber: null as string | null,
},
};
// ---------------------------------------------------------------------------
// Localized strings (mirrors PdfStrings in billing-pdf.tsx, adapted
// for the credit-note context)
// ---------------------------------------------------------------------------
interface CreditNoteStrings {
/** Document title at the top — "Gutschrift" etc. */
creditNote: string;
/** "Credit note no." label */
creditNoteNumber: string;
/** "Issue date" label */
issueDate: string;
/** "Bill to" label, same as invoice */
billTo: string;
/** "Attn:" / "z.Hd." prefix */
attentionPrefix: string;
/** "Reference invoice" — links the credit note back to the original */
referenceInvoice: string;
/** "Reason" label for the free-text reason block */
reason: string;
/** Body text describing what this credit note is for. Takes the
* invoice number as a `{number}` placeholder. */
voidLineLabel: string;
refundLineLabel: string;
/** Totals labels — same words as invoice but separated for clarity */
subtotal: string;
vatLabel: string;
totalCredited: string;
/** Footer note explaining the document */
footerVoidNote: string;
footerRefundNote: string;
/** VAT note (reverse-charge or normal) — same logic as invoice */
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.",
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function pickStrings(locale: string): CreditNoteStrings {
return MESSAGES[locale] ?? MESSAGES.de;
}
function fmtChf(n: number): string {
return n.toLocaleString("de-CH", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function fmtDate(iso: string, locale: string): string {
const d = new Date(iso);
const localeMap: Record<string, string> = {
de: "de-CH",
en: "en-GB",
fr: "fr-CH",
it: "it-CH",
};
return d.toLocaleDateString(localeMap[locale] ?? "de-CH", {
year: "numeric",
month: "long",
day: "numeric",
});
}
function pickVatNote(
invoice: Invoice,
strings: CreditNoteStrings
): string | null {
// Mirror the invoice's VAT note logic — the credit note's VAT
// treatment must match the original invoice's, otherwise the
// accounting wouldn't reconcile.
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: "#fdf2f2",
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,
},
});
// Mini SVG logo — identical shape to billing-pdf, just recolored
// to the credit-note red so it matches the document accent.
function Logo() {
return (
<Svg width={26} height={26} viewBox="0 0 100 100">
<Polygon points="50,15 80,40 65,80 35,80 20,40" fill={BRAND.primary} />
<Polyline
points="35,80 50,60 65,80"
stroke="#ffffff"
strokeWidth={3}
fill="none"
/>
</Svg>
);
}
// ---------------------------------------------------------------------------
// Main document
// ---------------------------------------------------------------------------
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;
// Subtotal + VAT breakdown. We carry the VAT proportion from the
// credit note row itself (set by billing.ts based on the invoice's
// VAT rate × the refund/void amount).
const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header: logo+brand left, issuer block right */}
<View style={styles.headerRow}>
<View style={styles.logoBlock}>
<Logo />
<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>
{/* Meta row: number, issue date, reference invoice */}
<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>
{/* Bill-to (mirrors invoice block) */}
<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>
{/* Amount line — single row, the credit note isn't itemized */}
<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>
{/* Totals */}
<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>
{/* Reason block — only if the admin provided one */}
{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>
)}
{/* Footer note explaining what this document is */}
<View style={styles.noteBox}>
<Text>{footerNote}</Text>
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
</View>
{/* Tiny footer with credit-note number on every page */}
<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} />;
// @react-pdf/renderer's renderToBuffer returns a Node Buffer.
return renderToBuffer(doc) as unknown as Buffer;
}