docs / inventory 2026-07-31
Operations

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 /products requires a UUID Idempotency-Key. The key is stored as the opening movement's client_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 returns 409 idempotency_conflict.
  • The 201 response bundles product, threshold, opening_movement, balance, and alert. threshold is always present. opening_movement and balance are null when opening_stock is 0, and alert carries a transition only when the create crossed a threshold.
  • An approval submission returns 202 with {"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_in or stock_out, the two types at launch
  • quantity: a strictly positive integer; the type carries the sign
  • note: 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-movements names the product and requires a UUID Idempotency-Key, stored as the movement's client_operation_id. A replay with the same payload returns the original response.
  • The 201 response returns the movement with signed_delta, positive for stock_in and negative for stock_out, plus the post-movement balance and the alert transition 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_stock carries 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 stateConditionIn the app
healthycurrent stock above a positive thresholdneutral card
at_thresholdcurrent stock equal to a positive thresholdLow stock, warning gold
below_thresholdcurrent stock above zero and below a positive thresholdLow stock, danger red
out_of_stockcurrent stock at zero with a positive thresholdNo stock chip, danger red
unmonitoredthreshold missing or 0neutral 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 /products supports q, stock_state, status, limit, and cursor. Rows sort by name then id, ascending, behind an opaque cursor.
  • stock_state=low_stock returns products with stock above zero and at or below a positive threshold; stock_state=out_of_stock returns 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-movements sorts newest first by occurred_at then id. In product detail, recent_movements is null without 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": null clears 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}/deactivate and /activate flip is_active and return the product with the alert transition when one occurred. Repeating either returns 409 product_already_deactivated or 409 product_already_active.
  • DELETE /products/{product_id} returns 204 for a movement-free product and 409 product_has_stock_history otherwise.
  • Each product write emits one audit event: product.created, product.updated, product.deactivated, product.activated, or product.deleted.
  • PATCH and 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_in and stock_out. Adjustment and transfer types, and the reserved reason_code field, 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 metadata column reserves room for category, barcode, supplier, and expiry details.
  • Thresholds are managed through product create and edit today; standalone threshold endpoints are planned.