Skip to content

PrestaShop Integration

Reference for App\Integrations\PrestaShop\PrestaShopIntegration. Covers the connectivity verification contract, the customer, product, and order bulk-export implementations, the HTTP endpoints they target, and the test coverage for each.

Class

Namespace: App\Integrations\PrestaShop\PrestaShopIntegration Extends: App\Services\AbstractIntegration

Connectivity Verification

PrestaShopIntegration implements performVerification(SystemConnection $connection): VerificationResult, which is called by the sealed AbstractIntegration::verify() method. Any uncaught Throwable from performVerification() is caught by verify() and converted into a failed VerificationResult, so callers never handle exceptions from the verification path.

What it does

  1. Reads api_key from the connection's encrypted credentials array.
  2. GETs {base_url}/api/shops?output_format=JSON using HTTP Basic Auth — the API key as the username, an empty password (the PrestaShop legacy Webservice API convention).
  3. TLS certificate verification is disabled when running locally (app()->isLocal()), to allow self-signed certificates in local PrestaShop containers.
  4. $response->throw() raises on any non-2xx status, which AbstractIntegration::verify() catches and converts to a failed VerificationResult.
  5. On success, resolves the first shop ID from the response (shops.0.id) as the external user identifier; falls back to the literal string 'unknown' if the field is missing or non-scalar.

Endpoint

GET {base_url}/api/shops?output_format=JSON

Authenticated via HTTP Basic Auth: credentials.api_key as username, empty password.

Outcomes

Condition VerificationResult returned
200 with a shop ID present VerificationResult::success($shopId)successful = true, externalUserId is the first shop's ID (cast to string)
200 with no shop ID resolvable VerificationResult::success('unknown')
Any non-2xx status (e.g. 401 for an invalid API key) $response->throw() raises an Illuminate\Http\Client\RequestException, caught by AbstractIntegration::verify() and converted to VerificationResult::failure($e->getMessage())
Network error (e.g. connection timeout) Illuminate\Http\Client\ConnectionException, caught the same way

VerificationResult shape

Namespace: App\Data\VerificationResult

Property Type Description
successful bool true on success, false on any failure
externalUserId ?string The resolved shop ID on success; null on failure
message ?string Human-readable failure reason; null on success

Bulk Export

PrestaShopIntegration implements fetchBulkExportBatches(), performProbeBulkExport(), performFetchBulkExportPageRange(), and provideTransformerClass(). Both the sequential and the parallel path dispatch on $request->capability via a match with no default arm — all three SystemCapability cases are handled explicitly in each:

Capability Sequential (fetchBulkExportBatches()) Parallel page range (performFetchBulkExportPageRange())
Customers private fetchCustomerBatches() private fetchCustomerPageRange()
Products private fetchProductBatches() private fetchProductPageRange()
Orders private fetchOrderBatches() private fetchOrderPageRange()

The sequential helpers run a full pagination loop internally and are exercised whenever a caller drives requestBulkExport()/streamBulkExport() directly, as the unit tests below do. In production, BulkSyncJob instead calls probeBulkExport() first and fans out parallel FetchBulkPageRangeJobs via fetchBulkExportPageRange() — see Parallel bulk export below.

Parallel bulk export (page-range fan-out)

Unlike WooCommerce's REST API, PrestaShop's Webservice API exposes no total-count or total-pages response header. performProbeBulkExport() works around this by listing all record ids for the resource in a single unpaginated request — display=[id], no limit parameter at all — and deriving the page count from the number of ids returned:

GET {base_url}/api/{customers|products|orders}?output_format=JSON&display=[id]
$totalPages = (int) ceil(count($ids) / PrestaShopIntegration::PER_PAGE); // PER_PAGE = 50

If the resource has no records at all, totalPages is 0. Otherwise BulkSyncJob fans out FetchBulkPageRangeJob batches of up to 5 pages each (BulkSyncJob::PAGES_PER_JOB) — the same mechanism WooCommerceIntegration uses, just fed by an id count instead of a response header.

performFetchBulkExportPageRange() dispatches on capability the same way fetchBulkExportBatches() does, delegating to a private page-range counterpart (see table above). Each counterpart loops $startPage to $endPage and issues one limit=<offset>,<count> request per page — the same offset-based pagination the sequential loop uses, just bounded to the assigned range instead of running until a short page.

Per-job lookup prefetch

Products and Orders both depend on lookups resolved from other PrestaShop resources (category names, the default currency, order state names, currency codes, country names). In the sequential path these are resolved once for the whole export, before the pagination loop starts. The parallel path has no single "whole export" scope — each FetchBulkPageRangeJob runs independently — so each page-range job resolves its own copy of these lookups once per job, before looping over its assigned pages:

