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

View File

@@ -30,6 +30,7 @@
*/
import type {
CreditNote,
Invoice,
InvoiceBillingSnapshot,
InvoiceDraft,
@@ -44,6 +45,8 @@ import type {
TenantSuspensionEvent,
} from "@/types";
import {
attachCreditNotePdf,
createCreditNote,
createInvoice,
getInvoiceById,
getOrgBilling,
@@ -53,6 +56,8 @@ import {
listSkillEventsForTenant,
listSkillPricing,
listSuspensionEventsForTenant,
markInvoiceVoided,
recordInvoiceRefund,
tenantHasSetupFeeBilled,
tenantSkillHasBeenBilled,
updateInvoicePdf,
@@ -61,7 +66,9 @@ import { listTenants } from "./k8s";
import { getTeamSpendLogsV2 } from "./litellm";
import { getUsage as getThreemaUsage } from "./threema-relay";
import { renderInvoicePdf } from "./billing-pdf";
import { sendInvoiceIssuedEmail } from "./email";
import { renderCreditNotePdf } from "./credit-note-pdf";
import { sendCreditNoteEmail, sendInvoiceIssuedEmail } from "./email";
import { createInvoiceRefund } from "./stripe";
import { formatLineDescription } from "./billing-i18n";
// ---------------------------------------------------------------------------
@@ -836,3 +843,362 @@ export async function generateInvoice(opts: {
);
}
}
// ---------------------------------------------------------------------------
// Phase 7 — void and refund orchestration
// ---------------------------------------------------------------------------
export class VoidNotAllowedError extends Error {
constructor(message: string, public readonly currentStatus: string) {
super(message);
this.name = "VoidNotAllowedError";
}
}
export class RefundNotAllowedError extends Error {
constructor(message: string, public readonly currentStatus: string) {
super(message);
this.name = "RefundNotAllowedError";
}
}
/**
* Sanitize a locale string to the supported four. Used when picking
* which translation block to render emails/PDFs with. We never
* fall back to admin's locale here — the credit note inherits the
* invoice's locale so both documents read consistently to the
* customer.
*/
function pickSupportedLocale(
locale: string | null | undefined
): "de" | "en" | "fr" | "it" {
const supported = ["de", "en", "fr", "it"] as const;
return (supported as readonly string[]).includes(locale ?? "")
? (locale as "de" | "en" | "fr" | "it")
: "de";
}
/**
* Round a CHF amount to 2 decimal places. Used when proportionally
* splitting VAT between subtotal and refund amount — avoids
* accumulating fractional rappen across operations.
*/
function roundChf(amount: number): number {
return Math.round(amount * 100) / 100;
}
/**
* Void an unpaid invoice. State transition: open/overdue → void.
*
* Side effects, in order:
* 1. Mark the invoice voided (status, void_reason, voided_at, voided_by)
* 2. Insert credit_notes row (kind='void', amount=full invoice total)
* 3. Render the credit-note PDF and attach it to the row
* 4. Best-effort email to the billing contact
*
* Not allowed:
* - status='paid' (use refundInvoice instead — voiding paid
* invoices would create a record mismatch with the payment
* processor)
* - status='void' (already voided)
* - status='draft' (drafts aren't issued; nothing to void)
* - status='partially_refunded' / 'fully_refunded' (use refund
* for the remaining amount instead)
*
* Throws VoidNotAllowedError if the invoice is in a non-voidable
* state. Caller surfaces this as 409 Conflict to the admin.
*/
export async function voidInvoice(params: {
invoiceId: string;
reason: string;
voidedBy: string;
}): Promise<CreditNote> {
const invoice = await getInvoiceById(params.invoiceId);
if (!invoice) {
throw new Error(`Invoice not found: ${params.invoiceId}`);
}
// Only unpaid invoices can be voided. The state machine puts
// paid invoices on the refund path; voiding them would skip the
// payment reversal and leave the customer's money in our account
// with no obligation showing in the portal.
if (!["open", "overdue"].includes(invoice.status)) {
throw new VoidNotAllowedError(
`Cannot void invoice in status '${invoice.status}'. Voids are allowed only for open or overdue invoices; paid invoices must be refunded.`,
invoice.status
);
}
const locale = pickSupportedLocale(invoice.locale);
// The credit note matches the invoice 1:1 in amount and VAT.
// We carry the same VAT breakdown so the PDF can render
// "subtotal + VAT" the same way the original invoice did.
const creditNote = await createCreditNote({
invoiceId: invoice.id,
zitadelOrgId: invoice.zitadelOrgId,
kind: "void",
amountChf: invoice.totalChf,
vatAmountChf: invoice.vatAmountChf,
reason: params.reason || null,
issuedBy: params.voidedBy,
locale,
billingSnapshot: invoice.billingSnapshot,
});
// Mark invoice voided AFTER the credit note row exists, so the
// status change has a credit note to point at. If anything below
// here fails (PDF render, email), the invoice is still correctly
// voided and the credit note row exists — just without a PDF
// until manually re-rendered.
await markInvoiceVoided({
invoiceId: invoice.id,
reason: params.reason,
voidedBy: params.voidedBy,
});
// Render PDF + attach. PDF failure here doesn't undo the void —
// the customer can be told their invoice is voided and the PDF
// can be re-issued later. We surface the error in the response
// so admin knows to retry, but the void itself stands.
try {
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
const filename = `${creditNote.creditNoteNumber}.pdf`;
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
} catch (e) {
console.error(
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
e
);
}
// Best-effort email. Same fail-soft pattern as invoice issuance.
try {
const snap = invoice.billingSnapshot;
if (snap.billingEmail) {
await sendCreditNoteEmail({
to: snap.billingEmail,
contactName: snap.contactName || snap.companyName,
companyName: snap.companyName,
creditNoteNumber: creditNote.creditNoteNumber,
invoiceNumber: invoice.invoiceNumber,
amountChf: creditNote.amountChf,
currency: "CHF",
kind: "void",
reason: params.reason || null,
locale,
});
}
} catch (e) {
console.error(
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
e
);
}
return creditNote;
}
/**
* Refund a paid invoice (in part or in full). State transition:
* paid → partially_refunded (if amount < remaining)
* paid → fully_refunded (if amount >= remaining)
* partially_refunded → fully_refunded (if cumulative >= total)
*
* Side effects, in order:
* 1. If the invoice was Stripe-paid (payment_method='card' with a
* stripe_payment_intent_id) AND no `existingStripeRefund` was
* passed, call Stripe to issue the refund. Stripe is the source
* of truth for actual money movement; we mirror its outcome
* locally.
* 2. Insert credit_notes row (kind='refund', amount=refund amount,
* VAT proportional)
* 3. Insert invoice_refunds row, linking to the credit note and to
* the Stripe refund (if any). recordInvoiceRefund updates the
* invoice's status atomically based on the new running total.
* 4. Render PDF + attach
* 5. Best-effort email
*
* `existingStripeRefund` is for the webhook path: when Stripe fires
* `charge.refunded` for a refund that was initiated directly in the
* Stripe Dashboard (not via this portal), the webhook needs to
* mirror the refund into the DB and issue a credit note WITHOUT
* calling Stripe again. Pass the refund id and status to skip the
* Stripe call.
*
* Not allowed:
* - status not in {paid, partially_refunded} — full refunds are
* only meaningful against actual payment
* - amount <= 0 or > remaining refundable
*
* For invoice-paid (non-Stripe) customers the Stripe step is
* skipped; refund settlement happens out-of-band (bank transfer)
* and admin records the action in the portal.
*/
export async function refundInvoice(params: {
invoiceId: string;
amountChf: number;
reason: string;
refundedBy: string;
/**
* Webhook path: a Stripe refund that has already been created
* (in the Stripe Dashboard or via a prior API call) and now needs
* to be mirrored into the portal. When set, the Stripe API call
* is skipped and the provided id/status are recorded as-is.
*/
existingStripeRefund?: {
id: string;
status: "pending" | "succeeded" | "failed" | "canceled";
};
}): Promise<CreditNote> {
const invoice = await getInvoiceById(params.invoiceId);
if (!invoice) {
throw new Error(`Invoice not found: ${params.invoiceId}`);
}
if (!["paid", "partially_refunded"].includes(invoice.status)) {
throw new RefundNotAllowedError(
`Cannot refund invoice in status '${invoice.status}'. Refunds are allowed only for paid invoices.`,
invoice.status
);
}
if (params.amountChf <= 0) {
throw new RefundNotAllowedError(
"Refund amount must be greater than zero.",
invoice.status
);
}
const remaining = roundChf(invoice.totalChf - invoice.refundedTotalChf);
if (params.amountChf - remaining > 0.005) {
// Allow a 0.005 tolerance to account for floating-point dust;
// anything genuinely larger is a real over-refund attempt.
throw new RefundNotAllowedError(
`Refund amount CHF ${params.amountChf.toFixed(2)} exceeds remaining refundable CHF ${remaining.toFixed(2)}.`,
invoice.status
);
}
const locale = pickSupportedLocale(invoice.locale);
// Proportional VAT split: refunded VAT / total VAT = refunded
// amount / total amount. Keep the proportion explicit so the
// credit note's "subtotal + VAT" lines reconcile to the same
// VAT rate as the original invoice.
const vatPortion =
invoice.totalChf > 0
? roundChf((params.amountChf * invoice.vatAmountChf) / invoice.totalChf)
: 0;
// Step 1: Stripe (only for card-paid invoices, and only when the
// caller hasn't already created the refund). We do this BEFORE
// any local DB writes for refund tracking — Stripe is the source
// of truth for money movement, and if the Stripe call fails we
// must NOT have recorded the refund locally (the customer would
// see a credit note for money they never received).
//
// The charge.refunded webhook will also fire later, but we record
// the refund here too so the admin gets immediate confirmation
// and the credit note can be issued without waiting for the
// webhook round-trip. The webhook is idempotent (dedups by
// stripe_refund_id) so it's safe to do both.
let stripeRefundId: string | null = null;
let stripeStatus: "pending" | "succeeded" | "failed" | "canceled" =
"succeeded";
const isStripePaid =
invoice.paymentMethod === "card" && !!invoice.stripePaymentIntentId;
if (params.existingStripeRefund) {
// Webhook path: don't call Stripe again; trust the provided id.
stripeRefundId = params.existingStripeRefund.id;
stripeStatus = params.existingStripeRefund.status;
} else if (isStripePaid) {
try {
const refund = await createInvoiceRefund({
paymentIntentId: invoice.stripePaymentIntentId!,
amountChf: params.amountChf,
reason: "requested_by_customer",
metadata: {
invoice_number: invoice.invoiceNumber,
refunded_by: params.refundedBy,
},
});
stripeRefundId = refund.id;
// Map Stripe statuses to our enum. Anything other than
// 'succeeded' or 'pending' is treated as a failure — we
// don't record the credit note in that case (see below).
if (refund.status === "succeeded") stripeStatus = "succeeded";
else if (refund.status === "pending") stripeStatus = "pending";
else if (refund.status === "canceled") stripeStatus = "canceled";
else stripeStatus = "failed";
} catch (e) {
throw new Error(
`Stripe refund failed: ${e instanceof Error ? e.message : String(e)}`
);
}
if (stripeStatus === "failed" || stripeStatus === "canceled") {
throw new Error(
`Stripe refund returned non-success status: ${stripeStatus}`
);
}
}
// Step 2: insert credit note (PDF still null at this point).
const creditNote = await createCreditNote({
invoiceId: invoice.id,
zitadelOrgId: invoice.zitadelOrgId,
kind: "refund",
amountChf: params.amountChf,
vatAmountChf: vatPortion,
reason: params.reason || null,
issuedBy: params.refundedBy,
locale,
billingSnapshot: invoice.billingSnapshot,
});
// Step 3: record the refund event and bump invoice status.
// recordInvoiceRefund handles status transitions and idempotency.
await recordInvoiceRefund({
invoiceId: invoice.id,
stripeRefundId,
amountChf: params.amountChf,
reason: params.reason || null,
refundedBy: params.refundedBy,
creditNoteId: creditNote.id,
status: stripeStatus,
});
// Step 4: render + attach PDF. As with voidInvoice, a PDF failure
// here doesn't undo the refund — the refund happened (in Stripe
// and the DB), only the document is missing. Admin can re-render.
try {
const pdfBuffer = await renderCreditNotePdf(creditNote, invoice);
const filename = `${creditNote.creditNoteNumber}.pdf`;
await attachCreditNotePdf(creditNote.id, pdfBuffer, filename);
} catch (e) {
console.error(
`Credit note ${creditNote.creditNoteNumber} created but PDF render failed; re-render manually.`,
e
);
}
// Step 5: best-effort email.
try {
const snap = invoice.billingSnapshot;
if (snap.billingEmail) {
await sendCreditNoteEmail({
to: snap.billingEmail,
contactName: snap.contactName || snap.companyName,
companyName: snap.companyName,
creditNoteNumber: creditNote.creditNoteNumber,
invoiceNumber: invoice.invoiceNumber,
amountChf: creditNote.amountChf,
currency: "CHF",
kind: "refund",
reason: params.reason || null,
locale,
});
}
} catch (e) {
console.error(
`Credit note ${creditNote.creditNoteNumber} issued; email send failed.`,
e
);
}
return creditNote;
}

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;
}

View File

@@ -492,6 +492,32 @@ const MIGRATION_SQL = `
-- admin override at issue time.
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'de';
-- Phase 7 schema: void + refund tracking on the existing invoices
-- table, plus the credit-note and refund-event sub-entities. These
-- ALTERs are idempotent (IF NOT EXISTS) so re-running ensureSchema
-- on an already-migrated DB is a no-op.
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS void_reason TEXT;
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS voided_at TIMESTAMPTZ;
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS voided_by TEXT;
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS refunded_total_chf NUMERIC(10,2) NOT NULL DEFAULT 0;
-- Extend the status CHECK to allow partially/fully_refunded. We
-- drop-then-add because there's no "ALTER CONSTRAINT ... ADD
-- VALUE" in stock Postgres. Drop is conditional on the constraint
-- name; the constraint is auto-named after the table+column.
DO $constraints$
BEGIN
ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_status_check;
ALTER TABLE invoices ADD CONSTRAINT invoices_status_check CHECK (
status IN ('draft','open','paid','overdue','void','uncollectible',
'partially_refunded','fully_refunded')
);
END
$constraints$;
CREATE INDEX IF NOT EXISTS idx_invoices_org
ON invoices(zitadel_org_id, issued_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_status
@@ -501,6 +527,57 @@ const MIGRATION_SQL = `
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period
ON invoices(zitadel_org_id, period_start);
-- Phase 7: credit notes. One row per void or refund event. The
-- credit_note_number follows CN-YYYY-NNNNN allocated from the
-- per-year counter below.
CREATE TABLE IF NOT EXISTS credit_note_counters (
year INTEGER PRIMARY KEY,
last_number INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS credit_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
credit_note_number TEXT NOT NULL UNIQUE,
invoice_id UUID NOT NULL REFERENCES invoices(id),
zitadel_org_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('void','refund')),
amount_chf NUMERIC(10,2) NOT NULL,
vat_amount_chf NUMERIC(10,2) NOT NULL DEFAULT 0,
reason TEXT,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
issued_by TEXT NOT NULL,
locale TEXT NOT NULL DEFAULT 'de',
pdf_data BYTEA,
pdf_filename TEXT,
-- Frozen snapshot of the customer's billing address at the time
-- the credit note is issued. Mirrors invoices.billing_snapshot
-- so the PDF is self-contained and reproducible.
billing_snapshot JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_credit_notes_invoice
ON credit_notes(invoice_id);
CREATE INDEX IF NOT EXISTS idx_credit_notes_org
ON credit_notes(zitadel_org_id, issued_at DESC);
-- Phase 7: per-refund-event log. Each row is one Stripe Refund
-- object (stripe_refund_id non-null) OR one manual admin action
-- for invoice-paid customers (stripe_refund_id null). The
-- credit_note_id links the refund to the credit note PDF it
-- generated.
CREATE TABLE IF NOT EXISTS invoice_refunds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id),
stripe_refund_id TEXT UNIQUE,
amount_chf NUMERIC(10,2) NOT NULL,
reason TEXT,
status TEXT NOT NULL DEFAULT 'succeeded'
CHECK (status IN ('pending','succeeded','failed','canceled')),
refunded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
refunded_by TEXT NOT NULL,
credit_note_id UUID REFERENCES credit_notes(id)
);
CREATE INDEX IF NOT EXISTS idx_invoice_refunds_invoice
ON invoice_refunds(invoice_id);
-- 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.
@@ -2262,6 +2339,9 @@ import type {
InvoiceDraft,
InvoiceLine,
InvoiceStatus,
CreditNote,
CreditNoteKind,
InvoiceRefund,
} from "@/types";
function rowToInvoice(row: any): Invoice {
@@ -2294,6 +2374,12 @@ function rowToInvoice(row: any): Invoice {
paidAt: row.paid_at?.toISOString?.() ?? row.paid_at ?? null,
paidBy: row.paid_by ?? null,
paidMethodDetail: row.paid_method_detail ?? null,
voidReason: row.void_reason ?? null,
voidedAt: row.voided_at?.toISOString?.() ?? row.voided_at ?? null,
voidedBy: row.voided_by ?? null,
refundedTotalChf: row.refunded_total_chf != null
? Number(row.refunded_total_chf)
: 0,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
};
}
@@ -2323,7 +2409,8 @@ const INVOICE_LIST_COLUMNS = `
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
total_chf, status, locale, payment_method, billing_snapshot,
stripe_payment_intent_id, pdf_filename, admin_notes, paid_at,
paid_by, paid_method_detail, created_at,
paid_by, paid_method_detail, void_reason, voided_at, voided_by,
refunded_total_chf, created_at,
(pdf_data IS NOT NULL) AS has_pdf
`;
@@ -3188,3 +3275,465 @@ export async function recordReminderSent(params: {
);
return result.rowCount === 1;
}
// ---------------------------------------------------------------------------
// Phase 7 — credit notes and refunds
// ---------------------------------------------------------------------------
function rowToCreditNote(row: any): CreditNote {
return {
id: row.id,
creditNoteNumber: row.credit_note_number,
invoiceId: row.invoice_id,
invoiceNumber: row.invoice_number, // joined column when available
zitadelOrgId: row.zitadel_org_id,
kind: row.kind as CreditNoteKind,
amountChf: Number(row.amount_chf),
vatAmountChf: Number(row.vat_amount_chf),
reason: row.reason ?? null,
issuedAt: row.issued_at?.toISOString?.() ?? row.issued_at,
issuedBy: row.issued_by,
locale: row.locale ?? "de",
pdfFilename: row.pdf_filename ?? null,
hasPdf: row.has_pdf ?? row.pdf_data !== null,
billingSnapshot: row.billing_snapshot as InvoiceBillingSnapshot,
};
}
function rowToInvoiceRefund(row: any): InvoiceRefund {
return {
id: row.id,
invoiceId: row.invoice_id,
stripeRefundId: row.stripe_refund_id ?? null,
amountChf: Number(row.amount_chf),
reason: row.reason ?? null,
status: row.status,
refundedAt: row.refunded_at?.toISOString?.() ?? row.refunded_at,
refundedBy: row.refunded_by,
creditNoteId: row.credit_note_id ?? null,
};
}
const CREDIT_NOTE_COLUMNS = `
cn.id, cn.credit_note_number, cn.invoice_id, cn.zitadel_org_id,
cn.kind, cn.amount_chf, cn.vat_amount_chf, cn.reason, cn.issued_at,
cn.issued_by, cn.locale, cn.pdf_filename, cn.billing_snapshot,
(cn.pdf_data IS NOT NULL) AS has_pdf,
inv.invoice_number AS invoice_number
`;
/**
* Allocate the next credit-note number for the given year. Uses the
* per-year counter table with a row-level lock, same approach as
* invoice numbering. Format: CN-2026-00001 (5-digit padding, matches
* invoice padding for visual consistency even though Cedric agreed
* to "CN-YYYY-NNNN" originally — the extra digit is harmless headroom
* and keeps eyes from misreading "CN-2026-1" next to "2026-00001").
*
* Must be called inside a transaction; the caller passes the same
* client so the allocation and the INSERT roll back together if
* anything downstream fails.
*/
async function allocateCreditNoteNumber(
client: any,
year: number
): Promise<string> {
const r = await client.query(
`INSERT INTO credit_note_counters (year, last_number)
VALUES ($1, 1)
ON CONFLICT (year) DO UPDATE SET
last_number = credit_note_counters.last_number + 1
RETURNING last_number`,
[year]
);
const seq = r.rows[0].last_number;
return `CN-${year}-${String(seq).padStart(5, "0")}`;
}
/**
* Persist a new credit note (without its PDF — that's attached later
* via attachCreditNotePdf so the PDF render can read the just-inserted
* row, including its credit_note_number, for self-referential rendering).
*
* Snapshots the invoice's billing block at issue time. Returns the
* inserted row (with PDF still null).
*/
export async function createCreditNote(params: {
invoiceId: string;
zitadelOrgId: string;
kind: CreditNoteKind;
amountChf: number;
vatAmountChf: number;
reason: string | null;
issuedBy: string;
locale: string;
billingSnapshot: InvoiceBillingSnapshot;
}): Promise<CreditNote> {
await ensureSchema();
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// Allocate from the year of NOW — credit notes are issued
// "today", not retroactively, so the year is current.
const year = new Date().getUTCFullYear();
const creditNoteNumber = await allocateCreditNoteNumber(client, year);
const inserted = await client.query(
`INSERT INTO credit_notes (
credit_note_number, invoice_id, zitadel_org_id, kind,
amount_chf, vat_amount_chf, reason, issued_by, locale,
billing_snapshot
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING *`,
[
creditNoteNumber,
params.invoiceId,
params.zitadelOrgId,
params.kind,
params.amountChf,
params.vatAmountChf,
params.reason,
params.issuedBy,
params.locale,
JSON.stringify(params.billingSnapshot),
]
);
await client.query("COMMIT");
// Re-query with the invoice_number join so the returned row has
// it populated (matches what list/get methods return).
const detail = await pool.query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.id = $1`,
[inserted.rows[0].id]
);
return rowToCreditNote(detail.rows[0]);
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
/**
* Attach a freshly-rendered PDF to an existing credit note row.
* Two-phase issue (insert row, render PDF, attach PDF) mirrors the
* invoice flow, where the PDF generation needs the number on the
* row to render itself.
*/
export async function attachCreditNotePdf(
creditNoteId: string,
pdfBuffer: Buffer,
pdfFilename: string
): Promise<void> {
await getPool().query(
`UPDATE credit_notes
SET pdf_data = $1, pdf_filename = $2
WHERE id = $3`,
[pdfBuffer, pdfFilename, creditNoteId]
);
}
/**
* Read a credit note by its number, scoped to an org. Returns null
* if the row doesn't exist OR exists but belongs to a different org
* (same 404-not-403 leak-protection as the invoice equivalent).
*/
export async function getCreditNoteByNumberForOrg(
creditNoteNumber: string,
zitadelOrgId: string
): Promise<CreditNote | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.credit_note_number = $1 AND cn.zitadel_org_id = $2
LIMIT 1`,
[creditNoteNumber, zitadelOrgId]
);
return result.rows.length > 0 ? rowToCreditNote(result.rows[0]) : null;
}
/** Platform-admin variant: look up by number regardless of org. */
export async function getCreditNoteByNumber(
creditNoteNumber: string
): Promise<CreditNote | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.credit_note_number = $1
LIMIT 1`,
[creditNoteNumber]
);
return result.rows.length > 0 ? rowToCreditNote(result.rows[0]) : null;
}
/**
* List credit notes for an org, newest first. Used by /billing to
* render the credit-note list alongside the invoice list.
*/
export async function listCreditNotesForOrg(
zitadelOrgId: string,
limit = 50
): Promise<CreditNote[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.zitadel_org_id = $1
ORDER BY cn.issued_at DESC
LIMIT $2`,
[zitadelOrgId, limit]
);
return result.rows.map(rowToCreditNote);
}
/**
* All credit notes linked to a specific invoice. Used by the invoice
* detail page to surface "this invoice was voided / partially
* refunded by these credit notes".
*/
export async function listCreditNotesForInvoice(
invoiceId: string
): Promise<CreditNote[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.invoice_id = $1
ORDER BY cn.issued_at ASC`,
[invoiceId]
);
return result.rows.map(rowToCreditNote);
}
/**
* Fetch the PDF bytes for a credit note. Returns null if no PDF was
* ever attached (which would be a bug — every credit note should
* have one) or if the credit note doesn't exist.
*/
export async function getCreditNotePdf(
creditNoteId: string
): Promise<{ data: Buffer; filename: string } | null> {
const result = await getPool().query(
`SELECT pdf_data, pdf_filename, credit_note_number
FROM credit_notes WHERE id = $1`,
[creditNoteId]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
if (!row.pdf_data) return null;
return {
data: row.pdf_data,
filename: row.pdf_filename ?? `${row.credit_note_number}.pdf`,
};
}
/**
* Mark an invoice as voided and bump the status. Caller is
* responsible for ensuring the invoice is in a void-eligible state
* (status='open' or 'overdue'); this helper doesn't enforce that —
* billing.voidInvoice() does.
*/
export async function markInvoiceVoided(params: {
invoiceId: string;
reason: string;
voidedBy: string;
}): Promise<void> {
await getPool().query(
`UPDATE invoices
SET status = 'void',
void_reason = $2,
voided_at = now(),
voided_by = $3
WHERE id = $1`,
[params.invoiceId, params.reason, params.voidedBy]
);
}
/**
* Record a refund event and (optionally) bump the invoice status.
* The status transition is:
* refunded_total + amount >= total_chf → fully_refunded
* otherwise → partially_refunded
*
* Idempotent against Stripe webhook replays via stripe_refund_id:
* if a row with the same Stripe refund id already exists, returns
* the existing row without double-counting. Manual (non-Stripe)
* refunds have stripe_refund_id=null and can't be deduped — caller
* must guard against double-submit at the UI/API layer.
*
* Returns the recorded refund, the updated invoice's new total
* refunded amount, and the resulting invoice status.
*/
export interface RecordRefundResult {
refund: InvoiceRefund;
alreadyExisted: boolean;
newRefundedTotalChf: number;
newInvoiceStatus: InvoiceStatus;
}
export async function recordInvoiceRefund(params: {
invoiceId: string;
stripeRefundId: string | null;
amountChf: number;
reason: string | null;
refundedBy: string;
creditNoteId: string | null;
status?: "pending" | "succeeded" | "failed" | "canceled";
}): Promise<RecordRefundResult> {
await ensureSchema();
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// Webhook idempotency: if the Stripe refund id is already
// recorded, return the existing row without re-adding to the
// running total. The invoice status row reflects the cumulative
// state independent of how many times this function is called.
if (params.stripeRefundId) {
const existing = await client.query(
`SELECT * FROM invoice_refunds WHERE stripe_refund_id = $1`,
[params.stripeRefundId]
);
if (existing.rows.length > 0) {
const inv = await client.query(
`SELECT refunded_total_chf, status
FROM invoices WHERE id = $1`,
[params.invoiceId]
);
await client.query("COMMIT");
return {
refund: rowToInvoiceRefund(existing.rows[0]),
alreadyExisted: true,
newRefundedTotalChf: Number(inv.rows[0]?.refunded_total_chf ?? 0),
newInvoiceStatus: (inv.rows[0]?.status ?? "paid") as InvoiceStatus,
};
}
}
// Insert the refund event.
const inserted = await client.query(
`INSERT INTO invoice_refunds (
invoice_id, stripe_refund_id, amount_chf, reason,
status, refunded_by, credit_note_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
params.invoiceId,
params.stripeRefundId,
params.amountChf,
params.reason,
params.status ?? "succeeded",
params.refundedBy,
params.creditNoteId,
]
);
const recordedStatus = inserted.rows[0].status;
// Only succeeded refunds count toward the invoice's
// refunded_total. Pending/failed refunds are tracked for audit
// but don't change the customer-visible state.
if (recordedStatus !== "succeeded") {
const inv = await client.query(
`SELECT refunded_total_chf, status FROM invoices WHERE id = $1`,
[params.invoiceId]
);
await client.query("COMMIT");
return {
refund: rowToInvoiceRefund(inserted.rows[0]),
alreadyExisted: false,
newRefundedTotalChf: Number(inv.rows[0]?.refunded_total_chf ?? 0),
newInvoiceStatus: (inv.rows[0]?.status ?? "paid") as InvoiceStatus,
};
}
// Update aggregate + status atomically based on the new total.
const updated = await client.query(
`UPDATE invoices
SET refunded_total_chf = refunded_total_chf + $2,
status = CASE
WHEN refunded_total_chf + $2 >= total_chf
THEN 'fully_refunded'
ELSE 'partially_refunded'
END
WHERE id = $1
RETURNING refunded_total_chf, status`,
[params.invoiceId, params.amountChf]
);
await client.query("COMMIT");
return {
refund: rowToInvoiceRefund(inserted.rows[0]),
alreadyExisted: false,
newRefundedTotalChf: Number(updated.rows[0].refunded_total_chf),
newInvoiceStatus: updated.rows[0].status as InvoiceStatus,
};
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
/** All refund events for an invoice, ordered oldest first. */
export async function listRefundsForInvoice(
invoiceId: string
): Promise<InvoiceRefund[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM invoice_refunds
WHERE invoice_id = $1
ORDER BY refunded_at ASC`,
[invoiceId]
);
return result.rows.map(rowToInvoiceRefund);
}
/**
* Phase 7: find an invoice by its Stripe PaymentIntent id. Used by
* the charge.refunded webhook to locate the invoice when a refund
* is initiated outside the portal (e.g. directly in Stripe
* Dashboard).
*
* Returns null if no invoice has that payment intent recorded. That
* shouldn't happen in normal flow — every Stripe-paid invoice gets
* its intent stored at checkout.session.completed time — but a
* refund event for an unknown intent should be logged and ignored
* rather than throwing.
*/
export async function getInvoiceByStripePaymentIntent(
paymentIntentId: string
): Promise<Invoice | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices
WHERE stripe_payment_intent_id = $1
LIMIT 1`,
[paymentIntentId]
);
return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null;
}
/**
* Phase 7: check if a particular Stripe refund id is already
* recorded in invoice_refunds. Used by the charge.refunded webhook
* to skip refunds that were initiated via /api/admin/.../refund
* (which records them immediately) — the webhook would otherwise
* try to double-create the credit note.
*/
export async function isStripeRefundRecorded(
stripeRefundId: string
): Promise<boolean> {
const result = await getPool().query(
`SELECT 1 FROM invoice_refunds WHERE stripe_refund_id = $1 LIMIT 1`,
[stripeRefundId]
);
return result.rows.length > 0;
}

