PrestaShop synchronization
PrestaShop is the sales channel; the platform is the stock authority. Three entry points feed the platform from the shop: the order sync (cron every 3h, bulk mode), the product sync (cron every 3h, incremental, with vendor reconciliation), and real-time webhooks — which run exactly the same import path, one event at a time. This page covers what each flow writes, what it deliberately does not write (stock, for the order sync), and how stock writes stay integrated with the ledger.
Overview
| Flow | Cadence | Command / route | Writes |
|---|---|---|---|
| Order sync | 0 */3 * * * (every 3h) |
bun cli -c sync-prestashop --entities orders --bulk_mode --last_days 40 |
Orders, OrderItems, Clients, Users |
| Product sync | 0 */3 * * * (every 3h) |
bun cli -c sync-prestashop --entities products --incremental --link_vendors |
Products (prices, refs, vendor links), Vendors, categories |
| Webhooks | per event | POST /api/webhooks/prestashop/{customers,products,orders} |
same path as the crons, per unit |
Both crons live on the tenant box (/etc/crontab, Ploi-managed) and call the wrappers
scripts/cron/sync-prestashop-orders-latest.sh and scripts/cron/sync-prestashop-products.sh
(hard 2h timeout each). The full daily cadence is on the
Daily timeline.
Data flows from PrestaShop into the platform. Stock, however, belongs to the platform:
neither cron treats PrestaShop as the source of truth for stock in production (see
RESET_STOCK_ON_SYNC below).
Order sync — every 3h, bulk, last 40 days
Wrapper: scripts/cron/sync-prestashop-orders-latest.sh →
bun cli -c sync-prestashop \
-t <tenantId> -s <siteId> \
--bulk_mode --last_days 40 \
--entities orders --verbose -y
Bulk mode (packages/services-prestashop/src/prestashop/orderSyncBulk.ts) processes each
batch inside a PostgreSQL transaction (BEGIN … COMMIT, rollback on failure) and writes:
- Clients — bulk upsert of PrestaShop customers (then upsert of the linked
Userrows). - Orders — upsert keyed by
invoiceNumber = String(id_order)(duplicated intocustomInputs.prestashop_id). TheOrdermodel has noexternalIdfield — the matching key isinvoiceNumber, which is indexed. - OrderItems — upsert with stable per-line external keys
(
deriveOrderItemExternalKeys,orderSync.ts) so a re-sync updates the existing line instead of creating a duplicate.
What the order sync does NOT do: decrement stock
This is the single most important point on this page. The old AUTO_STOCK_DECREMENT path
(decrement on import of PAID/delivered orders) was disabled on 2026-06-13 and deleted on
2026-06-14 — the code no longer exists. The comment in orderSyncBulk.ts says it
outright:
// Step 8 (REMOVED 2026-06-14): order-sync no longer decrements stock.
// Stock is decremented on DELIVERY by the auto-validate cron — the SINGLE
// decrement engine.
Stock decrements on delivery only: the merchant's seal in the UI at the physical event
is the primary path, and the auto-validate cron (12:01) is now just a safety net that
seals whatever was forgotten. Full detail on Write paths.
Never reintroduce a decrement-on-import for orders. The old path double-counted against
auto-validate (no shared idempotency signal — it wrote a StockAction but no
OrderFulfillmentItem) and never updated Product.stockLedger. If a decrement-on-sync ever
becomes genuinely necessary, it must emit OrderFulfillment/OrderFulfillmentItem and
ledger entries, coordinated with auto-validate.
An order imported by the cron enters the reordering math on the next pass of the
auto-supplier-orders cron (every 30 min); webhooks, by contrast, push an immediate
targeted recalc (see below). See Supplier orders.
Product sync — every 3h, incremental
Wrapper: scripts/cron/sync-prestashop-products.sh →
bun cli -c sync-prestashop \
-t <tenantId> -s <siteId> \
--entities products --incremental --last_days 1 \
--link_vendors --verbose -y
importPrestashopProduct (packages/services-prestashop/src/prestashop/productSync.ts)
imports, for each product: name, description, reference, price, purchasePrice (the
cost — #2796, not the sale price), weight/dimensions, SKU, tax rate, manufacturer, the
defaultVendorId link, categories, bundle components, and classification. The
Product.data JSON is merged, never replaced wholesale — the same precaution as for
vendor.data below.
Stock: who owns the write?
The behavior is governed by the RESET_STOCK_ON_SYNC constant
(packages/common/src/utils/constants.ts) — false in production:
RESET_STOCK_ON_SYNC=false(prod): the sync does not touchProduct.stock. The back-office ledger is the authority (LEDGER_AS_SOURCE_OF_TRUTH=true). A new product starts atstock = 0— the merchant enters the real on-hand on the platform side, which avoids creatingstock ≠ 0without a ledger row (an I5 violation).RESET_STOCK_ON_SYNC=true: the sync owns the stock write, and every write emits the matchingStockAction(next section).
Ledger-integrated writes (#2749 / #2798)
Historically the sync wrote Product.stock raw, with no StockAction — drifting
Product.stock away from Product.stockLedger (the Σ of COMPLETED movements). Since
#2749, every stock write runs its before/after through buildPsStockLedgerEntry
(packages/services-prestashop/src/lib/prestashop/stock-ledger-sync.ts, a pure function
pinned by ps-sync-stock-ledger.test.ts):
| Case | Result |
|---|---|
manageStock = false |
null — untracked product, no entry (rule 17) |
delta (after − before) = 0 |
null — no movement |
| New product | OPENING_BALANCE entry (before = 0) |
| Existing product | PRESTASHOP_SYNC delta entry (INCREMENT or DECREMENT) |
A non-null entry is emitted as a COMPLETED StockAction via StockLedgerService,
keeping Product.stock and Product.stockLedger in lockstep (the I5 invariant checked by
the audit). Emission is best-effort: a ledger failure is
logged but never breaks the sync.
The #2798 refinement: the entry is only emitted when the sync actually owns the
stock write (RESET_STOCK_ON_SYNC=true). When RESET is off, the sync emits nothing —
the old "delta = 0 → natural no-op" guard was a trap: the stock value re-read after the
update picked up concurrent writes (auto-validate cron, manual edits), and the sync
mis-attributed them to itself as phantom "Sync stock PrestaShop" entries, double-counting
client orders into the ledger.
sequenceDiagram
participant PS as PrestaShop
participant Sync as ProductSyncService
participant P as Product (Prisma)
participant L as StockLedgerService
PS->>Sync: product (id_product, price, stock…)
Sync->>P: upsert Product (price, refs, vendor, merged data)
alt RESET_STOCK_ON_SYNC = true (sync owns stock)
Sync->>P: writes Product.stock
Sync->>Sync: buildPsStockLedgerEntry(before, after)
alt manageStock and delta not 0
Sync->>L: createStockAction COMPLETED (OPENING_BALANCE or PRESTASHOP_SYNC)
L->>P: stockLedger kept in lockstep (invariant I5)
else untracked or delta = 0
Sync-->>Sync: null — no ledger entry
end
else RESET off (production)
Sync-->>P: does NOT touch stock, emits NOTHING
end
Vendor reconciliation (--link_vendors)
--link_vendors does far more than link: since the #2693 fix it runs a four-pass
reconciliation before the actual product → vendor linking. Everything lives in
packages/services-prestashop/src/lib/prestashop/vendors.ts (pinned by
prestashop-reconcile.test.ts), except pass 4
(apps/app/src/scripts/commands/sync-prestashop.ts).
flowchart TD
A["--link_vendors"] --> B["1. fetchVendorsLookup<br/>manufacturers + suppliers → typed Map"]
B --> C["2. refreshVendorStatus<br/>diff remote active vs vendor.data.active"]
C -->|drift detected| C1["partial MERGE of vendor.data<br/>never a wholesale replace"]
C --> D["3. upsertMissingVendors<br/>composite key externalId + vendor_type"]
D -->|missing locally| D1["createVendorFromPrestashop"]
D --> E["4. linkProductsToVendors<br/>sentinel filter id NOT IN 0 or empty"]
E -->|unknown vendor| E1["gap-fill: on-the-fly creation"]
E --> F[(Products linked to vendors)]
fetchVendorsLookup— paginates PrestaShop'smanufacturersandsuppliersendpoints into a typed{ supplier: Map, manufacturer: Map }structure. Two separate maps, not one: see the warning below.refreshVendorStatus— for each local vendor, compares the remoteactivewith the storedvendor.data.active. On drift, it updates that one field via a partial merge ({ ...currentData, active }). Matching is type-aware: a localsuppliervendor is looked up in thesuppliermap, never inmanufacturer(falling back to both only for legacy rows withoutvendor_type).upsertMissingVendors— creates any PrestaShop vendor missing locally, keyed by the composite(externalId, vendor_type). A normalized-name dedup avoids creating two rows when the same real-world company is registered in PrestaShop both as a supplier and a manufacturer; genuinely distinct pairs (different names) are both created.linkProductsToVendors— selects candidate products with the filterid_supplier NOT IN ('0','') OR id_manufacturer NOT IN ('0',''), then links each product to its vendor. A product referencing an unknown vendor triggers an on-the-fly creation (gap-fill) using the same type-aware dedup.
Manufacturers and suppliers are two distinct ID namespaces in PrestaShop.
id_manufacturer = 110 and id_supplier = 110 can reference entirely different entities —
observed in production: manufacturer 110 = MILKY BREIZH, supplier 110 = TERRE HADENN. Never
collapse the lookup into a flat Map<externalId, vendor>, and never drop the vendor_type
filter from findFirst lookups: ID collisions cross-match silently and products get
mis-linked.
Never replace vendor.data wholesale during reconciliation — merge instead
({ ...existing, ...fresh }). That JSON also hosts non-PrestaShop state, notably
magicLinkToken / magicLinkExpires used by the vendor-portal authentication flow: a full
replace wipes them and breaks in-flight magic links.
The pass-4 sentinel filter has a history: PrestaShop uses '0' for "no
supplier/manufacturer". The old IS NOT NULL filter counted those products as "needing
linking", failed to resolve ID 0, and inflated the error counter (380 false errors on a
real tenant). Do not loosen the filter back to IS NOT NULL.
Real-time webhooks
Route: POST /api/webhooks/prestashop/{entity} with entity ∈ {customers, products, orders} (apps/app/src/app/api/webhooks/prestashop/[entity]/route.ts). PrestaShop sends a
batch of events { entity, action: create|update|delete, id }.
The key point: webhooks run the same import path as the crons.
products → syncSpecificProduct → importPrestashopProduct (so the exact same
ledger-integrated writes as above); orders → syncSpecificOrder →
processOrderFromPrestashop; customers → syncSpecificCustomer. Webhooks change the
frequency (per event instead of every 3h), not the correctness — they reintroduce
no ledger drift.
Authentication and guardrails
- The secret arrives verbatim in a header (
X-Webhook-Signature, precedence pinned byprestashop-webhook-secret.test.ts) and is resolved againstSiteSettingson two keys:webhook.secret(canonical) andprestashop.webhooks.secret(written by a different settings page — historically a disconnected namespace the receiver never read; both are accepted now). Invalid secret → 401. - Rate limiting with exponential backoff per tenant/site (429 +
Retry-After). - Every event is journaled in
WebhookLog(received → processing → completed|failed, with asessionIdand processing time) — browsable in the back office at/manage/integrations/prestashop/webhooks.
Deletions
delete events never remove rows:
- Product — marked
active: false+deleted_from_prestashopflags inProduct.data. - Order —
invoiceStatus: "CANCELED"+ cancellation of the order's PENDING forecastStockActionrows (never the COMPLETED ones — an applied movement is not reversed here, rule 17). - Customer —
deleted_from_prestashopflags incustomInputs.
Feeding the reordering pipeline
After each processed event, the route enqueues a targeted recalc into the bext
supplier-orders-recalc queue (best-effort, a no-op unless BEXT_SUPPLIER_RECALC is on —
see Configuration). Two ID-resolution subtleties, learned
the hard way:
- The sync services echo back the PrestaShop external ID (
result.productId,result.orderId), not the internal UUID. Products: resolved viaProduct.externalId. result.orderIdis dual-form: the internal UUID for an existing order, the PrestaShopid_orderfor a new one.resolveRecalcOrderIdhandles both forms (#812) — before that fix, order update webhooks (the common case) never enqueued a recalc.- An order deleted in PrestaShop also enqueues its recalc: the freed demand must recompute right away, not on the next safety-net cron.
See Supplier orders for the rest of the pipeline (queue, write lock, gates).
Verification commands (smoke tests)
After any change in these syncs' blast radius, validate the full code path with near-zero writes:
# The CLI loads without crashing (module-level errors, Prisma init…)
cd apps/app && bun run cli --help
# Order sync: full path, 1 record
cd apps/app && bun run cli -c sync-prestashop \
-t <tenantId> -s <siteId> \
--entities orders --last_days 1 --max_records 1 --verbose -y
# Product sync: incremental, 1 record
cd apps/app && bun run cli -c sync-prestashop \
-t <tenantId> -s <siteId> \
--entities products --incremental --max_records 1 --verbose -y
Always test with --max_records 1 before running a full sync after a code change. The CLI
smoke tests (cli-smoke.test.ts) cover module-graph loading — one of the regression
classes that silently break these crons.
See also
- Overview — the stock + reordering model on one page
- The stock ledger — signed authority, clamped mirror, invariants
- Write paths — who may write stock, and how
- Supplier orders — the event-driven reordering these syncs feed
- Daily timeline — a typical cron day
- Safety nets — the I1…I5 audit, replay, pinned tests
- Configuration —
BEXT_SUPPLIER_RECALCand the other gates