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.stockLedger is the signed authority: it may go negative — a negative is a real backorder (a delivery sealed before the covering receipt was recorded).
  • Product.stock is the clamped display mirror: invariant stock == max(0, stockLedger).
  • A StockAction has status PENDING (projection, no ledger effect), COMPLETED (applied), or CANCELLED. Only COMPLETED actions of type INCREMENT/DECREMENT move the ledger; ADJUSTMENT rows and PENDING rows 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)
Note

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.updateProductStockplanManualStockEdit + 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.confirmDeliveryrecordPhysicalMovement per-item manageStock; generics re-allocated SUPPLIER_DELIVERY · INCREMENT per-item tx + PENDING cancellation
Reception undo "Undo delivery" SupplierOrderService.undoSupplierOrderDeliveryreverseCompleted SAs stamped with deliveryId SA deletion + ledger reversal single $transaction
Client delivery seal Merchant click / bulk "Validate today's round" OrderFulfillmentService.createFulfillmentrecordPhysicalMovement 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 importPrestashopProductbuildPsStockLedgerEntry 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/stocks grid, 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):
    • planManualStockEdit refuses "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 SIMPLE product auto-enables manageStock (implicit opt-in).
    • The ledger delta is computed against Product.stockLedger, never against the displayed Product.stock (which can hold phantom PrestaShop values — the 2026-06-08 incident).
    • recordManualStockLedgerAdjustment emits the MANUAL COMPLETED SA (canonical reason "Ajustement de stock", referenceType=manual_adjustment), sets data.stockTrusted (a merchant entry is an on-hand validation: exemption from the anti-phantom-stock ceiling STOCK_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. The ProductStockAvailable mirror 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/stockQuantity when the merchant actually edited the value. Without a submitted value, neither Product.stock nor 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 manageStock choice: "enable tracking + enter a stock value" in the same save now emits the SA (the gate tests the effective manageStock, not the previous one).
  • Everything runs inside a single $transaction: product update, MANUAL SA (via the canonical helper, tx passed through), ProductStockAvailable mirror (#2744).

Phantom-stock recount

  • Trigger: the /manage/ecommerce/vendors/orders/phantom-stock page ("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.stockTrusted and realigns the PS mirror. SA INVENTORY_ADJUSTMENT COMPLETED, 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=false products are skipped entirely (skippedUntracked counter — no write, no SA);
    • the movement delta = counted − stockLedger (signed), and both columns land on counted (backorder ledger −5 / stock 0, counted 3 → SA +8, ledger 3, stock 3 — the stock == max(0, stockLedger) invariant holds since counted ≥ 0);
    • if the ledger already equals counted but the mirror is desynced, the mirror is repaired without a zero-delta SA;
    • the INVENTORY_ADJUSTMENT COMPLETED SA (referenceType=inventory_count) is linked back to the item via inventoryCountItem.stockActionId, and data.stockTrusted is 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 FINALIZED or CANCELLED count 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, source SUPPLIER_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 PENDING SAs (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 INCREMENT SAs stamped with referenceData.deliveryId by the confirm — that stamp is the idempotency signal — then, in a single $transaction:
    1. reverseCompleted(id) per SA (row deleted + stockLedger -= quantityChange);
    2. 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);
    3. the PENDING SAs cancelled by that confirm are restored (only when this was the last delivery of the order);
    4. 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), then recordPhysicalMovement (delta = −qty, source ORDER_UPDATE, referenceType=fulfillment, referenceId = the created fulfillment's id). Stock reservations are consumed in the same pass.
  • The order's PENDING SAs are cancelled — both order/ORDER casings are covered (a legacy-sync inheritance).
Warning

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

  • cancelFulfillment re-reads the fulfillment's original DECREMENT SA and restores |quantityChange| (what was actually decremented, not the nominal quantity — a decrement clamped to 0 under the OFF flag therefore restores nothing phantom), via recordPhysicalMovement (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 a userId the 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/CONFIRMED supplier orders without a delivery whose planned date is at least AUTO_VALIDATE_GRACE_HOURS in the past (12 h — a planned date is not proof of delivery) → creates the SupplierOrderDelivery + the SUPPLIER_DELIVERY INCREMENT SAs via computeReplenishStockWrite + createManyStockActions (referenceData.autoValidated=true). Made-to-order items are skipped unless the product is stockTrusted (#2932). Gated by AUTO_VALIDATE_WRITES_STOCK.
  • PART B — forgotten client deliveries: orders with unsealed items whose deliveryDate is past the same grace → creates the OrderFulfillment + one ORDER_UPDATE DECREMENT SA per item via computeFulfillmentStockWrite.
  • Idempotency: remaining = qty − Σ existing fulfillment items — an item already sealed (by the merchant or a previous pass) is never decremented twice. Bounds --daysBack 12 and --maxItems 200, dedicated system user.

PrestaShop sync and webhooks

  • Trigger: the product sync (3 h cron) and the real-time /api/webhooks/prestashop/products webhooks — both run the same importPrestashopProduct (see PrestaShop sync).
  • Path: packages/services-prestashop/src/prestashop/productSync.tsbuildPsStockLedgerEntry (packages/services-prestashop/src/lib/prestashop/stock-ledger-sync.ts), since #2749.
  • Pure helper: returns null when manageStock=false or when the delta is zero (the RESET_STOCK_ON_SYNC=off case becomes a natural no-op). A product created by the syncOPENING_BALANCE SA (before 0); an existing product → a PRESTASHOP_SYNC SA carrying the delta.
  • Emission via StockLedgerService is 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/After are 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), not max(0, 0−(−5)) = 5. Hence the sequence: reverseCompleted subtracts quantityChange from the ledger, and Product.stock is rewritten as max(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 via reverseCompleted.
  • PENDING/CANCELLED/unsigned SAs are simply removed (no ledger effect).
  • deleteWithOptionalStockEffect(id, affectStock): affectStock=true delegates to the path above; affectStock=false deletes the row without touching the physical counter but still reverses the ledger cache of a signed COMPLETED SA — the stockLedger == Σ 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), stale PENDING SUPPLIER_DELIVERY projections cancelled (I3), orphan SAs (I4), stock vs Σ COMPLETED desync (I5). Details in Safety nets.
  • reconcile-clamped-stock corrects the phantom accumulated by legacy clamped decrements: recordPhysicalMovement with a negative delta, source SYSTEM, referenceType=reconciliation, and forced allowBackorder: true (the correction itself must never be clamped, whatever the env says). Dry-run by default, safetyCap + mandatory --force beyond it, small/large buckets.

Anti-patterns — what rule 17 forbids

Warning

Each of these patterns caused a real incident (see the history in The stock ledger). They are forbidden in any new code:

  • Raw Product.stock writes without a StockAction in the same movement — the ledger drifts silently (the Marie et Ludovic case, −8470).
  • quantityBefore=0 stubs — always read the real ledger first (planManualStockEdit and recordPhysicalMovement do it for you).
  • Deltas computed against the displayed stock instead of the signed ledger — this is the phantom-decrement mechanism (2026-06-08 incident) and the post-backorder desync.
  • Cancelling or deleting a COMPLETED SA without reversing the ledger (and the counter where applicable) — go through reverseCompleted / deleteLedgerMovement.
  • SAs (even PENDING) on a manageStock=false product — 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 --task exits 0 otherwise) and the shell wrapper must propagate exit $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