Overview

This section documents the platform's stock & supplier-ordering system: a signed stock ledger that tells the physical truth, an event-driven reordering engine that regenerates supplier orders whenever demand changes, and a three-stage email pipeline (stage → review → send) guaranteeing that what the operator reviews is exactly what goes out. This page is the map: the actors, the end-to-end flow, the data model, and where to find the detail on each subsystem.

The actors

Actor Role
Merchant back office (/manage) Creates/edits orders, seals deliveries and receptions, reviews the supplier email queue, corrects stock
PrestaShop storefront Source of customer orders — arrives in real time (webhooks) and via a catch-up sync (every 3 h)
PrestaShop webhooks /api/webhooks/prestashop/* — push orders and products to the platform as events happen
The 7 production crons Order/product sync (3 h), reordering (30 min), email stage (06:00), health probe (06:30), reconcile (07:15), auto-validate (12:01 + every 2 h) — see Daily timeline
bext queue supplier-orders-recalc Serializes supplier-order recalculations triggered by events, under the supplier write lock
Vendors (×159) Receive order emails, deliver goods; each vendor carries its own send-slot schedule
Shared PostgreSQL Product.stockLedger / Product.stock, StockAction, Order, SupplierOrder — one database for the app, the crons and the scripts

The system map

The nominal flow, from storefront order to the ledger write at reception:

flowchart TB
  PS["Customer order<br/>(PrestaShop storefront)"]
  PS -->|real-time webhook| WH["/api/webhooks/prestashop"]
  PS -->|"catch-up sync (3 h)"| SYNC["Order-sync cron"]
  WH --> ORD[("Local Order")]
  SYNC --> ORD
  BO["Merchant back office"] -->|create / edit / cancel| ORD
  ORD -->|event producers| Q{{"bext queue<br/>supplier-orders-recalc"}}
  Q -->|under write lock| RECALC["recalculateVendorOrdersUnified"]
  RECALC --> SO["Supplier order<br/>drafts (DRAFT)"]
  SO -->|"06:00 stage (render-only)"| MAIL["Email queue — operator review"]
  MAIL -->|approved send| VEND["Vendors (×159)"]
  VEND -->|delivery| RCPT["Confirmed reception"]
  RCPT -->|"StockAction COMPLETED (+)"| LEDGER[("Product.stockLedger")]
  BO -->|"customer-delivery seal (−)"| LEDGER

Read top to bottom:

  1. Demand intake — a customer order arrives twice: in real time via webhook, and via the 3-hourly sync cron that catches anything the webhook missed (both paths converge on the same local Order, idempotently).
  2. Event-driven reordering — every customer-order create/update/cancel/status change (plus: supplier-order cancellation, reception, product webhook) pushes a message onto the bext supplier-orders-recalc queue. The worker recalculates the affected vendor's drafts under the supplier write lock, which serializes it against the reordering cron. Detail: Supplier orders.
  3. Three-stage email — the 06:00 cron renders each draft's email (render-only, nothing is sent), the operator reviews and adjusts in the review queue, then sending re-renders from the current items and blocks if quantities drifted since the stage. Review == sent.
  4. Stock only moves on physical events — customer delivery decrements, supplier reception increments. Order sync never touches stock (that path was deleted on 2026-06-14). Detail: Write paths.
Note

The auto-supplier-orders cron (every 30 min) remains as a safety net behind the event queue: if an event is lost, the cron catches up on its next pass. Likewise the 12:01 auto-validate seals the deliveries/receptions the merchant forgot — the seal at the physical event (UI) is the primary path.

The two-column stock model

Since the backorder cutover (#2886 "Option B", STOCK_LEDGER_ALLOW_BACKORDER=true in production since 2026-07-09), stock lives in two columns with distinct roles:

flowchart LR
  MV["recordPhysicalMovement<br/>(every physical movement)"] --> SA["StockAction COMPLETED<br/>± quantity"]
  SA -->|same transaction| L[("Product.stockLedger<br/>SIGNED authority<br/>negative = backorder")]
  L -->|"stock = max(0, stockLedger)"| S[("Product.stock<br/>clamped display mirror")]
  L -->|"trustedStock (guardrails)"| R["Reordering engine"]
  S --> UI["Storefront / back-office display"]
  • Product.stockLedger is the signed authority: opening + Σ receptions − Σ deliveries. It may go negative — that is a real backorder (delivered to the customer before the covering reception was recorded), and the next reception nets it out. Because it is never clamped, phantom stock (a counter inflated by a clamp at 0) is structurally impossible.
  • Product.stock is the display mirror: the invariant is stock == max(0, stockLedger). It is what the storefront and back-office lists show; it drives no reordering decision.
  • Every physical write goes through StockLedgerService.recordPhysicalMovement (pure math in packages/services/src/stock/rolling-stock-math.ts): it checks manageStock, emits the StockAction and updates both columns in the same transaction. No write site bypasses this contract — see Write paths.
  • The reordering read side goes through trustedStock() (packages/services-supplier-order/src/utils/supplier-order-utils-new.ts), which applies the guardrails: manageStock=false → 0, the STOCK_LEDGER_PESSIMISTIC kill switch → 0 (OFF in production), negatives clamped to 0 (until the STOCK_TRUST_ALLOW_NEGATIVE gate is armed), and an anti-phantom ceiling of STOCK_TRUST_MAX_COVER_WEEKS weeks of recent consumption (8 in production). Detail: Ledger and Configuration.

The StockAction rows form the journal: statuses PENDING (projection — e.g. the "virtual inflow" expected from a sent supplier order) / COMPLETED (applied to the ledger) / CANCELLED; types INCREMENT / DECREMENT / ADJUSTMENT; sources MANUAL, ORDER_UPDATE, SUPPLIER_DELIVERY, INVENTORY_ADJUSTMENT, etc. Only COMPLETED actions count toward the ledger.

Warning

The cardinal rule for any new code: never write Product.stock without (a) checking manageStock and (b) emitting the matching StockAction in the same transaction. A raw write desynchronizes the mirror from the authority and breaks the audit's I5 invariant. This is repo rule 17 (.claude/rules/17-stock-ledger.md).

The daily rhythm at a glance

Time / cadence Cron What it does
every 3 h (0 */3) PrestaShop order sync Imports/refreshes orders (last 40 days) — does not decrement stock
every 3 h (0 */3) PrestaShop product sync Products + vendor reconcile (--link_vendors); every stock write emits its StockAction
every 30 min Auto-supplier-orders Reordering safety net behind the event queue
06:00 Supplier email stage Renders email previews (render-only) for operator review
06:30 supplier-stock-health probe Read-only — health metrics to the control tower
07:15 Reconcile-supplier-orders --apply --removeOrphans — recomputes the expected state, corrects drift; the only cron that fills orders for manageStock=false products
12:01 Auto-validate-stock-actions Sealing safety net: planned receptions and deliveries ≥ 12 h overdue (AUTO_VALIDATE_GRACE_HOURS) and not yet confirmed

