Skip to main content

Credit Notes v2 — Documents, Store-Credit Application & Audit Completeness (2026-06-16)

Builds on the shipped v1 (commit 8416d573d) and its design spec 2026-06-15-credit-notes-design.md. v1 delivered: route + schema, Credits tab, Totals card, full-screen create flow, void, and cash-basis reports wiring. This spec covers the four follow-ups the user requested.

Goals

  1. Credit note is a first-class document — its own detail page with on-screen render, PDF, print and a public share link, an exact replica of the invoice/receipt/quotation experience.
  2. Store credit is usable — when a patient with store_credit_balance > 0 gets a new invoice, staff can apply it (one tap); on a new quote it is shown for information only.
  3. Audit/history is complete and reliable — every invoice/quote/credit-note lifecycle event lands on the parent entity’s History timeline.
  4. UI polish — tighten the Credits tab, create page and new detail page to match the finance document bar.
Access stays admin-only via the existing billing.credits.manage permission.

Workstream 1 — Credit note document, detail page, PDF, print, share

Detail page

  • New ui/src/pages/finance/CreditNoteDetailPage.tsx, modeled on ui/src/pages/finance/ReceiptDetailPage.tsx.
  • Route in ui/src/App.tsx: /dashboard/finance/invoices/:id/credit-notes/:cnId (admin/superadmin only, RequireModule module="finance"). Reached by clicking a credit-note row in the invoice Credits tab (replaces today’s inert row).
  • Data: new serverComm getCreditNote(cnId) hitting the existing GET /credit-notes/:id (returns the credit note + items). Loads the parent invoice + patient for header context.
  • Actions: Print, Download (letterhead), Share link, and (when status===‘issued’) Void — reusing the existing void mutation/dialog.

On-screen document

  • New ui/src/components/documents/CreditNoteDocument.tsx, mirroring InvoiceDocument.tsx: clinic letterhead/branding, “CREDIT NOTE” title + credit_note_number, patient block, reference to the original invoice number, credited line items (description, qty, unit, credited amount, cost reversed?), then the split block — Credited revenue, Cost reversed, Adjustment to bill, Against money paid, and the outcome (Refund: early-exit fee + cash refunded; or Store credit added). Reason + status stamp (draft / issued / void).
  • Accepts optional signatureDataUrl / stampDataUrl (same processing as the invoice page) for the on-screen + clinic-download copy.

