PRD: Inventory Ledger
| Module | Inventory | PRD ID | PRD-LDG-001 |
| Status | No item | FEAT | LDG |
| Epic | — | Plane | — |
| Date | 2026-07-13 | Version | v1.0 |
| Packages | @nx/inventory | URD | LDG |
| Surface | BackendClient · Owner/Mgr | ||
| Owner | Phát Nguyễn | ||
What the inventory ledger is
A merchant can hold goods in several inventory locations, and each item at each location carries a stock number. The stock table only says how much is left today.
The inventory ledger is the book that says why that number came out that way. Every time stock goes up or down, the system appends one entry: how much was there before, how much moved, how much is left, why, and under which document. The ledger is append-only - an entry is never deleted and never overwritten.
The four stock numbers
Quoted from Stock Locations & Levels so nobody has to open another PRD. Each item at each location carries four numbers:
| Number | What the system calls it | Meaning |
|---|---|---|
| On-hand | on-hand | The goods physically in the warehouse |
| Reserved | reserved | Already promised to another document, no longer sellable to anyone else |
| Available | available | The part that can still be sold |
| Incoming | incoming | Already left the source location, not yet arrived at the destination |
The invariant, true at every moment:
Available = On-hand - ReservedThe ledger records every single time those numbers move.
Why a stock table is not enough
The stock table cannot answer the question that always comes up when the numbers do not add up: there were 20 yesterday, why are there 12 now, who took 8. With nowhere to answer that, every discrepancy turns into an argument between the cashier, the stock keeper and the owner, and the only way to settle it is to count everything again from scratch.
The ledger is also where the context of a movement gets pinned down. A merchant receives goods by the case but holds stock by the can; this month's inventory valuation differs from next month's. If an entry only recorded a bare quantity, nobody reading it a year later would know whether "5" meant 5 cases or 5 cans, or what that batch was worth.
One can of soda, from cart to ledger
Coke at the bar counter, on-hand 20. A customer buys 2 cans.
| What happens | The entry written | Before | Delta | After |
|---|---|---|---|---|
| The cashier taps 2 cans into the order | Cart reservation | 20 | 0 | 20 |
| The customer pays, the order completes | Sale deduction | 20 | -2 | 18 |
| The system re-delivers the payment notification | no entry | 18 | 0 | 18 |
| Receive one more case (1 case = 24 cans) | Purchase receipt | 18 | +24 | 42 |
| The merchant changes the case factor from 24 to 20 | no entry | - | - | 42 |
- Tapping an item into the cart does not move on-hand. What moves is reserved and available.
- A payment notification delivered a second time does not deduct stock a second time. The same document and the same stock item can only write one entry.
- The receipt entry snapshots the unit case and the factor 24 at the moment it happened. Changing the factor later does not rewrite the old entry: reopen it and it still reads 1 case = 24 cans.
The easiest thing to get wrong
Dedupe must not rely on counting the entries that already exist
Reading "is there already an entry for this document" before writing is a very fragile way to dedupe: delete one entry and the door reopens for a second deduction.
The dedupe key is the triple source document + reason + stock item, enforced at the database layer. Deleting or editing an entry does not reopen the door for a second deduction.
The consequence: a wrong entry is fixed by appending a correction entry, never by editing the old one. Whoever reads the ledger sees both the error and the fix, which means today's stock figure can always be explained.
1. Goals & Non-Goals
Goals
- Write one ledger entry for every stock change a business operation causes: purchase receipts, sales, reservations, releases, material consumption, stock transfers, stock counts, write-offs, and opening stock.
- Give every entry the full before/delta/after triple, a reason picked from a fixed catalogue, and a traceable source document.
- Capture the unit, the conversion factor and the inventory valuation at the moment of the movement, on the entry itself.
- Prevent double-writes: one document plus one stock line leaves exactly one entry, no matter how many times the document is reprocessed.
- Fix mistakes by appending a correction entry, leaving the original entry intact in the history.
- Let the merchant review the full movement history of one item at one location, with filters.
- Give the operations team a direct edit/delete path for ledger entries when data has to be rescued, kept separate from the merchant UI.
Non-Goals
- Executing the stock movement itself: writing stock when an inventory ticket completes belongs to
TKT, reserving and releasing belongs toRSV, reconciling stock belongs toCNT. The ledger only receives and records the outcome of those operations. - Computing weighted moving average (AVCO) and standard cost - that belongs to
VAL. The ledger only snapshots the cost figure at the time of the movement. - The tax bookkeeping ledger of a household business. That is a different book, in a different module.
- Aggregate shrinkage reports and per-item margin reports - those read from the ledger, but they themselves sit outside this scope.
2. Success Metrics
| Metric | Target / signal |
|---|---|
| Ledger coverage | Every stock change has a matching entry; no unexplained stock movement exists |
| Stock rebuildable | Summing all ledger entries of a stock item yields exactly the on-hand figure shown on the stock table |
| No double deduction | No repeated deduction when a document is reprocessed; discrepancies caused by duplicate writes drop to 0 |
| Self-service history | Merchants open the Movement History screen to explain a discrepancy themselves instead of asking support |
| Manual intervention | The number of times the operations team has to use the escape hatch to edit or delete an entry trends down over time |
3. Personas & Scenarios
| Persona | Goal in this feature |
|---|---|
| Merchant owner | Trace a stock discrepancy back: 12 left today instead of 20, read the ledger to see where the other 8 went |
| Stock keeper | Check whether a receipt really did add to stock, by how much, and in which unit |
| Merchant accountant | Read back the inventory valuation of a past issue exactly as it stood when it happened |
| Operations team | Repair or delete a corrupted ledger entry after a data incident, when a normal correction entry cannot rescue it |
Core scenario: any operation changes stock (a can is sold, a receipt is posted, a stock count is reconciled) → the system immediately writes a ledger entry with before/delta/after, reason, document, unit, factor and inventory valuation → the next day the owner spots a discrepancy, opens the Movement History of that item at that location and filters by date range → sees every entry, and clicks the document to jump straight to the order or inventory ticket behind it.
4. User Stories
| # | As a | I want | So that |
|---|---|---|---|
| 01 | merchant owner | to review every single change to an item's stock | I know exactly where the goods went without recounting the warehouse |
| 02 | merchant owner | each entry to point straight at the document behind it | one click takes me to the order or inventory ticket that caused the movement |
| 03 | stock keeper | to see the stock before and after each change | I can reconcile against my own notes without adding things up myself |
| 04 | merchant accountant | the entry to keep the unit, the conversion factor and the inventory valuation as they were | reading old numbers back is not skewed by configuration changed since |
| 05 | merchant owner | a document to deduct stock only once even if the system reprocesses it several times | stock does not quietly go missing |
| 06 | stock keeper | to fix a mis-entry with a new correction entry | the history still shows both the error and the fix |
| 07 | operations team member | to edit or delete a corrupted entry directly after an incident | I can rescue the merchant's data without touching their UI |
5. Functional Requirements
| # | Requirement | Status | URD ref |
|---|---|---|---|
FR-001 | Every stock change caused by a business operation appends a new entry to the inventory ledger. • Applies to every operation that touches stock: purchase receipts, sales, reservations, releases, material consumption, stock transfers, stock counts, write-offs, and opening stock. • The ledger is append-only: no operation deletes or overwrites an existing entry. • One entry belongs to exactly one stock item - one item, at one location, in one lot if the item is lot-tracked. | 🔶 | URD-LDG-001 |
FR-002 | Every entry records the full triple of that movement. • Stock before the change. • The signed quantity delta: positive when goods come in, negative when they go out. • Stock after the change, always equal to before plus delta. | 🔶 | URD-LDG-002 |
FR-003 | Every entry carries a reason picked from a fixed catalogue; free text is not allowed. • Purchasing group: Purchase receipt, Vendor return. • Sales group: Cart reservation, Cart reservation release, Sale deduction, Blocked oversell, Customer return. • Materials and production group: Material reservation, Material consumption, Production consumption, Production output receipt. • Warehouse group: Transfer reservation, Transfer reservation release, Transfer out, Transfer in, Stock count adjustment, Correction, Damaged goods, Lost goods, Expired goods. • Opening group: Product opening stock, Material opening stock. | 🔶 | URD-LDG-003 |
FR-004 | Every entry references exactly one source document, recorded as two parts: document type and document code. • Accepted document types: purchase order, sales order, kitchen ticket, inventory ticket, production order, and manual adjustment when the merchant edits the stock figure directly. • Clicking the document code on the Movement History screen opens that document. | 🔶 | URD-LDG-004 |
FR-005 | Every entry snapshots the unit and price context at the moment it happened. • The unit the document author used, for example receiving by the case. • The conversion factor to the base unit as it stood then, for example 1 case equals 24 cans. • The inventory valuation per unit at that moment. • These figures stay frozen on the entry even if the merchant later changes the unit, changes the factor, or the cost drifts. | 🔶 | URD-LDG-005 |
FR-006 | Every entry also keeps the goods and place context of the movement. • Source location and destination location - a transfer entry carries both, a receipt entry only the destination, an issue entry only the source. • Lot code, expiry and serial number if the item is lot-tracked or serial-tracked. • The operator and the timestamp of the movement. • An optional free-text note field. | 🔶 | URD-LDG-010 · URD-LSE-002 |
FR-007 | When a receipt lands at a price different from the configured standard cost, the variance is recorded right on that receipt's ledger entry. • The variance is an observable figure: how much more or less than standard cost this receipt was bought at. • No accounting journal entry is created and nothing is pushed to the Finance module. | 🚧 | URD-LDG-005 · URD-VAL-008 |
FR-008 | The same document, however many times it is reprocessed, leaves exactly one entry per affected stock line. • The dedupe key is the triple: source document, reason, and the stock item touched. • Reprocessing an already-recorded document does not add or deduct stock a second time and does not create a second entry. • Dedupe does not depend on counting existing entries, so deleting or editing an entry does not reopen the door to a second deduction. | 🚧 | URD-LDG-006 |
FR-009 | A correction is a new entry, appended; the original entry is never touched. • The correction entry carries the Correction reason and points back at the original document, so a reader can link the error to the fix. • The wrong entry stays in the history, neither deleted nor overwritten. • No merchant-facing operation - selling, receiving, transferring, counting - has any path to edit or delete a ledger entry. | 🔶 | URD-LDG-001 |
FR-010 | The inventory ledger is the source of truth for stock; the stock table is only a rollup for fast reads. • Summing all entries of a stock item yields exactly the on-hand figure shown on the stock table. • When the two disagree, the ledger is right and the stock table is rebuilt from it. | 🔶 | URD-LDG-007 |
FR-011 | The merchant opens the Movement History screen to review every ledger entry for one item at one location, newest first. • Each row shows: timestamp, reason, source document, stock before, quantity delta, stock after, unit, inventory valuation at that moment, and the operator. • Lot-tracked items also show lot code and expiry columns. | 🚧 | URD-LDG-008 |
FR-012 | The Movement History screen filters down to a single movement quickly. • Filter by date range. • Filter by reason, several reasons at once. • Filter by document type, or type the document code directly. • Filter by location when the item is stocked in several warehouses. | 🚧 | URD-LDG-008 |
FR-013 | The Movement History screen opens from the stock item detail screen, keeping the item and location already in view so the merchant does not have to pick them again. | 🚧 | URD-LDG-008 |
FR-014 | The operations team edits or deletes a ledger entry directly when data has to be rescued after an incident. • This is an internal tool used outside the merchant UI. • It is used only when a normal correction entry cannot solve the problem, for example an entry with badly corrupted data. • The edit/delete path is locked down to the operations team alone: a merchant role, even one with full warehouse rights, cannot touch a ledger entry. • The work is done on the ops screen in the Back Office. | 🔶 | URD-LDG-009 |
FR-015 | The inventory ledger is per-merchant data. A merchant only reads its own entries, and every filter operates within that scope. | 🔶 | URD-LDG-011 |
5.1 Acceptance criteria
- Receive 5 units of an item currently at 10 via an inventory receipt ticket → open the Movement History of that item → see one new entry: before 10, delta +5, after 15, reason Purchase receipt, document the code of the receipt just created.
- Sell 2 cans of an item at the counter → open the Movement History → see a Cart reservation entry when the cashier taps the item, then a Sale deduction entry when the order is paid, each with a complete before/delta/after.
- Receive goods in cases with the item declared as 1 case equals 24 cans → look at the entry just created → it records the unit as case, the conversion factor as 24, and the quantity delta converted to the base unit as 24 cans per case.
- Change the item's conversion factor from 24 to 20, then reopen the old entry → it still shows factor 24 and the old quantity, not recalculated with the new factor.
- Look at a sale entry from last month after the item's inventory valuation has since changed → the old entry still shows the valuation as it stood at the time of the sale, not the current one.
- An item runs on a standard cost of 20,000d and a lot is received at an actual 22,000d → that receipt's entry shows the variance above standard cost, and no accounting journal entry is created.
- Pay for an order, then have the system re-deliver the payment-success notification a second time → no second entry is written for the same order line, and stock is not deducted twice.
- Delete an entry through the ops escape hatch, then let the system reprocess the very same document → stock is still not deducted a second time.
- An entry has the wrong quantity → append a correction entry with the Correction reason, pointing back at the original document → the Movement History shows both entries, the wrong one still intact.
- Sum the quantity deltas of every entry of a stock item → the result equals exactly the on-hand figure shown on that stock item's screen.
- Open a stock item detail screen and click View movement history → the Movement History screen opens already filtered to the item and location in view, newest first.
- On the Movement History screen, filter by the Stock count adjustment reason within a one-month range → only the stock count entries from that month appear; sale and receipt entries are hidden.
- Click the document code of an entry → the system opens that document directly, for example the matching sales order or receipt ticket.
- For a lot-tracked item, issue 3 units from lot B → the entry additionally shows lot code B and that lot's expiry.
- Transfer 5 units from warehouse A to warehouse B → the entries of that transfer record both source location A and destination location B, along with the operator name and the timestamp.
- Receive 5 units into warehouse A via a receipt ticket → the entry carries only destination location A and no source location; an issue entry is the mirror image, carrying only a source location.
- The document author types a note on an adjustment ticket → the matching entry shows exactly that note; leaving the note empty still writes the entry normally.
- Create a stock transfer and move it to Pending transfer → the ledger records a reservation on the source warehouse with the Transfer reservation reason; cancel that transfer → the ledger records a release with the Transfer reservation release reason.
- An entry is badly corrupted after an incident → the operations team edits or deletes it through the internal tool → the merchant, opening their own UI, sees no edit or delete button on any ledger entry.
- Sign in with a merchant role holding full warehouse rights and call the ledger edit/delete path directly → the system rejects it, because that path is open to the operations team only.
- Sign in as merchant B and open the Movement History → only merchant B's entries appear, none of merchant A's.
6. Non-Functional Requirements
| Aspect | Requirement |
|---|---|
| Append-only | No business path edits or deletes a written entry; only the internal ops escape hatch has that right |
| Dedupe | The triple source document + reason + stock item is a unique key enforced at the database layer, not a count of existing entries taken before writing |
| Atomicity | Writing the entry and changing the stock figure happen in the same save - there is never a state where stock moved but the ledger did not, or the reverse |
| Correct under concurrency | Several operations touching the same stock item at once still produce a correct before/delta/after triple for each entry, never derived from a stock figure read beforehand |
| Merchant scoping | Every entry is confined to the user's own merchant; reads require authentication |
| Performance | The Movement History screen is paginated and opens fast even when a stock item already has hundreds of thousands of entries |
| Numeric precision | Quantity and inventory valuation on an entry use 4-decimal precision |
| i18n | Reason labels and document-type labels are shown bilingually |
7. UX & Flows
The Movement History screen lives in the Client app and opens from the stock item detail screen. The operations team works the edit/delete escape hatch through an internal tool, outside the merchant UI.
8. Data & Domain
| Concept | Role |
|---|---|
| Ledger entry | One change to the stock of one stock item - the smallest unit of record, append-only, never edited, never deleted |
| Before/delta/after triple | Stock before, the signed quantity delta, stock after - the three numbers that make an entry verifiable |
| Reason | Why stock changed, picked from a fixed catalogue, never free text |
| Source document | The document type and code behind the movement - purchase order, sales order, kitchen ticket, inventory ticket, production order, manual adjustment |
| Point-in-time context | Unit, conversion factor, inventory valuation, lot, expiry, serial - snapshotted and frozen thereafter |
| Dedupe key | The triple source document + reason + stock item - guarantees a document writes to the ledger once per stock item |
| Correction entry | A new entry written to fix an earlier wrong one, pointing back at the same original document |
| Ops escape hatch | The direct edit/delete path for ledger entries, reserved for the operations team, outside the merchant UI |
Conceptual level only - the full schema and invariants live in the inventory domain model.
9. Dependencies & Assumptions
Depends on
| # | Feature | What is depended on |
|---|---|---|
| 01 | STK Stock items & levels | every entry belongs to exactly one stock item, and before/after are read against it. |
| 02 | UOM Units of measure & conversion | the conversion factor snapshotted on the entry comes from the conversion engine at document time. |
| 03 | VAL Inventory valuation | the point-in-time cost and the variance against standard cost both come from here. |
| 04 | TKT Inventory tickets · PO Purchase orders · CNT Stock counts · TRF Stock transfers, and the sale flow | these are the sources that generate entries. |
| 05 | LSE Lots, expiry & picking strategy | lot code, expiry and serial snapshotted on the entry. |
Assumptions
| # | Assumption | What breaks if it is wrong |
|---|---|---|
| 01 | Every operation that touches stock goes through a document with a code, including a merchant editing the stock figure straight from the admin screen - that edit counts as a manual adjustment document. | A movement with no document has no dedupe key, cannot be traced back, and its entry explains nothing to anyone. |
| 02 | A document may be reprocessed several times, so dedupe is the default, not an edge case. | Treating reprocessing as an edge case turns every re-delivered payment notification into an extra deduction, and stock quietly drains with nobody able to explain it. |
| 03 | Merchants accept reading the ledger to explain a discrepancy, rather than expecting the system to silently patch the numbers into agreement. | A system that patches the numbers itself buries the discrepancy, the real cause never surfaces, and the same fault repeats forever. |
10. Release Plan & Criteria
| Aspect | Plan |
|---|---|
| Phase | Phase 2 - see the URD feature catalogue |
| Rollout | All merchants; no feature flag. The Movement History screen is open to the merchant owner and manager roles |
| Migration | Existing entries are kept as-is. Adding the dedupe key needs a data cleanup step first: collapse entries sharing the same document + reason + stock item triple, keeping the earliest, before enforcing the key |
| Release criteria | Ledger sums match on-hand stock on sampled stock items; reprocessing a document creates no second entry; the Movement History screen opens from stock item detail and filters correctly by reason, document and date range |
| Monitoring | Number of unexplained stock discrepancies, number of times dedupe blocks a repeat write, Movement History screen opens, number of times the operations team has to use the escape hatch |
References
- URD: Inventory ledger · How the system thinks about stock
- Related: Stock items & levels · Inventory valuation · Units of measure & conversion · Inventory tickets · Stock counts
- Module: Inventory - URD
- Developer: @nx/inventory · domain model
Risks & Open Questions
| # | Risk / question | Mitigation / status |
|---|---|---|
| 01 | Dedupe based on counting existing entries before writing is fragile - deleting an entry reopens the door to a second deduction | A unique key enforced at the database layer on the triple document + reason + stock item (FR-008), never a count of entries |
| 02 | The ops escape hatch allows editing or deleting entries, yet the ledger is the source of truth for stock | The escape hatch sits outside the merchant UI and is used by the operations team only, to rescue data (FR-014); the dedupe key at the data layer holds even if an entry is deleted (FR-008) |
| 03 | A before/delta/after triple derived from a stock figure read before the write can come out wrong when several operations touch the same stock item at once, even though the final stock figure is still correct | The triple is taken at the moment stock is written, not from an earlier read (§6, "Correct under concurrency") |
| 04 | The ledger grows fast for high-volume merchants - each can sold is two entries (reserve and deduct) | The Movement History screen is paginated and always filtered by item + location; a long-term retention threshold for old entries is still open |
| 05 | Merchants confuse the Movement History screen with the tax bookkeeping ledger of a household business, because both are called a "ledger" | Place the screen under the Warehouse group and name it exactly Movement History, never shortened to "ledger" in navigation |
| 06 | The reason catalogue is long and hard for a merchant to filter | Group reasons by operation in the filter (purchasing, sales, materials, warehouse, opening) instead of dumping a flat list (FR-003, FR-012) |
Frequently asked questions
| # | Question | Answer |
|---|---|---|
| 01 | How is the inventory ledger different from the stock table? | The stock table says how much is left today - a single number. The ledger says why that number came out that way - one change at a time, with a reason and a document. The stock table can be rebuilt from the ledger; the ledger cannot be rebuilt from the stock table. When the two disagree, the ledger is right. |
| 02 | Why can a wrong entry not be edited? | Because editing the old entry erases the trace of the error, and whoever reads the ledger later no longer understands what happened. A fix is a new correction entry pointing back at the same document; the ledger keeps both the error and the fix. |
| 03 | Then why can the operations team delete one? | Some entries get badly corrupted by a system incident and cannot be rescued with a correction entry. That escape hatch is an internal tool for rescuing data. Merchants opening their own UI see no edit or delete button on any entry. |
| 04 | How many entries does an order selling one can produce? | Two. One when the cashier taps the item into the cart, reason Cart reservation, where on-hand does not move yet but available goes down. One more when the order is paid, reason Sale deduction, when the goods really leave the shelf. |
| 05 | If the system re-delivers the payment notification, does stock get deducted twice? | No. The same document and the same stock item can only write one entry, and that constraint is enforced at the data layer, not by checking whether an entry already exists before writing. |
| 06 | Will reading back an entry from last year be skewed by configuration that has changed? | No. The unit, the conversion factor and the inventory valuation are snapshotted on the entry at the moment it happened, so they stay put even if the merchant changes units or the cost drifts afterwards. |
| 07 | Is the Movement History screen the tax bookkeeping ledger? | No. This is the warehouse's ledger - it records goods in and goods out. The tax bookkeeping ledger of a household business is a different book, in a different module. |