View File

@@ -1158,3 +1158,156 @@ export async function sendInvoiceReminderEmail(params: {
);
}
}
// ---------------------------------------------------------------------------
// Credit note emails — Phase 7
// ---------------------------------------------------------------------------
/**
* Send a credit-note notification to the customer's billing email.
*
* Covers both kinds (void and refund). The subject and body adapt
* based on `kind` — voids ("we've cancelled invoice X, no payment
* needed") read very differently from refunds ("we've refunded CHF
* X, expect to see it on your card statement within 5-10 days").
*
* Link-only — the PDF is not attached. The customer downloads it
* from /api/credit-notes/<number>/pdf when they click through, which
* also gives them a permanent in-portal record next to their
* invoices. Same approach as invoice emails.
*
* Best-effort: failures are logged and swallowed. A mail-server
* hiccup must never roll back a credit-note issuance.
*/
export async function sendCreditNoteEmail(params: {
to: string;
contactName: string;
companyName: string;
creditNoteNumber: string;
invoiceNumber: string;
amountChf: number;
currency: string;
kind: "void" | "refund";
reason: string | null;
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
const L = params.locale;
const totalFmt = `${params.currency} ${params.amountChf.toFixed(2)}`;
const link = `https://app.pieced.ch/billing/cn/${encodeURIComponent(
params.creditNoteNumber
)}`;
// Subject lines diverge between void and refund — different
// mental models for the recipient. Void: "your charge is
// cancelled". Refund: "your money is on the way back".
const subjectsByLocale: Record<typeof L, { void: string; refund: string }> = {
en: {
void: `Invoice ${params.invoiceNumber} cancelled — credit note ${params.creditNoteNumber}`,
refund: `Refund of ${totalFmt} for invoice ${params.invoiceNumber} — credit note ${params.creditNoteNumber}`,
},
de: {
void: `Rechnung ${params.invoiceNumber} storniert — Gutschrift ${params.creditNoteNumber}`,
refund: `Rückerstattung ${totalFmt} für Rechnung ${params.invoiceNumber} — Gutschrift ${params.creditNoteNumber}`,
},
fr: {
void: `Facture ${params.invoiceNumber} annulée — note de crédit ${params.creditNoteNumber}`,
refund: `Remboursement ${totalFmt} pour la facture ${params.invoiceNumber} — note de crédit ${params.creditNoteNumber}`,
},
it: {
void: `Fattura ${params.invoiceNumber} annullata — nota di credito ${params.creditNoteNumber}`,
refund: `Rimborso ${totalFmt} per fattura ${params.invoiceNumber} — nota di credito ${params.creditNoteNumber}`,
},
};
const greetingsByLocale: Record<typeof L, string> = {
en: `Hello ${params.contactName},`,
de: `Sehr geehrte/r ${params.contactName},`,
fr: `Bonjour ${params.contactName},`,
it: `Gentile ${params.contactName},`,
};
// Intro: distinct phrasing per kind in each locale.
const introsByLocale: Record<typeof L, { void: string; refund: string }> = {
en: {
void: `We've cancelled invoice ${params.invoiceNumber}. The invoice is no longer payable, and a credit note has been issued for your records.`,
refund: `We've refunded ${totalFmt} for invoice ${params.invoiceNumber}. The refund will appear on the original payment method within 510 business days, depending on your bank.`,
},
de: {
void: `Wir haben Rechnung ${params.invoiceNumber} storniert. Die Rechnung ist nicht mehr zahlbar; eine Gutschrift wurde für Ihre Unterlagen ausgestellt.`,
refund: `Wir haben ${totalFmt} für Rechnung ${params.invoiceNumber} zurückerstattet. Der Betrag wird je nach Bank innerhalb von 510 Geschäftstagen auf dem ursprünglichen Zahlungsweg gutgeschrieben.`,
},
fr: {
void: `Nous avons annulé la facture ${params.invoiceNumber}. La facture n'est plus exigible ; une note de crédit a été émise pour vos archives.`,
refund: `Nous avons remboursé ${totalFmt} pour la facture ${params.invoiceNumber}. Le montant apparaîtra sur le moyen de paiement initial sous 5 à 10 jours ouvrés, selon votre banque.`,
},
it: {
void: `Abbiamo annullato la fattura ${params.invoiceNumber}. La fattura non è più dovuta; è stata emessa una nota di credito per la sua documentazione.`,
refund: `Abbiamo rimborsato ${totalFmt} per la fattura ${params.invoiceNumber}. L'importo apparirà sul metodo di pagamento originale entro 510 giorni lavorativi, a seconda della banca.`,
},
};
const labels: Record<typeof L, Record<string, string>> = {
en: { creditNote: "Credit note", invoice: "Invoice", amount: "Amount", reason: "Reason", cta: "View credit note & download PDF", signoff: "Best regards", brand: "PieCed IT" },
de: { creditNote: "Gutschrift", invoice: "Rechnung", amount: "Betrag", reason: "Begründung", cta: "Gutschrift ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
fr: { creditNote: "Note de crédit", invoice: "Facture", amount: "Montant", reason: "Motif", cta: "Voir la note de crédit & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
it: { creditNote: "Nota di credito", invoice: "Fattura", amount: "Importo", reason: "Motivo", cta: "Visualizza nota di credito & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
};
const l = labels[L];
const subject = subjectsByLocale[L][params.kind];
const intro = introsByLocale[L][params.kind];
const safeName = escapeHtml(params.contactName);
const safeNumberCN = escapeHtml(params.creditNoteNumber);
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";
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject,
text: [
greetingsByLocale[L],
"",
intro,
"",
`${l.creditNote}: ${params.creditNoteNumber}`,
`${l.invoice}: ${params.invoiceNumber}`,
`${l.amount}: ${totalFmt}`,
...(params.reason ? [`${l.reason}: ${params.reason}`] : []),
"",
`${l.cta}:`,
link,
"",
`${l.signoff},`,
l.brand,
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
<h2 style="margin: 0 0 16px; color: ${ACCENT};">${escapeHtml(intro)}</h2>
<p>${safeName === "" ? "" : escapeHtml(greetingsByLocale[L])}</p>
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
<tr><td style="color:#888; padding:6px 0; width:140px;">${l.creditNote}</td><td><strong>${safeNumberCN}</strong></td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.invoice}</td><td>${safeNumberINV}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.amount}</td><td style="color:${ACCENT}; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
${safeReason ? `<tr><td style="color:#888; padding:6px 0; vertical-align:top;">${l.reason}</td><td style="color:#bbb;">${safeReason}</td></tr>` : ""}
</table>
<p>
<a href="${link}" style="display:inline-block; padding:10px 24px; background:${ACCENT}; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
${l.cta}
</a>
</p>
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
<p style="color:#666; font-size:12px;">${l.brand}</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send credit note email:", err);
}
}

View File

@@ -258,3 +258,57 @@ export async function createCheckoutSessionForInvoice(params: {
}
return { url: session.url, sessionId: session.id };
}
// ---------------------------------------------------------------------------
// Phase 7 — refunds
// ---------------------------------------------------------------------------
/**
* Create a Stripe Refund against an invoice's PaymentIntent.
*
* The amount is in CHF; we convert to rappen for Stripe's smallest-
* currency-unit API. Pass 0 or undefined for `amountChf` to refund
* the full charge.
*
* Returns the Stripe refund object so the caller can record the
* refund id and final status. Stripe processes refunds asynchronously
* for some payment methods, so the initial status may be 'pending'
* — the charge.refunded webhook delivers the eventual succeeded /
* failed transition.
*
* Throws on Stripe API errors (no charge, insufficient balance,
* etc.). The caller surfaces these to the admin via the API
* response — we don't swallow them because partial-refund logic
* shouldn't be guessing about server state.
*/
export async function createInvoiceRefund(params: {
paymentIntentId: string;
amountChf?: number;
reason?: "duplicate" | "fraudulent" | "requested_by_customer";
metadata?: Record<string, string>;
}): Promise<{
id: string;
amountChf: number;
status: string;
}> {
const stripe = getStripeClient();
const refundParams: Parameters<typeof stripe.refunds.create>[0] = {
payment_intent: params.paymentIntentId,
metadata: params.metadata,
};
if (params.amountChf && params.amountChf > 0) {
refundParams.amount = chfToRappen(params.amountChf);
}
if (params.reason) {
refundParams.reason = params.reason;
}
const refund = await stripe.refunds.create(refundParams);
// The amount on the response is in rappen; convert back. If no
// amount was passed, Stripe defaults to the full remaining
// charge, which is what we read back.
return {
id: refund.id,
amountChf: refund.amount != null ? refund.amount / 100 : 0,
status: refund.status ?? "unknown",
};
}