Capability Lookups resolved once per job
Products category names (fetchLocalizedNameMap(..., 'categories')) and the default currency (fetchDefaultCurrency())
Orders order state names, currencies, and country names (all via fetchLocalizedNameMap()/fetchCurrencies())

This is a deliberate trade-off for job independence, not a bug: a full parallel export re-fetches these lookup resources once per page-range job rather than once per capability. A large product catalogue split across several jobs issues one /api/categories fetch and one /api/configurations+/api/currencies/{id} round-trip per job, not once overall.

Credentials

Verification and all three bulk-export paths authenticate via HTTP Basic Auth using credentials.api_key as the username and an empty password. The extraction is shared by a private apiKey(SystemConnection $connection): string helper.

Customers

Endpoint

GET {base_url}/api/customers

The response body is wrapped under a customers key — {"customers": [...]} — not a flat array. The integration reads it via $response->json('customers') ?? []. When the requested offset is beyond the total record count, the real Webservice API returns a bare [] with no customers key at all; the ?? [] fallback covers that case, and the empty result ends the pagination loop.

Query parameter Value Description
output_format JSON Requests JSON instead of the Webservice API's default XML
display [id,firstname,lastname,email,id_gender,birthday,date_add,date_upd] Explicit field list. Without it, PrestaShop's list endpoint returns bare {"id": N} objects with no other fields
limit "{$offset},{$perPage}", e.g. 0,50 then 50,50 PrestaShop's native single-parameter offset syntax: limit=<offset>,<count>

Pagination

page is not a valid pagination parameter for this resource

An earlier version of this integration used separate page/limit query parameters, following the ticket's original assumption. Live testing against a real PrestaShop sandbox showed the Webservice API silently ignores page for the customers resource — requesting page=1, page=2, … with a fixed limit returns the same first N records every time. That would have caused an infinite loop (or silently duplicated data) in production, since the loop's termination condition depends on eventually receiving a short page.

The integration instead tracks an $offset (starting at 0, incremented by PrestaShopIntegration::PER_PAGE, i.e. 50, after each request) and sends it via PrestaShop's native limit=<offset>,<count> syntax — limit=0,50 for the first page, limit=50,50 for the next, and so on. There is no total-count or total-pages header to read; the loop condition is unchanged: it continues until a page returns fewer than PER_PAGE (50) items (count($items) === $perPage).

The parallel counterpart, fetchCustomerPageRange(), uses the identical limit=<offset>,<count> request per page, but bounded to the job's assigned $startPage..$endPage range instead of looping until a short page — see Parallel bulk export above.

Delta sync via modifiedAfter

No server-side delta filter exists for this resource

The ticket assumed a date_upd[gt] query filter would restrict the export server-side. Live testing showed the real Webservice API silently ignores it — no error, no filtering effect. Explicitly requesting filter[date_upd] fares worse: PrestaShop returns an HTTP 400 rejecting date_upd as an unsupported filter field for the customers resource on this store (the error lists the allowed fields, e.g. id, id_default_group, id_lang, ..., active, note, is_guest, id_shop, id_shop_group, reset_password_token, reset_password_validity — neither date_add nor date_upd is among them). There is no server-side way to filter or sort by date_upd on this PrestaShop instance.

The integration filters client-side instead. ExportRequest::$modifiedAfter is an optional ISO-8601 string; when set, the integration parses it into a Carbon threshold and, for every fetched page, discards any record whose date_upd is not strictly after that threshold — via a private wasModifiedAfter(array $item, Carbon $threshold, ?string $shopTimezone): bool helper (Carbon::parse($dateUpd, $shopTimezone)->greaterThan($threshold)) — before mapping the page to CustomerData and handing it to $onBatch. When modifiedAfter is null (a first full sync), no filtering is applied, every fetched record is forwarded, and the shop timezone is never resolved — see Shop timezone resolution below.

Because the filtering happens after the full page has already been fetched, a delta sync still retrieves the entire customer list from PrestaShop page by page — there is no bandwidth saving on the outbound HTTP calls. The only effect of modifiedAfter is a reduction in what gets mapped, forwarded downstream, and ultimately persisted. This is not a "real" delta sync from the API's perspective; readers relying on this integration for large PrestaShop stores should budget for a full customer scan on every sync regardless of modifiedAfter.

Shop timezone resolution

PrestaShop's webservice returns date_upd in shop-local time with no UTC offset (e.g. 2024-06-01 03:00:00), while modifiedAfter is always a UTC instant. Comparing the two naively in the app timezone (UTC) made delta syncs unreliable: shops ahead of UTC re-exported unchanged records on every run, and shops behind UTC could miss changes entirely.

A private fetchShopTimezone(string $baseUrl, string $apiKey): ?string helper resolves the shop's configured timezone via the PS_TIMEZONE configuration, using the same lookup pattern fetchDefaultCurrency() uses for PS_CURRENCY_DEFAULT (see Default currency resolution above), and validates the value against PHP's timezone_identifiers_list():

