The inventory movement ledger (audit trail)
Every change to your on-hand stock — a receipt, a sale, an adjustment, a transfer, a stock-take reconciliation, a manufacturing run — is recorded as a row in the inventory movement ledger. The ledger is the single source of truth for how a product's quantity and value reached their current state. This page explains the model behind that ledger: what a movement is, the sign convention that makes on-hand a running total, how transfers and location moves post as paired rows, how each movement carries a point-in-time cost snapshot, and two edge cases — open stock takes and consignment stock — that behave differently from everything else.
To view and filter the ledger, see View & filter the inventory movement ledger. For the exhaustive field and type lookup, see the Movement types & fields reference.
What a movement is
A movement is one atomic change to inventory for a single product, in a single warehouse, at a single inventory status, at a point in time. Each row records:
- What moved — the product and a signed quantity.
- Where — the warehouse, and (for active stock) the bin location.
- What kind of change — a type such as Inbound Receipt, Sale, Adjustment, Transfer, Stock Take, Return, Purchase Return, Reclassification, or a Manufacturing consumption/output/scrap.
- The inventory status the stock is in — active (physically on hand and countable) or in_transit (owned but travelling between warehouses).
- What caused it — a polymorphic link to the source document (the sales order line, receipt line, adjustment, stock-take item, transfer line, and more), plus a cached human-readable reference.
- The cost lineage — an optional link to the FIFO layer the stock came from or was consumed against, with a snapshotted unit cost.
Together these are what make the ledger a true audit trail: given any quantity or valuation figure in SKU.io, you can trace it back to the exact movements that produced it.
The ledger is immutable and system-generated
You never create, edit, or delete a movement by hand. There is no New Movement button and no edit form — the ledger UI is purely read, search, filter, drill-down, and export. The API exposes only an index (list) and an export endpoint; there are deliberately no create, update, or delete routes for movements.
Instead, movements are emitted by the subsystems that actually change stock:
| When you… | The subsystem that posts the movement |
|---|---|
| Receive a purchase order | Receipts |
| Ship a sales order | Fulfillment |
| Adjust stock up or down | Adjustments |
| Ship and receive a warehouse transfer | Transfers |
| Finalize a stock take | Stock takes |
| Receive a customer return or send a purchase return | Returns |
| Build or consume in a manufacturing order | Manufacturing |
| Move stock between bins | Location transfers |
Because the ledger is generated rather than entered, you correct inventory by making a new offsetting movement (an adjustment, a stock take, a reversal in the source document) — never by editing history.
Some subsystem operations — reversing a receipt, voiding a shipment — delete the movements they created. When that happens, SKU.io reverses every cache the movement contributed to (product on-hand, per-location quantity, and the FIFO layer's fulfilled quantity) before the row is removed, so the running totals stay correct. If a cache reversal fails, it's logged but doesn't block the deletion, so you never end up with an orphaned movement stuck in the ledger.
Each movement also keeps an Activity log (visible on the movement detail page's Activity tab) that records field-level changes, excluding routine timestamp churn. In normal operation a movement is written once and never touched again, so this tab is mostly of interest when investigating a correction.
The sign convention: Positive adds, negative removes
Every movement carries a signed quantity, and the sign is the direction:
- Positive quantity = inbound. Stock enters — receipts, customer returns, positive adjustments, manufacturing output, the destination side of a transfer.
- Negative quantity = outbound. Stock leaves — sales, purchase returns, negative adjustments, manufacturing consumption and scrap, the source side of a transfer.
On-hand is therefore not stored as a single number that gets overwritten. For any product, warehouse, and status, on-hand is the running sum of the movement quantities. Add a positive receipt and the sum rises; post a negative sale and it falls. This is why the ledger can always reconstruct exactly how a figure was reached — the figure is the sum of its history.
The ledger UI reflects the sign directly: a positive quantity renders green with a leading +, a negative quantity renders red, and a zero-quantity row renders grey with no sign.
The same signed-sum logic underpins the stock states you see elsewhere: summing all statuses gives the warehouse total, summing only active rows gives what's physically in the warehouse, and summing only in_transit rows gives what's travelling. See Stock states: on-hand, available, allocated, on-hold, non-sellable, incoming & in-transit for how those totals split into Available, Allocated, On Hold, and Non-Sellable.
Movements maintain the caches incrementally
Reading on-hand by re-summing millions of movement rows on every page load would be slow, so SKU.io keeps cached totals and updates them incrementally as movements are written:
- Product on-hand cache — every movement save applies its delta to the product's cached inventory for that warehouse and status.
- Per-location cache — only active movements that carry a bin location touch the per-location on-hand cache (
inventory_location_quantities). Historic movements with no location are attributed to locations later, at rebuild time, by theinventory:rebuild-location-quantitiesmaintenance command.
When a movement is deleted, all three caches — product on-hand, per-location quantity, and the FIFO layer's fulfilled quantity — are reversed before the row is removed. The upshot for you: on-hand figures are live and consistent without a full recount, and the ledger, the product stock view, and the allocations list all agree.
Movements and FIFO layers: The cost snapshot
A movement optionally references a FIFO layer — the costed batch of stock it belongs to. The relationship runs in two directions:
- A positive movement originates a layer: receiving stock creates a new FIFO layer and the positive movement that puts it on the shelf. This is the entry point of new, costed inventory into the system.
- A negative movement consumes from a layer: shipping or otherwise removing stock draws down a layer's fulfilled quantity. Reversing that movement gives the quantity back to the layer atomically.
The load-bearing detail is the cost snapshot. When a movement is saved against a layer, SKU.io stamps the layer's per-unit cost and its cost version onto the movement as a coherent pair. Valuation then reads that point-in-time cost, not the layer's current live cost. This is what lets you revalue COGS later without silently restating historical valuation — the ledger remembers what the stock cost when it moved.
Bulk movement imports and upserts deliberately exclude the unit cost and cost version from the columns they update, so a re-import can never clobber a movement's historical cost. If you need to change what stock is worth, use the COGS tools — Revalue COGS or Rebuild COGS — which layer their changes on top of the snapshot rather than erasing it.
A few nuances are worth knowing:
- Not every movement has a layer. The FIFO layer link is nullable. Some negative movements from the allocation/backorder era have no layer, and those rows are simply excluded from valuation joins.
- Cross-product contamination is blocked. A save that tries to attach a layer belonging to a different product is rejected outright, so a movement's cost can never come from the wrong SKU.
- Re-layering is a controlled operation. SKU.io can split a consumption movement or reassign it to a different layer (for example, when correcting which batch a sale drew from), which is how backordered sales get costed once stock arrives. You don't do this by hand; it happens inside the allocation and COGS machinery.
For the full FIFO model, see FIFO layers & how COGS is realized and the FIFO layer fields, statuses & enums reference.
How warehouse transfers appear: In-transit and active pairs
A single warehouse transfer doesn't produce one movement — it produces a sequence of signed rows that walk the same stock from one warehouse to another while never letting it exist in two places at once. Using the in_transit status, the goods stay owned-but-unavailable while they travel:
- Ship from the source. A negative active movement deducts the stock from the source warehouse, and a positive in_transit movement puts the same quantity into transit. The source's active on-hand drops; the stock is now "on the truck."
- Receive at the destination. A negative in_transit movement clears the transit balance, and a positive active movement lands the stock in the destination warehouse.
The same FIFO layer travels across the paired in_transit movements, so the cost lineage is preserved end to end — stock arrives at the destination carrying the cost it left with. On a partial receipt, the transit and active rows are posted only for the quantity actually received; the remainder stays in transit until it arrives.
Transfer 40 units, Main Warehouse → East Warehouse
Ship: -40 active @ Main (leaves the source shelf)
+40 in_transit @ Main (now travelling)
Receive: -40 in_transit @ East (clears transit)
+40 active @ East (lands at destination)
When a transfer's destination is a channel-managed warehouse — Amazon FBA, Amazon AWD, or Walmart WFS — you can't record its receipt manually. The receipt flows in from the channel (Amazon's pending-inbound status, or the Walmart WFS inbound). Attempting a manual receive is rejected with a clear message — "This transfer's destination is managed by [channel]; its receipt is recorded automatically from the channel, not received manually" — because the destination holds no SKU-side on-hand and a manual receipt would create orphan inventory. See Receive a transfer and How warehouse transfers work.
Location transfers: Paired reclassification movements
Moving stock between bins in the same warehouse is different from a warehouse transfer. It never uses the in_transit status, because the stock isn't going anywhere the warehouse total would notice. Instead, a location transfer posts two active reclassification movements:
- a negative row out of the source bin, and
- a positive row into the destination bin.
The net effect on the warehouse's on-hand is zero — only the per-location quantities change. Reclassifications never touch costing, so they carry no FIFO layer. This is the mechanism that keeps your bin-level allocations accurate as stock is physically shuffled around a warehouse.
| Warehouse transfer (Transfer) | Location transfer (Reclassification) | |
|---|---|---|
| Scope | Between two warehouses | Between bins in one warehouse |
| Statuses used | active + in_transit | active only |
| Net warehouse quantity | Moves from one warehouse to another | Zero — only bins change |
| FIFO layer | Same layer travels with the goods | None — costing untouched |
Edge case: Movements during an open stock take are logged, not blocked
An in-progress stock take doesn't freeze the warehouse. Business keeps running — sales ship, receipts land, adjustments post — and those concurrent movements are allowed, not rejected. SKU.io simply logs each non-stock-take movement that occurs against a product and warehouse with an open count, so the stock-take reconciliation can account for the drift at finalization.
Two refinements keep the log meaningful:
- A movement that's part of the same open stock take isn't logged as concurrent activity — only genuinely unrelated movements are.
- Callers can bypass the check where it would be noise, and the check only runs at all when an open stock take actually exists for that warehouse and product.
The practical takeaway: you don't have to halt operations to count. But because the world kept moving, finalizing the stock take reconciles the counted quantity against whatever the ledger says now — see How stock takes work for how the count-versus-system delta is resolved.
Edge case: Consignment stock is excluded from valuation
Consignment stock is inventory you hold but a supplier still owns — it isn't yours until it sells. Movements against a consignment FIFO layer are recorded in the ledger exactly like any other movement (they still count toward your physical on-hand and your available-to-sell figures), but they're excluded from inventory valuation and GL reconciliation totals. Consigned stock posts no inventory journal, so counting its cost as an asset on your books would be wrong.
Two rules define what actually counts toward valuation:
- Only active and in_transit movements that carry a non-consignment cost layer are valued.
- Movements with no FIFO layer are excluded from valuation entirely (there is no cost to value).
So a warehouse can show plenty of on-hand consignment units while contributing nothing to the valuation total — which is exactly what the SKU COGS Health dashboard and the inventory-to-accounting reconciliation expect. For how those cost figures are built and corrected, see FIFO layers & how COGS is realized and Correcting COGS: revalue vs rebuild vs recalculate.
Next steps
- View & filter the inventory movement ledger — find and drill into the movements behind any figure.
- Movement types & fields reference — every type, status, and column defined.
- Stock states: on-hand, available, allocated, on-hold, non-sellable, incoming & in-transit — how the running sums split into the states you see on the stock page.
- FIFO layers & how COGS is realized — the cost lineage that movements snapshot.
- How warehouse transfers work — the full transfer lifecycle behind the in-transit pairs.