Inventory Module — Vendor surface
Vendor-facing HTTP endpoints for managing product-variant stock levels, policies, manual adjustments, audit trails, vendor-wide variant listing, and bulk CSV imports.
Vendor-facing HTTP endpoints for managing product-variant stock levels, policies, manual adjustments, audit trails, vendor-wide variant listing, and bulk CSV imports.
Source:
api-modules/inventory/src/controllers/vendor-inventory.controller.ts,api-modules/inventory/src/controllers/vendor-inventory-variants.controller.ts,api-modules/inventory/src/controllers/vendor-inventory-import.controller.ts.
Conventions
Authentication
All endpoints require a Better-Auth bearer session.
Authorization: Bearer <session-token>The vendor scope is read from the session via resolveActiveVendorId() — it prefers session.session.activeOrganizationId (better-auth canonical) and falls back to activeVendorId. If neither is set, the request fails with 403 Forbidden (No active vendor on session).
Tenant scoping
Every read and write scopes to the active vendor's id. Product / variant / batch ids that belong to a different vendor return 404 Not Found, never 403 — so existence of another vendor's row can't be inferred.
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
Failures are normalized by HttpExceptionFilter:
{
"data": null,
"message": "Human-readable summary",
"statusCode": 400,
"errorCode": "BAD_REQUEST",
"errors": [ /* zod issues, when validation fails */ ],
"debug": { /* dev-only: error + stack */ }
}statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, NOT_NULL_VIOLATION |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT, UNIQUE_VIOLATION, FOREIGN_KEY_VIOLATION |
| 413 | HTTP_413 (payload too large) |
| 422 | UNPROCESSABLE_ENTITY |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Domain types
InventorySnapshot
type InventoryStockStatus =
| "in_stock"
| "low_stock"
| "out_of_stock"
| "untracked"
| "backorder";
type InventorySnapshot = {
variantId: string;
productId: string;
vendorId: string;
trackInventory: boolean;
quantityOnHand: number;
reservedQuantity: number;
safetyStockQuantity: number;
lowStockThreshold: number | null;
allowBackorder: boolean;
backorderLimit: number | null;
availableQuantity: number | null; // null when trackInventory=false
isOrderable: boolean;
stockStatus: InventoryStockStatus;
};InventoryMovement
Immutable audit row, one per stock change.
type InventoryMovement = {
id: string;
variantId: string;
productId: string;
vendorId: string;
reservationId: string | null;
type:
| "adjustment"
| "reservation_created"
| "reservation_committed"
| "reservation_released"
| "reservation_expired"
| "order_cancelled"
| "order_edited"
| "return_restocked"
| "import";
quantityDelta: number; // signed
reservedDelta: number; // signed
previousQuantityOnHand: number;
newQuantityOnHand: number;
previousReservedQuantity: number;
newReservedQuantity: number;
reason: string | null;
referenceType: string | null;
referenceId: string | null;
actorId: string | null;
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
};VendorInventoryVariantListItem
Lightweight projection for the vendor-wide variant list.
type VendorInventoryVariantListItem = {
variantId: string;
productId: string;
sku: string | null;
productTitle: string;
productThumbnail: string | null;
trackInventory: boolean;
availableQuantity: number | null; // null when trackInventory=false
stockStatus: InventoryStockStatus;
};InventoryImportPreview
type InventoryImportPreview = {
batchId: string;
status:
| "validated"
| "failed_validation"
| "applied"
| "applying"
| "failed";
totalRows: number;
validRows: number;
invalidRows: number;
rows: InventoryImportPreviewRow[];
};
type InventoryImportPreviewRow = {
rowNumber: number;
sku: string | null;
variantId: string | null;
productId: string | null;
productTitle: string | null;
variantLabel: string | null;
currentQuantityOnHand: number | null;
quantityDelta: number | null;
newQuantityOnHand: number | null;
status: "valid" | "invalid" | "applied" | "skipped";
errorCode?:
| "MISSING_SKU"
| "MISSING_QUANTITY"
| "INVALID_QUANTITY"
| "DUPLICATE_SKU_IN_FILE"
| "SKU_NOT_FOUND"
| "SKU_NOT_OWNED_BY_VENDOR"
| "VARIANT_DELETED"
| "INVENTORY_ROW_NOT_FOUND";
errorMessage?: string;
};Vendor-wide variant listing
Base path: /vendor/inventory/variants.
GET /vendor/inventory/variants — List the vendor's variants
Cross-product variant list with stock + status. Use for the inventory dashboard, low-stock pickers, and the import preview's product context.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Substring match on product title / SKU |
stockStatus | "in_stock" | "low_stock" | "out_of_stock" | "backorder" | "untracked"? | — | Filter by current stock bucket |
limit | int | 50 | 1..200 |
offset | int | 0 | >= 0 |
Response 200 — paginated envelope of VendorInventoryVariantListItem[].
Per-variant inventory
Base path: /vendor/products/:productId/variants/:variantId/inventory.
GET / — Get inventory snapshot
Returns the current stock state for a variant the calling vendor owns.
Path params
| Name | Type | Notes |
|---|---|---|
productId | string (UUID) | Must belong to the active vendor |
variantId | string (UUID) | Must belong to productId |
Response 200
{
"data": {
"variantId": "01J9...",
"productId": "01J9...",
"vendorId": "01J9...",
"trackInventory": true,
"quantityOnHand": 42,
"reservedQuantity": 3,
"safetyStockQuantity": 5,
"lowStockThreshold": 10,
"allowBackorder": false,
"backorderLimit": null,
"availableQuantity": 39,
"isOrderable": true,
"stockStatus": "in_stock"
},
"message": "Success",
"statusCode": 200
}Errors
| Status | Code | When |
|---|---|---|
| 403 | FORBIDDEN | Session has no active vendor |
| 404 | NOT_FOUND | Variant does not exist or is not owned by the active vendor |
PATCH /policy — Update inventory policy
Update tracking settings, safety stock, low-stock threshold, and backorder rules.
Body (all fields optional; only provided fields are changed)
{
"trackInventory": true,
"safetyStockQuantity": 5,
"lowStockThreshold": 10, // nullable
"allowBackorder": false,
"backorderLimit": null // nullable
}| Field | Type | Constraints |
|---|---|---|
trackInventory | boolean? | When false, availableQuantity becomes null and stockStatus is untracked |
safetyStockQuantity | int? | >= 0 |
lowStockThreshold | int | null? | >= 0 |
allowBackorder | boolean? | |
backorderLimit | int | null? | >= 0. Only meaningful when allowBackorder=true |
Response 200 — updated InventorySnapshot.
Side effects — emits INVENTORY_POLICY_UPDATED on the EventEmitter bus.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod validation |
| 403 | FORBIDDEN | No active vendor on session |
| 404 | NOT_FOUND | Variant not owned by vendor, or inventory row vanished mid-update |
POST /adjustments — Adjust on-hand quantity
Apply a manual stock change with an audit trail.
Body
{
"quantityDelta": -2,
"reason": "Damaged in warehouse",
"referenceType": "internal_note",
"referenceId": "note-1234",
"metadata": { "warehouse": "BLR-1" }
}| Field | Type | Constraints |
|---|---|---|
quantityDelta | int | Required; must not be 0. Negative values are allowed but cannot exceed available stock unless backorder is enabled |
reason | string | 1–500 chars |
referenceType | string? | Free-form, ≤100 chars |
referenceId | string? | Free-form, ≤255 chars |
metadata | Record<string, unknown> | Default {} |
Response 200 — updated InventorySnapshot.
Side effects
- Writes one
inventoryMovementrow withtype="adjustment". - Emits
INVENTORY_ADJUSTEDon the EventEmitter bus.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Bad delta, missing reason, etc. |
| 409 | CONFLICT | Adjustment would drive available stock below allowed floor |
| 404 | NOT_FOUND | Variant not owned by vendor |
GET /movements — List audit trail
Returns inventory movements for the variant, newest first. Paginated
(offset/limit) with the standard metadata envelope.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
limit | int | 100 | 1–500 |
offset | int | 0 | |
type | string | — | Optional movement-type filter (e.g. adjustment, reservation_committed, return_restocked) |
Response 200
{
"data": [
{
"id": "01J9...",
"variantId": "01J9...",
"productId": "01J9...",
"vendorId": "01J9...",
"reservationId": null,
"type": "adjustment",
"quantityDelta": -2,
"reservedDelta": 0,
"previousQuantityOnHand": 44,
"newQuantityOnHand": 42,
"previousReservedQuantity": 3,
"newReservedQuantity": 3,
"reason": "Damaged in warehouse",
"referenceType": "internal_note",
"referenceId": "note-1234",
"actorId": "01J9...",
"createdAt": "2026-04-28T14:05:12.000Z"
}
],
"metadata": { "total": 137, "limit": 100, "offset": 0, "hasMore": true },
"message": "Success",
"statusCode": 200
}CSV imports
Base path: /vendor/inventory/imports.
Two-step flow: upload + validate → review preview → apply.
Constraints
| Value | |
|---|---|
| Max file size | 2 MiB (INVENTORY_IMPORT_MAX_BYTES) |
| Max rows | 5000 (INVENTORY_IMPORT_MAX_ROWS) |
| Accepted mimetype | text/csv (or .csv extension) |
| Default reason | "CSV stock import" |
CSV format
| Column | Required | Notes |
|---|---|---|
sku | yes | Must match a variant SKU owned by the vendor |
quantity | yes | Integer; treated as the absolute new quantity on-hand (not a delta) |
reason | no | Per-row override; falls back to multipart reason field |
reference | no | Per-row override; falls back to multipart reference field |
GET /vendor/inventory/imports/template — Download CSV template
Returns a text/csv stream prefilled with the vendor's active variants — one row per (variant) SKU with the current quantity on hand. Use this as the starting point for a stock-take.
Response is not wrapped (the ResponseInterceptor is skipped); the file is delivered with:
content-type: text/csv; charset=utf-8
content-disposition: attachment; filename="inventory-import-template.csv"POST /vendor/inventory/imports — Upload + validate CSV
multipart/form-data upload. Validates every row but does not apply any changes — review the preview and call apply to commit.
Form parts
| Field | Type | Notes |
|---|---|---|
| (file) | binary | Exactly one file part. CSV, ≤2 MiB |
reason | string? | Default reason for rows missing one. Trimmed |
reference | string? | Default reference for rows missing one |
Example (curl)
curl -X POST https://api.example/vendor/inventory/imports \
-H "Authorization: Bearer $SESSION" \
-F "file=@stock-2026-04-28.csv;type=text/csv" \
-F "reason=Monthly stocktake" \
-F "reference=stocktake-apr-2026"Response 200 — InventoryImportPreview
{
"data": {
"batchId": "01J9...",
"status": "validated",
"totalRows": 1200,
"validRows": 1187,
"invalidRows": 13,
"rows": [
{
"rowNumber": 1,
"sku": "TSHIRT-RED-M",
"variantId": "01J9...",
"productId": "01J9...",
"productTitle": "Red Tee",
"variantLabel": "M",
"currentQuantityOnHand": 42,
"quantityDelta": -2,
"newQuantityOnHand": 40,
"status": "valid"
},
{
"rowNumber": 2,
"sku": "UNKNOWN-SKU",
"variantId": null,
"productId": null,
"productTitle": null,
"variantLabel": null,
"currentQuantityOnHand": null,
"quantityDelta": null,
"newQuantityOnHand": null,
"status": "invalid",
"errorCode": "SKU_NOT_FOUND",
"errorMessage": "No variant matches SKU \"UNKNOWN-SKU\""
}
]
},
"message": "Success",
"statusCode": 200
}If invalidRows > 0, status is failed_validation and apply is not allowed until the file is re-uploaded.
Errors
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Missing file, non-CSV mimetype, or no .csv extension |
| 409 | CONFLICT | More than one file part in the request |
| 413 | HTTP_413 | File exceeds INVENTORY_IMPORT_MAX_BYTES (2 MiB) |
| 422 | UNPROCESSABLE_ENTITY | Row count exceeds INVENTORY_IMPORT_MAX_ROWS (5000) |
GET /vendor/inventory/imports — List import batches
Returns recent imports for the active vendor.
Response 200
{
"data": [
{
"batchId": "01J9...",
"fileName": "stock-2026-04-28.csv",
"status": "applied",
"totalRows": 1200,
"validRows": 1200,
"invalidRows": 0,
"createdAt": "2026-04-28T13:22:00.000Z",
"appliedAt": "2026-04-28T13:24:11.000Z"
}
],
"message": "Success",
"statusCode": 200
}GET /vendor/inventory/imports/:batchId — Get import details
Returns the full InventoryImportPreview for a batch, including every row and its status (mirrors the upload response).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Batch does not exist or belongs to another vendor |
POST /vendor/inventory/imports/:batchId/apply — Apply a validated import
Commits the changes from a validated (or partially_applied) batch. The claim is an atomic status CAS, so a second concurrent apply loses and 409s rather than double-counting.
Body — empty.
Response 200 — final InventoryImportPreview with status="applied" and each row marked applied.
Side effects
- Writes one
inventoryMovementrow per applied row,type="import". - Emits
INVENTORY_IMPORT_APPLIEDon the EventEmitter bus — also for a partial run, covering the rows that did commit.
Chunking and partial failure
Rows are applied in transactions of 200 rows so inventory_stock row locks are never held across the whole file; a concurrent checkout reserve/commit waits on at most one chunk. The apply is therefore not all-or-nothing:
- the failing chunk rolls back whole — no row is left half-applied;
- chunks that already committed keep their rows at
applied; - the batch lands on
status="partially_applied"with progress inmetadata.apply(appliedRows,pendingRows,failedAtRowNumber,error), and the error is returned to the caller; - re-applying resumes: the claim accepts
partially_appliedand only picks up rows stillvalid, so applied rows can never double-count.
Errors
| Status | Code | When |
|---|---|---|
| 409 | CONFLICT | Batch is in failed_validation, failed, applying, or applied and cannot be claimed (validated and partially_applied are the claimable states) |
| 404 | NOT_FOUND | Batch not owned by vendor |
Reservations (internal API)
Reservation lifecycle (reserve → commit / release / expire) is not exposed over HTTP. It is invoked in-process via InventoryService from checkout/order flows. Background expiry runs on the inventory BullMQ queue (jobs expire-reservation, sweep-expired-reservations); the store operator sets how long checkout holds stock (default 15 minutes) and how long an unpaid order keeps it (default 24 hours).
Cancellation restock
Committing a reservation at payment decrements quantity_on_hand. Releasing only ever moves an active batch, so it cannot undo a commit — a cancel after payment would otherwise leave the stock permanently missing while the goods sit on the shelf.
InventoryService.restockCancelledSubOrder({ orderId, orderVendorId, vendorId }) reverses that commit, and the order module calls it only for a sub-order whose fulfilment status was pending when it was cancelled:
- Cancelled before it shipped (
pending) → stock is restocked automatically. Oneorder_cancelledmovement per variant,referenceType="inventory_reservation_batch",referenceId=<newest committed batch>,metadata.orderVendorId, adding back exactly what the matchingreservation_committedmovement removed (a backordered commit clamps on-hand at zero, so it removed fewer units than it reserved).reservedQuantityis untouched — the commit already released it. - Cancelled after it shipped (
fulfilled) → no automatic restock. Those goods physically left, so they come back only through the return flow:qc_passwrites areturn_restockedmovement per return line. - Delivered or returned → cannot be cancelled at all; use the return flow.
- Never paid (batch still
active, or expired before payment) → nothing to reverse.releaseReservationrestoresreservedQuantityand on-hand was never touched, so noorder_cancelledmovement is written.
Admin order edits. When the store operator lowers a quantity on a committed sub-order, one order_edited movement per variant adds the removed units back (referenceType="order_vendor", referenceId=<orderVendorId>), capped at what the sub-order still holds; a later cancel restocks only the remainder.
The restock is idempotent: a replayed cancel is skipped by a pre-check on the existing order_cancelled movement, backed by the partial unique index inventory_movement_order_cancel_restock_uidx on (vendor_id, variant_id, reference_type, reference_id, type) WHERE type = 'order_cancelled'. It runs after the cancel commits and is best-effort — a failure is logged and leaves the cancel standing, since a later retry is safe.
Refunds are money-only and never move stock.
Domain events emitted on reservation transitions:
| Event | Fired when |
|---|---|
INVENTORY_RESERVATION_CREATED | A new reservation batch is held |
INVENTORY_RESERVATION_COMMITTED | Order placed; reserved stock decrements on-hand |
INVENTORY_RESERVATION_RELEASED | Cart abandoned or order cancelled before commit |
INVENTORY_RESERVATION_EXPIRED | TTL elapsed without commit/release |
Related modules
catalog— variants belong to products; an inventory row is provisioned when a variant is created. Seecatalog.md.order— places orders by committing reservations.order_line.hsn_code_at_orderis snapshotted from the variant. Seeorder.md.cart— checkout-prepare callsreserveagainst this module's in-process port. Seecart.md.
Catalog Module — Vendor surface
Vendor-facing CRUD over the vendor's own products, variants, options, and tabs, plus the vendor-side "request a new taxonomy entry" workflow (new brand, category, tag, or…
Invoice Module — Vendor
HTTP surface for a vendor to check, generate, stream, download, and regenerate the GST tax invoice for their own sub-order — async generation with SSE readiness notification.