GET {base_url}/api/configurations?filter[name]=PS_TIMEZONE&display=[value]

It is called only when modifiedAfter is set — a full export never issues this request — and, like the other lookups, once per bulk-export call on the sequential path or once per page-range job on the parallel path (see Per-job lookup prefetch above). Resolution failures — a failed response, a missing value, or a value that isn't a valid PHP timezone identifier — are non-fatal: fetchShopTimezone() returns null, and wasModifiedAfter() falls back to Carbon::parse($dateUpd, null), i.e. the app timezone (UTC) — the previous, timezone-naive behaviour.

Error handling

Any response for which $response->failed() is true (any non-2xx status, including 401) causes fetchCustomerBatches() to throw IntegrationRequestException with the HTTP status code. There is no token-refresh-retry logic: PrestaShop authenticates with a static api_key rather than an OAuth token, so there is no refresh-token concept to retry against — a 401 is treated like any other HTTP error, and the request is not retried.

Mapping: PrestaShop customer → CustomerData

Field mapping lives on PrestaShopTransformer::mapCustomer() (public static), called directly by PrestaShopIntegration from fetchCustomerBatches() — it is not invoked through the TransformerContract::transform() interface.

PrestaShop field CustomerData property Notes
id sourceId Cast to string; empty string if missing or non-scalar
(constant) sourceSystem Always SystemDriver::PrestaShop
email email Empty string if missing or non-string
firstname firstName null if missing, non-string, or empty
lastname lastName null if missing, non-string, or empty
date_add createdAt Empty string if missing or non-string
date_upd updatedAt Empty string if missing or non-string
id_gender gender Via mapGender(): 1Gender::Male, 2Gender::Female, any other scalar → Gender::Unknown, missing or non-scalar → null
birthday dateOfBirth Via mapDateOfBirth(): PrestaShop's null-date sentinel '0000-00-00' and an empty string both map to null; any other string is passed through as-is

addressBilling and addressShipping are not mapped — always null. The real PrestaShop Legacy Webservice API exposes addresses as a separate /api/addresses resource, not inline on the customer record; inline address mapping is deferred to a future ticket. tags is not populated either — it defaults to an empty array as defined by CustomerData.

Products

Endpoint

GET {base_url}/api/products

The response body is wrapped under a products key — {"products": [...]} — read the same way as customers, via $response->json('products') ?? [].

Query parameter Value Description
output_format JSON Requests JSON instead of the Webservice API's default XML
display full The full resource representation, not a restricted field list. Unlike customers, an explicit display=[id,name,...] field list omits associations.categories and id_default_image — both are required for mapping and only come back with display=full
limit "{$offset},{$perPage}", e.g. 0,50 then 50,50 Same offset-based syntax as customers

Category name resolution

Product category IDs (associations.categories[].id) need to be resolved to localized names, but PrestaShop does not inline category names on the product resource. Rather than issuing a /api/categories/{id} lookup per product (an N+1 pattern — categories are shared across many products), a private fetchCategoryNames(string $baseUrl, string $apiKey): array helper fetches all categories once per bulk-export call, before the product pagination loop starts:

GET {base_url}/api/categories?output_format=JSON&display=[id,name]&limit={offset},{perPage}

This is paginated the same way as products/customers (offset-based limit, loop until a short page). Each category's name field — like a product's name — is PrestaShop's translatable-field shape (see below) and is resolved via PrestaShopTransformer::mapLocalizedValue(). The result is an array<int, string> lookup keyed by category ID, passed into PrestaShopTransformer::mapProduct() for every product on every page.

Default currency resolution

PrestaShopTransformer::mapProduct() takes a fourth parameter, ?string $currency, alongside $data, $categoryNames, and $baseUrl. Like $categoryNames, the currency is resolved once per bulk-export call, before the product pagination loop starts, by a private fetchDefaultCurrency(string $baseUrl, string $apiKey): ?string helper — not per product.

Resolution is a two-step lookup:

GET {base_url}/api/configurations?filter[name]=PS_CURRENCY_DEFAULT   → the default currency's numeric id
GET {base_url}/api/currencies/{id}                                    → that currency's iso_code, e.g. "EUR"

Unlike category resolution — where a failure throws IntegrationRequestException and aborts the whole export — a failure at either step of currency resolution is non-fatal: fetchDefaultCurrency() returns null, every product in the export gets currency: null, and the product sync proceeds normally. This is deliberate: currency is enrichment metadata, not core to any acceptance criterion, so a currency-resolution outage should not block product syncing the way a category-resolution outage does.

Pagination

Identical strategy to customers: an $offset starting at 0, incremented by PrestaShopIntegration::PER_PAGE (50) after each request, sent via limit=<offset>,<count>. The loop continues until a page returns fewer than PER_PAGE items. The categories fetch (above) uses the same pagination loop independently, before the products loop begins.

