# Fable [FIN] review — client mtw39 wave 1 (money / GL / stock)

Reviewed 2026-10-24 on moonui3 (`hazemdev3`), from the commits + the surrounding code, read-only. Scope: the seven BE commits named by the orchestrator. Dev state checked: `journal_entries.entry_type` is `varchar(50)` (new enum cases need no migration); dev co1 has `clinic.package_revenue_mode = per_session`, `inventory.transit_account_id = 119`, transit warehouse id 2 on account 119; `clinic.split.post_to_gl`, `inventory.post_to_gl`, `purchases.supplier_advance_account_id` are NOT set on dev, so the SPLIT GL path, every transit entry and the separate-account advance path ran only in the SQLite tests. No P0.

Paths are relative to `/home/moonui3/moon-erp-be/`. Line numbers are of the committed files.

## Verdicts

| Commit | WP | Verdict | Why |
|---|---|---|---|
| 7a59f395b | W1-SPLIT commission accrual | **APPROVE WITH FIXES** | Accounts, direction, amounts, idempotency, reversal and off-switch are right. One P1: with `post_to_gl` on and `package_revenue_mode ≠ per_session`, package sessions 2..N never earn (their consume lines are priced 0 and are never revenue-recognised). |
| e14145b26 | W1-POS blind close + handover + variance | **APPROVE WITH FIXES** | Entries are correct and atomic. Two P1: the entries carry no branch (`pos_session` is not a known journal source), and the default handover includes the opening float, which has no GL — the drawer account drifts negative by the float every shift. |
| 56955a7c1 | W1-PUR supplier advance | **APPROVE WITH FIXES** | Advance / apply / unapply are balanced, locked, capped and reversible; the cancel guard and the bill-cancel block hold. P2s only (entry type reuse, unclamped date, bill correction unverified). |
| 94ee90573 | GL review cancel fixes | **APPROVE** | All six fixes are correct and follow the document, not the setting. One P2 guard on the sales-invoice cancel. |
| 03e92fd98 | W1-INVTR transit + variances; W1-INVRPT count auto-approve | **APPROVE WITH FIXES** | Ship / receive / cancel / resolution legs are value-exact, keyed per phase, and the fence on the transit warehouse holds. P2s only (a movement lookup, GL-switch flip, dates). |
| fdfa9566a | W1-CL gate + free visit (money aspect) | **APPROVE** | Free visit nets 0 for patient and insurer and is never revenue-recognised, so it never earns commission; the gate reads the visit's own remaining; the quota is taken under a doctor-row lock. |
| a91f63fd3 | shared registries | **APPROVE** | Types, expectations, mirrors and grants are consistent. Two branch-resolver rows belong here (SPLIT-3, POS-1). |

## Findings

Severity: **P0** = wrong money / stock that will happen in normal use; **P1** = wrong money / stock in a plausible configuration or flow, or a silent drift; **P2** = correctness debt, cosmetic, or a decision to record.

### 7a59f395b — W1-SPLIT

