# One currency, one source — Purchases (and the Sales sibling)

> **2026-08-10 · moonui2 · branch `hazemdev2` · [FIN]** — owner-approved verbatim:
> «الاتنين بقي واحد يعني اعدل دي او دي نفس القصه، لان العملة الديفولت في البرنامج بتبقى واحدة».
> Trigger: «العملة في المشتريات مش مظبوطة، المفروض تيجي من السيتنج العام».

## What is actually true (measured on `moonui2_dev_be`, not assumed)

| Source | State | Verdict |
|---|---|---|
| `companies.currency` | company 4 «بلاك سيركل» = **SAR**; every company has a value | **the truth** |
| `currencies` table (`is_base`) | company 4's SAR row has `is_base = 1` — **agrees** with the column | a per-company representation of the same fact, needed for the `currency_id` FK documents |
| `core.default_currency` setting | defined + seeded + shown in the settings screen, **0 rows written, 0 readers in BE and FE** | a pure orphan — the recurring "setting stored and displayed that nobody reads" defect |

So there was never a real conflict to arbitrate: two of the three already agree.
**Source of truth = `companies.currency`.** The Angular client already agrees —
`src/app/core/services/company-currency.service.ts` reads `user.company.currency`, and the
purchase-orders screen already defaults its field from it. This work makes the backend, the
orphan setting, and the two screens that ignore it all say the same thing.

## The defects this closes (all verified in code)

1. **`PurchaseOrderController::convertFromRequest()` hardcodes `'currency_code' => 'KWD'`.** Every
   order raised from a purchase request is stamped Kuwaiti dinar whatever the company trades in.
   Inherited from the pre-existing block, flagged at the WP3 review gate of
   [`purchase-request-partial-conversion`](purchase-request-partial-conversion/LEDGER.md) as
   "worth its own ticket" — **this is that ticket**.
2. **`PurchaseBillController` bill-from-order reads `$order->currency_id` — a column that does not
   exist.** `purchase_orders` has `currency_code` (string) only; no column, not fillable, no
   accessor. Eloquent returns null, so **every bill created from a purchase order carries
   `currency_id = null`**. Two sites: `:535` and `:661`.
3. **`PurchaseReturnController:158-159` falls back to the literal `'KWD'`** — under a comment that
   claims "currency ← company default". The comment was aspirational; make it true.
4. **`SalesReturnController:137`** — the same literal fallback on the Sales side.
5. **The purchase-returns SCREEN hardcodes `currency_code: 'EGP'`** (`returns.component.ts:598` and
   `:693`) while the orders screen beside it correctly uses `CompanyCurrencyService`.
6. **The orphan setting** — unify so editing either surface changes the one value.

## Design

One reader in Core, mirroring the `ProcurementPolicy` single-reader pattern already used for
purchases settings, with two accessors because the documents disagree on representation:

- `code(int $companyId): string` — for `currency_code` documents (orders, returns, sales).
- `id(int $companyId): ?int` — resolves the company's `currencies` row **by that code**, for the
  `currency_id` FK documents (bills, payments).

`id()` must not throw: companies 1, 2, 3, 5 and 6 have **no `currencies` rows at all** and company
4's EGP rows are inactive. `currency_id` is nullable on bills, so null is schema-legal — return
null and log, never auto-create a currency row as a side effect of raising a document.

`exchange_rate = 1` becomes correct **by definition** once the stamped currency is the company's
own, rather than the coincidence it is today.

## Out of scope — recorded, deliberately not done

- Migration column defaults (`default('KWD')` on `purchase_orders`, `purchase_returns`,
  `sales_*`) — harmless once the code always supplies a value; changing them is a schema churn
  with no behavioural gain.
- Factories, and `NumberToWordsService`'s display fallback.
- POS / LIS / WebStore siblings — latent, same pattern, not in the owner's complaint.
- **No backfill of existing rows stamped KWD.** With `exchange_rate = 1` everywhere, those amounts
  are almost certainly SAR numbers wearing a KWD label, so relabelling is *plausible* — but
  rewriting the currency on posted financial documents is the owner's decision, not a default.
  **Ask him.**

---

## 2026-08-19 — the system-wide sweep (this is now DONE, not scoped-out)

The section above deliberately left POS / LIS / WebStore as "latent, same pattern, not in the
owner's complaint". The owner then asked for exactly that: a whole-ERP currency review with direct
correction. Report: [`currency-unification-review.html`](currency-unification-review.html).

