Inventory: a ledger of movements, not a stock counter
How Tomero turns recorded stock movements into current levels, low-stock alerts, and a replayable history
The Monday delivery arrives at Folake's Surulere pharmacy: 36 bottles of amoxicillin syrup. She opens the product on her phone, taps Stock in, enters 36, and saves. The app appends one entry to the product's movement ledger, an append-only list of stock changes, and the level on screen rises to match the shelf. Each later sale appends another entry. Folake records what happened; the app computes what is left.
A product and its opening stock (products) #
A product is a stockable item a business tracks: a name, an optional SKU, and a unit label such as "bottle" or "sets". The SKU is a business-supplied internal code. It stays optional, and every screen treats its absence as a normal state.
The create form carries five fields:
name: required, 1 to 200 characters.sku: optional, 1 to 64 characters when present, unique within the business.unit_label: required, 1 to 64 characters.opening_stock: an integer of 0 or more, default 0.low_stock_threshold: an integer of 0 or more, default 0.
Creating a product is a composite write. One transaction writes the product row, the low-stock threshold row for the business's Default Location, and, when the opening stock is above zero, a stock_in opening movement plus the balance row it produces. Opening stock enters the ledger as a movement like any other, so the level is derived from day one. A product created with zero opening stock starts with an empty ledger.
Product creation is keyed per member by the paired add-product capabilities. A member holding add_products_directly, which the Owner holds implicitly, creates a live product. A member holding submit_products_for_approval submits the same form for the owner's decision instead, and live rows are written only after approval; the approval workflow has its own doc. Product creation is also one of the two writes the app queues offline (employee creation is the other).
Product create on the wire
POST /productsrequires a UUIDIdempotency-Key. The key is stored as the opening movement'sclient_operation_id, so a queued offline create applies once. A replay with the same payload returns the original response; a replay with a different payload returns409 idempotency_conflict.- The
201response bundlesproduct,threshold,opening_movement,balance, andalert.thresholdis always present.opening_movementandbalancearenullwhenopening_stockis0, andalertcarries a transition only when the create crossed a threshold. - An approval submission returns
202with{"status": "pending_approval"}and the approval request snapshot. - A SKU collision within the business returns
409 sku_already_exists.
Stock movements (stock_movements) #
Every stock change is a movement, one entry in the ledger. A movement carries:
movement_type:stock_inorstock_out, the two types at launchquantity: a strictly positive integer; the type carries the signnote: optional free text up to 1000 characters- the actor who recorded it and the moment it occurred
The ledger changes only by appending. A recorded movement stays as recorded, which is what makes the history replayable and the balances rebuildable.
A stock-out is capped at the current stock. The server blocks a stock-out that would drive the balance below zero and returns an insufficient-stock error with every row untouched; launch supports non-negative inventory only. The movement form mirrors the rule: it shows the current level as Available stock, previews the resulting stock, and flips to a warning state with the save disabled when the entry exceeds it.
An owner, or a team member granted create_stock_movements, records movements. For a member without the grant, the Stock in and Stock out buttons render disabled, and the tap opens a dialog naming the missing access. The time and the location default on the server: an omitted occurred_at resolves to the server clock, and an omitted location resolves to the Default Location.
Recording a movement on the wire
POST /stock-movementsnames the product and requires a UUIDIdempotency-Key, stored as the movement'sclient_operation_id. A replay with the same payload returns the original response.- The
201response returns the movement withsigned_delta, positive forstock_inand negative forstock_out, plus the post-movementbalanceand thealerttransition when one occurred. Clients render the server's sign as sent. - Movements are recorded for in-use products only; an inactive product returns
404 product_not_found. 409 insufficient_stockcarries field-level detail and leaves movement, balance, alert, audit, and idempotency rows unchanged.
Current stock (inventory_balances) #
Current stock is derived: the sum of signed movement quantities for a product at a location. The app reads it from inventory_balances, one materialized balance row per product and location, updated in the same service-layer transaction as the movement that changes it. The ledger stays canonical; the balance row exists to make reads fast.
Each balance row carries the quantity, a reference to the latest contributing movement, and that movement's moment; product screens show the last as "Last updated". Balance rows persist through product deactivation, so a not-in-use product keeps its level.
The balance is rebuildable
An operator can truncate the balance table and rebuild every row from the ledger alone: group the movements by business, location, and product, sum stock-in quantities minus stock-out quantities, and stamp each group's latest movement. The rebuild is deterministic, and it runs only as an operator recovery procedure outside the HTTP API. Day to day, balances change only inside the movement transaction; a direct insert into the ledger that skips the service layer drifts the balance and is the data model's named anti-pattern.
Low-stock thresholds and alerts (inventory_thresholds) #
Each product carries one low-stock threshold per location. Product creation writes the Default Location row even when the threshold is 0, so a later edit has a stable row to update. A positive threshold turns monitoring on; a threshold of 0 keeps the product unmonitored.
The threshold and the balance combine into one server-computed stock state per product:
| Stock state | Condition | In the app |
|---|---|---|
healthy | current stock above a positive threshold | neutral card |
at_threshold | current stock equal to a positive threshold | Low stock, warning gold |
below_threshold | current stock above zero and below a positive threshold | Low stock, danger red |
out_of_stock | current stock at zero with a positive threshold | No stock chip, danger red |
unmonitored | threshold missing or 0 | neutral card |
Out of stock takes precedence over low stock: a monitored product at zero leaves every low-stock surface and shows the No stock chip. Only monitored products count as out of stock, so a catalog-style business that leaves thresholds at 0 stays quiet at zero balance.
The low_stock alert follows the balance inside the write transaction. A movement that takes the balance from above the threshold to at or below it opens the alert; a movement that takes the balance back above the threshold resolves it. The transition is evaluated in the same transaction as the movement and the balance update, so the ledger, the balance, and the alert agree at every commit. Threshold edits, deactivation, and reactivation re-evaluate the alert the same way: deactivating a product resolves its open alert, and reactivating may open one when the balance sits at or below the threshold. Open alerts surface on the Alerts tab and in the Needs attention rows on Home for viewers holding view_inventory.
Ngozi is a pharmacy assistant at Folake's pharmacy, granted view_inventory and create_stock_movements. The amoxicillin syrup carries a threshold of 10, and its current stock is 12. A customer buys three bottles. Ngozi taps Stock out, enters 3, and saves. One transaction appends the movement, sets the balance to 9, and opens the low-stock alert; the response returns all three. The product page now renders the stock figure in danger red with the below-threshold banner. The Recent movements section on the same page shows an inline permission explanation in place of the feed: reading movement history takes view_stock_history, a separate grant outside Ngozi's set. When the owner records a stock-in of 24 bottles the next morning, the balance crosses back above the threshold and the same transaction resolves the alert.
The alert transition on the wire
Movement and product write responses carry an alert object when a transition occurred: an alert id, alert_type "low_stock", the resulting status, and a transition of "opened" or "resolved". The field is null on writes with a steady alert state. Open alerts are read through the alerts feed that backs the Alerts tab and the Home preview.
Reading inventory #
An owner, or a team member granted view_inventory, reads the product list and product detail. The list shows in-use products only; a not-in-use product stays reachable through the Not in use filter and its detail page, which renders a deactivated badge. Each row carries the name, the optional SKU, the unit label, the current stock, and the server's stock state; low-stock and no-stock styling follows the state field as sent.
Search matches a case-insensitive substring of the name and SKU. The filter chips map to the server: Low stock, No stock, and Not in use. The summary counts above the list, total, low stock, no stock, and not in use, are computed from the unfiltered set of in-use products, so searching and filtering change the rows and leave the counts stable.
Movement history is a second grant. The product-scoped movement feed and product detail's Recent movements block take view_stock_history. A member with view_inventory alone reads full product detail with the movements block withheld, the field only, rendered as a locked section with an inline explanation. With the grant, Recent movements carries at most the 10 newest entries, and older history pages through the feed.
Reads on the wire
GET /productssupportsq,stock_state,status,limit, andcursor. Rows sort by name then id, ascending, behind an opaque cursor.stock_state=low_stockreturns products with stock above zero and at or below a positive threshold;stock_state=out_of_stockreturns monitored products at zero. The two sets are disjoint.GET /products/{product_id}returns active and inactive products, so historical detail stays readable.GET /products/{product_id}/stock-movementssorts newest first byoccurred_atthen id. In product detail,recent_movementsisnullwithout the grant, an empty list with the grant and an empty ledger, and the 10 newest movements otherwise.- Read responses include
signed_delta; sign rules stay server-owned.
Product lifecycle #
Product edit, deactivation, reactivation, and deletion sit behind manage_products, a capability only the Owner holds at launch; the product actions menu appears only for the owner. An edit changes the name, the SKU, the unit label, and the low-stock threshold. The stock level stays out of the edit form, because it changes only through movements.
Deactivating marks the product not in use: it leaves the default list, the Stock in and Stock out buttons leave its page for everyone, and its open low-stock alert resolves. The ledger and the balance row persist through it. Reactivating returns the product to active inventory with history intact and re-evaluates the alert, which may reopen when the balance sits at or below the threshold.
Deleting is permanent, and it succeeds only for a product with an empty ledger; opening stock counts as ledger history. For a product with history, the server returns a conflict and the app offers deactivation instead.
Lifecycle on the wire
PATCH /products/{product_id}edits fields in place; omitted fields stay unchanged, and"sku": nullclears the SKU. A threshold change updates the Default Location threshold row and re-evaluates the alert in the same transaction. A unit-label change records the old and new labels in audit metadata.POST /products/{product_id}/deactivateand/activateflipis_activeand return the product with the alert transition when one occurred. Repeating either returns409 product_already_deactivatedor409 product_already_active.DELETE /products/{product_id}returns204for a movement-free product and409 product_has_stock_historyotherwise.- Each product write emits one audit event:
product.created,product.updated,product.deactivated,product.activated, orproduct.deleted. PATCHand the lifecycle endpoints run without idempotency keys.
Launch boundaries #
The model is built so later workflows arrive as additions. The boundaries today:
- The ledger records two movement types only:
stock_inandstock_out. Adjustment and transfer types, and the reservedreason_codefield, arrive with later slices. - Inventory lives at the Default Location only. Every threshold, movement, and balance row carries a
location_id, so multi-location inventory arrives as an addition. - Launch supports non-negative stock only; a stock-out is capped at the current balance.
- Product creation is one of the two writes that queue offline. Stock movements, edits, lifecycle actions, and deletion need a connection, and reads come from the server, so a cold offline start shows an error state.
- Products enter one at a time through the form; barcode capture and bulk import are deferred. The product's
metadatacolumn reserves room for category, barcode, supplier, and expiry details. - Thresholds are managed through product create and edit today; standalone threshold endpoints are planned.