medOS ultra

Menu Price Ledger

Backend-stamped pricing per queue row: menu_items + price history + append-only billable_ledger + resolve_menu_price().

24 min read diagramsUpdated 2026-05-25docs/architecture/menu-price-ledger.md

Status: Design — resolved 2026-05-15, ready for M1 implementation Owner: Encounter orchestrator + financial service Branch: claude/check-queue-state-D8YP5 Date: 2026-05-15

Why this doc exists

Today every order type spawns a department_queues row through one of two writers (encounter-orchestrator or inpatient-handlers), but none of those writers stamp a price. The chargemaster (Product collection in MongoDB) does exist, but its price is only resolved at one moment — when the financial service creates an OrderRequestItem — and after that the queue row, the orchestrator, the read-model cache, and the cashier UI all have to re-derive the cost from scattered places (OrderRequestItem.unitPrice, or_case_costing JSONB, blood_lis_results.price, the bare Product.price).

The frontend currently fills that gap. We want to close it on the backend so:

  1. Every department_queues row carries the price it was billed at, the source of that price (standard / payor / scheme / contract), and a pointer to a ledger entry.
  2. A billable_ledger table is the single append-only source of truth for the live bill on any encounter — re-sum it at any time, no recomputation in the UI.
  3. Frontend reads metadata.unitPrice (queue) or billable_ledger (totals) instead of computing prices client-side.
  4. ER and LR — which today don’t flow through the order path — get a queue + ledger entry from the encounter/admission lifecycle, not from a frontend “create charge” call.

What already exists (audited 2026-05-15)

Layer What’s there What’s missing
Chargemaster (canonical menu) Product (Mongo) — price, additionalPrice[], payorPrice[] with {payor, price, discount, additionalPrice[], startDate, cancelDate} (packages/platform-api-schema/src/financial/product/entity/Product.ts:79-91), schemePrice[] with {plan, price, displayingCode, discount, additionalPrice[], startDate, cancelDate} (Product.ts:97-110); financial.product.service action getProductByCode. Effective-dating already exists per-tier via startDate/cancelDate. A separate, near-identical Product (subset — no payor/scheme arrays) lives in merchandise (packages/platform-api-schema/src/merchandise/product/entity/Product.ts); both target the product Mongo collection — financial is canonical. No Supabase projection; no audit row on price change; no centralized resolver (every caller re-implements tier selection); the merchandise subset can silently miss payor/scheme overrides if a service mistakenly imports it
Hardcoded service-charge SKUs salesOrder.controller.mixin.ts:571,619 reads product.price for SVC-0001 (OPD service charge, business hours), SVC-003 (after-hours), SVC-0002 (new patient card) Hardcoded code list — adding/renaming requires backend change
Queue spawning encounter-orchestrator/index.ts writes queue rows in handleOrderCreated (:1041-1090), ORDERS_ACKNOWLEDGED (:1816-1862), ORDERS_ACKNOWLEDGED for held (:1949-1994), dispatch.released (:2072-2103), handleLabSpecimenPipeline (:2803). Plus 18 inpatient handlers (see inventory below). None of the 19 writers inject price into metadata
OR case costing or_case_costing (migration 084_or_case_costing.sql) — already has unit_price per line item in implant_line_items/supply_line_items/manual_line_items jsonb (each {sku,label,quantity,unit_price,total,source}), plus time × rate columns and a status state machine (draft → submitted → posted → voided → amended) with invoice_ref back-pointer. Idempotent UNIQUE on case_id — not append-only. Not joined to a queue row; status transitions aren’t propagated into a per-encounter running total
Market-pack rate tables philhealth_case_rates + philhealth_professional_fees + philhealth_room_rates (PH); kaigo_basic_rates + kaigo_additional_charges + kaigo_food_housing_costs + kaigo_regional_unit_prices (JP). All have effective_from/effective_to or effective_date. No backend code queries any of these. Seed data sits unused — the resolver is the natural first consumer (contract tier)
FP&A charge fact fpa_fact_charge (migration 091_fpa_facts.sql:83-108) — grain: 1 row per sales-order line, with revenue_code, charge_code, charge_description, quantity, unit_price, gross/discount/net/cost, generated margin, and charge_category enum (room/pharmacy/lab/imaging/procedure/supply/professional/blood/therapy/emergency/nursing/diagnostic/clinic/other). Synced retroactively from sales_order by fpa-mongo-sync. Historical, not live; not refreshed until a salesOrder exists. The proposed billable_ledger becomes its operational predecessor — same shape, but populated at order-spawn time and at every status transition; fpa-mongo-sync re-sources from ledger
Bill of record salesOrder totals computed in salesOrder.transform.ts:73-287 from OrderRequestItem.unitPrice (which itself was set at item-create time, often from frontend) No append-only ledger; running total only exists post-salesOrder-creation
Blood / lab pricing columns Searched — they do not exist. No blood_lis_results.price column; no 20260429_blood_lab_pricing_columns.sql migration. (An earlier draft of this doc cited this — incorrect.) Has to be added net-new as part of M5
ER queue Mapping exists in resolveDepartmentKeyFromOrderItem() (encounter-orchestrator/index.ts:367-407) for category='emergency' ER itself produces no queue row — only the orders placed during the ER visit do. Encounter-driven wiring blocked by event-naming gap (see M0 below)
LR queue Same mapping for category='labour' + dedicated module per docs/architecture/delivery-room-remaining-backend-work.md Same. LR ledger close-out blocked by encounter.billClosed flag (still open). Note: NutritionRequest.npoStartAt/npoEndAt shipped 2026-04-26 — no longer a blocker, only billClosed remains.

