# Fable [FIN] review — W1-BE-A2: admission encounters bill to the stay folio

Date: 2026-10-23. Reviewer: Fable 5.1 ([FIN]). Scope: the uncommitted diff in `/home/moonui3/moon-erp-be` for the files listed in the brief. Review-only; nothing edited, nothing written to `moonui3_dev_be`.
Evidence: `AdmissionFolioOrderTest` re-run on SQLite → **8 passed (93 assertions)**; code paths traced below.

## Verdict: **APPROVE WITH FIXES**

The live-path fix is right and is the real money win: lab / rad / decide-here lines on an admission now land on the stay folio and are priced against the **folio's payer contract** (`OrderLabFromEncounter::core` → `resolveOrderContract($order)` → `payer_price_list_id`; `CreateRadOrder::core` same), instead of on a cash stray with `payer_contract_id = null`. The discharge accrual (`AdmissionService::discharge` → `recognizeOrder(folio, TRIGGER_DISCHARGE)`) and `SettlementService` / `Folio::summary` (both read `admission.service_order_id`) now see those lines. No tenant leak found: every new query filters `company_id` on both `hospital_admissions` and `service_orders`.

The P1s are all in the **repair command** and the **resolver's cancelled-folio fallback**. They matter because `clinic:repair-admission-orders` ships to self-hosted clients via MoonStack, and on dev every stray had **zero lines**, so the line-move / re-split / money-guard paths were only ever exercised on SQLite fixtures, never on a real stray with lines.

---

## P0 — none

## P1 (fix before merge)

### P1-1 Repair: a cash folio never re-splits the moved lines → insurer coverage survives on a cash bill
`Modules/Hospital/app/Console/RepairAdmissionOrdersCommand.php:160-167`

```php
if ($folio->payer_contract_id !== null) { … splitForOrder($folio->load('lines'), $contract); }
$folio->recalculateBillingStatus();
```
A pre-fix "stray" is not always the listener's empty order. `OpenServiceOrderForEncounter.php:41-46` step 1 reuses **any** open order of the patient when the encounter has no appointment (an admission encounter never has one) and stamps `encounter_id` on it if null — so the stray can be a reception order that carries a `payer_contract_id` and lines with `coverage_amount > 0`. Moved onto a **cash** folio, those lines keep their insurer share; `recalculateBillingStatus` then sums `patient_amount` → `patient_due` understated by the coverage, money nobody will collect.
**Fix:** always call `$coverage->splitForOrder($folio->load('lines'), $contract)` where `$contract` may be null (`CoverageService::splitForOrder:385-396` already handles the cash case: coverage → 0, patient → base − discount; billed / recognized lines stay frozen). Add the payer mismatch to the report line (`stray.payer_contract_id` vs `folio.payer_contract_id`) and a test: insured stray line (coverage 80) → cash folio → coverage 0 / patient 100.

### P1-2 Repair: "stray" is any non-folio order on the encounter — can swallow a real reception order
`RepairAdmissionOrdersCommand.php:79-85`
Same root as P1-1: the candidate query is `encounter_id = X AND id ≠ folio AND status ≠ cancelled`. A hijacked reception order (step-1 reuse above) or an order reception opened deliberately with `encounter_id` (`ServiceOrderController::store` accepts it, line 98) is indistinguishable from the listener-made stray. Its lines (e.g. an outpatient consultation booked for a later appointment) would be merged into the stay bill, its `appointment_id` link is lost when it is cancelled, and `RadOrder.payer_contract_id` (`CreateRadOrder.php:93`, a per-order snapshot) now disagrees with the folio for claims/audit.
**Fix (either, prefer both):**
1. Only treat as a listener-made stray an order with `appointment_id IS NULL`, `payer_contract_id IS NULL` **and** `created_at >= encounter.created_at` (the listener creates it in the same transaction as the encounter). Anything else → `SKIP … looks like a reception order (appointment #…, created before the encounter)`.
2. Move lines only behind an explicit `--move-lines` flag; the default run cancels line-less strays only (which is all dev needed). Print line count **and** `subtotal / coverage_total / patient_due` of every candidate in dry-run so the operator can see money before it moves.

