# W1-BE-A2 [FIN]: admission encounters bill to the stay folio (report)

Date: 2026-10-23. Scope: BE only, nothing committed.

## For the orchestrator (action items)
- **CHANGELOG bullet is needed** because this is user-facing and the pre-push hook will block without it. I did not edit the shared `docs/moonstack/CHANGELOG.md`; the bilingual text is at the end of this report.
- **Shared files touched, one method each:**
  - `Modules/Clinic/app/Http/Resources/EncounterResource.php` → `billingOrderId()`
  - `Modules/Clinic/app/Http/Controllers/ServiceOrderController.php` → `encounterOrder()`
- **Command registration:** the new command is registered in `HospitalServiceProvider`, not `ClinicServiceProvider`, because W3-BE-D is editing that file.
- No migrations, no FE, nothing to deploy.

## Root cause (confirmed)
- `AdmissionService::start()` calls `EncounterService::open([... 'admission_id' => $a->id], false)`.
- `EncounterService::open` writes `encounters.admission_id` first (line 94) and only then dispatches `EncounterStarted` (line 97).
- The listener `OpenServiceOrderForEncounter` then created an empty open order (the stray). After that, `Folio::open()` created the real stay bill as a second order with a higher id.
- `EncounterServiceOrder::findForEncounter()` used `->first()`, so it picked the stray. `GET encounters/{e}/order` did the same.
- `EncounterResource::billingOrderId()` sorted id desc, so it happened to pick the folio.
- Result: lab, rad and decide-here orders on an admission were billed to the stray order. The stray was also always a cash order (`payer_contract_id = null`), even for an insured stay. On dev, admission 9 has contract 2.

## Fix
1. **One resolver.** I added `EncounterServiceOrder::admissionFolioFor(companyId, encounterId)`.
   - It looks up `hospital_admissions` by `encounter_id` (the same link `Folio::open` stamps), takes the admission's `service_order_id`, and returns that order unless it is cancelled.
   - A closed folio is still returned (a discharged stay). Falling back to the old lookup there would pick the stray again.
   - It is guarded by `class_exists(Admission::class)`, the same guard `EncounterStayReader` uses, because Hospital is an optional module.
   - Every place that finds "the encounter's order" now asks the folio first:
     - `findForEncounter()`, which also covers `findOrCreateForEncounter()`. That means `OrderLabFromEncounter::handle`, `CreateRadOrder::handle` and `DecideClinicalIntent` all go through it.
     - `EncounterResource::billingOrderId()`.
     - `ServiceOrderController::encounterOrder` (`GET encounters/{e}/order`).
   - Outpatient behaviour is unchanged: when there is no folio, the old queries run exactly as before.
2. **Both orders of events are handled.**
   - (a) `OpenServiceOrderForEncounter` now returns early when `encounter->admission_id !== null`. This is reliable because `admission_id` is set before the event fires.
   - (b) `Folio::open()` now adopts an existing empty open order on the admission encounter instead of creating a second one. "Empty" means: same company, encounter and patient; status open; no lines, not even soft-deleted ones; no receipts; `paid_total` 0. On adoption it stamps `payer_contract_id`, `notes` and `created_by`.
   - (a) covers today's flow. (b) covers an encounter that gets linked to its admission only after its order was opened.
   - A folio-less admission encounter would still get a plain order from `findOrCreateForEncounter`, and (b) adopts it while it is empty.
