Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m42s
All checks were successful
Build and Push / build (push) Successful in 1m42s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user