# 08 — Inventory Increment 1.0: Defect Fixes — Report

**Date:** 2026-11-04
**Branch:** `techtitans/main`
**Baseline:** backend `a125e0f`, frontend `485c477`

**Scope:** fix four inherited inventory defects in the existing engine. There is **no redesign**, no new inventory module, and no IMEI or repair features.

**Status:**
- All four defects are fixed and covered by regression tests.
- Focused backend batches: **2,080 tests passed, 0 failed**. The workforce and attendance suites (unaffected) were not re-run; see §5.
- Frontend: **1,689/1,689** passed.

> Numbering note: `08-WORDPRESS-CONNECTOR-REQUIREMENTS.md` also starts with `08`. This file uses the requested name.

---

## 1. Root causes

| # | Defect | Root cause (verified in code) |
|---|---|---|
| 1 | POS sale succeeds with no stock configured, so stock goes negative | `InventoryMovementService::lockStock()` used `insertOrIgnore` to **auto-create** a missing balance row with `on_hand=0` and `prevent_sale_when_out_of_stock=false`, then deducted. A missing balance therefore behaved as unlimited stock. Separately, the ceiling **defaulted to off** everywhere: the DB column default, the stock-settings auto-create, and the UI drawer for unconfigured items. So even configured tracked items oversold by default. |
| 2 | Archived items still accept stock changes | `applyManual`, `applyCount` and `applyConsumptionAs` never read `inventory_items.status`. `InventoryAvailabilityResolver` ignored it too, so archived items still showed as purchasable. |
| 3 | Online stock pre-check always says "available" | `OrderFinalizationCoordinator::stockAvailable()` was a stub that `return true`. An out-of-stock online order was **captured and then refunded** instead of voided. Three gaps were found alongside it: (a) the storefront quote checked each line alone, so two lines (or a line and a modifier) sharing one item were never added together; (b) payment initiation did no stock check; (c) **partial commit on compensation:** each line's `commitLine` is a nested savepoint, so if line N failed after lines 1..N-1 committed, compensation ran and the outer transaction **kept the earlier lines' stock deductions** for a refunded, unconfirmed order. |
| 4 | Automatic SKU collisions in bulk operations | The real scope is the P0-A unique index `(tenant_id, location_id, sku)` on `product_variants`, which is `utf8mb4_unicode_ci` (case- and accent-insensitive). The generator already reserved SKUs location-wide, but: (a) **soft-deleted** variants were not reserved, yet they still occupy the index; (b) the selected variants' **current** SKUs were left out of the reserved set, but saves are sequential, so a swap or reuse among them hit a sibling's not-yet-overwritten value; (c) the in-memory check used `strtoupper`, which cannot reproduce the collation (`café` = `CAFE`); (d) two parallel generations at one location could pick the same candidate, and the loser failed with a 422 instead of retrying. |

---

## 2. Files modified

### Backend: application code

| File | Change |
|---|---|
| `app/Inventory/InventoryDemandChecker.php` | **New.** The single sale-stock rule. It aggregates demand per item across all nodes; an item is blocked as `archived`, `not_configured`, or `insufficient` (ceiling on and `on_hand < demand`). Used by every pre-check, and by the commit through the shared `judge()`. |
| `app/Inventory/InventoryMovementService.php` | Consumption locks item status (shared lock), then the **existing** stock rows (`FOR UPDATE`, sorted). It never auto-creates rows, validates every item with `judge()` before any deduction, and throws with all shortfalls. Manual movements and counts are rejected for archived items. `lockStock` (manual movements only) creates new rows with the ceiling **on**. Docblocks updated. |
| `app/Inventory/Exceptions/InventoryException.php` | Adds `forShortfalls()`, `itemArchivedForSale()` and `itemArchivedForMovement()`. Shortfall errors render `errors: {code, shortfalls[]}`; all other inventory errors keep `errors: null`. The insufficient-stock message is clearer. |
| `app/Inventory/InventoryAvailabilityResolver.php` | A component on an archived item makes the variant or modifier `blocked`, and adds `item_archived` to each component. |
| `app/Inventory/InventoryStockService.php` | Stock-settings auto-create defaults the ceiling **on**. The endpoint itself always requires the flag explicitly. |
| `app/Inventory/InventoryDeductionService.php`, `InventoryConsumptionResolver.php` | Stale "not wired to any live path" docblocks corrected. Comments only. |
| `app/Orders/OrderInventoryBridge.php` | Adds `stockShortfalls(Order)`, an advisory pre-check built from exactly the consume nodes that `commitLine` projects. |
| `app/Payments/OrderFinalizationCoordinator.php` | The stub is replaced by the real pre-capture check, with shortfalls recorded on the `voided_no_stock` payment event. The commit of every line plus the confirm run in **one nested transaction**, so a failure rolls back all lines before compensation. Lines commit in deterministic `id` order. |
| `app/Storefront/StorefrontPaymentService.php` | Payment initiation is refused (`409 out_of_stock`) for a DRAFT order that cannot be committed. |
| `app/Storefront/Exceptions/StorefrontCheckoutException.php` | Adds `outOfStock()` (409). Internal item names and quantities are not exposed to the storefront. |
| `app/Checkout/CheckoutQuoteService.php` | Adds an order-level aggregate stock check across lines. A shortage sets `is_fulfillable=false` with error code `insufficient_stock`, so order placement gets a `requote` response. |
| `app/Menu/Variants/VariantBulkService.php` | `generateSkus`: (1) reserves soft-deleted SKUs; (2) releases the selected variants' SKUs first (same transaction); (3) `saveWithUniqueSku()` confirms each candidate against the DB (collation-aware), saves, and on a duplicate-key error retries with the next suffix (maximum 25 attempts, then a 422 `sku_taken`). The SKU format is unchanged (`BASE-VARIANT[-n]`). |
| `app/Menu/Variants/VariantSkuGenerator.php` | Docblock corrected; logic unchanged. |
| `database/migrations/2026_11_20_100000_default_prevent_sale_on_inventory_location_stock.php` | **New.** Column default only (see §3). |

