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:
- 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). - 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-recalcqueue. The worker recalculates the affected vendor's drafts under the supplier write lock, which serializes it against the reordering cron. Detail: Supplier orders. - 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.
- 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.
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.stockLedgeris 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.stockis the display mirror: the invariant isstock == 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 inpackages/services/src/stock/rolling-stock-math.ts): it checksmanageStock, emits theStockActionand 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, theSTOCK_LEDGER_PESSIMISTICkill switch → 0 (OFF in production), negatives clamped to 0 (until theSTOCK_TRUST_ALLOW_NEGATIVEgate is armed), and an anti-phantom ceiling ofSTOCK_TRUST_MAX_COVER_WEEKSweeks 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.
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
- The ledger tells the physical truth. Stock only moves on delivery or reception,
never at order intake. A negative
stockLedgeris information (a backorder), not an error. - Never order into the past. The
SUPPLIER_NO_PAST_SLOTSgate enforces a floor at now, causality (send ≥ creation of the source order), and rolls late demand forward to the next future slot. - 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
- New to the system → read Ledger then Supplier orders: they are the two halves of the model.
- Writing code that touches stock → Write paths first, then Safety nets to know which tests to pin.
- Operating production → Daily timeline for the schedules, Configuration for the gates, and Back office for the control surfaces.
- Debugging a PrestaShop divergence → PrestaShop sync.
See also
- Stock ledger — the signed model in detail
- Supplier orders — event-driven reordering and the email pipeline
- Daily timeline — the 7 crons hour by hour
- Safety nets — invariants, audit, tests