| # | Sev | Where | Failure scenario | Fix |
|---|---|---|---|---|
| SPLIT-1 | **P1** | `Modules/Clinic/app/Services/SplitCommissionJournal.php:84` (`earn()` is only ever driven by `ClinicLineRevenueRecognized`, `Listeners/EarnSplitPartiesOnRevenueRecognized.php:24`); `Modules/Clinic/app/Actions/AddServiceOrderLine.php:235-241` (consume line: `unit_price 0`, `split_base = grant.unit_value`); `Modules/Clinic/app/Services/ClinicRevenueRecognizer.php:543-546, 619-623` (a 0-patient line is stamped but **not announced**); `PostReceiptJournalEntry.php:101-103` (a 0 receipt line is skipped). | `clinic.split.post_to_gl = true` and `clinic.package_revenue_mode ≠ per_session` (the default on every install but dev co1). A 10-session package at 1000 with doctor 20%: the sale line earns 20 (its 1/10 split base) when collected; consume lines 2..10 each carry split rows of 20 but their revenue is never recognised (price 0 → never announced), so `earned_at` stays null and nothing posts. The doctor is owed 200, the ledger accrues 20. The dues report shows `share 200 / earned 20` forever. W1S-* tests and dev both run per_session and cannot see it. | Minimum for W3: in `Modules/Clinic/app/Support/ClinicSettingValidator.php` (the file that already lists the two commission accounts under `OPTIONAL_GL_KEYS`) refuse saving `clinic.split.post_to_gl = true` unless `clinic.package_revenue_mode = per_session` (bilingual message: «تفعيل ترحيل العمولات يتطلب وضع إيراد الباقات = لكل جلسة»), and say so in the setting description. Proper fix: in `SplitCommissionJournal::earn()`, when an earned line is a package SALE line (`package_grant_id` set and `id = grant.sale_line_id`) and the mode is not per_session, also call `earn()` for the grant's existing consume lines; and in `Modules/Clinic/app/Listeners/SnapshotEncounterParties.php` after the snapshot of a consume line, if its grant's sale line already has `earned_at` and the mode is not per_session, call `earn($company, [$line->id], today)` (the session was given today). |
| SPLIT-2 | P2 | `Modules/Clinic/app/Listeners/EarnSplitPartiesOnRevenueRecognized.php:24`; `SplitCommissionJournal.php:84-116` | The listener passes `lineIds` only and ignores `$event->amounts`, against the contract written on both events («Σ recognised − Σ derecognised, never recognitions alone»). Harmless today: a WP5 partial receipt announces the line's billable amount, not the paid part (`PostReceiptJournalEntry.php:124`), and 0 lines are not announced. It becomes wrong the day a recogniser announces a partial amount. | `earn()` takes the event's `amounts`; stamp `earned_at` only when Σ recognised for the line > 0.0005, and keep the «full share on first non-zero recognition» rule as an explicit, documented decision in the class docblock. |
| SPLIT-3 | P2 | `Modules/Accounting/app/Support/JournalBranchResolver.php` (SOURCES) | `source_type = 'clinic_split_party'` is not in `SOURCES`, so every commission entry and reversal is company-level (no branch) while the line and order carry `branch_id`. Branch P&L misses doctor commissions. | Add to the Clinic block: `'clinic_split_party' => ['table' => 'clinic_encounter_service_parties', 'parent' => ['service_order_line_id', 'service_order_lines', 'branch_id', null]]`. Land it in the shared-registries commit. |
| SPLIT-4 | P2 | `SplitCommissionJournal.php:277-280` (`require_posted => false`, row stamped `accrued` + `journal_entry_id` whatever the entry's status); `:302-349` (`reverseRow` mirrors the entry's lines and posts the mirror, key at `:343`); `DoctorDuesReport.php:48` (`posted` = `journal_entry_id IS NOT NULL`). | Auto-post fails on the earning (closed period that `fallbackToday` cannot save, acting-user gap, auto-post off): the entry stays a DRAFT, the row says `accrued`, the report says «posted», and a later cancel posts a **posted reversal of an unposted entry** — a lone Dr payable / Cr expense in the ledger. | In `postRow`, stamp `status = 'accrued'` only when `$entry->isPosted()`, else keep `pending` with `journal_entry_id` set (retry = post the draft). In `reverseRow`, if the original is not posted, cancel it (`CancelJournalEntry::execute($entry, fromSourceDocument: true)`) and clear `journal_entry_id` instead of posting a mirror. In `DoctorDuesReport`, join `journal_entries` and count `posted` only when `status = 'posted'`. |
| SPLIT-5 | P2 | `Modules/Clinic/app/Services/DoctorDuesReport.php:34` | The dues are bucketed by `DATE(esp.created_at)` (line-add day), while `earned_on` now exists and is the day the share became payable. A line added on the 31st and collected on the 1st is paid in the wrong month. | Add an `earned_from/earned_to` window (or a `date_basis = earned|created` parameter) and default the dues breakdown to `earned_on`. |
| SPLIT-6 | P2 (owner decision) | `Modules/Clinic/app/Services/RevenueSplitService.php:245-251` (`splitBase` = `unit_price × qty`, discounts ignored — pre-existing); interaction with fdfa9566a `AddServiceOrderLine.php:255-258` (`free_visit` = 100% discount) | A line sold with a discount pays the doctor on the gross price. Until now report-only; with `post_to_gl` on it is money: a 300 service sold at 200 accrues 60 to the doctor, not 40. A free visit shows `share 60` in the dues report (it never earns, because 0 revenue is never announced, so no GL leak — but the report misleads). | Decide the base: if commission is on what the clinic actually bills, make `splitBase()` return `max(0, unit_price × qty − discount_amount)` (keep the `split_base` override for packages) and exclude `discount_reason = 'free_visit'` rows from the dues table; if commission is on the list price (some clinics do this), say so in the scheme editor and show `gross` as list price. |
| SPLIT-7 | P2 | `Modules/Clinic/app/Support/SplitPerformerResolver.php:30-62` (`forLine` picks the latest completed session's doctor of the visit, `orderByDesc('ended_at')` at `:62`); `:72-104` (`linesForSession` matches by `service_id` OR `package_grant_id` within the visit, `:100`) | Two sessions of the same service (or grant) in ONE visit performed by two doctors: the second session's doctor becomes the performer of both lines; the first doctor's posted share is reversed and re-posted to the second. Across visits it is correct (the match is scoped to the session's appointment / encounter). | Add `clinic_device_sessions.service_order_line_id` (nullable), set it when the session is started from / for a specific line (DeviceSessionService) and prefer it in `forLine` / `linesForSession`; fall back to the current match only when null. |
| SPLIT-8 | P2 (decision) | `SplitCommissionJournal.php:63` (`UNDO_REASONS` excludes `package_true_up`) | A per_session package sale refunded after sessions were given: `ClinicPackageRevenue` announces `package_true_up` for the sale line, which the listener ignores, so the sale line's (session 1) commission stays earned and posted. Defensible (the session was given); record it. | Record in the KB topic; no code change unless the owner wants the sale-line share reversed on a refund. |
| SPLIT-9 | note | `Modules/Clinic/app/Listeners/VoidLineParties.php:51`; `RevenueSplitService.php:312-326` | Two behaviour changes are not behind the setting: a receipt void no longer voids the parties of a revenue-booked line (correct: nothing was derecognised), and over-price shares are scaled instead of failing the visit. Both are right; both are already in the LEDGER log. | None. Keep the LEDGER line. |
| SPLIT-10 | P2 (deviation from brief) | `SplitCommissionJournal.php:84-116` (earned = recognised); `SplitPerformerResolver.php:30-62` (performer null until a COMPLETED session) | The brief defined earned as «collected/recognised AND (for sessions) performed». The build earns on revenue recognition alone. The `performer` row effectively waits (no party until a session completes, so nothing posts), but the `primary_doctor` share of a session service posts on recognition — in accrual mode on the service day — before the session is performed, and stays posted if the session is never done (only a cancel / refund of the line takes it back). | Record for the owner. If «performed» must gate the doctor's share too: in `isPostable()` require, for lines whose service is a device/session service, a completed `ClinicDeviceSession` for the line (reuse `SplitPerformerResolver::forLine() !== null` as the signal) and let `syncPerformerForSession` be the trigger that posts it. |

