Phase7: Void/Refund logic

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

View File

@@ -356,45 +356,60 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
max: remainingRefundable.toFixed(2), max: remainingRefundable.toFixed(2),
})} })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-4 flex-wrap">
<input <div className="flex flex-col gap-1">
type="number" <label className="text-[10px] uppercase tracking-wider text-text-muted">
step="0.01" {t("refundAmountLabel")}
min="0.01" </label>
max={remainingRefundable} <input
placeholder="CHF" type="number"
value={refundAmount} step="0.01"
onChange={(e) => setRefundAmount(e.target.value)} min="0.01"
className="w-28 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono" max={remainingRefundable}
autoFocus placeholder="CHF"
/> value={refundAmount}
<input onChange={(e) => setRefundAmount(e.target.value)}
type="text" className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
placeholder={t("refundReasonPlaceholder")} autoFocus
value={refundReason} />
onChange={(e) => setRefundReason(e.target.value)} <span className="text-[10px] text-text-muted italic">
maxLength={500} {t("refundAmountInclVatHint")}
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm" </span>
/> </div>
<button <div className="flex flex-col gap-1 flex-grow min-w-[200px]">
onClick={refundInvoice} <label className="text-[10px] uppercase tracking-wider text-text-muted">
disabled={busyAction !== null} {t("refundReasonLabel")}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50" </label>
> <input
{busyAction === "refund" type="text"
? t("saving") placeholder={t("refundReasonPlaceholder")}
: t("confirmRefund")} value={refundReason}
</button> onChange={(e) => setRefundReason(e.target.value)}
<button maxLength={500}
onClick={() => { className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
setRefundOpen(false); />
setRefundAmount(""); </div>
setRefundReason(""); <div className="flex items-center gap-2 self-end">
}} <button
className="px-3 py-1.5 rounded-md border border-border text-sm" onClick={refundInvoice}
> disabled={busyAction !== null}
{t("cancel")} className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
</button> >
{busyAction === "refund"
? t("saving")
: t("confirmRefund")}
</button>
<button
onClick={() => {
setRefundOpen(false);
setRefundAmount("");
setRefundReason("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -31,44 +31,18 @@ import {
Text, Text,
View, View,
StyleSheet, StyleSheet,
Svg,
Polygon,
Polyline,
renderToBuffer, renderToBuffer,
} from "@react-pdf/renderer"; } from "@react-pdf/renderer";
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types"; import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
import { BRAND, Logo } from "./pdf-brand";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Brand constants — edit here to tweak look without touching layout // Brand: imported from lib/pdf-brand. Edit there to change issuer
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
// share the same source of truth so a brand change applies to every
// PDF the portal produces.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const BRAND = {
name: "PieCed IT",
// Primary emerald — matches the logo SVG fill (#10B981).
primary: "#10B981",
// Slightly darker emerald for headings.
primaryDark: "#0a8060",
textColor: "#1a1a1a",
mutedColor: "#666",
borderColor: "#d4d4d4",
// Issuer block — change these to your real legal info.
issuer: {
legalName: "PieCed IT",
addressLine1: "Cedric Mosimann",
addressLine2: "[Strasse Nr.]",
postalCity: "[PLZ] Basel",
country: "Switzerland",
email: "billing@pieced.ch",
web: "pieced.ch",
// Show "MWST-Nr. ..." on PDF when set.
vatNumber: null as string | null,
// Bank instructions — Phase 7 replaces with QR-bill.
bankName: "[Bank name]",
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
bankBic: "[BIC]",
},
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Localized strings // Localized strings
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -358,62 +332,6 @@ const styles = StyleSheet.create({
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Logo — inlined SVG primitives
// ---------------------------------------------------------------------------
/**
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
* Width/height are independent of the original viewBox so we can
* scale it without losing stroke quality.
*/
const Logo = ({ size = 60 }: { size?: number }) => (
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
{/* H1 solid */}
<Polygon
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
fill="#10B981"
stroke="#10B981"
strokeWidth={1.6}
/>
{/* H2 outline */}
<Polygon
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
{/* H3 outline */}
<Polygon
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
{/* H4 solid */}
<Polygon
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
fill="#10B981"
stroke="#10B981"
strokeWidth={1.6}
/>
{/* H5 partial */}
<Polyline
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
{/* H6 partial */}
<Polyline
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
</Svg>
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,22 +1,17 @@
/** /**
* Credit-note PDF rendering via @react-pdf/renderer. * Credit-note PDF rendering via @react-pdf/renderer.
* *
* Phase 7. Mirrors billing-pdf.tsx in layout but with: * Phase 7. Renders the same brand identity as the invoice PDF
* - Title "Gutschrift" / "Credit note" / "Note de crédit" / "Nota di credito" * (hexagon logo, issuer block, layout) with one accent override:
* - Red accent (vs invoice's emerald) so the document is visually * red instead of emerald. That difference is enough to make voids
* unmistakable from an invoice * and refunds visually unmistakable from an invoice at a glance,
* - References the original invoice number prominently * while keeping every other element (logo shape, fonts, structure,
* - One amount line ("Refund for invoice 2026-00042" or * issuer info, page footer) identical so the document family reads
* "Voided invoice 2026-00042") with VAT broken out * as one brand.
* - 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 * Brand + Logo come from lib/pdf-brand. Edit there to change
* from billing-pdf.tsx for now. A future refactor can hoist them * issuer info, colours, or the logo glyph — both invoice and
* into lib/pdf-brand.ts; doing so today is out of scope. * credit-note PDFs pick the changes up.
*/ */
import React from "react"; import React from "react";
@@ -26,71 +21,32 @@ import {
Text, Text,
View, View,
StyleSheet, StyleSheet,
Svg,
Polygon,
Polyline,
renderToBuffer, renderToBuffer,
} from "@react-pdf/renderer"; } from "@react-pdf/renderer";
import type { CreditNote, Invoice } from "@/types"; 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 // Localized strings
// 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 { interface CreditNoteStrings {
/** Document title at the top — "Gutschrift" etc. */
creditNote: string; creditNote: string;
/** "Credit note no." label */
creditNoteNumber: string; creditNoteNumber: string;
/** "Issue date" label */
issueDate: string; issueDate: string;
/** "Bill to" label, same as invoice */
billTo: string; billTo: string;
/** "Attn:" / "z.Hd." prefix */
attentionPrefix: string; attentionPrefix: string;
/** "Reference invoice" — links the credit note back to the original */
referenceInvoice: string; referenceInvoice: string;
/** "Reason" label for the free-text reason block */
reason: string; reason: string;
/** Body text describing what this credit note is for. Takes the
* invoice number as a `{number}` placeholder. */
voidLineLabel: string; voidLineLabel: string;
refundLineLabel: string; refundLineLabel: string;
/** Totals labels — same words as invoice but separated for clarity */
subtotal: string; subtotal: string;
vatLabel: string; vatLabel: string;
totalCredited: string; totalCredited: string;
/** Footer note explaining the document */
footerVoidNote: string; footerVoidNote: string;
footerRefundNote: string; footerRefundNote: string;
/** VAT note (reverse-charge or normal) — same logic as invoice */
vatNoteSwiss: string; vatNoteSwiss: string;
vatNoteReverseCharge: string; vatNoteReverseCharge: string;
vatNoteOutOfScope: string; vatNoteOutOfScope: string;
@@ -195,51 +151,38 @@ const MESSAGES: Record<string, CreditNoteStrings> = {
}, },
}; };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function pickStrings(locale: string): CreditNoteStrings { function pickStrings(locale: string): CreditNoteStrings {
return MESSAGES[locale] ?? MESSAGES.de; return MESSAGES[locale] ?? MESSAGES.de;
} }
// Swiss number formatting — matches billing-pdf for consistency
function fmtChf(n: number): string { function fmtChf(n: number): string {
return n.toLocaleString("de-CH", { const fixed = n.toFixed(2);
minimumFractionDigits: 2, const [intPart, decPart] = fixed.split(".");
maximumFractionDigits: 2, const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
}); return decPart ? `${withSep}.${decPart}` : withSep;
} }
function fmtDate(iso: string, locale: string): string { function fmtDate(iso: string, locale: string): string {
const d = new Date(iso); const [y, m, d] = iso.split("T")[0].split("-").map(Number);
const localeMap: Record<string, string> = { if (locale === "en") {
de: "de-CH", return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
en: "en-GB", year: "numeric",
fr: "fr-CH", month: "short",
it: "it-CH", day: "numeric",
}; });
return d.toLocaleDateString(localeMap[locale] ?? "de-CH", { }
year: "numeric", return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
month: "long",
day: "numeric",
});
} }
function pickVatNote( function pickVatNote(
invoice: Invoice, invoice: Invoice,
strings: CreditNoteStrings strings: CreditNoteStrings
): string | null { ): 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 country = invoice.billingSnapshot.country?.toUpperCase();
const hasVat = invoice.billingSnapshot.vatNumber?.trim(); const hasVat = invoice.billingSnapshot.vatNumber?.trim();
if (country === "CH" || country === "LI") { if (country === "CH" || country === "LI") return strings.vatNoteSwiss;
return strings.vatNoteSwiss; if (hasVat) return strings.vatNoteReverseCharge;
}
if (hasVat) {
return strings.vatNoteReverseCharge;
}
return strings.vatNoteOutOfScope; return strings.vatNoteOutOfScope;
} }
@@ -264,15 +207,15 @@ const styles = StyleSheet.create({
logoBlock: { flexDirection: "row", alignItems: "center" }, logoBlock: { flexDirection: "row", alignItems: "center" },
brandName: { brandName: {
fontSize: 16, fontSize: 16,
color: BRAND.primaryDark, color: ACCENT.primaryDark,
marginLeft: 8, marginLeft: 8,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
}, },
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor }, 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: { docTitle: {
fontSize: 22, fontSize: 22,
color: BRAND.primaryDark, color: ACCENT.primaryDark,
marginBottom: 8, marginBottom: 8,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
}, },
@@ -289,7 +232,7 @@ const styles = StyleSheet.create({
padding: 8, padding: 8,
backgroundColor: "#fdf2f2", backgroundColor: "#fdf2f2",
borderLeftWidth: 3, borderLeftWidth: 3,
borderLeftColor: BRAND.primary, borderLeftColor: ACCENT.primary,
}, },
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 }, billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
billToName: { fontSize: 11, marginBottom: 2 }, billToName: { fontSize: 11, marginBottom: 2 },
@@ -302,7 +245,7 @@ const styles = StyleSheet.create({
}, },
amountHeader: { amountHeader: {
flexDirection: "row", flexDirection: "row",
backgroundColor: BRAND.primaryDark, backgroundColor: ACCENT.primaryDark,
color: "#ffffff", color: "#ffffff",
paddingVertical: 5, paddingVertical: 5,
paddingHorizontal: 6, paddingHorizontal: 6,
@@ -318,11 +261,7 @@ const styles = StyleSheet.create({
}, },
amountDesc: { flex: 1 }, amountDesc: { flex: 1 },
amountValue: { width: 90, textAlign: "right" }, amountValue: { width: 90, textAlign: "right" },
totals: { totals: { marginLeft: "auto", width: 220, marginBottom: 20 },
marginLeft: "auto",
width: 220,
marginBottom: 20,
},
totalsRow: { totalsRow: {
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
@@ -334,17 +273,17 @@ const styles = StyleSheet.create({
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: BRAND.primaryDark, borderTopColor: ACCENT.primaryDark,
paddingTop: 6, paddingTop: 6,
marginTop: 4, marginTop: 4,
}, },
totalsGrandLabel: { totalsGrandLabel: {
color: BRAND.primaryDark, color: ACCENT.primaryDark,
fontSize: 11, fontSize: 11,
fontFamily: "Helvetica-Bold", fontFamily: "Helvetica-Bold",
}, },
totalsGrandValue: { totalsGrandValue: {
color: BRAND.primaryDark, color: ACCENT.primaryDark,
fontSize: 11, fontSize: 11,
textAlign: "right", textAlign: "right",
fontFamily: "Helvetica-Bold", 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 { interface CreditNotePdfProps {
creditNote: CreditNote; creditNote: CreditNote;
invoice: Invoice; invoice: Invoice;
@@ -417,20 +336,24 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
const vatNote = pickVatNote(invoice, strings); const vatNote = pickVatNote(invoice, strings);
const amountLabelTemplate = const amountLabelTemplate =
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel; creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
const amountLabel = amountLabelTemplate.replace("{number}", invoice.invoiceNumber); const amountLabel = amountLabelTemplate.replace(
"{number}",
invoice.invoiceNumber
);
const footerNote = const footerNote =
creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote; creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote;
// Subtotal + VAT breakdown. We carry the VAT proportion from the // Stored convention: amount_chf is gross (incl. VAT),
// credit note row itself (set by billing.ts based on the invoice's // vat_amount_chf is the VAT portion. Subtotal computed for
// VAT rate × the refund/void amount). // display.
const subtotal = creditNote.amountChf - creditNote.vatAmountChf; const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
return ( return (
<Document> <Document>
<Page size="A4" style={styles.page}> <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.headerRow}>
<View style={styles.logoBlock}> <View style={styles.logoBlock}>
<Logo /> <Logo size={42} color={ACCENT.primary} />
<Text style={styles.brandName}>{BRAND.name}</Text> <Text style={styles.brandName}>{BRAND.name}</Text>
</View> </View>
<View style={styles.issuerBlock}> <View style={styles.issuerBlock}>
@@ -449,7 +372,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
<Text style={styles.docTitle}>{strings.creditNote}</Text> <Text style={styles.docTitle}>{strings.creditNote}</Text>
{/* Meta row: number, issue date, reference invoice */}
<View style={styles.metaTable}> <View style={styles.metaTable}>
<View style={styles.metaCol}> <View style={styles.metaCol}>
<Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text> <Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text>
@@ -467,7 +389,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</View> </View>
</View> </View>
{/* Bill-to (mirrors invoice block) */}
<View style={styles.billTo}> <View style={styles.billTo}>
<Text style={styles.billToLabel}>{strings.billTo}</Text> <Text style={styles.billToLabel}>{strings.billTo}</Text>
<Text style={styles.billToName}>{snap.companyName}</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>} {snap.vatNumber && <Text>MWST/VAT: {snap.vatNumber}</Text>}
</View> </View>
{/* Amount line — single row, the credit note isn't itemized */}
<View style={styles.amountTable}> <View style={styles.amountTable}>
<View style={styles.amountHeader}> <View style={styles.amountHeader}>
<Text style={styles.amountDesc}> </Text> <Text style={styles.amountDesc}> </Text>
@@ -496,7 +416,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</View> </View>
</View> </View>
{/* Totals */}
<View style={styles.totals}> <View style={styles.totals}>
<View style={styles.totalsRow}> <View style={styles.totalsRow}>
<Text style={styles.totalsLabel}>{strings.subtotal}</Text> <Text style={styles.totalsLabel}>{strings.subtotal}</Text>
@@ -520,7 +439,6 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</View> </View>
</View> </View>
{/* Reason block — only if the admin provided one */}
{creditNote.reason && creditNote.reason.trim().length > 0 && ( {creditNote.reason && creditNote.reason.trim().length > 0 && (
<View style={styles.reasonBox}> <View style={styles.reasonBox}>
<Text style={styles.reasonLabel}>{strings.reason}</Text> <Text style={styles.reasonLabel}>{strings.reason}</Text>
@@ -528,13 +446,11 @@ function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
</View> </View>
)} )}
{/* Footer note explaining what this document is */}
<View style={styles.noteBox}> <View style={styles.noteBox}>
<Text>{footerNote}</Text> <Text>{footerNote}</Text>
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>} {vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
</View> </View>
{/* Tiny footer with credit-note number on every page */}
<Text style={styles.footer} fixed> <Text style={styles.footer} fixed>
{BRAND.issuer.legalName} · {creditNote.creditNoteNumber} {BRAND.issuer.legalName} · {creditNote.creditNoteNumber}
</Text> </Text>
@@ -548,6 +464,5 @@ export async function renderCreditNotePdf(
invoice: Invoice invoice: Invoice
): Promise<Buffer> { ): Promise<Buffer> {
const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />; const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />;
// @react-pdf/renderer's renderToBuffer returns a Node Buffer.
return renderToBuffer(doc) as unknown as Buffer; return renderToBuffer(doc) as unknown as Buffer;
} }

View File

@@ -578,6 +578,36 @@ const MIGRATION_SQL = `
CREATE INDEX IF NOT EXISTS idx_invoice_refunds_invoice CREATE INDEX IF NOT EXISTS idx_invoice_refunds_invoice
ON invoice_refunds(invoice_id); ON invoice_refunds(invoice_id);
-- Phase 7 fix: the credit_notes.invoice_id and
-- invoice_refunds.invoice_id FKs were originally created without
-- ON DELETE CASCADE, which made admin "delete invoice" fail with
-- a FK violation when the invoice had any voids/refunds attached.
-- The production policy is to never delete an invoice that's been
-- refunded (the credit notes are part of the customer's records),
-- but the schema should allow the admin tool to clean up for test
-- data. We drop and re-add the FKs with CASCADE so a delete tears
-- down everything related. The DROP/ADD pair is idempotent — on a
-- DB that already has the CASCADE variant it's a no-op (we drop
-- by name and re-add identically).
DO $cnfk$
BEGIN
ALTER TABLE credit_notes
DROP CONSTRAINT IF EXISTS credit_notes_invoice_id_fkey;
ALTER TABLE credit_notes
ADD CONSTRAINT credit_notes_invoice_id_fkey
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE;
END
$cnfk$;
DO $irfk$
BEGIN
ALTER TABLE invoice_refunds
DROP CONSTRAINT IF EXISTS invoice_refunds_invoice_id_fkey;
ALTER TABLE invoice_refunds
ADD CONSTRAINT invoice_refunds_invoice_id_fkey
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE;
END
$irfk$;
-- Invoice line items. The kind column lets the PDF renderer -- Invoice line items. The kind column lets the PDF renderer
-- group lines (all monthly fees together, all AI usage together, -- group lines (all monthly fees together, all AI usage together,
-- etc.) and the admin UI filter by category. -- etc.) and the admin UI filter by category.

126
src/lib/pdf-brand.tsx Normal file
View File

@@ -0,0 +1,126 @@
/**
* Shared brand constants and Logo component for all PDF documents
* (invoices, credit notes, future quotes / reminders).
*
* Phase 7 fix: previously each PDF generator carried its own copy
* of BRAND and its own Logo. When Cedric customized the invoice
* issuer block in his deployment (real Strasse Nr., PLZ, etc.),
* the credit note PDF kept the original placeholders because it
* had its own duplicate. Hoisting both here means every PDF reads
* the same source of truth.
*
* To change the brand: edit BRAND below. To change the logo:
* edit Logo below. To change the issuer info Cedric ships: edit
* BRAND.issuer — both billing-pdf.tsx and credit-note-pdf.tsx pick
* it up automatically.
*
* The Logo component accepts a `color` prop so the credit-note
* variant can render the SAME shape tinted red (the document
* family is visually consistent; only the accent colour signals
* "this is a credit, not an invoice").
*/
import React from "react";
import { Svg, Polygon, Polyline } from "@react-pdf/renderer";
// ---------------------------------------------------------------------------
// Brand constants
// ---------------------------------------------------------------------------
export const BRAND = {
name: "PieCed IT",
// Primary emerald — matches the logo SVG fill (#10B981).
primary: "#10B981",
// Slightly darker emerald for headings.
primaryDark: "#0a8060",
textColor: "#1a1a1a",
mutedColor: "#666",
borderColor: "#d4d4d4",
// Issuer block — change these to your real legal info.
// Both billing-pdf.tsx and credit-note-pdf.tsx read from here.
issuer: {
legalName: "PieCed IT",
addressLine1: "Cedric Mosimann",
addressLine2: "[Strasse Nr.]",
postalCity: "[PLZ] Basel",
country: "Switzerland",
email: "billing@pieced.ch",
web: "pieced.ch",
// Show "MWST-Nr. ..." on PDF when set.
vatNumber: null as string | null,
// Bank instructions — used by invoice PDF, ignored on credit
// notes (refunds flow back via the original payment method).
bankName: "[Bank name]",
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
bankBic: "[BIC]",
},
};
// ---------------------------------------------------------------------------
// Accent colours for document variants — credit notes are red so
// customers can tell them apart from invoices at a glance.
// ---------------------------------------------------------------------------
export const ACCENT_CREDIT_NOTE = {
primary: "#DC2626",
primaryDark: "#991B1B",
};
// ---------------------------------------------------------------------------
// Logo — PieCed's hexagon-pattern mark. Same shape used everywhere;
// only the colour changes per document type.
// ---------------------------------------------------------------------------
interface LogoProps {
size?: number;
/** Defaults to BRAND.primary (emerald). Pass ACCENT_CREDIT_NOTE.primary
* on credit notes for the red variant. */
color?: string;
}
export const Logo = ({ size = 60, color = BRAND.primary }: LogoProps) => (
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
{/* H1 solid */}
<Polygon
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
fill={color}
stroke={color}
strokeWidth={1.6}
/>
{/* H2 outline */}
<Polygon
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
fill="none"
stroke={color}
strokeWidth={1.8}
/>
{/* H3 outline */}
<Polygon
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
fill="none"
stroke={color}
strokeWidth={1.8}
/>
{/* H4 solid */}
<Polygon
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
fill={color}
stroke={color}
strokeWidth={1.6}
/>
{/* H5 partial */}
<Polyline
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
fill="none"
stroke={color}
strokeWidth={1.8}
/>
{/* H6 partial */}
<Polyline
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
fill="none"
stroke={color}
strokeWidth={1.8}
/>
</Svg>
);

View File

@@ -699,7 +699,10 @@
"creditNotePdfHeader": "PDF", "creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Storno", "creditNoteKind_void": "Storno",
"creditNoteKind_refund": "Rückerstattung", "creditNoteKind_refund": "Rückerstattung",
"creditNoteNoPdf": "—" "creditNoteNoPdf": "—",
"refundAmountLabel": "Betrag",
"refundReasonLabel": "Grund",
"refundAmountInclVatHint": "inkl. MWST"
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Aktivierungskosten bestätigen", "title": "Aktivierungskosten bestätigen",
@@ -772,7 +775,9 @@
"paid": "Bezahlt", "paid": "Bezahlt",
"overdue": "Überfällig", "overdue": "Überfällig",
"void": "Storniert", "void": "Storniert",
"uncollectible": "Uneinbringlich" "uncollectible": "Uneinbringlich",
"partially_refunded": "Teilrückerstattung",
"fully_refunded": "Vollständig rückerstattet"
}, },
"payWithCard": "Mit Karte bezahlen", "payWithCard": "Mit Karte bezahlen",
"redirectingToStripe": "Weiterleitung…", "redirectingToStripe": "Weiterleitung…",

View File

@@ -699,7 +699,10 @@
"creditNotePdfHeader": "PDF", "creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Void", "creditNoteKind_void": "Void",
"creditNoteKind_refund": "Refund", "creditNoteKind_refund": "Refund",
"creditNoteNoPdf": "—" "creditNoteNoPdf": "—",
"refundAmountLabel": "Amount",
"refundReasonLabel": "Reason",
"refundAmountInclVatHint": "incl. VAT"
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Confirm activation cost", "title": "Confirm activation cost",
@@ -772,7 +775,9 @@
"paid": "Paid", "paid": "Paid",
"overdue": "Overdue", "overdue": "Overdue",
"void": "Void", "void": "Void",
"uncollectible": "Uncollectible" "uncollectible": "Uncollectible",
"partially_refunded": "Partially refunded",
"fully_refunded": "Fully refunded"
}, },
"payWithCard": "Pay with card", "payWithCard": "Pay with card",
"redirectingToStripe": "Redirecting…", "redirectingToStripe": "Redirecting…",

View File

@@ -699,7 +699,10 @@
"creditNotePdfHeader": "PDF", "creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Annulation", "creditNoteKind_void": "Annulation",
"creditNoteKind_refund": "Remboursement", "creditNoteKind_refund": "Remboursement",
"creditNoteNoPdf": "—" "creditNoteNoPdf": "—",
"refundAmountLabel": "Montant",
"refundReasonLabel": "Motif",
"refundAmountInclVatHint": "TVA incluse"
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Confirmer le coût d'activation", "title": "Confirmer le coût d'activation",
@@ -772,7 +775,9 @@
"paid": "Payée", "paid": "Payée",
"overdue": "En retard", "overdue": "En retard",
"void": "Annulée", "void": "Annulée",
"uncollectible": "Irrécouvrable" "uncollectible": "Irrécouvrable",
"partially_refunded": "Partiellement remboursée",
"fully_refunded": "Entièrement remboursée"
}, },
"payWithCard": "Payer par carte", "payWithCard": "Payer par carte",
"redirectingToStripe": "Redirection…", "redirectingToStripe": "Redirection…",

View File

@@ -699,7 +699,10 @@
"creditNotePdfHeader": "PDF", "creditNotePdfHeader": "PDF",
"creditNoteKind_void": "Annullamento", "creditNoteKind_void": "Annullamento",
"creditNoteKind_refund": "Rimborso", "creditNoteKind_refund": "Rimborso",
"creditNoteNoPdf": "—" "creditNoteNoPdf": "—",
"refundAmountLabel": "Importo",
"refundReasonLabel": "Motivo",
"refundAmountInclVatHint": "IVA inclusa"
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Conferma costi di attivazione", "title": "Conferma costi di attivazione",
@@ -772,7 +775,9 @@
"paid": "Pagata", "paid": "Pagata",
"overdue": "In ritardo", "overdue": "In ritardo",
"void": "Annullata", "void": "Annullata",
"uncollectible": "Inesigibile" "uncollectible": "Inesigibile",
"partially_refunded": "Rimborsata parzialmente",
"fully_refunded": "Rimborsata integralmente"
}, },
"payWithCard": "Paga con carta", "payWithCard": "Paga con carta",
"redirectingToStripe": "Reindirizzamento…", "redirectingToStripe": "Reindirizzamento…",