The stock ledger
The platform's stock is kept in a signed ledger: Product.stockLedger is the
authority (it may go negative — a real backorder), and Product.stock is only its
clamped display mirror (stock == max(0, stockLedger)). This page is the reference
for the model: why it exists, the anatomy of a StockAction, the trustedStock trust
boundary that supplier ordering reads through, and the invariants that keep it all honest.
For the end-to-end flow (stock → reordering → vendor email), see the overview; for every individual write site, see write paths.
The Option B model (#2886)
Two columns on Product, one contract:
| Column | Role | May be negative? |
|---|---|---|
Product.stockLedger |
Signed authority: opening + Σ receipts − Σ deliveries ± adjustments. A negative value = a backorder (delivered to the client before the covering receipt was recorded). |
Yes |
Product.stock |
Display mirror for the storefront and back office: max(0, stockLedger). |
No |
Every physical movement records the true signed delta in the ledger, even when it drives it below zero. The next receipt nets the backorder instead of stacking free stock on top of a counter clamped at 0. The math is a pure function shared by every write site:
packages/services/src/stock/rolling-stock-math.ts → computeStockWrite / displayStock
packages/services/src/stock/stock-ledger.service.ts → recordPhysicalMovement
The signed behaviour is gated by STOCK_LEDGER_ALLOW_BACKORDER (true in production
since 2026-07-09T20:00Z). With the flag off, the math falls back byte-for-byte to the
old clamped model — the fix only exists under the flag.
Cardinal rule (rule 17): never write Product.stock without (a) checking
manageStock AND (b) emitting the matching StockAction in the same transaction.
Any new write site must go through StockLedgerService — never a bare
product.update({ stock }).
Why a signed ledger — three generations of the same bug
The current model answers a well-documented incident class:
- May 2026 — the original phantom stock. Virtual PENDING
StockActionrows were created formanageStock=falseproducts, withquantityBefore=0stubbed in; supplier-order regenerations cancelled the source without reversing the COMPLETED increments already applied. Result: ~912 phantom units across ~57 products, and under-ordered supplier orders (Bio Rennes 2026-05-06: 6 lines where 39+ were needed). - #2728 — multi-source drift.
Product.stock, the quantity synced from PrestaShop andΣ(stockAction)silently disagreed: three incompatible "truths", none reliable. - #2886 — the clamp under just-in-time flow. In a rolling operation with no safety
stock, physical stock legitimately sits at 0 between receipts. A client delivery
sealed at stock 0 was clamped (
max(0, 0 − qty) = 0): the consumption was silently lost, the next receipt created phantom stock, and the following supplier order was undersized (measured case: Baron 31 → 26). The trap: this phantom passed the audit, becausestock == stockLedger ==the fictional value — internally consistent, physically wrong.
The signed ledger makes clamp-born phantoms structurally impossible: the −5 of a
delivery at stock 2 is recorded in full (ledger −3), and the receipt of 10 lands on 7,
not 10.
Anatomy of a StockAction
Every movement — real or projected — is a StockAction row
(packages/db/prisma/schema/order.prisma):
| Field | Role |
|---|---|
actionType |
INCREMENT (receipt, return), DECREMENT (delivery, sale), ADJUSTMENT (defined but ledger-inert — real corrections emit signed INCREMENT/DECREMENT) — plus peripheral types (DAMAGED, LOST, FOUND, …) |
source |
Provenance: MANUAL, ORDER_UPDATE, SUPPLIER_DELIVERY, INVENTORY_ADJUSTMENT, PRESTASHOP_SYNC, OPENING_BALANCE (the T0 opening balance), SYSTEM, API |
status |
PENDING (projection), COMPLETED (applied to the ledger), CANCELLED (abandoned) |
quantityBefore |
The real ledger at application time — never a stub |
quantityChange |
Signed delta; invariant: quantityChange = quantityAfter − quantityBefore |
quantityAfter |
Ledger after application (signed under Option B) |
referenceType / referenceId |
The source business entity (order, fulfillment, supplier order, …) |
processedAt |
Business date of the application (≠ createdAt) |
Only COMPLETED rows count toward the ledger: stockLedger caches
Σ quantityChange over the product's COMPLETED SAs. PENDING rows are projections —
reserved-in ("virtual entry" from an issued supplier order) and reserved-out ("virtual
exit" from a client order) — and touch neither stock nor stockLedger.
Lifecycle and reversal rules
stateDiagram-v2
[*] --> PENDING : projection created (supplier order issued, client order)
PENDING --> COMPLETED : confirmed at the physical movement
PENDING --> CANCELLED : source cancelled or regenerated (no stock effect)
COMPLETED --> [*] : deletion = reverseCompleted (ledger -= quantityChange)
CANCELLED --> [*]
note right of COMPLETED
The only state that writes the ledger.
quantityBefore/After recomputed
against the real ledger on confirm.
end note
The rules, in the order they were learned:
- PENDING → COMPLETED (
confirmStockAction/StockLedgerService.transitionToCompleted): quantities are recomputed against the current ledger at confirm time — the projection's stubbed values are never trusted. Under Option B the base isstockLedgerandProduct.stockreceives the clamped mirror. - PENDING → CANCELLED: no stock effect. This is the normal fate of a projection whose source is cancelled or regenerated.
- COMPLETED is never silently cancelled. Deleting a movement goes through
reverseCompleted(instock-actions.service.ts): the ledger is decremented by the exactquantityChange, thenProduct.stockis rewritten as the clamped mirror of the reversed ledger. Reversing "by applied delta" is wrong for movements that crossed zero — which is precisely the historical bug. - Never stub
quantityBefore=0. Read the real ledger first (May 2026 incident). - The
PROCESSING,FAILED,PARTIAL,VALIDATED,REJECTEDstatuses exist in the enum, but the three states above carry all of the ledger semantics.
manageStock semantics
manageStock separates tracked products from untracked ones:
manageStock=true |
manageStock=false |
|
|---|---|---|
| Stock writes | Mandatory, atomic, with an SA | Forbidden — confirm marks the SA COMPLETED for audit but skips the Product.stock write |
Reordering read (trustedStock) |
Ledger value (see below) | 0 — the supplier order covers the full client demand |
| Virtual PENDING projections | Yes | Never — this is the May 2026 ledger pollution |
| Cron-driven reordering | Event queue + 30-min cron | Only the reconcile-supplier-orders cron (07:15) |
Never create a virtual PENDING StockAction for a manageStock=false product, and never
"fix" its stock to anything but 0 (invariant I2). An untracked product has no stock
truth — reordering treats it as full demand.
The trust boundary: trustedStock
All supplier-order math reads stock through one function, trustedStock()
(packages/services-supplier-order/src/utils/supplier-order-utils-new.ts). It decides
how many on-hand units are credible when sizing a supplier order:
flowchart TD
A[Product] --> B{STOCK_LEDGER_PESSIMISTIC ?}
B -->|true| Z0["return 0 (kill switch)"]
B -->|false| C{manageStock ?}
C -->|false| Z0
C -->|true| D{"made-to-order vendor<br/>AND count not merchant-validated ?"}
D -->|yes| Z0
D -->|no| E{vendor.useStockLedger ?}
E -->|yes| F{STOCK_TRUST_ALLOW_NEGATIVE ?}
F -->|yes| G["raw = stockLedger (signed)"]
F -->|no| H["raw = max(0, stockLedger)"]
E -->|no| I["raw = max(0, stock)"]
G --> J{"data.stockTrusted<br/>(validated count) ?"}
H --> J
I --> J
J -->|yes| K[return raw]
J -->|no| L["ceiling = weekly consumption × STOCK_TRUST_MAX_COVER_WEEKS<br/>return min(raw, ceiling)"]
The guards, one by one:
STOCK_LEDGER_PESSIMISTIC— the kill switch (#2728).true⇒ stock is ignored everywhere (returns 0, over-ordering accepted, drafts are reviewed before sending). OFF in production: the ledger has been trusted since every write site became audited.manageStock=false⇒ 0 — an untracked product provides no coverage.- Made-to-order (
madeToOrder) ⇒ 0 — a vendor holding no inventory has no real shelf stock; its "on hand" is a validation artefact. Exception #2932: if the merchant has explicitly validated a count (data.stockTrusted), the value counts. - Negative ⇒ clamped to 0 (#2725) — unless the
STOCK_TRUST_ALLOW_NEGATIVEgate is on (OFF in prod): once enabled, a negative ledger passes through as-is and the backorder grows the next order. The flip is planned after ≥ 4 weeks of a green I6 invariant. The legacyProduct.stockpath always stays clamped. - The anti-phantom ceiling —
STOCK_TRUST_MAX_COVER_WEEKS(8 in prod): never trust more than N weeks of recent consumption (Σ of COMPLETED DECREMENTs over the last 8 weeks, attached byattachRecentWeeklyConsumption). A product showing 991 on hand but ~1 sale/week is almost certainly inflated PrestaShop stock — the ceiling caps it. Zero consumption ⇒ ceiling 0 ⇒ stock fully distrusted: order the full demand, the conservative direction (over-order, never stock out).
The boundary is pinned by
packages/tests/tests/unit/supplier-order/stock-ledger-trust-boundary.test.ts (77 cases,
real Bio Rennes / #2725 / ceiling data). Every newly discovered edge case must add a test
there — it is the cheapest safety net in the system.
Invariants I1–I7
The stock-ledger-audit task (packages/scripts/src/tasks/stock-ledger-audit.ts)
continuously verifies the ledger:
# Report (read-only)
cd apps/app && bun run cli --task stock-ledger-audit --tenantId <uuid> --siteId <uuid>
# CI gate / probe — throws on any violation
cd apps/app && bun run cli --task stock-ledger-audit --tenantId <uuid> --siteId <uuid> --strict
# Heal (cancel orphans, resync mirrors, …)
cd apps/app && bun run cli --task stock-ledger-audit --tenantId <uuid> --siteId <uuid> --apply
| Invariant | Statement | What it catches | Scope limits |
|---|---|---|---|
| I1 | No Product.stock < 0 |
A display mirror that leaked past the clamp | — |
| I2 | No manageStock=false with stock != 0 |
Writes to untracked products (the Bio Rennes case) | — |
| I3 | No stale PENDING SUPPLIER_DELIVERY projection |
Projections whose parent order is cancelled, or sent and overdue | Drafts in the review queue are a normal state (info, not a violation) |
| I4 | No SA referencing a deleted supplier order | Deletion orphans | — |
| I5 | stock == max(0, Σ COMPLETED) (Option B; strict equality legacy) |
Any path that moves Product.stock without a ledger entry |
Active products only; product level (not variations) |
| I5b | stockLedger == Σ COMPLETED |
Drift of the cache that trustedStock actually reads — I5 can stay green while reordering reads a wrong value |
--apply snaps the cache back to Σ (Σ is the truth, no correcting SA) |
| I6 | No clamp-signature fulfillment DECREMENT (quantityChange=0) since cutover |
A delivery that still clamps its consumption = an Option B regression | Detects the full clamp only (not partial clamps); without STOCK_LEDGER_BACKORDER_CUTOVER, counts historical debt as info-only |
| I7 (info-only) | ProductVariation.stock == Σ COMPLETED of variation-scoped SAs |
Drift at the variation level — the blind spot of I5/I5b, which scope to productVariationId: null |
Variation stock is outside the ledger model (trustedStock and reordering read the product): never gates --strict, never healed by --apply — pure visibility |
Under Option B, the I5 heal resyncs only the display mirror — the Σ of COMPLETED SAs is the authority; a backfill SA is never written to make the counter match.
Worked example: a backorder, step by step
The canonical just-in-time scenario — a client delivery goes out before the covering receipt is recorded. Watch both columns.
The product is tracked (manageStock=true) and the vendor reads the ledger
(useStockLedger=true).
stockLedger |
Product.stock |
|---|---|
| 2 | 2 (= max(0, 2)) |
Σ of COMPLETED StockActions = +2 (the OPENING_BALANCE row). I5 and I5b are green.
The merchant seals the delivery (or auto-validate sweeps it up later). The math
(computeFulfillmentStockWrite, delta = −5):
quantityBefore = 2 (real ledger, never a stub)
quantityChange = −5 (the TRUE delta — no clamp)
quantityAfter = −3 (signed ledger)
Product.stock = max(0, −3) = 0
stockLedger |
Product.stock |
|---|---|
| −3 | 0 |
The −3 is a backorder: 3 delivered units we still owe coverage for. Under the old
clamped model the SA would have carried quantityChange = −2 and 3 units of consumption
would have vanished — the seed of phantom stock.
With the STOCK_TRUST_ALLOW_NEGATIVE gate OFF (current prod state), the −3 is
clamped: trustedStock = 0. The uncovered client demand still drives the supplier
order — nothing is lost operationally, but the backorder itself does not yet grow the
order.
Gate ON (planned after ≥ 4 weeks of green I6): the −3 passes through, and the next order grows by 3 extra units. That is the model's final refinement.
The supplier order arrives; the reception is confirmed
(computeReplenishStockWrite, delta = +10):
quantityBefore = −3
quantityChange = +10
quantityAfter = 7
Product.stock = max(0, 7) = 7
stockLedger |
Product.stock |
|---|---|
| 7 | 7 |
The receipt nets the −3 instead of stacking +10 on a counter clamped at 0 (which would have yielded 10 — i.e. +3 phantom units, exactly the Baron 31 → 26 mechanism).
Σ COMPLETED = +2 − 5 + 10 = 7 = stockLedger (I5b ✓). Product.stock = 7 =
max(0, 7) (I5 ✓). No SA with quantityChange = 0 (I6 ✓). Every physical unit is
accounted for; the next supplier order starts from a truth, not a fiction.
Production flags
State verified on the production tenant (see Configuration for the full matrix):
| Variable | Prod | Effect |
|---|---|---|
STOCK_LEDGER_ALLOW_BACKORDER |
true (since 2026-07-09) |
Signed ledger, stock = clamped mirror |
STOCK_LEDGER_PESSIMISTIC |
false |
Kill switch OFF — stock is read (with the ceiling) |
LEDGER_AS_SOURCE_OF_TRUTH |
true |
The local ledger is the stock authority |
STOCK_TRUST_MAX_COVER_WEEKS |
8 |
Anti-phantom ceiling (weeks of consumption) |
STOCK_TRUST_ALLOW_NEGATIVE |
OFF | Negative passthrough to reordering (future) |
STOCK_LEDGER_BACKORDER_CUTOVER |
2026-07-09T20:00:18Z |
I6 counter boundary (hard gate since) |
apps/app bakes the environment at build time: flipping a gate requires a rebuild,
not just a restart. Crons, on the other hand, re-read the environment on every run.
See also
- Stock & reordering overview — the full flow on one page
- Write paths — every site that writes the ledger
- Supplier orders — the event-driven reordering that reads
trustedStock - The daily timeline — the 06:00 / 06:30 / 07:15 / 12:01 crons
- PrestaShop sync — ledger-integrated sync writes (#2749)
- Safety nets — audit, replay diff, pinned tests, health probe
- Back office — ledger timeline, manual movements, StockAction journal
- Configuration — the full flag matrix