# Perpetual-inventory GL review (W1-INVRPT §5, first bullet)

Client ask (Emad, mtw39): «موضوع القيود في الجرد المستمر… راجعها». Reviewed 2026-10-24 on moonui3 (`hazemdev3`), from the code, not the KB. Paths are relative to `/home/moonui3/moon-erp-be/Modules/`.

## How posting is gated

- **`inventory.post_to_gl`** (definition default `false`; `InventoryGlPoster::isEnabled`) gates **only the documents that call `InventoryGlPoster`**: stock receipts of type other / adjustment / return, opening receipts (also need `inventory.post_opening_to_gl`), stock issues of type consumption / job_tag / other / damage / adjustment, adjustments (count variance included; `FinalizeCount` only drafts the adjustment), transfers (posted at receipt), WebStore store-order COGS, and the opening-stock catch-up command. `PostSalesReturn` also checks it, but only to refuse a return when there is no COGS account.
- **These always post, whatever the switch:** purchase bills, GRN GR/IR accrual, direct-bill receipt recognition, landed-cost vouchers, purchase returns and their cost variance, sales COGS (at the invoice, at the GDN in delivery mode, at the delivery note, or through «goods delivered not invoiced»), sales returns, the COGS true-up. Each of these documents writes its own inventory leg, so they never go through the poster too. Checked: no double posting. Sale and production GDNs map to reason `null` in `reasonForIssueType`, and purchase, bill, production and sales-invoice receipts map to `null` in `reasonForReceiptType`.
- **Amount:** the poster sums `inventory_movements.total_cost` for the document's own rows, per resolved account. The resolver order is warehouse → category chain → company fallback. Rows on a consignment warehouse and rows with a customer owner are left out (off-book). If the movements span more than one branch, there is one line per account and branch (WP9).
- **Cancels:** a cancel posts the reversal whenever the original posted an entry, even if the switch has been turned off since. If the original was approved before the switch was on, its cancel goes to the opening/alignment account and not to P&L. Every key is idempotent (`inv_*:{id}` / `inv_*_cancel:{id}`).

## Findings