The parallel counterpart, fetchProductPageRange(), resolves $categoryNames and $currency once per job (see Per-job lookup prefetch above), then loops only the job's assigned $startPage..$endPage range using the same limit=<offset>,<count> request per page.

Delta sync via modifiedAfter

No server-side delta filter exists for this resource either

Live testing against the same PrestaShop test store confirmed the products resource behaves like customers: requesting filter[date_upd]=... against /api/products returns an HTTP 400 with an explicit list of filterable fields that does not include date_upd. There is no server-side way to filter or sort by date_upd on this resource.

The integration filters client-side, reusing the same private wasModifiedAfter(array $item, Carbon $threshold, ?string $shopTimezone): bool helper used for customers — including the same fetchShopTimezone() resolution described in Shop timezone resolution above — it checks the raw date_upd field on each fetched product before mapping. As with customers, a delta sync still retrieves the entire product catalogue from PrestaShop page by page; modifiedAfter only reduces what gets mapped and forwarded, not what gets fetched.

Error handling

Any response for which $response->failed() is true (any non-2xx status) causes fetchProductBatches() — or fetchCategoryNames(), if the categories request itself fails — to throw IntegrationRequestException with the HTTP status code. A failure while fetching categories aborts before any request is made to /api/products.

Mapping: PrestaShop product → ProductData

Field mapping lives on PrestaShopTransformer::mapProduct(array $data, array $categoryNames, string $baseUrl, ?string $currency): ProductData (public static), called directly by PrestaShopIntegration from fetchProductBatches() with the pre-resolved $categoryNames lookup, the connection's $baseUrl, and the pre-resolved $currency (see Default currency resolution above) — it is not invoked through the TransformerContract::transform() interface.

PrestaShop field ProductData property Notes
id sourceId Cast to string; empty string if missing or non-scalar
(constant) sourceSystem Always SystemDriver::PrestaShop
name name Via mapLocalizedValue() — see below
description description Via mapLocalizedValue() — same per-language array shape as name. Empty string maps to null
active status '1' (string) → ProductStatus::Active; anything else, including missing, → ProductStatus::Inactive. ProductStatus::Deleted is never produced — the Webservice API does not return deleted products at all
date_add createdAt Empty string if missing or non-string
date_upd updatedAt Empty string if missing or non-string
associations.categories[].id categories Each ID is resolved against the $categoryNames lookup map; falls back to the ID cast to a string if not found in the map. Empty array if associations or associations.categories is missing
reference sku null if missing or empty
id url Constructed as {baseUrl}/index.php?controller=product&id_product={id} — deliberately not PrestaShop's friendly-URL (link_rewrite) format; see below. null if id is missing or non-scalar
price price Decimal string (e.g. "23.900000") converted to integer minor units via (int) round((float) $price * 100), per this project's monetary-value convention. null if missing or non-scalar
id + id_default_image imageUrl Built manually as {baseUrl}/api/images/products/{id}/{id_default_image} — PrestaShop does not return a ready-made image URL, only an image ID. null if id_default_image is missing, non-scalar, or <= 0
(pre-resolved, once per export) currency The pre-resolved $currency argument, passed straight through — see Default currency resolution above. null if resolution failed
manufacturer_name brandName Direct passthrough — a plain, non-localized string, unlike name/description. null if missing or empty

originalPrice, targetGroup, and orderCount are not mapped — always null. tags is not populated either — it defaults to an empty array as defined by ProductData. See Fields deliberately excluded below for why.

PrestaShop supports "friendly URLs" via the link_rewrite field (e.g. /12-hummingbird-printed-t-shirt.html), but mapUrl() deliberately does not use it. Friendly-URL resolution depends on shop-specific rewrite configuration — whether it's enabled at all, whether categories are included in the path, the active URL-rewrite ruleset — none of which is reliably reproducible from the API response alone. The non-rewritten index.php?controller=product&id_product={id} format always resolves correctly regardless of shop configuration; this was verified against a live PrestaShop test store.

Fields deliberately excluded

These fields were investigated against a live PrestaShop test store's Webservice API and deliberately excluded, to save a future reader from re-investigating the same ground:

ProductData field Why it's excluded
tags PrestaShop's product resource has no tags association. A full-representation fetch's associations block only contains categories, images, combinations, product_option_values, product_features, and stock_availables — no tags
originalPrice Would require resolving PrestaShop's specific_price discount-rule associations (date ranges, shop/currency scoping) — materially more complex than a field read. Deferred
orderCount Would require aggregating order data, which isn't product data at all — out of scope for a Products-capability export
sourceCustomId, customAttributes, targetGroup No corresponding PrestaShop concept identified
mapLocalizedValue(): PrestaShop's translatable-field shape