### Backend: tests

| File | Change |
|---|---|
| `tests/Feature/Orders/OrderCommitStockGuardTest.php` | **New**, 9 tests (Task 1). |
| `tests/Feature/Inventory/InventoryArchivedItemGuardTest.php` | **New**, 11 tests (Task 2). |
| `tests/Feature/Storefront/StorefrontPaymentApiTest.php` | **6 new tests** (Task 3). One existing test was retargeted (see §6). |
| `tests/Feature/Storefront/StorefrontBamboraPaymentApiTest.php`, `tests/Feature/Payments/PaymentReconciliationTest.php` | 3 existing compensation tests retargeted (see §6). |
| `tests/Feature/Menu/VariantSkuGenerationTest.php` | **New**, 8 tests (Task 4). |
| `tests/Feature/Inventory/InventorySaleOversellConcurrencyTest.php` + `tests/Support/pos_sale_commit_worker.php` | **New.** A real two-process race to sell the last unit. |
| `tests/Feature/Menu/VariantSkuConcurrencyTest.php` + `tests/Support/variant_sku_generate_worker.php` | **New.** A real two-process race to generate the same SKU. |

The workers are test-only scripts. They refuse to run outside `APP_ENV=testing` and outside `techtitans_pos_test`.

### Frontend

| File | Change |
|---|---|
| `src/components/menu/inventory/StockSettingsDrawer.tsx` | "Prevent sale when out of stock" defaults **on** for an item not yet configured at a location (`?? true`). The setup flow already defaulted to on. |
| `src/components/menu/inventory/StockSettingsDrawer.test.tsx` | **New**, 2 tests. |

---

## 3. Database changes

There is **one migration**: `inventory_location_stock.prevent_sale_when_out_of_stock` `DEFAULT 0` → `DEFAULT 1`.
- It changes the column default only. **No existing rows or explicit settings are modified.** It is reversible.
- It was applied to `techtitans_pos_dev` (195 migrations; the dev DB had 0 stock rows). It runs automatically in `techtitans_pos_test`.
- There are no new tables, indexes or data rewrites.
- The ledger (`inventory_movements`) and `audit_logs` are untouched. Only the existing append path writes to them.
- The protected `techpos` copy is unchanged: its checksums match the Phase 0 baseline.

---

## 4. How each fix works

### Task 1: insufficient stock at POS commit

A tracked sale passes through `OrderInventoryBridge`, then `InventoryEventProjector`, then `applyConsumptionAs`. Inside one transaction, the engine:
1. reads the item rows' status under a **shared lock**, in sorted order;
2. locks the **existing** `(location, item)` balance rows `FOR UPDATE`, in sorted order;
3. aggregates demand per item and judges every item;
4. if any item fails, throws **before any deduction**, returning a 422 with `errors.code` (`inventory_not_configured`, `inventory_insufficient_stock` or `inventory_item_archived`) and `errors.shortfalls[]` (`inventory_item_id`, `inventory_item_name`, `reason`, `required`, `available`).

