This commit is contained in:
551
src/lib/db.ts
551
src/lib/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user