Skip to content

Inventory Stock

1. Overview

PropertyValue
IDFEAT-INV-STOCK
StatusStable
Ownerinventory-team
Depends onMaterial / ProductVariant (polymorphic principal), InventoryLocation

The stock model is three layers — InventoryItem (polymorphic principal × merchant), InventoryStock (per-location bucket), InventoryIdentifier (SKU / barcode / serial / etc.). The atomic mutator InventoryStockRepository.adjustStock performs read+write in a single SQL UPDATE with optional forceNonNegative guard.

2. Entity Model

InventoryItem

FieldTypeRequiredDescription
merchantIdtextOwner
itemTypetextMATERIAL / PRODUCT_VARIANT
itemIdtextFK target id (polymorphic, no DB FK)
identifiertextAuto, INI prefix
statustextInventoryItemStatuses (default ACTIVATED)

Idempotent upsert: InventoryItemRepository.ensureInventoryItem({ merchantId, itemType, itemId }) — keyed by partial unique index (merchantId, itemType, itemId) WHERE deletedAt IS NULL.

InventoryStock — bucket per (item × location × lot × serial)

FieldTypeRequiredDescription
inventoryItemIdtextFK
inventoryLocationIdtextFK
merchantIdtextDenormalized
quantityOnHanddecimal(15,4)Total physical stock
quantityReserveddecimal(15,4)Reserved for orders
quantityAvailabledecimal(15,4)Stored field; service maintains as onHand − reserved
lotNumbertextBucket-key extension
serialNumbertextBucket-key extension
expiryDatetimestamptzFEFO support (data only)
manufactureDatetimestamptz
lastCountedAttimestamptzLast cycle count
lastStockedAttimestamptzLast receipt
averageCostdecimal(15,4)AVCO snapshot
costingMethodtextDefault AVERAGE
metadatajsonbPer-bucket config — IInventoryStockMetadata: { allowOversell?: boolean; lowStockThreshold?: number }. lowStockThreshold is a per-location override of the InventoryItem.metadata default.

Bucket key: unique (inventoryItemId, inventoryLocationId, lotNumber, serialNumber) with NULLS NOT DISTINCT — rows with NULL lot/serial still count as duplicates.

InventoryIdentifier — polymorphic tag

FieldTypeRequiredDescription
principalTypetextINVENTORY_ITEM / INVENTORY_STOCK
principalIdtextFK target id
schemetextSKU / BARCODE / QRCODE / IMEI / SERIAL
valuetextIdentifier string

Constraint: unique (scheme, value) per principal.

3. Lifecycle

InventoryStock rows have no status — existence implies an active bucket. Rows are soft-deletable but typically only deleted as part of merchant offboarding.

InventoryItem is also stateless — its lifecycle mirrors the principal (Material / ProductVariant).

4. Operations

Read

MethodSourcePurpose
find / findByIdInventoryStockService (CRUD)Merchant-scoped read
Bulk-load by item + locationService-internalUsed by workers; avoids N+1

Write — atomic

MethodSignaturePurpose
InventoryStockRepository.adjustStock{ stockId, adjustOnHand?, adjustAvailable?, adjustReserved?, forceNonNegative? }Single SQL UPDATE; returns post-state or null if guard fails
InventoryService.seedInventoryAcrossLocations{ merchantId, itemType, itemId, baseUomId, locations?, ... }Idempotent multi-location seed (used at merchant onboard, ProductVariant create)
InventoryService.receiveInventoryFromPurchaseOrderItem{ purchaseOrderId, merchantId, inventoryLocationId, item, transaction }Increment from PO receive; writes tracking with effectivePrice

adjustStock SQL semantics

sql
UPDATE "InventoryStock"
SET quantity_on_hand = quantity_on_hand + :adjustOnHand,
    quantity_available = quantity_available + :adjustAvailable,
    quantity_reserved = quantity_reserved + :adjustReserved
WHERE id = :stockId
  AND ( :forceNonNegative = false
        OR (
            quantity_on_hand + :adjustOnHand >= 0
            AND quantity_available + :adjustAvailable >= 0
            AND quantity_reserved + :adjustReserved >= 0
        ) )
RETURNING quantity_on_hand, quantity_available, quantity_reserved;
OutcomeBehavior
Guard satisfied → row updatedReturns { quantityOnHand, quantityAvailable, quantityReserved }
Guard fails → 0 rows updatedReturns null — caller writes OVERSELL_BLOCKED tracking note and skips
forceNonNegative=falseAlways succeeds; allows negative quantities

See ADR-0003 for rationale.

5. REST Endpoints

