Skip to main content

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.manageadmin/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 in server/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
Rollups (idempotent ALTER ADD COLUMN IF NOT EXISTS):
  • app.invoices.amount_credited numeric default 0
  • app.invoices.applied_store_credit numeric default 0
  • app.patients.store_credit_balance numeric default 0
Document numbering: 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
Split the credit between the unpaid bill and already-paid money:
  • 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)
Outcome by type (only the excessPaid portion involves cash/credit):
  • 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.
On issue (draft → issued):
  • 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/credit or 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_note template) 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).