PDF

  • New ui/src/lib/pdf/CreditNote.tsx (react-pdf) — the live path, modeled on ui/src/lib/pdf/Receipt.tsx/Invoice.tsx. The templates/*Pdf.tsx files are DEAD; do not touch them.
  • Wired through openPdfInNewTab(...) and LetterheadDownloadButton exactly as ReceiptDetailPage does.

Print

  • PrintButton + useDocumentPrint hook + doc-print-wrapper, same as the invoice/receipt pages.
  • server/src/routes/public-documents-protected.ts: add 'credit_note' to the documentType z.enum([...]) and a validation branch (load credit note by id, confirm it belongs to the clinic) alongside the existing invoice/receipt branches.
  • server/src/routes/public-documents.ts: add a fetch-by-token branch that returns the credit note + items + clinic branding for documentType:'credit_note'.
  • ui/src/pages/PublicDocumentPage.tsx: add 'credit_note' to the type union and a render branch using CreditNoteDocument with no signature/stamp (public copy stays unsigned — anti-misuse rule). No accept/reject CTA (credit notes are not actionable by the patient, unlike quotations).
  • public_document_links.documentType is free-text; only the comment needs updating, no DDL.

Workstream 2 — Store credit: show + one-tap apply

New invoice (apply)

  • Server POST /invoices (and the appointment-invoice + with-installments create paths) accept an optional appliedStoreCredit number. The server:
    • re-reads patients.store_credit_balance and clamps applied = min(requested, balance, invoiceTotal) — never trusts the client;
    • within the create transaction: sets invoices.applied_store_credit = applied, decrements patients.store_credit_balance -= applied, and sets invoices.balance = total - amount_paid - applied (status → ‘paid’ if ≤ 0).
    • No receipt is created and cash reports are untouched — store credit is previously-collected cash, not new revenue, so folding it into receipts would double-count. (Consistent with v1’s cash-basis reports.)
    • records an audit event (Workstream 3).
  • UI: in the invoice create surfaces (ui/src/components/billing/InvoiceForm.tsx and AppointmentInvoiceDialog.tsx / AppointmentInvoiceSheet.tsx), when the selected patient has store_credit_balance > 0, show a banner with an Apply control (numeric input defaulting to min(balance, total), capped). Submit passes appliedStoreCredit.
  • InvoiceDocument shows an “Store credit applied” line when applied_store_credit > 0.

New quote (show only)

  • In the quotation create/detail surfaces, when the patient has store credit, show an informational note (“Patient has PKR X store credit”) with a link to the source credit note. No application — a quote is not money.

Patient store-credit visibility

  • Surface store_credit_balance on the patient header/billing area so staff can see it outside the invoice flow (small addition; read-only).

Workstream 3 — Audit / history completeness (mirror-log)

Root cause: getEntityActivity(entityType, entityId, clinicId) filters strictly by entityType + entityId, so only rows logged as entityType:'invoice' for that invoice appear on its History tab. Child-entity events (receipts, payments, credit notes) and several invoice/quote endpoints either log under a different entityType or don’t log at all. Approach (A — mirror-log to parent). Add two thin helpers in server/src/lib/activity.ts:
  • logInvoiceEvent({ db, clinicId, invoiceId, patientId, userId, userRole, action, message, changes })
  • logQuotationEvent({ ... quotationId ... })
These wrap recordActivity with entityType:'invoice' / 'quotation'. Child flows continue to log their own entity AND call the parent helper so the event shows on the parent timeline. Events to guarantee on the INVOICE timeline:
  • created, updated, sent, status→cancelled/closed, split, convert-to-installments
  • payment recorded / receipt issued (mirror from receipts.tsx)
  • share link created + revoked (from public-documents*)
  • credit note: draft created, issued, voided (from credit-notes.tsx — today only issue logs; add draft + void)
  • store credit applied / reversed (Workstream 2)
Events to guarantee on the QUOTATION timeline:
  • created, updated, sent, accepted, rejected, revision_requested, reissued, converted-to-invoice, share link created/revoked.
Concrete gaps to fill (from audit): invoices.tsx /:id/split (no log); credit-notes.tsx draft + void (no log); receipts/payments not consistently mirrored to the invoice; quote→invoice conversion only logs one side; share/revoke coverage. Each gets the appropriate logInvoiceEvent/logQuotationEvent call. No timeline/query change — rendering and getEntityActivity stay as-is; we only ensure the rows exist. Messages stay human-readable (they render verbatim in the timeline).

Workstream 4 — UI polish

  • Credits tab: row click → new detail page; consistent finance styling; clearer empty/loading states; the Totals card and refund/fee footnotes already shipped — align spacing/typography with the receipt/invoice pages.
  • Create page: align field styling, confirm the live-preview matches the server split, tidy the type selector and line picker on mobile.
  • Store-credit banner styled to match finance cards (theme-aware light + dark).
  • Get a visual pass (reload + screenshot or Playwright) before calling UI done — tsc/build does not prove UI quality.

Data model

No new tables. Columns already exist from v1 (invoices.amount_credited, invoices.applied_store_credit, patients.store_credit_balance, credit_notes, credit_note_items) and are live on prod. The store-credit application path is the first writer of invoices.applied_store_credit.

Reports

Already cash-basis and correct from v1 (subtract refunded cash + reversed cost). Store-credit application creates no receipt, so it correctly does not change realized cash revenue. No further reports work in this spec.

Out of scope (YAGNI)

  • Standalone “Credit Notes” sidebar list (credit notes always belong to an invoice).
  • Emailed-PDF auto-share beyond the copy link (the separate autoShareDocumentsByEmail switch is unchanged).
  • Spending store credit anywhere except a new invoice.
  • EOD report credit integration (still deferred from v1; daily accrual-vs-prior- invoice semantics need their own pass).
  • Editable early-exit-fee % settings editor (still deferred; preview uses the 15% server default).
  • Credit-note numbering reconciliation with generateDocumentNumber (count-based CN-#### with the unique index stays).

Testing / verification

  • Server: permission parity tests still green; add/verify the activity helpers.
  • Money math unchanged (server computeSplit is the authority); store-credit clamp enforced server-side and unit-checkable.
  • Visual pass on the new detail page, public share render, store-credit banner, and a real PDF (eyeball it — react-pdf layout must be checked, per the Urdu/PDF lessons) before shipping.