ERP Inventory Module Design: Movements, Valuation, Guards

Photo by Tiger Lily
A mutable on-hand counter updated from multiple code paths drifts from reality within months, because concurrent writes and bugs leave no trace of what changed. By treating every receipt, issue, transfer, and adjustment as an immutable row in a stock_movement table, on-hand is always derived via SUM and any discrepancy can be traced to a specific business document in minutes. Corrections are new compensating movements rather than edits, giving you a free audit trail.
The choice changes your COGS, margins, and tax position, so it must be made with the client's accountant, not unilaterally. For most Indonesian SMB clients, average cost is the default because IDR purchase prices are volatile enough that FIFO cost-layer tracking adds real complexity, while AVCO gives the accountant one defensible number per item. FIFO is implemented only when the business genuinely rotates stock that way and the finance team explicitly requests it.
Between the SELECT that reads the current balance and the INSERT that appends the issue movement, another transaction can commit its own issue — so both writes succeed and the balance goes negative. The database must serialize writers on the same item-warehouse pair using row-level locking (SELECT FOR UPDATE), a serializable isolation level, or an exclusion constraint. A concurrent load test, not a code review, is what proves the guard actually holds.
The four quantities are on-hand (physical stock as a SUM of the movement ledger), reserved (committed to confirmed orders but not yet issued), available (on-hand minus reserved), and incoming (confirmed purchase order lines not yet received). Showing only on-hand causes sales staff to over-promise and reorder logic to duplicate in-transit stock. Reservations must live in their own table rather than the movement ledger, because a reservation is an intention, not a physical event — mixing the two corrupts valuation.
Summing the full movement ledger becomes slow at high row counts, so periodic snapshot rows let the system sum only movements after the latest snapshot. The post recommends adding a monthly stock_snapshot table from day one, even before performance demands it, because month-end accounting closing already requires frozen historical balances — retrofitting snapshots onto a live ledger during closing week is described as avoidable misery.

