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.

Warning

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:

  1. May 2026 — the original phantom stock. Virtual PENDING StockAction rows were created for manageStock=false products, with quantityBefore=0 stubbed 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).
  2. #2728 — multi-source drift. Product.stock, the quantity synced from PrestaShop and Σ(stockAction) silently disagreed: three incompatible "truths", none reliable.
  3. #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, because stock == 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 is stockLedger and Product.stock receives 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 (in stock-actions.service.ts): the ledger is decremented by the exact quantityChange, then Product.stock is 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, REJECTED statuses 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)
Warning

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_NEGATIVE gate 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 legacy Product.stock path always stays clamped.
  • The anti-phantom ceilingSTOCK_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 by attachRecentWeeklyConsumption). 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).
Tip

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)
Note

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