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

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