Inventory is a movement protocol — not a stock counter.
DreamBees Art inventory coordinates cached stock counts (sellable catalog truth), location levels (warehouse truth), reservations (checkout holds), commits (post-payment), and an append-only ledger (audit truth). It is the stock movement boundary for this open-source ecommerce platform (Shopify inventory analogue, with explicit ledger and reconciliation).
Routes, checkout, fulfillment, admin, and system jobs call services.inventory only — never productRepo.batchUpdateStock directly.
Cheat sheet: quick-reference.md · Data model: data-model.md · Tests: testing.md
Policy: commerce-protocol-frozen.md · Platform context: platform-overview.md
Related: flows.md § Receive stock · checkout.md § Checkout × inventory · onboarding.md
| You want to… | Jump to |
|---|---|
| Understand the three stock truths | Three layers of stock truth |
| Trace checkout holds | Checkout integration |
| Receive a PO | §6 PO receiving |
| Adjust counts in admin | §6 Admin correction |
| Look up API methods | §3 Public API |
| Debug stock mismatch | §6 Reconcile + reconcileInventory |
| Run proof tests | §12 Verification |
| Shopify concept | DreamBees Art implementation |
|---|---|
| Available inventory | Cached catalog stock on product/variant |
| Location inventory | inventory_levels.availableQty per location |
| Committed at checkout | reserveInventory → reservation document |
| PO receive | receiveStockAtLocation (catalog + location + ledger) |
| Inventory adjustments | adjustInventory via admin batch API |
| Inventory history | Append-only ledger (getProductLedger) |
Shopify hides ledger logic; this platform makes movement auditable by design.
┌─────────────────────────────────────────────────────────────┐
│ CATALOG STOCK (product.stock) │
│ Sellable count for storefront + cart availability │
│ Mutated only by InventoryMutationService (internal) │
└───────────────────────────┬─────────────────────────────────┘
│ reserve / release / delta
┌───────────────────────────▼─────────────────────────────────┐
│ LOCATION LEVELS (inventory_levels) │
│ Warehouse/retail availableQty — PO receive fan-out │
└───────────────────────────┬─────────────────────────────────┘
│ every movement logged
┌───────────────────────────▼─────────────────────────────────┐
│ LEDGER (append-only) │
│ Audit truth + idempotency markers │
└─────────────────────────────────────────────────────────────┘
Reconciliation compares catalog stock to ledger balance and opens cases on drift.
stateDiagram-v2
[*] --> Available: product in stock
Available --> Reserved: reserveInventory (checkout)
Reserved --> Committed: confirmReservation (paid)
Reserved --> Available: releaseReservation (fail/expiry)
Committed --> Available: applyInventoryDeltas (refund restock)
| Event | Protocol method | Reservation state | Catalog stock |
|---|---|---|---|
| Add to cart | checkAvailability only |
— | unchanged |
| Checkout start | reserveInventory |
reserved |
decreased |
| Payment OK | confirmReservation |
committed |
unchanged |
| Payment fail | releaseReservation |
released |
restored |
| Cleanup job | cleanupExpiredReservations |
expired |
restored |
Checkout passes optional Firestore transaction so reserve/confirm/release align with order writes.
Full purchase narrative: flows.md § Purchase flow
Admin Inventory UI
→ POST /api/admin/inventory/batch
→ services.admin (authorize)
→ services.inventory.adjustInventory
→ catalog stock set + ledger delta + idempotency marker
Client generates crypto.randomUUID() idempotency key per batch submit.
/admin/purchase-orders/[id]/receive
→ services.admin.receivePurchaseOrder
→ inventory.receiveStockAtLocation (ONE call)
→ catalog += qty
→ location.availableQty += qty
→ ledger lines + receive marker
Duplicate submit with same idempotency key returns { duplicate: true }.
POST /api/admin/inventory/reconcile
→ reconcileInventory
→ compares catalog vs ledger
→ opens reconciliation cases for review
Every inventory HTTP entry point follows the same stack:
HTTP route
→ getServerServices().inventory (InventoryApplicationService)
→ InventoryResult<T> (typed success or failure)
→ inventoryRouteAdapter (maps to HTTP status + JSON body)
Internal orchestration (not imported by routes):
InventoryFlowService
→ InventoryReservationService (reservation lifecycle + oversell cases)
→ InventoryMutationService (only path to productRepo.batchUpdateStock)
→ InventoryLedgerService (append-only audit log)
→ IProductRepository (ProductCatalog / VariantStore)
→ IInventoryLevelRepository? (location availableQty — PO receive fan-out)
→ IInventoryReservationRepository
→ IInventoryLedgerRepository
→ IInventoryReconciliationRepository
Checkout depends on InventoryMutationBackend (reserve / confirm / release only) — not the full service surface.
Single factory path — never wire InventoryMutationService directly in routes or tests (use createInventoryStack()).
createInventoryApplication(productRepo, orderRepo?, inventoryLevelRepo?)
→ createInventoryStack({
productRepo,
ledgerRepo, // FirestoreInventoryLedgerRepository
reservationRepo, // FirestoreInventoryReservationRepository
reconciliationRepo, // FirestoreInventoryReconciliationRepository
inventoryLevelRepo?, // FirestoreInventoryLevelRepository (required for PO receive)
onReservationReleased?, // sync order.metadata.inventoryReservationReleased
})
→ { inventory, mutations, reservations, ledger }
| Dependency | Source | Used for |
|---|---|---|
productRepo |
Firestore | Cached catalog stock (batchUpdateStock internal only) |
ledgerRepo |
Firestore | Append-only movement audit |
reservationRepo |
Firestore | Checkout holds |
reconciliationRepo |
Firestore | Oversell / ledger discrepancy cases |
inventoryLevelRepo |
Firestore | Location availableQty fan-out from receiveStockAtLocation |
onReservationReleased |
Container hook | Order metadata sync after release |
Container export: services.inventory
Interface: InventoryApplicationService (src/core/inventory/inventoryApplicationService.ts)
Implementation: InventoryFlowService (src/core/inventory/InventoryFlowService.ts)
Every public method returns InventoryResult<T>. Expected failures never throw.
type InventoryResult<T> =
| { ok: true; data: T; duplicate?: boolean }
| { ok: false; code: InventoryErrorCode; message: string; retryable: boolean };Helpers: inventoryOk, inventoryErr, inventoryTry, inventoryFromError in src/core/inventory/inventoryResult.ts.
Pre-flight stock validation without mutation.
| Callers | CartService (add/update for physical items) |
| Input | { items: InventoryLineItem[] } |
| Success data | { available, lines[] } per product/variant |
| Typical errors | none (returns available: false in success data) |
Skips digital products and products with continueSellingWhenOutOfStock.
Temporary stock claim during checkout.
| Callers | CheckoutMutationService via InventoryMutationBackend |
| Input | orderId, items, idempotencyKey, actor: 'checkout', optional transaction |
| Success data | { reservationId, orderId, state: 'reserved', expiresAt, lines } |
| Typical errors | INSUFFICIENT_STOCK, INVALID_INPUT |
Decrements cached catalog stock and writes ledger entries (reservation_created). Oversell during mutation creates an oversell reconciliation case — never silent double-sell.
Finalizes a hold after payment.
| Callers | CheckoutMutationService |
| Input | orderId or reservationId, idempotencyKey, actor: 'checkout', optional transaction |
| Success data | { reservationId, orderId, state: 'committed' } |
| Typical errors | RESERVATION_NOT_FOUND, RESERVATION_INVALID_STATE |
Does not change stock again — reservation already decremented catalog count.
Restores stock from a cancelled/expired/failed checkout.
| Callers | CheckoutMutationService, OrderAdminService, cleanup jobs |
| Input | orderId or reservationId, idempotencyKey, actor, optional reason, transaction |
| Success data | { reservationId, orderId, state: 'released' | 'expired', restoredLines } |
| Typical errors | RESERVATION_NOT_FOUND, RESERVATION_INVALID_STATE |
Idempotent via ledger release marker. Container hook syncs order.metadata.inventoryReservationReleased.
Absolute stock corrections (admin set-to-N).
| Route | POST /api/admin/inventory/batch |
| Callers | ProductService (create/update/batch admin), OperationsRuntimeService |
| Input | updates[], idempotencyKey, actor: 'admin', optional note, transaction |
| Success data | { adjustments[] } with previous/new/delta per line |
| Typical errors | INVALID_INPUT, INSUFFICIENT_STOCK (negative delta path) |
Writes delta ledger entries + adjust marker for idempotency.
Relative stock mutations (delta +/- N).
| Callers | RefundService, TransferService, OrderAdminService (swap/restock) |
| Input | deltas[], idempotencyKey, actor, reason, optional purchaseOrderId, transaction |
| Success data | { applied[] } |
| Typical errors | INSUFFICIENT_STOCK, INVALID_INPUT |
Not called directly by PurchaseOrderService — PO receiving uses receiveStockAtLocation instead.
Single movement action for PO receiving — fans out catalog + location + ledger internally.
| Callers | PurchaseOrderService.receiveItems only |
| Input | items[] (productId, locationId, delta), idempotencyKey, actor, reason: 'location_receive', optional purchaseOrderId, locationReason, transaction |
| Success data | { catalog: { applied[] }, locations[] } |
| Typical errors | LOCATION_RECEIVE_FAILED (503 retryable), catalog errors from inner applyInventoryDeltas |
receiveStockAtLocation(idempotencyKey)
→ check receive marker (duplicate → ok + duplicate)
→ applyInventoryDeltas (catalog stock + catalog ledger)
→ for each line: inventoryLevelRepo.adjustQuantity (location stock)
→ append per-line ledger (productId, locationId, purchaseOrderId)
→ append receive marker (delta 0)
On location failure without an outer transaction: catalog is rolled back via inverse deltas and LOCATION_RECEIVE_FAILED is returned. With an outer Firestore transaction (PO receive): error propagates and the transaction rolls back atomically.
Scans cached stock vs ledger balance; opens reconciliation cases on discrepancy.
| Route | POST /api/admin/inventory/reconcile |
| Input | optional productIds[], actor |
| Success data | { scanned, discrepancies[] } |
Releases expired reservations in batch with structured partial-failure report.
| Routes | POST /api/system/cleanup-inventory, POST /api/system/cleanup-orders |
| Input | optional before, limit |
| Success data | { scanned, expired, released, failed, errors[] } |
| HTTP | 207 when failed > 0 (via inventoryPartialReportResponse) |
Read-only audit trail for a product.
| Route | GET /api/admin/inventory/ledger?productId=&limit= |
| Input | productId, optional limit (1–500) |
| Success data | { productId, entries[] } |
| Typical errors | PRODUCT_NOT_FOUND, INVALID_INPUT |
| Code | HTTP | When |
|---|---|---|
INSUFFICIENT_STOCK |
409 | Reserve/adjust/delta would oversell |
PRODUCT_NOT_FOUND |
404 | Ledger read for missing product |
RESERVATION_NOT_FOUND |
404 | Confirm/release target missing |
RESERVATION_INVALID_STATE |
409 | Confirm/release on terminal reservation |
RECONCILIATION_REQUIRED |
409 | Ledger vs stock mismatch detected |
LOCATION_RECEIVE_FAILED |
503 | PO location fan-out failed; catalog rolled back (non-txn) |
OVERSELL_DETECTED |
409 | Oversell case opened |
INVALID_INPUT |
400 | Missing keys, empty payloads, stack misconfiguration |
DOMAIN_ERROR |
422 | Domain validation surfaced through protocol |
UNKNOWN (retryable) |
503 | Transient external failure |
UNKNOWN (non-retryable) |
500 | Unexpected error |
Error response body: { error, code, retryable }.
Adapter: src/infrastructure/server/inventoryRouteAdapter.ts
Duplicate successful operations return { duplicate: true } in the success payload (batch route includes duplicate field).
Inventory protocol routes call only services.inventory:
| Route | Method | Inventory API |
|---|---|---|
/api/admin/inventory/batch |
POST | adjustInventory |
/api/admin/inventory/reconcile |
POST | reconcileInventory |
/api/admin/inventory/ledger |
GET | getProductLedger |
/api/system/cleanup-inventory |
POST | cleanupExpiredReservations |
/api/system/cleanup-orders |
POST | checkout cleanup + cleanupExpiredReservations |
Read-only (no mutation):
| Route | Purpose |
|---|---|
GET /api/admin/inventory |
Inventory overview via ProductService.getInventoryOverview |
Forbidden in inventory protocol routes:
- Import or call
IProductRepository.batchUpdateStock,updateStock, orbatchSetInventory - Write
stockon product PATCH (rejected byparseProductUpdate) - Call
applyInventoryDeltasfrom PO receiving routes (usePurchaseOrderService→receiveStockAtLocation)
| Service | Protocol methods |
|---|---|
CheckoutMutationService |
reserveInventory, confirmReservation, releaseReservation |
CartService |
checkAvailability |
ProductService |
adjustInventory |
PurchaseOrderService |
receiveStockAtLocation |
RefundService |
applyInventoryDeltas |
TransferService |
applyInventoryDeltas |
OrderAdminService |
applyInventoryDeltas, releaseReservation |
OperationsRuntimeService |
adjustInventory |
Admin UI (AdminInventory.tsx) delegates to POST /api/admin/inventory/batch with client-generated idempotency keys — never repo writes.
cart validated (checkAvailability)
→ order created
→ reserveInventory() catalog stock -= qty, reservation = reserved
→ payment succeeds
→ confirmReservation() reservation = committed (no second stock decrement)
→ payment fails / cancel / expire
→ releaseReservation() catalog stock restored, reservation = released
Checkout passes transaction for reserve/confirm/release when inside the checkout Firestore transaction.
PurchaseOrderService.receiveItems
→ receiveStockAtLocation (single protocol call)
→ catalog delta (+ ledger: productId, purchaseOrderId)
→ location availableQty (+ ledger: productId, locationId, purchaseOrderId)
→ receive marker (idempotency)
Duplicate receive with the same idempotencyKey returns { duplicate: true } without double-adding catalog or location stock.
AdminInventory bulk save
→ POST /api/admin/inventory/batch (client idempotencyKey)
→ adjustInventory (absolute stock targets)
Product create: catalog record at stock: 0, then adjustInventory for initial quantity.
RefundService / TransferService / OrderAdminService
→ applyInventoryDeltas (relative +/- with idempotency marker)
(reservation created)
→ reserved
→ committed (payment confirmed)
→ released | expired (cancel / cleanup / rollback)
→ oversold_review (mutation conflict — reconciliation case opened)
| Store | Collection | Meaning | Mutated by |
|---|---|---|---|
| Catalog stock | products.stock |
Sellable count (checkout, storefront) | InventoryMutationService only |
| Location level | inventory_levels.availableQty |
Warehouse/location ops | receiveStockAtLocation fan-out |
| Ledger | inventory_ledger |
Audit truth | Every protocol mutation |
PO receiving is the primary path that updates both catalog and location in one sealed action. Checkout reservations affect catalog only.
Every mutation creates an InventoryLedgerEntry. Stock counts are derived/cached; ledger is audit truth.
interface InventoryLedgerEntry {
id: string;
productId: string;
variantId?: string;
locationId?: string; // present on location_receive line entries
purchaseOrderId?: string; // present on PO receive entries
reservationId?: string;
orderId?: string;
delta: number;
reason: InventoryLedgerReason;
actor: InventoryActor;
idempotencyKey: string;
createdAt: string;
}| Reason | Meaning |
|---|---|
reservation_created |
Checkout hold decrements stock |
reservation_confirmed |
Payment commit marker (delta 0) |
reservation_released |
Hold restored |
reservation_expired |
Cleanup expiry |
admin_adjustment |
Admin absolute correction |
reconciliation |
Refund/swap restock |
location_receive |
PO receive (catalog + location movement) |
| Operation | Marker key pattern | Duplicate behavior |
|---|---|---|
| Reserve | reservation idempotency key | Returns existing reservation |
| Confirm | confirm:{idempotencyKey} |
Returns committed state |
| Release | release marker + reservation lookup | No double-restore |
| Adjust | adjust:{idempotencyKey} |
No double-apply |
| Deltas | deltas:{idempotencyKey} |
No double-apply |
| PO receive | receive:{idempotencyKey} |
No catalog/location double-add |
| PO receive catalog | receive-catalog:{idempotencyKey} |
Inner catalog idempotency |
| PO receive line | receive:…:line:{productId}:{locationId} |
Per-line ledger dedup |
Admin bulk edits send a client crypto.randomUUID() idempotency key from AdminInventory.tsx.
Direct stock writes throw at the infrastructure layer:
update()/batchUpdate()rejectstockand_variantStockUpdateupdateStock(),updateVariantStock(),batchSetInventory()throw with protocol messagebatchUpdateStock()remains internal —InventoryMutationServiceonly
parseProductUpdate rejects stock on product PATCH — use POST /api/admin/inventory/batch.
SeedDataLoader bypasses the runtime container but mirrors protocol semantics:
- Product documents written with
stock: 0 seedCatalogStockAdmin()applies target stock + delta/marker ledger entries- Digital / non-tracked products skip stock bootstrap
Re-running seed is safe: duplicate marker keys are ignored.
| Module | Role |
|---|---|
InventoryMutationService |
Only caller of productRepo.batchUpdateStock |
InventoryReservationService |
Reservation CRUD + oversell cases |
InventoryLedgerService |
Append-only log + idempotency lookup |
InventoryFlowService |
Public orchestration |
createInventoryStack |
Single wiring factory |
inventoryMutationBackend.ts |
Checkout-facing reserve/confirm/release contract |
inventoryRouteAdapter.ts |
HTTP mapping |
inventoryHttpMapping.ts |
Error code → status |
Tests use createInventoryStack() with in-memory repos (src/tests/helpers/inMemoryInventoryStores.ts).
Frozen invariants are proven by the inventory test suite:
npm run test:storefront-release # includes reservation proof + protocol ladders
npm test -- --run \
src/tests/inventory-protocol.test.ts \
src/tests/inventory-verification-ladder.test.ts \
src/tests/inventory-reservation-proof.test.ts \
src/tests/inventory-location-consistency-ladder.test.ts \
src/core/PurchaseOrderService.test.ts \
src/core/CartService.test.ts| Invariant | Proof |
|---|---|
Cart uses checkAvailability only |
Static — no reserveInventory in cart stack |
| Checkout owns reserve/confirm/release | checkoutMutationService in transaction |
System cleanup uses services.inventory.cleanupExpiredReservations |
Route static seal |
| Cannot confirm after release / release committed hold | Behavioral lifecycle |
Competing reserves fail with INSUFFICIENT_STOCK |
Behavioral |
| Expired cleanup restores catalog stock | Behavioral |
| Cart UX events ≠ commerce timeline | Static event boundary |
| Invariant | Proof |
|---|---|
| Availability does not mutate | batchUpdateStock not called |
| Reserve decrements + ledger | Stock and ledger entries |
| Duplicate reserve | No double-decrement |
| Confirm without second decrement | Stock unchanged after confirm |
| Release restores + idempotent | Stock restored once |
| Adjust/deltas idempotent | Marker prevents double-apply |
| Reconcile flags discrepancies | Cases opened |
| Cleanup partial failure | Structured report, no throw |
| Invariant | Proof |
|---|---|
InsufficientStockError → INSUFFICIENT_STOCK |
inventoryFromError |
Transient → UNKNOWN retryable |
Error message heuristics |
| Reserve insufficient | No mutation |
| Ledger read | getProductLedger |
| Client idempotency on adjust | Duplicate key safe |
| Invariant | Proof |
|---|---|
| PO receive updates catalog once | Single batchUpdateStock |
| PO receive updates location once | Single adjustQuantity |
| Duplicate PO receive | No double-add (receive marker) |
| Catalog failure | Location never touched |
| Location failure | Catalog rolled back, LOCATION_RECEIVE_FAILED |
| Ledger records productId / locationId / purchaseOrderId | Line entries |
PurchaseOrderService never calls applyInventoryDeltas |
Static seal |
| Routes/UI use protocol only | Static seal |
[x] No route writes inventory count directly
[x] Checkout calls InventoryMutationBackend (reserve / confirm / release)
[x] Cart add/update calls checkAvailability for physical items
[x] Admin adjustments go through adjustInventory()
[x] PO receiving goes through receiveStockAtLocation() only
[x] Refund/transfer restock uses applyInventoryDeltas()
[x] Every mutation creates a ledger entry
[x] Every mutation has an idempotency key
[x] Duplicate reserve/confirm/release/adjust/deltas/receive do not double-mutate
[x] Partial cleanup returns structured report (207 on partial failure)
[x] InventoryResult<T> maps cleanly to HTTP
[x] Oversell creates reconciliation case, not silent corruption
[x] Firestore product repo rejects direct stock writes
[x] Product PATCH rejects stock field
[x] Seed loader: stock 0 + ledger-aligned bootstrap
[x] Admin bulk edits send client idempotency keys
[x] receiveStockAtLocation fans out catalog + location + ledger
[x] Location receive failure rolls back catalog when not transactional
[x] Ledger records productId / locationId / purchaseOrderId on location_receivesrc/core/inventory/
inventoryApplicationService.ts # Public interface + I/O types
InventoryFlowService.ts # Orchestrator
InventoryMutationService.ts # Only batchUpdateStock caller
InventoryReservationService.ts # Reservation lifecycle
InventoryLedgerService.ts # Append-only audit
InventoryMutationBackend.ts # Checkout contract (reserve/confirm/release)
inventoryResult.ts # InventoryResult<T> helpers
inventoryHttpMapping.ts # Error → HTTP status
createInventoryStack.ts # Wiring factory
index.ts # Public exports
src/infrastructure/
server/inventoryRouteAdapter.ts
repositories/firestore/FirestoreInventoryLedgerRepository.ts
repositories/firestore/FirestoreInventoryReservationRepository.ts
repositories/firestore/FirestoreInventoryReconciliationRepository.ts
repositories/firestore/FirestoreInventoryLevelRepository.ts
repositories/firestore/products/index.ts # Stock write guards
src/core/
container.ts # createInventoryApplication + services.inventory
PurchaseOrderService.ts # receiveStockAtLocation caller
ProductService.ts # adjustInventory for admin catalog
CartService.ts # checkAvailability
order/checkoutMutationService.ts # InventoryMutationBackend consumer
src/tests/
inventory-protocol.test.ts
inventory-verification-ladder.test.ts
inventory-reservation-proof.test.ts
inventory-location-consistency-ladder.test.ts
helpers/inMemoryInventoryStores.ts
- Production:
getInitialServices().inventoryorgetServiceContainer().inventory - Tests:
createInventoryStack()orcreateOrderTestStack()with in-memory repos - Never:
productRepo.batchUpdateStock()outsideInventoryMutationService - Never:
PurchaseOrderService→applyInventoryDeltas(usereceiveStockAtLocation)