Menu Price Ledger
Backend-stamped pricing per queue row: menu_items + price history + append-only billable_ledger + resolve_menu_price().
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:
- Every
department_queuesrow carries the price it was billed at, the source of that price (standard / payor / scheme / contract), and a pointer to a ledger entry. - A
billable_ledgertable 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. - Frontend reads
metadata.unitPrice(queue) orbillable_ledger(totals) instead of computing prices client-side. - 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.created → handleOrderCreated). 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:
- Contract / regulated rate for
{sku, payorPlan, scheme, asOf}from market-pack tables:- PH:
philhealth_case_rates.case_rate_amountmatched oncase_code/icd_code×effective_date - PH:
philhealth_room_rates.daily_rate_limitmatched onaccommodation_type(LR/ER room day-rates) - JP:
kaigo_basic_rates.units_per_day × kaigo_regional_unit_pricesmatched on{facility_type, care_level, room_type, effective_from} - →
priceSource='contract',contractRef='<schema>.<table>.<row_id>'
- PH:
menu_items.scheme_pricesentry whereplan = scheme AND startDate <= asOf AND (cancelDate IS NULL OR cancelDate > asOf)—priceSource='scheme'menu_items.payor_pricesentry wherepayor = payorPlan AND startDate <= asOf AND (cancelDate IS NULL OR cancelDate > asOf)—priceSource='payor'menu_items.base_price—priceSource='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 M0 — clinical.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.arrived → manifest.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 runningTotalShadow → runningTotal 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_ledgerunique 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 negativeqtypointing to the original viavoided_by_id. - Realtime:
billable_ledgeris added to thesupabase_realtimepublication so the cashier UI gets live running-total updates without polling. - Audit:
menu_item_price_historycaptures every price change. Combined withbillable_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 onpricedAt ∈ [effective_from, effective_to). - Dead-letter: Resolver failures (sku not found, payor lookup failed) push to
medbase_dead_letter_queuewithorigin='price-resolver'rather than blocking queue insertion. Queue row getsmetadata.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,619to backend-only pricing. Larger change — frontend stops sendingunitPriceentirely. Touches every invoice path; separate session. - Pathology distinct dept_type. Recommended slice keeps pathology co-mapped to
labwithmetadata.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_minuteis flat. If non-flat pricing is needed, model asmenu_items.metadata.rateCurvejsonb consumed byresolve_menu_price. - Bulk-discount / bundle pricing. Cross-line discounts live in
salesOrder.transform.ts:73-287; ledger is line-level. Bundle support would need abundle_idforeign key on ledger rows + cross-row evaluation in the resolver. - Per-tier base_price history.
payor_prices/scheme_pricescarry their ownstartDate/cancelDate. Onlybase_priceitself needs the side-tablemenu_item_price_history. - Retroactive ledger backfill. Pre-M5 encounters won’t have ledger rows.
fpa_fact_chargestill covers them historically. Backfilling is possible but not in scope. - Multi-tenant cloud rollout. Today on-prem-only — no
tenant_idcolumn onbillable_ledgeror any read-model table (per Decision D4). When cloud SaaS lands, a dedicated cross-cutting migration addstenant_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_chargeskaigo_food_housing_costskaigo_regional_unit_prices— multiplieskaigo_basic_rates.units_per_dayto 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):
- Contract —
philhealth_case_rates(matched oncase_code/icd_code) orkaigo_basic_rates × kaigo_regional_unit_prices(matched onfacility_type, care_level, room_type). If a row applies, return immediately;payor_pricesandscheme_pricesare not consulted. - Scheme —
menu_items.scheme_pricesentry whereplan = scheme AND startDate <= asOf AND (cancelDate IS NULL OR cancelDate > asOf). - Payor —
menu_items.payor_pricesentry, same window check. - Standard —
menu_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
audiencecolumn — 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 allbillable_ledgerrows whereorigin_event = 'or.completed' AND origin_ref = NEW.case_idtostatus = 'VOIDED'(no re-POST).amended→ UPDATE the same rows tostatus = 'VOIDED', then INSERT new POSTED rows projected from the amendedor_case_costingsnapshot (or_total_amount,anesthesia_total_amount,pacu_total_amount, plus each element ofimplant_line_items[]/supply_line_items[]/manual_line_items[]).posted(transitioning fromdraft/submitted) → VOID allmetadata.estimate=trueledger rows for thatcase_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.
Related docs
docs/architecture/encounter-orchestrator-triggers.md— master reference for the read-model layerdocs/architecture/billing-queue-auto-sync.md— existingtrg_sync_billing_queuetriggerdocs/architecture/lab-data-pipeline.md— lab end-to-end flow (queue stamp would slot intohandleLabSpecimenPipeline)docs/architecture/ipd-dispense-cycles.md— dispense-cycle-runner (ledger write per round)docs/architecture/delivery-room-remaining-backend-work.md—encounter.billClosedflag (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)