3. **Repair command `clinic:repair-admission-orders {--company=} {--dry-run}`.** It lives in `Modules/Hospital/app/Console/RepairAdmissionOrdersCommand.php` and is registered in `HospitalServiceProvider`. I used Hospital because `ClinicServiceProvider` is being edited by W3-BE-D.
   - **Candidates:** for each admission with an encounter, every order on that encounter that is not the folio, not cancelled and not soft-deleted.
   - **Skipped and reported** when:
     - the admission has no folio;
     - the encounter belongs to another admission;
     - the order is for a different patient;
     - there is any money on the stray: `paid_total`, `reception_receipts`, `clinic_credit_notes` (order or line), `clinic_claims` or `clinic_claim_lines`, `reception_receipt_lines`, `clinic_package_session_revenues`, `patient_ledger_entries` (service_order_line), billed lines, or lines with `revenue_recognized_at`, `recognition_journal_entry_id` or `revenue_deferred_at` set;
     - the stray has lines but the folio is closed or cancelled, because moving them would reopen a settled bill.
   - **Move:** one transaction per stray, with both rows locked and the money check repeated inside the lock.
     - The lines, including soft-deleted ones, move with one UPDATE of `service_order_lines.service_order_id`.
     - Line ids do not change, so every reference to a line follows it: intents, lab requests and rad orders (via `fulfillment_*`), revenue-split parties, claim lines, night/RS/surgery/med rows, dental records.
   - **Totals:** if the folio is insured, it is re-split with `CoverageService::splitForOrder`. This is the same call and company check as `ServiceOrderController::resettleCoverage`. I did not use `resolveOrderContract`, because it aborts with 422. Then `recalculateBillingStatus()` runs on the folio.
   - **Cancel:** the stray gets `recalculateBillingStatus()` (all zeros) and is then forced to `cancelled`, with a note. The recalc has to come first, because the recalc leaves an order with no lines at all `open`.
   - **Logging:** every decision is printed and also written with `Log::info` or `Log::warning`.
   - **Idempotent:** a cancelled stray is no longer a candidate. The run counters are reset in `handle()`; Artisan reuses the command instance in-process, and a test caught this.

## Files changed
- `Modules/Clinic/app/Support/EncounterServiceOrder.php`: resolver (`admissionFolioFor`, `findForEncounter`).
- `Modules/Clinic/app/Listeners/OpenServiceOrderForEncounter.php`: skips admission encounters.
- `Modules/Clinic/app/Http/Resources/EncounterResource.php`: `billingOrderId()` only. This is a shared file that also has W1-BE-A / W2-BE-C edits; my edit is confined to that method.
- `Modules/Clinic/app/Http/Controllers/ServiceOrderController.php`: `encounterOrder()` only.
- `Modules/Hospital/app/Services/Folio.php`: adopt-empty-order in `open()`, plus private `adoptableOrder()`.
- `Modules/Hospital/app/Console/RepairAdmissionOrdersCommand.php`: new.
- `Modules/Hospital/app/Providers/HospitalServiceProvider.php`: command registration.
- `Modules/Hospital/tests/Feature/AdmissionFolioOrderTest.php`: new; helpers are prefixed `a2*`.
- All files were chowned to moonui3, followed by `composer dump-autoload` (ea-php82 CLI) and `optimize:clear`. No migrations. Pint `--test` passes on the new and fully edited files.

## Checked and deliberately left unchanged
- **`AddServiceOrderLine` step 4** (patient + encounter open order `->first()`, used when no `service_order_id` is passed, i.e. CreateVisit). It is outpatient-only in practice. After the listener skip and the repair, an admission encounter has exactly one live order, the folio.
- **Dental `ChargeService::orderFor`** uses id desc over open, partially billed and billed orders, so it already picks the folio, and cancelled strays are excluded.
- **`DiagnosisService::isInsured`** is an `exists()` over all orders and is still correct.
- **`RecognizeEncounterRevenue`** runs over all orders and is unaffected; a cancelled empty stray books nothing.

## Behaviour changes to note
- `GET encounters/{e}/order` for an admission encounter returns the folio whatever its status (except cancelled), including a closed folio after discharge. Before, it returned only an `open` order, which was the stray. The FE does not call this endpoint today (`ClinicOrderService.getByEncounter` has no callers); the rail uses `encounter.service_order_id`.
- **Intended: orders placed after discharge go on the settled stay bill.**
  - A lab/rad/decide-here on an admission encounter after discharge now lands on the stay folio even when it is `closed`. `recalculateBillingStatus()` then reopens it (`open` or `partially_billed`) with the new amount owed on the discharged stay.
  - Before the fix, such an order went to the stray order.
  - This is deliberate: one bill per stay, the same as a late night charge.
- The 7 cancelled strays now show as `cancelled` in order lists instead of as empty `open` orders.

## Tests (Pest, SQLite)
New file `Modules/Hospital/tests/Feature/AdmissionFolioOrderTest.php`: **8 passed (93 assertions)**.
1. Admitting opens ONE order (the folio), and `findForEncounter`, `GET encounters/{e}/order` and `EncounterResource.service_order_id` all return it.
2. An immediate lab order (`OrderLabFromEncounter::handle`) on an admission encounter lands on the folio, even with a stray left on the encounter at a **lower** id than the folio.
3. An intent decided "here" on an admission bills to the folio, and there is one order on the encounter.
4. Outpatient is unchanged: the listener's visit order receives the lab line, and `admissionFolioFor` returns null.
5. `Folio::open` adopts an empty order that is already on the encounter.
6. The repair:
   - dry-run writes nothing;
   - the real run moves the line, its `fulfillment_*` lab link and the intent's `service_order_line_id`, cancels the stray, and the folio `patient_due` goes up by exactly 150.000;
   - a second run reports `0 ... repaired, 0 skipped` and changes nothing.