What follows from this:
- The POS commit endpoint's outer transaction rolls back every line. No movement is written, no balance row is created, and no commit event is recorded. The commit endpoint never creates payments or invoices.
- **Location-specific:** only the order's location row counts. Stock at another location returns `not_configured`.
- **Concurrency:** the row lock serializes concurrent sales. The loser re-reads the committed balance and is rejected. This is proven cross-process.
- **Service products** (no active inventory mapping) produce no demand and sell as before.
- **Explicit backorder:** a `(location, item)` whose ceiling an operator has deliberately turned **off** may still go negative. This is preserved behaviour and is now never the default.

### Task 2: archived items

- `applyManual` (receiving, adjustment, waste, damage, staff meal) and `applyCount` reject archived items with a 422 before touching a balance, and never create one at a new location.
- Sales of archived items are rejected (`inventory_item_archived`).
- `applyRestockAs` (cancellation, void, refund, line removed) is **intentionally still allowed**, so a sale made before archiving can be reversed and the ledger balances.
- Historical movements are untouched and still readable.
- Archive takes `FOR UPDATE` on the item row and movements take a shared lock, so an archive cannot interleave with an in-flight movement.
- The resolver reports archived components as blocked, so catalogue, quote and POS reads agree with the commit.
- Mappings are **not** revoked on archive. Revoking would make the variant *untracked*, which means unlimited sales. Instead the variant is unsellable until it is remapped.
- No transfer feature exists, so there is nothing to guard yet. Transfers come in Increment 1.6.

### Task 3: online stock before payment

| Stage | Check |
|---|---|
| Quote / order placement | Per-line checks (existing) **plus** an aggregate per-item check across all lines and modifiers (`is_fulfillable=false`, code `insufficient_stock`). Order creation re-quotes, so an unfulfillable cart gets `409 requote` and no order is created. |
| Payment initiation | DRAFT order with shortfalls returns `409 out_of_stock`. No payment row is created and no authorization is attempted. |
| Finalization, before capture | Real pre-check (`OrderInventoryBridge::stockShortfalls`). On a shortfall the authorization is **voided**: never captured, payment `cancelled`, order `VOIDED`, shortfalls recorded on the payment event. |
| Commit (authoritative) | Re-validates atomically under row locks (Task 1). All lines plus the confirm form one nested transaction. A failure after capture rolls back **every** line, then runs the existing compensation (refund). |

Payment-provider behaviour, verification, ambiguous-capture reconciliation and the order workflows are otherwise unchanged.

### Task 4: SKU generation

Uniqueness is still per (tenant, location), separately for products and variants, and case- and accent-insensitive (the existing P0-A index and `SkuPolicy`). Bulk generation now:
1. reserves every variant SKU at the location, including soft-deleted rows;
2. nulls the selected variants' SKUs inside the transaction;
3. for each variant in display order, takes the first candidate in `BASE-VARIANT`, `-2`, `-3` … that the **database** reports free, then saves;
4. on a duplicate-key error (a parallel writer won), retries with the next suffix.

Manual SKUs on variants that were not selected are untouched. Genuine unresolvable conflicts still return `422 sku_taken`. Other SKU write paths (manual create, update, duplicate) are unchanged and keep the existing index check, which returns 422 on a clash.

---

## 5. Tests executed (all against `techtitans_pos_test`, Xdebug off, bounded batches)

| Batch | Scope | Result |
|---|---|---|
| r1 | Inventory, Orders, Checkout (concurrency classes excluded) | **OK, 374 tests**, 1,448 assertions |
| r2 | Storefront, Payments, Channels, Customers, Tax, Kds, Stations, Printers, Shipping, Platform | **OK, 449**, 1,727 |
| r3 | Menu (products, variants, SKU, including the new SKU tests) | **OK, 773**, 3,139 |
| r4 | Unit, Auth, Authorization, Roles, Tenancy, Audit, Settings, Locations, Pin, plus `SkuUniquenessTest` | **OK, 469**, 1,456 |
| Concurrency, one process each | InventoryConcurrency 4, InventoryRestockConcurrency 3, **InventorySaleOversellConcurrency 2**, OrderConcurrency 5, **VariantSkuConcurrency 1** | **OK, 15** |
| **Total** | | **2,080 passed, 0 failed** |
| After Pint formatting | StorefrontPaymentApiTest, InventoryArchivedItemGuardTest | OK, 36 |

**Mutation checks** (fix temporarily reverted, the test must fail):
- The partial-commit rollback test **fails** without the nested transaction.
- 4 of the 8 SKU tests (swap, soft-deleted, accent, concurrent writer) **fail** on the original `VariantBulkService`.
- The cross-process SKU race **fails** on the original code ("The SKU has already been taken at this location.") and passes on the fix.