Photo by Tiger Lily
Every ERP I have seen fail at inventory failed the same way: someone stored on-hand quantity as a single mutable number on the item table, updated it from six different code paths, and within three months the number on screen disagreed with the number on the shelf. When I built the AP/AR and stock-touching flows for ANCoraPRO, a production ERP for a real Indonesian client running on React, NestJS, and PostgreSQL, the first architectural decision was the opposite: quantity is never stored as truth, it is always derived.
This post covers the three design decisions that make or break an inventory module: modeling stock movements as an immutable ledger, picking a valuation method you can actually defend to an accountant, and building negative stock guards that survive concurrent writes. These are the parts you cannot bolt on later, because every other module — purchasing, sales, accounting — builds on top of them.
The core insight is borrowed straight from double-entry accounting: you never edit a balance, you append a transaction. Every receipt, issue, transfer, and adjustment becomes one immutable row in a stock_movement table with a signed quantity. On-hand stock for any item in any warehouse is simply the SUM of its movements. Deleting or editing a movement is forbidden; corrections are new compensating movements, which gives you a free audit trail.
Each movement carries a reference_type and reference_id pointing back to the business document that caused it — a purchase order line, a sales delivery, a stock opname. This is what lets you answer the question warehouse teams actually ask: not just how many do we have, but why does the system say we have 40 when I count 38. With a ledger, you trace the discrepancy to a specific document in minutes.
-- The core table: every stock change is one immutable row
CREATE TABLE stock_movement (
id BIGSERIAL PRIMARY KEY,
item_id BIGINT NOT NULL REFERENCES item(id),
warehouse_id BIGINT NOT NULL REFERENCES warehouse(id),
movement_type TEXT NOT NULL, -- RECEIPT | ISSUE | TRANSFER_IN |
-- TRANSFER_OUT | ADJUSTMENT
quantity NUMERIC(18,4) NOT NULL, -- signed: +in, -out
unit_cost NUMERIC(18,4), -- filled at receipt
reference_type TEXT NOT NULL, -- PO | SO | TRANSFER | OPNAME
reference_id BIGINT NOT NULL,
moved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by BIGINT NOT NULL REFERENCES app_user(id)
);
-- On-hand quantity is ALWAYS derived, never stored as truth:
SELECT item_id, warehouse_id, SUM(quantity) AS on_hand
FROM stock_movement
GROUP BY item_id, warehouse_id;Yes, SUM over millions of rows gets slow eventually. The answer is not to abandon the ledger — it is periodic snapshot rows plus summing only movements after the snapshot, exactly how banks handle account balances. PostgreSQL handles this pattern comfortably into the hundreds of millions of rows before you need anything exotic.
Pro tip: add a monthly stock_snapshot table from day one, even if you do not need it for performance yet. Month-end closing in accounting will need frozen historical balances anyway, and retrofitting snapshots onto a live ledger during closing week is misery I do not recommend.
Valuation answers a different question than quantity: what is this stock worth, and what cost do we book when it leaves. The method you pick changes your COGS, your margins, and your tax position, so this is a decision to make with the client's accountant, not alone at your keyboard. The three methods that matter in practice:
| Method | How cost flows | Best for | Implementation cost |
|---|---|---|---|
| FIFO | Oldest receipt cost is consumed first; each issue is matched against remaining receipt layers in order. | Goods with shelf life, stable margins reporting, businesses where physical flow really is first-in-first-out. | Highest — you must persist cost layers per receipt and track remaining quantity per layer. |
| Average (AVCO) | Each receipt recalculates a weighted average unit cost; every issue uses the current average. | Commodity-like or mixed stock, volatile purchase prices, teams that want simple month-end math. | Low — one running average per item per costing scope, recalculated on receipt. |
| Standard | A preset cost is used for all movements; differences against actual purchase cost post to variance accounts. | Manufacturing with disciplined cost engineering teams who maintain the standards. | Medium code, high process discipline — useless if nobody maintains the standards. |
Microsoft's Business Central design docs are honest about the trade-offs: FIFO inflates balance sheet value when prices rise, average cost smooths volatility, and back-dated movements force recalculation of every affected entry afterwards. That last point is the one that bites custom builds — if you allow back-dated receipts, your AVCO implementation must replay the average forward from that date, or your books silently drift.
For most Indonesian SMB clients I default to average cost. Purchase prices in IDR are volatile enough that FIFO layer tracking adds real complexity, while AVCO gives the accountant one defensible number per item. I implement FIFO only when the business genuinely rotates stock that way and the finance team asks for it. Odoo's own documentation calls FIFO the most accurate but highly sensitive to input data and human error — an honest description of what you are signing up to maintain.
Negative stock is the inventory module's classic data-quality disease. Two warehouse operators issue the last 5 units at the same moment, both reads see on-hand 5, both writes succeed, and now the system says minus 5. Every report downstream — valuation, COGS, reorder suggestions — is now quietly wrong.
The check-then-insert pattern in application code alone does not work. Between your SELECT and your INSERT, another transaction can commit its own issue. You need the database to serialize writers on the same item-warehouse pair: row-level locking, a serializable isolation level, or an exclusion constraint. Code review will not catch this; load testing will.
My approach in NestJS with PostgreSQL: wrap the read and the write in one transaction, take a row-level lock on the movements for that item and warehouse with SELECT FOR UPDATE, validate the balance, then append. The lock forces concurrent issues of the same item to queue up and re-read a true balance:
// NestJS service: issue stock with a row-level lock guard
async issueStock(dto: IssueStockDto) {
return this.dataSource.transaction(async (em) => {
// 1. Lock the item+warehouse balance row (serialize writers)
const balance = await em.query(
`SELECT COALESCE(SUM(quantity), 0) AS on_hand
FROM stock_movement
WHERE item_id = $1 AND warehouse_id = $2
FOR UPDATE OF stock_movement`,
[dto.itemId, dto.warehouseId],
);
// 2. The guard: reject, do not "fix later"
if (Number(balance[0].on_hand) < dto.quantity) {
throw new ConflictException(
'Insufficient stock: on hand ' + balance[0].on_hand,
);
}
// 3. Append the movement row
await em.getRepository(StockMovement).insert({
itemId: dto.itemId,
warehouseId: dto.warehouseId,
movementType: 'ISSUE',
quantity: -dto.quantity,
referenceType: dto.referenceType,
referenceId: dto.referenceId,
createdBy: dto.userId,
});
});
}One nuance worth building in: a per-warehouse allow_negative_stock flag. Some clients genuinely need to ship now and fix paperwork later — a hard block stops the business, so they will work around your system in Excel, which is worse. The compromise that works: allow it per warehouse, flag every negative-stock movement for mandatory review, and show the offending list on the inventory dashboard until someone clears it.
The second most common design miss: treating on-hand as the only quantity. The moment you support sales orders that are confirmed but not yet shipped, or purchase orders in transit, one number is not enough. You need four, and the UI must be explicit about which one it is showing:
On hand
Physical stock in the warehouse right now — the SUM of the movement ledger. This is what stock opname counts against.
Reserved
Committed to confirmed sales orders or production orders but not yet issued. Reservations are their own table, never movements.
Available
On hand minus reserved. This is the number sales staff need when they promise delivery dates — show them this, not on hand.
Incoming
Confirmed purchase order lines not yet received. Drives reorder logic so you do not order what is already on a truck.
Keep reservations out of the movement ledger. A reservation is an intention, not a physical event — mixing the two makes your valuation wrong and your audit trail lie. When the goods physically leave, the reservation is consumed and a real ISSUE movement is appended in the same transaction.
These are the questions I now force myself to answer before coding any inventory module, in roughly the order they will hurt if skipped:
An inventory module is an accounting system that happens to count boxes. Treat quantity like money: append-only ledgers, derived balances, database-enforced guards, and valuation rules agreed with the people who sign the financial statements. Every shortcut against those principles turns into a reconciliation meeting six months after go-live.
If you are building this on NestJS and PostgreSQL like I do, you already have everything you need: transactions, row locks, and NUMERIC columns. The hard part is not the technology — it is refusing to store that one convenient mutable quantity column when the deadline pressure says you should.