Credit Notes — Design Spec (2026-06-15)
Goal
Let an admin/owner issue a credit note against an existing invoice when a patient stops treatment mid-way (or is over-charged). Two outcomes: store credit (clinic credit usable on future invoices) or refund (cash back minus a configurable early-exit fee, default 15%). All money math must be provably correct and keep reports (revenue/profit) honest. UI mirrors the reference: a Credits tab on the invoice page, a Credit Invoice button that opens a full-screen creation flow, and a Totals card (Invoice Total · Amount Credited · Amount Paid · Remaining Balance).
Access
- New permission
billing.credits.manage— admin/owner only (not reception/doctor). - Add to PERMISSION_TREE (UI) + server permission map; CI parity tripwire must pass.
Data model (schema-ensure idempotent DDL — NOT a drizzle migration)
All inserver/src/lib/schema-ensure.ts (self-heals on next request; no manual prod SQL).
app.credit_notes
- id uuid pk, credit_note_number text, clinic_id uuid, invoice_id uuid, patient_id uuid
- type text — ‘store_credit’ | ‘refund’
- status text — ‘draft’ | ‘issued’ | ‘void’
- credited_revenue numeric — Σ credited line revenue (the headline “Total”)
- cost_reversal numeric — Σ reversed line cost
- excess_paid numeric — portion of credit hitting already-paid money
- early_exit_fee_rate numeric — e.g. 15.00 (refund only)
- early_exit_fee_amount numeric — excess_paid * rate/100 (refund only)
- refund_amount numeric — cash returned = excess_paid - fee (refund only)
- store_credit_amount numeric — excess_paid (store_credit only)
- reason text, notes text
- created_by uuid, created_by_role text, issued_at timestamptz, created_at, updated_at
app.credit_note_items
- id uuid pk, credit_note_id uuid, invoice_item_id uuid null, description text, quantity int, unit_amount numeric, revenue numeric, cost numeric, reverse_cost bool, created_at
app.invoices.amount_credited numeric default 0app.invoices.applied_store_credit numeric default 0app.patients.store_credit_balance numeric default 0
credit_note sequence → CN-#### via existing generateDocumentNumber.
The math (locked)
For a credit note C on invoice I with selected lines (each: revenue rᵢ, cost cᵢ, reverseCost flag):- creditedRevenue = Σ rᵢ (= C.credited_revenue, the card “Amount Credited” delta)
- reversedCost = Σ (reverseCost ? cᵢ : 0) (= C.cost_reversal)
- creditedMargin = creditedRevenue − reversedCost
- outstandingBefore = I.total_amount − I.amount_paid − I.amount_credited (balance before C)
- adjustmentPart = min(creditedRevenue, max(0, outstandingBefore)) (just lowers the bill, no cash)
- excessPaid = creditedRevenue − adjustmentPart (hits money already paid)
- store_credit: store_credit_amount = excessPaid; fee = 0; refund = 0
- refund: fee = excessPaid × rate/100; refund_amount = excessPaid − fee; store_credit_amount = 0
- The retained fee is clinic income.
- I.amount_credited += creditedRevenue
- I.balance = effectiveTotal − netPaid, where effectiveTotal = I.total_amount − I.amount_credited + Σ(refund fees on I) netPaid = I.amount_paid − Σ(refund_amount on I) → a full refund settlement returns balance to 0 and books stay balanced.
- status: if amount_credited ≥ total_amount → ‘cancelled’; else if balance ≤ 0 → ‘paid’; else unchanged.
- store_credit: patients.store_credit_balance += excessPaid
- refund: record refund_amount as cash-out (negative payment) + fee retained as income.
Worked example (matches the reference £18,000 case, in PKR here)
Invoice 18,000 fully paid. Patient stops; zirconia bridge 12,000 (cost 4,500) not done → refund credit.- creditedRevenue 12,000, reversedCost 4,500, creditedMargin 7,500
- outstandingBefore = 0 → adjustmentPart 0, excessPaid 12,000
- fee = 12,000 × 15% = 1,800; refund_amount = 10,200
- Revenue impact −12,000 + 1,800(fee) = −10,200; Profit impact −7,500 + 1,800 = −5,700; Cash out 10,200
- Invoice settles to balance 0.
Store credit application (v1, chosen)
- On new invoice creation, if patient.store_credit_balance > 0, show “Apply clinic credit (avail: X)”; admin enters applied amount ≤ min(balance, invoiceTotal).
- On save: invoices.applied_store_credit = applied; patients.store_credit_balance −= applied; invoice balance reduced by applied (treated like a payment of type ‘store_credit’).
Reports wiring (must-do — this is what keeps profit honest)
In financial-summary / financial-statement / EOD / profit aggregations:- net revenue = Σ invoice revenue − Σ issued credit_notes.credited_revenue + Σ early_exit_fee_amount
- net cost = Σ invoice cost − Σ issued credit_notes.cost_reversal
- net profit = net revenue − net cost
- Cash refunded (Σ refund_amount) reduces collected cash.
UI
- InvoiceDetailPage: add a Credits tab (list: No., Total, Date Raised, Status) + a
Totals card (Invoice Total, Amount Credited, Amount Paid, Remaining Balance) + a
Credit Invoice button (gated on
billing.credits.manage, hidden when fully credited). - Full-screen Credit Note creation (
/dashboard/finance/invoices/:id/creditor a full-window overlay): original invoice header, line picker (checkbox + editable amount + per-line “procedure not performed → reverse cost” toggle), store-credit vs refund radio, live preview of credited total / cost reversed / (refund: fee + cash back), reason, Save Draft / Issue. - Premium, theme-aware (light + dark), matches existing finance document styling.
Settings
documentSettings.earlyExitFeePercent(default 15) — editable; shown on plan/quote disclosure text.
Lifecycle / safety
- Draft is editable; Issue locks and applies all effects in ONE transaction.
- Void (admin) reverses an issued credit note’s effects (re-add to balance, remove store credit).
- Credit note PDF (
credit_notetemplate) for sharing/printing.
Out of scope (v1)
- Auto-credit on plan cancellation (manual issue only).
- Partial void of a single line (void whole credit note only).