Checked and OK: Dr `clinic.commission_expense_account_id` / Cr `row.payable_account_id ?: clinic.commission_payable_account_id`, one entry per party row, centre and null-party rows never post, amounts rounded to 3 dp and asserted balanced, `lockForUpdate` + `isPostable` re-check inside the posting transaction, idempotency key `clinic_split_{row}` / `_r{reversal}` so void → re-collect posts once more, reversal = exact mirror of the posted lines under key `clinic_split_rev_{je}`, reversal always runs even with the switch off, cancel / refund / receipt_void reverse while adjudication / write-off / true-up do not, per_session packages earn one session's share per line (sale line and each consume line carry `split_base = unit_value`), listeners never throw after commit.

### e14145b26 — W1-POS

| # | Sev | Where | Failure scenario | Fix |
|---|---|---|---|---|
| POS-1 | **P1** | `Modules/POS/app/Services/POSShiftClosePostings.php:198, 257` (`source_type => 'pos_session'`); `Modules/Accounting/app/Support/JournalBranchResolver.php:118` has `cashier_session` but no `pos_session` | Both entries (variance, handover) resolve no branch and post company-level although `pos_sessions.branch_id` is set. A multi-branch client's branch cash and over/short are wrong in every branch report; the Clinic twin is mapped and correct. | Add `'pos_session' => ['table' => 'pos_sessions', 'branch' => 'branch_id'],` next to `cashier_session` in `JournalBranchResolver::SOURCES` (shared-registries commit). |
| POS-2 | **P1** | `Modules/POS/app/Http/Controllers/POSSessionController.php:264-265` (default handover = counted − `keep_float`, `keep_float` default 0; `:378` returns the same figure to the dialog); `POSShiftClosePostings.php:220-261` (`postHandover` credits the drawer for the whole amount); `POSSessionController.php:185` (`open()` stores `opening_balance`, posts nothing) | Opening float 100 (taken physically from the main treasury, no entry), cash sales 1000 (Dr drawer 1000), counted 1100, variance 0. Default handover 1100 → Cr drawer 1100: drawer GL −100, main treasury GL +100 over its physical balance, **cumulative every shift** (next shift repeats). When the drawer account is a petty-cash box with no-overdraft the close is instead refused with «handover_over_box_balance» every shift until the cashier lowers the amount by hand (`W1PosBlindCloseHandoverTest.php:251` asserts exactly this). | (a) Default `handover_amount = counted − max(keep_float, opening_balance)` in `close()` and return that figure as `meta.default_handover` from `handoverTargets()` (the dialog prefill). (b) In `postHandover`, post the entry for `min($amount, max(0, counted − opening_balance))` and store the rest on the session as `float_returned` (data only — the float never entered the GL, so returning it needs no entry); when the posted part is 0 skip the entry. (c) Longer term: post the float at `open()` (Dr drawer / Cr main treasury) when an `opening_from_petty_cash_id` is given — then (b) becomes unnecessary. Apply (a)+(b) now. |
| POS-3 | P2 | `POSShiftClosePostings.php:193, 252` | `'date' => now()->toDateString()` is server-clock date; the rest of the ledger uses `CompanyToday::date($companyId)` (a close at 00:30 company time posts on the wrong day). | Use `CompanyToday::date($companyId)` for both entries. |
| POS-4 | P2 | `POSSessionController.php:429-446` (`blindSummary`) | Fine as built. Note the LEDGER deferral stands: a cashier holding `pos.reports.view` / `sales.invoices.view` can still read the shift's takings elsewhere; the blind close is only as blind as the role. | Role decision (already in Deferrals). |
| POS-5 | note | `POSShiftClosePostings.php:153-212` | Variance is fail-safe (savepoint + log, data kept) and card/transfer differences are not posted — both by design and documented in the class. Idempotency keys per session; no reopen exists, so no reversal path is needed. | None. |