| Path | What posts (Dr / Cr) | Verdict | Fix / file:line |
|---|---|---|---|
| `InventoryGlPoster::netByAccount` / `netByAccountBranch` | sign per movement | **P1 (latent), fixed** | Rows were signed with `quantity_in > 0 ? + : −`, so a **value-only** row (RevalueStock landed-cost uplift, both qty 0) counted as an outflow. No poster caller hits that row today, but the reconciliation drill-down did. New `signedValue()`: `quantity_out > 0` → −, else +. `Inventory/app/Services/InventoryGlPoster.php:354`, used at `:312` and `:346` |
| `InventoryGlReconciliationService::drillDown` | n/a (report) | **P1, fixed** | Same sign bug in SQL: landed-cost uplifts showed as *negative* stock under source `inventory_receipt`. `…/InventoryGlReconciliationService.php:143` |
| `InventoryGlReconciliationService::report` | n/a | OK | Stock side is Σ `stock_balances.total_value` (RevalueStock raises it), plus in transit, minus pending sale GDNs. |
| ApproveReceipt → `postReceipt` | Dr inventory / Cr adjustment (other, adjustment, return) · Dr inventory / Cr opening account (opening, opt-in) | OK | Purchase, bill, production and sales-invoice receipts return null (their owners post). |
| CancelReceipt → `postCancellation` | mirror, counter = current setting | OK / P2 | P2-1: the counter account is read from today's settings, not from the original entry's counter line. P2-2: for an **opening** receipt, `reverse_of` names `inventory_receipt` but the opening layers carry `opening`, so FIFO draws oldest-first (normally the same layer). |
| ApproveIssue → `postInventoryJournal` | Dr consumption / damage / adjustment (+ cost center) / Cr inventory | OK | Sale and production GDNs are skipped (reason null). |
| CancelIssue → `postCancellation` | mirror, exact moved value (MovementValue), original cost center | OK / P2 | P2-1 as above. |
| ApproveAdjustment | increase: Dr inventory / Cr count variance, damage or adjustment · decrease at system cost (WAC or FIFO) | OK | |
| CancelAdjustment (increase) | Cr inventory / Dr counter | **P1, fixed** | The cancel of an INCREASE had no `reverse_of`, so under FIFO it drained the **oldest** layers at their cost. The reversal then missed the original on the counter account, and the adjustment's own layer stayed on hand. It now uses its own layer (`reverse_of`) and the exact moved value (`MovementValue::forLine(…,'in')`). `Inventory/app/Actions/CancelAdjustment.php:41,60` |
| CancelAdjustment (decrease) | Dr inventory / Cr counter | OK | Already used the posted movement value. |
| RevalueStock / PostLandedCostVoucher | Dr inventory (on-hand share) + Dr expense (issued share) / Cr landed-cost clearing | OK / P2 | P2-3: RevalueStock bumps `unit_cost` but not `remaining_value`. A layer converted from another unit (factor ≠ 1) has `remaining_value` set, and FIFO drains `remaining_value`, so the uplift never reaches COGS (it stays on the balance). P2-4: under **weighted average** the receipt layers are never consumed, so Σ `remaining_quantity` = the original quantity and 100% is capitalised even after goods were issued. Stock and GL still agree (balance value includes it), but on-hand is overvalued. |
| CancelApprovedGrn with a posted landed-cost voucher | (GR/IR reversal only) | **P1, fixed** | A voucher has no unpost. The GRN cancel drained the uplifted layers (FIFO) or left the uplift on a zero-qty balance (WAC), while Dr inventory from the voucher stayed. The cancel is now refused by name (bilingual message), and the goods go back through a purchase return. `Purchases/app/Actions/CancelApprovedGrn.php:95` + `lang/{en,ar}/purchases.php` `grn_cancel_blocked_landed_cost` |
| OpeningStockCatchUp | same entry as ApproveReceipt (key `inv_receipt:{id}`) | OK | Refuses draft results; blocks when the opening account is not a detail account. |
| InventoryGlAlignmentService (DR6) | one-time Dr/Cr inventory vs opening account | OK (skimmed) | |
| PostSalesInvoice (invoice mode / AtSale) | Dr COGS / Cr inventory per resolved account, from the movement read back | OK / P2 | P2-5: no `idempotency_key` on the COGS entry. A double post is covered by the invoice-status check, but there is no row lock before it. P2-6: in invoice mode with an auto-GDN, COGS is booked at the **estimate**. Without `sales.cogs_true_up_on_gdn_approval` (default false) the difference to the GDN's actual cost stays as a permanent stock-vs-GL gap. |
| CancelSalesInvoice | reverses revenue + COGS; restores stock | **P1, fixed** | Outside delivery mode, the restore depended on **today's settings**. With `auto_create_stock_issue_on_invoice` ON, the cancel looked only for GDNs. A POS on-account sale (AtSale: the stock leaves at the invoice and no GDN is created) had its COGS reversed and **never got its units back**. With the switch OFF it restored stock for an invoice that never moved any. Both now follow the document: `sales_invoice` movements → restore; issued sale GDNs → cancel them. `Sales/app/Actions/CancelSalesInvoice.php:190` |
| ConfirmDeliveryNote / CancelDeliveryNote | legacy: Dr COGS / Cr inventory · GDNI: Dr interim / Cr inventory | OK | The cancel already decides from movements. |
| PostSaleCogsOnIssueApproved / ReverseSaleCogsOnIssueCancelled | Dr COGS / Cr inventory per account (netByAccount) · exact swap on cancel | OK | |
| InvoiceCogsTrueUp (+ reverse) | ±(actual − estimate) Dr/Cr COGS vs inventory | OK (skimmed) | |
| GoodsDeliveredNotInvoicedService | interim ↔ COGS clearing | OK (skimmed, not deep-verified) | |
| PostSalesReturn | Dr inventory / Cr COGS at the return cost chain | OK / P2 | P2-5 (no idempotency key). |
| CancelSalesReturn | reverse COGS; remove restocked stock | **P1, fixed** | (a) Stock was removed only if the COGS entry was **posted**. A zero-cost return, a return posted with no COGS account, or one whose cost entry stayed a draft kept the units on the shelf forever, and the draft was left behind as an orphan. (b) There was no `reverse_of`, so under FIFO the units left at the oldest layers' cost while the GL reversed the return's value. Now: removal follows the return's own `sales_return` movements, FIFO uses its own layer, and a draft or approved cost entry is cancelled. `Sales/app/Actions/CancelSalesReturn.php:61,137` |
| PostPurchaseBill (direct GRN mode) | Dr inventory / Cr AP + auto-approved `purchase` receipt | OK | |
| CancelPurchaseBill | reverse bill JE + direct-mode stock + stock-out variance | **P1, fixed** | The stock reversal was gated on **today's** `grn_mode`. Direct → GRN/controlled since: the entry was reversed and the goods were kept. GRN → direct since: the cancel took goods off that the (still approved) GRN had received. It now follows the document: the bill's own approved direct receipt (excluding GRN receipts, whose ids live in another id space). `Purchases/app/Actions/CancelPurchaseBill.php:143-170` |
| PostGrnGrniAccrual / CancelApprovedGrn | Dr inventory per account / Cr GR/IR · exact reversal | OK / P2 | P2-7: the accrual is qty × the GRN line's unit_cost, while stock comes in at the receipt movement value. They can differ by a 3-dp rounding on unit-converted lines. |
| PostDirectBillReceiptRecognition | Dr inventory / Cr GR/IR at the receipt line total | OK | |
| PostPurchaseReturn / CancelPurchaseReturn | Cr inventory at the stock-out value (gap → PPV) · restore at `exact_value` | OK / P2 | P2-8: both use the raw `item->quantity` without `unit_id`, so a return line in a non-base unit moves the wrong base quantity (both ways, consistently). P2-9: a draft return JE is not voided on cancel. |
| `BillReceipts::receiptIds` / `directReceiptUnitCost` | (helper) | P2 | P2-10: `$direct` matches `reference_type='purchase'` + `reference_id = bill id`, which can also catch a GRN receipt whose GRN id equals the bill id. That gives wrong `reverse_of` layers or a wrong net-basis unit cost on purchase returns. 0 collisions on dev. Fix: exclude `purchase_grns.inventory_receipt_id`, as the new `CancelPurchaseBill::billReceivedItsOwnGoods` does. |
| WebStore StoreOrderGlPoster | Dr COGS / Cr inventory · reverse on cancel/return (alignment account if the deduction was absorbed) | OK / P2 | P2-11: when `post_to_gl` is turned OFF after a sale posted, its cancel/return is **not** reversed (`postDocument` returns early on `isEnabled`). The Inventory documents reverse whenever the original posted. |

