.dev

The inventory engine: one quantity the order can trust

How Feeef stock works now — scoped SKUs, reservations, movements, and why the number on the product is no longer the stock.

FeeefFeeef engineering

A COD store learns this the hard way. Two people confirm the same variant in the same minute, both see "3 left", and the warehouse ships one of them. The number on the product card was a field. It was not a lock.

The inventory module is the lock. Quantities live in the Cloud inventory engine. The API you call is still /v1/inventory. Feeef accepts the request; on-hand and reserved counts come from Cloud. The full reference is the inventory guide. This post is the model.

A bucket, not a product field

Stock is an object: one bucket keyed by namespace, SKU, and batch, inside a project. A project is the thing one or more stores share (store.projectId). The bucket stores quantityOnHand and quantityReserved. Available is on hand minus reserved.

Products point at those buckets by SKU. The product's own sku is the root. A variant hangs off it as a path (tshirt/red/m). An addon can too (tshirt/addon/gift-wrap). When an order line needs a SKU, the leaf variant wins, then parent options, then the product root.

Availability accepts a trailing *. tshirt* is the whole subtree. tshirt is that exact bucket. Aliases map a barcode or a supplier code onto the SKU you actually stock.

Once the module is on, stop treating product.stock as the truth. Bind the variant to a scoped SKU and read availability. The public check is POST /v1/inventory/public/availability with { storeId, skus } — up to 250 SKUs, cached for about fifteen seconds. It returns 402 when the store is not entitled to inventory.

What an order does to stock

Orders do not decrement a counter in the controller and hope. They go through a reservation.

MomentDefault statusesWhat happens
Reservepending, review, accepted, followup, processingA hold, holderRef = the order id. quantityReserved goes up.
Releasecancelled, draftThe hold comes off.
ConsumecompletedThe hold becomes a real decrement.

You can change those status lists per store in store.configs.inventory_integration. Two knobs matter on day one:

  • missing_bucket_policy — ignore (default) skips a SKU you do not track. reject fails the order.
  • allow_backorder — default true, so a reservation may exceed on-hand and availability goes negative. Turn it off when you would rather refuse the order.

Every change is a movement: delta, reason (reserve, release, consume, receive, an adjustment), correlationRef, balanceAfter. GET /v1/inventory/movements is the audit log. You do not reconstruct history by diffing a stock column.

Receiving stock is a finance action

Buying goods is not "type 50 into the product". A purchase receipt, posted from finance, does three things in one transaction: the lines become inventory objects (the batch keeps unit_cost in metadata), each line writes a receive movement, and an open supplier bill is created.

An existing batch only accepts more stock at the same unit cost. A different cost is BATCH_COST_MISMATCH — use another batch. Voiding a posted receipt reverses the stock-in, and it refuses if that quantity was already reserved or sold.

Warehouses are still how the merchant app groups stock. A transfer is POST /v1/inventory/operations with type transfer. Adjustments and receives use the same operations route. Direct quantity edits go through POST /v1/inventory/objects/apply-deltas.

What to wire first

  1. Create or reuse the project (GET /v1/inventory/projects).
  2. Receive a bucket for one real SKU, with a batch and a unit cost.
  3. Place a test order in a reserving status and read the reservation.
  4. Complete it and read the movement. The available quantity should have dropped once, not twice.
  5. Point the storefront at POST /v1/inventory/public/availability instead of the catalog stock field.

If those five steps match what the warehouse did, the engine is doing its job. The rest — aliases, storage class, expiry, barcode labels — hangs off the same bucket.

On this page