# Backend — PROGRESS (Phase 1: Inventory & Supply Chain)

Legend: `[ ]` not started · `[~]` in progress · `[x]` done
Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API)
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.

## 0. Bootstrap
- [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4)
- [x] Folder structure per 00-CORE §5.3
- [x] `ErpDbContext` + Npgsql wired; `InitialCreate` migration **created and applied** (2026-07-10, 8 master-data tables). `/health` → `Healthy`.
- [x] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs` — JWT bearer now validates **RS256** tokens from the external **AuthHex IdP** (issuer `AuthHex` / audience `AuthHexClient` / static RSA public key). v1 endpoints `[Authorize]`-gated via the `ErpAccess` door policy (§6); `/health`, `/api/meta`, Swagger stay anonymous.
- [x] `IUnitOfWork` + `UnitOfWork` (transaction boundary)
- [x] Generic repository base + interfaces
- [x] `ICurrentUser` (audit stamp from token identity claim `nameid`/`sub`) — with AuthHex the actor comes from the `UserId` GUID → local shadow user (`nameid` injected by the §6 provisioning step)
- [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`)

## 1. Master Data
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting (**that endpoint was removed on 2026-07-16** — categories no longer nest; see the entry at the end of this section); `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical.
- [x] Item: entity + config + enums (StockNature [ex-ItemType], TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU
- [x] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
- [x] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert)
- [x] Category — **rebuilt 2026-07-16 as a two-level Category/SubCategory model** (was a self-nesting tree); full CRUD + status + ETag, which it previously lacked entirely
- [x] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`)
- [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
- [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
- [x] Brand master (FR-MD-09) — CRUD + status + ETag; `Item.brandId` nullable FK
- [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only
- [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId`
- [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes
- [x] Item **sale price** (FR-MD-01, 2026-07-22) — nullable `Item.SalePrice` (`numeric(18,4)`); on all Item DTOs (list/detail/create/update), validated `>= 0`. **Sales-only** — never enters GRN/FIFO/ledger. `null` ⇒ sell at stock value. Migration `AddItemSalePrice`. See the 2026-07-22 Done entry.

> ### 2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2)
> Makes real three concepts the frontend had been faking on mock data (`Frontend/erp-system/lib/api/mock-data.ts`), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8.
>
> **Deviations / decisions (all recorded in docs/10 §B.8.4 #11–13):**
> - **`ItemType` enum → `StockNature`.** The Stocked/NonStocked/Service enum was renamed to free the name `ItemType` for the new master entity. FR-MD-01 stays satisfied; the DB column was renamed in-place (`RenameColumn`, data preserved). Blast radius was 4 files — nothing in stock/GRN/costing branches on it.
> - **`CATEGORY.parent_id` removed.** Arbitrary nesting is gone, replaced by a dedicated `SUBCATEGORY` table (exactly two levels). Items now carry **both** FKs; previously the frontend collapsed them (`effectiveCategoryId = subCategoryId ?? categoryId`), losing the parent.
> - **Item types are unlinked to items, deliberately.** No value table, no join — values live only in the client-generated SKU (`BL-100-0003`) and are never parsed server-side. Accepted trade-off (no query-by-colour/size; renaming a type doesn't touch existing SKUs) written up in docs/10 Part C.9. This is *not* a product-variation model; none was requested.
> - **`itemTypesEnabled` is advisory, not enforced.** With no item-type reference on an item there is nothing on a write to reject; only `subcategoriesEnabled`/`brandsEnabled` produce `CONFIG_DISABLED`. Stated plainly in docs/11 §2.8 so it isn't mistaken for a backend guarantee.
> - **`PUT /product-config` is door-policy-gated only** — any ERP-admitted user can flip the flags. A `CONFIG_MANAGE` permission is reserved for when RBAC lands (open decision #13).
> - **`brandId` is now a documented field**, no longer the undocumented frontend-only extra it was.
>
> **Migration #2 (`AddBrandsSubcategoriesItemTypesAndProductConfig`) carries data, not just DDL.** The scaffolded version dropped `parent_id` outright, which would have silently flattened every child category into a root and stranded items on the wrong one. Hand-added: backfill of child categories into `subcategories`, repoint of items onto the correct (category, subcategory) pair, delete of the migrated rows, and the config singleton insert. A recursive CTE maps categories at **any** depth to their root ancestor, since the old model allowed unlimited nesting but the new one is two levels — a grandchild becomes a subcategory of its *root*, not of its (now-nonexistent) parent category. `Down()` was likewise hand-written to restore the tree instead of dropping `subcategories` and losing it.
>
> Also fixed while writing it: the `ck_product_config_singleton` check constraint was scaffolded as `config_id = 1`, but the column is created quoted-PascalCase (`"ConfigId"`) — unquoted, Postgres folds it to a column that doesn't exist. And `UpdateProductConfigRequest`'s flags are `bool?` on purpose: `[Required]` on a non-nullable `bool` is a no-op, so a body of `{}` would have bound all three to `false` and silently switched every feature off.
>
> **Schema/migration verified:** `dotnet build` clean. Migration `Up` **and** `Down` exercised against a purpose-seeded 3-level tree (Hardware → Fasteners → Bolts, plus items on each level and a childless root) — 9/9 forward assertions and 7/7 rollback assertions passed, including the grandchild depth-collapse and `StockNature` data preservation; the fixture was then removed. `DataSeeder` seeds `Color`/`Size` + the config singleton idempotently (it needed restructuring — an early `return` in the reason-code path would otherwise have skipped the new seeds on every start after the first).
>
> **Live smoke test PASSED (2026-07-16), all 24 checks, against Postgres + a real AuthHex session.** Auth note: a token *is* obtainable despite the `loginUser` blocker — **`POST /api/v1/auth/register` succeeds and issues the `erp_at` session cookie directly**, and the JWT handler's cookie fallback means that session authenticates every other controller. (`loginUser` still `500`s "Invalid credentials" for that same freshly-registered user, by username *or* email, with *or* without `userTypeId` — the §6 blocker is real and reproduces, but it is not a barrier to testing.) Registration needs AuthHex-internal `roleId`/`userTypeId` GUIDs, supplied by the user; Admin = role `08de6a11-9e9f-4401-8a10-6859860b41ec` / userType `00000000-0000-0000-0000-000000000004`.
>
> Covered: brand/category/subcategory/item-type create; **case-insensitive duplicate name → 409** (brand, and subcategory scoped per-parent); subcategory under a missing category → 404; item create carrying all three new FKs with SKU `BL-100-0003` → 201 and full round-trip on `GET /items/{id}`; new `brandId`/`subCategoryId` list filters; **cross-FK guard → 422** ("Subcategory 3 belongs to category 7, not 8"); missing/inactive brand → 422; `PUT /product-config {}` → **400** (proving the `bool?` fix — an empty body no longer silently disables everything); `subcategoriesEnabled:false` + `subCategoryId` → **422 CONFIG_DISABLED**, same item without it → 201, and **pre-existing items with a subcategory still read back fine**; `brandsEnabled:false` + `brandId` → 422; **`itemTypesEnabled:false` correctly does NOT block item writes** (advisory, as documented); ETag round-trip 200 / stale-but-well-formed → **412 CONCURRENCY_CONFLICT** (brand + subcategory) / absent → 428; `PATCH /status` → 204 then inactive-brand reference → 422; and **renaming an item type left existing SKUs untouched**, confirming the intended decoupling. Audit stamp confirmed live: `product_config.updatedBy` resolved to a JIT-provisioned shadow user (`SMOKE001`) from the AuthHex `UserId`/`NIC` claims.
>
> Test data was removed afterwards (masters back to empty, config flags restored to all-true with the audit stamp cleared). **Two artifacts left behind on purpose:** the AuthHex user `smoketest_admin` / NIC `SMOKE001` in AuthHex's own MySQL store, and its ERPCore shadow user (`users.UserId = 3`) — referenced by nothing, kept so the session can be reused for future testing. Delete both if unwanted.

## 2. Procurement
> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired.
- [x] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get)
- [x] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix)
- [x] Purchase Order: create (auto-approve **or `saveAsDraft`**, `approvalRequired` flag), edit **Draft-only** (If-Match), **submit** (Draft→Approved), **delete** (Draft-only), approve (no-op), cancel — see the 2026-07-20 entry (FR-PROC-05 revised: draft-lock supersedes edit-while-open)
- [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)

> **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match.
> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly. **Consequence surfaced 2026-07-17:** no UI can show "invited but not yet quoted" — the RFQ screens now report quotations received instead. Persisting the invite list would need a new table.

> ### 2026-07-17 — read endpoints added so the frontend could be connected
> The frontend rewire (see `Frontend/PROGRESS.md`) needed reads that did not exist. `stock-adjustments` and `purchase-returns` had **no GET at all** — a UI could not re-display a record it had just created.
> - **New:** `GET /grns`, `GET /rfqs`, `GET /stock-transfers`, `GET /stock-counts`, `GET /stock-adjustments` **+ `/{id}`**, `GET /purchase-returns` **+ `/{id}`**, `GET /stock/on-hand/list`. All follow `ItemService.ListAsync` (ILike on `q`, filters, `PagedResponse<T>.Create`) with matching `*SummaryDto`s carrying a `lineCount`.
> - **`GET /stock/on-hand/list`** is deliberately set-based — four grouped queries regardless of page size — rather than calling `GetOnHandAsync` per row (N+1). It replaces a client-side loop the mock used to do.
> - **`GET /stock/ledger` gained `sourceDocType`/`sourceDocId`.** The ledger's document reference is polymorphic with no FK to follow, so this is the only way to ask "what did document X post?". Needed by the wastage report to cost its lines; also useful for any document's movement history.
> - **`ItemDetailDto` gained `conversions`** (+ `.Include(i => i.UomConversions)`): they could only be *written* (`PUT /items/{id}/uom-conversions` returns them; nothing read them back), so the item detail screen could never show current state before editing. Closes a deviation `Frontend/PROGRESS.md` had flagged.
> - **DTOs gained fields the entities already had** and the UI needed: `createdBy`/`createdAt` on transfers + counts, `createdAt` on purchase returns, `lineCount` + a `status` filter on requisitions. Cheaper and more honest than deleting working columns from the screens.
> - **Bug fixed — `POST /auth/logout` made `userId` optional.** AuthHex returns `user.userId: null` on login, so a browser could never supply the id the endpoint required; the call was skipped and the session cookies survived, making logout cosmetic. The controller now resolves the id from the token's `UserId` claim and **always** clears the cookies, even if the upstream revoke fails. Found by driving the real logout in a browser.
> - **Verified:** `dotnet build` clean; every new endpoint returns a correct `PagedResponse` against a live cookie session; `conversions` round-trips; `CONFIG_DISABLED` (422), `CONCURRENCY_CONFLICT` (412) and the cross-FK 422 (*"Subcategory 5 belongs to category 10, not 11"*) all confirmed through the browser. Test data removed afterwards.
> - **Not done — serial numbers (FR-GRN-04, priority M):** `CreateGrnLineInput` carries `batch` but has no serial field, so serials cannot be captured on receipt as the requirement mandates. The frontend does **not** collect them rather than silently discarding them. `SERIAL`/`StockLayer.serial_id` already exist in the model, so this is a service+DTO gap, not a schema one. Recorded in docs/11 §4.

## 3. Goods Receipt
> Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate.
- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **defaults to the PO price but is now overridable per line** (variance recorded vs `poUnitPrice` snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. **Discount/VAT added** — see the 2026-07-20 entry.
- [x] **Off-PO lines on a PO-based GRN** (FR-GRN-01, 2026-07-22) — a line with `poLineId: null` on a PO-based GRN is received like a direct line (entered `unitCost`, no over-receipt check, PO balances untouched). **No code change was needed** — `GrnService.CreateAsync` already branches per-line on `input.PoLineId is not null`; documented + frontend-enabled. Same review/audit surface as AR-04 (02-SECURITY C.3).
- [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
- [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).

## 4. Stock Core
> Implemented + **live smoke test PASSED** 2026-07-13: receive→confirm creates FIFO layers + inbound ledger (qtyBase/unitCost/value/runningBalance correct), on-hand/valuation/ledger queries correct, OnHold excluded from `available`, UOM→base conversion applied. Same `[~]` gate (§6 auth+audit).
- [x] StockLayer + StockLedger entities/config — ledger **append-only at the app level** (never updated/deleted); DB-role `UPDATE`/`DELETE` revoke is deferred hardening (02-SECURITY B.3). Layers keyed per item **per warehouse**, base-UOM qty + unit cost; ledger polymorphic source (`sourceDocType`/`sourceDocId`), time-series indexes.
- [x] `FifoCostingService` — inbound layer + ledger posting + valuation **and oldest-first consume with row lock** (`SELECT … FOR UPDATE`, on-hold/expired exclusion, negative-stock block) all implemented + verified 2026-07-13 via §5. Blended cost on multi-layer consume verified (700@10 + 100@12 → 10.25).
- [x] Stock enquiry (onHand / available / onHold / inTransit) — onHand/available/onHold **and inTransit** now live + verified (inTransit = outstanding InTransit-transfer qty out of this warehouse). `reserved` stays a 0 stub until Sales.
- [x] Ledger query · Valuation query — `GET /stock/ledger` (item/warehouse/from/to + paging), `GET /stock/valuation` (open layers, totals, FIFO); both verified.

## 5. Stock Transactions
> **All four §5 features implemented + live smoke test PASSED 2026-07-13** (Adjustment, Transfer, Count, Reorder alerts); Purchase Return (§3.4) also done this pass. Same `[~]` gate (§6 auth+audit).
- [x] Transfer: create → dispatch (consume source FIFO row-locked → In-Transit) → receive (dest layer, **cost-preserving**) — verified: dispatch reduces source onHand + reports inTransit; receive creates dest layer at inherited cost (300 @12 → dest value 3600); `destWarehouseId != srcWarehouseId`→422; dispatch short→`409 STOCK_NEGATIVE_BLOCKED`. Partial receive supported (`QtyReceived`).
- [x] Adjustment (auto-post, mandatory reason code) — **highest-risk feature (02-SECURITY C.5)**: `REASON_CODE_REQUIRED`→400, non-Adjustment reason→422, decrease FIFO-consumes (blended cost, negative→409), increase creates a layer at last cost. All verified.
- [x] Count (cycle/full → enter counts → variance → post) — create snapshots systemQty (immutable), enter sets counted+variance→Counted, post emits a variance `StockAdjustment` via shared `StockMutator` + closes the count. Verified: variance −15 (post→on-hand 485) and +10 increase; re-post→409.
- [x] Reorder alerts (query) + suggest requisition — `GET /stock/reorder-alerts` (available ≤ ROP, computed on read) + `POST …/{itemId}/requisition` (draft PR at suggested qty). Verified.

## 6. Cross-cutting
> **Status: COMPLETE.** Audit trail, doc numbering, reason codes, JournalEntryStub, negative-stock block, and now **authentication** (external AuthHex IdP integration) are all done + verified. The §6 security gate (NFR-03 auth + AR-01 audit) is met — **§1–§5 flipped `[~]`→`[x]`** (2026-07-14). FEFO pick-ordering is the only intentional deferral.
- [x] Audit log on every mutation (who/when/old→new) — `AuditLog` (jsonb `changeSet`), written by an `ErpDbContext.SaveChanges` override (`AuditScribe`): Create captures the field set, Update captures **only changed fields as {old,new}**, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from `ICurrentUser` (system=1 until auth). Read via `GET /audit-logs`. **Verified** (Item create+update old→new; StockAdjustment create). This is the **AR-01 compensating control** (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred.
- [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO.
- [x] Auth: **external AuthHex IdP integration** (2026-07-14) — ERPCore is a resource server. `JwtAuthExtensions` validates **RS256** against AuthHex's RSA **public** key (config `Auth:RsaPublicKeyXml` → `RsaSecurityKey`; `MapInboundClaims=false`), issuer `AuthHex`, audience `AuthHexClient` (no JWKS → static key). `[Authorize(ErpAccess)]` on `ApiControllerBase` gates every v1 endpoint; the `ErpAccess` policy `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` from `Auth:RequiredUserTypeCode`/`RequiredRoleCode` (empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). **Shadow-user JIT provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`) maps the token's `UserId` **GUID** → a local `users` row (`auth_user_id` unique; Username/DisplayName = `NIC`), idempotent, and injects the local `int` id as `nameid` so `ICurrentUser.AuditUserId` resolves the real actor. Migration `AddAuthUserId`. **Verified:** no token→401; `/health`,`/api/meta`,Swagger anonymous; valid token→200; shadow user provisioned (User 2, Username=NIC, AuthUserId=GUID); item Create **audited as the shadow user (id 2, not system)**; re-request reuses the same user; door gate → **403** on UserType mismatch, **200** on match.
- [x] Auth proxy: **`AuthController` fronting AuthHex** (2026-07-16) — the frontend no longer calls AuthHex directly; `Controllers/AuthController.cs` + `Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}` proxy all 24 AuthHex functions (register/login/OTP-login/refresh/sessions/status/lock/change-password/verify-password/logout/update/2FA×5/recovery×4/alt×3) via `Infra/Auth/AuthHex/{IAuthHexClient,AuthHexClient}` (`AuthHex:BaseUrl` config). Sessions delivered as httpOnly Secure `erp_at`/`erp_rt` cookies + `XSRF-TOKEN` double-submit cookie (`Infra/Auth/AuthCookieWriter.cs`, 02-SECURITY §B.2); `ValidateCsrfAttribute` guards every mutating action; the JWT bearer handler now also accepts `erp_at` as a fallback (`JwtAuthExtensions`'s `OnMessageReceived`) so every other v1 controller keeps working unchanged. See `docs/11-BACKEND-PHASE1.md §2.0` for the full route table and `docs/02-SECURITY.md` AR-07/AR-08 for the two carried-over exposures (anonymous `getUserDetails`/`LogoutUser`, no rate limiting yet). **Not done this pass:** CORS (needed once a browser frontend calls these endpoints cross-origin), rate limiting, and the frontend wiring itself (`lib/api/auth.ts` + login/OTP/reset pages) — all deliberately deferred follow-ups.
- [x] JournalEntryStub emitted per stock movement (data only) — `JournalEntryStub` written in `FifoCostingService.PostLedgerAsync` for every ledger entry (In → Dr Inventory `1300` / Cr Clearing `2100`; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via `GET /journal-entries`. **Verified** (GRN In 700, ADJ Out 70).
- [x] Negative-stock policy enforcement (default block) — enforced in `FifoCostingService.ConsumeAsync` → `409 STOCK_NEGATIVE_BLOCKED` (verified). Per-item override still a config stub.
- [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built.
- [x] Reason codes (FR-X-04) — `ReasonCode` entity + `GET/POST /reason-codes`; standard set (docs/10 §B.8.3) seeded idempotently at startup (`DataSeeder`). Verified.

## Deferred (Phase 2+ — do NOT build now, hooks only)
- [ ] Vendor invoice + three-way match
- [ ] Reservation/allocation fulfilment
- [ ] RBAC policy enforcement + approval workflow activation

---

# HRM (Phase 2)

Spec: `docs/12-BACKEND-HRM.md` (model + rules) · `docs/13-BACKEND-HRM-API.md` (API). Security: `docs/02-SECURITY.md §C.8` (run before ticking any HRM feature `[x]` — salary/PII data, see AR-09/AR-10).

### 2026-07-23 — Bug fix: `DateTime Kind=Unspecified` 500 on every user-supplied date (Employee create, Statutory Settings create, etc.)
- **Root cause:** Postgres/Npgsql requires `DateTime` values written to a `timestamp with time zone` column to have `Kind=Utc`. Every Phase-1 `DateTime` was always server-generated (`DateTime.UtcNow`), so this never surfaced before. HRM is the first place user-supplied dates (hire date, salary-structure/statutory-setting effective date, leave/attendance period dates, document issue/expiry dates, …) get deserialized straight from a JSON request body — which produces `Kind=Unspecified` — and then persisted, so **any** create involving a date (`POST /employees`, `POST /payroll-statutory-settings`, `POST /tax-slabs`, `POST /employees/{id}/salary-structure`, `POST /leave-requests`, attendance upload, …) threw a `500` (`DbUpdateException` → `ArgumentException: Cannot write DateTime with Kind=Unspecified...`). Confirmed via `logs/erpcore-20260723.log`.
- **Fix:** a global EF Core `ValueConverter<DateTime, DateTime>`/`ValueConverter<DateTime?, DateTime?>` registered once in `ErpDbContext.OnModelCreating` (applied to every entity property of type `DateTime`/`DateTime?` via `modelBuilder.Model.GetEntityTypes()`), forcing `Kind=Utc` on write. Fixes the bug for every current and future HRM (and Phase-1) entity in one place, rather than patching each service call site individually.
- **No migration needed** — confirmed by scaffolding a migration and finding it empty (`Up`/`Down` both no-ops), then removing it. The converter doesn't change the store type (`timestamp with time zone` throughout), only how the CLR value's `Kind` is normalized before Npgsql sees it.
- **Verified:** `dotnet build` clean (0/0). Not yet re-verified end-to-end against a live AuthHex session (same blocker as the rest of this phase's runtime testing) — the next person to get a token should re-try `POST /employees` and `POST /payroll-statutory-settings` to confirm the `500` is gone.

## 7. Sub-phase 2.1 — Employee + User-link + Documents
> **Code complete + migration applied (2026-07-23).** `dotnet build` clean (0/0); migration `AddHrmPhase1Foundation` generated + applied to the local Postgres DB (new tables only + one nullable `users.Email` column — the scaffolder's "possible data loss" warning is just the benign `UpdateData` setting the seeded system user's `Email` to `null`, not a drop). Runtime smoke-test (Swagger/browser) not yet run — do that before ticking `[x]`, per the §6 security-gate convention §1's note established for Phase 1.
- [x] Org masters: Branch, Department (self-nesting + cycle guard), Designation, EmploymentType, WorkShift — entities + configs + services + controllers (CRUD, ETag, deactivate-not-delete). Routes: `/branches`, `/departments`, `/designations`, `/employment-types`, `/work-shifts`.
- [x] Employee entity + config (EmployeeStatus enum, EmployeeCode uniqueness, all FKs) + service + controller (`/employees`)
- [x] EmployeeBankDetail (one-to-many, IsPrimary) — `GET/PUT /employees/{id}/bank-details` (full-replace)
- [x] `User.Email` column + unique index (Postgres allows multiple NULLs natively, same pattern as `AuthUserId` — no explicit filter needed); `UsersController.Create` now persists it locally (previously silently dropped despite `CreateUserRequest.Email` being required) and returns it on `ManagedUserDto`.
- [x] `EmployeeUserLinkService` + email-lookup endpoints (`GET /employees/email-lookup`, `GET /users/email-lookup`, both advisory/non-mutating) + `POST/DELETE /employees/{id}/link-user` + `linkUserId`/`linkEmployeeId` on the two create endpoints. `Employee.UserId` has a unique index (DB-level one-User-per-Employee guarantee) backed by service-level `EMPLOYEE_ALREADY_LINKED`/`USER_ALREADY_LINKED` (409) checks.
- [x] `HrDocumentType` master (CRUD, deactivate-not-delete) — `/hr-document-types`
- [x] `Infra/Storage/IFileStorageService` + `LocalFileStorageService` (root `App_Data/hr-documents` outside wwwroot, year/month bucketing, path-escape guard, registered as a **singleton** — stateless aside from the configured root)
- [x] `EmployeeDocument` entity + upload/list/download/status endpoints (`POST/GET /employees/{id}/documents`, `GET .../documents/{docId}/download`, `PATCH .../documents/{docId}/status`) — extension allowlist (`.pdf/.jpg/.jpeg/.png/.docx`) cross-checked against declared content-type, size cap from `FileStorage:MaxSizeBytes` (appsettings, default 10MB)

**Deviations (recorded, not silently skipped):**
- **JIT-provisioning Email backfill not implemented.** `ShadowUserClaimsTransformation` still only sets Username/DisplayName from the AuthHex token — the token carries no `Email` claim (confirmed set: `UserId`/`UserTypeCode`/`RoleCode`/`NIC`/`jti`/`iat`), so backfilling it would require an extra AuthHex API call (`getUserDetails`) inside the claims-transformation hot path. Deferred as a follow-up; the primary path (`UsersController.Create`, which already collects `Email` in the request body) covers the common case of an ERPCore-driven user creation.
- **`DOCUMENT_TYPE_IN_USE` error code is defined but not wired to anything** — there is no hard-`DELETE` endpoint for `HrDocumentType` (same deactivate-only convention as every Phase-1 master; FR-MD-08), so nothing currently triggers it. Reserved for consistency with the doc, same posture as Phase 1's unused `MASTER_IN_USE` before transaction tables existed.
- **`EmployeeDocumentService` reads `Stream.Length` for the size-cap check** rather than `IFormFile.Length` directly — works because ASP.NET Core's default `IFormFile.OpenReadStream()` returns a seekable buffered stream, but would need revisiting if a non-seekable upload path is ever added.

## 8. Sub-phase 2.2 — Attendance + Leave
> **Code complete + migration applied (2026-07-23).** `dotnet build` clean (0 errors); migration `AddHrmAttendanceAndLeave` generated (purely additive, no data-loss warning) + applied to the local Postgres DB. Packages added: `ClosedXML` 0.105.0, `CsvHelper` 33.1.0. Runtime smoke-test not yet run.
- [x] LeaveType (master CRUD, `/leave-types`), LeaveRequest (Draft/Submitted/Approved/Rejected/Cancelled, `/leave-requests`, DocNo via `NumberSequenceService` "LV"), LeaveBalance (`GET/PUT /employees/{id}/leave-balances`) — approving a request increments `LeaveBalance.TakenDays` via `ILeaveBalanceService.IncrementTakenDaysAsync`
- [x] WorkShift-based `AttendanceComputationService` (Working/Late/Early/OT minutes; overnight-shift handling; derives Present/HalfDay/Absent/Holiday/WeekOff/OnLeave) — the analog of `FifoCostingService`
- [x] AttendanceUploadBatch + AttendanceRecord entities/config (`WorkShiftId` snapshotted at ingestion per docs A.3)
- [x] Excel/CSV parsing (ClosedXML for `.xlsx`, CsvHelper for `.csv`) + `GET /attendance-batches/template.xlsx`/`?format=csv` — both share `AttendanceUploadService.ColumnNames` so template and parser can't drift
- [x] Upload → validate (employee-code resolution against Active employees, date/time parse, within-batch + cross-batch-confirmed duplicate detection) → confirm pipeline, exact status flow Draft→Validated→Confirmed→UsedInPayroll (`/attendance-batches`, `.../validate`, `.../confirm`)
- [x] Manual record edit (`PUT .../records/{id}`, blocked once Confirmed/UsedInPayroll → `409 ATTENDANCE_BATCH_LOCKED`) + duplicate resolution (`POST .../resolve-duplicate`, keep/discard/supersede) + unlock (`POST .../unlock`, mandatory reason, blocked once `UsedInPayroll`)
- [x] Leave→Attendance OnLeave classification wired in — `AttendanceUploadService` calls `ILeaveRequestService.FindApprovedLeaveCoveringAsync` per record during upload and re-computation

**Deviations (recorded):**
- **LeaveRequest.DaysCount is a calendar-day count** (`EndDate − StartDate + 1`), not business-day/holiday-aware. Flagged as a simplification in the service's own doc comment — a real deployment will want to exclude weekends/holidays from paid-leave day counts before this feeds Payroll.
- **No `Holiday` calendar entity exists yet** — `AttendanceComputationService.Compute` always receives `isHoliday: false`; only `WeekOff` (derived from `WorkShift.WorkingDaysMask`) and `OnLeave` are currently distinguishable from a plain `Absent`. A company-holiday calendar is a natural near-term addition, not built in this pass.
- **`ResolveDuplicateAsync`'s "supersede" action does not yet locate/mutate the prior confirmed record** — it currently just accepts the new row as Valid. The prior confirmed `AttendanceRecord` this is meant to supersede is not looked up or flagged; this needs a follow-up pass before "supersede" is safe to expose to non-admin users in the UI.

## 9. Sub-phase 2.3 — Payroll
> **Code complete + migration applied (2026-07-23).** `dotnet build` clean (0 errors); migration `AddHrmPayroll` generated + applied to the local Postgres DB. Runtime smoke-test not yet run.
- [x] SalaryComponent master (`/salary-components`) — Earning/Deduction, IsTaxable, IsEpfEtfApplicable
- [x] EmployeeSalaryStructure (+Lines), effective-dated (`GET/POST /employees/{id}/salary-structure`) — creating a new structure automatically supersedes the previous open-ended one (`EffectiveTo` set the day before the new `EffectiveFrom`), `409 SALARY_STRUCTURE_OVERLAP` if the new date isn't after the current one
- [x] EmployeeLoan (+Installments) ledger (`GET/POST /employees/{id}/loans`) — creating a loan generates its full installment schedule up front; `IEmployeeLoanService.GetDueInstallmentsAsync` is what `PayrollCalculationService` consumes
- [x] PayrollStatutorySetting (`/payroll-statutory-settings`), TaxSlab (`/tax-slabs`) — both effective-dated; creating a new statutory setting supersedes the prior open-ended one; tax slab creation validates no gap/overlap for the same effective date (`422 TAX_SLAB_GAP_INVALID`)
- [x] `PayrollCalculationService` — Gross = Basic + allowance lines + OT; Net = Gross − Late − NoPay − Loan − EPF(employee) − Tax − OtherDeductions; **EPF-employer/ETF are informational-only, never subtracted**, matching the spec's "Company Contribution" framing; Tax via standard ascending marginal-slab computation over taxable earnings
- [x] PayrollRun/PayrollLine/PayrollLineComponent + Draft→Approved→Locked workflow (`/payroll-runs`, generate/approve/lock/unlock/generate-payslips) — Generate blocked (`422 ATTENDANCE_NOT_CONFIRMED`) if any attendance batch for the period is still Draft/Validated; loan-installment (Pending→Deducted, balance decremented) and attendance-batch (Confirmed→UsedInPayroll) stamping deferred to **Lock**, not Generate/Approve, per docs A.4
- [x] Unlock (`POST .../unlock`, mandatory reason) — reverses both the loan-installment and attendance-batch stamps made at Lock, back to Approved
- [x] Payslip generation (Locked-only, idempotent) + HTML print view (`GET /payslips/{id}/view`) — no PDF dependency, per the confirmed decision

**Deviations / open items (recorded, not silently assumed):**
- **The exact APIT taxable-income base is a real compliance question, not resolved here** (docs/12-BACKEND-HRM.md B.4 flags this explicitly) — `PayrollCalculationService` computes taxable income as `Basic + taxable allowance lines + Overtime` and applies the configurable `TaxSlab` table as a standard ascending marginal calculation; whether EPF-employee should reduce taxable income first, or whether OT should be taxable at all, needs finance/statutory sign-off before go-live.
- **NoPayAmount currently only counts plain `Absent` days**, not unpaid-leave days — `AttendanceRecord` doesn't yet carry which `LeaveType` covered an `OnLeave` day (or whether it's paid), so all `OnLeave` days are currently treated as paid. A follow-up should either stamp the record with the leave's paid/unpaid flag at attendance-computation time, or join back to `LeaveRequest`/`LeaveType` during payroll calculation.
- **OT/Late per-minute rate model**: `dailyRate = Basic ÷ daysInMonth`, `perMinuteRate = dailyRate ÷ WorkShift.StandardWorkingMinutes` — a simplification flagged in `12-BACKEND-HRM.md B.4` (tiered late/OT policies are a future improvement, not built now).
- **`PayrollRunService.GenerateAsync` skips employees with no effective salary structure** for the period rather than failing the whole run — intentional (a partially-onboarded workforce shouldn't block payroll for everyone else), but means a run's employee count can silently be less than total active headcount; worth surfacing in the frontend as a warning list.
- **Loan-installment/attendance-batch reversal on Unlock re-derives "what this run touched" by period/status query**, not from a stored per-run link table (e.g. any `Confirmed`-turned-`UsedInPayroll` batch for the run's period, any installment whose `PayrollRunId` matches). This is correct for the common case but would need a real link if multiple concurrent runs ever target overlapping periods/branches — not expected in this phase (one run per period/branch).

## 10. Sub-phase 2.4 — Reports
> **Code complete (2026-07-23).** `dotnet build` clean (0 errors). No new entities/migration — pure read-only aggregation over Attendance/Payroll/Leave/Document tables (`IHrReportService`/`HrReportsController`, `/reports/hrm/*`), same posture as `StockController`'s on-hand/ledger queries. Runtime smoke-test not yet run.
- [x] Attendance summary (`GET /reports/hrm/attendance-summary?periodYear=&periodMonth=&departmentId=`), OT report (`.../overtime`), late-arrival report (`.../late-arrivals`)
- [x] Payroll register (`.../payroll-register?payrollRunId=`) — `TotalDeductions` computed as `GrossSalary − NetSalary` (informational EPF-employer/ETF already excluded since they were never subtracted from Net)
- [x] Employee salary history (`.../salary-history?employeeId=`) — full `EmployeeSalaryStructure` revision history, ordered newest first
- [x] Leave balance report (`.../leave-balances?year=`), document expiry report (`.../document-expiry?withinDays=`)

## Done
<!-- move [x] items here with date + note if the active list grows long -->

### 2026-07-22 — Item fixed sale price + GRN off-PO items (migration `AddItemSalePrice`)
- **Item sale price (FR-MD-01).** New nullable `Item.SalePrice` (`numeric(18,4)`, `ItemConfiguration.HasPrecision(18,4)`), threaded through `ItemListItemDto`/`ItemDetailDto`/`CreateItemRequest`/`UpdateItemRequest` (`[Range(0, …)]`) and mapped in `ItemService` (create/update/`ToDetail`/list projection). **Sales-only** — it never touches `GrnService`, FIFO, `StockLayer`, or the ledger, so receipt/costing behaviour is byte-for-byte unchanged. `null` ⇒ "use stock value"; the fixed-vs-stock choice is a frontend toggle, not a server field (no `price_mode` enum). docs/10 C.1/C.9 + decision #14, docs/11 §2.1, 02-SECURITY C.1.
- **GRN off-PO items (FR-GRN-01).** A PO-based GRN may now carry lines with `poLineId: null` (item not on the PO). **No backend change** — `GrnService.CreateAsync` already routed such lines through the direct-receipt path (entered cost, no over-receipt check, no PO-balance update). Documented as intended behaviour; the frontend now exposes it. docs/10 FR-GRN-01/C.3 (`po_line_id` nullable) + decision #15, docs/11 §4.1, 02-SECURITY C.3.
- **Migration** `AddItemSalePrice` — single nullable column add; no backfill (`Down()` drops it). **Applied** to the local DB (`dotnet ef database update` → Done).
- **Verified:** `dotnet build` clean (compile succeeded; the only earlier failure was the running dev exe holding a file lock, resolved by stopping it). Migration Up applied. Frontend `tsc --noEmit` clean. End-to-end runtime smoke (Swagger/UI) still to be run by the user.
- **PO draft lifecycle (FR-PROC-05 revised).** `CreatePurchaseOrderRequest.SaveAsDraft` (default `false` → auto-approve unchanged; `true` → `Draft`). New `POST /purchase-orders/{id}/submit` (Draft→Approved, else `409 PO_NOT_EDITABLE`) and `DELETE /purchase-orders/{id}` (Draft-only, else 409). `IsEditable` narrowed from "not FullyReceived/Closed/Cancelled" to **`Draft` only** — so `PUT` now 409s on any submitted PO. **Option B ("freely edit while open") is superseded**; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ **Every pre-existing PO is `Approved` and therefore now uneditable/undeletable** — intended, not a regression. No schema change (reuses the existing `Draft` enum value).
- **GRN discount + VAT + price override.** `GrnLine` gained `PoUnitPrice`(nullable snapshot), `DiscountPct`, `NetUnitCost`, `VatPct`, `VatAmount`, `LineTotal`. All derived figures **server-computed**, never client-supplied. FIFO layer + ledger now cost at **`NetUnitCost`** (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, `unitCost` defaults to the PO price but an entered override wins and a **variance** is recorded against `PoUnitPrice` (02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked via `openQty`/`QtyReceived` and is untouched.
- **Migration** `AddGrnPricingAndPoDraft` — hand-added a data backfill (`UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue`) so existing GRN lines stay consistent with their already-posted FIFO layers; `PoUnitPrice` left NULL for historical rows (no retroactive variance). `Down()` drops the six columns cleanly.
- **Verified:** `dotnet build` clean (0/0); migration **Up and Down** exercised against the live DB (rollback to `AddRolesNavPermissions` then re-apply — both `Done`). **Runtime end-to-end PASSED — 22/22 assertions** (Node script, register→cookie session): PO draft→edit→submit→edit/delete-locked (409), draft delete (204→404), plain create still auto-approves; **costing proof** (100 @10, 10% disc, 18% VAT → net 9.00, receivedValue 900, VAT 162, lineTotal 1062, **FIFO layer @9.00, valuation 900 — VAT absent from stock**); multi-GRN 20@10/50@11/30@12 → variances +50/+60, PO FullyReceived, blended valuation 2010.

### 2026-07-20 (2) — Procurement sidebar submenu (migration `AddProcurementSubNav`)
- The sidebar submenu is driven by seeded `SubNavItem` rows + `GET /auth/me` navCodes; only Products/Settings had children, so **Purchase Orders had no sidebar section**. Added 4 `SubNavItem`s (ids 9–12, `NavItemId 4`) + 4 `Permission`s (ids 19–22) for Requisitions/RFQs/Purchase Orders/Purchase Returns via `AddProcurementSubNav`. The migration also grants the 4 to any role already holding the parent `NAV:procurement` (raw SQL, `ON CONFLICT DO NOTHING`); `Down()` removes the grants then the rows.
- **Found:** the `Admin` role (`RoleId 2`) was never granted `NAV:procurement` at all (nor Vendors), so its whole Procurement branch was hidden — granted the parent + 4 children directly. **Verified:** `/auth/me` for Admin returns `procurement` + all 4 children; frontend `tsc`/`eslint` clean.

### 2026-07-09 — Bootstrap verified + Master Data (§1) implemented
- Bootstrap scaffolding confirmed against 00-CORE §5 (solution, packages, `Program.cs` wiring, UoW, generic repo, `ICurrentUser`, ProblemDetails handler). Added enum-as-string JSON (`JsonStringEnumConverter`) and registered the 5 master-data services.
- Domain: 3 enums (`ItemType`, `TrackingMode`, `EntityStatus`) + 8 entities (Category, Uom, UomConversion, Item, ItemReorder, Vendor, Warehouse, Bin) with one `IEntityTypeConfiguration` each; FKs `Restrict` (masters deactivate, not cascade-delete), unique indexes (SKU, vendor/warehouse code, uom name, bin code per-warehouse), decimal precision, `xmin` concurrency token on Item/Vendor.
- API: 5 controllers, lowercase routes matching `docs/11 §2` exactly (verified via generated `swagger.json`). ETag/If-Match (428 if missing, 412 on mismatch), narrow request DTOs (no over-posting), `PagedResponse<T>` list envelope (§1.4), `PageQuery` with pageSize clamp ≤200 (B.6).
- Migration `InitialCreate` generated (`xmin` correctly produces no DDL — uses the PG system column).
- **Verified:** `dotnet build` clean (0 warn/0 err); app boots (`Now listening… Application started`); `/api/meta` 200; `swagger.json` 200 with all 13 master-data paths; DI resolves controller→service→repo→DbContext (a DB-backed call reaches Npgsql, failing only on creds).
- **Blocked / follow-ups:** (1) auth enforcement + audit trail — §6 (the remaining security gate for `[x]`); (2) no `DELETE` master endpoints — `MASTER_IN_USE` code reserved until transaction tables exist (deactivate-only per FR-MD-08); (3) minor: bad-enum bind error leaks the CLR type name in `detail` (02-SECURITY B.5) — fine in Dev, tidy before prod.

### 2026-07-10 — Migration applied + live smoke test PASSED
- `dotnet ef database update` applied `InitialCreate` to local Postgres; `/health` → `Healthy`.
- End-to-end curl smoke across all 5 controllers — all green: warehouse/bin create+list; uom create; category + child + `?tree=true` nesting; vendor create + PUT (If-Match 200 / stale 412 / missing 428); item create (201, referencing category/uom/vendor) + GET (ETag header) + list/filter `q` + `pageSize=9999`→clamped 200; reorder PUT; uom-conversions PUT; full item PUT with fresh ETag→200; PATCH status Inactive→204; duplicate SKU→400 `SKU_DUPLICATE`; bad reference→422; missing required→400 ValidationProblemDetails; bad enum→400. Concurrency token (`xmin`) confirmed incrementing per mutation.
- Note: local dev DB now holds smoke-test rows (warehouse/bin/uom×2/category×2/vendor/item, item left Inactive). Reset any time with `dotnet ef database drop -f && dotnet ef database update`.

### 2026-07-10 — Procurement (§2, minus returns) + cross-cutting foundations
- Cross-cutting: `User` entity (+ seeded `system` user via `HasData`), `ICurrentUser.AuditUserId` (numeric actor, system fallback), `NumberSequence` + `NumberSequenceService` (atomic per-type/per-year doc numbers issued inside the UoW txn).
- Procurement: enums (RequisitionStatus, RfqStatus, PurchaseOrderStatus); 8 entities (Requisition/Line, Rfq/Line, VendorQuotation/Line, PurchaseOrder/Line) + configs; DTOs; 3 services; 3 controllers (`/requisitions`, `/rfqs`, `/purchase-orders`). PO carries the `xmin` ETag token; totals computed server-side; create/edit wrapped in `IUnitOfWork.ExecuteInTransactionAsync` so the reserved doc number rolls back with the doc.
- Migration `AddProcurement` generated + applied (10 tables incl. users/number_sequences; system-user seed; PO `xmin` emits no DDL).
- **Verified:** build clean; app boots; full procurement smoke green (see §2 note) — requisition→submit, RFQ→quotation→comparison, PO create/get/edit/approve/cancel, numbering increment, error paths 409/412/422.
- **Deferred/next:** Purchase Return (needs GRN+stock), then §3 GRN, §4 Stock Core (FIFO/ledger), §5 stock transactions. Auth/audit (§6) still the gate for flipping §1/§2 to `[x]`.
- Housekeeping: an empty user-created migration `20260709124415_initial` sits between InitialCreate and AddProcurement (applied, harmless no-op; emits a cosmetic CS8981 lowercase-name warning).

### 2026-07-13 — Stock Core (§4) + Goods Receipt (§3, minus purchase return)
- Entities: Batch, Serial, StockLayer (FIFO), StockLedger (append-only), Grn/GrnLine + configs; enums Direction, HoldStatus, GrnStatus. DbSets + migration `AddStockAndGrn` (6 tables) applied.
- Services: `FifoCostingService` (Services/Stock — inbound layer, ledger post, on-hand, valuation), `StockService` (enquiry/ledger/valuation), `GrnService` (create with PO-derived cost + over-receipt + batch resolve + UOM→base; confirm in `ExecuteInTransactionAsync`; release/reject). Controllers `/stock`, `/grns`.
- **Verified against Postgres:** PO→GRN receive (client cost ignored, PO price used) → confirm → 2 FIFO layers + 2 ledger rows (running balance) → PO FullyReceived; on-hand 5000 / valuation 62500 / ledger In; **idempotent re-confirm** (on-hand stayed 5000); over-receipt `422` at the open-qty boundary, exact-fill `201`; batch receive **OnHold** → excluded from `available` → **release** → available; **UOM conversion** 10×Box-12 → 120 base units @10 (value 1200).
- **Deferred (next):** §5 stock transactions — Transfer (dispatch/receive, in-transit, cost-preserving), Adjustment (auto-post + reason code), Count, Reorder alerts — which is where the FIFO **oldest-first consume + row lock** lands; then Purchase Return (§3.4) and GRN reject→return linkage. `ReasonCode` (§6) needed for adjustments/returns. Auth/audit (§6) remains the `[x]` gate.

### 2026-07-13 (2) — Stock Transactions (§5: Adjustment, Transfer, Reorder) + FIFO consume + Reason codes
- **FIFO consume engine** in `FifoCostingService.ConsumeAsync`: oldest-first, row-locked via `SELECT … FOR UPDATE` (raw SQL, no LINQ composed on top so the lock reaches PG; `{batchId}::bigint` cast avoids a 42P18 null-param error), excludes on-hold + expired layers, throws `STOCK_NEGATIVE_BLOCKED`/`ONHOLD_NOT_ISSUABLE`/`EXPIRED_BATCH_BLOCKED`, returns consumed segments for cost-preserving moves.
- Entities: ReasonCode, StockAdjustment/Line, StockTransfer/Line (+ `UnitCost`/`QtyReceived` extension on the transfer line for cost-preservation) + configs; enums TransferStatus, AdjustmentStatus, ReasonContext. Migration `AddStockTransactions` (5 tables) applied.
- Services/controllers: `ReasonCodeService` (`/reason-codes`, startup seed via `DataSeeder`), `AdjustmentService` (`/stock-adjustments`), `TransferService` (`/stock-transfers` create/dispatch/receive), `ReorderService` (`/stock/reorder-alerts` + suggest-requisition). `StockService.GetOnHandAsync` now computes real inTransit.
- **Verified against Postgres:** adjustment decrease FIFO-consume with blended cost 10.25 across two layers, negative→409, `REASON_CODE_REQUIRED`→400, wrong-context reason→422, increase-at-last-cost; transfer create→dispatch (source onHand↓, inTransit↑, consumedLayers)→receive (dest layer cost-preserved @12, value 3600), dest==src→422, dispatch-short→409; reorder alerts list + draft-PR suggestion; reason codes seeded (5 Adjustment + 4 Return).
- **Deferred (next):** §5 **Count** (create snapshot → enter counts → post variance via the Adjustment engine), **Purchase Return** (§3.4, + GRN reject→return linkage), FEFO pick ordering, `JournalEntryStub`. Auth/audit (§6) remains the `[x]` gate.

### 2026-07-13 (3) — Count (§5.6) + Purchase Return (§3.4) + shared StockMutator
- **`StockMutator`** (Services/Stock): shared signed-delta poster (negative → FIFO consume; positive → layer at last cost) + ledger, run inside the caller's txn. Adjustment/Count-post/Return all delegate to it → one code path for stock-affecting postings.
- Refactored `AdjustmentService` onto `StockMutator` and added an **intermediate `SaveChanges`** so the header id is flushed before ledger posting — fixes a latent bug where new-in-txn documents wrote `sourceDocId=0` (verified: `ADJ` ledger now `sourceDocId=5`). Also fixed `ledgerRefs:[0]` by mapping ledger ids **after** commit (Count + Return).
- Entities: StockCount/Line (+ CountType, CountStatus), PurchaseReturn/Line (+ ReturnStatus) + configs. Migration `AddCountsAndReturns` (4 tables) applied.
- Services/controllers: `CountService` (`/stock-counts` create/enter/post), `PurchaseReturnService` (`/purchase-returns`).
- **Verified against Postgres:** count snapshot 500 → counted 485 → post variance −15 (on-hand→485, adjustmentId+ledgerRefs), positive variance +10, re-post→409; return 100 outbound (on-hand→385), `REASON_CODE_REQUIRED`→400, non-Return reason→422, over-return→409 `STOCK_NEGATIVE_BLOCKED`.
- **§5 is now complete.** Remaining Phase-1 backend: §6 (auth/login + `[Authorize]`, audit trail, `JournalEntryStub`, negative-stock per-item override, FEFO pick ordering). Auth/audit is still the gate for flipping §1–§5 `[~]`→`[x]`.

### 2026-07-13 (4) — §6 audit trail + JournalEntryStub (auth deferred by request)
- **Audit trail (FR-X-02):** `AuditLog` (jsonb `changeSet`) written by an `ErpDbContext.SaveChanges/Async` override via `AuditScribe` — captures before save (accurate old→new), writes rows after inserts get their keys. Create = field set, Update = only changed fields `{old,new}`, Delete = prior row; excludes PK/RowVersion and the ledger/layer/sequence/journal/self tables. Actor from `ICurrentUser` (system=1). `ErpDbContext` now takes `ICurrentUser` (design-time migration still works via DI).
- **JournalEntryStub (FR-STK-13):** emitted for every ledger entry in `FifoCostingService.PostLedgerAsync` (In → Dr `1300`/Cr `2100`; Out reverses; amount = value). Placeholder GL accounts.
- Read endpoints (auditor role, beyond documented §11): `GET /audit-logs` (entityType/entityId/userId/from/to), `GET /journal-entries` (sourceDocType/sourceDocId). `AuditService`. Migration `AddAuditAndJournal` (2 tables, jsonb) applied.
- **Verified against Postgres:** item Create logged full field set (userId 1); item Update logged only `Name` + `UpdatedAt` as `{old,new}`; GRN confirm → journal In Dr1300/Cr2100 amount 700; adjustment decrease → journal Out Dr2100/Cr1300 amount 70; StockAdjustment Create audited.
- **Only auth remains for Phase 1.** Everything else in §6 is done. Auth (~~`POST /auth/login` + global `[Authorize]`~~ — **superseded 2026-07-14**, now external **AuthHex** IdP integration; see the next entry) is intentionally deferred per request; wiring it is what flips §1–§5 `[~]`→`[x]`. FEFO pick-ordering left as a documented deferral (would conflict with FIFO-costing integrity without a physical/cost layer split); negative-stock stays the resolved global block (open-decision #2).

### 2026-07-14 — Auth architecture change: external AuthHex IdP (docs-only pass)
- **Plan changed:** auth is no longer a local `POST /auth/login` inside ERPCore. A **separate AuthHex IdP** (runs on `:5011`, source at `c:\Users\WAS\Documents\Developments\ERP_Auth_Service\`) owns login/registration/recovery; ERPCore becomes a **resource server** that only validates AuthHex tokens. Updated `docs/10-BACKEND-PHASE1.md` (header, A.4 auth/audit-actor, A.5 DI, B.2.3, FR-X-01, NFR-03, C.7 `USER`, C.9, B.8.4 decision #10) and this file. **No code changed this pass.**
- **Decisions (confirmed):** (1) identity = **shadow-user JIT provisioning** — add `auth_user_id` GUID (unique) to `users`, keep all `int` FKs; (2) authorization = **door-gate to an ERP `UserType`/`Role`**, per-endpoint RBAC still deferred; (3) scope = **docs only** now, code integration is a follow-up.
- **Confirmed AuthHex facts:** RS256 (RSA 2048; ERPCore needs the static **public** key — no JWKS), issuer `AuthHex`, audience `AuthHexClient`, lifetime 1000 min prod / 60 min dev; claims `UserId`(GUID)/`UserTypeCode`/`RoleCode`/`NIC`/`jti`/`iat` (no `sub`/`nameid`); BCrypt password hashing; login `POST /api/loginUser {identifier,password}`.
- **Open blockers (resolve before the code phase):** exact ERP `UserTypeCode`/`RoleCode` for the door gate (must exist in AuthHex); RSA public-key distribution + rotation process (no JWKS); shadow-user `Username`/`DisplayName` source (token has no name); secrets hygiene in AuthHex config (private key/SMTP/DB in plaintext); `docs/11 §2.0` still documents `/auth/login` (now AuthHex-owned) — recommend a follow-up annotation.

### 2026-07-14 (2) — Auth code integration: AuthHex resource server (§6 COMPLETE → §1–§5 flipped `[x]`)
- **RS256 validation:** `JwtAuthExtensions` rewritten — `RsaSecurityKey` from `Auth:RsaPublicKeyXml` (AuthHex public key), `ValidIssuer=AuthHex`, `ValidAudience=AuthHexClient`, `ValidAlgorithms=[RS256]`, `MapInboundClaims=false` (keeps `UserId`/`UserTypeCode`/`RoleCode` verbatim). `appsettings.json` `Jwt`→`Auth` (public key + issuer/audience + `RequiredUserTypeCode`/`RequiredRoleCode`); removed the HS256 dev signing key.
- **Door policy** `ErpAccess`: `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` when configured (AuthHex is ERP-dedicated → empty default = any valid token). `[Authorize(ErpAccess)]` on `ApiControllerBase`; `MetaController`/health/Swagger stay anonymous.
- **Shadow-user provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`, scoped) maps token `UserId` GUID → local `users` row (`auth_user_id` unique, Username/DisplayName=`NIC`), idempotent w/ race-safe re-read, injects local `int` id as `nameid`. `User.AuthUserId` (Guid?) + `AuthHexClaims` consts + migration `AddAuthUserId`.
- **Verified (minted AuthHex-shaped RS256 token, signed with AuthHex's real private key):** no token→401; `/health`,`/api/meta`,Swagger→200 anon; valid token→200; POST item→201 **audited as shadow user id 2** (Username=NIC, AuthUserId=GUID), not system; repeat request reuses user (1 provision); door gate `RequiredUserTypeCode=WAREHOUSE` → ERP-type token **403**, WAREHOUSE-type token **200**. Build clean; migration applied.
- **§6 COMPLETE.** Only intentional deferral left in Phase 1: FEFO pick-ordering (§6, `[~]`). Follow-ups: set the real ERP `Auth:RequiredUserTypeCode`/`RoleCode` for production; secure the RSA key rotation process.

### 2026-07-16 — Auth proxy: `AuthController` fronting AuthHex (frontend no longer calls AuthHex directly)
- **Architecture reversal:** the 2026-07-14 "resource-server-only, no login proxy" decision (docs/10, docs/11 §2.0) is reversed — the frontend was found to have **zero** existing AuthHex integration (login/OTP/reset screens were UI-only mocks with no network calls), so this was greenfield backend work, not a migration. `docs/10-BACKEND-PHASE1.md` (header, A.4, NFR-03) and `docs/11-BACKEND-PHASE1.md §2.0` updated in place; `docs/02-SECURITY.md` gained AR-07/AR-08 and ticked 3 of 5 B.2 boxes.
- **`Infra/Auth/AuthHex/`** — `IAuthHexClient`/`AuthHexClient` (typed `HttpClient`, `AuthHex:BaseUrl` config = `http://localhost:5011` dev), one C# method per AuthHex `functionName`, hides the `{functionName,payload,reference}`/`{statusCode,success,message,data}` dispatcher envelope entirely; upstream failures → `DomainException` (`AUTH_UPSTREAM_ERROR`/`AUTH_SERVICE_UNAVAILABLE`).
- **`Dtos/Auth/*`** — REST-shaped request/response DTOs per function (not a functionName-dispatcher passthrough), matching ERPCore's existing DTO-at-boundary convention. Session-issuing responses (`AuthSessionResponse`, `OtpLoginVerifiedResponse`) deliberately omit tokens.
- **`Services/Auth/{AuthUserService,AuthRecoveryService,AuthAltService}`** — orchestrate `IAuthHexClient` calls; `AuthSessionResult`/`OtpAuthSessionResult` (`Services/Auth/AuthSessionResult.cs`) carry tokens from service → controller only, never serialized.
- **`Controllers/AuthController.cs`** — `api/v1/auth/*`, 24 actions (see `docs/11 §2.0` table); inherits `ControllerBase` directly (not `ApiControllerBase`) since most actions need `[AllowAnonymous]` and its ETag/If-Match handling doesn't apply here.
- **Cookie/CSRF (`Infra/Auth/AuthCookieWriter.cs`, `ValidateCsrfAttribute.cs`, `JwtAuthExtensions.cs`):** `erp_at` (Path `/`), `erp_rt` (Path `/api/v1/auth/refresh-token`, scoped so it's only sent to the refresh call), `XSRF-TOKEN` (non-httpOnly) — all `HttpOnly`(except CSRF)/`Secure`/`SameSite=Strict`. `ValidateCsrfAttribute` double-submit-checks `X-XSRF-TOKEN` against the cookie on every mutating action, exempting Bearer-header callers. `JwtAuthExtensions`'s `OnMessageReceived` falls back to the `erp_at` cookie when no `Authorization` header is present — every existing v1 controller keeps working unchanged under either auth mode.
- **Verified:** `dotnet build` clean (0 warn/0 err) after two passes — first pass hit `CS0051` (a public interface/constructor can't expose an `internal` parameter type) on `IAuthHexClient` and its supporting `AuthHex*` wire types, fixed by making them `public`; second pass caught a cookie-path bug (`erp_rt`'s `Path` was written as `/api/auth/refresh-token`, not matching the actual `/api/v1/auth/refresh-token` route — the browser would never have sent the cookie back on refresh) before it shipped.
- **Not done this pass (tracked as follow-ups, not silently skipped):** CORS (needed once a browser frontend calls cross-origin — `docs/02-SECURITY.md §B.2` left unticked), rate limiting on the anonymous endpoints (`docs/02-SECURITY.md` AR-08), and the frontend wiring itself (`lib/api/auth.ts` + wiring `app/login/**`'s mock pages to these endpoints) — deliberately out of scope per user decision.
- **Live-verified against the running AuthHex instance (`:5011`) and ERPCore (`:5224`, dev):** `register` → `200` with `Set-Cookie: erp_at`(httpOnly/Secure/Strict/maxAge=3600) + `erp_rt`(httpOnly/Secure/Strict/Path=`/api/v1/auth/refresh-token`/30d) + `XSRF-TOKEN`(Secure/Strict, JS-readable), body carries `user`+`expiresIn` only, **no tokens**; the `erp_at` cookie alone (zero `Authorization` header) authenticated `GET /api/v1/items` — confirms the `OnMessageReceived` cookie fallback works for every existing v1 controller unchanged; `GET /api/v1/auth/sessions` (protected, cookie-authenticated) → `200`; mutating `POST /api/v1/auth/change-password` without `X-XSRF-TOKEN` → `403 CSRF_TOKEN_MISMATCH`, with the matching header → `204` + all three cookies cleared, exactly as designed.
- **Found + fixed a real bug during live testing:** `AuthHexClient` trusted the envelope's `success` flag alone; AuthHex was observed returning **`HTTP 500` with `"success": true, "data": null`** on a business failure (invalid-credentials login), which slipped past the `!envelope.Success` check and null-derefed inside `AuthUserService.ToSessionResult` (`NullReferenceException` → bare unhandled `500`, no `code`). Fixed `AuthHexClient.CallAsync` to also fail on `!httpResponse.IsSuccessStatusCode` regardless of `envelope.Success`, plus added `result is null` guards in `ToSessionResult`/`ToOtpSessionResult`/`AuthAltService.VerifyOtpAsync` as defense-in-depth. Re-verified: the same invalid-credentials case now returns a clean `500 AUTH_UPSTREAM_ERROR` ProblemDetails instead of crashing.
- **✅ RESOLVED 2026-07-17 — login works.** The AuthHex fix below was applied (`ERP_Auth_Service`, uncommenting the `PasswordHash` assignment) and verified: `POST /api/v1/auth/login` now returns **200 + `Set-Cookie: erp_at`** for a freshly-registered user, where it previously returned `500 "Invalid credentials"`. This unblocked the §1–§5 live verification that had been pending for two sessions. **Users registered before the fix have a null hash and can never log in** — they must be re-registered (the session's `smoketest_admin` among them).
- **Historical:** `loginUser` used to fail with `{"statusCode":500,"success":true,"message":"Invalid credentials","data":null}` **even immediately after a fresh registration with the exact same credentials**, confirmed by calling AuthHex's `/api/user` directly with the identical payload (bypassing ERPCore entirely) — a bug in AuthHex's own `loginUser`/password-verification path, not in this proxy.
  - **ROOT CAUSE (2026-07-16), in `ERP_Auth_Service` — two independent bugs, both one-liners.** Bug 1 fixed 2026-07-17; bug 2 left alone (out of scope, and email login is what the UI uses). Re-confirmed the failure against a user registered this session (`smoketest_admin`/`SMOKE001`), by username *and* email, with *and* without `userTypeId`.
    1. **The password was never stored — FIXED 2026-07-17.** `Services/UserManager/UserManagerService.cs:97` computes `var PasswordHash = PasswordHasher.Hash(...)`, but the assignment in the `new User { … }` initializer at **line 116 was commented out** (`//PasswordHash = PasswordHash`). Every registered user landed in MySQL with a null `PasswordHash`, so `loginUser`'s `if (string.IsNullOrEmpty(user.PasswordHash) || !PasswordHasher.Verify(...))` (line 230) always threw `"Invalid credentials"`. **Uncommenting that line fixed login outright** — verified end-to-end. Pre-fix users are unrecoverable (their hashes were never written) and need re-registration or a password reset (`ChangeUserPassword`/`UpdateUser` do persist the hash correctly, and `UpdateUser` even handles the null-hash case at line 836).
    2. **Username is not a valid login identifier.** `Repos/UserManageRepository.cs:46` `GetUserByIdentifierAndType` matches only `Email`/`MobileNumber`/`Nic` — **not `UserName`** — and ignores its `userTypeId` argument entirely (that filtering sits commented out at lines 56–65, so the "AndType" half of the method name is currently a lie). Even with bug 1 fixed, `identifier: "<username>"` will not resolve a user; only email/mobile/NIC will.
  - **Workaround meanwhile: `POST /api/v1/auth/register` issues a working `erp_at` session cookie directly**, which authenticates every v1 controller via the handler's cookie fallback. That is how this session's Master-Data smoke test (§1) was run — no login needed.
