# WP4 — close the unguarded back door (defect 1)

**Flags:** no migration · **advisor gate** (tenant isolation + approval bypass).
**Repo:** BE `/home/moonui2/moon-erp-be`, `hazemdev2`. **Depends on:** WP3.

## Goal

There are **two** write paths that mark a purchase request converted, not one. The second is
unguarded and, now that conversion is partial, it is also a way to bypass every quantity
check WP3 just built. Close it.

## The defect, precisely

`PurchaseOrderController::store()` — the ordinary `POST /api/purchases/orders` —
`Modules/Purchases/app/Http/Controllers/PurchaseOrderController.php:168-178`:

```php
// If created from purchase request, mark PR as converted
if ($order->purchase_request_id) {
    $pr = PurchaseRequest::find($order->purchase_request_id);
    if ($pr && $pr->canConvert()) {
        $pr->update([
            'status' => PurchaseRequestStatus::Converted,
            'converted_to_order_id' => $order->id,
            'converted_at' => now(),
        ]);
    }
}
```

`StorePurchaseOrderRequest.php:26` accepts the id with `'purchase_request_id' => ['nullable', 'exists:purchase_requests,id']`.

Four separate problems:

1. **No line correlation whatsoever.** The caller supplies arbitrary `items[]`; nothing
   checks they relate to the request. With WP3 in place this becomes the hole through which
   the remaining-quantity cap is bypassed entirely.
2. **No approval gate.** `assertApprovedForPost()` is never called here, unlike the real
   conversion path (`:516`). A request with a pending approval log can be burned through
   this route.
3. **Not company-scoped.** `exists:purchase_requests,id` matches **any** tenant's request.
   The subsequent `canConvert()` is the only thing standing between a caller and flipping
   another company's request.
4. It writes the whole request to `Converted` regardless of how much was actually ordered.

## Exact changes

Decide between two shapes, and record the reasoning in the commit message:

**(a) Refuse the linkage here.** Reject `purchase_request_id` on `store()` with a 422 that
points the caller at `POST /purchases/requests/{id}/convert-to-order`. Cleanest and makes the
conversion path singular — but it is a **breaking API change** for any client that uses it
today. Check first whether anything does: search the FE repo
(`/home/moonui2/public_html/moon-erp`) for a `purchase_request_id` in an order-create
payload, and check the tests.

**(b) Make it behave like the real path.** Company-scope the `exists:` rule, call
`assertApprovedForPost()`, correlate every submitted line to a request line, enforce the
remaining-quantity cap, increment `converted_quantity`, and derive the status through WP3's
recompute method instead of hard-writing `Converted`.

**Recommendation: (b) if anything actually uses the path, (a) if nothing does.** Establish
which by evidence, not assumption, and say so.

Either way, `converted_at`/status writing here must go through the same model method WP3
added — no second copy of the status logic.

## Acceptance criteria

1. A `purchase_request_id` belonging to **another company** can no longer flip that
   request — assert the request is untouched and the response is 422/403.
2. A request with a **pending approval log** cannot be converted through `store()`.
3. Ordering through `store()` cannot exceed a request line's remaining quantity (if you
   chose (b)), or is refused outright (if you chose (a)).
4. The request's status after this path matches what the real conversion path would produce
   for the same quantities — never a blind `Converted`.
5. Existing tests still pass. If a test exercised the old permissive behaviour, do not just
   delete it — update it to assert the new, correct behaviour and note that in the commit.

## Tests

New file `Modules/Purchases/tests/Feature/PurchaseOrderStoreRequestLinkTest.php` covering
all four criteria. This path currently has **no test coverage at all** — the report
confirmed `store()`'s conversion branch is untested — so these are the first.

⛔ Prefix every top-level helper with `posrl…` (Pest one-process redeclare trap).

## Out of scope

The convert-to-order endpoint itself (WP3) and cancellation (WP5).

## Finish

`pint` touched files · `chown moonui2:moonui2` · `local-deploy.sh` · conventional commit on
`hazemdev2` · no push, no merge. A CHANGELOG bullet only if you chose (a) — a breaking API
change the owner's integrators must know about. If you chose (b), no bullet: it is a
security fix with no visible surface, but record it in the ledger notes.