VerbPathAuthPermissionHandler
6× CRUD/inventory-itemsJWT/BASICInventoryItem.<crud>merchant-scoped
GET/inventory-items/listJWT/BASICInventoryItem.findInventoryItemService.getList — slim list + summary (left pane of Stock Edit)
GET/inventory-items/list/countJWT/BASICInventoryItem.countInventoryItemService.getCount — total only, mirrors /products/list/count
GET/inventory-items/{id}/stocksJWT/BASICInventoryItem.findInventoryItemService.getStocks — per-bucket detail for the right pane
PATCH/inventory-items/{id}/stocks/{inventoryStockId}JWT/BASICInventoryStock.updateByIdInventoryItemService.updateStock — atomic single-bucket patch
6× CRUD/inventory-stocksJWT/BASICInventoryStock.<crud>merchant-scoped
GET/inventory-stocks/overviewJWT/BASICInventoryStock.findInventoryStockService.getOverview
6× CRUD/inventory-identifiersJWT/BASICInventoryIdentifier.<crud>merchant-scoped

No mutation endpoints for adjustStock are exposed — stock changes happen only via PurchaseOrder.receive, sale-payment Kafka, kitchen-ticket Kafka, or InventoryTicket.complete.

GET /inventory-stocks/overview — Stock List dashboard

Powers the four KPI cards on the Inventory > Stock List screen. The four metrics are fanned out from the repository in parallel.

Query

NameRequiredDefaultDescription
merchantIdMust fall inside the caller's Casbin merchant scope (admin / always-allowed bypass).
inventoryLocationIdNarrow stock and needAttention to a single location. The variant + location counts ignore it.

Low-stock threshold is not a query input. It's a two-tier inventory-domain config: per-location override on InventoryStock.metadata.lowStockThreshold, item-level default on InventoryItem.metadata.lowStockThreshold, system fallback 5. Per-bucket readers resolve COALESCE(stock override, item default, 5). Seeded at item creation from the aggregate's inventory.lowStockThreshold; the default is edited live via PATCH /inventory-items/{id} ({ metadata: { lowStockThreshold } }) and the override via the stock PATCH below.

Response — InventoryStockOverviewResponse

CardFieldSource
Storable VariantstorableVariant.totalCOUNT(*) ProductVariant WHERE type ∈ {STORABLE, MANUFACTURED} (merchant-scoped).
Storable Variant ("tracked")storableVariant.trackedSame filter + metadata.inventory.isInventoryTracked = true.
Locationlocation.total / physical / simulationCOUNT(*) InventoryLocation with FILTERs by type. total = physical + simulation.
Stockstock.totalOnHandSUM(quantity_on_hand) across every live InventoryStock bucket in scope. Returned as a numeric-precision string.
Stockstock.totalValueSUM(quantity_on_hand × COALESCE(average_cost, 0)) — monetary inventory value across every live bucket in scope. NULL average_cost is treated as 0 (untracked cost). Returned as a numeric-precision string.
Need AttentionneedAttention.outquantity_available ≤ 0 — empty or negative.
Need AttentionneedAttention.oversellquantity_available < 0 — negative; the more specific subset of out. Happens when the variant allows oversell and a sale / reservation pushed stock below zero.
Need AttentionneedAttention.low0 < quantity_available <= threshold, where threshold is COALESCE((stock.metadata->>lowStockThreshold)::numeric, (inv_item.metadata->>lowStockThreshold)::numeric, 5). Per-bucket — the stock row's override wins, else the InventoryItem default, else 5.
Need AttentionneedAttention.totalout + low — distinct buckets needing attention. oversell is a subset of out, so it is not added again.

The out / oversell / low conditions here are the same ones used by the /inventory-items/list per-item flags — both come from the shared stock-posture.sql fragments. The only difference is the rollup: the overview counts buckets, the item list does BOOL_OR per item.

Authorization: reuses InventoryStock.find since this is a read-only aggregate over the same rows. The service rejects mis-scoped merchants with HTTP 403.

Caching: response is cached in cache-redis for 60 seconds keyed per (merchantId, inventoryLocationId). The merchant-scope check runs before the cache lookup, so a cached entry can never be returned to an unauthorised caller. Cache failures fall through to the live query — Redis is best-effort, never a hard dependency.

GET /inventory-items/list — Stock Edit left pane

Paginated list. Slim row + aggregated summary; per-bucket detail is fetched lazily via the /stocks endpoint below.

Query (mirrors CRUD find)

NameDescription
filter[where][merchantId]Required — the screen's merchant picker. The query is scoped to this single merchant.
filter[where][itemType]ProductVariant / Material — omit for both.
filter[order]Whitelist: name · id · identifier · status · itemType · createdAt · modifiedAt. Default: name ASC NULLS LAST, id ASC (stable tiebreaker).
filter[limit] / filter[offset]Page size (max 250) / offset.