Checked and OK: shortage = Dr over/short / Cr drawer, overage = Dr drawer / Cr over/short; handover = Dr target box / Cr drawer, bounded by counted cash and by the drawer box's book balance; order variance → handover; the whole close is one transaction so a handover 422 rolls everything back; `cash_account_id` snapshotted; `pos.cash_over_short_account_id` ?: `accounting.cash_over_short_account_id`; blind refusal messages never carry the expected figure.

Not verified on MySQL: dev co1 has no terminal / cashier user (LEDGER CFG).

### 56955a7c1 — W1-PUR (supplier advance)

| # | Sev | Where | Failure scenario | Fix |
|---|---|---|---|---|
| PUR-1 | P2 | `Modules/Purchases/app/Services/SupplierAdvanceService.php:64` (`ENTRY_TYPE = 'purchase_payment'`, used at `:426`) | The application entry is typed `purchase_payment` with no `purchase_payments` row and no cash movement. Any report keyed on the type (payments journal, cash-flow by type) counts the advance voucher AND its application. | Add `JournalEntryType::SupplierAdvanceApplication = 'supplier_advance_application'` (+ label) and use it here; the open-item settlement is keyed on `source_type`, not the entry type, so nothing else changes. |
| PUR-2 | P2 | `SupplierAdvanceService.php:333` (`$date = $date ?: CompanyToday`) ; `ApplySupplierAdvanceRequest.php:22` (`date` free) | A user may apply an advance dated before the voucher or the bill (back-dating a settlement into a period where the bill did not exist). | Clamp: `$date = max($date ?: today, voucher.date, bill.date)` or refuse with a message. |
| PUR-3 | P2 (unverified) | `Modules/Purchases/app/Actions/CorrectPurchaseBill.php:244-245` → `SupplierAdvanceService::repoint()` | Bill 1000 with 800 advance applied, corrected to 500: the application moves to the new bill unchecked; the new bill's open amount goes negative (over-settled). LEDGER already lists «bill correction with applied advance» as not verified. | In `CorrectPurchaseBill::finalize`, before `repoint`, compare Σ applied (live rows on the old bill) with the new total; refuse the correction with a bilingual message («الغِ تسوية الدفعة المقدمة أولًا») when applied > new total. |
| PUR-4 | P2 (unverified) | `StoreSupplierAdvanceRequest.php:28` | `payment_method = check` is accepted; the voucher's cheque path (cheques issued, not cash) was not exercised by the new tests. | Add one test: advance by cheque posts Cr cheques-issued and the cheque row exists; or restrict v1 to cash / bank_transfer / card. |
| PUR-5 | note | `SupplierAdvanceService.php:426-445` | Without a separate account the application is Dr AP (document line) / Cr AP (partner, no document) on the same account — net zero by design; it exists only to settle the bill through the open-item machinery. Correct, and the supplier statement shows both lines. | None. |