### Other P2s (for later)

- P2-12: cancel dates are not consistent. CancelIssue and CancelReceipt date the movement on the document date but the entry today. CancelAdjustment dates its movement today.
- P2-13: `CancelSalesInvoice::restoreStock` restores at the 3-dp line `unit_cost`, not at the movement value. That leaves a ≤0.001 residual per line versus the COGS reversal. `MovementValue` exists for this.
- P2-14: CancelSalesReturn has no stock-sufficiency guard (CancelApprovedGrn has one), so stock can go negative if the returned goods were already resold.
- P2-15: an invoice-mode auto-GDN that is cancelled while the invoice stays posted restores the stock, but the invoice's COGS stays credited. `pendingSaleIssues` counts only draft or pending GDNs, so the reconciliation shows a gap until a new GDN issues the goods.
- P2-16: `InventoryAccountResolver::categoryAccountId` stops at the first category with an account. If that account was deleted it falls to the company account and does not walk further up to the parent category.
- P2-17: `CancelSalesReturn` does not void a revenue entry left as a draft.

## For W1-INVTR (transfers: reported, not changed)

- Received transfers cannot be cancelled (`CancelTransfer:28`). A shipped transfer's cancel posts nothing, which is correct while the GL moves only at receipt. **Your via-transit design posts at ship**, so a shipped via-transit transfer's cancel must reverse the ship entry.
- `ReceiveTransfer` posts with `user_id => auth()->id() ?? 0`. A console or job receive has no author, so `CreateJournalEntry` cannot auto-post it and the entry stays a draft (the GL falls behind). Use the company owner fallback, as `OpeningStockCatchUp::actingUser` does.
- Consignment: `netByAccount` drops every movement on an `is_consignment` warehouse, but transfer movements carry no owner (`ownership_resolved_per_lot`). Company-owned lots moved ordinary → consignment warehouse post Cr inventory / **Dr adjustment** (the asset is expensed). Customer goods moved the other way post Dr inventory / Cr adjustment (off-book goods put on the books). Decide per lot owner.
- Short receipt: the unreceived value goes to `inventory.adjustment_account_id` (reason `adjustment`). Consider a dedicated transit-loss account.
- The branch split (WP9) is correct: one line per account and branch, and the counter line only for the net.