PrestaShop returns translatable fields — a product's name, a category's name — as an array of per-language entries, not a plain string: [{"id": 1, "value": "Hummingbird printed t-shirt"}, {"id": 2, "value": "T-shirt imprimé colibri"}, ...], one entry per shop language. PrestaShopTransformer::mapLocalizedValue(mixed $raw): string (public static) picks the entry with the lowest language id — the shop's default language — and returns its value. Used for product names, product descriptions, and category names. Returns an empty string if $raw is not an array or is empty.

Orders

Verified against a live PrestaShop sandbox

Field names and shapes for /api/orders, /api/order_states, /api/currencies, /api/countries, and /api/addresses were confirmed by fetching real data from a running PrestaShop test store — the same kind of live verification already done for Customers and Products. One assumption from the initial implementation turned out to be wrong and was corrected before merging: associations.order_rows[] has no total_price_tax_incl field — a per-row total doesn't exist on the resource at all. LineItemData::total is computed client-side as unitPrice * quantity instead of being read from a non-existent field. Everything else (current_state, id_currency, total_paid_tax_incl, total_paid_tax_excl, total_shipping_tax_incl, id_address_invoice, id_address_delivery, the localized-array shape of order_states.name and countries.name, and the flat addresses field shape) matched the initial implementation exactly.

Endpoint

GET {base_url}/api/orders

The response body is wrapped under an orders key — {"orders": [...]} — read the same way as customers and products, via $response->json('orders') ?? [].

