Stock and FEFO
Understand batches, the append-only movement ledger, and how each sale drains the earliest expiry first.
The three layers of stock truth
Stock lives in three places and they have a strict order. The stock_movements table in packages/database/schema.sql is the ledger of record: append-only rows with movement_type of IN, OUT, ADJUST, RETURN, or WRITE_OFF (packages/database/schema/catalogue.ts). Never update or delete those rows. batches.qty_remaining and medicines.stock_quantity are caches you update alongside each movement so reads stay fast, but you can always rebuild them from the ledger.
How batches qualify for sale
Each batch row holds medicine_id, branch_id, qty_remaining, unit_cost, expiry_date, status, and the regulatory sfda_status. The selectFefoBatches query in apps/server/src/features/sales/creation/queries.ts only considers batches with qty_remaining > 0, status = 'active', a sfda_status of active or null, and an expiry_date on today or later. It sorts them by expiry_date ascending, so the earliest expiry is always the first candidate. Sales run it with all guards on, so expired, quarantined, or recalled batches can never leave the shelf.
How FEFO deduction splits a line
createSale in apps/server/src/features/sales/creation/service.ts walks the ordered candidates until the requested quantity is covered. Map consumedByBatch holds what earlier cart lines already drew, so two lines selling the same medicine never take the same units twice. Each slice records batchId, deduct, unitCost, and its VAT fields.
insertSaleItemsAndDeductStock in apps/server/src/features/sales/creation/mutations.ts then writes the stock effects: one sale_items row per slice with its own cost_at_sale snapshot, a compare and swap decrement of batches.qty_remaining, one stock_movements row of type OUT with a negative qty_delta per slice, and a re-sync of medicines.stock_quantity through syncSoldMedicines in the same file. That per slice cost snapshot is what makes historical margin reports possible.
What happens when stock runs short
If the available total is below the requested quantity, createSale in apps/server/src/features/sales/creation/service.ts throws an HttpError. When blocked batches exist, findBlockedBatch in apps/server/src/features/sales/creation/queries.ts names the batch number and its QUARANTINED or RECALLED status so you can tell the user why the sale stopped. The push replay in apps/server/src/features/sync/service.ts feeds those failures through deriveRejectionCode in apps/server/src/features/sync/protocol/rejection.ts: an insufficient or blocked message becomes INSUFFICIENT_STOCK, 403 becomes BRANCH_MISMATCH, 409 becomes CONFLICT, any other 4xx becomes VALIDATION.
How other flows reuse the ledger
Purchases write IN movements when batches arrive (apps/server/src/features/purchases/creation/mutations.ts), purchase order receipts write IN as well (apps/server/src/features/purchase-orders/lifecycle/mutations.ts), returns write RETURN (apps/server/src/features/returns/creation/mutations.ts), stock counts write ADJUST (apps/server/src/features/stockcount/service.ts), and expiry write-offs write WRITE_OFF (apps/server/src/features/purchases/creation/service.ts, writeOffBatch). Transfers run selectTransferFefoBatches in apps/server/src/features/transfers/queries.ts on the source branch, deduct a OUT movement there and create a new destination batch with a matching IN movement in apps/server/src/features/transfers/service.ts. Returns are separate documents in sale_returns, never negative sales, and only restockable items add quantity back.
How to read a movement row
Each stock_movements row tells you what, where, how much, and why. medicine_id plus batch_id names the physical stock, branch_id scopes it, and the signed qty_delta shows direction. reference_id and reference_type link back to the sale, purchase, or count that caused it, and created_by plus created_at give you the audit trail. The optional client_mutation_id and device_id (packages/database/schema/catalogue.ts) survive offline replays so you can trace a movement to the exact device that queued it.
Common stock mistakes
Stock bugs usually come from touching the wrong layer. Watch for these when you review a pull request.
- Updating
batches.qty_remainingwithout writing the matching movement row - Editing or deleting a
stock_movementsrow instead of writing a reversal - Deducting without the expiry and status guards that sales use
- Recording a return as a negative sale instead of a
sale_returnsdocument
Try it with curl
Log in, then fetch a medicine to inspect its cached quantity before selling.
curl -s -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"pharm1@demo.pharmaflow","password":"Demo@1234"}'
TOKEN="<paste access_token here>"
curl -s "http://localhost:8000/api/v1/medicines?search=amox" \
-H "Authorization: Bearer $TOKEN"
Next steps
- Read /architecture/vat-and-money for how each FEFO slice gets its VAT line
- Read /architecture/offline-sync for how offline sales replay the same deduction
- Read /inventory/transfers-and-stockcount for the count flow that writes ADJUST movements