where[merchantId] is required (400 if missing) and validated against the caller's scope (403 if outside it; admin / always-allowed callers may read any merchant). /inventory-items/list/count takes the same where[merchantId] requirement.

Response — InventoryItemListResponse (array of InventoryItemListRowResponse)

FieldSource
id / identifier / status / merchantIdInventoryItem columns
itemId / itemTypeThe polymorphic principal — ProductVariant.id or Material.id
itemNameCOALESCE(ProductInfo.name, Material.name) — i18n jsonb. PV name comes from ProductInfo(principalType=ProductVariant), Material name from Material.name directly.
identifiers[]{ scheme, identifier } chips — from ProductIdentifier (PV) or MaterialIdentifier (Material). Polymorphic source flattened by the SQL projection; FE doesn't branch.
summary.total{ location: COUNT(DISTINCT inventory_location_id), quantity: SUM(onHand), value: SUM(onHand × averageCost) }
summary.onHand / summary.reserved{ quantity, value } slices
needAttention{ out, low, oversell } booleans — per-location (BOOL_OR across the item's buckets): true if ANY bucket hits the condition. out = ∃ bucket available ≤ 0 (empty or negative); oversell = ∃ bucket available < 0 (the more specific subset of out); low = ∃ bucket 0 < available ≤ COALESCE(stock override, item default, 5) (same per-bucket threshold cascade as the overview's low). Flags are independent / can overlap (a negative bucket sets both out and oversell; an item can be low at one location and oversell at another); all false = healthy. Items with no stock rows → all false.
Note: these conditions are identical to the /inventory-stocks/overview cards (both derive from the shared stock-posture.sql fragments — out = available ≤ 0, oversell = available < 0out, low on the same threshold cascade). The only difference is the rollup: the overview counts buckets, this list does BOOL_OR per item.

Pagination total comes back in the Content-Range HTTP response header (records 0-49/123). Body shape obeys the x-request-count-data toggle (same convention as CRUD find):

Header x-request-count-dataResponse body
omitted / true{ data: [...], count: <page-row-count> }
false[...] (bare array)

The list + count queries run inside one transaction so they see the same snapshot — range.total always agrees with the rows on the page.

GET /inventory-items/list/count — total companion

Same where filter shape as CRUD count. Returns { count: number }. FE pagination footer reads from here when it just needs a fresh total without re-fetching rows.

GET /inventory-items/{id}/stocks — Stock Edit right pane

