Ghost-Ledger Billing
The order to ghost to realized to sales-order to cashier billing loop on the append-only ledger.
Shipped 2026-06-09. Wires the order → ghost → realized → (sales order → cashier) loop on top of
the existing financial substrate by building the missing billable_ledger running-bill layer
that menu-price-ledger.md and universal-facility-stay-billing.md designed but never built.
Also merges the labour-room cancel cascade (free bed + flip worklist + void ghost charge).
Reuses, never forks: the real price source stays MongoDB
financial.product; realized lines still become aSalesOrderthrough the existingcreateSalesOrderpath and light up the existingtrg_sync_billing_queue → department_queues 'billing' → cashierflow (that bridge = deferred P3).
The model
ORDER PLACED ──► GHOST ──────► REALIZED ─────► SALES ORDER ──► CASHIER
(manifest.order (billable_ledger (clear (createFromLedger (existing billing
.created → row, POSTED + metadata. — DEFERRED P3) queue → cashier)
handleOrder metadata. estimate on
Created) estimate=true) ticket.completed)
└──────── CANCEL / DISCONTINUE ⇒ status=VOIDED (+ free bed, flip worklist) ────────┘
- Ghost = a placed order’s provisional charge.
billable_ledgerrow,status='POSTED',metadata.estimate=true, priced byresolve_menu_price(sku, …). Sums intoencounter_journey_cache.financial_summary.estimatedTotal. - Realized = order completed → the estimate flag is cleared → the row counts toward
financial_summary.runningTotal(the firm bill). - Void = order cancelled/discontinued →
status='VOIDED'(firm/realized charges are preserved; only un-realized ghosts are voided) → drops out of both totals.
Immutable financial snapshot: qty/unit_price/total are never edited — only the lifecycle
flags (metadata.estimate, status) transition.
Billing policy (important). metadata.estimate is a live-display confidence signal
(firm vs provisional) + a cashier-review hint — not a billing gate. The bill (P3
close-encounter-billing) = every non-voided POSTED line, firm or still-estimate, because an
ordered-and-not-cancelled service is a real charge even if its completion event never correlated.
This avoids silent under-billing when realize correlation misses (see below). A tenant wanting
completion-gated billing can filter on the flag at close; the default is order-intent billing.
Realize correlation is best-effort. A ghost is realized by matching the completed ticket’s
ticket_id to the ledger row’s queue_ticket_id (= the order item’s productId). This is exact
for service-charge / orderItem-fallback orders; for structured orders where a department request
bundles several order items (lab/imaging), the ids differ and the line stays estimate — which is
cosmetic only under the billing policy above (it still bills, just shown as provisional). A
precise order-line↔ticket map is a future refinement, not a billing-correctness dependency.
What shipped
| Phase | File | What |
|---|---|---|
| P0 | infrastructure/medbase/migrations/20260609a_menu_items.sql |
menu_items (Supabase price projection of financial.product) + menu_item_price_history |
| P0 | …/20260609b_billable_ledger.sql |
append-only billable_ledger (POSTED/VOIDED/REFUNDED + metadata.estimate ghost flag), encounter-scoped idempotency index, RLS read-only |
| P0 | …/20260609c_resolve_menu_price.sql |
resolve_menu_price() — contract>scheme>payor>standard, effective-dated, never a silent zero |
| P0 | …/20260609d_ledger_running_total_trigger.sql |
projects firm runningTotal + ghost estimatedTotal into encounter_journey_cache.financial_summary |
| P0 | …/20260609e_menu_items_demo_seed.sql |
bilingual demo catalog (incl. payor/scheme overrides) so resolve + ghosts work pre-backfill |
| P2 | …/20260609f_ledger_lifecycle_fns.sql |
atomic realize_ledger_for_ticket() + void_ghost_ledger() (race-safe single-statement UPDATEs; void matches by order id / ticket ids / department incl. NULL-ticket lines) |
| P1 | …/functions/encounter-orchestrator/index.ts writeGhostLedgerLines() |
ghost line per billable order item, called (guarded) from handleOrderCreated; per-line idempotency key scoped by order id |
| P2 | same — realizeLedgerForTicket() |
calls realize_ledger_for_ticket rpc on handleTicketCompleted + handleTicketUpdated(COMPLETED) |
| P2 | same — voidGhostLedgerLines() |
calls void_ghost_ledger rpc on handleOrderDiscontinued (reuses the existing discontinue event — no new backend emit) |
| P5 | web/supabase/migrations/20260609_labour_room_cancel_cascade.sql |
trigger on labour_room_requests cancel → free bed (existing trigger frees the bed) + flip labour_room_worklist → CANCELLED + emit manifest.labour_room.cancelled |
| P5 | orchestrator handleLabourRoomCancelled() + routeEvent case + event-contract.ts union |
cross-domain cleanup: cancel labour department_queues row, void ghost labour charge, sync journey cache |
Every billing call in the orchestrator is wrapped in try/catch — a billing failure can never break the
clinical order/queue flow. Non-billable directives (no productCode) write no charge.
Verification status
Validated against a real Postgres 16.2 (self-contained pgserver): 65 behavioral assertions pass
— resolve-price chain + effective-dating + malformed-date safety, ghost→estimated/realized/void totals,
encounter-scoped idempotency, price-history audit, generated column, multi-line-per-ticket grouping,
the atomic realize/void functions + race semantics, NULL-ticket / order-id / dept void coverage, and the
full labour cascade (incl. cancel-after-bed-freed encounter recovery). Orchestrator passes deno check
(strict) with no new type errors. SQL syntax validated with libpg_query.
Hardening (post adversarial review)
An adversarial 3-reviewer pass (each finding independently verified) found and fixed 5 real bugs the
happy-path tests missed: (1) originRef collision — two orders, same SKU, no productId → false-duplicate
skip → under-bill (now order-scoped key); (2) malformed effective-dates crashed resolve_menu_price →
silent price 0 (now safe_to_timestamptz + rpc-error logging); (3) void missed NULL-queue_ticket_id
ghosts on discontinue → over-bill (now void_ghost_ledger matches by order id / dept too); (4) labour
cancel with the bed freed first dropped the encounter id → ghost never voided → over-bill (now recovers
encounter_ref from the most recent assignment); (5) realize/void SELECT-then-UPDATE TOCTOU race (now
atomic single-statement functions, deterministic last-writer-wins).
Deploy (manual — see CLAUDE.md deploy table)
- Supabase migrations (SQL editor or
psql -f, in order):20260609a → b → c → d → e → f(medbase) and20260609_labour_room_cancel_cascade.sql(web/supabase). - Edge functions:
supabase functions deploy encounter-orchestrator+supabase functions deploy close-encounter-billing(setGATEWAY_URL+GATEWAY_SERVICE_TOKENon the latter for P3). - Backend (P3 only): deploy
services/financial(push to main triggers it) + restart the gateway so thesalesOrder.createFromLedgerauto-alias registers. P0–P2/P5/P4 need no backend deploy. - Frontend (P4): ships with the normal web build; mount
RunningBillPanelConnectedwhere the running bill should show.
Verify
-- price resolution (chain + never-null fallback)
SELECT * FROM resolve_menu_price('ITEM-3356', 'ENC1'); -- standard 500
SELECT * FROM resolve_menu_price('ITEM-3356', 'ENC1', now(), 'PHC'); -- payor 450
SELECT * FROM resolve_menu_price('LAB-CBC', 'ENC1', now(), NULL, 'NHSO'); -- scheme 120
SELECT * FROM resolve_menu_price('NOPE-SKU', 'ENC1'); -- unresolved, 0
-- ghost → running total (manual ledger insert as service role)
INSERT INTO billable_ledger (encounter_id, sku, qty, unit_price, status, metadata)
VALUES ('ENC1','ITEM-3356',1,500,'POSTED','{"estimate":true}');
SELECT financial_summary->'estimatedTotal' FROM encounter_journey_cache WHERE encounter_id='ENC1'; -- 500
-- realize: clear estimate → moves to runningTotal
UPDATE billable_ledger SET metadata = metadata - 'estimate'
WHERE encounter_id='ENC1' AND sku='ITEM-3356';
SELECT financial_summary->'runningTotal' FROM encounter_journey_cache WHERE encounter_id='ENC1'; -- 500
Labour cancel: set a labour_room_requests.status='CANCELLED_BY_USER' → its active
labour_room_bed_assignments.is_active flips false (bed → AVAILABLE via the existing trigger),
the labour_room_worklist row → CANCELLED, and a manifest.labour_room.cancelled event fires.
P3 — close → SalesOrder → cashier (SHIPPED 2026-06-09)
| File | What |
|---|---|
services/financial/.../salesOrder/dto/createFromLedgerSalesOrder.dto.ts |
request/response DTOs ({ encounterId, patientId?, closeReason?, idempotencyKey, lines[] }) |
services/financial/.../salesOrder/salesOrder.controller.mixin.ts salesOrder.createFromLedger action |
POST /v2/financial/salesOrders/createFromLedger — resolves products by sku, builds OrderRequestItem[] with ledger-canonical unit price, calls the existing createSalesOrder (reusing its orderRequest-dedup idempotency + 24h consolidation), then emits queue.status.update (CASHIER) + hospital.financial.updated exactly like create → lights up the existing cashier queue, zero cashier-UI change |
infrastructure/medbase/functions/close-encounter-billing/index.ts |
reads NON-VOIDED POSTED lines → POSTs them to the endpoint (Bearer GATEWAY_SERVICE_TOKEN) → stamps encounter_journey_cache.financial_summary.billing_closed |
Gateway: the financial.salesOrder.* whitelist wildcard already covers the new action — only a
backend deploy + gateway restart is needed, no whitelist edit. Verified: the financial service
type-checks clean (0 errors in the DTO + action under the real tsconfig with platform-api-schema built);
edge fn passes deno check. Runtime (Mongo/Moleculer) verification happens at deploy.
P4 — price reflected on the Order System (SHIPPED 2026-06-09)
web/src/services/encounter-ledger/: RunningBillPanel (read-only line table — qty × unit price,
price-source chip, GHOST/REALIZED/VOIDED badge, unresolved warning, Ghost/Realized/Total split) +
useEncounterLedger(encounterId) (Supabase realtime read of billable_ledger, never writes) +
RunningBillPanelConnected drop-in. Verified in the sandbox (?target=RunningBillPanel): all states
render, totals correct (Ghost 420 + Realized 520 = 940, voided excluded), 0 console/page errors.
Follow-up: wire the per-line price/badge into the inline order-dialog cells (SelectedOrderItem).
Completion (SHIPPED 2026-06-09)
- menu-items-sync —
product.syncMenuItemsaction (POST /v2/financial/products/syncMenuItems) +ProductService.syncMenuItems(): projects the MongoDBfinancial.productchargemaster → Supabasemenu_items(sku=code, base_price, payor_prices by payor id, scheme_prices bydisplayingCode), upsert-on-sku, batched. Run once to price real orders beyond the demo seed.financial.product.*gateway wildcard already covers it. Type-checks clean. - Per-line ledger → SalesOrder stamp —
20260609g_billable_ledger_sales_order.sqladdsbillable_ledger.sales_order_id;close-encounter-billingstamps it on every materialized POSTED line at close (alongsidefinancial_summary.billing_closed+ salesOrderId). - Inline order-cell price —
SelectedOrderItem.tsxnow prefers the ledger-canonicalunitPriceand shows a price-source + ghost/realized chip when present (purely additive + gated → existing composed orders unchanged).
Completion — round 2 (SHIPPED 2026-06-10)
Both previously-deferred items are now built (additive, fail-soft, zero-regression).
-
Event-driven
menu_itemssync —ProductService.upsertMenuItem()(fail-soft single-row projection reusingtoMenuItemRow) now fires after everycreateProduct(post-commit, outside the txn) andupdateProduct(off the full lean docrepository.updatereturns), so a price / payor / scheme / status edit reachesresolve_menu_priceimmediately — no more waiting for the one-shot backfill. Shares amenuItemsClient()helper withsyncMenuItems; a missing-env / Supabase outage / code-less product is a silent no-op (never breaks the product write; the backfill stays the safety net). Also fixed a latent bug in the reusedtoMenuItemRow: it read(p as any).nameEng(the Product field isnameEn) soname_enalways fell back to the Thai name — breaking the bilingual rule; nowp.nameEn ?? (p as any).nameEng ?? p.name. Type-clean (the<any>Database generic also cleared 2 latent.upsertoverload errors in the originalsyncMenuItems). -
Inline chip lit from the live ledger —
NewOrder.tsx(the sole parent that mounts the order composer, already holdingresolvedEncounterId) now calls the existinguseEncounterLedgerhook and threads ONE optionalresolveLedger(order) → {unitPrice?, priceSource?, ledgerStatus?}closure down throughOrderSummaryAndActions→SelectedOrderItem(which already consumed those fields). Match key =order.product.code === billable_ledger.sku(scoped by the hook’sencounter_idfilter); among same-SKU rows it prefers firm > ghost > voided and fails closed (chip hidden) on a genuine same-SKU tie rather than guessing. Display-only: Formikvalues.ordersis never mutated, aggregate totals stay onorder.price, and an unresolved ghost (unit_price 0) keeps the catalog price while still showing the redunresolvedchip. Verified: 11/11 resolver edge cases (zero-regression / tier selection / ambiguity bail / unresolved-0 / voided mapping) + medical-kit package type-checks clean.Scope (honest): the chip lights only where a concrete
encounterId+ already-placed lines exist (e.g.typeForm='docterOrder'). Being-composed orders (no ledger row) and appointment/future-order mounts (noencounterId) no-op → chip hidden, byte-identical to before. The@/servicesimport lives ONLY inNewOrder.tsx(the same app-boundary leak it already uses for@/auth.config); the two leaf components stay presentational + Supabase-free.