medOS ultra

Ghost-Ledger Billing

The order to ghost to realized to sales-order to cashier billing loop on the append-only ledger.

9 min read diagramsUpdated 2026-06-09docs/architecture/ghost-ledger-billing-implementation.md

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 a SalesOrder through the existing createSalesOrder path and light up the existing trg_sync_billing_queue → department_queues 'billing' → cashier flow (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_ledger row, status='POSTED', metadata.estimate=true, priced by resolve_menu_price(sku, …). Sums into encounter_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)

  1. Supabase migrations (SQL editor or psql -f, in order): 20260609a → b → c → d → e → f (medbase) and 20260609_labour_room_cancel_cascade.sql (web/supabase).
  2. Edge functions: supabase functions deploy encounter-orchestrator + supabase functions deploy close-encounter-billing (set GATEWAY_URL + GATEWAY_SERVICE_TOKEN on the latter for P3).
  3. Backend (P3 only): deploy services/financial (push to main triggers it) + restart the gateway so the salesOrder.createFromLedger auto-alias registers. P0–P2/P5/P4 need no backend deploy.
  4. Frontend (P4): ships with the normal web build; mount RunningBillPanelConnected where 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-syncproduct.syncMenuItems action (POST /v2/financial/products/syncMenuItems) + ProductService.syncMenuItems(): projects the MongoDB financial.product chargemaster → Supabase menu_items (sku=code, base_price, payor_prices by payor id, scheme_prices by displayingCode), 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 stamp20260609g_billable_ledger_sales_order.sql adds billable_ledger.sales_order_id; close-encounter-billing stamps it on every materialized POSTED line at close (alongside financial_summary.billing_closed + salesOrderId).
  • Inline order-cell priceSelectedOrderItem.tsx now prefers the ledger-canonical unitPrice and 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_items syncProductService.upsertMenuItem() (fail-soft single-row projection reusing toMenuItemRow) now fires after every createProduct (post-commit, outside the txn) and updateProduct (off the full lean doc repository.update returns), so a price / payor / scheme / status edit reaches resolve_menu_price immediately — no more waiting for the one-shot backfill. Shares a menuItemsClient() helper with syncMenuItems; 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 reused toMenuItemRow: it read (p as any).nameEng (the Product field is nameEn) so name_en always fell back to the Thai name — breaking the bilingual rule; now p.nameEn ?? (p as any).nameEng ?? p.name. Type-clean (the <any> Database generic also cleared 2 latent .upsert overload errors in the original syncMenuItems).

  • Inline chip lit from the live ledgerNewOrder.tsx (the sole parent that mounts the order composer, already holding resolvedEncounterId) now calls the existing useEncounterLedger hook and threads ONE optional resolveLedger(order) → {unitPrice?, priceSource?, ledgerStatus?} closure down through OrderSummaryAndActionsSelectedOrderItem (which already consumed those fields). Match key = order.product.code === billable_ledger.sku (scoped by the hook’s encounter_id filter); 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: Formik values.orders is never mutated, aggregate totals stay on order.price, and an unresolved ghost (unit_price 0) keeps the catalog price while still showing the red unresolved chip. 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 (no encounterId) no-op → chip hidden, byte-identical to before. The @/services import lives ONLY in NewOrder.tsx (the same app-boundary leak it already uses for @/auth.config); the two leaf components stay presentational + Supabase-free.

Ask Anything