Stock write paths
On the platform, no physical stock write is ever free-form: every mutation of
Product.stock goes through a single contract — check manageStock, then emit a
StockAction in the same movement so that Product.stockLedger (the signed authority)
stays equal to the sum of movements. This page inventories every write path, each with
its trigger, code path, transactional contract, and idempotency. For the data model itself,
see The stock ledger.
The shared contract
Since the Option B model (#2886, live in production since 2026-07-09):
Product.stockLedgeris the signed authority: it may go negative — a negative is a real backorder (a delivery sealed before the covering receipt was recorded).Product.stockis the clamped display mirror: invariantstock == max(0, stockLedger).- A
StockActionhas statusPENDING(projection, no ledger effect),COMPLETED(applied), orCANCELLED. OnlyCOMPLETEDactions of typeINCREMENT/DECREMENTmove the ledger;ADJUSTMENTrows andPENDINGrows have no effect.
The shared entry point is StockLedgerService
(packages/services/src/stock/stock-ledger.service.ts):
| Method | Role |
|---|---|
recordPhysicalMovement(tx, {signed delta, source, reference}) |
The physical-movement contract: reads stock + stockLedger, computes the write, writes Product.stock, creates the COMPLETED SA and bumps stockLedger — all inside the provided transaction |
createStockAction(data, tx?) |
Creates an SA; if COMPLETED and signed, bumps stockLedger in the same transaction |
transitionToCompleted(saId) |
PENDING → COMPLETED + ledger bump (idempotent: already COMPLETED = no-op) |
reverseCompleted(saId, tx?) |
Reverses a COMPLETED SA: deletes the row + stockLedger -= quantityChange. A "cancel" mode (flip to CANCELLED rather than delete) once existed but no call site ever used it — removed, leaving a single reversal contract |
cancelPendingByReference({referenceId}) |
Bulk-cancels PENDING projections (no ledger effect) |
The computation itself is pure, I/O-free math in
packages/services/src/stock/rolling-stock-math.ts (computeStockWrite,
computeFulfillmentStockWrite, computeReplenishStockWrite,
computeManualStockAdjustment). Under STOCK_LEDGER_ALLOW_BACKORDER=true the computation is
based on the signed ledger and quantityChange carries the true delta (fulfilling 10
on stock 0 records −10, not 0); with the flag off, behaviour is byte-identical to the
historical clamp. The actionType follows the sign of the intended delta, not of the
applied one.
The canonical movement, from the UI down to the database:
sequenceDiagram
autonumber
participant UI as Back office (/manage)
participant TRPC as TRPC router
participant SVC as Business service
participant LED as StockLedgerService
participant DB as PostgreSQL
UI->>TRPC: mutation (seal, reception, edit…)
TRPC->>SVC: getService(...) + permission guard
SVC->>DB: BEGIN $transaction
SVC->>SVC: manageStock guard (untracked = no movement)
SVC->>LED: recordPhysicalMovement(tx, signed delta)
LED->>DB: SELECT stock, stockLedger
LED->>LED: computeStockWrite(ledgerBefore, delta)
LED->>DB: UPDATE product SET stock = max(0, ledgerAfter)
LED->>DB: INSERT StockAction (COMPLETED, signed quantityChange)
LED->>DB: UPDATE product SET stockLedger += quantityChange
SVC->>DB: COMMIT
SVC-->>UI: result (before / after)
The rule 17 contract fits in one sentence: never write Product.stock without (a)
checking manageStock and (b) emitting the matching StockAction in the same movement.
Every path below is a variation on this contract.
Summary table
| Path | Trigger | Code (service · method) | Guard | SA emitted (source · type) | Transaction |
|---|---|---|---|---|---|
| Stock pencil (inline + sheet) | Quick edit in /manage/ecommerce/vendors/stocks |
VendorService.updateProductStock → planManualStockEdit + recordManualStockLedgerAdjustment |
"stockless by nature" products refused; auto-enables manageStock |
MANUAL · ± (COMPLETED) |
Stock write, then SA + ledger atomic (ledger service tx) |
| Bulk edit | Multi-select in the stocks grid | VendorService.bulkUpdateProductStock (same helpers) |
same as pencil | MANUAL · ± |
same as pencil |
| Product editor (save) | Saving the full product sheet | ProductService.updateWithAllNeeds |
Dirty-gated (value submitted only if edited); effective form manageStock |
MANUAL · ± |
single $transaction (SA inside the tx) |
| Phantom-stock recount | /manage/ecommerce/vendors/orders/phantom-stock page |
SupplierOrderService.setProductStockManual |
manageStock=false → error |
INVENTORY_ADJUSTMENT · ± |
single $transaction |
| Physical count (finalize) | /manage/ecommerce/inventory/counts |
InventoryCountService.finalize |
manageStock=false skipped (no write, no SA) |
INVENTORY_ADJUSTMENT · ± (referenceType=inventory_count) |
single $transaction for the whole count |
| API adjust / restock | InventoryService.adjustStock / restockProduct |
packages/services/src/inventory/service.ts |
untracked → warn + no-op | INVENTORY_ADJUSTMENT · ± / SUPPLIER_DELIVERY · INCREMENT |
single $transaction (+ PS mirror) |
| Supplier reception | "Confirm delivery" on a supplier order | SupplierOrderService.confirmDelivery → recordPhysicalMovement |
per-item manageStock; generics re-allocated |
SUPPLIER_DELIVERY · INCREMENT |
per-item tx + PENDING cancellation |
| Reception undo | "Undo delivery" | SupplierOrderService.undoSupplierOrderDelivery → reverseCompleted |
SAs stamped with deliveryId |
SA deletion + ledger reversal | single $transaction |
| Client delivery seal | Merchant click / bulk "Validate today's round" | OrderFulfillmentService.createFulfillment → recordPhysicalMovement |
manageStock (variation inherits parent) |
ORDER_UPDATE · DECREMENT (referenceType=fulfillment) |
enclosing tx; stock write is best-effort |
| Seal cancellation | Cancelling a fulfillment | OrderFulfillmentService.cancelFulfillment |
restores |quantityChange| of the original SA |
ORDER_UPDATE · INCREMENT |
enclosing tx |
| Order cancellation | Cancelled order with delivered items | lifecycle.ts cancelOrder |
only actually-fulfilled qty; without userId → raw SA-less write (inherited quirk) |
ORDER_UPDATE · INCREMENT |
per-product tx |
| Auto-validate (cron) | 12:01 + every 2 h (safety net) | auto-validate-stock-actions.ts |
12 h grace; AUTO_VALIDATE_WRITES_STOCK; made-to-order skipped unless stockTrusted |
SUPPLIER_DELIVERY · INCREMENT / ORDER_UPDATE · DECREMENT |
per-supplier-order / per-order tx |
| PrestaShop sync + webhooks | 3 h cron + /api/webhooks/prestashop/products |
importPrestashopProduct → buildPsStockLedgerEntry |
null when manageStock=false or delta 0 |
PRESTASHOP_SYNC / OPENING_BALANCE · ± |
best-effort (never fails the sync) |
| Movement deletion | Journal /manage/ecommerce/inventory/stock-actions |
StockActionsService.deleteLedgerMovement |
reversal only for signed COMPLETED + tracked |
deletion + ledger reversal | single $transaction |
| Heal / backfill | stock-ledger-audit --apply, reconcile-clamped-stock |
packages/scripts/src/tasks/ |
dry-run by default, safety cap | SYSTEM · ± |
per-product tx |
The following sections detail each family.
Manual edits
Stock pencil (list + sheet)
- Trigger: the inline edit in the
/manage/ecommerce/vendors/stocksgrid, or the pencil on the product sheet tab. - Path: TRPC
vendors.updateProductStock(packages/trpc-routers/src/vendors/router.ts) →VendorService.updateProductStock(packages/services-vendor/src/vendor/service.ts). - Rules (centralised in
packages/services/src/stock/manual-stock-adjustment.ts):planManualStockEditrefuses "stockless by nature" products (generics,COMPOSITE/FORMULE/MENU) — their stock derives from linked products, an SA would pollute the ledger.- Editing the stock of a previously-untracked
SIMPLEproduct auto-enablesmanageStock(implicit opt-in). - The ledger delta is computed against
Product.stockLedger, never against the displayedProduct.stock(which can hold phantom PrestaShop values — the 2026-06-08 incident). recordManualStockLedgerAdjustmentemits theMANUALCOMPLETEDSA (canonical reason "Ajustement de stock",referenceType=manual_adjustment), setsdata.stockTrusted(a merchant entry is an on-hand validation: exemption from the anti-phantom-stock ceilingSTOCK_TRUST_MAX_COVER_WEEKS, #2802) and enqueues a scoped supplier recalc (fire-and-forget, never blocking inside the tx).
- Idempotency:
newStock == displayed value→ immediate return, no write, no SA. TheProductStockAvailablemirror is upserted with the same value.
Full product editor (dirty-gated)
- Trigger: saving the product sheet (
products.updateWithAllNeeds). - Path:
ProductService.updateWithAllNeeds(packages/services/src/product/local/product.service.ts). - Key safeguard (2026-07-11): the client only submits
stock/stockQuantitywhen the merchant actually edited the value. Without a submitted value, neitherProduct.stocknor the ledger is touched — otherwise the page-load snapshot would clobber a movement that happened in the meantime (cron, reception, seal), and the post-update SA would faithfully book that phantom delta. - The Inventory tab carries an explicit
manageStockchoice: "enable tracking + enter a stock value" in the same save now emits the SA (the gate tests the effectivemanageStock, not the previous one). - Everything runs inside a single
$transaction: product update,MANUALSA (via the canonical helper,txpassed through),ProductStockAvailablemirror (#2744).
Phantom-stock recount
- Trigger: the
/manage/ecommerce/vendors/orders/phantom-stockpage ("correct the stock"). - Path:
SupplierOrderService.setProductStockManual(packages/services-supplier-order/src/supplier-order/service.ts). manageStock=false→ error (BAD_REQUEST), never a silent skip.- The delta is computed against the signed ledger via
computeManualStockAdjustment: a product at ledger −256 / display 0 recounted to 12 emits an SA of +268 so the ledger lands on 12 (basing the delta on the clamped stock would leave the ledger at −244 → desync). - Sets
data.stockPin+data.stockTrustedand realigns the PS mirror. SAINVENTORY_ADJUSTMENTCOMPLETED, all in a single$transaction.
Physical inventory count (finalize)
- Trigger: finalizing a count in
/manage/ecommerce/inventory/counts. - Path:
InventoryCountService.finalize(packages/services/src/stock/inventory-count.service.ts). - Ledger-based since 2026-07-11: for every item with a non-null
countedQty,manageStock=falseproducts are skipped entirely (skippedUntrackedcounter — no write, no SA);- the movement delta =
counted − stockLedger(signed), and both columns land oncounted(backorder ledger −5 / stock 0, counted 3 → SA +8, ledger 3, stock 3 — thestock == max(0, stockLedger)invariant holds sincecounted ≥ 0); - if the ledger already equals
countedbut the mirror is desynced, the mirror is repaired without a zero-delta SA; - the
INVENTORY_ADJUSTMENTCOMPLETEDSA (referenceType=inventory_count) is linked back to the item viainventoryCountItem.stockActionId, anddata.stockTrustedis set (#2802).
- Transaction: the whole count in one
$transaction, re-reading the stock under the transaction so concurrent mutations between count start and finalize don't get clobbered. - Idempotency: a
FINALIZEDorCANCELLEDcount cannot be finalized again. - Per-variation rows keep the historical behaviour (write against
ProductVariation.stock, outside the product ledger contract).
Supplier reception
Confirming a delivery
- Trigger: "Confirm delivery" on a supplier order.
- Path:
SupplierOrderService.confirmDelivery(packages/services-supplier-order/src/supplier-order/service.ts). - Supplier-order quantities are in packaging units; the conversion to consumption units
is
quantityReceived × packageSize. - Every tracked item goes through
recordPhysicalMovement(positive delta, sourceSUPPLIER_DELIVERY,referenceType=supplier_order,referenceData.deliveryId) in a per-item transaction. Generic products are re-allocated to their real linked products before the write. Under Option B, a receipt first nets the standing backorder (ledger −5 +10 → 5, display 5) instead of stacking on top of the clamped counter. - Then: bulk cancellation of the order's
PENDINGSAs (the projections just became real) + a scoped supplier recalc (reason: "reception") — see Supplier orders.
Undoing a delivery
- Path:
SupplierOrderService.undoSupplierOrderDelivery. - The undo finds exactly the
COMPLETED INCREMENTSAs stamped withreferenceData.deliveryIdby the confirm — that stamp is the idempotency signal — then, in a single$transaction:reverseCompleted(id)per SA (row deleted +stockLedger -= quantityChange);- under Option B, the display mirror is rewritten as
Product.stock = max(0, stockLedger)after the reversal (a raw counter decrement would push the display negative when the delivery had netted a backorder); - the
PENDINGSAs cancelled by that confirm are restored (only when this was the last delivery of the order); - the delivery record itself is deleted.
Client delivery seal (fulfillment)
Stock decrements on delivery only — order sync never decrements (that path was deleted on 2026-06-14). The primary seal is the merchant's gesture; the auto-validate cron is only the safety net.
- Trigger: the merchant's click in the back office, or the bulk "Validate today's round" button.
- Path:
OrderFulfillmentService.createFulfillment(packages/services-order/src/order/fulfillment.ts). - Per item: read
product.manageStock(the variation inherits the parent's setting), thenrecordPhysicalMovement(delta = −qty, sourceORDER_UPDATE,referenceType=fulfillment,referenceId= the created fulfillment's id). Stock reservations are consumed in the same pass. - The order's
PENDINGSAs are cancelled — bothorder/ORDERcasings are covered (a legacy-sync inheritance).
The seal's stock write is best-effort: a stock error is logged but does not fail the
fulfillment (the business gesture wins). The daily stock-ledger-audit catches any missed
write — see Safety nets.
Cancelling a seal or an order
cancelFulfillmentre-reads the fulfillment's originalDECREMENTSA and restores |quantityChange| (what was actually decremented, not the nominal quantity — a decrement clamped to 0 under the OFF flag therefore restores nothing phantom), viarecordPhysicalMovement(positive delta).cancelOrder(packages/services-order/src/order/lifecycle.ts) restores only the quantities that were actually fulfilled (Σ of non-cancelled fulfillment items). Inherited quirk, kept on purpose: without auserIdthe SA's FK cannot be satisfied → raw counter write without an SA; the daily audit (I5) is the net.
Auto-validate — the safety net
- Trigger: daily 12:01 cron + a 2-hourly pass (
auto-validate-stock-actions.sh), see the daily timeline. - Path:
packages/scripts/src/tasks/auto-validate-stock-actions.ts. - Since #2886 this cron is no longer the primary engine: it seals what the merchant
forgot (the health probe tracks
autoValidateSealed24h— ideally close to 0). - PART A — forgotten receptions:
SENT/CONFIRMEDsupplier orders without a delivery whose planned date is at leastAUTO_VALIDATE_GRACE_HOURSin the past (12 h — a planned date is not proof of delivery) → creates theSupplierOrderDelivery+ theSUPPLIER_DELIVERY INCREMENTSAs viacomputeReplenishStockWrite+createManyStockActions(referenceData.autoValidated=true). Made-to-order items are skipped unless the product isstockTrusted(#2932). Gated byAUTO_VALIDATE_WRITES_STOCK. - PART B — forgotten client deliveries: orders with unsealed items whose
deliveryDateis past the same grace → creates theOrderFulfillment+ oneORDER_UPDATE DECREMENTSA per item viacomputeFulfillmentStockWrite. - Idempotency:
remaining = qty − Σ existing fulfillment items— an item already sealed (by the merchant or a previous pass) is never decremented twice. Bounds--daysBack 12and--maxItems 200, dedicated system user.
PrestaShop sync and webhooks
- Trigger: the product sync (3 h cron) and the real-time
/api/webhooks/prestashop/productswebhooks — both run the sameimportPrestashopProduct(see PrestaShop sync). - Path:
packages/services-prestashop/src/prestashop/productSync.ts→buildPsStockLedgerEntry(packages/services-prestashop/src/lib/prestashop/stock-ledger-sync.ts), since #2749. - Pure helper: returns
nullwhenmanageStock=falseor when the delta is zero (theRESET_STOCK_ON_SYNC=offcase becomes a natural no-op). A product created by the sync →OPENING_BALANCESA (before 0); an existing product → aPRESTASHOP_SYNCSA carrying the delta. - Emission via
StockLedgerServiceis best-effort: a ledger failure never breaks the sync. - Reminder: the PrestaShop order sync does not touch stock at all.
Movement deletion and reversal
Deleting a movement from the journal (/manage/ecommerce/inventory/stock-actions) must
undo exactly what it did — on both columns.
sequenceDiagram
autonumber
participant UI as Movements journal
participant SAS as StockActionsService
participant LED as StockLedgerService
participant DB as PostgreSQL
UI->>SAS: deleteLedgerMovement(id)
SAS->>DB: BEGIN $transaction
SAS->>DB: SELECT StockAction (status, quantityChange)
alt signed COMPLETED and tracked product
SAS->>DB: UPDATE product SET stock = max(0, ledger - quantityChange)
SAS->>LED: reverseCompleted(id, tx)
LED->>DB: DELETE StockAction
LED->>DB: UPDATE product SET stockLedger -= quantityChange
else PENDING / CANCELLED / unsigned
SAS->>DB: DELETE StockAction (no ledger effect)
end
SAS->>DB: COMMIT
- Path:
StockActionsService.deleteLedgerMovement(packages/services/src/stock/stock-actions.service.ts), a single$transaction. - Under Option B (backorder enabled),
quantityBefore/Afterare signed ledger values: reversing the "applied delta" would be wrong for a movement that crossed zero. Pinned example: ledger 2 → manual −5 → ledger −3, stock 0. Deleting that movement must restore stock 2 (the reversed ledger), notmax(0, 0−(−5)) = 5. Hence the sequence:reverseCompletedsubtractsquantityChangefrom the ledger, andProduct.stockis rewritten asmax(0, reversed ledger)— the same math (computeStockWrite) as the writes. - Flag OFF (legacy): the physical counter is reversed by the applied delta
(
quantityAfter − quantityBefore, clamped ≥ 0), the ledger viareverseCompleted. PENDING/CANCELLED/unsigned SAs are simply removed (no ledger effect).deleteWithOptionalStockEffect(id, affectStock):affectStock=truedelegates to the path above;affectStock=falsedeletes the row without touching the physical counter but still reverses the ledger cache of a signedCOMPLETEDSA — thestockLedger == Σinvariant holds in both modes.- Bulk deletion (
bulkDeleteWithOptionalStockEffect) replays the per-id path sequentially (each id in its own transaction): stock-affecting deletes on the same product must not race, and a partial batch is acceptable + reported.
Heal paths
Two CLI tasks write stock as corrections, never in the normal flow:
# Invariants audit (read-only); --apply heals, --strict throws
bun run cli --task stock-ledger-audit --tenantId <t> --siteId <s> --strict
# Backfill of historical clamp losses (#2886) — dry-run by default
bun run cli --task reconcile-clamped-stock --tenantId <t> --siteId <s>
stock-ledger-audit --apply(packages/scripts/src/tasks/stock-ledger-audit.ts) heals invariant violations: negative stocks zeroed (I1), non-zero stock on untracked products (I2), stalePENDING SUPPLIER_DELIVERYprojections cancelled (I3), orphan SAs (I4),stockvs ΣCOMPLETEDdesync (I5). Details in Safety nets.reconcile-clamped-stockcorrects the phantom accumulated by legacy clamped decrements:recordPhysicalMovementwith a negative delta, sourceSYSTEM,referenceType=reconciliation, and forcedallowBackorder: true(the correction itself must never be clamped, whatever the env says). Dry-run by default,safetyCap+ mandatory--forcebeyond it, small/large buckets.
Anti-patterns — what rule 17 forbids
Each of these patterns caused a real incident (see the history in The stock ledger). They are forbidden in any new code:
- Raw
Product.stockwrites without aStockActionin the same movement — the ledger drifts silently (the Marie et Ludovic case, −8470). quantityBefore=0stubs — always read the real ledger first (planManualStockEditandrecordPhysicalMovementdo it for you).- Deltas computed against the displayed
stockinstead of the signed ledger — this is the phantom-decrement mechanism (2026-06-08 incident) and the post-backorder desync. - Cancelling or deleting a
COMPLETEDSA without reversing the ledger (and the counter where applicable) — go throughreverseCompleted/deleteLedgerMovement. - SAs (even
PENDING) on amanageStock=falseproduct — pollutes the ledger (the Bio Rennes case, 912 phantom units). - Swallowing per-item Prisma errors in a cron — a task used as a gate must throw
(
bun run cli --taskexits 0 otherwise) and the shell wrapper must propagateexit $EXIT_CODE.
Every path on this page is pinned by unit tests
(packages/tests/tests/unit/: stock-write-math, inventory-count-finalize,
bulk-delete, stock-ledger-trust-boundary — 77 tests on the trust boundary). Any newly
discovered edge case must add its test there.
See also
- The stock ledger — the Option B model, columns and invariants
- Supplier orders — the event-driven reordering that reads these writes
- Daily timeline — when each cron writes
- PrestaShop sync — the sync's ledger integration (#2749)
- Safety nets — audit, replay-diff, probes
- Back office — the
/managepages that trigger these paths - Configuration — the flags (
STOCK_LEDGER_ALLOW_BACKORDER, …)