Inventory Truth: A Stock Movement Ledger, Not a Column

Photo by Nick Saltmarsh via Openverse (CC BY 2.0)
Because the ledger records every change as an immutable row, the sum of all qtyDelta values is a complete, reconstructable history rather than a single overwritable number. Product.stockQty is only a cache written for fast reads. If the two ever disagree, the ledger is authoritative and the cache is rebuilt from it.
Not in practice. A composite index on branchId and productId keeps the grouped SUM fast, and the denormalized Product.stockQty cache serves the common product-listing reads instantly. You only aggregate the ledger directly when you need guaranteed truth, such as during reconciliation or drift checks.
You never edit Product.stockQty or a movement row directly. You post a new StockMovement of type ADJUSTMENT whose qtyDelta equals the counted difference, referencing the stock-opname document. This keeps the ledger append-only and makes the correction a traceable event in the history.
A scheduled read-only job runs the same grouped SUM the reconciler uses and compares each computed total against the cached stockQty. Any product where they differ emits a metric and an alert naming the product and branch. The reconciler can then overwrite the cache from the ledger in a single command.
It grows with every movement, which is a real cost, but it buys a complete audit trail, immunity to lost-update races, and the ability to rebuild state after any bug. For a multi-branch ERP where a wrong count means real money, that trade is easily worth the extra rows.

Photo by Nick Saltmarsh via Openverse (CC BY 2.0)
Key Takeaway
In my carwash ERP, the true stock of any product is the SUM of every StockMovement.qtyDelta row, never the number in Product.stockQty. The ledger is append-only and authoritative; the column is a denormalized cache for fast reads. When they disagree, the ledger wins and the cache is rebuilt, so no update can silently corrupt inventory.
One morning a branch manager called me because the app said we had negative three bottles of tire shine. Not zero, negative three. That is the kind of number that only appears when several people update the same quantity column at nearly the same moment and one write clobbers another. I had built the inventory module the obvious way first: Product.stockQty was a plain integer, and every sale or restock did an UPDATE that added or subtracted from it. It worked in the demo and rotted in production.
The fix was to stop treating the quantity as a value I own and start treating it as a value I compute. Instead of one mutable number, I recorded an immutable row for every event that changes stock: a sale of two units is qtyDelta of minus two, a purchase receipt of a carton is qtyDelta of plus twenty-four. The current quantity is then just an aggregate. This is the same append-only, event-sourced idea that large inventory systems reach for, and it is worth understanding why before you copy it.
A single mutable integer looks efficient, but it destroys information on every write. An UPDATE that sets stockQty from ten to eight tells you the new value and erases the reason, the actor, and the previous state. In a multi-branch, multi-terminal setup that is a losing trade. Here is what a mutating column cost me in practice.
Every physical thing that moves stock writes exactly one immutable row. There are no UPDATE and no DELETE statements against this table in application code. Each row carries a signed qtyDelta, the branch that owns it, a movement type, and a reference back to the document that caused it. Note that qtyDelta is an integer of whole units, the same integer discipline I use for money, where all IDR amounts are integers and never floats.
// prisma/schema.prisma
model StockMovement {
id String @id @default(cuid())
branchId String // every query scopes by this
productId String
qtyDelta Int // signed: -2 sale, +24 receipt
type MovementType // SALE | PURCHASE | ADJUSTMENT | TRANSFER
refType String // 'Order' | 'Purchase' | 'StockOpname'
refId String
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id])
branch Branch @relation(fields: [branchId], references: [id])
@@index([branchId, productId]) // fast per-product SUM
@@index([branchId, createdAt])
}
model Product {
id String @id @default(cuid())
branchId String
name String
stockQty Int @default(0) // CACHE ONLY - derived, never trusted
}The authoritative quantity for a product is a single aggregate over that table, scoped by branchId exactly like every other list and detail query in the API. Scoping by branchId is also the IDOR fix I apply everywhere, so a terminal in one branch can never read or move another branch's stock. In Prisma the read is a grouped sum.
// The source of truth: SUM(qtyDelta), never Product.stockQty
async function trueStock(branchId: string, productId: string) {
const agg = await prisma.stockMovement.aggregate({
where: { branchId, productId },
_sum: { qtyDelta: true },
});
return agg._sum.qtyDelta ?? 0;
}
// Recording a sale is an INSERT, never an UPDATE of a quantity
await prisma.stockMovement.create({
data: { branchId, productId, qtyDelta: -qtySold,
type: 'SALE', refType: 'Order', refId: order.id },
});An append-only ledger composes cleanly with the rest of the ERP. My AuditLog is append-only for the same reason, payment settlement is protected by idempotency keys so a retried request never double-writes, and the cash book is a derived union view rather than a stored table. Stock is just one more thing you compute instead of store.
The trade-off is not free. The ledger costs storage and needs a cache to stay fast. Here is the honest comparison I used to justify the rewrite to myself.
| Concern | Append-only ledger | Mutable quantity column |
|---|---|---|
| Source of truth | SUM of all qtyDelta rows | The current column value |
| Audit trail | Complete and built in, every change is a row | None, each write erases history |
| Concurrency | Inserts never conflict, no lost updates | Read-modify-write races corrupt the count |
| Recover from a bug | Post a correcting row, replay to rebuild | Manual guesswork, truth already gone |
| Read latency | Slower unless cached, needs an index | Instant single-row read |
| Storage cost | Grows with every movement | Constant, one row per product |
Reading a grouped SUM on every product listing would hammer the database, so Product.stockQty stays, but only as a cache. It is written in the same transaction as the movement insert, and it is disposable. If it is ever wrong, the ledger rebuilds it. The rebuild is a pure projection: read the ledger, write the caches, done.
// scripts/reconcile-stock.ts - safe to run any time, idempotent
const truth = await prisma.stockMovement.groupBy({
by: ['productId'],
where: { branchId },
_sum: { qtyDelta: true },
});
for (const row of truth) {
const computed = row._sum.qtyDelta ?? 0;
await prisma.product.update({
where: { id: row.productId },
data: { stockQty: computed }, // cache := source of truth
});
}
// Because it only ever overwrites the cache FROM the ledger,
// re-running it changes nothing once caches already match.A physical stock count that disagrees with the ledger is never fixed by editing stockQty. That would lie to the source of truth. Instead I post a movement of type ADJUSTMENT whose qtyDelta is the counted difference, referencing the stock-opname document. The count becomes another event in the history, not a silent overwrite of it.
Because the cache can theoretically drift from the ledger, I do not wait for a phone call to find out. A scheduled job runs the same aggregate as the reconciler, but in read-only mode: it compares computed truth against cached stockQty and emits a metric for any product where they differ. In the six months since the rewrite, real drift has been effectively zero, which is the point. The cache is now provably a projection of the ledger, and the negative-three phantom cannot come back, because there is no shared quantity for two writers to clobber. When drift is non-zero, the alert tells me exactly which product and branch to inspect, and the reconciler makes the fix a one-command operation rather than a late-night guessing game.