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.

Note

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 User rows).
  • Orders — upsert keyed by invoiceNumber = String(id_order) (duplicated into customInputs.prestashop_id). The Order model has no externalId field — the matching key is invoiceNumber, 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.

Warning

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 touch Product.stock. The back-office ledger is the authority (LEDGER_AS_SOURCE_OF_TRUTH=true). A new product starts at stock = 0 — the merchant enters the real on-hand on the platform side, which avoids creating stock ≠ 0 without a ledger row (an I5 violation).
  • RESET_STOCK_ON_SYNC=true: the sync owns the stock write, and every write emits the matching StockAction (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)]
  1. fetchVendorsLookup — paginates PrestaShop's manufacturers and suppliers endpoints into a typed { supplier: Map, manufacturer: Map } structure. Two separate maps, not one: see the warning below.
  2. refreshVendorStatus — for each local vendor, compares the remote active with the stored vendor.data.active. On drift, it updates that one field via a partial merge ({ ...currentData, active }). Matching is type-aware: a local supplier vendor is looked up in the supplier map, never in manufacturer (falling back to both only for legacy rows without vendor_type).
  3. 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.
  4. linkProductsToVendors — selects candidate products with the filter id_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.
Warning

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.

Warning

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.

Tip

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. productssyncSpecificProductimportPrestashopProduct (so the exact same ledger-integrated writes as above); orderssyncSpecificOrderprocessOrderFromPrestashop; customerssyncSpecificCustomer. 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 by prestashop-webhook-secret.test.ts) and is resolved against SiteSettings on two keys: webhook.secret (canonical) and prestashop.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 a sessionId and processing time) — browsable in the back office at /manage/integrations/prestashop/webhooks.

Deletions

delete events never remove rows:

  • Product — marked active: false + deleted_from_prestashop flags in Product.data.
  • OrderinvoiceStatus: "CANCELED" + cancellation of the order's PENDING forecast StockAction rows (never the COMPLETED ones — an applied movement is not reversed here, rule 17).
  • Customerdeleted_from_prestashop flags in customInputs.

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 via Product.externalId.
  • result.orderId is dual-form: the internal UUID for an existing order, the PrestaShop id_order for a new one. resolveRecalcOrderId handles 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
Tip

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