Compare commits

...

3 Commits

Author SHA1 Message Date
667617296b Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 22:59:18 +02:00
1c61111da3 Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m46s
2026-05-25 22:52:54 +02:00
6fed5b083b Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 22:39:27 +02:00
11 changed files with 302 additions and 285 deletions

View File

@@ -356,45 +356,60 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
max: remainingRefundable.toFixed(2),
})}
</div>
<div className="flex items-center gap-2">
<input
type="number"
step="0.01"
min="0.01"
max={remainingRefundable}
placeholder="CHF"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
className="w-28 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
autoFocus
/>
<input
type="text"
placeholder={t("refundReasonPlaceholder")}
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
maxLength={500}
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
/>
<button
onClick={refundInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{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 className="flex items-center gap-4 flex-wrap">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundAmountLabel")}
</label>
<input
type="number"
step="0.01"
min="0.01"
max={remainingRefundable}
placeholder="CHF"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
autoFocus
/>
<span className="text-[10px] text-text-muted italic">
{t("refundAmountInclVatHint")}
</span>
</div>
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundReasonLabel")}
</label>
<input
type="text"
placeholder={t("refundReasonPlaceholder")}
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
maxLength={500}
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 self-end">
<button
onClick={refundInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{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>
)}
@@ -447,34 +462,34 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("creditNoteNumberHeader")}</th>
<th className="pb-2">{t("creditNoteKindHeader")}</th>
<th className="pb-2 text-right">
<th className="pb-2 pr-4">{t("creditNoteNumberHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteKindHeader")}</th>
<th className="pb-2 pr-4 text-right">
{t("creditNoteAmountHeader")}
</th>
<th className="pb-2">{t("creditNoteReasonHeader")}</th>
<th className="pb-2">{t("creditNoteIssuedHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteReasonHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteIssuedHeader")}</th>
<th className="pb-2 text-right">{t("creditNotePdfHeader")}</th>
</tr>
</thead>
<tbody>
{creditNotes.map((cn) => (
<tr key={cn.id} className="border-t border-border">
<td className="py-2 font-mono text-xs">
<td className="py-2 pr-4 font-mono text-xs">
{cn.creditNoteNumber}
</td>
<td className="py-2">
<td className="py-2 pr-4">
<span className="px-2 py-0.5 rounded text-xs text-error bg-error/10">
{t(`creditNoteKind_${cn.kind}` as any)}
</span>
</td>
<td className="py-2 text-right font-mono">
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
CHF {cn.amountChf.toFixed(2)}
</td>
<td className="py-2 text-text-secondary text-xs">
<td className="py-2 pr-4 text-text-secondary text-xs">
{cn.reason ?? "—"}
</td>
<td className="py-2 text-xs text-text-muted">
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{cn.issuedAt.slice(0, 10)}
</td>
<td className="py-2 text-right">

View File

@@ -39,11 +39,11 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("creditNoteNumberCol")}</th>
<th className="pb-2">{t("creditNoteInvoiceCol")}</th>
<th className="pb-2">{t("creditNoteIssuedCol")}</th>
<th className="pb-2 text-right">{t("creditNoteAmountCol")}</th>
<th className="pb-2 text-right">{t("creditNoteKindCol")}</th>
<th className="pb-2 pr-4">{t("creditNoteNumberCol")}</th>
<th className="pb-2 pr-4">{t("creditNoteInvoiceCol")}</th>
<th className="pb-2 pr-4">{t("creditNoteIssuedCol")}</th>
<th className="pb-2 pr-4 text-right">{t("creditNoteAmountCol")}</th>
<th className="pb-2 pr-4 text-right">{t("creditNoteKindCol")}</th>
<th className="pb-2 text-right">{t("creditNotePdfCol")}</th>
</tr>
</thead>
@@ -53,19 +53,19 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
key={cn.id}
className="border-t border-border align-middle"
>
<td className="py-2 font-mono text-xs">
<td className="py-2 pr-4 font-mono text-xs">
{cn.creditNoteNumber}
</td>
<td className="py-2 font-mono text-xs text-text-secondary">
<td className="py-2 pr-4 font-mono text-xs text-text-secondary">
{cn.invoiceNumber}
</td>
<td className="py-2 text-text-secondary">
<td className="py-2 pr-4 text-text-secondary whitespace-nowrap">
{fmt.dateTime(new Date(cn.issuedAt), { dateStyle: "medium" })}
</td>
<td className="py-2 text-right font-mono">
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
CHF {cn.amountChf.toFixed(2)}
</td>
<td className="py-2 text-right">
<td className="py-2 pr-4 text-right">
<span
className={`px-2 py-0.5 rounded text-xs ${
kindColors[cn.kind] ?? ""

View File

@@ -31,44 +31,18 @@ import {
Text,
View,
StyleSheet,
Svg,
Polygon,
Polyline,
renderToBuffer,
} from "@react-pdf/renderer";
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
// ---------------------------------------------------------------------------
@@ -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
// ---------------------------------------------------------------------------

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,31 @@ import {
Text,
View,
StyleSheet,
Svg,
Polygon,
Polyline,
renderToBuffer,
} from "@react-pdf/renderer";
import type { CreditNote, Invoice } from "@/types";
import { BRAND, Logo } from "./pdf-brand";
// ---------------------------------------------------------------------------
// 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 +150,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;
}
@@ -287,7 +229,7 @@ const styles = StyleSheet.create({
billTo: {
marginBottom: 24,
padding: 8,
backgroundColor: "#fdf2f2",
backgroundColor: "#f7f7f5",
borderLeftWidth: 3,
borderLeftColor: BRAND.primary,
},
@@ -318,11 +260,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",
@@ -386,26 +324,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 +335,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={BRAND.primary} />
<Text style={styles.brandName}>{BRAND.name}</Text>
</View>
<View style={styles.issuerBlock}>
@@ -449,7 +371,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 +388,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 +404,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 +415,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 +438,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 +445,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 +463,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;
}

View File

@@ -578,6 +578,36 @@ const MIGRATION_SQL = `
CREATE INDEX IF NOT EXISTS idx_invoice_refunds_invoice
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
-- group lines (all monthly fees together, all AI usage together,
-- etc.) and the admin UI filter by category.

View File

@@ -1261,10 +1261,12 @@ export async function sendCreditNoteEmail(params: {
const safeNumberINV = escapeHtml(params.invoiceNumber);
const safeReason = params.reason ? escapeHtml(params.reason) : null;
// Red accent (#DC2626) for the credit-note emails, mirroring the
// PDF accent so the document family reads visually consistent.
// The invoice email uses emerald; the credit note uses red.
const ACCENT = "#DC2626";
// PieCed brand emerald — same accent the invoice email uses.
// A credit note is still a PieCed IT document; the company
// identity stays consistent across the document family. The
// doc type is distinguished by the subject line and copy, not
// by colour.
const ACCENT = "#10B981";
try {
await getTransporter().sendMail({

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

@@ -0,0 +1,118 @@
/**
* 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]",
},
};
// ---------------------------------------------------------------------------
// Logo — PieCed's hexagon-pattern mark. Same shape used everywhere
// and same brand colour. The credit note is still a PieCed IT
// document and reads with the same company identity as an invoice.
// ---------------------------------------------------------------------------
interface LogoProps {
size?: number;
/** Defaults to BRAND.primary. Override only for special cases
* (e.g. an inverse variant on a dark background). Standard
* documents — invoices, credit notes — all use BRAND.primary. */
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",
"creditNoteKind_void": "Storno",
"creditNoteKind_refund": "Rückerstattung",
"creditNoteNoPdf": "—"
"creditNoteNoPdf": "—",
"refundAmountLabel": "Betrag",
"refundReasonLabel": "Grund",
"refundAmountInclVatHint": "inkl. MWST"
},
"skillCostDialog": {
"title": "Aktivierungskosten bestätigen",
@@ -772,7 +775,9 @@
"paid": "Bezahlt",
"overdue": "Überfällig",
"void": "Storniert",
"uncollectible": "Uneinbringlich"
"uncollectible": "Uneinbringlich",
"partially_refunded": "Teilrückerstattung",
"fully_refunded": "Vollständig rückerstattet"
},
"payWithCard": "Mit Karte bezahlen",
"redirectingToStripe": "Weiterleitung…",

View File

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

View File

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

View File

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