**Not re-run:** the workforce, time-clock, scheduling, attendance and AI suites (1,071 tests plus their concurrency classes) and `RoleScopeMigrationAtomicityTest`. None of them touches inventory, checkout or SKU code. They passed at the Phase 0 baseline.

**Frontend:**
- `tsc -b` exit 0.
- eslint: 0 errors, 3 pre-existing warnings.
- Vitest (menu and inventory) 473/473; full run **1,689/1,689** (165 files).

**Pint:** passes on all changed files.

**Playwright:** not run (NH fixtures).

---

## 6. Existing functionality affected

- **New stock balances block overselling by default.** This covers rows created by receiving into a new location, by stock-settings auto-create, by the DB default, and by the UI drawer default. Existing rows keep their explicit setting.
- **A tracked item with no balance row at a location can no longer be sold there.** Previously such a sale auto-created the row and went negative. To sell, configure the stock at that location or remove the mapping (for service products).
- **Archived items:** receiving, adjustments and counts now return 422, and sales are rejected. Restocks and reversals still work.
- **Online:**
  - an order whose stock disappears before finalization is now **voided** (never charged) instead of captured and refunded;
  - payment initiation can return `409 out_of_stock`;
  - quotes can be unfulfillable for aggregate shortages.
- **Four existing tests changed** (`StorefrontPaymentApiTest`, `StorefrontBamboraPaymentApiTest`, 2× `PaymentReconciliationTest`). They encoded the stub: "stock lost before confirm, so captured then compensated". That scenario now voids. To keep the compensation path covered, the tests now lose stock **during capture** (after the pre-check, before the commit), which is the race the pre-check cannot close. Their assertions are unchanged.
- **Inventory error responses:** shortfall errors now carry `errors.code` and `errors.shortfalls`. Other inventory errors are unchanged (`errors: null`).

---

## 7. Remaining limitations (not claimed as fixed)

1. **The in-store UI never commits inventory.** The React POS flow creates, confirms and pays orders but never calls `POST /orders/{id}/commit` (the only POS deduction path). UI POS sales therefore **do not deduct stock at all**, and confirm or payment do not check stock. The engine and endpoint are fixed. Wiring commit into the POS checkout (at confirm, or before recording payment) changes the order workflow and is a **product decision**. It is recommended before or with serialized-device sales.
2. **No stock reservations.** Stock is not held between quote, order and payment, so another sale can take it. The new checks then refuse payment (`409`) or void the authorization, so the customer is not charged. They do **not** guarantee availability.
3. **Remaining charge-then-refund window.** Between the pre-capture check and the commit (the provider capture call), a concurrent sale can still take the stock. That case is still handled by the existing compensation refund, and partial deductions are now rolled back. Only reservations (Increment 1.7) close it.
4. **Pre-checks are advisory.** Quote, initiation and pre-capture reads take no locks. The commit is the authoritative atomic gate.
5. **Manual movements can still go negative** (waste, damage, negative adjustment), and counts set the physical figure. This is intentional: they record physical reality.
6. **Explicit per-location opt-out** (ceiling off) still allows backorder by design.
7. **Archive is one-way** (no restore endpoint exists). Variants mapped to an archived item stay unsellable until they are remapped.
8. **Quote aggregate shortage is reported at quote level** (`scope: items`), not tied to the specific lines.
9. **Product-level SKUs are not auto-generated** (there is no generator for them). Manual SKU conflicts still return `422 sku_taken`, as before.
10. **The oversell race test is a guarantee test.** With the ceiling on, the original code also serialized correctly. The defect fixed for Task 1 is the auto-create of missing rows and the default-off ceiling, which the RefreshDatabase tests cover.
11. Decimal quantities are still accepted for all items. Integer-only serialized goods come in Increment 1.1/1.2.

---

## 8. Readiness for Increment 1.1: Serialized Device Inventory

**Ready.**
- The sale rule is now centralized (`InventoryDemandChecker` / `judge()`) and enforced under row locks, so serialized units can add a unit-level rule in the same place.
- Archived and not-configured items fail closed.
- Online pre-checks match the commit.
- SKU generation is collision-safe.
- No schema redesign was needed.

**Recommended before or with 1.1:**
- Decide limitation 7.1 (POS UI commit wiring); serialized sales must deduct a specific unit.
- Plan reservations (Increment 1.7) for online single-unit devices.

Increment 1.1 has **not** been started.
