# Inventory Management (zync embed) — Perpetual, Multi-Location, Tax-Grade

**Date:** 2026-07-04
**Status:** Draft
**Spec:** 184
**Tier:** All tiers (goods-selling businesses)
**Platform module spec (Part A, separate repo):** `~/Projects/platform/docs/specs/2026-07-04-inventory-module-design.md` — the reusable `@platform-modules/inventory` engine
**Depends on:** `@platform-modules/inventory` (NEW — must be PR'd + merged first), `@platform-modules/ledger`, `@platform-modules/tax`, `invoices-core`, `expenses-module`, `product-service-library` (spec 85), `accountant-export` (spec 181), `foundation-auth-rbac`
**Referenced by:** `accountant-export`, `israeli-tax-reports`, `reports-analytics`

---

## Overview

Professional perpetual inventory for zync as a business OS. Every purchase, sale, adjustment, transfer, and count posts an **immutable stock movement** in real time; stock on-hand and cost valuation stay live. zync produces the numbers a goods business is legally required to report to the ITA: **opening stock, closing stock, and COGS** (income-tax return / Form 6111 lines), plus the year-end **מפקד מלאי (physical stock count)** report.

The reusable engine lives in a **new platform module `@platform-modules/inventory`** (composes `ledger`, reuses `tax`) — specified separately and PR'd to the platform repo FIRST. **This document is the zync HOST spec**: how the module is embedded, plus the Israel-specific tax presentation, product linking, receiving capture, UI, RBAC, and migration that stay host-owned in zync.

### Decisions locked (this brainstorm)

| Decision | Choice |
|---|---|
| Depth | Full perpetual + tax-grade valuation |
| Module topology | New `@platform-modules/inventory` (L2), composes `ledger`; `commerce-inventory` untouched; reuse `tax/rates-table` |
| Valuation | FIFO **and** weighted-average, per-tenant method (module carries both cost structures) |
| Locations | Multi-location from v1 + inter-location transfers |
| Stock flow | Auto-post from invoice-issue (issue + COGS) and purchase-receive (receipt @ net cost); manual adjust / count / transfer; void → compensating movement |
| Negative stock | Allow-negative with cost true-up (default); strict per-tenant opt-in |

### Build order (cross-repo, two-phase — hard gate)

1. **Phase A (platform repo):** author `@platform-modules/inventory` per its boundary spec, open PR, **merge**. Agents touching `/home/user/Projects/platform` MUST first load that repo's `CLAUDE.md`, skills, memories, agent defs and follow platform doctrine (one-contract-per-capability, Gate-1 delete-test, layer DAG, PR-only landing, adapter-minimalism). A zync-context agent must not edit platform files under zync rules.
2. **Phase B (this spec, zync repo):** embed the merged module. Every task below has a hard dependency on the Phase-A PR landing.

---

## B.1 Product link (extend spec 85 `products`)

Add to `products` (migration, additive):
- `is_tracked boolean not null default false` — is this a stock-tracked good (vs a service/flat fee).
- `stock_item_id uuid references stock_item(id)` — set when tracked; 1 product → 1 stock item.

Services and flat-fee products stay `is_tracked=false` and never touch inventory. Toggling a product to tracked provisions its `stock_item` (host service).

**Tenant setting:** `inventory_method ('fifo'|'weighted_average', default 'weighted_average')` + `allow_negative boolean default true` (allow-negative-with-true-up is the default; strict tenants set false) on the tenant/settings table; seeds each new `stock_item.method` and the `allowNegative` arg passed to `postIssue`.

## B.2 Purchase-receiving capture (surfaced wrinkle — REQUIRED host addition)

Zync `expenses` are OCR/single-amount and do **not** carry product+qty lines, so an expense alone cannot auto-post a stock receipt. Add a lightweight **receiving line** capture:

```
expense_stock_lines
  id uuid pk, tenant_id, expense_id uuid not null references expenses(id),
  product_id uuid not null references products(id),   -- must be is_tracked
  location_id uuid not null references stock_location(id),
  qty numeric(18,4) not null,
  unit_cost_net numeric(18,6) not null,               -- ex-VAT; derived from expense.amount split or entered
  created_at
```

On expense finalize (or a "receive stock" action on a tracked-goods expense): for each line → `postReceipt(holderRef='expense:<id>:line:<lineId>', unitCost=unit_cost_net)`. Net cost basis = `expenses.amount` (canonical net accounting field), apportioned across lines; recoverable VAT stays out of stock cost. **Non-recoverable-VAT boundary (host-owned):** for a tenant whose input VAT is not reclaimable (עוסק פטור / non-deductible items), `unit_cost_net` must be the VAT-inclusive amount — that VAT belongs in the cost basis. The host computes the correct net; the module stores it verbatim and never branches on recoverability.

## B.3 Invoice-issue wiring (auto issue + COGS)

Invoice lines already reference `products` (spec 85) with qty. On invoice reaching **`TAX_ISSUED`** (statutory issue moment), for each line whose product `is_tracked`:
- `postIssue(holderRef='invoice:<id>:line:<lineId>', itemId, locationId=<invoice source location, default tenant main>, qty, method=item.method, allowNegative=<tenant setting>)` — one call per tracked line, each with its own per-line `holderRef`.
- Booked `cogs_amount` is captured for the reports/analytics COGS and Form 6111.

**Never blocks issuance under the default.** With `allow_negative=true` the issue always posts (provisional COGS, `pending_cost`) even if stock is not yet recorded — a חשבונית מס is never halted by bookkeeping lag; the later receiving `postReceipt` trues-up cost. Strict tenants (`allow_negative=false`) surface `OversoldError` as a blocking UI error.

Invoice **void / credit note** → `reverseMovement(holderRef='invoice:<id>:line:<lineId>')` per line (compensating issue-back, restores layers). Idempotent both directions: re-issue of an already-issued invoice does not double-post (issue idempotency on per-line `holderRef`), and a repeated void returns the existing reversal without a second compensation (module 3-state contract — see Error handling).

**Location on invoice line:** invoice/line gains an optional `location_id` (defaults to tenant's default location) so multi-location tenants issue from the right warehouse.

## B.4 Israel tax composition (host-owned)

Compose the module's generic numbers into ITA presentation — **no IL logic in the platform module**:
- **Opening / closing stock:** `getValuation(asOf=periodStart)` and `getValuation(asOf=periodEnd)`, ex-VAT.
- **COGS:** `getCogs(from,to)` → income statement + Form 6111 field.
- **Form 6111 lines:** map opening stock, purchases, closing stock, COGS into the existing accountant-export Form 6111 builder (spec 181).
- **מפקד מלאי (year-end physical count) report:** driven by `recordCount`; produces the statutory count sheet (item, counted qty, unit cost, extended value, variance) as a dated, exportable document via the accountant-export/xlsx path.

## B.5 UI (apps/zync-app)

Pages (design-token / zc-ui-dev conventions; responsive):
- `/inventory` — stock list: item, on-hand per location, valuation, low-stock flag. Filters by location/category/active.
- `/inventory/[item]` — movement history (append-only ledger view), on-hand, valuation, cost layers.
- Actions: **Adjust** (qty + reason), **Transfer** (between locations), **Stocktake/Count** (enter counted qty → variance).
- `/inventory/locations` — CRUD locations, set default.
- `/inventory/reports` — valuation report (as-of date), COGS (period), **מפקד מלאי** count report export.
- Product form (extend spec 85): "Track inventory" toggle → provisions stock item; opening-balance entry.

## B.6 RBAC, isolation, migration

- New permissions: `inventory:read`, `inventory:write` (adjust/transfer/count), `inventory:manage` (locations, method). Backfill existing roles (OWNER/ADMIN → manage; MEMBER → write per tenant policy) — same backfill discipline as prior waves.
- **Tenant isolation:** every movement/read predicated on `tenant_id`; by-id fetches carry tenant predicate (IDOR-closed, per prior isolation waves).
- **Migration:** new Neon migration (host tables + module tables installed via module `pushSchema`: locations, stock_item, stock_movement, stock_cost_layer, stock_position, plus host expense_stock_lines, products alter, tenant settings alter) via zc-dba Floor-4 protocol; seed one default location per existing tenant.

---

## Data flow

```
purchase (expense + stock lines) ──postReceipt──▶ stock_movement(+qty,unit_cost) ──▶ stock_cost_layer|position
                                                          │
invoice TAX_ISSUED (tracked lines) ──postIssue──▶ stock_movement(−qty) ──consume(fifo|avg)──▶ cogs_amount
                                                          │
adjust / transfer / count ─────────────────────▶ stock_movement(±qty[, transfer_group])
                                                          │
on-hand  = Σ qty_delta per (item,location)  ◀─────────────┘
valuation = Σ qty_remaining×unit_cost (FIFO) | on-hand×avg_cost (AVG), ex-VAT
COGS     = Σ cogs_amount over period  ──▶ zync: Form 6111 + מפקד מלאי + reports/analytics
```

## Error handling (host surface)

- `OversoldError` only when tenant is strict (`allow_negative=false`) and issue/transfer exceeds on-hand; surfaced as a blocking UI error with current on-hand. Under the default (`allow_negative=true`) the issue posts and is marked `pending_cost` instead.
- **`reverseMovement` is 3-state on the `holder_ref` (module contract) — the host must handle all three, never treat a throw as a benign no-op:** (1) issued line found, not yet reversed → compensating movement posted; (2) already reversed → idempotent, returns the existing reversal (double-void books no second compensation); (3) `holder_ref` never existed → module throws `MovementNotFoundError`. For a real void of an issued invoice the ref always exists, so retry-safety comes from case (2); a case-(3) throw signals a wiring/typo bug and is surfaced as an error, NOT swallowed.
- All movement posts idempotent on `(tenant_id, holder_ref, kind)` — safe under retry / at-least-once job delivery.
- **VAT basis is ex-VAT unconditionally (not a tenant toggle).** Deriving the correct net `unit_cost` is the host's job: standard VAT-registered dealers strip recoverable input VAT; for a non-recoverable-VAT posture (עוסק פטור / non-deductible items) the host passes the VAT-inclusive amount as the net cost, since that VAT is not reclaimable and belongs in the cost basis. The module never branches on VAT-recoverability — it stores whatever net the host computes.

---

## Architecture Decisions (host)

- **Engine is a platform module, not zync code.** The reusable perpetual-accounting engine (`@platform-modules/inventory`) is authored + PR'd + merged in the platform repo FIRST; this spec only embeds it. Its own boundary/valuation/negative-stock decisions live in the platform module spec. Rationale for the split: reusability, zero duplicate code, platform doctrine ownership.
- **Cross-repo two-phase build with a hard gate.** Phase B (this spec) has a hard dependency on Phase A's PR landing; the run-plan is two phases, not one flat wave list.
- **Platform-repo agents load platform context.** Any agent touching `/home/user/Projects/platform` loads that repo's `CLAUDE.md`, skills, memories, agents and follows platform doctrine; a zync-context agent must not edit platform files under zync rules.
- **Negative stock: allow-with-true-up default (USER-DECIDED 2026-07-04).** zync passes `allowNegative=true` by default so a statutory tax invoice is never blocked by bookkeeping lag; strict per-tenant opt-in restores hard block.
- **IL tax host-owned.** Form 6111 / מפקד מלאי are product-defining Israel logic → zync host, composing the module's generic ex-VAT valuation/COGS. No IL leakage into the platform module.
- **Purchase-receiving capture is host-owned and required.** Zync expenses lack product/qty lines; `expense_stock_lines` is the minimal host bridge (this app's entity/wiring), not a platform concern.
- **`commerce-inventory` reservation not wired in zync v1.** Zync issues stock at invoice-issue, not at a cart hold; reservation stays a documented sibling for a future e-commerce surface.

## Deferred (not v1)

- Product variants / SKU matrix (size×color) — v1 is 1 tracked product = 1 stock item.
- Reorder automation / PO documents (low-stock **flag** shown; automated purchase orders deferred).
- Landed-cost allocation (freight/duty into unit cost).
- Barcode/scanner intake.
- `commerce-inventory` reservation wiring (future e-commerce checkout surface).
