# The GRN ↔ stock-receipt cancellation deadlock

> **2026-08-14 · [FIN] · found on production (prod.elbaset.com), diagnosed on moonui2.**
> Owner's report: GRN-2026-00006 «موافق عليه» — cancelling it says «لا يمكن إلغاء إذن الاستلام»,
> and cancelling its stock receipt says «ألغِ إذن الاستلام». Each door sends you to the other.

## The symptom is a THREE-way lock, not two

| Door | Where | Why it refuses |
|---|---|---|
| Cancel the GRN | `PurchaseGrnController::cancel()` → `PurchaseGrnStatus::canCancel()` | `Approved` is **not in the list**: `[Draft, PendingQuality, QualityApproved, QualityRejected, PendingApproval, PendingReceipt]` |
| Delete/edit the receipt | `InventoryReceiptController::destroy()` :479 and `update()` :288 | `receipt_locked_by_grn` — its own comment says "the only verbs allowed are approve, **or reject via cancelling the GRN**" |
| Cancel the receipt | `CancelReceipt::execute()` :29-35 | the GRN carries a `journal_entry_id` (the GR/IR accrual) ⇒ `receipt_cancel_blocked_grni`, "must be unwound through the purchase bill/GRN chain" |

**The third door's intended exit does not exist for this state.** It says unwind through the bill
— but no bill had been raised, and the bill is precisely what clears GR/IR. Verified on the live
row: `purchase_bills` where `purchase_grn_id = 6` or `purchase_order_id = 5` → **zero rows**.

## Original gap, not a regression

`git log -S` on the enum: `canCancel()` has excluded `Approved` since **`c1417eefd`, 2026-02-25**
(the first GRN commit). Later commits only ever ADDED intermediate states (`PendingApproval`,
`PendingReceipt`) — the terminal one was never revisited. Nobody removed an exit; there never was one.

## The evidence that it is an oversight

`PurchaseBillStatus::canCancel()` **includes `Posted`** — the sibling document, which likewise
carries a journal entry and a stock effect, IS cancellable, with reversal machinery behind it.
The GRN is the outlier. The mental model the rest of the module follows is
*"cancellable until something downstream consumed it"*.

## A fourth defect: the one unlocked door DESYNCS the pair

`InventoryReceiptController::cancel()` has **no GRN check at all**, and `CancelReceipt`'s guard
fires only when `journal_entry_id` is set. On a company in **bill**-recognition mode the GRN
approval posts no accrual — so cancelling the receipt SUCCEEDS, reversing the stock while the GRN
stays `Approved` and the purchase order stays received. Reachable today; it is the mirror image of
the deadlock and must be closed in the same change, or the fix just moves the hole.

## Latent sibling — the same deadlock, different documents

The direct-purchase-bill branch has it too, and here **both sides name each other in their own
comments**:

- `InventoryReceiptController::destroy()` :485 → `receipt_locked_by_bill`, "voided only by cancelling the bill".
- `CancelPurchaseBill::voidOrBlockDirectBillReceipts()` :96-99 → if the receipt `isApproved()` → **throw** `bill_cancel_blocked_receipt_approved`.
- `CancelReceipt` :41-46 → a `PurchaseBill` receipt with a `direct_bill_receipt:{id}` JE → refuse.

Nobody has hit it yet. It is the same shape and wants the same treatment.

## Side finding — the settings screen is not telling the truth

Company 1 on production has `purchases.inventory_recognition_point = 'bill'` stored, yet the GR/IR
accrual posted. Not a bug: `ProcurementPolicy::recognitionPoint()` **forces** `Receipt` whenever
`procurement_mode = controlled` (documented preset). But the settings screen shows the stored
value with no hint that controlled mode overrides it — which is exactly why the owner distrusts
the screen. A badge on the overridden keys, not a code change.

## Side finding [FIN] — GR/IR was posting to an EXPENSE account

Separately tracked, worse than the deadlock: `purchases.grni_account_id` on production pointed at
account 70 = **5403 «خسائر متنوعة»**, `nature = debit` (an expense). The accrual CREDITS it, so a
500,000 goods-received liability was reducing reported losses instead of sitting on the balance
sheet, and no GR/IR liability existed at all. The code accepts any account id without checking its
type. Needs both a validation guard and an owner decision on the correct account (no
«بضاعة واردة لم تُفوتر» account exists under 2101 الذمم الدائنة).

## What was done on 2026-08-14

The stuck production document was **removed** at the owner's explicit instruction — the Purchases
module is still being trialled there, its numbers are not real, and the warehouses run for real on
stock receipts/issues. Backup `/home/prod/db-backups/grn6-rollback-20260814-221123.sql`; script
`scratchpad/remove_grn6.php` (dry-run first, pinned ids, pre-flight refusing to run if a bill had
appeared / the lot had been consumed / later movements existed). Balance restored 1650.240 →
650.240 with `total_value` 500,000 → 0 (exact: the stored `total_value` and the receipt's
contribution were equal), PO back to `confirmed`/`pending`, GRN + receipt + JE + movement + cost
layer + lot deleted, zero orphans.

**That was the instance. The class is still open** — the fix below is what stops it recurring.
