Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m42s

This commit is contained in:
2026-05-25 22:39:27 +02:00
parent 4f868d751e
commit 6fed5b083b
9 changed files with 292 additions and 268 deletions

View File

@@ -1,22 +1,17 @@
/**
* 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)
* 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.
*
* 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.
* 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";
@@ -26,71 +21,32 @@ import {
Text,
View,
StyleSheet,
Svg,
Polygon,
Polyline,
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;
// ---------------------------------------------------------------------------
// 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)
// Localized strings
// ---------------------------------------------------------------------------
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;
@@ -195,51 +151,38 @@ const MESSAGES: Record<string, CreditNoteStrings> = {
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function pickStrings(locale: string): CreditNoteStrings {
return MESSAGES[locale] ?? MESSAGES.de;
}
// Swiss number formatting — matches billing-pdf for consistency
function fmtChf(n: number): string {
return n.toLocaleString("de-CH", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
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 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",
});
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 {
// 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;
}
if (country === "CH" || country === "LI") return strings.vatNoteSwiss;
if (hasVat) return strings.vatNoteReverseCharge;
return strings.vatNoteOutOfScope;
}
@@ -264,15 +207,15 @@ const styles = StyleSheet.create({
logoBlock: { flexDirection: "row", alignItems: "center" },
brandName: {
fontSize: 16,
color: BRAND.primaryDark,
color: ACCENT.primaryDark,
marginLeft: 8,
fontFamily: "Helvetica-Bold",
},
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
issuerName: { fontSize: 11, color: ACCENT.primaryDark, marginBottom: 2 },
docTitle: {
fontSize: 22,
color: BRAND.primaryDark,
color: ACCENT.primaryDark,
marginBottom: 8,
fontFamily: "Helvetica-Bold",
},
@@ -289,7 +232,7 @@ const styles = StyleSheet.create({
padding: 8,
backgroundColor: "#fdf2f2",
borderLeftWidth: 3,
borderLeftColor: BRAND.primary,
borderLeftColor: ACCENT.primary,
},
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
billToName: { fontSize: 11, marginBottom: 2 },
@@ -302,7 +245,7 @@ const styles = StyleSheet.create({
},
amountHeader: {
flexDirection: "row",
backgroundColor: BRAND.primaryDark,
backgroundColor: ACCENT.primaryDark,
color: "#ffffff",
paddingVertical: 5,
paddingHorizontal: 6,
@@ -318,11 +261,7 @@ const styles = StyleSheet.create({
},
amountDesc: { flex: 1 },
amountValue: { width: 90, textAlign: "right" },
totals: {
marginLeft: "auto",
width: 220,
marginBottom: 20,
},
totals: { marginLeft: "auto", width: 220, marginBottom: 20 },
totalsRow: {
flexDirection: "row",
justifyContent: "space-between",
@@ -334,17 +273,17 @@ const styles = StyleSheet.create({
flexDirection: "row",
justifyContent: "space-between",
borderTopWidth: 1,
borderTopColor: BRAND.primaryDark,
borderTopColor: ACCENT.primaryDark,
paddingTop: 6,
marginTop: 4,
},
totalsGrandLabel: {
color: BRAND.primaryDark,
color: ACCENT.primaryDark,
fontSize: 11,
fontFamily: "Helvetica-Bold",
},
totalsGrandValue: {
color: BRAND.primaryDark,
color: ACCENT.primaryDark,
fontSize: 11,
textAlign: "right",
fontFamily: "Helvetica-Bold",
@@ -386,26 +325,6 @@ const styles = StyleSheet.create({
},
});
// 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;
@@ -417,20 +336,24 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
const vatNote = pickVatNote(invoice, strings);
const amountLabelTemplate =
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
const amountLabel = amountLabelTemplate.replace("{number}", invoice.invoiceNumber);
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).
// 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: logo+brand left, issuer block right */}
{/* 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 />
<Logo size={42} color={ACCENT.primary} />
<Text style={styles.brandName}>{BRAND.name}</Text>
</View>
<View style={styles.issuerBlock}>
@@ -449,7 +372,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
<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>
@@ -467,7 +389,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</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>
@@ -484,7 +405,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
{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>
@@ -496,7 +416,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</View>
</View>
{/* Totals */}
<View style={styles.totals}>
<View style={styles.totalsRow}>
<Text style={styles.totalsLabel}>{strings.subtotal}</Text>
@@ -520,7 +439,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</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>
@@ -528,13 +446,11 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</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>
@@ -548,6 +464,5 @@ export async function renderCreditNotePdf(
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;
}