**The finding that mattered: there were THREE hardcoded defaults, not one.**
`KWD` in Accounting/Sales/Purchases, `SAR` in LIS (3 sites), `EGP` in WebStore (2 sites) — so one
company could print a voucher, a lab receipt and a store event in three different currencies on the
same day. All ten sites now read `app(CompanyCurrency::class)->code($companyId)`.

**The write path is now closed, which the read-side fix alone never did.**
New trait `Modules/Core/app/Concerns/FillsCompanyCurrency.php` fills an ABSENT currency at
`creating`, applied to the seven models the census proved empty (SalesPayment, PurchasePayment,
PaymentVoucher, ReceiptVoucher, AccountTransfer, PurchaseBill, BankAccount). It never overwrites a
caller's choice, so it cannot change an existing amount's meaning. This is the reason a future
screen cannot reopen the gap — the enforcement is at the write boundary, not on the screens.

**`journal_entry_lines` was deliberately NOT touched** (523/523 null). Proved from
`CurrencyRevaluationService.php:51`: the query is `whereNotNull('currency_id')` AND
`where('currency_id','!=',$baseCurrencyId)` — so NULL and base-currency rows are excluded
*together*. NULL therefore already MEANS "base currency" and is load-bearing. Filling it would be
behaviour-neutral **and** pointless, so it stays. Foreign-currency lines inherit from the source
voucher, and the vouchers are now guaranteed.

**Root cause of the bad master row found and closed:** `StoreCurrencyRequest` validated the code as
`max:10` — any string. That is how `code='EG'` (a typo for EGP) entered company 4's currency list.
Now `regex:/^[A-Z]{3}$/` + uppercased in `prepareForValidation()`. The update request never allowed
editing a code, so this was the only door.

## Still the owner's call (unchanged, now with exact rows)

- **INV-2026-00006** — company 4, 2026-04-28, total 95.000, `exchange_rate=1`, status **paid**,
  `currency_code='EG'`. Rate=1 on an SAR company says the amount is riyals mislabelled.
- **13 invoices stamped KWD** on the same SAR company, total 3,038.900, all rate=1 — the same story
  at scale, and exactly the rows the old `'KWD'` default would have produced.
- **The `EG` currency row** (id=7, inactive) — deletable once the invoice above is decided; must
  stay if the invoice keeps the code.

Relabelling posted, paid documents is not something to do under a "correct it directly" mandate.
The code that CAUSED it is corrected; the history is his decision.

## Measured test baseline — 2026-08-19 (Sales + Purchases + Accounting + POS)

`pest Modules/Sales Modules/Purchases Modules/Accounting Modules/POS` → **1880 passed, 20 failed**
(~66 min wall-clock on a contended box). Every one of the 20 was verified as PRE-EXISTING and
unrelated to the currency work. Recorded here so nobody re-derives it:

| # | Test | Cause |
|---|---|---|
| 6 | `Sales/CogsAtDeliveryCancelTest` (cancel-1,2,3,4,4b,6) | `RuntimeException: No open fiscal period found for the given date` — fixture date drift; the seeded periods no longer cover "today" |
| 1 | `Sales/LotAllocationDeliveryNoteTest` | same fiscal-period drift |
| 8 | `Purchases/PurchasesSettingApiTest` | `UniqueConstraintViolationException` — the known double-seed baseline (already in the purchases KB topic) |
| 2 | `Purchases/StandaloneReceiptGateTest` | GRN-linked receipt gate |
| 1 | `Sales/SalesReturnApiTest` · 1 `Purchases/PurchaseReturnApiTest` | test expects a `branch_id` required-error the API no longer returns (only `customer_id`/`supplier_id`) |
| 1 | `Accounting/CashBoxScopeInvariantTest` | route-coverage invariant — an unscoped cash route needs scoping or a written exemption |

**Change-surface evidence for the currency work:** 260 targeted tests green — every test file that
creates the seven trait-carrying models (`AccountTransferApiTest`, `BankAccountApiTest`,
`CurrencyApiTest`, `PaymentVoucherApiTest`, `ReceiptVoucherApiTest`, `VoucherApproveJeTest`,
`VoucherJournalEntryStatusTest`, `PurchaseBillApiTest`, `PurchasePaymentApiTest`,
`SalesPaymentApiTest`) plus the print and WebStore-analytics tests that exercise the changed read
sites. Zero failures among them.

**Two fixture families are worth fixing on their own merit** (not currency work, not done):
the fiscal-period drift (7 tests) will keep rotting as the calendar advances, and the
PurchasesSettingApiTest double-seed (8 tests) has been failing long enough to be quoted as a
baseline. Together they are 15 of the 20.