7. The repair re-splits a moved cash line under an insured folio's 80% contract (coverage 80 / patient 20).
8. The repair skips a stray with a posted receipt, reports "has money movement", and the line stays put.

Mutation check: with `admissionFolioFor` forced to return null, test 2 fails; I then restored the file. Tests 1 and 3 also pass through the listener skip alone, which is defense in depth.

Existing tests re-run (each file individually), all passed:
- Clinic EncounterTest 14, ServiceOrderTest 16, OrderLabFromEncounterTest 9, ClinicalIntentDecideTest 17, RadOrderFlowTest 11, P3ExecutorRadiologyTest 17.
- Hospital HospitalAdmissionTest 9, Af2AdmissionEncounterKindTest 3, HospitalAccrualSmokeTest 2, HospitalSurgeryTest 6, P2InpatientMedsTest 25, HospitalNursingTest 4.

## Dev run (moonui3_dev_be)
Dry-run:
```
[dry-run] clinic:repair-admission-orders
WOULD ADM-2026-00001 (admission #1, company 1, encounter #20): move 0 line(s) of stray order #68 SO-2026-00063 to folio #69 SO-2026-00064, then cancel the stray
WOULD ADM-2026-00003 (admission #3, company 1, encounter #22): move 0 line(s) of stray order #71 SO-2026-00066 to folio #72 SO-2026-00067, then cancel the stray
WOULD ADM-2026-00004 (admission #4, company 1, encounter #23): move 0 line(s) of stray order #73 SO-2026-00068 to folio #74 SO-2026-00069, then cancel the stray
WOULD ADM-2026-00005 (admission #5, company 1, encounter #24): move 0 line(s) of stray order #75 SO-2026-00070 to folio #76 SO-2026-00071, then cancel the stray
WOULD ADM-2026-00007 (admission #7, company 1, encounter #25): move 0 line(s) of stray order #77 SO-2026-00072 to folio #78 SO-2026-00073, then cancel the stray
WOULD ADM-2026-00008 (admission #8, company 1, encounter #26): move 0 line(s) of stray order #79 SO-2026-00074 to folio #80 SO-2026-00075, then cancel the stray
WOULD ADM-2026-00009 (admission #9, company 1, encounter #27): move 0 line(s) of stray order #81 SO-2026-00076 to folio #82 SO-2026-00077, then cancel the stray
[dry-run] 7 stray order(s) would be repaired, 0 skipped
```
Real run, `--company=1`:
```
clinic:repair-admission-orders
DONE ADM-2026-00001 (admission #1, company 1, encounter #20): moved 0 line(s) of stray order #68 SO-2026-00063 to folio #69 SO-2026-00064; stray cancelled; folio patient_due 2300.000
DONE ADM-2026-00003 (admission #3, company 1, encounter #22): moved 0 line(s) of stray order #71 SO-2026-00066 to folio #72 SO-2026-00067; stray cancelled; folio patient_due 1800.000
DONE ADM-2026-00004 (admission #4, company 1, encounter #23): moved 0 line(s) of stray order #73 SO-2026-00068 to folio #74 SO-2026-00069; stray cancelled; folio patient_due 0.000
DONE ADM-2026-00005 (admission #5, company 1, encounter #24): moved 0 line(s) of stray order #75 SO-2026-00070 to folio #76 SO-2026-00071; stray cancelled; folio patient_due 0.000
DONE ADM-2026-00007 (admission #7, company 1, encounter #25): moved 0 line(s) of stray order #77 SO-2026-00072 to folio #78 SO-2026-00073; stray cancelled; folio patient_due 21800.000
DONE ADM-2026-00008 (admission #8, company 1, encounter #26): moved 0 line(s) of stray order #79 SO-2026-00074 to folio #80 SO-2026-00075; stray cancelled; folio patient_due 9800.000
DONE ADM-2026-00009 (admission #9, company 1, encounter #27): moved 0 line(s) of stray order #81 SO-2026-00076 to folio #82 SO-2026-00077; stray cancelled; folio patient_due 2400.000
7 stray order(s) repaired, 0 skipped
```
Idempotency, with a second dry-run afterwards: `[dry-run] 0 stray order(s) would be repaired, 0 skipped`.