### P1-3 Resolver: a cancelled folio falls back to a lookup that does not exclude cancelled orders
`Modules/Clinic/app/Support/EncounterServiceOrder.php:43-47` and `:79`
`admissionFolioFor` returns null when the folio is `cancelled`; `findForEncounter` then runs the legacy `->first()` with **no status filter**. `ServiceOrder::recalculateBillingStatus:174` marks an order `cancelled` whenever it has lines and all are cancelled (every charge refunded / voided) — so a stay whose bill was fully refunded and then gets a new lab order resolves to… the lowest-id order on the encounter, which on dev is now the **repair-cancelled stray** (e.g. #68 for encounter 20). The line lands there, the listener's recalc flips the stray back to `open`, and `Folio::summary` / `SettlementService` (which read `admission.service_order_id`) never see it → discharge with due = 0 while money is owed on a ghost order. The same hole exists for outpatients (pre-existing) but this WP's own cancelled strays make it live.
**Fix:** return the folio **regardless of status** (a cancelled-by-recalc folio self-heals to `open`/`partially_billed` as soon as an active line lands — same mechanism as the closed→reopen path the WP already relies on), or at minimum add `->where('status', '!=', Cancelled)` to the legacy fallback. Add a test: folio with its only line refunded (status cancelled) + cancelled stray → new lab line → lands on the folio.

### P1-4 Dental `ChargeService::orderFor` still picks "the encounter's order" without the resolver
`Modules/Dental/app/Services/ChargeService.php:77-91`
`whereIn('status', [open, partially_billed, billed]) … orderByDesc('id')`. On an admission encounter with a **closed** folio (settled, discharge ordered) a dental charge opens a **new** order — the exact stray class this WP removes, and inconsistent with the "closed folio reopens" policy the resolver now implements for lab / rad / intents. The report lists this as "already picks the folio"; it does only while the folio is not closed. Dental on an inpatient is uncommon — this is P1 because the brief's rule is explicit: every picker goes through the resolver.
**Fix:** `return app(EncounterServiceOrder::class)->admissionFolioFor($companyId, $encounter->id) ?? <existing lookup>;` (the appointment_id OR-branch is outpatient-only and stays).

## P2 (should fix / document)

### P2-1 `EncounterResource::billingOrderId` is now one extra query per encounter in list endpoints
`Modules/Clinic/app/Http/Resources/EncounterResource.php:161-167`; `EncounterController.php:61` uses `EncounterResource::collection` (25/page). Outpatient: 1 (admissions, null) + the unchanged legacy query; admission: 2 (admissions + order). Previously 1 → +25 queries per page. `encounters.admission_id` is reliable (set by `AdmissionService::start`, backfilled by `2026_11_03_110001_backfill_encounters_kind_from_admissions`), so short-circuit: `if ($this->admission_id === null) { legacy lookup }` and only call `admissionFolioFor` for admission encounters. Same in `ServiceOrderController::encounterOrder` if the encounter is loaded.

### P2-2 Hospital-module guard is `class_exists`, which is true even when the module is disabled
`EncounterServiceOrder.php:61`. Shipped `modules_statuses.json` has Hospital enabled, so today the table exists everywhere; but a client that disables the module keeps the class. The codebase's convention is `Module::has('X') && Module::isEnabled('X')` (`ClinicProfilePackService.php:139`, `IntakeProfileFields.php:148`) or `Schema::hasTable('hospital_…')` (`ProcedureService.php:262`). Use the convention (P2-1's short-circuit makes this moot for outpatients anyway).

### P2-3 Repair drops the stray's cashier hand-off
`RepairAdmissionOrdersCommand.php:171-175`. A stray with `payment_disposition = send_to_cashier` sits in `CashierQueueController`'s queue; cancelling it removes it and the folio may not be queued → the moved lines vanish from the cashier's screen. Carry `payment_disposition / sent_to_cashier_at / sent_to_cashier_by` to the folio when the folio has none, and say so in the log line.