Checked and OK: advance = Dr `advance_account_id` (stamped at creation: separate asset or the supplier's payable, same rule as bills) / Cr cash-bank through `ApprovePaymentVoucher`; advance vouchers refuse allocations; apply locks bill + vouchers + application rows, caps at the voucher's open amount and the bill's remaining, FIFO prefers the bill's PO; `ensurePosted` forces a posted entry or rolls back; unapply = `ReverseJournalEntry` (posted) dated max(today, entry date); `GuardSupplierAdvanceCancel` blocks cancelling an applied advance; `CancelPurchaseBill` is blocked by `amount_paid > 0`; `supplier_advance_application` is in `OpenItemSettlementService::SETTLING_SOURCES` and `JournalBranchResolver::SOURCES`; base currency pinned; foreign bills refused.

### 94ee90573 — GL review cancel fixes

| # | Sev | Where | Failure scenario | Fix |
|---|---|---|---|---|
| GLR-1 | P2 | `Modules/Sales/app/Actions/CancelSalesInvoice.php:203-205, 210` | `restoreStock` (when a `sales_invoice` movement exists) and `cancelCancellableSaleGdns` now both run unconditionally. `PostSalesInvoice` never does both, so today it is safe; a manual issued sale GDN raised later against an AtSale invoice (if any screen allows it) would restore the units twice. | Guard: if `stockLeftAtInvoicePosting($invoice)` and an issued sale GDN exists, refuse the cancel by name (or skip the GDN cascade with a warning) — make the invariant explicit rather than assumed. |
| GLR-2 | note | `reports/inventory-gl-review.md` | The six fixes are correct: value-only rows signed by `quantity_out`, increase-cancel on its own layer at its own value, GRN cancel refused with a posted landed cost, bill cancel by what the bill received, invoice cancel by the stock ledger, return cancel restocks on its own movements and voids a draft cost entry. The P2 list in the review file stands; the proposed CHANGELOG bullets are accurate. `git diff 94ee90573 HEAD` on the four files is empty (the reset incident lost nothing). | None. |

### 03e92fd98 — W1-INVTR (transit + variances) and W1-INVRPT count auto-approve

| # | Sev | Where | Failure scenario | Fix |
|---|---|---|---|---|
| TR-1 | P2 | `Modules/Inventory/app/Services/TransitStockMover.php:167-176` | `$moved` is read by `reference_type / reference_id / product_id`, `latest('id')`, no `reference_line_id` and no variant. It is right today because the line's own out-movement was just written; if `decreaseStock` ever writes more than one movement for a line (per-owner split) or two lines of one product are processed in one call, the destination is credited with the wrong value and the receive entry gains a silent counter line on the adjustment account. | Filter by `reference_line_id = $item->id` (and variant) and `SUM(total_cost)` / `SUM(quantity_out)` instead of `latest()`. |
| TR-2 | P2 | `Modules/Inventory/app/Services/TransitWarehouseService.php:137-159, 167-178` | No `inventory.transit_account_id` (the best-effort wiring failed or the chart has no current-assets parent): the transit warehouse resolves to the company inventory account, ship / receive are net-zero per account and `post()` returns null — stock and GL stay aligned but `journal_entry_id` is null and no «بضاعة بالطريق» balance ever appears. Dev co1 is wired (119). | Surface it: in the transfers screen / setup checklist show «حساب بضاعة بالطريق غير مضبوط» when `post_to_gl` is on and the setting is empty. No posting change. |
| TR-3 | P2 | `ShipTransfer.php:195-201`, `ReceiveTransfer.php:231-237` | `inventory.post_to_gl` OFF at ship, ON at receive: the receive posts Dr destination / Cr transit with nothing ever debited to transit. Same class as every other document and covered only if the DR6 alignment runs at switch-on (it aligns the transit warehouse's on-hand too). | Document in `inventory-gl-review.md` under «switch flips»; optionally in `receiveFromTransit` skip the Cr transit leg when the ship posted nothing and `inventory_gl_adjustments` has no acknowledged row after the ship date (post against the alignment account instead, as cancels do). |
| TR-4 | P2 | `ShipTransfer.php:197` (ship entry dated `$transfer->date`), `ReceiveTransfer.php:201`, `CancelTransfer.php:43`, `ResolveTransferVariance.php:76` (today) | The ship entry takes the document date (may be days before the ship, or in a closed period — `InventoryGlPoster::post` passes the date straight to `CreateJournalEntry`), the other legs today. Movements carry the same dates, so stock and GL agree; the inconsistency is between ship and the rest. | Date the ship entry and the transit-in movement on the ship day (`CompanyToday::date`), as the legacy receive did. |
| TR-5 | P2 | `ReceiveTransfer.php:222, 287` | An open variance row's `value` = qty × 3-dp transit unit cost; the resolution overwrites it with the exact moved value. The in-transit / variances lists therefore show a value that can differ by a few mils from what will post. | Compute the open value as (line transit value − Σ drawn) × qty / qty left, or label the column «تقديري». |
| TR-6 | P2 | `Modules/Inventory/app/Actions/FinalizeCount.php:167-173` | With `inventory.count.auto_approve_adjustment` on and `post_to_gl` on, a missing `inventory.count_variance_account_id` / `adjustment_account_id` rolls the whole finalize back: the keeper cannot finish a count because of an accounting setting, and the 422 names the setting only through the poster's message. | Keep the rollback (correct), but catch the poster's `RuntimeException` and rethrow a `ValidationException` on `auto_approve` naming the setting and the switch, so the keeper knows what to ask for. |
| TR-7 | P2 | `TransitStockMover.php:251-252` (every leg: `source_type inventory_transfer`, `source_id` transfer) | The branch resolver maps `inventory_transfer` to `to_warehouse_id`, so the ship entry (source → transit) carries the destination's branch in its header; the WP9 per-branch line split keeps the amounts right, the header is cosmetic. Variance entries also point at the transfer, not the variance row. | Pass `branch_id => fromWarehouse.branch_id` in the ship `post()` ctx; use `source_type inventory_transfer_variance` + the row id for resolutions (add it to `SOURCES` with `parent` → transfer → `to_warehouse_id`). |
| TR-8 | note | `TransitWarehouseService.php:50-75` | Consignment ends, customer-owned lines and production requisition / surplus transfers keep the direct path (no transit). A sensible narrowing of «transit ON by default» — the transit warehouse is company stock. | Record in the KB topic. |

Checked and OK: one movement `reference_type` per phase (`inventory_transfer` ship, `_receive`, `_cancel`, `_variance`) so the poster never re-nets another phase's legs; transit in at the exact source-out value (`exact_value`); transit out at the transfer's own value (FIFO `reverse_of` its own layer; WAC passes the transfer's transit unit cost and the last draw of a line takes exactly the value left, so transit ends at 0.000); destination in at exactly what left transit; ship = Dr transit / Cr source, receive = Dr destination / Cr transit, cancel = Dr source / Cr transit, return / late receipt likewise, write-off = Dr damage or adjustment / Cr transit — all through `InventoryGlPoster::post` so nothing posts with `post_to_gl` off; keys `inv_transfer:{id}`, `inv_transfer_receive:{id}`, `inv_transfer_cancel:{id}`, `inv_transfer_var:{row}`; `via_transit` stamped at ship so a setting flip never changes an in-flight transfer; legacy shipped transfers take the old receive byte for byte and now record a resolved `auto_write_off` variance; the short remainder stays in transit as open rows (no silent write-off); over-receipt still 422; `StockService` refuses every non-transit-leg movement into / out of the system transit warehouse; `inTransit()` in the reconciliation excludes via-transit transfers (their goods are on hand in the transit warehouse); lots (batch / expiry / owner) are re-created in transit and at the target; `ReceiveTransfer` legacy post now falls back to the company owner so a job receive auto-posts; count auto-approve runs `ApproveAdjustment` inside the finalize transaction and respects a configured approval workflow.

Not verified on MySQL: `inventory.post_to_gl` is off on dev, so no transit entry has posted on dev `/app`; FIFO and WAC cost paths ran in `TransferTransitTest` (SQLite) only.

### fdfa9566a — W1-CL (money aspect)

| # | Sev | Where | Failure scenario | Fix |
|---|---|---|---|---|
| CL-1 | P2 | cross-reference SPLIT-6 | A free-visit line (100% discount, `discount_reason = free_visit`) still snapshots split rows on the gross price, so the dues report shows a share the doctor will never earn. No GL effect (0 revenue is never announced). | See SPLIT-6. |
| CL-2 | note | `Modules/Clinic/app/Support/ConsultPaymentGate.php:50-76`; `FreeVisitQuota.php:75-97`; `AddServiceOrderLine.php:255-262` | Gate = the visit's own `billingTotals()['remaining']` (patient share only, never old debt); a free visit, a package session and a zero-price line all net 0 and pass; the quota is counted per calendar month on non-cancelled `is_free_visit` appointments under a `lab_doctors` row lock inside the booking transaction. Correct. | None. |

### a91f63fd3 — shared registries

| # | Sev | Where | Failure scenario | Fix |
|---|---|---|---|---|
| REG-1 | P2 | `Modules/Accounting/app/Support/JournalBranchResolver.php` | The two new journal sources of this wave (`clinic_split_party`, `pos_session`) are missing from the shared resolver (SPLIT-3, POS-1). | Add both rows here, in the shared-registries commit. |
| REG-2 | note | `Modules/Accounting/app/Enums/JournalEntryType.php`, `DefaultAccountExpectations.php`, `SettingDefinitionSeeder.php:4720-4723, 4811` | `journal_entries.entry_type` is `varchar(50)` on dev, so the four new cases need no column migration. Expectations (transit asset, supplier advances asset, commission payable liability, commission expense, POS over/short expense|revenue) and the `pos.cash_over_short_account_id` mirror onto the company over/short leaf are right; the clinic commission leaves are deliberately separate from the LIS ones. | None. |

## P0 / P1 summary (for W3-FIX)

- **SPLIT-1 (P1)** — package sessions 2..N never earn commission unless `clinic.package_revenue_mode = per_session`. Minimum: `ClinicSettingValidator` refuses `clinic.split.post_to_gl = true` when the mode is not per_session; proper: earn the grant's consume lines when the sale line is earned / when a consume line is added.
- **POS-1 (P1)** — add `'pos_session' => ['table' => 'pos_sessions', 'branch' => 'branch_id']` to `JournalBranchResolver::SOURCES`.
- **POS-2 (P1)** — default handover = counted − max(`keep_float`, `opening_balance`); post the handover entry only for `min(handover, counted − opening_balance)` and keep the returned float as data (the float never entered the GL).

Everything else is P2 or a recorded decision. No commit needs to be reverted.