Complete department_queues writer inventory

19 distinct writers (infrastructure/medbase/functions/...):

Writer Trigger event dept_type produced
encounter-orchestrator/index.ts:1041-1090 (handleOrderCreated) manifest.order.created dynamic: consultation, medication, lab, imaging, procedure, nutrition, blood, surgery, labour, emergency, billing
inpatient-handlers/handleAdmissionRequested.ts:105 admission.requested admission_pending
inpatient-handlers/handleAdmissionConfirmed.ts:109 admission.confirmed ipd_ward_arrival
inpatient-handlers/handleOrScheduled.ts:91 or.scheduled or_pending
inpatient-handlers/handleOrStarted.ts:78 or.started or_in_progress
inpatient-handlers/handleOrCompleted.ts:75 or.completed or_completed
inpatient-handlers/handlePacuAdmitted.ts:82 pacu.admitted pacu_arrival
inpatient-handlers/handlePacuDischarged.ts:89,113 pacu.discharged pacu_discharged + ipd_ward_arrival
inpatient-handlers/handleDischargePlanned.ts:91 discharge.planned ipd_discharge_planned
inpatient-handlers/handleDischargeCompleted.ts:95 discharge.completed ipd_discharge_completed
inpatient-handlers/handlePorterRequested.ts:75 porter.requested porter_pending
inpatient-handlers/handlePorterCompleted.ts:70 porter.completed porter_completed
inpatient-handlers/handleRxPrescribed.ts:70 rx.prescribed ipd_pharmacy_pending
inpatient-handlers/handleRxVerified.ts:67 rx.verified ipd_pharmacy_verified
inpatient-handlers/handleRxDispensed.ts:64 rx.dispensed ipd_pharmacy_dispensed
inpatient-handlers/handleBedTransferred.ts:136,155 bed.transferred ipd_ward_transfer_out + ipd_ward_transfer_in
inpatient-handlers/handleReferralReceived.ts:99 referral.received referral_inbound
inpatient-handlers/handleReferralAccepted.ts:96 referral.accepted referral_admitted
inpatient-handlers/handleAdrReported.ts:63 adr.reported ipd_adr_review

Plus the Postgres-side trigger trg_sync_billing_queue (per docs/architecture/billing-queue-auto-sync.md) which auto-spawns a dept_type='billing' row from encounter_journey_cache.financial_summary — relevant because the new ledger drives financial_summary, so trg_sync_billing_queue becomes the natural close-out cue.

Orchestrator event-name reality

The orchestrator’s routeEvent switch (encounter-orchestrator/index.ts:535-622) only handles manifest.* events — 21 of them, never raw clinical.encounter.*:

manifest.encounter.seeded         manifest.order.acknowledged
manifest.order.created            manifest.order.discontinued
manifest.ticket.updated           manifest.queue.workflow_transition
manifest.ticket.completed         manifest.queue.doctor_assigned
manifest.financial.updated        manifest.queue.orders_acknowledged
manifest.encounter.closed         manifest.order.dispatch_held
manifest.encounter.status_updated manifest.order.dispatch_released
manifest.clinical.updated         manifest.order.supplemental_created
manifest.clinical.alert           manifest.claim.account_closed
                                  manifest.claim.invoice_generated
                                  manifest.claim.exported

The clinical service emits clinical.encounter.created and clinical.encounter.arrived at encounter.controller.mixin.ts:4010,4112 — these are not normalized to manifest.* today, so the orchestrator never sees encounter-open. Encounter rows appear in department_queues only when the first order arrives (manifest.order.createdhandleOrderCreated). This is the root cause of “ER/LR don’t spawn from encounter-open” and the prereq for M6.