The full timeline, with cron interactions and alerting pitfalls, is on Daily timeline.

The subsystems

Subsystem One-line role Page
Stock ledger Signed stockLedger = authority; stock = clamped mirror; trustedStock trust boundary /stock-ordering/ledger
Write paths Every site that touches stock (UI seal, reception, inventory, cancellation, cron) and their shared contract /stock-ordering/write-paths
Supplier orders Event-driven reordering, the supplier-orders-recalc queue, send slots, the stage → review → send email pipeline /stock-ordering/supplier-orders
Daily timeline The 7 production crons, their real schedules and how they interleave /stock-ordering/daily-timeline
A typical week Supplier waves, client rounds, assumed receptions and safe counting windows over one week /stock-ordering/weekly-cycle
PrestaShop sync Order/product sync, real-time webhooks, ledger integration (#2749), vendor reconcile /stock-ordering/prestashop-sync
Back office The /manage surfaces: stock grid, movement journal, physical counts, health /stock-ordering/back-office
Configuration The environment flags (STOCK_LEDGER_ALLOW_BACKORDER, SUPPLIER_NO_PAST_SLOTS, …) and their production state /stock-ordering/configuration
Safety nets The I1–I5 invariant audit, replay-diff, pinned tests, daily probe /stock-ordering/safety-nets

Three invariants to remember

  1. The ledger tells the physical truth. Stock only moves on delivery or reception, never at order intake. A negative stockLedger is information (a backorder), not an error.
  2. Never order into the past. The SUPPLIER_NO_PAST_SLOTS gate enforces a floor at now, causality (send ≥ creation of the source order), and rolls late demand forward to the next future slot.
  3. What the operator reviews is what goes out. Sending a supplier email re-renders from the current items and refuses (CONFLICT) if the items fingerprint changed since the stage. A cancelled order is never emailed.

To check system health at any time, run the invariant audit from the application repo:

cd apps/app && bun run cli --task stock-ledger-audit \
  --tenantId <tenant> --siteId <site> --strict   # exits non-zero on any violation

Where to go next

See also