### P2-4 Post-discharge orders reopen a closed stay bill — accept, but document the consequences
`EncounterServiceOrder.php:76-80` returns a `closed` folio; `recalculateBillingStatus` moves it to `partially_billed` with new `patient_due`. Encounter `done` is not terminal (`Encounter::isTerminal:264` = signed/cancelled only), so this is reachable after discharge, not only in `discharge_ordered`. It is consistent with outpatient behaviour (legacy `first()` already appends to closed orders) and with "one bill per stay", and the period-cutoff accrual (`recognizeUpTo`) will book the late line. But: (a) a `clinic_claims` row already submitted for the folio will not include the late lines; (b) `admissions.left_with_balance` stays false; (c) the repair refuses to touch a closed folio (`:105-107`) while live code reopens it — defensible for historical data, but name the asymmetry in the KB topic. Recommend a one-line `Log::info` + the FE stay-bill badge for "charges after settlement".

### P2-5 Behaviour change for insured stays with an unusable contract
Lab / rad on an admission now go through `resolveOrderContract(folio)` → `assertContractUsable` → **422** when the stay's contract expired mid-stay. Before, they went through as cash on the stray. This is the same guard `Folio::charge` → `AddServiceOrderLine` applies to nights, so it is consistent — but it is user-visible; add to the CHANGELOG bullet ("insured stays: an expired contract now blocks lab/rad orders the same way it blocks nights").

### P2-6 `splitForOrder` re-derives coverage/patient but never `unit_price`
`CoverageService.php:446-462`. A moved clinic-service line snapshotted under the stray's contract keeps its `unit_price`, so after the repair's re-split `subtotal` (= Σ unit_price × qty, `ServiceOrder.php:125`) can disagree with coverage + patient + discount. Pre-existing (resettleCoverage has it too); not charged to this WP, but it is why P1-2's "don't move insured-stray lines by default" matters.

### P2-7 Minor
- `Folio::adoptableOrder` (`Folio.php:64-80`) adopts an order that may carry `appointment_id` and overwrites `notes` — harmless (no lines, no money), but keep the original notes (`$order->notes ? $order->notes.' · ' : ''`).
- `moneyOn` counts voided receipts too — conservative, fine; say "receipts (incl. voided)" in the reason so the operator is not confused.
- Repair concurrency: `CollectReceptionReceipt` locks the order row (`:81-83`), so the lock in `move()` serialises correctly against a live collection; no change needed.
- The legacy fallback in `findForEncounter` has no `orderBy` → MySQL returns by PK in practice but is not guaranteed; add `->orderBy('id')` while touching it for P1-3.

## Checked and OK
- Money links follow the line id (`clinic_claim_lines`, `reception_receipt_lines`, `patient_ledger_entries(source_type=service_order_line)`, `clinic_package_session_revenues`, `hospital_night_charges.service_order_line_id`, `encounter_service_parties`, `clinical_intents.service_order_line_id`, `fulfillment_*`). Order-keyed money (`reception_receipts`, `clinic_claims`, `clinic_credit_notes`, journal entries `source_type=service_order`) is fully guarded because every such row implies a receipt / claim / credit note / recognized line that `moneyOn` already checks (`:181-207`). Deposits key on the admission, not the order.
- Guard inside the lock re-runs `moneyOn`; mass `update()` keeps line ids; idempotent via `status ≠ cancelled`; counters reset per run; `--company` scoped; `PayerContract` company-checked before the re-split.
- Outpatient paths byte-identical when `admissionFolioFor` is null (test 4); `DiagnosisService::isInsured` (`exists()` over all orders) unaffected; `RecognizeEncounterRevenue` runs over all orders of the encounter, a cancelled empty stray books nothing.
- `AddServiceOrderLine` step 4 (`:281-289`, patient + open + encounter) is only reached without `service_order_id`; every encounter-bound caller passes the id (`Folio::charge`, `ExecuteCatalogItem`, `Dental ChargeService`, `CreateVisit`), so it cannot pick a stray on an admission encounter.

## Tests to add (with the fixes)
1. Insured stray line → cash folio → coverage 0 / patient full (P1-1).
2. Stray with `appointment_id` / created before the encounter → skipped and reported (P1-2).
3. Folio cancelled by recalc (all lines refunded) + cancelled stray → new lab line lands on the folio, folio reopens (P1-3).
4. Dental charge on an admission encounter with a closed folio → lands on the folio (P1-4).