MySQL spot-check after the run (tinker), showing `findForEncounter` and `billingOrderId` for each admission encounter:
- 20 gives 69, 21 gives 70, 22 gives 72, 23 gives 74, 24 gives 76, 25 gives 78, 26 gives 80, 27 gives 82. All are the folios, and the strays are `cancelled` at 0.000.
- Outpatient encounter 67: `admissionFolioFor` is null and `findForEncounter` gives 133 (unchanged).
- The folio totals in the real run are the same as before the run (2300 / 1800 / 0 / 0 / 21800 / 9800 / 2400).

**[FIN] reviewer note.** All 7 dev strays had **0 lines and no money**, so the dev run only cancelled empty orders. The line-move, re-split and money-skip paths were exercised **only in the SQLite tests** (6, 7 and 8), not on real MySQL data.

## Not verified / open
- No live HTTP call against moonui3's API (the rail, stay-bill, or `GET encounters/{e}/order` with a real token). This was verified with Pest plus tinker on MySQL only.
- **Lab lines moved by the repair keep their stored `unit_price`.** On an insured folio, `splitForOrder` re-splits coverage on that price (its standard lab/rad behaviour). The price is not re-fetched from the payer's LIS price list. This does not apply on dev, where no stray had lines.
- **Pre-existing quirk, out of scope and not changed.** For an encounter without an appointment, the listener's step 1 reuses *any* open order of the patient (no encounter filter). This is now bypassed for admission encounters. It remains for outpatient walk-ins.
- **CHANGELOG:** I did not edit `docs/moonstack/CHANGELOG.md` (it is shared). Suggested `[Unreleased]` bullet:
  - EN: "Inpatient / day-case: lab, radiology and service orders placed on an admission now go on the stay bill (no stray empty order is opened beside it); `clinic:repair-admission-orders` merges existing strays."
  - AR: «الإقامة/اليوم الواحد: طلبات المعمل والأشعة والخدمات على الدخول بقت بتتسجل على فاتورة الإقامة نفسها (من غير أمر فاضي جنبها)، والأمر `clinic:repair-admission-orders` بيصلّح القديم.»

---

## Round 2: Fable [FIN] review fixes (on top of BE aacaac484, not committed)
Review: `design/FABLE-FIN-W1-BE-A2.md` (APPROVE WITH FIXES). Every P1 is applied. The P2s that were small and safe are applied too; the rest are documented or left to the orchestrator (see below).

### Files changed
- `Modules/Clinic/app/Support/EncounterServiceOrder.php`
- `Modules/Clinic/app/Http/Resources/EncounterResource.php` (in `billingOrderId()` only)
- `Modules/Dental/app/Services/ChargeService.php` (in `orderFor()` only)
- `Modules/Hospital/app/Console/RepairAdmissionOrdersCommand.php`
- `Modules/Hospital/app/Services/Folio.php`
- `Modules/Hospital/tests/Feature/AdmissionFolioOrderTest.php`

All are chowned to moonui3, followed by `optimize:clear`. Pint `--test` passes on my files; I ran pint only on the command and did not touch `EncounterResource`'s other code.

### P1 fixes
- **P1-1: the repair always re-splits.**
  - When lines move, it calls `splitForOrder($folio->load('lines'), $contract)`, where `$contract` is the folio's contract or null for a cash folio. So a cash folio drops any stale insurer share (coverage becomes 0, the patient pays the full base minus discount).
  - If the folio names a contract that is not found in its company, the stray is skipped with that reason. The check runs at scan time and again under the lock, before any line moves.
  - Every log line now shows `payer <stray> → folio payer <folio>`.
- **P1-2: only the listener's own stray is repaired.**
  - A listener-made stray has `appointment_id IS NULL`, `payer_contract_id IS NULL` and `created_at >= encounter.created_at`; both tables have real `created_at` columns.
  - Anything else is skipped with `looks like a reception order, not the listener's stray (appointment #…, payer …, created … vs encounter …)`.
  - **New `--move-lines` flag.** Without it, only empty strays are cancelled; a stray with lines is reported as `has lines — rerun with --move-lines`. This is re-checked under the lock.
  - Every WOULD / SKIP / DONE line prints `[N line(s), subtotal, coverage, patient_due, payer … → folio payer …]`.