## Proposed CHANGELOG `[Unreleased]` bullets (orchestrator to paste; the shared file was not touched)

- **Fixed:** cancelling a sales credit note now always takes the returned goods back out of stock. This covers a zero-cost return, a return without a cost entry, and a return whose cost entry is still a draft (the draft is voided). Under FIFO the goods leave at the return's own cost. / **إصلاح:** إلغاء مرتجع المبيعات بيشيل البضاعة المرتجعة من المخزون في كل الحالات (حتى لو تكلفتها صفر أو قيد التكلفة لسه مسودة — المسودة بتتلغي)، وبتكلفة المرتجع نفسه في FIFO.
- **Fixed:** cancelling a sales invoice returns the stock according to what the invoice actually did, not today's settings. A POS sale on account that is cancelled now gets its goods back. / **إصلاح:** إلغاء فاتورة المبيعات بيرجّع المخزون حسب اللي الفاتورة عملته فعلًا مش حسب الإعدادات الحالية — بيع نقطة البيع الآجل لما يتلغي بضاعته بترجع.
- **Fixed:** cancelling a purchase bill reverses its stock according to how the bill received the goods, even if the GRN mode changed since. / **إصلاح:** إلغاء فاتورة المشتريات بيعكس المخزون حسب طريقة استلام الفاتورة نفسها حتى لو نمط الاستلام اتغيّر بعدها.
- **Changed:** a goods-received note with a posted landed-cost voucher can no longer be cancelled. Use a purchase return instead. / **تغيير:** إذن الاستلام اللي عليه قسيمة تكلفة وصول مُرحَّلة مبقاش ينفع يتلغي — استخدم مرتجع مشتريات.
- **Fixed:** with perpetual inventory on, cancelling a stock increase adjustment (FIFO) reverses exactly what it posted. Landed-cost uplifts now show as increases in the stock ↔ GL drill-down. / **إصلاح:** في المخزون المستمر، إلغاء تسوية الزيادة (FIFO) بيعكس نفس القيمة بالظبط، وتحميل تكلفة الوصول بيظهر كزيادة في تفاصيل مطابقة المخزون والأستاذ.

## Notes / disclosures

- Dev: 0 companies have `inventory.post_to_gl` on, so the poster-side fixes (sign, adjustment cancel) are dormant on dev `/app`. The Sales and Purchases cancel fixes apply whatever the switch.
- No conflict with LEDGER decision 13 (review + fix; `post_to_gl` default unchanged).

## Tests

New files, each run alone with `/opt/cpanel/ea-php82/root/usr/bin/php artisan test <file>`. All pass, and each was also run once against the pre-fix code to confirm it fails there:

- `Inventory/tests/Feature/InventoryGlReviewFixesTest.php`: 2 tests (FIFO increase-adjustment cancel mirror; value-only sign in poster and drill-down).
- `Sales/tests/Feature/SalesGlReviewCancelFixesTest.php`: 4 tests (AtSale cancel with auto-GDN restores stock; FIFO return cancel own layer; zero-cost return cancel; draft COGS voided).
- `Purchases/tests/Feature/PurchasesGlReviewFixesTest.php`: 3 tests (direct bill cancel after a switch to GRN mode; GRN-mode bill cancel after a switch to direct; GRN with a posted landed-cost voucher is refused).

Existing suites touching the changed actions were not run (the brief allows only new/changed files). Recommended regression run: `Inventory/…/PerpetualInventoryGlTest.php`, `InventoryAdjustmentApiTest.php`, `Sales/…/CogsAtDeliveryCancelTest.php`, `Wp4cReviewFixesTest.php`, `Wp4cGoodsDeliveredNotInvoicedTest.php`, `SalesConsignmentConversionTest.php`, `SalesOrderLifecycleTest.php`, `POS/…/POSImmediateStockRecognitionTest.php`, `POSRefundSeamGuardsTest.php`, `UnifiedLoyaltyPosTest.php`, `Purchases/…/PurchaseGrnCancelAfterApprovalTest.php`, `DirectModeDiscountCostTest.php`, `DirectPurchaseBillTest.php`, `LandedCostVoucherApiTest.php`.