Target architecture

                    ┌────────────────────────────────────────────┐
                    │   services/financial/product   (Mongo)     │
                    │   ── canonical chargemaster                │
                    │   ── add: effectiveFrom, effectiveTo,      │
                    │           supersededBy, payorPrice[],      │
                    │           schemePrice[]                    │
                    └─────────────────┬──────────────────────────┘
                                      │
                       NATS event:    │  product.priceVersioned
                                      ▼
                ┌──────────────────────────────────────────────────┐
                │  Supabase: menu_items + menu_item_price_history  │
                │  ── projection of every active Product row       │
                │  ── one row per (productId, effectiveFrom)       │
                │  ── pricing edge function reads from here        │
                └─────────────┬────────────────────────────────────┘
                              │
                              │   resolvePrice(sku, encounterCtx, asOf)
                              ▼
   ┌────────────────────────────────────────────────────────────────┐
   │  price-resolver  (new Supabase edge function OR Moleculer act) │
   │  ── input:  {sku, payorPlan, scheme, encounterClass, asOf}     │
   │  ── output: {unitPrice, priceSource, contractRef, snapshotId}  │
   │  ── used by:                                                   │
   │       • orchestrator handlers (queue stamp)                    │
   │       • salesOrder controller (replace product.price line)     │
   │       • inpatient-handlers (RX, admission, OR)                 │
   └────────────────┬─────────────────────────────┬─────────────────┘
                    │                             │
                    ▼                             ▼
        ┌─────────────────────────┐   ┌──────────────────────────────┐
        │  department_queues      │   │  billable_ledger (NEW)       │
        │  .metadata.unitPrice    │   │  ── append-only              │
        │  .metadata.priceSource  │   │  ── one row per chargeable   │
        │  .metadata.ledgerEntry  │   │      event (order, OR        │
        │                         │   │      minute, RX dose, ER     │
        │                         │   │      visit, LR delivery)     │
        └─────────────────────────┘   └──────────────────────────────┘
                                                  │
                                                  ▼
                              ┌────────────────────────────────────────┐
                              │  encounter_journey_cache               │
                              │   .financial_summary.runningTotal      │
                              │   .financial_summary.ledgerCount       │
                              │  (auto-recomputed by trigger on        │
                              │   billable_ledger INSERT/UPDATE)       │
                              └────────────────────────────────────────┘

Schema additions

1. menu_items (Supabase projection of financial.product)

Shape mirrors the Mongo Product document one-to-one — including the existing payorPrice[]/schemePrice[] arrays with their startDate/cancelDate. Reuses charge_category enum from fpa_fact_charge for consistency:

CREATE TABLE menu_items (
  id              uuid PRIMARY KEY,                       -- mirrors product._id
  sku             text NOT NULL UNIQUE,                   -- product.code
  name            text NOT NULL,                          -- product.name (TH)
  name_en         text,                                   -- product.nameEn
  unit            text,
  category        text NOT NULL                           -- aligns with fpa_fact_charge.charge_category
                    CHECK (category IN (
                      'room','pharmacy','lab','imaging','procedure',
                      'supply','professional','blood','therapy',
                      'emergency','nursing','diagnostic','clinic','other'
                    )),
  base_price      numeric(12,2) NOT NULL,                 -- product.price
  payor_prices    jsonb DEFAULT '[]',                     -- mirrors Product.payorPrice[]
                                                          -- [{payor,price,discount,additionalPrice[],startDate,cancelDate}]
  scheme_prices   jsonb DEFAULT '[]',                     -- mirrors Product.schemePrice[]
                                                          -- [{plan,price,discount,displayingCode,additionalPrice[],startDate,cancelDate}]
  is_active       boolean NOT NULL DEFAULT true,
  source          text NOT NULL DEFAULT 'mongo',          -- 'mongo','market-pack','manual'
  metadata        jsonb DEFAULT '{}',
  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_menu_items_sku ON menu_items(sku) WHERE is_active;
CREATE INDEX idx_menu_items_category ON menu_items(category) WHERE is_active;
CREATE INDEX idx_menu_items_payor_gin ON menu_items USING gin (payor_prices);
CREATE INDEX idx_menu_items_scheme_gin ON menu_items USING gin (scheme_prices);

2. menu_item_price_history (audit)

CREATE TABLE menu_item_price_history (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  menu_item_id    uuid NOT NULL REFERENCES menu_items(id),
  sku             text NOT NULL,
  base_price      numeric(12,2) NOT NULL,
  payor_prices    jsonb DEFAULT '[]',
  scheme_prices   jsonb DEFAULT '[]',
  effective_from  timestamptz NOT NULL,
  effective_to    timestamptz,                            -- null = still current
  changed_by      text,
  change_reason   text,
  created_at      timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_mph_sku_window ON menu_item_price_history(sku, effective_from DESC);

A BEFORE UPDATE trigger on menu_items writes the old row to menu_item_price_history whenever base_price, payor_prices, or scheme_prices change.

3. billable_ledger (append-only running bill)

CREATE TABLE billable_ledger (
  id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  encounter_id      text NOT NULL,
  patient_id        text NOT NULL,
  queue_ticket_id   text,                                 -- nullable: ER admission isn't a queue ticket
  dept_type         text NOT NULL,
  menu_item_id      uuid REFERENCES menu_items(id),
  sku               text,
  description       text NOT NULL,
  qty               numeric(12,4) NOT NULL DEFAULT 1,
  unit_price        numeric(12,2) NOT NULL,
  price_source      text NOT NULL,                        -- 'standard','payor','scheme','contract','manual'
  payor_plan        text,
  scheme            text,
  contract_ref      text,
  total             numeric(14,2) GENERATED ALWAYS AS (qty * unit_price) STORED,
  origin_event      text NOT NULL,                        -- 'order.created','rx.prescribed','or.minute',...
  origin_ref        text,                                 -- source row id (orderRequestItem._id, or_case_costing.id, ...)
  status            text NOT NULL DEFAULT 'POSTED',       -- POSTED | VOIDED | REFUNDED
  voided_by_id      uuid REFERENCES billable_ledger(id),  -- pointer to the refund row
  posted_at         timestamptz NOT NULL DEFAULT now(),
  posted_by         text,
  metadata          jsonb DEFAULT '{}'
);
CREATE INDEX idx_ledger_encounter ON billable_ledger(encounter_id, posted_at);
CREATE INDEX idx_ledger_ticket ON billable_ledger(queue_ticket_id);
CREATE INDEX idx_ledger_origin ON billable_ledger(origin_ref);

A AFTER INSERT trigger sums billable_ledger.total per encounter and updates encounter_journey_cache.financial_summary.runningTotal. This replaces the frontend’s current bill calculation.

4. department_queues.metadata shape (no schema change — jsonb)

Every writer adds these keys:

{
  "unitPrice":    <number>,
  "priceSource":  "standard" | "payor" | "scheme" | "contract",
  "menuItemId":   "<uuid>",
  "ledgerEntry":  "<billable_ledger.id>",
  "pricedAt":     "<iso8601>",
  "payorPlan":    "<plan-code>?",     // when applicable
  "scheme":       "<scheme-code>?"
}

Price resolver contract

type ResolvePriceInput = {
  sku: string;                          // menu_items.sku
  encounterId: string;
  asOf?: string;                        // ISO8601, defaults to now()
  payorPlan?: string;
  scheme?: string;
};

type ResolvePriceOutput = {
  menuItemId: string;
  sku: string;
  unitPrice: number;
  priceSource: 'standard' | 'payor' | 'scheme' | 'contract';
  pricedAt: string;
  effectiveFrom: string;                // window the price came from
  effectiveTo: string | null;
};

Resolution order:

  1. Contract / regulated rate for {sku, payorPlan, scheme, asOf} from market-pack tables:
    • PH: philhealth_case_rates.case_rate_amount matched on case_code/icd_code × effective_date
    • PH: philhealth_room_rates.daily_rate_limit matched on accommodation_type (LR/ER room day-rates)
    • JP: kaigo_basic_rates.units_per_day × kaigo_regional_unit_prices matched on {facility_type, care_level, room_type, effective_from}
    • priceSource='contract', contractRef='<schema>.<table>.<row_id>'
  2. menu_items.scheme_prices entry where plan = scheme AND startDate <= asOf AND (cancelDate IS NULL OR cancelDate > asOf)priceSource='scheme'
  3. menu_items.payor_prices entry where payor = payorPlan AND startDate <= asOf AND (cancelDate IS NULL OR cancelDate > asOf)priceSource='payor'
  4. menu_items.base_pricepriceSource='standard'

Per-tier effective dating maps directly onto the existing Product.payorPrice[].startDate/cancelDate and Product.schemePrice[].startDate/cancelDate arrays — no schema invention required, just consume what’s there. The menu_item_price_history table is for the base price changes (since base_price is a scalar with no built-in dating); per-tier history is reconstructible from the startDate/cancelDate pairs in the jsonb arrays themselves.

Implementation: Postgres function resolve_menu_price(sku, encounter_id, as_of, payor, scheme) RETURNS resolve_price_result — co-located with orchestrator queries, single round-trip. The financial NestJS service calls it via Supabase REST (supabase.rpc('resolve_menu_price', ...)). One implementation, two callers — avoids drift between an edge function and a Postgres function doing the same job.

Per-order-type wiring plan

Each line below identifies (1) the existing handler / writer, (2) what to add. None of these require frontend changes.

Order type Stamp point Origin event What to add
Lab handleOrderCreated at encounter-orchestrator/index.ts:1041-1090 (queue stamp) + handleLabSpecimenPipeline at :2755 (ledger entry when specimen accepted) manifest.order.created call resolver, write ledger row with origin_ref=orderRequestItem._id, set queue.metadata.unitPrice
Imaging Same handler as lab — handleOrderCreated already stamps metadata.system/modality at index.ts:443-448 manifest.order.created piggyback on modality stamp; add ledger row keyed to imagingItemId
Pharmacy (OPD) handleOrderCreated for category='drug' manifest.order.created ledger row per orderRequestItem; queue stamp
Pharmacy (IPD) inpatient-handlers/handleRxPrescribed.ts (queue), handleRxDispensed.ts (status), dispense-cycle-runner (materialization) inpatient.rx.prescribed + dispense cycle ledger row per dispense round (NOT per prescribe — IPD is dose-based); dispense-cycle-runner calls resolver per round
Blood bank handleOrderCreated for category='blood' manifest.order.created ledger row at request; second ledger row at result-time (blood_lis_results has price + price_source columns ready); populate the two existing-but-unused columns via the same resolver
OR inpatient-handlers/handleOrScheduled.ts:91 (queue spawn) + existing or_case_costing upsert or.scheduled (estimate) + or.completed (actuals) + or_case_costing.status transitions (draft→submitted→posted) (a) at or.scheduled: POST one ledger row per estimate component (SVC-OR-MINUTE × est_duration, anesthesia, PACU) with metadata.estimate=true; (b) when or_case_costing.status transitions to posted: VOID all metadata.estimate=true rows for that case_id and POST actuals from or_case_costing.{or_total_amount, anesthesia_total_amount, pacu_total_amount, implant_line_items[], supply_line_items[], manual_line_items[]} — each jsonb element already has {sku,label,quantity,unit_price,total,source} so it maps 1:1 onto ledger rows. Do not duplicate or_case_costing — treat it as the canonical OR costing entity and let the ledger be the projection. or_case_costing.invoice_ref becomes the salesOrder link once posted.
Pathology Today co-mapped to lab. Two options: (i) keep co-mapped + flag pathology orders via metadata.subtype='pathology'; (ii) add a distinct dept_type='pathology'. Recommend (i) for this slice — saves a UI change. manifest.order.created same as lab; add metadata.subtype
ER Encounter-driven. Blocked by M0clinical.encounter.created/clinical.encounter.arrived are not normalized to manifest.* today, so the orchestrator doesn’t see them. After M0: add handleEmergencyEncounterArrived switch case in routeEvent (index.ts:535) that reads encounter.class='EMR' from the manifest payload and spawns queue + ledger rows. PH market pack: contract price comes from philhealth_case_rates + philhealth_room_rates; JP: not applicable (no kaigo ER rate). manifest.encounter.arrived (class=EMR) — emitted after M0 new switch case + new SKU SVC-ER-VISIT — single global SKU, regional pricing variance via payor_prices/scheme_prices (per Decision D2)
LR Same — encounter-driven, blocked by M0. After M0: handleLabourEncounterArrived (class=LR) writes room/day ledger row at admission; subsequent delivery.recorded adds procedure + newborn rows. Ledger close-out blocked by encounter.billClosed (delivery-room-remaining-backend-work.md §2) — without it, the gate has no closer. NutritionRequest.npoStartAt/npoEndAt is shipped 2026-04-26 so the diet-class side is unblocked. manifest.encounter.arrived (class=LR) + delivery.recorded new switch case + new SKUs SVC-LR-ROOM-DAY, SVC-LR-DELIVERY-VAGINAL, SVC-LR-DELIVERY-CS, SVC-LR-NEWBORN-CARE

Edge-function impact summary

Out of the 22 functions in infrastructure/medbase/functions/ (full list: ack-escalator, bot-executor, chula-daily-cash-push, clinical-rules-engine, coding-ai-assistant, coding-rules-engine, dispense-cycle-runner, eclaim-connector, encounter-orchestrator, evaluate-cds-rules, fhir-subscription-matcher, fpa-mongo-sync, gold-layer-refresh, inpatient-handlers, migration-orchestrator, prm-sidecars, procedure-mongo-sync, rcm-reconcile-discharged-unsettled, rcm-rule-engine, report-template-engine, terminology-server, usage-aggregator), 6 change:

Function Change
encounter-orchestrator Inject resolver call into the 5 handlers that write to department_queues (handleOrderCreated, ORDERS_ACKNOWLEDGED × 2, dispatch.released, handleLabSpecimenPipeline); add 2 new switch cases for manifest.encounter.arrived with class=EMR and class=LR after M0
inpatient-handlers handleOrScheduled (estimate ledger), handleOrCompleted (no ledger write — or_case_costing.status transition is the trigger), handleRxPrescribed/handleRxDispensed (NB: IPD is dose-based — ledger writes happen from dispense-cycle-runner, not from handleRxPrescribed), handleAdmissionConfirmed (room-day ledger row), handleBedTransferred (no ledger — it’s a movement, not a charge), handleDischargeCompleted (close-out signal)
dispense-cycle-runner Per-round dose materialization writes one ledger entry per dispensed line — already loops over IPD dispense rounds, just adds a ledger insert
fpa-mongo-sync Re-source fpa_fact_charge from billable_ledger (operational source of truth) instead of from salesOrder.orderRequestItems retroactively. The grain matches 1:1 — straight projection
rcm-reconcile-discharged-unsettled Read from billable_ledger for the unsettled total; reduces drift vs the current recomputation from salesOrder.orderRequestItems
procedure-mongo-sync Optional: emit a manifest.financial.updated after procedure cost upsert, so the ledger picks up procedure cost amendments. Not strictly required for M1–M6

All others are unaffected (ack-escalator, bot-executor, chula-daily-cash-push, clinical-rules-engine, coding-ai-assistant, coding-rules-engine, eclaim-connector, evaluate-cds-rules, fhir-subscription-matcher, gold-layer-refresh, migration-orchestrator, prm-sidecars, rcm-rule-engine, report-template-engine, terminology-server, usage-aggregator).

Relationship to existing tables

Table Today Under this design Net change
Product (Mongo) Canonical chargemaster, scattered consumers Canonical chargemaster; one consumer (fpa-mongo-sync-style projector populates menu_items); resolver is the only price-reading callsite that matters No schema change to Product; new projector job
menu_items (Supabase) doesn’t exist Read-mostly Supabase projection of Product, refreshed on every product.priceVersioned NATS event New table (M1)
or_case_costing (Supabase) Per-case upsert with status state machine, line items already shaped {sku,label,quantity,unit_price,total,source} Unchanged. Ledger projects from or_case_costing.status='posted' transitions — does not duplicate the per-line breakdown, just mirrors total per source row + voids estimate rows No change
fpa_fact_charge (Supabase) Synced retroactively from salesOrder line items Synced from billable_ledger instead — same grain, same columns, just live-sourced fpa-mongo-sync source change only
philhealth_case_rates, kaigo_basic_rates, etc. (market pack) Seeded but unread First reader: resolve_menu_price() contract tier No schema change; new SELECT path
encounter_journey_cache.financial_summary (Supabase) Written by orchestrator from manifest.financial.updated events Additionally maintained by ledger trigger so runningTotal reflects every queued charge, not just paid-up totals. Maintains runningTotalShadow for a week of A/B verification before cutover Trigger added
salesOrder (Mongo) Created at order time with frontend-supplied unitPrice Unchanged in M1–M6. In M7, unitPrice comes from resolve_menu_price() instead of the frontend or product.price Deferred to M7

Migration sequence

Step What Reversible? Frontend impact
M0 (prereq) Bridge clinical.encounter.created/clinical.encounter.arrivedmanifest.encounter.arrived so the orchestrator can react to encounter-open. Two options: (a) add a normalizer to the existing event-contract.ts layer; (b) make the clinical service dual-emit the manifest event alongside the clinical event. Choose (b) to keep the orchestrator’s switch-on-manifest.* invariant. Yes — remove dual-emit. None
M1 Add menu_items + menu_item_price_history tables. Backfill from financial.product via a one-shot projector worker (model after fpa-mongo-sync); wire ongoing sync on a NATS product.priceVersioned event emitted by the financial service on every Product update. Sub-step (D6): replace merchandise/product/entity/Product.ts with a re-export of financial.product.entity.Product so both barrels resolve to one canonical schema. Yes — drop the tables; revert the re-export. None
M2 Add billable_ledger table + AFTER INSERT/UPDATE trigger that increments a runningTotalShadow column on encounter_journey_cache.financial_summary. Run for a week alongside existing computation; diff shadow vs canonical, investigate every mismatch. Yes — drop trigger + column. None
M3 Add Postgres function resolve_menu_price(sku, encounter_id, as_of, payor, scheme) RETURNS resolve_price_result. Resolution order = contract (PhilHealth/Kaigo) → scheme → payor → standard. Hot-path: single function call from the orchestrator. Yes — drop function. None
M4 Wire the 5 orchestrator queue-stamping handlers (handleOrderCreated, two ORDERS_ACKNOWLEDGED paths, dispatch.released, handleLabSpecimenPipeline) + the 4 inpatient ledger writers (handleOrScheduled, handleAdmissionConfirmed, dispense-cycle-runner, plus the new trg_or_case_costing_sync_ledger trigger — per D5 — that handles all three or_case_costing.status transitions: posted voids estimate rows + posts actuals, voided voids the case’s posted rows, amended voids + re-posts from the new snapshot). Resolver failures push to medbase_dead_letter_queue and stamp metadata.priceSource='unresolved' rather than blocking queue insertion. Yes — handlers fall back to existing no-stamp behaviour if resolver returns null; drop the trigger to revert ledger sync. None (additive)
M5 Cut over runningTotalShadowrunningTotal after a week of clean diff. Make fpa-mongo-sync source fpa_fact_charge from billable_ledger. Make rcm-reconcile-discharged-unsettled read from ledger. Yes — flip back to shadow + restore old sources. None
M6 Add ER + LR manifest.encounter.arrived switch cases in routeEvent (depends on M0). LR ledger close-out still blocked by encounter.billClosed flag — ship that flag first or LR ledger lacks a terminal close cue. Yes. None
M7 (deferred) Swap salesOrder.controller.mixin.ts:571,619 to source prices via supabase.rpc('resolve_menu_price', ...) instead of product.price. After this, frontend unitPrice arguments are ignored entirely. Hard to reverse — every new sales order would be priced via resolver. Frontend’s frontend-priced paths become no-ops

Idempotency, correctness, observability

  • Idempotency: billable_ledger unique constraint on (origin_event, origin_ref, status='POSTED') prevents duplicate POSTs when the orchestrator replays an event. The OR estimate→actuals swap explicitly VOIDs the estimate row and inserts a new POSTED actuals row.
  • Refunds: Never delete; insert a status='REFUNDED' row with negative qty pointing to the original via voided_by_id.
  • Realtime: billable_ledger is added to the supabase_realtime publication so the cashier UI gets live running-total updates without polling.
  • Audit: menu_item_price_history captures every price change. Combined with billable_ledger.pricedAt, we can answer “what did we charge for SKU X on this encounter, and was that the price in effect that day?” by joining ledger ↔ history on pricedAt ∈ [effective_from, effective_to).
  • Dead-letter: Resolver failures (sku not found, payor lookup failed) push to medbase_dead_letter_queue with origin='price-resolver' rather than blocking queue insertion. Queue row gets metadata.priceSource='unresolved' and the cashier UI surfaces it as a fixable warning.

What this doc does NOT cover (deferred)

  • Phase 2 swap (M7) of salesOrder.controller.mixin.ts:571,619 to backend-only pricing. Larger change — frontend stops sending unitPrice entirely. Touches every invoice path; separate session.
  • Pathology distinct dept_type. Recommended slice keeps pathology co-mapped to lab with metadata.subtype='pathology'. Separate dept_type is a UI change, defer.
  • Time-based OR pricing curve (rate increases past Nth minute, weekend/night premiums). Today or_case_costing.or_rate_per_minute is flat. If non-flat pricing is needed, model as menu_items.metadata.rateCurve jsonb consumed by resolve_menu_price.
  • Bulk-discount / bundle pricing. Cross-line discounts live in salesOrder.transform.ts:73-287; ledger is line-level. Bundle support would need a bundle_id foreign key on ledger rows + cross-row evaluation in the resolver.
  • Per-tier base_price history. payor_prices/scheme_prices carry their own startDate/cancelDate. Only base_price itself needs the side-table menu_item_price_history.
  • Retroactive ledger backfill. Pre-M5 encounters won’t have ledger rows. fpa_fact_charge still covers them historically. Backfilling is possible but not in scope.
  • Multi-tenant cloud rollout. Today on-prem-only — no tenant_id column on billable_ledger or any read-model table (per Decision D4). When cloud SaaS lands, a dedicated cross-cutting migration adds tenant_id + RLS to every read-model table at once; do not half-commit by adding it to ledger alone.

Appendix A — Hardcoded chargemaster references discovered

These are the only literal SKU strings in the financial service today. Each becomes a menu_items row after M1; the resolver replaces the product.price reads at M7:

SKU Purpose Hardcoded at
SVC-0001 OPD service charge (business hours, Mon-Fri 08:00-16:00) services/financial/src/api/financial/modules/salesOrder/salesOrder.controller.mixin.ts:619
SVC-003 OPD service charge (after-hours / weekend) same file, time-of-day branch at :618
SVC-0002 New patient card same file, :571

New SKUs introduced by this design (all suffixes are placeholders, finalize during M1):

SKU Purpose Introduced by
SVC-ER-VISIT ER triage / visit base charge M6
SVC-LR-ROOM-DAY LR room per-day M6
SVC-LR-DELIVERY-VAGINAL LR vaginal delivery procedure M6
SVC-LR-DELIVERY-CS LR C-section procedure M6
SVC-LR-NEWBORN-CARE LR newborn baseline care M6
SVC-OR-MINUTE OR per-minute room charge (estimate phase) M4 (actuals come from or_case_costing line items)

Appendix B — Market-pack rate tables (orphaned today, first reader = resolver)

Philippines (infrastructure/market-packs/medos-philippines/seed-philhealth-rates.sql)

  • philhealth_case_types(case_type_code, name, category: ordinary|catastrophic|z_benefit)
  • philhealth_case_rates(case_type_id, case_code, icd_code, case_rate_amount, hospital_share, professional_share, effective_date, status)
  • philhealth_professional_fees(role_code, case_category, min_fee, max_fee, standard_fee)
  • philhealth_room_rates(accommodation_type, daily_rate_limit, max_days_covered)
  • philhealth_membership_types(type_code, premium_rate, income_floor/ceiling, monthly_premium_min/max)

Japan (infrastructure/market-packs/medos-japan/seed-kaigo-rates.sql)

  • kaigo_service_types(code, name_ja, name_en, category, is_active, sort_order)
  • kaigo_basic_rates(facility_type, care_level, room_type, units_per_day, effective_from, effective_to) — UNIQUE on (facility_type, care_level, room_type, effective_from)
  • kaigo_additional_charges
  • kaigo_food_housing_costs
  • kaigo_regional_unit_prices — multiplies kaigo_basic_rates.units_per_day to JPY

Today: zero callers in services/ query any of these tables. The resolver in M3 becomes the first consumer.

Resolved decisions (2026-05-15)

Six open questions reviewed and locked. Decisions referenced as D1–D6 elsewhere in this doc and in subsequent migration commits.

D1 — Resolver composition: contract overrides payor

philhealth_case_rates / kaigo_basic_rates and Product.payorPrice[] are not orthogonal layers that compose — they’re a fallback chain. A contract-tier match wins outright.

Resolution order (final):

  1. Contractphilhealth_case_rates (matched on case_code/icd_code) or kaigo_basic_rates × kaigo_regional_unit_prices (matched on facility_type, care_level, room_type). If a row applies, return immediately; payor_prices and scheme_prices are not consulted.
  2. Schememenu_items.scheme_prices entry where plan = scheme AND startDate <= asOf AND (cancelDate IS NULL OR cancelDate > asOf).
  3. Payormenu_items.payor_prices entry, same window check.
  4. Standardmenu_items.base_price.

Rationale: PhilHealth and Kaigo are statutory schemes — when they apply, they apply as the regulated rate regardless of what a payor-specific contract says. Composition (apply payor discount on top of contract rate) was rejected because it doubles the audit surface and makes resolver output non-obvious.

Schema impact: None — this is the resolution order already drafted in the “Price resolver contract” section, just locked in.

D2 — ER/LR SKU convention: global SKU + payor overrides

Single global SKU per service (e.g. SVC-ER-VISIT, SVC-LR-ROOM-DAY). Regional pricing variance flows entirely through Product.payorPrice[] and Product.schemePrice[] — the same mechanism that already differentiates Thai vs Philippine vs Japanese pricing for every other chargeable item.

Rejected: Per-facility SKUs (SVC-ER-VISIT-PH, SVC-ER-VISIT-JP). That pattern bloats the chargemaster, breaks cross-region reporting (you’d have to GROUP BY across N similar codes), and doesn’t model anything that payor_prices/scheme_prices doesn’t already model.

Schema impact: None. New SKUs from Appendix A go in as single rows; market-pack seeds populate payor_prices[] / scheme_prices[] arrays per region.

D3 — OR pre-op estimate: metadata.estimate=true + view-level filter

Pre-op estimate ledger rows are written to billable_ledger like any other row but tagged metadata.estimate=true. A SQL view (or RLS policy keyed on role) filters them out for cashier role and lets pre-op / OR roles see them.

CREATE VIEW billable_ledger_cashier AS
  SELECT * FROM billable_ledger
  WHERE NOT COALESCE((metadata->>'estimate')::boolean, false);

Rationale: Keeps the ledger schema clean (no explicit audience column to migrate later), preserves the estimate→actuals A/B comparison after close-out (estimate rows VOIDed but still queryable for variance analysis), and lets new role-scoped views be added without schema changes.

Rejected:

  • Explicit audience column — adds a CHECK constraint that’s hard to extend (new audiences need a migration).
  • Don’t ledger estimates at all — would lose the variance trail and force a separate read path for pre-op briefings.

Schema impact: None to ledger; one view (or RLS policy) per consuming role.

D4 — Multi-tenancy: no tenant_id, on-prem deployments only

billable_ledger ships without a tenant_id column, matching the existing read-model pattern (department_queues, encounter_journey_cache, hospital_events, etc. all have no tenant_id). Each on-prem deployment runs its own Supabase project — tenant isolation is enforced at the project boundary, not at the row level.

Future cloud rollout is a separate, larger decision: when cloud SaaS lands it requires a dedicated cross-cutting migration that adds tenant_id + RLS policies to every read-model table at once. Adding it only to billable_ledger now would create an inconsistent partial design that becomes load-bearing the moment someone forgets to filter.

Schema impact: None. Today’s design ships unchanged.

D5 — or_case_costing voiding: auto-sync via trigger

A Postgres trigger on or_case_costing.status reacts to transitions to voided or amended:

  • voided → UPDATE all billable_ledger rows where origin_event = 'or.completed' AND origin_ref = NEW.case_id to status = 'VOIDED' (no re-POST).
  • amended → UPDATE the same rows to status = 'VOIDED', then INSERT new POSTED rows projected from the amended or_case_costing snapshot (or_total_amount, anesthesia_total_amount, pacu_total_amount, plus each element of implant_line_items[] / supply_line_items[] / manual_line_items[]).
  • posted (transitioning from draft/submitted) → VOID all metadata.estimate=true ledger rows for that case_id, POST the actuals (this is the path already specified in the per-order-type wiring table).

Rationale: Ledger stays continuously consistent with or_case_costing — the canonical OR costing entity. Cashier UI never sees stale lines. Manual reconciliation was rejected because it relies on a human noticing the mismatch and slows close-out.

Schema impact: New trigger trg_or_case_costing_sync_ledger (added in M4 alongside the orchestrator handlers).

D6 — Product schema duplication: merchandise.Product re-exports financial.Product

packages/platform-api-schema/src/merchandise/product/entity/Product.ts becomes a one-line re-export of packages/platform-api-schema/src/financial/product/entity/Product.ts. Single canonical schema; both domain barrels resolve to the same class, so payor_prices / scheme_prices arrays can never be silently dropped by a stray import.

Rejected:

  • Leave both — risk persists; resolver could be wrong against any service that imports the merchandise variant.
  • Delete merchandise variant outright — expands M1 scope, risks breaking hidden writers in the merchandise service. Re-export is the minimum invasive fix.

Schema impact: No DB change. Code-level: small refactor folded into M1 (see migration sequence).

Migration-sequence delta

D6 adds one sub-step to M1: alongside the menu_items projector, replace packages/platform-api-schema/src/merchandise/product/entity/Product.ts with a re-export of financial.product.entity.Product. Single commit, verifiable with yarn build:platform and a tsc --noEmit over the merchandise service.

D5 adds the trg_or_case_costing_sync_ledger trigger to M4 (was implied; now explicit). D1–D4 require no migration-sequence change — they’re properties of the design that’s already drafted.

  • docs/architecture/encounter-orchestrator-triggers.md — master reference for the read-model layer
  • docs/architecture/billing-queue-auto-sync.md — existing trg_sync_billing_queue trigger
  • docs/architecture/lab-data-pipeline.md — lab end-to-end flow (queue stamp would slot into handleLabSpecimenPipeline)
  • docs/architecture/ipd-dispense-cycles.md — dispense-cycle-runner (ledger write per round)
  • docs/architecture/delivery-room-remaining-backend-work.mdencounter.billClosed flag (blocker for LR ledger close-out)
  • docs/architecture/nuclear-medicine-remaining-backend-work.md — imaging URGENT dispatch (independent of pricing)
  • docs/architecture/or-closeout-mongo-canonical.md — OR case closeout state (source for actuals ledger swap)
Ask Anything