- **P1-3: the resolver ignores folio status.**
  - `admissionFolioFor` returns the folio whatever its status, including cancelled-by-recalc. Recalc reopens it once an active line lands.
  - The legacy fallback in `findForEncounter` now excludes `cancelled` and has an explicit `orderBy('id')`.
  - This changes outpatient behaviour slightly: an outpatient encounter whose only order is cancelled now gets a fresh order from `findOrCreateForEncounter`, instead of appending to the cancelled one.
- **P1-4: Dental uses the resolver.** `ChargeService::orderFor` asks `admissionFolioFor()` first, so a closed folio reopens and no new order is created. The outpatient lookup is unchanged.

### P2 items
Applied:
- **P2-1:** `billingOrderId` skips the folio lookup when `encounters.admission_id` is null, so outpatient list rows have no extra query. `encounterOrder` gets only an int, so I left it as it is (one endpoint, not a list).
- **P2-2:** the Hospital guard is now `class_exists` plus `Module::has('Hospital') && Module::isEnabled('Hospital')`, memoized and wrapped in try/catch (the codebase convention).
- **P2-3:** when lines move, the stray's `payment_disposition`, `sent_to_cashier_at` and `sent_to_cashier_by` are carried to the folio if the folio has none, and the log line says so.
- **P2-7:**
  - `Folio::adoptableOrder` keeps the adopted order's original notes (`… · Hospital stay ADM-…`).
  - The money reason now says `receipts (incl. voided)`.
  - Fallback `orderBy('id')` is done.

Documented, not changed:
- **P2-4: closed or cancelled folios.** The live code reopens a closed folio when a late charge arrives, but the repair still refuses to move lines onto one. This asymmetry is deliberate: the repair works on historical data, so it is conservative.
  - Lines added after a claim was submitted are not in that claim.
  - `left_with_balance` stays false.
  - No FE "charges after settlement" badge was added (FE is out of scope).
- **P2-6:** `splitForOrder` does not re-derive `unit_price`. This is pre-existing. Because lines move only with `--move-lines`, and strays carrying a payer contract are skipped, the risk is small.

Left to the orchestrator: P2-5 (CHANGELOG wording about the expired-contract 422 on insured stays).

### Tests
`Modules/Hospital/tests/Feature/AdmissionFolioOrderTest.php`: **12 passed (143 assertions)**.
- The earlier repair tests now pass `--move-lines`. Test 6 also checks that a run without the flag only reports, and that the dry-run prints the line count and totals.
- New tests:
  - **P1-1:** a stray line with a stale 80/20 split moved onto a cash folio ends up at coverage 0 and patient 100; folio `patient_due` is 100.
  - **P1-2:** three candidates (one with an appointment, one with a payer contract, one created before the encounter and carrying a line) are all skipped (`0 repaired, 3 skipped`), and nothing changes.
  - **P1-3:** the folio is cancelled by recalc (its only line was cancelled), and a repair-cancelled stray sits at a lower id. A new lab order lands on the folio, the folio becomes `open`, and the stray stays cancelled with no lines.
  - **P1-4:** a dental procedure completed on an admission encounter with a closed folio lands on the folio, and the encounter still has one order.

Regression files re-run one at a time, all passing:
- Clinic: EncounterTest 14, ServiceOrderTest 16, OrderLabFromEncounterTest 9, ClinicalIntentDecideTest 17, RadOrderFlowTest 11, P3ExecutorRadiologyTest 17.
- Hospital: HospitalAdmissionTest 9, Af2AdmissionEncounterKindTest 3, HospitalAccrualSmokeTest 2, HospitalSurgeryTest 6, P2InpatientMedsTest 25, HospitalNursingTest 4.
- Dental, added because `ChargeService` changed: DentalChartTest 11, DentalPlanTest 7, DentalLabRecallOrthoTest 6.

### Dev dry-run (as expected: 0)
```
[dry-run] clinic:repair-admission-orders
[dry-run] 0 stray order(s) would be repaired, 0 skipped
```
With `--dry-run --move-lines`: `[dry-run] 0 stray order(s) would be repaired, 0 skipped`.

MySQL spot-check, unchanged: encounters 20 to 27 resolve to folios 69, 70, 72, 74, 76, 78, 80 and 82. Outpatient encounter 67 still gives 133.
