Happy House - Ecommerce Docs
Developer ResourcesInventory

Inventory Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for the Inventory module.

Inventory - Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringinventory.module.ts, inventory-admin-aggregate.module.ts, inventory-import-export.module.ts, inventory-worker.module.ts, inventory-shared.module.tsComposition, shared services
Controllersadmin/stock/*.controller.ts, admin/job/*.controller.tsRoute ownership, guards, permissions
Servicesinventory-write.service.ts, inventory-reservation.service.ts, inventory-availability.service.ts, inventory-ledger.service.ts, inventory-projection.service.ts, inventory-job-lifecycle.service.tsGuarded UPDATEs, lifecycle, projections
DTOsdto/*.ts under each leafValidation
Schemapackages/db/src/schema/inventory/*.tsFour tables, GENERATED columns, CHECKs
Jobspackages/jobs/src/index.ts, workers/*InventoryJob names, lease claim, scheduler
Cachecache-invalidation.tags.tsinventory domain
Mathapps/api/src/utils/inventory/inventory-math.util.tsPure arithmetic, projections

2. Backend Scope and Boundaries

Owns

  • Stock counters, reservations, the movement ledger, per-product configuration.
  • Admin surface (stock, configuration, movements, bulk adjust) and import/export jobs.
  • The INVENTORY queue (one worker, five job names) and the maintenance scheduler.
  • product.stock_status as a synchronous projection of the inventory row.

Does Not Own

  • Pricing, promotions, orders, payments — it has no opinion on what a reservation is for.
  • The product domain — it consumes product ids, never the other way.
  • The outbox — generic infra; all enqueues go through it.

Source of Truth

ConcernSource of TruthNotes
Runtime statePostgreSQL (inventory, inventory_reservation, inventory_movement, inventory_job)
AvailabilityGENERATED columns — cannot be written
Movement historyinventory_movement (same-tx)Mongo is analytics only
Job stateinventory_job row + lease claim

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
InventoryModuleAggregateinventory.module.tsNoneLeavesComposes admin/worker/import-export
InventoryAdminAggregateModuleAggregateadmin/NoneLeavesComposes stock + job leaves
InventoryStockAdminModuleLeafadmin/stock/InventoryStockAdminController, InventoryStockAdminBulkControllerServicesStock surface
InventoryJobAdminModuleLeafadmin/job/InventoryJobAdminControllerServiceJobs surface
InventoryImportExportModuleLeafimport-export/NoneServicesImport/export orchestration
InventoryWorkerModuleLeafworkers/NoneProcessor/handlersQueue + scheduler
InventorySharedModuleLeafshared/NoneWrite/Reservation/Ledger/Availability/ProjectionServicesShared domain services

4. File and Directory Map

apps/api/src/modules/inventory/
  inventory.module.ts
  admin/
    stock/   inventory-stock-admin.{controller,service}.ts
             inventory-stock-admin-bulk.{controller,service}.ts  dto/
             inventory-stock-movements-admin.service.ts   # the ledger read
             inventory-admin-cursor.builder.ts            # keyset on variant_id
    job/     inventory-job-admin.{controller,service}.ts  dto/
  import-export/
    inventory-import-parser.ts   inventory-import-row.ts
    inventory-import.service.ts  inventory-export.service.ts
    inventory-job-lifecycle.service.ts
  shared/
    inventory-write.service.ts       # the ONLY permitted writer
    inventory-reservation.service.ts
    inventory-ledger.service.ts
    inventory-availability.service.ts
    inventory-projection.service.ts
    inventory-filter.builder.ts      inventory-response.builder.ts
    inventory-mutation.types.ts      inventory.constants.ts
  workers/
    inventory-queue.processor.ts     # THE one @Processor
    inventory-{reservation-sweep,reconcile,projection,job}.processor.ts
    inventory-maintenance.scheduler.ts
packages/db/src/schema/inventory/
  inventory.ts  inventory-reservation.ts  inventory-movement.ts
  inventory-job.ts  enums.ts
apps/api/src/utils/inventory/inventory-math.util.ts   # pure arithmetic

Key files:

FilePurposeKey ExportsNotes
shared/inventory-write.service.tsThe only writer of counter/config columnsInventoryWriteServiceGuarded UPDATEs, variant_id ordering
shared/inventory-variant-resolver.service.tsThe product → variant bridgeresolveStockVariant, resolveDefaultVariantId(s)For callers that hold only a product. Returns null rather than throwing — "every product has one live default variant" is enforced by application layers, not by a constraint, so its absence is representable
shared/inventory-reservation.service.tsReservation lifecycleInventoryReservationServiceGlobal key, guarded transitions
workers/inventory-queue.processor.tsThe one @Processor on INVENTORYInventoryQueueProcessorRoutes via Record<InventoryJob, handler>
import-export/inventory-job-lifecycle.service.tsJob row transitionsclaim, completeExport, failExportZero-row guard
utils/inventory/inventory-math.util.tsPure stock arithmeticderiveStockStatus, canReserve, canDisableOversellNo NestJS imports

5. Data Model

5.1 Schema Source

packages/db/src/schema/inventory/
  inventory.ts  inventory-reservation.ts  inventory-movement.ts
  inventory-job.ts  enums.ts

5.2 Tables

inventory

ColumnTypeNullableDefaultIndex/ConstraintRelationNotes
variant_idintegerNoPKproduct_variant.idOne row per variant — a second row for one variant is unrepresentable. This was product_id until migration 0021: once a product sells in several configurations, stock is a property of the configuration, and a product-keyed row cannot represent it at all
product_idintegerNoFK, index, not uniqueproducts.idDenormalised from product_variant.product_id and RETAINED after the PK move, because the storefront's hottest read is WHERE product_id IN (...) and joining through product_variant per card would be a wasted hop. A product-keyed query now matches N rows
public_iduuidNouuid7uniqueThe stock row's own identity, distinct from both ids above
total_quantityintegerNo0CHECKsNegative legal under allow_oversell (units owed)
reserved_quantityintegerNo0CHECK >= 0
available_quantityintegerNogeneratedGENERATED ALWAYS AS (total - reserved) — write attempt raises 428C9
stock_statusenumNogeneratedpartial index WHERE stock_status = 'low_stock'GENERATED CASE over counters
track_inventorybooleanNotrueCounters are authoritative unless turned off
allow_oversellbooleanNofalse
low_stock_thresholdintegerNo5CHECK >= 0Zero means "never warn"
versionintegerNo1CHECK >= 1Optimistic lock, configuration fields only

Every sort index is (sort_column, variant_id), not (sort_column, product_id). The admin list is keyset-paginated, and a keyset tiebreaker must be unique; product_id stopped being unique at the PK move, and a non-unique tiebreaker makes a page silently skip or repeat rows.

CHECKs: chk_*_total_quantity_* and chk_*_reserved_le_total gated on allow_oversell OR NOT track_inventory.

Every application-side guard that mirrors those CHECKs must carry BOTH escapes. The adjustStock WHERE clause and its util twin validateAdjustment carried only allow_oversell, which made them stricter than the database they mirror — and the gap was reachable, not theoretical.

createForVariant creates every row untracked, and the reserve guard short-circuits on untracked ("tracking disabled means unlimited purchasing, stock ignored"), so an untracked row legitimately reaches reserved > total. Adding stock to such a row was then refused as INVENTORY_INSUFFICIENT_STOCKan operator receiving 500 units told there was not enough stock, to add stock — and the only delta the guard would accept was larger than the quantity that had actually arrived. The database would have taken the write.

The tell that it was an oversight rather than a decision: the same NOT track_inventory escape had been deliberately added to canDisableOversell and canEnableTracking, with a long comment explaining why. It was applied to two of the three sibling predicates and missed on the third plus its SQL twin.

inventory_reservation

reservation_key varchar globally unique, NOT partial; status enum (active/released/finalized/expired); expires_at business timestamp; quantity positive.

inventory_movement

Append-only ledger, keyed on variant_id (FK to inventory.variant_id) with product_id carried alongside so a product-level audit needs no join: kind enum (6 values incl. correction), reason, source, the signed total_delta / reserved_delta, the post-write total_after / reserved_after snapshots, note, actor_*, correlation_id, timestamps. Written in the same transaction as the counter change.

inventory_job

Mirrors catalog_job: kind (import/export), entity (inventory), status enum, counters, retained errors (cap 500), result_file_url required on completed export.

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 InventoryWriteService

MethodCalled ByReadsWritesSide EffectsErrors
createForProduct()product create/importinventory row (untracked)
adjust()admin adjust, importrowcounters + movementprojection (outbox), cacheINVENTORY_ADJUSTMENT_WOULD_GO_NEGATIVE, INVENTORY_INSUFFICIENT_STOCK
updateConfiguration()admin configrow + versionconfig fields (+ movement if counters change)projection, cacheINVENTORY_VERSION_CONFLICT, INVENTORY_OVERSELL_DISABLE_BLOCKED
bulkAdjust()admin bulkrowscounters + movementsprojection, cacheper-item failures

Every mutation is a guarded UPDATE, never a read-then-writerowCount = 0 means "refused" and is the only trustworthy way to learn there is not enough stock. Multi-row operations sort by variant_id ascending (unordered writes deadlock — reproduced 40P01). The ordering key follows the primary key: ordering by product_id leaves the lock order between two variants of one product unconstrained, which is all a deadlock needs.

6.2 InventoryReservationService

Reserve (idempotent insert under the global key, TTL default 15 min), release (idempotent, twice OK), finalize (409 on second), expire (sweep claims batches). Every transition writes a movement in the same transaction.

6.3 InventoryLedgerService / InventoryAvailabilityService

Ledger: movement writes + reads with _before/_after snapshots. Availability: batched reads for product responses (one query per page).

6.4 InventoryProjectionService

Writes product.stock_status as a synchronous projection: only when tracking is on and the current value is not pre_order (a pre-order product's status is admin truth and is never overwritten). Documented reset on the true → false tracking edge. Mongo activity projection happens asynchronously via the outbox (PROJECT_ACTIVITY).

The status written is the product's AGGREGATE across its live variants, not the status of the one variant that moved. The projection re-reads every inventory row belonging to an active, non-deleted variant of the product and collapses them with aggregateAvailability, the same function and the same PRODUCT_STOCK_STATUS_PRECEDENCE the read path uses — a product is as buyable as its most buyable variant.

This is the correction to a real defect, not a description of a design choice.

The projection used to write the moved variant's own status straight onto the product. Once a product could have more than one variant that was wrong in the most visible way possible: reserving the last unit of a one-unit configuration marked the whole product out_of_stock while a sibling still had ten sellable units, and nothing re-projected until an unrelated movement happened to land on that sibling. The storefront filters on this column, so the product simply disappeared from sale.

The rule already existed — inventory-math.util.ts had written aggregateAvailability precisely because "every caller that still builds a Map keyed on product_id silently keeps whichever row the planner returned LAST". The read path used it. Only this write did not, so the denormalized column and the live-computed answer disagreed for every multi-variant product.

It survived because every fixture in inventory-write.service.spec.ts built exactly one variant per product, which makes "this variant's status == the product's status" true trivially. inventory-stock-admin.int.spec.ts states that lesson in its own header — "a single-variant fixture reproduces none of them" — and it had been applied there and not here.

The trackingTurnedOff edge now also asks the aggregate: it resets to in_stock only when no live variant tracks any more, so "tracking disabled means unlimited purchasing" is true of the product as a whole rather than of one configuration.

Soft-deleted and inactive variants are excluded, matching InventoryAvailabilityService. A retired configuration must not decide whether the product can be bought.

7. Runtime Flows

7.1 Guarded adjustment

7.2 Reservation reserve

8. Cache

DomainRevalidation tagsRedis patterns
inventoryinventory:* tags + page tagsinventory:*, product:* where stock is embedded

Every write invalidates the inventory domain after commit; product responses carrying the inventory group are refreshed the same way.

9. Jobs and Workers

QueueDispatcherJob names
INVENTORYinventory-queue.processor.ts (the only @Processor)IMPORT_STOCK, EXPORT_STOCK, SWEEP_EXPIRED_RESERVATIONS, RECONCILE, PROJECT_ACTIVITY
  • Import/export: enqueued through the outbox; lease-based claim guarded on status = 'queued'; import owns its terminal write (accumulates per-row errors); export's terminal write carries total_rows + result_file_url together; failExport never advertises a partial file.
  • Sweep: every minute via @Cron — settles expired reservations in batches (default TTL 15 min).
  • Reconcile: every hour — asserts reserved = SUM(active) and the newest _after matches counters; repairs with correction movements, never silent rewrites; heals products with no row.

9.1 How the reconciler picks its batch, and why that is load-bearing

It reads a bounded batch per pass (INVENTORY_RECONCILE_BATCH_SIZE), ordered last_reconciled_at ASC NULLS FIRST, variant_id. Every row it examines is stamped — clean ones too — so a row just audited sorts last and cannot come up again until every other row has had a turn. Never-audited rows go first, which is why the column is nullable with no backfill.

This job is the only drift detection the module has, and its coverage was a fixed prefix of the table.

The claim was ORDER BY product_id LIMIT n with no cursor, offset or watermark, so every hourly pass examined the same first n rows. Past n inventory rows the remainder was never audited — not "within a day" as the constant's comment claimed, but never. product_id also stopped being unique at migration 0021, so the prefix was not even deterministic across passes.

Separately, the per-row loop had no error isolation. applyCorrection writes reserved_quantity as an absolute and is deliberately unguarded, so a row whose active holds sum past total_quantity — exactly the drift this job exists to find — makes the write violate chk_inventory_reserved_within_total and throw. Unhandled, that failed the whole job, and every BullMQ retry hit the same row first: nothing ordered after it was ever reconciled again.

Both are fixed. Each row now runs in its own try, matching the reservation sweeper, and the watermark is stamped outside it so a row that threw still advances — otherwise a poisoned row monopolises the front of the queue and reintroduces the same defect one row at a time.

9.2 A crashed import is recorded as failed, not left running

claim() flips the job row to processing before the import service runs, and everything before the write transaction — resolving the requester, reading the file, parsing, duplicate detection, product resolution — used to be unprotected. A throw there exited with no database write at all and the row stuck at processing.

That state could not be retried out of: the next attempt's claim() requires status = 'queued', finds processing, and returns false — and the processor returns rather than throwing, so BullMQ recorded a clean run and stopped. The job then sat at processing with no errors and no finished_at, indistinguishable from one still in flight. An upload removed between submit and pickup was enough.

run() now wraps the whole flow and records the failure before rethrowing, and the terminal status is forced to failed rather than derived from failedRows — on a crash no row was reached, so failedRows is 0, and deriving from it would have recorded a job that died reading its own file as completed.

  • Projection: via outbox after movements.
  • Both cron jobs enqueue directly (no DB write to be atomic with) — the documented exemption.
  • One worker per queue is the repo-wide rule — see BullMQ Worker Wiring.

10. Security and Authorization

  • Admin: JwtAuthGuard + RoleGuard; permissions Inventory_READ / Inventory_UPDATE; superadmin bypasses.
  • Import submit requires Inventory_UPDATE; export Inventory_READ; cancel Inventory_UPDATE.
  • Rate limits: ADMIN_READ 30/min, ADMIN_WRITE 10/min, ADMIN_BULK_WRITE 5/min, ADMIN_ASYNC_JOB_SUBMIT 10/hour.
  • InventoryWriteService is the only class permitted to UPDATE any column stock_status is generated from.
  • No guest surface exists; stock reaches customers through the product response only.