medOS ultra

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.

34 min read diagramsUpdated 2026-05-28docs/architecture/universal-facility-stay-billing.md

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:


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.

  1. One ledger, all departments. ER, IPD, OR, LR (and future radiotherapy series, hemodialysis sessions, hyperbaric oxygen, etc.) post charges to the same billable_ledger rows. SalesOrder reads only the ledger — never the per-dept stay tables.

  2. Per-dept stay logs stay department-specific, but speak a common contract. We do not force a facility_stay_log mega-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 to billable_ledger. Compare-and-contrast table in §6.4.

  3. Rules are data, not code. Every country-specific behavior (rounding, grace periods, day caps, scheme switching) is a row in facility_billing_rule with a JSONB predicate using the same safe token grammar as cds_rules. Adding a country = inserting rule rows in a market-pack seed file. Zero TypeScript changes.

  4. 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.

  5. Stay logs are append-only. No UPDATE except closed_at (set once). No DELETE ever. Audit trail is immutable. Corrections happen via compensating action='override' or action='void' rows.

  6. 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.

  7. 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-time rcm-reconcile-facility-stays cron catches it within 6 hours.

  8. Bilingual everything. Every user-facing label has _th, _en, _ja, _fil variants. Rule messages too — the cashier sees the actual reason text in their locale.

  9. 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.

  10. Negative-space prevention. The system warns at ปิดบัญชี if any open stay segment exists, and refuses to close the bill if bill_close_eligibility rules 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_rates table × kaigo_regional_unit_prices.yen_per_unit for 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):

  1. Add infrastructure/market-packs/medos-vietnam/ with manifest.json + seeds
  2. Seed menu_items with Vietnamese SKU set (bilingual name_vi/name_en)
  3. Seed facility_billing_rule with VN rules (BHYT day caps, district-tier multipliers, etc.)
  4. Optionally seed a bhyt_rates reference table if rates aren’t expressible directly in the rule action JSON
  5. Add region.config.ts entry mapping his-vietnam.vercel.appvi-VN
  6. 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/billClosedBy to MongoDB Encounter entity (deferred from delivery-room-remaining-backend-work.md)
  • [ ] Bridge clinical.encounter.discharged / clinical.encounter.bill_closed events to orchestrator
  • [ ] Project bill_closed columns to encounter_journey_cache

Phase M1 — Chargemaster (2 weeks)

  • [ ] menu_items table + Supabase projection edge function sync-product-to-menu-items (writes on financial.product.{created,updated} events)
  • [ ] menu_item_price_history table + trigger
  • [ ] Backfill seed: seed-menu-items-facility-th.sql for 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, applies payor_prices / scheme_prices with effective-date filter
    • Returns { unit_price, currency, price_source, payor_plan, scheme, contract_ref, applied_rules }
  • [ ] Unit test fixtures for all 3 country price-resolution paths

Phase M3 — Stay logs harmonization (2 weeks)

  • [ ] IPD: bed_stay_segments view (LAG-based pairing)
  • [ ] LR: lr_room_stay_log table + lr_room_place_patient() / lr_room_close_encounter() RPCs + LR Bed Board UI (mirrors ER pattern)
  • [ ] OR: trg_or_case_costing_to_ledger trigger (status flip → ledger row)
  • [ ] ER: already shipped; verify the trigger contract matches

Phase M4 — Billable ledger + rule engine (2 weeks)

  • [ ] billable_ledger table with idempotency constraint
  • [ ] facility_billing_rule + facility_billing_rule_history tables
  • [ ] 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_ledger triggers
  • [ ] 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-billing edge function
  • [ ] NestJS endpoint: POST /v2/financial/salesOrders/createFromLedger
  • [ ] Cashier UI: BillCloseSummary component on the ปิดบัญชี dialog
  • [ ] Hook into EMERGENCY_DISPOSED and discharge orchestrator handlers to auto-trigger
  • [ ] rcm-reconcile-ledger-unpushed cron (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_ledger instead 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)

  1. Append-only. No row in billable_ledger, er_bed_stay_log, lr_room_stay_log, bed_status_log, menu_item_price_history, or facility_billing_rule_history is ever UPDATEd (except vacated_at/closed_at once, and status for ledger void).
  2. Idempotent. Triggers and edge functions can re-run safely. UNIQUE(origin_event, origin_ref) WHERE status='POSTED' guarantees one ledger row per source event.
  3. Closed bill ⇒ frozen ledger. Once encounter.billClosed=true, no new POSTED ledger rows may insert. New charges go to a separate addendum SalesOrder.
  4. Country = country_code. A rule with country_code='TH' only fires for encounters where encounter.country_code='TH'. The '*' wildcard exists for truly universal rules (e.g. “round to 2 decimals”).
  5. Bilingual or fail. Any rule message, SKU name, addon label that’s missing a translation falls back to _en. If _en is also missing, log a warning but don’t break billing.
  6. Effective date wins. Past encounters always bill at the rate active on their discharge date, never today’s rate. The asof_date parameter on resolve_menu_price() is mandatory.
  7. No silent zero. If resolve_menu_price() returns 0 because no price was found, the trigger writes a ledger row with status='POSTED' BUT metadata.price_missing=true, and BillCloseSummary shows a red banner. Cashier cannot close until cleared.
  8. One SalesOrder per encounter per close cycle. createFromLedger creates a new SO; it never merges into an existing settled one. Re-opens require explicit amendment workflow.
  9. 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).
  10. PHI safety. billable_ledger.metadata may 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_costing are 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 is createFromLedger, a thin wrapper that hands line items to the existing salesOrderService.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. BillCloseSummary reads 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).

Ask Anything