One row per InventoryStock × InventoryLocation bucket for a single InventoryItem. Each row is self-contained (location nested + principal's uom + per-bucket allowOversell + the resolved lowStockThreshold tiers) so the FE renders the edit pane without extra lookups.

Response — InventoryStocksResponse (array of InventoryStockLocationRowResponse)

FieldSource
stock.idInventoryStock.id — needed by FE to build the PATCH URL /inventory-items/{id}/stocks/{stockId}
location.id / identifier / type / name / isDefaultInventoryLocation joined on stock.inventory_location_id
uomCOALESCE(ProductVariant.uom, Material.uom){ base, purchase, sale }
allowOversellCOALESCE((stock.metadata→>allowOversell)::boolean, false) — per-bucket
lowStockThreshold{ default, byItem, byStock } numeric strings: default = system 5; byItem = COALESCE(item default, 5) (effective item-level); byStock = COALESCE(stock override, item default, 5) (the threshold actually in effect for this bucket). FE shows byStock as the active value and compares it to byItem to tell whether the bucket overrides the item default. All tiers jsonb_typeof = 'number' guarded (non-number → degrade to next tier).
averageCostInventoryStock.average_cost (string)
onHand / reserved / available{ quantity, value } where value = quantity × averageCost

Rows are ordered default-location-first then by location id. Merchant scope is enforced via the parent InventoryItem.merchant_id so an out-of-scope caller cannot read another merchant's stock by guessing ids.

PATCH /inventory-items/{id}/stocks/{inventoryStockId} — atomic single-bucket patch

Body — all fields optional, only provided fields change:

json
{
  "onHand": "12.5",
  "reserved": "3",
  "averageCost": "40000",
  "allowOversell": true,
  "lowStockThreshold": 4.5
}

Single SQL statement — no read-then-write race. quantity_available is re-derived inside the same UPDATE as quantity_on_hand - quantity_reserved so the three counters stay coherent regardless of which fields the caller touched. allowOversell and lowStockThreshold are shallow-merged into the row's metadata jsonb via ||, so unrelated metadata keys are preserved. lowStockThreshold is the per-location override (decimal allowed); it wins over the InventoryItem default in per-bucket low-stock readers:

sql
UPDATE inventory."InventoryStock"
SET quantity_on_hand   = COALESCE($onHand,         quantity_on_hand),
    quantity_reserved  = COALESCE($reserved,       quantity_reserved),
    quantity_available = COALESCE($onHand,         quantity_on_hand)
                       - COALESCE($reserved,       quantity_reserved),
    average_cost       = COALESCE($averageCost,    average_cost),
    metadata           = COALESCE(metadata, '{}'::jsonb)
                       || COALESCE($metadataPatch::jsonb, '{}'::jsonb)
WHERE id = $stockId
  AND inventory_item_id = $itemId   -- enforces stock-belongs-to-item
  AND merchant_id IN ($scope)       -- server-side scope guard
  AND deleted_at IS NULL
RETURNING *;

The service translates top-level allowOversell / lowStockThreshold into the metadata-patch object (only present keys are included), e.g. { allowOversell: true }metadata = metadata || '{"allowOversell": true}'. Omitting a field from the body leaves its existing value untouched.

Strict-mode pre-check. Before the UPDATE fires, the service runs a findOne to load the row and predict the post-patch state. If allowOversell would be false after the patch (either set by the body or inherited from the row's current metadata) AND any of the predicted onHand, reserved, or available = onHand − reserved is < 0, the request is rejected with HTTP 409 and messageCode: server.inventory.inventory_stock.update.oversell_disable_requires_non_negative. The caller must either fix the quantities in the same PATCH (e.g. send a non-negative onHand alongside allowOversell: false) or keep allowOversell: true. This prevents leaving a bucket in a state the sale-deduct guard can never recover from.

If RETURNING yields 0 rows → HTTP 404 with messageCode: server.inventory.inventory_stock.find.not_found (bad stockId, mismatched itemId, or out-of-scope merchant — all collapse into the same not-found response).

Still on the principal, not the stock row:

FieldUpdate via
Base UoMPATCH /product-variants/{id}/aggregate (or material aggregate) — uom.base is shared across all the principal's stock buckets.

Source of truth for allowOversell

The flag previously lived on ProductVariant.metadata.inventory.allowOversell / Material.metadata.inventory.allowOversell. As of migration 0009_charming_armor it lives on InventoryStock.metadata.allowOversell (per location) — runtime reads at the deduct/reserve hot path source from the stock row.

The principal's metadata.inventory.allowOversell is now seed-only: read once at first-time stock creation (CDC handler / Material aggregate create) and never again. Editing it after stocks have been seeded has no runtime effect — use this PATCH endpoint per location instead.

Editing lowStockThreshold (two-tier)

  • Per-location override → this stock PATCH (lowStockThreshold in the body). Wins for the bucket it targets.
  • Item-level defaultPATCH /inventory-items/{id} with { "metadata": { "lowStockThreshold": 30 } } (the standard InventoryItem CRUD update, merchant-scoped). Applies to every bucket that has no override.
  • Seed at create → the aggregate's inventory.lowStockThreshold (PV via CDC, Material in-process) writes the InventoryItem default once at creation; later catalog edits don't propagate (use the item update above).
  • System fallback 5 when neither tier is set.

(As of migration 0010_mellow_threshold the threshold no longer lives on ProductVariant/Material metadata at runtime.)

6. Events

Inbound stock-mutating Kafka topics (handled by workers, not via HTTP):

TopicWorkerStock effect
payment.successInventoryWorkerService.handlePaymentSuccess−quantity for product items; reserve materials per recipe
kitchen-ticket-item.status-changedMaterialWorkerService.handleKitchenTicketItemStatusChangedMaterial consume on READY; restore on VOIDED
material.transferredInventoryWorkerService.handleMaterialTransferred from source, + at destination
CDC nx.seller.public.product_variantInventoryWorkerService.handleProductVariantCDCensureInventoryItem upsert

Outbound:

TopicWhen
material.stock-changedAfter material consume/transfer
WebSocket observation/inventory/inventory-stockAfter any adjustStock write

7. Sale Deduction Flow

8. Side effects per stock change

Side effectWhen
InventoryTracking row insert (referenceType + referenceId)Always — every stock change writes audit
WebSocket broadcast on 3 roomsAfter adjustStock returns non-null
Possible Kafka emit (MATERIAL_STOCK_CHANGED)If material
InventoryStock.lastStockedAt / lastCountedAt updatePer operation type

9. Idempotency

CallerKey
Sale payment(SALE_ORDER, saleOrderId, stockId)
Kitchen READY(KITCHEN_TICKET_ITEM, kitchenTicketItemId, materialStockId)
PO receive(PURCHASE_ORDER, purchaseOrderId, stockId)
Material transfer(transferId, fromStockId) + (transferId, toStockId)

All keys queried in InventoryTracking before write — see ADR-0004.

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.