Happy House - Ecommerce Docs
Developer ResourcesInventory

Inventory API Reference

Complete API contracts for the Inventory module, including routes, auth, DTOs, responses, errors, examples, and integration notes.

Inventory - API Reference

Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: Admin stock, configuration, movements, bulk adjust, and import/export job APIs owned by the Inventory module. There is no customer-facing inventory endpoint — stock reaches the storefront through the product response's inventory group.

1. Documentation Evidence

AreaFiles InspectedWhat Was Verified
Controllersadmin/stock/inventory-stock-admin.controller.ts, inventory-stock-admin-bulk.controller.ts, admin/job/inventory-job-admin.controller.tsRoutes, methods, guards, permissions, status codes
DTOsdto/*.tsValidation, defaults, enums
Servicesinventory-stock-admin.service.ts, inventory-job-admin.service.ts, shared servicesBehavior, side effects, errors
Schemapackages/db/src/schema/inventory/*.tsTables, GENERATED columns, constraints
Jobs/cachepackages/jobs/src/index.ts, cache-invalidation.tags.tsQueue names, payloads, domains
Error registryapps/api/src/common/types/error-codes.tsINVENTORY_* codes

2. Module Summary

FieldValue
Module nameinventory
Module sluginventory
Primary actorsadmin, worker, internal system
API surfacesadmin only
Base route prefixes/api/admin/inventory, /api/admin/inventory/jobs
Auth modelJwtAuthGuard + RoleGuard
PersistencePostgreSQL (4 tables), Redis (cache), BullMQ (INVENTORY queue via outbox), MongoDB (activity projections)
Runtime source of truthPostgreSQL tables
Sibling docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
productPublicIdThe product a row belongs to. Stock is NOT keyed on it — see variantPublicIdcontrollersAll stock routes
variantPublicIdThe row's real identity. inventory's primary key is variant_id, so a product with three variants owns three stock rows. Every response names its variant, and the variant-scoped routes are addressed by thisinventory-stock-admin.service.tsAll stock responses
variantName, variantSku, isDefaultVariantVariant identity carried on every stock response, off the innerJoin(product_variant) the query already needs to resolve the row. variantName is null when the variant IS the product — render the product's name, never a literal like "Default"sameAll stock responses
productName, productSkuProduct identity carried on every stock response, off the innerJoin(products) the query already performs. Added so a list row is readable without a per-row lookup — that N+1 exceeds the 30/min ADMIN_READ budget on the second page view and degrades a list to raw uuidssameAll stock responses
availableQuantitytotal - reserved, GENERATED — read-onlyschemaAll responses
stockStatusGENERATED enum (not_tracked, in_stock, low_stock, out_of_stock, overselling)schemaResponses
trackInventoryWhether the row's counters are authoritativeschemaConfiguration
allowOversellPermits negative total_quantity (units owed)schemaConfiguration, guards
versionOptimistic lockschemaConfiguration
reservationKeyGlobally unique idempotency key for reservationsreservation serviceReservations (internal)
cursorOpaque keyset tokencursor utilList/movements

4. API Surface Map

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/admin/inventoryAdminJWT+RoleInventory_READInventoryStockAdminControllerCursor-paginated list
AdminGET/api/admin/inventory/products/:productPublicIdAdminJWT+RoleInventory_READsameThe product's default variant's row
AdminPATCH/api/admin/inventory/products/:productPublicId/configurationAdminJWT+RoleInventory_UPDATEsameTracking/oversell/threshold, default variant
AdminPOST/api/admin/inventory/products/:productPublicId/adjustAdminJWT+RoleInventory_UPDATEsameManual adjustment, default variant
AdminGET/api/admin/inventory/products/:productPublicId/movementsAdminJWT+RoleInventory_READsameMovement ledger, all variants
AdminGET/api/admin/inventory/products/:productPublicId/variantsAdminJWT+RoleInventory_READsameEvery variant's row, unpaginated
AdminGET/api/admin/inventory/products/:productPublicId/variants/:variantPublicIdAdminJWT+RoleInventory_READsameOne variant's row
AdminPATCH/api/admin/inventory/products/:productPublicId/variants/:variantPublicId/configurationAdminJWT+RoleInventory_UPDATEsameOne variant's configuration
AdminPOST/api/admin/inventory/products/:productPublicId/variants/:variantPublicId/adjustAdminJWT+RoleInventory_UPDATEsameOne variant's adjustment
AdminGET/api/admin/inventory/products/:productPublicId/variants/:variantPublicId/movementsAdminJWT+RoleInventory_READsameOne variant's ledger
AdminPOST/api/admin/inventory/bulk/adjustAdminJWT+RoleInventory_UPDATEInventoryStockAdminBulkControllerBulk adjust
AdminPOST/api/admin/inventory/jobs/importAdminJWT+RoleInventory_UPDATEInventoryJobAdminControllerCSV import job
AdminPOST/api/admin/inventory/jobs/exportAdminJWT+RoleInventory_READsameExport job
AdminPOST/api/admin/inventory/jobs/:publicId/cancelAdminJWT+RoleInventory_UPDATEsameCancel job
AdminGET/api/admin/inventory/jobsAdminJWT+RoleInventory_READsameJob list
AdminGET/api/admin/inventory/jobs/:publicIdAdminJWT+RoleInventory_READsameJob detail

The /products/ segment is deliberate, so a future /admin/inventory/reservations leaf cannot collide with a bare :productPublicId route.

There are two address forms over the same rows, and the distinction is not cosmetic. inventory's primary key has been variant_id since migration 0021, so "the stock of product X" is a complete address only while X has one variant.

  • /products/{id}/… resolves the product's default variant, on reads and writes alike.
  • /products/{id}/variants/{id}/… names the row explicitly. This is what a screen offering a variant picker uses. The product stays in the path so the variant is resolved within it — a variant of another product addressed here is a 404, not a write whose movement-ledger entry and cache invalidation both name the wrong product.

The product-addressed detail read used to be … WHERE products.public_id = $1 LIMIT 1 with no ORDER BY, returning an arbitrary one of the N rows, while the writes resolved the default. Read and write could name different rows: the screen showed 12 units, an adjustment of +5 was accepted, and a different variant became 17. Nothing in the response named either row, so nothing on screen could reveal it, and it stayed invisible because every product in the database had exactly one variant.

5. Auth, Identity, and Permissions

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
AdminJwtAuthGuard, RoleGuard, IpThrottlerGuardreq.userInventory_READ / Inventory_UPDATENosuperadmin bypasses

Rate limits: ADMIN_READ 30/min (list/detail/movements/jobs), ADMIN_WRITE 10/min (configuration/adjust/cancel), ADMIN_BULK_WRITE 5/min (bulk adjust), ADMIN_ASYNC_JOB_SUBMIT 10/hour (import/export). Idempotency: Idempotency-Key required on job submits (scopes inventory-job-import / inventory-job-export); missing → 400 IDEMPOTENCY_KEY_REQUIRED, replay-mismatch → 409, in-flight → 409.

6. DTO and Model Reference

6.1 AdjustInventoryAdminDto

FieldTypeRequiredDefaultValidationExampleSource
deltanumberYesN/A@IsInt, @NotEquals(0); may be negative-2adjust-inventory-admin.dto.ts
reasonenumYesN/A@IsIn(inventoryMovementReasonEnum)"damage"
notestringNoNULLmax 1000"Two units damaged in transit"

The field is delta, a signed change and not a new total, and zero is rejected outright — a movement that moves nothing is a ledger row that explains nothing.

6.2 UpdateInventoryConfigurationAdminDto

FieldTypeRequiredDefaultValidationExample
trackInventorybooleanNo@IsBooleantrue
allowOversellbooleanNo@IsBooleanfalse
lowStockThresholdnumberNo@IsInt, >= 05
versionnumberYes@IsInt, >= 13

6.3 Bulk adjust

{ items: [{ productPublicId, variantPublicId?, delta }], reason, note? }reason and note apply to the whole batch; a caller adjusting for two reasons sends two requests. Max 100 items (409 INVENTORY_BULK_LIMIT_EXCEEDED).

variantPublicId is optional and means the product's default variant when omitted — correct for a product sold in one configuration, and an arbitrary choice for anything else. A multi-variant product should always name one.

Result:

{
  "succeeded": [{ "productPublicId": "018f…", "variantPublicId": "019b…" }],
  "failures": [
    { "productPublicId": "018f…", "variantPublicId": "019b…", "errorCode": "INVENTORY_ADJUSTMENT_WOULD_GO_NEGATIVE" }
  ]
}

variantPublicId on a success row is the variant the item resolved to, not the one it asked for, so an item that named none still reports the counter that moved. It is null on a failure row only when the failure is why no variant could be resolved — an unknown product, or one with no default variant.

Dedup is by (product, variant), not by product, so two items naming two variants of one product are two counters and both apply. A second dedupe runs after resolution, keyed on the resolved variant_id: the pre-resolution key cannot tell that {product} and {product, its default variant} are the same row, and applying both would double a delta on a duplicate the caller could not see was one.

6.4 Query DTOs

FetchInventoryAdminDto: cursor, limit (1..100, default 20), plus filters. FetchInventoryMovementsAdminDto: cursor, limit. FetchInventoryJobDto: kind, entity, status, page/size.

7. Enum Reference

EnumValueMeaningRuntime EffectSource
inventory_stock_statusnot_trackedTracking offGenerated; product projection skippedenums.ts
inventory_stock_statusin_stock / low_stock / out_of_stock / oversellingAvailabilityGenerated from counters + threshold
inventory_reservation_statusactive / released / finalized / expiredReservation lifecycleGuarded transitions
inventory_movement_kindadjustment, reservation_created, reservation_released, reservation_finalized, reservation_expired, correctionLedger entry typeWritten same-tx
inventory_job_statusqueued / processing / completed / failed / cancelledJob lifecycleLease claim

8. Endpoint Reference

8.1 POST /api/admin/inventory/products/:productPublicId/adjust

Purpose

Manually adjust a product's stock — restock, write-off, physical-count fix. The only way to change counters (besides import and the reconciler).

Auth and Permissions

JwtAuthGuard, RoleGuard, IpThrottlerGuard; Inventory_UPDATE; ADMIN_WRITE 10/min; no idempotency header.

Request

Body per §6.1.

Response

200 — inventory envelope:

{
  "message": "Inventory adjusted successfully",
  "data": {
    "productPublicId": "018f4e2a-…",
    "productName": "Gaming Laptop",
    "productSku": "HH0001",
    "variantPublicId": "019b0b5f-…",
    "variantName": "32GB / Black",
    "variantSku": "HH0001-32-BLK",
    "isDefaultVariant": true,
    "publicId": "019fcc90-…",
    "totalQuantity": 18,
    "reservedQuantity": 2,
    "availableQuantity": 16,
    "stockStatus": "in_stock",
    "trackInventory": true,
    "allowOversell": false,
    "lowStockThreshold": 5,
    "version": 3
  },
  "errorCode": null
}

availableQuantity and stockStatus are GENERATED — read-only. publicId is the inventory row's own id; variantPublicId is what the variant-scoped routes are addressed by. The three ids are distinct and conflating them is a 404.

Side Effects

Guarded UPDATE + inventory_movement row in the same transaction; product.stock_status projection; outbox → Mongo activity projection; cache invalidation.

Error Cases

HTTPCodeCondition
400INVENTORY_QUANTITY_INVALIDMalformed delta
404INVENTORY_NOT_FOUNDNo row (create is implicit on first adjust)
409INVENTORY_ADJUSTMENT_WOULD_GO_NEGATIVEWould go negative without oversell
409INVENTORY_INSUFFICIENT_STOCKGuarded operation lacks stock

Edge Cases

First adjustment on an untracked row auto-enables tracking. Oversell rows may go negative (units owed). A zero delta is rejected by the DTO (@NotEquals(0)).

Without variantPublicId this moves the product's default variant. On a multi-variant product that is a real choice being made silently, so a screen that lets an operator see one variant's count must adjust through the variant-scoped route, or it will show one row and move another.

8.2 PATCH /api/admin/inventory/products/:productPublicId/configuration

Purpose

Enable/disable tracking, allow/disallow oversell, set the low-stock threshold.

Request

Body per §6.2.

Error Cases

HTTPCodeCondition
409INVENTORY_VERSION_CONFLICTStale version
409INVENTORY_OVERSELL_DISABLE_BLOCKEDDisabling oversell while units are owed
409re-enable tracking on a row in debtRefused until debt resolved

8.3 The read surface

GET /api/admin/inventory — cursor-paginated list, one row per variant. GET /products/:productPublicId — the default variant's row, 404 INVENTORY_NOT_FOUND when there is none. GET /products/:productPublicId/movements — the ledger, newest first, cursor-paginated. All Inventory_READ / ADMIN_READ 30/min.

8.3a The variant-scoped read surface

GET /products/:productPublicId/variants returns every variant's row for one product, unpaginated — the row count is the product's variant count, which is bounded far below a page, and this is what a variant picker reads. Ordered by the variant's own position, so it matches the order the product screen lists its variants in; an operator comparing the two screens is comparing the same list.

GET /products/:productPublicId/variants/:variantPublicId is the detail read, and …/movements is that variant's ledger alone. The product-scoped ledger answers "what happened to this product"; the variant-scoped one answers "where did these units go", which a combined ledger cannot.

Every variant-scoped route resolves the variant within the product, so a variant belonging to another product is 404 INVENTORY_NOT_FOUND rather than a cross-product read.

8.4 POST /api/admin/inventory/bulk/adjust

200 with a per-item result; up to 100 items; one failure never rolls back the batch, including an unresolvable product or variant — that used to throw inside the transaction and discard the items that had already applied.

Writes are ordered by variant_id ascending, not product_id. inventory's row lock is taken on its primary key, which is variant_id; ordering by product leaves the lock order between two variants of one product unconstrained, which is all a deadlock (SQLSTATE 40P01) needs.

Resolution runs as a separate pass over the whole batch before anything is written — the variant ids are not known until every item is resolved, and the apply pass has to run in their order.

8.5 POST /api/admin/inventory/jobs/import

multipart/form-data (file + entity). CSV only (unlike catalog/products), 25 MB, 50,000 rows; required columns productPublicId (UUID v7) + quantityDelta (integer), optional note. Idempotency-Key required; ADMIN_ASYNC_JOB_SUBMIT 10/hour. Returns 200 with the queued job.

8.6 POST /api/admin/inventory/jobs/export

JSON body; Idempotency-Key required; > 50,000 matching rows fails the job (INVENTORY_EXPORT_TOO_LARGE); zero-row export succeeds with a header-only file.

8.7 POST /api/admin/inventory/jobs/:publicId/cancel

200 with the job row; 404 INVENTORY_JOB_NOT_FOUND; 409 INVENTORY_JOB_NOT_CANCELLABLE from a terminal state. Cooperative — a processing import aborts and rolls back.

8.8 GET /api/admin/inventory/jobs(/:publicId)

List (paginated, filters kind/entity/status) and detail (adds startedAt, isStalled, retained errors).

8.9 GET /api/admin/inventory/jobs/import-template

Downloads the column template for an import file — header row only, no example row, because a downloadable import file is eventually uploaded unmodified and a data row would create junk. Zero rows is a no-op.

entity is inventory — required even though it is the only value, so the three template routes read alike.

Permission Inventory_READ — the file carries no data, only column names. Rate limit ADMIN_READ 30/min. Returns 200 as text/csv; charset=utf-8 with Content-Disposition: attachment. An unknown entity fails DTO validation with 400.

Columns: productPublicId, quantityDelta, note. quantityDelta is a signed change, not a new total.

The list is generated from the same constant the row builder's fields are typed against, so a column cannot silently diverge from what the parser reads: satisfies rejects a column that is not a row field, an Exclude assertion rejects a row field with no column, and a round-trip spec parses the emitted template back through the real builder.

Route ordering is load-bearing — this literal segment is declared above @Get(":publicId") in the controller. Declared after it, the wildcard swallows the path and the request fails as "publicId must be a UUID".

9. Flow Diagrams

9.1 Route Ownership

9.2 Request Sequence (adjust)

9.3 Error Branch (adjust)

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /api/admin/inventorykeyset cursor20100(sortKey, variantId)stock status, trackingper page
GET /api/admin/inventory/products/:id/movementskeyset cursor20100newest firstkindper page
GET /api/admin/inventory/jobsoffset page/size20100createdAtkind/entity/statusoffset cap

No search endpoint exists; the inventory list supports stock-status and tracking filters only.

The list's cursor tiebreaker is variant_id, and a cursor carrying anything else is rejected with 400 INVENTORY_PAGINATION_CURSOR_INVALID. A keyset cursor is correct only when its tiebreaker is unique. product_id was unique while it was the primary key; after migration 0021 two variants of one product share it, and a page boundary between them silently skips a row or repeats one. Rejecting rather than ignoring matters too: an unmappable cursor column drops the WHERE clause, which re-serves page 1 with a fresh page-1 cursor — a client that follows nextCursor loops forever. Every sort index in inventory.ts is (sort_column, variant_id) for the same reason.

11. Caching, Jobs, and External Integrations

IntegrationUsed?DetailsSource
Redis cacheYesinventory cache domain; invalidated on every writecache-invalidation.tags.ts
BullMQYesINVENTORY queue: 5 job names; one worker; import/export via outbox; cron sweep + reconcilepackages/jobs/src/index.ts
MongoDBYesActivity/analytics projections via outbox — never a ledgerinventory-projection.service.ts
External APINo

13. Mandatory Deep API Documentation Pack

13.1 Route-by-Route Completeness Matrix

RouteController MethodDTOsService MethodGuardsPermissionsCacheJobsDB TouchesErrorsDocumented?
GET /api/admin/inventoryfindAllFetchInventoryAdminDtoInventoryStockAdminService.findAllJWT+Role+IpThrottleInventory_READinventoryinventoryYes
GET /products/:productPublicIdfindOneInventoryProductParamsDto…findOnesameInventory_READinventoryinventory404Yes
PATCH /products/:productPublicId/configurationupdateConfigurationUpdateInventoryConfigurationAdminDto…updateConfigurationsameInventory_UPDATEinvalidateprojectioninventory409Yes
POST /products/:productPublicId/adjustadjustAdjustInventoryAdminDto…adjustsameInventory_UPDATEinvalidateprojectioninventory, movement400/404/409Yes
GET /products/:productPublicId/movementsfindMovementsFetchInventoryMovementsAdminDtoInventoryStockMovementsAdminService.findMovementssameInventory_READmovement404Yes
GET /products/:productPublicId/variantsfindAllForProductInventoryProductParamsDto…findAllForProductsameInventory_READinventoryinventory404Yes
GET /products/:productPublicId/variants/:variantPublicIdfindOneVariantInventoryVariantParamsDto…findOnesameInventory_READinventoryinventory404Yes
PATCH /products/:productPublicId/variants/:variantPublicId/configurationupdateVariantConfigurationInventoryVariantParamsDto, UpdateInventoryConfigurationAdminDto…updateConfigurationsameInventory_UPDATEinvalidateprojectioninventory404/409Yes
POST /products/:productPublicId/variants/:variantPublicId/adjustadjustVariantInventoryVariantParamsDto, AdjustInventoryAdminDto…adjustsameInventory_UPDATEinvalidateprojectioninventory, movement400/404/409Yes
GET /products/:productPublicId/variants/:variantPublicId/movementsfindVariantMovementsInventoryVariantParamsDto, FetchInventoryMovementsAdminDtoInventoryStockMovementsAdminService.findMovementssameInventory_READmovement404Yes
POST /bulk/adjustbulkAdjustbulk DTOInventoryStockAdminBulkService.bulkAdjustsameInventory_UPDATEinvalidateprojectioninventory, movement409Yes
POST /jobs/importsubmitImportSubmitInventoryImportDtoInventoryJobAdminService.submitImportJWT+Role+IpThrottle+IdempotencyInventory_UPDATEoutbox → inventory.import_stockinventory_job, outbox_events400/409Yes
POST /jobs/exportsubmitExportSubmitInventoryExportDto…submitExportsameInventory_READoutbox → inventory.export_stockinventory_job, outbox_events400/409Yes
POST /jobs/:publicId/cancelcancelInventoryJobParamsDto…cancelJWT+Role+IpThrottleInventory_UPDATEinventory_job404/409Yes
GET /jobsfindAllFetchInventoryJobDto…findAllJWT+Role+IpThrottleInventory_READinventory_jobYes
GET /jobs/:publicIdfindByIdInventoryJobParamsDto…findByIdJWT+Role+IpThrottleInventory_READinventory_job404Yes

13.2 Request/Response Exhaustiveness

Covered in §8: minimal adjust request (§6.1/8.1), full configuration request (§6.2/8.2), success responses (§8.1), empty-list behavior (cursor lists return data: [], nextCursor: null), validation error (400 INVENTORY_QUANTITY_INVALID), domain errors (§8 error tables), rate-limit behavior (429), permission errors (403).

13.3 API Diagram Pack

Route ownership (§9.1), sequence per endpoint family (§9.2, backend §7), activity diagrams per mutation family (§9.3, feature §6.1), error decision trees (§9.3), async/job flow (backend §7 + §9), cache flow (backend §8).

13.4 Consumer Integration Notes

ConsumerRequired KnowledgeFailure HandlingContract Stability
Admin panelGuards (oversell disable, debt re-enable), version lock, per-item bulk failures409 codes → specific messages; re-read on version conflictStable
Product domain (internal)inventory response group; stock PATCH narrowing409 INVENTORY_STOCK_STATUS_DERIVED → adjust via inventoryStable
Future cart/checkoutReservation contract (reservation_key, TTL, release/finalize)409 ALREADY_SETTLED on double finalizeStable (internal)
QAGENERATED columns read-only, oversell semantics, zero-row exportReproduce via exact codesStable

13.5 API Tradeoffs and Rationale

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
Product-addressed routes/products/:productPublicId/...Inventory-id routesFuture reservation leaf cannot collideLonger pathsDocumented
Cursor paginationKeysetOffsetStable orderingSort-bound cursorsLoud 400
Generated columnsDB-computed availabilityService-computedNo stale-write path428C9 on misuseDocumented
No guest endpointsAdmin-only surfacePublic stock readsStock reaches storefront via product groupDocumented
Version lockOptimisticLast-write-winsConfig races on shared row409 churnRe-read/retry

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
inventory group populatedWeb/mobile/adminAssemblerNone (was null)NoReserved-null contract
PATCH /admin/products/:publicId/stock 409Admin panelresolveStockStatusForWriteNoneNoUntracked unchanged

14. Zero-Omission API Checklist

  • Every controller route is documented (§4, §8, §13.1).
  • Every parent route prefix and runtime URL is documented (§2, §4).
  • Every DTO field, enum, default, transform and validator is documented (§6, §7).
  • Every response field, nullable field, generated field and omitted raw entity field is documented (§8.1).
  • Every auth, guard, permission, public decorator and guest identity branch is documented (§5).
  • Every success, validation, auth, permission, not-found, conflict, rate-limit and server-error branch is documented (§8).
  • Every DB read/write, cache invalidation, queue job, audit log and external call is documented (§11, backend §8/§9).
  • Every route has examples for minimal request, success response and representative failures (§8).
  • Every endpoint family has route, sequence, activity and error diagrams (§9, backend §7).
  • Every tradeoff and compatibility risk is documented (§13.5, §13.6).
  • The API doc links to backend and features/flows (§1, See Also).

15. Integration Checklist

  • Every route from controllers is documented.
  • Every DTO field is documented.
  • Every enum value is documented.
  • Every response envelope is documented.
  • Every error code is documented.
  • Every auth guard and permission is documented.
  • Every cache key, queue job and external call is documented.
  • Every diagram matches the current code.
  • The API doc links to backend and features/flows.

See Also