Universal Facility-Stay Billing
Facility-stay billing engine for ER+IPD+OR+LR: per-dept stay logs + dynamic country rule engine, worked TH/PH/JP examples.
Status: Architecture (2026-05-26). Foundation for ER bed board billing, IPD room charges, OR/PACU costing, LR delivery room time. Dynamic per-country rules without code changes.
Related docs:
menu-price-ledger.md— thebillable_ledger+menu_itemsdesign this builds on (M0–M7)policy-gates.md— reusable schema pattern for the rule enginecds-vital-signs-rules.md— reusable safe condition-language patternencounter-orchestrator-triggers.md— where the orchestrator picks up the stay-close eventsinsurance-aware-workflows.md—encounter_journey_cache.insurance_contextfor scheme-aware pricingnhso-revenue-engine-master-plan.md— Thailand-specific NHSO rules this plugs into
1. Problem Statement
medOS tracks patient time across four facility-stay departments:
| Dept | Time data | Billing bridge | Status |
|---|---|---|---|
| ER | er_bed_stay_log (zone-based, multi-segment) |
❌ none | Time only |
| IPD | bed_status_log (state machine, multi-bed) |
❌ manual SalesOrder line items | Timestamps only — no duration calc |
| OR | or_case_costing (OR + Anesthesia + PACU time × rate) |
⚠️ standalone costing doc, not in SalesOrder pipeline | Closest pattern |
| LR | labour_room_worklist (status enum) |
❌ none | Blocked — no time fields |
Every department independently solves a piece of the same problem:
For each segment of facility time, compute
duration × rate × dynamic-country-rules = charge, drop it into a SalesOrder line item at ปิดบัญชี.
And every country expresses those “dynamic rules” differently:
- 🇹🇭 Thailand — per-hour or per-15-min bed-block rates, 30-min grace period at admission/discharge, NHSO/SSO/CSMBS scheme rate switching, ICD10-TM-coded ChrgItem 19-category bucketing, project codes (UCEP, ER quality, off-hours).
- 🇵🇭 Philippines — PhilHealth case-rate “all-in package” pricing (DRG-style: room+procedure+meds = single fixed amount), 45-day annual room-rate cap, hospital/professional fee split, Z-Benefit categories.
- 🇯🇵 Japan — Kaigo per-unit pricing (
units_per_day × yen_per_unit_for_region), care-level matrix (要介護1–5), stackable daily/monthly addons (night-shift, dementia, oral-care, pressure-ulcer), monthly co-pay cap by burden stage. - 🌏 Future markets — Vietnam BHYT, Korea NHIS, Singapore MediShield, etc. Each pluggable as a market pack without TypeScript changes.
A per-dept, per-country code path would explode into hundreds of branches. The system needs one mechanism that subsumes all four departments and swaps country rules via market-pack data.
2. Backend Reality Check
This section summarises what the audit confirmed already exists vs. what is missing. Architecture decisions below assume this baseline.
2.1 What already works
| Capability | Where | Notes |
|---|---|---|
Product entity with payorPrice[] + schemePrice[] + effective dating |
packages/platform-api-schema/src/financial/product/entity/Product.ts |
Per-payor + per-scheme tiered pricing already modelled. startDate/cancelDate stored but not filtered in queries (gap below). |
| Price resolver: payor + scheme + discount/surcharge stacking | services/financial/.../payorPlan/payorPlan.service.ts::getPayorPlanDiscount() |
Multi-level: Item → SubGroup → Group. Dinero.js for 2-decimal money math. |
| OR time-based costing reference impl | services/financial/.../orCaseCosting/ + infrastructure/medbase/migrations/084_or_case_costing.sql |
TimeCostBlock { startedAt, endedAt, minutes, ratePerMinute, totalAmount }. Status: draft→submitted→posted→voided→amended. The canonical pattern for time billing. |
| SalesOrder + OrderRequestItem aggregation | salesOrder.service.ts::createSalesOrder() |
qty × unitPrice → discount → coverage → netPay. 24-hour consolidation window. |
| Discharge-triggered settlement | salesOrder.event.ts + settleAccountByEncounter() |
Idempotent flip to claimable=true, claimStatus=PREPARE. |
| Reconciliation safety net | infrastructure/medbase/functions/rcm-reconcile-discharged-unsettled/ |
6-hour cron catches orphans where discharge event was lost. |
| Region detection | web/src/config/region.config.ts |
Domain → {locale, timezone, currency, countryCode}. Single source of truth. |
| Market pack manifests | infrastructure/market-packs/{thailand,japan,philippines}/manifest.json |
Per-region config: locale, timezone, currency, codeSystems, billingCodeSystem, drugCodeSystem, seeds[]. |
| Country rate tables (Phase 1 seeded) | seed-philhealth-rates.sql, seed-kaigo-rates.sql, seed-nhso-*.sql |
Schemas + RLS exist. PhilHealth has zero callers, NHSO has admin CRUD only. |
policy_gates dynamic rule engine |
web/supabase/migrations/20260425_policy_gates.sql + services/policy-gate.service.ts + hooks/usePolicyGate.ts |
Schemaless JSONB scope/predicate/action, priority, realtime invalidation, admin UI. The blueprint. |
cds_rules safe condition language |
infrastructure/medbase/migrations/20260514b_cds_vital_signs_rules.sql |
Token-based loinc:8867-4 > 130 && loinc:8480-6 >= 140 — no JS eval. Reuse this grammar for billing rules. |
insurance_context on encounter cache |
docs/architecture/insurance-aware-workflows.md |
{ schemeCode, payerId, benefitSchemeIds[], coverageStatus, preauthRefs[], tier }. Orthogonal axis to spatial/workflow/organizational. |
| ER time tracking (this work) | infrastructure/medbase/migrations/20260526b_er_bed_stay_tracking.sql |
er_bed_stay_log + summary view + er_bed_close_encounter() RPC. Reference impl for the per-dept stay log. |
2.2 What’s missing — the critical gaps
| Gap | Impact | Phase |
|---|---|---|
billable_ledger table |
No append-only universal source-of-truth for charges; every dept invents its own bridge | M1 |
menu_items Supabase projection of financial.Product |
No way for Postgres triggers/edge functions to look up prices without round-tripping NestJS | M1 |
resolve_menu_price() Postgres function |
Each caller hand-rolls the contract → scheme → payor → standard resolution order | M2 |
Universal facility_stay_log shape OR consistent per-dept stay logs |
IPD bed_status_log has no duration_minutes; LR has no time tracking at all |
M3 |
facility_billing_rule engine (per-country, dynamic) |
Country rules would otherwise be hardcoded TypeScript: rounding modes, grace periods, minimum charges, daily caps, scheme switching | M3–M4 |
charge_unit field on Product (minute/hour/day/unit) |
OR is per-minute, IPD/LR/Kaigo are per-day; current Product only has flat price |
M1 |
| Auto-injection of facility-stay charges into SalesOrder | At discharge, NestJS doesn’t know about Supabase stay logs; need an edge function + write-back endpoint | M5 |
| Effective-date filter in price queries | Product.payorPrice[i].startDate/cancelDate stored but ignored |
M2 |
Rounding-mode config (round-to-nearest-15min, round-up-to-hour, round-up-to-day-after-grace) |
Hardcoded Math.round() everywhere |
M3 |
| IPD bed_status_log duration enrichment | Reconstructing LOS requires reading two rows (admit + discharge); need a view or generated column | M3 |
| LR room stay table | No lr_room_stay_log exists; delivery time invisible to billing |
M3 |
encounter.billClosed flag in MongoDB Encounter |
Cashier ปิดบัญชี has no canonical “bill is closed” signal; needed to gate stay-log auto-close | M0 (prereq) |
3. Architectural Principles
These are the design invariants. Every line of the implementation must hold all of them.
-
One ledger, all departments. ER, IPD, OR, LR (and future radiotherapy series, hemodialysis sessions, hyperbaric oxygen, etc.) post charges to the same
billable_ledgerrows. SalesOrder reads only the ledger — never the per-dept stay tables. -
Per-dept stay logs stay department-specific, but speak a common contract. We do not force a
facility_stay_logmega-table. Each dept keeps its native columns (zone for ER, bed_id for IPD, phase for OR, room_id for LR — they semantically differ). The contract is “every dept stay log has a trigger that writes tobillable_ledger”. Compare-and-contrast table in §6.4. -
Rules are data, not code. Every country-specific behavior (rounding, grace periods, day caps, scheme switching) is a row in
facility_billing_rulewith a JSONB predicate using the same safe token grammar ascds_rules. Adding a country = inserting rule rows in a market-pack seed file. Zero TypeScript changes. -
Three-tier price resolution. Standard → payor → scheme → contract override. Postgres function
resolve_menu_price(sku, encounter_context, asof_date)is the single source. Backend, edge functions, and frontend (via service-key) all call it. -
Stay logs are append-only. No UPDATE except
closed_at(set once). No DELETE ever. Audit trail is immutable. Corrections happen via compensatingaction='override'oraction='void'rows. -
Backend = write truth, Supabase = read model + realtime. Stay events originate from frontend RPCs or backend events. The orchestrator routes both to the same handlers. NestJS owns SalesOrder writes; Supabase mirrors the ledger for fast UI and admin tooling.
-
Fail loud, settle later. Ledger writes are idempotent (
UNIQUE(origin_event, origin_ref, status='POSTED')). If a trigger fires twice, the duplicate is rejected. If a stay close is missed, the discharge-timercm-reconcile-facility-stayscron catches it within 6 hours. -
Bilingual everything. Every user-facing label has
_th,_en,_ja,_filvariants. Rule messages too — the cashier sees the actual reason text in their locale. -
Effective dating is required, not optional. Every rate, every rule, every product price has
effective_from+effective_to. Past encounters always bill at the rate that was active on their discharge date, never the current rate. -
Negative-space prevention. The system warns at ปิดบัญชี if any open stay segment exists, and refuses to close the bill if
bill_close_eligibilityrules flag missing data. No silent zero charges.
4. High-Level Architecture
┌──────────────────────────────────────────────────┐
│ FRONTEND — ER Bed Board, IPD Ward View, │
│ OR Phase Board, LR Room Board │
│ + Cashier ปิดบัญชี panel │
└────────────────┬─────────────────────────────────┘
│ Supabase RPC / Realtime
┌────────────────▼─────────────────────────────────┐
│ PER-DEPT STAY LOGS (Supabase, append-only) │
│ │
│ er_bed_stay_log [SHIPPED 20260526b] │
│ bed_status_log [SHIPPED 043, needs dur view] │
│ or_case_costing [SHIPPED 084] │
│ lr_room_stay_log [NEW M3 — mirrors ER pattern] │
└────────────────┬─────────────────────────────────┘
│ Postgres trigger on close
│ (vacated_at / discharge_completed / pacu_ended_at / lr_completed)
│
▼
┌──────────────────────────────────────────────────┐
│ resolve_menu_price() Postgres function │
│ │
│ Input: (sku, encounter_id, asof_date) │
│ Output: { unit_price, price_source, │
│ payor_plan, scheme, contract_ref, │
│ rounding_mode, billing_unit, │
│ currency, applied_rules[] } │
│ │
│ Resolution: contract > scheme > payor > standard │
│ Looks up: insurance_context, menu_items, │
│ menu_item_price_history, │
│ facility_billing_rule │
└────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ evaluate_facility_billing_rules() Postgres fn │
│ │
│ Inputs: { stay_log_row, encounter_context, │
│ insurance_context, country_code } │
│ Outputs: { qty, unit_price_override?, │
│ discount?, surcharge?, line_notes[] } │
│ │
│ Walks facility_billing_rule WHERE country=... │
│ AND status='active' ORDER BY priority DESC. │
│ Applies rounding, grace, caps, scheme overrides. │
└────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ billable_ledger (Supabase, append-only) │
│ │
│ encounter_id, dept_type, menu_item_id, sku, │
│ qty, unit_price, total (GENERATED), │
│ price_source, payor_plan, scheme, contract_ref, │
│ origin_event, origin_ref, status, posted_at, │
│ applied_rules[], metadata │
│ │
│ UNIQUE(origin_event, origin_ref, status='POSTED')│
└────────────────┬─────────────────────────────────┘
│
│ At ปิดบัญชี (cashier click) OR
│ at EMERGENCY_DISPOSED / DISCHARGE event:
│
▼
┌──────────────────────────────────────────────────┐
│ close-encounter-billing Edge Function (Deno) │
│ │
│ 1. Close any open stay segments (per dept RPC) │
│ 2. SELECT * FROM billable_ledger │
│ WHERE encounter_id=? AND status='POSTED' │
│ 3. POST to NestJS: │
│ POST /v2/financial/salesOrders/ │
│ createFromLedger │
│ 4. NestJS creates OrderRequest + │
│ OrderRequestItems → SalesOrder │
│ 5. On success: mark ledger rows │
│ metadata.sales_order_id=... │
└────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ NestJS Financial Service (MongoDB) — write truth │
│ │
│ SalesOrder.create() with line items │
│ → emits FINANCIAL_UPDATE event │
│ → orchestrator updates │
│ encounter_journey_cache.financial_summary │
└──────────────────────────────────────────────────┘
5. Data Model
5.1 menu_items — Universal chargemaster (M1)
Mirrors financial.Product from MongoDB, projected to Supabase by an edge function on every product.{created,updated} event. Frontend and edge functions read from here, never from MongoDB directly.
CREATE TABLE menu_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
mongo_ref text UNIQUE, -- FK to financial.product._id
sku text NOT NULL UNIQUE, -- 'SVC-ER-RESUS', 'SVC-OR-MINUTE', 'SVC-IPD-WARD-A-BED'
category text NOT NULL, -- 'facility_stay' | 'drug' | 'lab' | 'imaging' | 'procedure' | 'consult'
-- Bilingual display
name_th text,
name_en text,
name_ja text,
name_fil text,
-- Pricing
base_price numeric(12,4) NOT NULL DEFAULT 0,
currency text NOT NULL DEFAULT 'THB',
-- Billing unit (critical for facility-stay products)
billing_unit text NOT NULL DEFAULT 'unit',
-- 'unit' | 'minute' | 'hour' | 'day' | 'block_15min' | 'block_30min'
rounding_mode text NOT NULL DEFAULT 'none',
-- 'none' | 'round_up_to_unit' | 'round_to_nearest_unit' | 'round_down_to_unit'
grace_period_min integer NOT NULL DEFAULT 0, -- e.g. 30 = first 30 min not billed
minimum_charge numeric(12,4) DEFAULT 0, -- e.g. always at least 1 hour even if <60 min
daily_cap numeric(12,4), -- e.g. PhilHealth 45-day room cap
-- Per-payor / per-scheme JSONB (mirror Product.payorPrice + schemePrice)
payor_prices jsonb DEFAULT '[]'::jsonb,
-- [{ payor_id, price, discount?, effective_from, effective_to }]
scheme_prices jsonb DEFAULT '[]'::jsonb,
-- [{ scheme_code, plan_code, price, discount?, package_type?, effective_from, effective_to }]
-- Classification
billing_group_code text, -- '11' (room), '21' (drug), etc.
facility_kind text, -- 'er_zone' | 'ipd_bed' | 'or_theatre' | 'or_pacu' | 'lr_room'
facility_subtype text, -- 'resus' | 'icu' | 'ward_a' | 'main_or' | 'pacu' | 'delivery'
-- Lifecycle
is_active boolean NOT NULL DEFAULT true,
effective_from date NOT NULL DEFAULT CURRENT_DATE,
effective_to date,
-- Metadata
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (billing_unit IN ('unit','minute','hour','day','block_15min','block_30min')),
CHECK (rounding_mode IN ('none','round_up_to_unit','round_to_nearest_unit','round_down_to_unit'))
);
CREATE INDEX idx_menu_items_sku ON menu_items(sku);
CREATE INDEX idx_menu_items_facility ON menu_items(facility_kind, facility_subtype) WHERE is_active;
CREATE INDEX idx_menu_items_category ON menu_items(category, is_active);
CREATE INDEX idx_menu_items_payor_prices ON menu_items USING gin(payor_prices);
CREATE INDEX idx_menu_items_scheme_prices ON menu_items USING gin(scheme_prices);
5.2 menu_item_price_history — Price audit (M1)
Triggered by UPDATE on menu_items.base_price (or payor/scheme prices). Append-only.
CREATE TABLE menu_item_price_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
menu_item_id uuid NOT NULL REFERENCES menu_items(id) ON DELETE CASCADE,
sku text NOT NULL,
base_price numeric(12,4) NOT NULL,
payor_prices jsonb,
scheme_prices jsonb,
effective_from date NOT NULL,
effective_to date,
changed_by text,
change_reason text,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_price_history_sku_window
ON menu_item_price_history(sku, effective_from DESC);
5.3 facility_billing_rule — Dynamic country rule engine (M3)
This is the heart of multi-country support. Mirrors policy_gates schema verbatim. Every country-specific behavior lives here as a row.
CREATE TABLE facility_billing_rule (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identity
rule_code text NOT NULL, -- 'TH.IPD.ROUND_UP_HOUR', 'PH.IPD.45DAY_CAP', 'JP.KAIGO.NIGHT_SHIFT'
name_en text NOT NULL,
name_th text,
name_ja text,
name_fil text,
-- Lifecycle
status text NOT NULL DEFAULT 'active', -- 'active' | 'inactive' | 'draft'
priority integer NOT NULL DEFAULT 100, -- Higher wins
effective_from date NOT NULL DEFAULT CURRENT_DATE,
effective_to date,
-- Scope (which encounters/products this rule applies to)
country_code text NOT NULL, -- 'TH' | 'PH' | 'JP' | '*' (universal)
scope_json jsonb NOT NULL DEFAULT '{}'::jsonb,
-- {
-- facility_kind: ['ipd_bed', 'er_zone'],
-- facility_subtype: ['icu', 'resus'], // optional
-- scheme_codes: ['NHSO_UC', 'SSO'], // optional
-- payor_ids: [], // optional
-- encounter_classes: ['IMP', 'EMER'],
-- skus: ['SVC-IPD-ICU-BED'], // optional, narrows by product
-- patient_age_min: 65, // optional
-- facility_id: 'hospital-main' // optional, per-facility override
-- }
-- Predicate (what must be true to fire)
predicate_json jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Token grammar (same as cds_rules):
-- {
-- when: [
-- "stay.duration_minutes > 30",
-- "stay.duration_minutes <= 60",
-- "encounter.insurance.scheme_code == 'NHSO_UC'"
-- ],
-- combinator: 'AND' // or 'OR'
-- }
-- Action (what the rule does to the charge)
action_json jsonb NOT NULL,
-- One of (combinable):
-- {
-- kind: 'set_unit_price', value: 500
-- } |
-- {
-- kind: 'apply_discount_pct', value: 10
-- } |
-- {
-- kind: 'apply_surcharge_pct', value: 50, label_th: 'ค่าบริการนอกเวลา'
-- } |
-- {
-- kind: 'cap_qty', value: 45, label_en: 'PhilHealth 45-day room cap'
-- } |
-- {
-- kind: 'override_rounding', mode: 'round_up_to_unit', billing_unit: 'hour'
-- } |
-- {
-- kind: 'override_grace_period', minutes: 30
-- } |
-- {
-- kind: 'add_addon_sku', sku: 'SVC-KAIGO-NIGHT-SHIFT', qty: 1, label_ja: '夜勤体制加算'
-- } |
-- {
-- kind: 'use_package_price', package_sku: 'PH-Z-CABG', replaces: ['SVC-OR-MINUTE','SVC-PACU-MINUTE']
-- } |
-- {
-- kind: 'block', message_en: 'NHSO requires preauth', message_th: 'ต้องขออนุมัติก่อน'
-- }
-- Audit
organization_id uuid, -- Tenancy
created_by text,
updated_by text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(rule_code, effective_from),
CHECK (status IN ('active','inactive','draft'))
);
CREATE INDEX idx_fbr_country_active
ON facility_billing_rule(country_code, status, priority DESC)
WHERE status = 'active';
CREATE INDEX idx_fbr_scope_facility
ON facility_billing_rule USING gin((scope_json -> 'facility_kind'));
CREATE INDEX idx_fbr_scope_scheme
ON facility_billing_rule USING gin((scope_json -> 'scheme_codes'));
5.4 billable_ledger — Universal charge source-of-truth (M2)
The append-only journal that every department writes to and SalesOrder reads from. From menu-price-ledger.md with field additions for facility-stay needs.
CREATE TABLE billable_ledger (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Encounter linkage
encounter_id text NOT NULL,
patient_id text NOT NULL,
queue_ticket_id text, -- optional FK to department_queues row
-- Charge item
dept_type text NOT NULL, -- 'emergency' | 'admission' | 'surgery' | 'labour' | 'pharmacy' | 'lab' | 'imaging' | ...
menu_item_id uuid REFERENCES menu_items(id),
sku text NOT NULL,
description text NOT NULL, -- snapshot at post time (in encounter's locale)
-- Quantity + pricing (snapshot — never changes after POSTED)
qty numeric(12,4) NOT NULL, -- minutes / hours / days / units
unit_price numeric(12,4) NOT NULL,
total numeric(12,4) GENERATED ALWAYS AS (qty * unit_price) STORED,
currency text NOT NULL DEFAULT 'THB',
-- Price provenance
price_source text NOT NULL, -- 'standard' | 'payor' | 'scheme' | 'contract' | 'package' | 'manual'
payor_plan text,
scheme text,
contract_ref text,
applied_rules jsonb DEFAULT '[]'::jsonb, -- [{rule_code, kind, value, label}]
-- Origin (idempotency anchor)
origin_event text NOT NULL, -- 'facility_stay.closed' | 'or_case_costing.posted' | 'rx.dispensed' | ...
origin_ref text NOT NULL, -- e.g. er_bed_stay_log.id, or_case_costing.id
-- Lifecycle
status text NOT NULL DEFAULT 'POSTED', -- 'POSTED' | 'VOIDED' | 'REFUNDED'
voided_by_id uuid REFERENCES billable_ledger(id), -- self-ref for refund tracing
void_reason text,
-- Audit
posted_at timestamptz NOT NULL DEFAULT now(),
posted_by text,
metadata jsonb,
CHECK (status IN ('POSTED','VOIDED','REFUNDED'))
);
-- Idempotency: only one POSTED row per (origin_event, origin_ref)
CREATE UNIQUE INDEX uq_ledger_origin_posted
ON billable_ledger(origin_event, origin_ref)
WHERE status = 'POSTED';
CREATE INDEX idx_ledger_encounter_posted
ON billable_ledger(encounter_id, status, posted_at)
WHERE status = 'POSTED';
CREATE INDEX idx_ledger_dept_type
ON billable_ledger(dept_type, posted_at);
5.5 IPD bed_status_log hardening (M3)
Add a duration_minutes view so IPD parity with ER is automatic:
CREATE OR REPLACE VIEW bed_stay_segments AS
WITH paired AS (
SELECT
bs.id,
bs.encounter_id,
bs.bed_id,
bs.ward_id,
bs.occurred_at AS placed_at,
LEAD(bs.occurred_at) OVER (
PARTITION BY bs.encounter_id, bs.bed_id
ORDER BY bs.occurred_at
) AS vacated_at
FROM bed_status_log bs
WHERE bs.transition IN ('admit','transfer_in','transfer_out','discharge_completed')
)
SELECT
id,
encounter_id,
bed_id,
ward_id,
placed_at,
vacated_at,
CASE WHEN vacated_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (vacated_at - placed_at)) / 60.0
ELSE EXTRACT(EPOCH FROM (now() - placed_at)) / 60.0
END AS duration_minutes,
vacated_at IS NULL AS is_active
FROM paired
WHERE placed_at IS NOT NULL;
This gives IPD the same (encounter_id, bed_id, placed_at, vacated_at, duration_minutes) shape as er_bed_stay_log without touching the underlying table.
5.6 LR lr_room_stay_log (M3) — new table mirroring ER pattern
CREATE TABLE lr_room_stay_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
encounter_id text NOT NULL,
patient_id text NOT NULL,
room_id text NOT NULL, -- delivery room identifier
room_label text, -- 'LR-1', 'LR-2'
phase text NOT NULL DEFAULT 'labour',
-- 'labour' | 'delivery' | 'recovery'
occurred_at timestamptz NOT NULL DEFAULT now(),
vacated_at timestamptz,
duration_minutes numeric(10,2) GENERATED ALWAYS AS (
CASE WHEN vacated_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (vacated_at - occurred_at)) / 60.0
ELSE NULL
END
) STORED,
delivery_method text, -- 'NVD' | 'CS' | 'VBAC' | 'forceps' | ...
newborn_an text, -- AN if newborn HN/AN was issued
actor_user_id text,
actor_name text,
reason text,
billing_product_id text,
charge_amount numeric(12,2),
charge_currency text DEFAULT 'THB',
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_lr_stay_encounter ON lr_room_stay_log(encounter_id);
CREATE INDEX idx_lr_stay_active ON lr_room_stay_log(encounter_id) WHERE vacated_at IS NULL;
Plus lr_room_place_patient() and lr_room_close_encounter() RPCs mirroring ER’s pattern verbatim.
5.7 OR adapter (M3) — trigger over or_case_costing.posted
OR is mostly done. Add a trigger that posts ledger rows when the status flips to posted:
CREATE OR REPLACE FUNCTION trg_or_case_costing_sync_ledger()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
-- Only fire on transition INTO 'posted'
IF NEW.status = 'posted' AND COALESCE(OLD.status, '') != 'posted' THEN
-- OR time
IF NEW.or_minutes > 0 AND NEW.or_rate_per_minute IS NOT NULL THEN
INSERT INTO billable_ledger (encounter_id, patient_id, dept_type, sku, description,
qty, unit_price, currency,
price_source, origin_event, origin_ref, status, posted_at)
VALUES (NEW.encounter_id, NEW.patient_id, 'surgery', 'SVC-OR-MINUTE', 'OR theatre time',
NEW.or_minutes, NEW.or_rate_per_minute, NEW.currency,
'standard', 'or_case_costing.posted.or', NEW.id::text, 'POSTED', now())
ON CONFLICT DO NOTHING;
END IF;
-- Anesthesia, PACU, implants, supplies — same pattern, separate ledger rows
-- (omitted for brevity)
END IF;
-- Status flip TO 'voided' or 'amended' → VOID matching rows
IF NEW.status IN ('voided','amended') AND OLD.status = 'posted' THEN
UPDATE billable_ledger
SET status='VOIDED', void_reason='or_case_costing.'||NEW.status, voided_by_id=NEW.id
WHERE origin_event LIKE 'or_case_costing.posted.%' AND origin_ref = NEW.id::text;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_or_case_costing_to_ledger
AFTER INSERT OR UPDATE ON or_case_costing
FOR EACH ROW EXECUTE FUNCTION trg_or_case_costing_sync_ledger();
5.8 ER trigger (M4) — fire ledger write on er_bed_stay_log close
CREATE OR REPLACE FUNCTION trg_er_bed_stay_sync_ledger()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_price_result jsonb;
v_rule_result jsonb;
BEGIN
-- Only fire when vacated_at gets set (segment closed)
IF NEW.vacated_at IS NOT NULL AND OLD.vacated_at IS NULL THEN
-- 1. Resolve SKU → unit price (contract/scheme/payor/standard)
SELECT resolve_menu_price(
'SVC-ER-' || UPPER(NEW.zone), -- SKU
NEW.encounter_id,
NEW.vacated_at::date
) INTO v_price_result;
-- 2. Evaluate billing rules (rounding, grace, surcharges, scheme overrides)
SELECT evaluate_facility_billing_rules(
jsonb_build_object(
'facility_kind', 'er_zone',
'facility_subtype', NEW.zone,
'duration_minutes', NEW.duration_minutes,
'unit_price', v_price_result->'unit_price'
),
NEW.encounter_id
) INTO v_rule_result;
-- 3. Insert ledger row
INSERT INTO billable_ledger (
encounter_id, patient_id, dept_type, sku, description,
qty, unit_price, currency, price_source,
payor_plan, scheme, contract_ref, applied_rules,
origin_event, origin_ref, status, posted_at, metadata
) VALUES (
NEW.encounter_id, NEW.patient_id, 'emergency',
'SVC-ER-' || UPPER(NEW.zone),
'ER ' || NEW.zone || ' bed time',
(v_rule_result->>'qty')::numeric,
(v_rule_result->>'unit_price')::numeric,
v_price_result->>'currency',
v_price_result->>'price_source',
v_price_result->>'payor_plan',
v_price_result->>'scheme',
v_price_result->>'contract_ref',
v_rule_result->'applied_rules',
'facility_stay.er.closed',
NEW.id::text,
'POSTED',
now(),
jsonb_build_object('zone', NEW.zone, 'bed_label', NEW.bed_label)
)
ON CONFLICT (origin_event, origin_ref) WHERE status='POSTED' DO NOTHING;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_er_bed_stay_to_ledger
AFTER UPDATE ON er_bed_stay_log
FOR EACH ROW EXECUTE FUNCTION trg_er_bed_stay_sync_ledger();
Equivalent triggers for IPD (via bed_stay_segments view + scheduler), LR, and OR follow the same pattern.
6. The Dynamic Rules Engine
6.1 The Condition Grammar
Same token-based grammar as cds_rules (safe, no JavaScript eval). Predicates evaluate against a context object built from the stay-log row + encounter + insurance_context.
Available token namespaces:
stay.duration_minutes # current segment duration
stay.facility_kind # 'er_zone' | 'ipd_bed' | 'or_theatre' | ...
stay.facility_subtype # 'resus' | 'icu' | 'main_or' | ...
stay.occurred_at # ISO datetime
stay.vacated_at # ISO datetime
stay.hour_of_day # 0..23 (from occurred_at, in tenant tz)
stay.day_of_week # 0..6 (Sun=0)
stay.duration_minutes_since_arrival # since encounter start
encounter.class # 'AMB' | 'IMP' | 'EMER'
encounter.age_years # patient age at encounter
encounter.sex
encounter.country_code # 'TH' | 'PH' | 'JP'
encounter.facility_id # the hospital/clinic ID
encounter.is_holiday # bool (from holiday calendar)
encounter.insurance.scheme_code # 'NHSO_UC' | 'PhilHealth' | 'Kaigo' | 'self_pay' | ...
encounter.insurance.plan_code # 'Z-BENEFIT' | '要介護3' | 'SSO' | ...
encounter.insurance.tier # 'tier1' | 'vip' | null
encounter.insurance.coverage_status # 'eligible' | 'pending' | 'expired' | 'none'
encounter.insurance.entitlements # ['preauth_required', 'ltc_certified', ...]
encounter.insurance.member_status # 'active' | 'lapsed' | 'pending'
cum.facility_kind.duration_minutes # cumulative across all segments this encounter
cum.facility_kind.day_count # days admitted so far
billing.unit_price # current candidate price (chainable rules)
billing.qty # current candidate qty
Operators: ==, !=, >, <, >=, <=, in [...], not in [...], null, not null.
Combinator: AND (default) or OR at the rule-body level.
6.2 Rule Evaluation Algorithm
function evaluate_facility_billing_rules(ctx, encounter_id):
rules = SELECT * FROM facility_billing_rule
WHERE status='active'
AND country_code IN (encounter.country_code, '*')
AND effective_from <= today
AND (effective_to IS NULL OR effective_to >= today)
AND scope_matches(scope_json, ctx)
ORDER BY priority DESC;
state = {
qty: ctx.duration_minutes,
unit_price: ctx.unit_price,
currency: ctx.currency,
applied_rules: []
};
for rule in rules:
if predicate_passes(rule.predicate_json, ctx, state):
state = apply_action(rule.action_json, state, ctx);
state.applied_rules.append({
rule_code: rule.rule_code,
kind: rule.action_json.kind,
...
});
if rule.action_json.kind == 'block':
return { error: 'blocked', rule: rule.rule_code, message: rule.message };
return state;
Rules chain — higher-priority rules run first and their output becomes input for lower-priority ones. Rounding is typically lowest priority (runs last so it sees the final qty).
6.3 Worked Examples — Three Countries, Same Stay
Scenario: Patient is in ER acute zone for 47 minutes, then transferred to ICU bed for 6 hours 22 minutes, then discharged.
🇹🇭 Thailand — NHSO Universal Coverage
Rule rows seeded by infrastructure/market-packs/medos-thailand/seed-facility-billing-rules.sql:
-- TH-1: ER zones billed per 30-minute block, rounded up
INSERT INTO facility_billing_rule (rule_code, country_code, name_th, name_en, priority,
scope_json, predicate_json, action_json) VALUES
('TH.ER.ROUND_30MIN_BLOCK', 'TH', 'ER ปัดขึ้นทุก 30 นาที', 'ER round up to 30-min block', 100,
'{"facility_kind":["er_zone"]}'::jsonb,
'{}'::jsonb,
'{"kind":"override_rounding","mode":"round_up_to_unit","billing_unit":"block_30min"}'::jsonb);
-- TH-2: ER first 30 min grace (waived for NHSO walk-in)
INSERT INTO facility_billing_rule (rule_code, country_code, name_th, name_en, priority,
scope_json, predicate_json, action_json) VALUES
('TH.ER.GRACE_30MIN_NHSO', 'TH', 'NHSO ฉุกเฉิน 30 นาทีแรกไม่คิด', 'NHSO ER 30-min grace', 110,
'{"facility_kind":["er_zone"], "scheme_codes":["NHSO_UC"]}'::jsonb,
'{"when":["encounter.insurance.scheme_code == ''NHSO_UC''"]}'::jsonb,
'{"kind":"override_grace_period","minutes":30}'::jsonb);
-- TH-3: IPD rounded up to next hour
INSERT INTO facility_billing_rule (...) VALUES
('TH.IPD.ROUND_UP_HOUR', 'TH', 'IPD ปัดขึ้นชั่วโมง', 'IPD round up to hour', 100,
'{"facility_kind":["ipd_bed"]}'::jsonb, '{}'::jsonb,
'{"kind":"override_rounding","mode":"round_up_to_unit","billing_unit":"hour"}'::jsonb);
-- TH-4: Off-hours surcharge 50% (16:30-08:29)
INSERT INTO facility_billing_rule (...) VALUES
('TH.ER.OFFHOURS_SURCHARGE', 'TH', 'ค่าบริการนอกเวลา', 'Off-hours ER surcharge', 90,
'{"facility_kind":["er_zone"]}'::jsonb,
'{"when":["stay.hour_of_day >= 16 || stay.hour_of_day < 9"],"combinator":"OR"}'::jsonb,
'{"kind":"apply_surcharge_pct","value":50,"label_th":"นอกเวลา","label_en":"Off-hours"}'::jsonb);
Evaluation:
- ER acute 47 min → grace -30 min → 17 min → round up to block_30min → 1 block × ฿300 = ฿300 (off-hours 50% if applicable → ฿450)
- ICU 6h22min → round up to hour → 7h × ฿2,000 = ฿14,000
🇵🇭 Philippines — PhilHealth Z-Benefit (case rate package)
-- PH-1: 45-day annual room rate cap
INSERT INTO facility_billing_rule (...) VALUES
('PH.IPD.45DAY_CAP', 'PH', NULL, 'PhilHealth 45-day room cap', 100,
'{"facility_kind":["ipd_bed"], "scheme_codes":["PhilHealth"]}'::jsonb,
'{}'::jsonb,
'{"kind":"cap_qty","value":45,"unit":"day","scope":"annual","label_en":"PhilHealth 45-day cap"}'::jsonb);
-- PH-2: Z-benefit replaces individual charges with package
INSERT INTO facility_billing_rule (...) VALUES
('PH.ZBENEFIT.PACKAGE', 'PH', NULL, 'Z-Benefit package pricing', 200,
'{"scheme_codes":["PhilHealth"], "facility_kind":["or_theatre","or_pacu","ipd_bed"]}'::jsonb,
'{"when":["encounter.insurance.plan_code == ''Z-BENEFIT''"]}'::jsonb,
'{"kind":"use_package_price","package_sku":"PH-Z-PACKAGE",
"replaces":["SVC-OR-MINUTE","SVC-PACU-MINUTE","SVC-IPD-WARD-A"],
"label_en":"Z-Benefit all-in package"}'::jsonb);
-- PH-3: IPD rounded by day, not hour
INSERT INTO facility_billing_rule (...) VALUES
('PH.IPD.DAY_BILLING', 'PH', NULL, 'IPD per-day billing', 100,
'{"facility_kind":["ipd_bed"]}'::jsonb, '{}'::jsonb,
'{"kind":"override_rounding","mode":"round_up_to_unit","billing_unit":"day"}'::jsonb);
Evaluation:
- If Z-Benefit applies: ER + ICU + (downstream OR if any) collapse to one ledger row with PH-Z-PACKAGE SKU at PhilHealth’s published case rate. Individual stay charges are voided.
- If not Z-Benefit: ER not separately billed in PH (typically bundled into OPD consult), ICU 1 day × ₱2,000 (capped at 45 days annual).
🇯🇵 Japan — Kaigo (long-term care)
-- JP-1: Kaigo basic rate by care level + room type
INSERT INTO facility_billing_rule (...) VALUES
('JP.KAIGO.BASIC_RATE', 'JP', NULL, 'Kaigo basic per-unit', 100,
'{"facility_kind":["ipd_bed"], "scheme_codes":["Kaigo"]}'::jsonb,
'{}'::jsonb,
'{"kind":"set_unit_price","resolver":"kaigo_basic_rates",
"lookup":{"care_level":"encounter.insurance.plan_code","room_type":"stay.facility_subtype",
"facility_type":"encounter.facility_type"}}'::jsonb);
-- JP-2: Night-shift addon (auto-triggered if patient stayed past 22:00)
INSERT INTO facility_billing_rule (...) VALUES
('JP.KAIGO.NIGHT_SHIFT', 'JP', NULL, 'Night shift staffing addon', 90,
'{"facility_kind":["ipd_bed"], "scheme_codes":["Kaigo"]}'::jsonb,
'{"when":["stay.hour_of_day >= 22 || stay.hour_of_day < 6"],"combinator":"OR"}'::jsonb,
'{"kind":"add_addon_sku","sku":"SVC-KAIGO-NIGHT-SHIFT","qty_resolver":"days_with_night_hours",
"label_ja":"夜勤体制加算","label_en":"Night shift addon"}'::jsonb);
-- JP-3: Regional yen-per-unit multiplier (Tokyo Grade 1 = ¥10.90/unit, others vary)
INSERT INTO facility_billing_rule (...) VALUES
('JP.KAIGO.REGIONAL_MULTIPLIER', 'JP', NULL, 'Regional unit price', 80,
'{"facility_kind":["ipd_bed"], "scheme_codes":["Kaigo"]}'::jsonb,
'{}'::jsonb,
'{"kind":"multiply_unit_price","resolver":"kaigo_regional_unit_prices",
"lookup":{"facility_id":"encounter.facility_id"}}'::jsonb);
-- JP-4: Monthly co-pay cap by burden stage (Kaigo)
INSERT INTO facility_billing_rule (...) VALUES
('JP.KAIGO.MONTHLY_CAP', 'JP', NULL, 'Monthly co-pay cap', 70,
'{"facility_kind":["ipd_bed"], "scheme_codes":["Kaigo"]}'::jsonb,
'{"when":["cum.ipd_bed.month_total > 44400"]}'::jsonb,
'{"kind":"cap_monthly_copay","value":44400,"label_ja":"高額介護サービス費"}'::jsonb);
Evaluation:
- ICU stay → Kaigo basic rate from
kaigo_basic_ratestable ×kaigo_regional_unit_prices.yen_per_unitfor the facility’s region → + night-shift addon (1 day) → monthly cap check.
Note how each country uses the same engine. Only the rule rows differ — no TypeScript was modified.
6.4 Per-Department Stay-Log Comparison (Final Contract)
| Property | ER | IPD | OR | LR |
|---|---|---|---|---|
| Table | er_bed_stay_log ✓ |
bed_status_log + bed_stay_segments view (M3) |
or_case_costing ✓ |
lr_room_stay_log (M3 new) |
| Time-start signal | er_bed_place_patient() RPC |
transition='admit' / 'transfer_in' event |
or_room_phase_transition('induction') |
lr_room_place_patient() RPC |
| Time-end signal | vacated_at set (next placement or er_bed_close_encounter) |
transition='transfer_out' / 'discharge_completed' |
or_ended_at, anesthesia_ended_at, pacu_ended_at |
vacated_at set or lr_room_close_encounter |
| Duration | duration_minutes GENERATED |
from view | or_minutes, anesthesia_minutes, pacu_minutes |
duration_minutes GENERATED |
| Default SKU template | SVC-ER-{ZONE} |
SVC-IPD-{WARD_OR_BEDTYPE} |
SVC-OR-MINUTE, SVC-ANEST-MINUTE, SVC-PACU-MINUTE + line items |
SVC-LR-{PHASE} |
| Ledger trigger | trg_er_bed_stay_to_ledger (M4) |
trg_bed_status_to_ledger (M4, fires on transition rows) |
trg_or_case_costing_to_ledger (M3) |
trg_lr_room_stay_to_ledger (M4) |
| Multi-segment | ✅ row per zone | ✅ row per bed | ⚠️ atomic case; PACU = separate time block on same row | ✅ row per phase |
| Country rule kinds typically used | grace, off-hours surcharge, rounding | round-up-to-hour/day, ICU surcharge, day cap | package replacement (Z-benefit, DPC), no rounding | round-up-to-hour, CS surcharge |
Key insight: The contract is just “on close, post to ledger”. The shape of each log is dept-specific, but the trigger output is identical.
7. The Bridge to NestJS — close-encounter-billing Edge Function
When the cashier clicks ปิดบัญชี (or the EMERGENCY_DISPOSED / discharge event fires), a single Deno edge function bridges Supabase ledger → NestJS SalesOrder.
infrastructure/medbase/functions/close-encounter-billing/index.ts
Pseudocode:
async function handler(req: Request) {
const { encounter_id, triggered_by } = await req.json();
const supabase = getMedbaseAdminClient();
// 1. Close any still-open stay segments
await supabase.rpc('er_bed_close_encounter', { p_encounter_id: encounter_id });
await supabase.rpc('lr_room_close_encounter', { p_encounter_id: encounter_id });
// OR: posted via its own UI (status flip), no-op here
// IPD: discharge event already triggered transfer_out
// 2. Read all POSTED ledger rows
const { data: ledgerRows } = await supabase
.from('billable_ledger')
.select('*')
.eq('encounter_id', encounter_id)
.eq('status', 'POSTED')
.is('metadata->>sales_order_id', null); // not yet pushed to NestJS
if (!ledgerRows?.length) return ok({ pushed: 0 });
// 3. Group by sku into OrderRequest line items
const lineItems = ledgerRows.map(row => ({
productSku: row.sku,
qty: row.qty,
unitPrice: row.unit_price,
description: row.description,
metadata: {
ledger_id: row.id,
origin_event: row.origin_event,
applied_rules: row.applied_rules,
price_source: row.price_source,
scheme: row.scheme,
},
}));
// 4. POST to NestJS — new endpoint
const resp = await fetch(`${NESTJS_BASE}/v2/financial/salesOrders/createFromLedger`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${SERVICE_JWT}` },
body: JSON.stringify({ encounterId: encounter_id, lineItems, triggeredBy: triggered_by }),
});
if (!resp.ok) {
// Log to DLQ, do NOT mark rows pushed
await supabase.from('billing_dlq').insert({
encounter_id, error: await resp.text(), payload: { lineItems },
});
return error(resp.status, await resp.text());
}
const { salesOrderId } = await resp.json();
// 5. Mark ledger rows pushed (idempotency for future re-runs)
await supabase.from('billable_ledger')
.update({ metadata: supabase.rpc('jsonb_merge', { existing: 'metadata', patch: { sales_order_id: salesOrderId } }) })
.in('id', ledgerRows.map(r => r.id));
return ok({ pushed: ledgerRows.length, sales_order_id: salesOrderId });
}
NestJS endpoint — createFromLedger
New method on salesOrder.controller.mixin.ts:
@Post('createFromLedger')
async createFromLedger(@Body() body: { encounterId: string; lineItems: LedgerLineItem[]; triggeredBy: string }) {
// 1. Look up encounter
const encounter = await this.broker.call('clinical.encounter.get', { id: body.encounterId });
if (!encounter) throw new MoleculerError('Encounter not found', 404);
// 2. Create an OrderRequest scoped to "facility-stay charges"
const orderRequest = await this.broker.call('medication.orderRequest.create', {
encounter: encounter._id,
patient: encounter.patientRef,
source: 'ledger',
items: body.lineItems.map(li => ({
productSku: li.productSku,
qty: li.qty,
unitPrice: li.unitPrice,
description: li.description,
metadata: li.metadata,
})),
});
// 3. SalesOrder.create() consumes the OrderRequest as usual
const salesOrder = await this.salesOrderService.createSalesOrder(
{ encounter: encounter._id, patient: encounter.patientRef, currency: 'THB' },
orderRequest.items,
[orderRequest._id],
encounter
);
// 4. Return SO ID back to the edge function (closes the loop)
return { salesOrderId: salesOrder._id, total: salesOrder.total };
}
This is the only new NestJS endpoint required. The existing settlement chain (discharge → settleAccountByEncounter → claimable flip → claim pipeline) continues to work unchanged.
Safety net — 6-hour reconciliation cron
Mirroring rcm-reconcile-discharged-unsettled, add rcm-reconcile-ledger-unpushed:
// infrastructure/medbase/functions/rcm-reconcile-ledger-unpushed/index.ts
// Cron: every 6 hours
// Find encounters where:
// - billable_ledger has POSTED rows without sales_order_id
// - encounter has er_disposition or discharge_date older than 1 hour
// - retry close-encounter-billing for each
8. Frontend Integration
8.1 Cashier ปิดบัญชี panel — BillCloseSummary component
Reads billable_ledger for the encounter, shows breakdown, lets cashier review before final close.
function BillCloseSummary({ encounterId }: { encounterId: string }) {
const { data: ledger } = useQuery({
queryKey: ['billable_ledger', encounterId],
queryFn: () => supabase.from('billable_ledger').select('*')
.eq('encounter_id', encounterId).eq('status', 'POSTED'),
});
const groupedByDept = useMemo(() => groupBy(ledger || [], 'dept_type'), [ledger]);
const total = useMemo(() => sumBy(ledger || [], 'total'), [ledger]);
return (
<Stack>
<Typography variant="h6">สรุปค่าใช้จ่าย / Bill Summary</Typography>
{Object.entries(groupedByDept).map(([dept, items]) => (
<DeptSection key={dept} dept={dept} items={items} />
))}
<Box>รวม: {total.toLocaleString()} {ledger?.[0]?.currency}</Box>
<Box>
{ledger?.[0]?.applied_rules?.map(r => <Chip key={r.rule_code} label={r.label_th || r.label_en} />)}
</Box>
<Button onClick={() => closeBill(encounterId)}>ปิดบัญชี</Button>
</Stack>
);
}
async function closeBill(encounterId: string) {
// Calls the close-encounter-billing edge function
await supabase.functions.invoke('close-encounter-billing', {
body: { encounter_id: encounterId, triggered_by: 'cashier_ui' },
});
}
8.2 ER Bed Board updates (already mostly done)
The duration badges on the ER Bed Board (shipped in this work) already read er_bed_stay_summary from encounter_journey_cache. After M4, the badges can also surface estimated charge by joining the live duration to the resolved rate:
<Chip label={`โซนนี้ ${formatDuration(p.currentZoneMinutes)} · ฿${estimateCharge(p)}`} />
8.3 Admin UI — /admin/facility-billing-rules
Mirror /admin/policy-gates and /admin/cds-rules:
- Table: list of rules with filters (country, status, facility_kind, scheme)
- Form: scope_json + predicate_json builders (visual editors backed by JSONB)
- Test panel: paste a sample stay-log row + encounter context, see which rules fire and the resulting
{qty, unit_price, applied_rules} - Diff view on save (writes to
facility_billing_rule_history) - Realtime channel invalidation → all open cashier panels refresh within 1s
9. Market-Pack Integration
Each country’s seed pack contributes:
infrastructure/market-packs/medos-{country}/
├── manifest.json
├── seed-menu-items-facility.sql # SKUs: SVC-ER-RESUS, SVC-IPD-ICU-BED, etc. (bilingual)
├── seed-facility-billing-rules.sql # The country-specific rule rows
├── seed-{nhso|philhealth|kaigo}-rates.sql # Already exists per country
└── seed-{nhso|philhealth|kaigo}-resolvers.sql # NEW: helper SQL functions referenced by rule action_json.resolver
Adding a new country (e.g. 🇻🇳 Vietnam BHYT):
- Add
infrastructure/market-packs/medos-vietnam/with manifest.json + seeds - Seed
menu_itemswith Vietnamese SKU set (bilingualname_vi/name_en) - Seed
facility_billing_rulewith VN rules (BHYT day caps, district-tier multipliers, etc.) - Optionally seed a
bhyt_ratesreference table if rates aren’t expressible directly in the rule action JSON - Add
region.config.tsentry mappinghis-vietnam.vercel.app→vi-VN - Deploy. No TypeScript or migration changes.
10. Implementation Roadmap (8 phases)
Builds on the existing M0–M7 roadmap in menu-price-ledger.md, extending with facility-stay specifics.
Phase M0 — Prerequisites (1 week)
- [ ] Add
billClosed/billClosedAt/billClosedByto MongoDBEncounterentity (deferred fromdelivery-room-remaining-backend-work.md) - [ ] Bridge
clinical.encounter.discharged/clinical.encounter.bill_closedevents to orchestrator - [ ] Project
bill_closedcolumns toencounter_journey_cache
Phase M1 — Chargemaster (2 weeks)
- [ ]
menu_itemstable + Supabase projection edge functionsync-product-to-menu-items(writes onfinancial.product.{created,updated}events) - [ ]
menu_item_price_historytable + trigger - [ ] Backfill seed:
seed-menu-items-facility-th.sqlfor ER/IPD/OR/LR products (Thai default) - [ ] Admin UI: read-only view at
/admin/menu-items
Phase M2 — Price resolver (1.5 weeks)
- [ ]
resolve_menu_price(sku, encounter_id, asof_date)Postgres function- Reads
menu_items, appliespayor_prices/scheme_priceswith effective-date filter - Returns
{ unit_price, currency, price_source, payor_plan, scheme, contract_ref, applied_rules }
- Reads
- [ ] Unit test fixtures for all 3 country price-resolution paths
Phase M3 — Stay logs harmonization (2 weeks)
- [ ] IPD:
bed_stay_segmentsview (LAG-based pairing) - [ ] LR:
lr_room_stay_logtable +lr_room_place_patient()/lr_room_close_encounter()RPCs + LR Bed Board UI (mirrors ER pattern) - [ ] OR:
trg_or_case_costing_to_ledgertrigger (status flip → ledger row) - [ ] ER: already shipped; verify the trigger contract matches
Phase M4 — Billable ledger + rule engine (2 weeks)
- [ ]
billable_ledgertable with idempotency constraint - [ ]
facility_billing_rule+facility_billing_rule_historytables - [ ]
evaluate_facility_billing_rules(ctx, encounter_id)Postgres function (executes the safe token grammar) - [ ]
trg_er_bed_stay_to_ledger,trg_bed_status_to_ledger,trg_lr_room_stay_to_ledgertriggers - [ ] Seed: Thailand baseline rules (
seed-facility-billing-rules-th.sql) - [ ] Admin UI at
/admin/facility-billing-rules(rule list + form + test panel)
Phase M5 — Bridge to NestJS (1 week)
- [ ]
close-encounter-billingedge function - [ ] NestJS endpoint:
POST /v2/financial/salesOrders/createFromLedger - [ ] Cashier UI:
BillCloseSummarycomponent on the ปิดบัญชี dialog - [ ] Hook into
EMERGENCY_DISPOSEDand discharge orchestrator handlers to auto-trigger - [ ]
rcm-reconcile-ledger-unpushedcron (6-hour safety net)
Phase M6 — Multi-country expansion (2 weeks)
- [ ] Philippines:
seed-menu-items-facility-ph.sql,seed-facility-billing-rules-ph.sql(Z-Benefit package, 45-day cap, day billing) - [ ] Japan:
seed-menu-items-facility-jp.sql,seed-facility-billing-rules-jp.sql(Kaigo addons, regional multipliers, monthly co-pay cap) - [ ] Helper resolvers:
kaigo_basic_rate_lookup(),philhealth_case_rate_lookup(),nhso_project_code_match() - [ ] Per-country Playwright smoke specs that close a synthetic encounter end-to-end
Phase M7 — Frontend ledger reads (1 week)
- [ ] Swap any frontend bill-calculation code (currently in cashier panel, RCM dashboards) to read from
billable_ledgerinstead of recomputing - [ ] Live charge estimates on ER Bed Board / IPD ward view / OR phase board / LR room board
Phase M8 — Observability + governance (1 week)
- [ ] Materialized view
v_billing_rule_hit_stats(rule_code → fire count per day) - [ ] Grafana panel: top-5 most-fired rules, top-5 zero-charge encounters (warning), DLQ queue size
- [ ] Alert: ledger row count vs SalesOrder line item count drift > 5% over 24h
- [ ] Quarterly governance review process (rule retirement, rate refresh, new addon onboarding)
Total estimate: ~12 weeks for full M0–M8 with one engineer. M0–M5 (single-country Thailand) is ~9 weeks and unlocks ER + LR + IPD + OR all using the unified pipeline.
11. Invariants (the things that must always hold)
- Append-only. No row in
billable_ledger,er_bed_stay_log,lr_room_stay_log,bed_status_log,menu_item_price_history, orfacility_billing_rule_historyis ever UPDATEd (exceptvacated_at/closed_atonce, andstatusfor ledger void). - Idempotent. Triggers and edge functions can re-run safely.
UNIQUE(origin_event, origin_ref) WHERE status='POSTED'guarantees one ledger row per source event. - Closed bill ⇒ frozen ledger. Once
encounter.billClosed=true, no new POSTED ledger rows may insert. New charges go to a separate addendum SalesOrder. - Country = country_code. A rule with
country_code='TH'only fires for encounters whereencounter.country_code='TH'. The'*'wildcard exists for truly universal rules (e.g. “round to 2 decimals”). - Bilingual or fail. Any rule message, SKU name, addon label that’s missing a translation falls back to
_en. If_enis also missing, log a warning but don’t break billing. - Effective date wins. Past encounters always bill at the rate active on their discharge date, never today’s rate. The
asof_dateparameter onresolve_menu_price()is mandatory. - No silent zero. If
resolve_menu_price()returns 0 because no price was found, the trigger writes a ledger row withstatus='POSTED'BUTmetadata.price_missing=true, andBillCloseSummaryshows a red banner. Cashier cannot close until cleared. - One SalesOrder per encounter per close cycle.
createFromLedgercreates a new SO; it never merges into an existing settled one. Re-opens require explicit amendment workflow. - No frontend-direct ledger writes. Frontend can only insert ledger rows via RPCs that wrap inserts with audit/idempotency. Direct table inserts are blocked by RLS (frontend role has SELECT only).
- PHI safety.
billable_ledger.metadatamay not contain free-text clinical notes. Only structured codes (SKU, scheme, payor_plan, rule_code). Prevents accidental PHI leak into RCM exports.
12. Open Questions / Risks
| Question | Default | Decision needed by |
|---|---|---|
| Should ER bed stays accrue estimated ledger rows while patient is still in zone, then VOID + re-POST when vacated? Or only POST on vacate? | Vacate only (simpler, no void churn). Live estimate computed on-the-fly for UI only. | M4 |
| How does package pricing handle partial coverage? E.g., Z-Benefit covers OR + 5 days IPD, but patient stays 8 days — do days 6-8 fall back to standard rates? | Yes, package replaces only the SKUs in its replaces[]. Days 6-8 use SVC-IPD-WARD-A rate. |
M6 |
| Should monthly caps (Kaigo) recompute on every ledger insert, or be a separate end-of-month reconciliation? | Recompute on insert for live UI accuracy. Cron verifies at month-end. | M6 |
| What happens if the cashier ปิดบัญชี runs while a stay is still open (e.g., patient still in ICU but cashier wants partial bill)? | UI blocks with warning; allow override with explicit confirmation. Open stay continues, generates a second ledger batch later. | M5 |
Should we move OR’s existing or_case_costing.{or_total_amount, anesthesia_total_amount, pacu_total_amount} to be derived from ledger rows instead of stored independently? |
No, keep or_case_costing as the OR-domain source. Ledger is the cross-dept aggregator. Single source of truth per dept; ledger is the integration layer. |
M3 |
| Should rules be tenant-scoped or organization-scoped or both? | organization_id column for tenant isolation (already in policy_gates); facility_id within scope_json for per-hospital override. |
M4 |
13. Why This Design
- Builds on what works.
policy_gates,cds_rules,er_bed_stay_log,or_case_costingare all proven patterns. We’re not inventing a new paradigm — we’re connecting four proven local patterns into one global system. - Scales by data, not by code. Every country, every payor, every contract = data rows. The TypeScript surface stays small and stable.
- Existing settlement chain intact. NestJS
settleAccountByEncounter(), discharge event flow, reconciliation cron — all unchanged. The only new NestJS endpoint iscreateFromLedger, a thin wrapper that hands line items to the existingsalesOrderService.createSalesOrder(). - Backend remains write truth. Even though the ledger lives in Supabase, the money still moves through NestJS. Supabase is the calculator and audit log; NestJS is the bank.
- Per-dept stay logs stay native. Forcing IPD’s complex bed state machine into ER’s flat zone log would mangle both. The unification happens at the trigger output (one ledger row format), not at the table level.
- Cashier sees what they need.
BillCloseSummaryreads one Supabase view and shows every dept, every rule applied, every adjustment — in the cashier’s language. No chasing data across systems. - New markets ship as seed files. Adding Vietnam, Korea, Singapore is a 1-week task: write the seed files, deploy. The engine itself never needs to learn about a new country.
Last updated: 2026-05-26 by the universal-facility-stay-billing design pass. Read alongside menu-price-ledger.md (the M0–M7 chargemaster + ledger plan this extends) and er-bed-board work (the reference per-dept stay-log implementation).