Query parameter Value Description
output_format JSON Requests JSON instead of the Webservice API's default XML
display full The full resource representation — needed for associations.order_rows[] (the order's line items), which is not returned by a restricted display=[...] field list
limit "{$offset},{$perPage}", e.g. 0,50 then 50,50 Same offset-based syntax as customers and products

One-time lookups resolved before the pagination loop

Order fields reference several other PrestaShop resources by numeric ID rather than inlining the resolved value. Following the same "resolve once, not per record" pattern used for product category names and the default currency (see Category name resolution above), three private helpers each run once per bulk-export call (or, on the parallel path, once per page-range job — see Per-job lookup prefetch above), before the order pagination loop starts:

Helper Endpoint Builds Used to resolve
fetchOrderStateNames() GET {base_url}/api/order_states?display=[id,name] (paginated) array<int, string>, order state id → localized name via mapLocalizedValue() current_statestatus
fetchCurrencies() GET {base_url}/api/currencies?display=[id,iso_code] (paginated) array<int, string>, currency id → ISO code id_currencycurrency
fetchCountryNames() GET {base_url}/api/countries?display=[id,name] (paginated) array<int, string>, country id → localized name via mapLocalizedValue() Addresses' country field only

Unlike currency resolution for products (each order carries its own id_currency, so orders can't share a single pre-resolved default the way products do), fetchCurrencies() builds a full id → ISO-code lookup covering every currency the shop has, resolved per order from that lookup.

Addresses are not in this one-time list — see Targeted address loading below.

Targeted address loading

Unlike Customers — where address mapping is explicitly deferred because addresses are a separate /api/addresses resource not inlined on the customer record — Orders does resolve real address data, since address resolution is in scope for this capability. It does so without a full scan of /api/addresses: for every fetched page of orders, a private collectAddressIds() helper collects the distinct id_address_invoice and id_address_delivery ids referenced by that page's orders (skipping missing or <= 0 ids), and a private fetchAddressesByIds() helper loads only those addresses:

GET {base_url}/api/addresses?output_format=JSON&display=full&filter[id]=[3|5|9]

filter[id] uses PrestaShop's OR filter syntax — a |-separated list of ids inside brackets. The id list is chunked at PrestaShopIntegration::PER_PAGE (50) ids per request via array_chunk(), so a page referencing more than 50 distinct addresses issues multiple /api/addresses requests. If no order on the page references any address, fetchAddressesByIds() is called with an empty id list and sends no request at all.

This replaced an earlier implementation that fetched the shop's entire /api/addresses collection once per export via fetchAddresses(), regardless of how many (if any) of those addresses were actually referenced by the exported orders.

Both the sequential path (fetchOrderBatches()) and the parallel page-range path (fetchOrderPageRange()) funnel through the same private emitOrderBatch() helper, which performs this per-page collect-then-fetch sequence before mapping the page to OrderData. Addresses are therefore fetched once per fetched page, not once per bulk-export call or once per job.

Pagination

Identical strategy to customers and products: an $offset starting at 0, incremented by PrestaShopIntegration::PER_PAGE (50) after each request, sent via limit=<offset>,<count>. The loop continues until a page returns fewer than PER_PAGE items. Each of the three one-time lookups above (order states, currencies, countries) uses the same pagination loop independently, before the orders loop begins. The address lookup is not part of this — it runs once per fetched page instead (see Targeted address loading above).

The parallel counterpart, fetchOrderPageRange(), resolves the three one-time lookups once per job, then loops only the job's assigned $startPage..$endPage range, performing the same per-page targeted address lookup for each page in that range.

Delta sync via modifiedAfter

No server-side delta filter is assumed for this resource either

Following the same pattern established for customers and products, no server-side date_upd filter is assumed to work reliably for the orders resource. The live verification against the sandbox (see above) did not specifically re-test filter[date_upd] rejection on /api/orders — the assumption carries over from the confirmed Customers/Products behaviour rather than being independently re-confirmed for Orders.

The integration filters client-side, reusing the same private wasModifiedAfter(array $item, Carbon $threshold, ?string $shopTimezone): bool helper used for customers and products — including the same fetchShopTimezone() resolution described in Shop timezone resolution above — it checks the raw date_upd field on each fetched order before mapping. As with customers and products, a delta sync still retrieves the entire order list from PrestaShop page by page; modifiedAfter only reduces what gets mapped and forwarded, not what gets fetched.

Error handling

Any response for which $response->failed() is true (any non-2xx status) causes fetchOrderBatches() — or any of fetchOrderStateNames(), fetchCurrencies(), fetchCountryNames(), fetchAddressesByIds(), if that lookup's own request fails — to throw IntegrationRequestException with the HTTP status code. None of the order lookups are non-fatal (unlike default-currency resolution for products): a failure in any one of the three one-time lookups aborts before any request is made to /api/orders, and a failure in the per-page fetchAddressesByIds() call aborts the export after that page's /api/orders request has already succeeded, since addresses are resolved after the orders page is fetched, not before.

Mapping: PrestaShop order → OrderData

Field mapping lives on PrestaShopTransformer::mapOrder(array $data, array $orderStateNames, array $currencies, array $addresses): OrderData (public static), called directly by PrestaShopIntegration from fetchOrderBatches() with the four pre-resolved lookups — it is not invoked through the TransformerContract::transform() interface.

PrestaShop field OrderData property Notes
id sourceId Cast to string; empty string if missing or non-scalar
(constant) sourceSystem Always SystemDriver::PrestaShop
reference sourceOrderNumber null if missing or empty
current_state status Via mapOrderState(): resolved against $orderStateNames; falls back to the numeric id cast to a string if not found in the lookup; empty string if current_state is missing or non-scalar
id_currency currency Via mapOrderCurrency(): resolved against $currencies; empty string if not found in the lookup, or if id_currency is missing or non-scalar. Note: OrderData::currency is a required non-nullable string, unlike ProductData::currency, which is nullable
total_paid_tax_incl total Minor units via mapAmount(); defaults to 0 if missing or non-scalar — OrderData::total is a required non-nullable int
total_paid_tax_incltotal_paid_tax_excl totalTax Computed tax amount in minor units via mapOrderTax(); null if either raw field is missing or non-scalar
total_shipping_tax_incl totalShipping Minor units via mapNullableAmount(); null if missing or non-scalar
date_add / date_upd createdAt / updatedAt Empty string if missing or non-string
id_customer customerSourceId Cast to string; null if missing or non-scalar
id_address_invoice / id_address_delivery addressBilling / addressShipping Via mapOrderAddress(): resolved against $addresses; null if the id is missing, non-scalar, <= 0, or not found in the lookup
associations.order_rows[] lineItems Via mapLineItems() / mapLineItem() — see Line item mapping below

customerId (the Conflux-internal customer UUID) is never set — always null; only customerSourceId (the PrestaShop id) is populated. It is resolved later during persistence, the same way products resolve line items' productId — this is existing AbstractTransformer behaviour, not new for this capability. tags and customAttributes default to empty, same as the other entity DTOs.

Fields deliberately excluded
OrderData field Why it's excluded
datePaid Confirmed absent from the orders resource via its ?schema=blank field list on the sandbox — no "date paid" field exists (invoice_date tracks invoicing, not payment, and is "0000-00-00 00:00:00" on unpaid orders). Always null
Line item mapping

PrestaShopTransformer::mapLineItem(array $row): LineItemData maps each entry of associations.order_rows[]:

PrestaShop field LineItemData property Notes
id sourceId Cast to string; empty string if missing
product_id productSourceId Cast to string; empty string if missing
product_name name Empty string if missing or non-string
product_quantity quantity Cast to int; 0 if missing or non-scalar
unit_price_tax_incl unitPrice Minor units via mapAmount(); 0 if missing or non-scalar
(computed) total unitPrice * quantity — see note below
product_reference sku null if missing or empty

total is computed, not read from a PrestaShop field: associations.order_rows[] has no per-row total field (total_price_tax_incl does not exist on this association — confirmed against a live PrestaShop store, see the verification note above). unit_price_tax_incl * product_quantity reproduces the row's tax-inclusive total.

productId and currency on the line item are never mapped — always null. Product-id resolution happens later, in AbstractTransformer::storeOrder(), by looking up the Product model via productSourceId — this is existing behaviour shared with other integrations, not new for this capability.

Address mapping: PrestaShopTransformer::mapAddress()

PrestaShopTransformer::mapAddress(array $data, array $countryNames): AddressData maps a raw PrestaShop address record to AddressData. It's used both by the one-time fetchAddresses() lookup and, transitively, for addressBilling/addressShipping on every mapped order.

PrestaShop field AddressData property Notes
firstname / lastname firstName / lastName null if missing or empty
company company null if missing or empty
address1 / address2 address1 / address2 null if missing or empty
city city null if missing or empty
postcode postalCode null if missing or empty
phone phone null if missing or empty
vat_number vatId null if missing or empty
id_country country Resolved against $countryNames; null if the id is not found in the lookup or missing/non-scalar

region and email are deliberately excluded:

AddressData field Why it's excluded
region Would require resolving PrestaShop's id_state, a nested resource under countries. Deferred
email PrestaShop's address resource has no email field — it only exists on the customer record

PHPStan note

base_url is declared as string|null on SystemConnection. Before passing it to rtrim(), the integration casts it explicitly: (string) $connection->base_url. This satisfies PHPStan level 10 (the same pattern WooCommerceIntegration uses, per CFX-80).

Test coverage

Tests live in tests/Unit/Integrations/PrestaShop/PrestaShopIntegrationTest.php. All tests use Http::fake() to stub the HTTP layer — no database or network access.

verify tests

Test Stubbed response Assertion
succeeds and resolves the shop id from a valid response HTTP 200 with a shop ID in the body result->successful is true; result->externalUserId equals the shop ID; the outbound request carries an Authorization header
fails when the api key is rejected HTTP 401 result->successful is false
fails when a network error occurs ConnectionException thrown by the fake result->successful is false

fetchBulkExport (Customers) tests (exercised via requestBulkExport(), which drives fetchBulkExportBatches()fetchCustomerBatches())

Test What it covers
maps PrestaShop customer fields to CustomerData Full field mapping including gender, date of birth, and null billing/shipping addresses
fetches all pages when paginating using offset-based limit Pagination continues until a page returns fewer than limit items; asserts the limit query param is sent as 0,50 then 50,50
filters out records not modified after modifiedAfter, client-side Records with date_upd not strictly after modifiedAfter are discarded client-side before mapping
returns all records when modifiedAfter is not set No client-side filtering is applied on a first full sync — all fetched records are forwarded
throws IntegrationRequestException on HTTP error A 5xx response raises IntegrationRequestException
throws IntegrationRequestException on HTTP 401 without retrying A 401 response raises IntegrationRequestException with no retry — Http::assertSentCount(1)

fetchBulkExport (Products) tests (exercised via requestBulkExport(), which drives fetchBulkExportBatches()fetchProductBatches())

Test What it covers
resolves category names and default currency once, and maps PrestaShop product fields to ProductData Full field mapping — including categories resolved via the one-time /api/categories fetch, description, url, brandName, the resolved currency, and the constructed image URL
fetches all product pages when paginating using offset-based limit Pagination continues until a page returns fewer than limit items; asserts the limit query param on /api/products is sent as 0,50 then 50,50
filters out products not modified after modifiedAfter, client-side Products with date_upd not strictly after modifiedAfter are discarded client-side before mapping
throws IntegrationRequestException on HTTP error from the products endpoint A 5xx response from /api/products raises IntegrationRequestException
throws IntegrationRequestException on HTTP error from the categories endpoint A 5xx response from /api/categories raises IntegrationRequestException before any request reaches /api/products

currency resolution failure is non-fatal tests (nested describe() within fetchBulkExport (Products))

Test What it covers
maps a null currency when the configurations endpoint fails A 5xx from /api/configurations results in currency: null on the mapped product — the product sync still completes
maps a null currency when the currencies endpoint fails A 5xx from /api/currencies/{id} (after a successful /api/configurations lookup) results in currency: null — the product sync still completes

fetchBulkExport (Orders) tests (exercised via requestBulkExport(), which drives fetchBulkExportBatches()fetchOrderBatches())

Test What it covers
resolves lookups once, fetches referenced addresses, and maps PrestaShop order fields to OrderData Full field mapping — including the once-per-export /api/order_states, /api/currencies, /api/countries resolution, the per-page /api/addresses lookup restricted to the ids referenced by the fetched page, resolved status/currency, computed totalTax, resolved billing/shipping addresses, and mapped line items; asserts Http::assertSentCount(5)
fetches all order pages when paginating using offset-based limit Pagination continues until a page returns fewer than limit items; asserts the limit query param on /api/orders is sent as 0,50 then 50,50
filters out orders not modified after modifiedAfter, client-side Orders with date_upd not strictly after modifiedAfter are discarded client-side before mapping
throws IntegrationRequestException on HTTP error from the order_states endpoint A 5xx response from /api/order_states raises IntegrationRequestException before any request reaches /api/currencies
throws IntegrationRequestException on HTTP error from the currencies endpoint A 5xx response from /api/currencies raises IntegrationRequestException before any request reaches /api/countries
throws IntegrationRequestException on HTTP error from the countries endpoint A 5xx response from /api/countries raises IntegrationRequestException before any request reaches /api/addresses
throws IntegrationRequestException on HTTP error from the orders endpoint A 5xx response from /api/orders raises IntegrationRequestException
throws IntegrationRequestException on HTTP error from the addresses endpoint A 5xx response from the per-page /api/addresses lookup raises IntegrationRequestException
maps a null billing address and skips the addresses request when no order references an address When no fetched order has a usable id_address_invoice/id_address_delivery, addressBilling maps to null and no /api/addresses request is sent at all

probeBulkExport tests

Test What it covers
derives the total page count from the id listing 120 customer ids returned from a single unpaginated GET /api/customers?display=[id] request yield totalPages: 3, perPage: 50
probes the endpoint matching the capability The same id-listing probe mechanism targets /api/customers, /api/products, or /api/orders depending on ExportRequest::$capability
returns zero pages when the shop has no records An empty id listing yields totalPages: 0
throws IntegrationRequestException on HTTP error A 5xx response from the id-listing request raises IntegrationRequestException

fetchBulkExportPageRange tests

Test What it covers
fetches only the requested pages for customers Requesting pages 2–3 sends only limit=50,50 and limit=100,50limit=0,50 (page 1) is never requested; Http::assertSentCount(2)
does not emit a batch for empty pages An empty page in the requested range produces zero batches
applies the modifiedAfter filter client-side Client-side modifiedAfter filtering applies identically to the page-range path
resolves product lookups once per page range Category names and the default currency are each resolved exactly once per fetchProductPageRange() call, not once per page within the assigned range; Http::assertSentCount(4) for a 2-page range
fetches only the addresses referenced by the fetched order pages Only the id_address_invoice/id_address_delivery ids present on the requested order pages are loaded, via the chunked filter[id]=[...] lookup; Http::assertSentCount(5)
throws IntegrationRequestException on HTTP error A 5xx response raises IntegrationRequestException

shop timezone handling tests

Test What it covers
interprets date_upd in the shop timezone when filtering A customer updated at 03:00 Europe/Berlin (01:00 UTC) is kept against a 2024-06-01T00:00:00Z threshold, while one updated at 01:00 Europe/Berlin (23:00 UTC the previous day) is discarded; asserts the PS_TIMEZONE configuration lookup is sent
does not fetch the shop timezone for full exports With no modifiedAfter set, /api/configurations (PS_TIMEZONE) is never requested
falls back to the app timezone when the configurations endpoint fails A 5xx from the PS_TIMEZONE lookup does not abort the export — filtering falls back to parsing date_upd in the app timezone
ignores an invalid PS_TIMEZONE value A PS_TIMEZONE value that isn't a valid PHP timezone identifier (e.g. Not/AZone) is discarded — filtering falls back to the app timezone

Field mapping for PrestaShopTransformer::mapCustomer() (including gender variants and date-of-birth edge cases), PrestaShopTransformer::mapProduct() (including localized-name and localized-description resolution, status, sku, price, categories, image-URL, url, brandName, and currency edge cases), PrestaShopTransformer::mapOrder() (including status/currency lookup fallbacks, total/totalTax/totalShipping edge cases, address resolution, and line item mapping), and PrestaShopTransformer::mapAddress() (including country-lookup resolution and missing-field defaults) is covered separately in tests/Unit/Integrations/PrestaShop/PrestaShopTransformerTest.php.

Source files

File Purpose
app/Integrations/PrestaShop/PrestaShopIntegration.php Integration implementation — sequential fetchBulkExportBatches() plus the parallel performProbeBulkExport()/performFetchBulkExportPageRange() overrides for Customers, Products, and Orders
app/Integrations/PrestaShop/PrestaShopTransformer.php Persistence inherited unchanged from AbstractTransformer; adds mapCustomer(), mapProduct(), mapOrder(), mapAddress(), mapLocalizedValue(), and their private mapping helpers
tests/Unit/Integrations/PrestaShop/PrestaShopIntegrationTest.php Unit tests for verify(), requestBulkExport() (Customers, Products, and Orders), probeBulkExport(), and fetchBulkExportPageRange()
tests/Unit/Integrations/PrestaShop/PrestaShopTransformerTest.php Unit tests for PrestaShopTransformer::mapCustomer(), mapProduct(), mapOrder(), and mapAddress() field mapping

See also