# WP2 — Enforce the POS terminal settings server-side  **[FIN]** **[SECURITY]**

**Repo:** BE `/home/moonui2/moon-erp-be` · **Branch:** `hazemdev2` · **Migration:** none

## Goal

`pos_terminals.settings` holds seven keys. An audit proved **every one of them is decoration**: they are stored, validated, and rendered in the UI, and **not one is read by any backend controller, request, service or action**. Any client that POSTs `/api/pos/sales` directly bypasses all of them. The screen tells a manager the till is constrained when it is not.

Make them real — or, where the concept already has a different owner, delete the duplicate and say so. Do not leave a third state.

## The seven keys, and the decision for each (these decisions are made — implement them, do not re-open)

| Key | Today | Decision |
|---|---|---|
| `allow_discount` | zero BE reads; discounts applied unconditionally | **ENFORCE.** When false → 422 if any line has `discount_percent > 0` **or** the header has `discount_amount > 0`. |
| `allow_price_override` | zero BE reads; FE-only | **ENFORCE.** When false → 422 if a line's `unit_price` differs from the resolved catalogue price for that product/variant/unit. |
| `require_customer` | zero BE reads; BE is *stricter* (`customer_id` is unconditionally `required`) | **ENFORCE the real intent:** the key means "a **named** customer, not walk-in". When true → 422 if `customer_id` equals the company's `pos.default_customer_id`. Leave `customer_id` unconditionally required as it is today. |
| `allow_negative_stock` | zero BE reads; **name collision** — the real guard reads `warehouses.allow_negative_stock` | **DELETE the duplicate.** One home per concept. Remove from `defaultSettings()`, both FormRequests, the FE terminal form, and the model. The warehouse column stays the single authority. Surface the effective warehouse value **read-only** in the terminal screen so the user knows where it lives. |
| `default_tax_rate` | zero BE reads; lines carry `tax_rate_id`; a test comment already calls it "the terminal's legacy `default_tax_rate`" | **DEPRECATE + REMOVE** from `defaultSettings()`, both FormRequests and the FE form. Tax is a real `tax_rate_id` per line — a raw percentage is a locked decision we will not reintroduce. |
| `receipt_header` / `receipt_footer` | zero BE reads; rendered client-side | **KEEP AS FE-ONLY, and document it.** These are display strings; there is nothing for the server to enforce. Add a short comment on `defaultSettings()` saying so, so the next audit does not re-flag them. |
| `show_unit` (undeclared 8th key) | exists only in the FE; survives the round-trip by accident because `settings` has its own `array` rule | **FORMALIZE.** Add to `defaultSettings()` (default `true`) and to both FormRequests as `nullable|boolean`. Display-only, FE-enforced — same comment treatment as the receipt strings. |

## The three conflicting defaults for `require_customer` — unify

- `Modules/POS/app/Models/POSTerminal.php` → `false`
- FE `pos-settings.component.ts` (create default and read fallback) → `true`
- FE `pos-policy.service.ts` → `false`

**Canonical value: `false`.** The model is the source of truth; fix the two FE spots to match.

## Where to enforce

Put the guards where the request is already validated, next to the existing checks:

- `Modules/POS/app/Http/Requests/StorePOSSaleRequest.php` — it already has an `after()`/`withValidator` block that calls `ValidatesMinimumSalePrice`. Add the terminal-policy checks there. The request can resolve the terminal: the payload carries `pos_session_id` → `POSSession` → `terminal_id`. **Resolve the session company-scoped**, never trust a session id blindly.
- Read the merged settings via the model's existing accessor so defaults are always applied — `POSTerminal::getSettingAttribute()` merges `defaultSettings()` with the stored blob. Note `POSTerminalResource` returns the **raw** cast (unmerged), so never read settings off the resource.

## Price resolution for `allow_price_override`

Find how the backend already resolves a line's expected price (start from `Modules/Sales/app/Http/Requests/Concerns/ValidatesMinimumSalePrice.php`, which already scales `min_sale_price` by `factorToBase`, and `Modules/Core/app/Services/UnitConversionService.php`). Reuse it.

Expected price precedence, mirroring the FE's `resolveUnitPrice`: variant price → `product_units.sale_price` for the line's `unit_id` → `products.sale_price × conversion_factor`.

Compare with a tolerance (use the money scale — amounts are `decimal(_,3)`; a `0.001` epsilon is right). The 422 must **name the offending line and both prices**; the cashier's error banner prints the server's sentence verbatim, so it has to be actionable.

## Acceptance criteria

1. With `allow_discount = false`, a sale carrying a line discount 422s; the same sale with `allow_discount = true` posts. Same for a header discount.
2. With `allow_price_override = false`, a sale whose `unit_price` differs from the resolved price 422s naming the line; an equal price posts; and a **non-base unit** (scan a carton) still posts at its own unit price — the guard must not fire on a legitimate unit price.
3. With `require_customer = true`, a sale using the walk-in default customer 422s; a named customer posts. With `false`, walk-in posts.
4. `allow_negative_stock` and `default_tax_rate` no longer appear in `defaultSettings()`, either FormRequest, or the FE terminal form; negative-stock behaviour is **unchanged** (still governed by the warehouse column) — prove with a test that a warehouse permitting negative stock still allows the sale.
5. `show_unit` is declared and validated, and round-trips.
6. The three `require_customer` defaults agree on `false`.
7. **No regression:** a sale with all settings at their defaults behaves exactly as before this WP.
8. `ng build` green.

## Tests

Extend `Modules/POS/tests/Feature/POSTerminalApiTest.php` for the settings shape, and add guard tests to the POS sale suite (`Modules/POS/tests/Feature/POSSaleApiTest.php` or a new `POSTerminalPolicyTest.php`).

**Red-before-green is required for every guard**: write the test, run it against the current code and confirm it FAILS (the sale posts when it should not), then implement, then confirm it passes. Paste both runs in your report. A guard test that was never seen failing proves nothing.

⚠️ **Pest loads every test file into ONE process.** Prefix any top-level helper with its file's subject (e.g. `terminalPolicyPayload()`). A generic name is a fatal redeclare that kills the whole suite — exit 255, zero output. Fourth occurrence in this project was fixed today.

## Out of scope

- Do NOT add any new setting definition or company-level setting — that is Phase C, later.
- Do NOT touch the generic settings tab (`src/app/features/settings/`) — that is WP1's area, already done.
- Do NOT change the POS payment, refund, session or offline paths.
- Do NOT touch `src/assets/i18n/{ar,en}.json` beyond additive keys; **never** `git checkout`/`restore`/`stash` those two files.

## Environment

- BE tests: `/opt/cpanel/ea-php82/root/usr/bin/php -d memory_limit=1G vendor/bin/pest --filter='<TestName>'` (default `php` is php-cgi → "Undefined constant STDOUT").
- Baseline: **174 passed / 14 pre-existing failures**, all in `Modules/WebStore/tests/Feature/StorefrontProductApiTest` (404s). Green = no NEW failures.
- `./vendor/bin/pint` on touched files only. `chown moonui2:moonui2` every file you edit.
- Working tree is deliberately dirty (POS overhaul awaiting the owner's push). Never revert/stash/commit anything you did not write. Do not commit at all — the orchestrator handles that.
- No migrations. Never `migrate:fresh`/`refresh`/`db:wipe`/`RefreshDatabase` against the dev DB.
