WooCommerce Integration¶
Reference for App\Integrations\WooCommerce\WooCommerceIntegration. Covers the connectivity verification contract, the HTTP endpoint it targets, the three possible outcomes, and the test coverage for each.
Class¶
Namespace: App\Integrations\WooCommerce\WooCommerceIntegration
Extends: App\Services\AbstractIntegration
Connectivity Verification¶
WooCommerceIntegration implements performVerification(SystemConnection $connection): VerificationResult, which is called by the sealed VerifiesConnections::verify(). 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¶
- Reads
connection_tokenfrom the connection's encryptedcredentialsarray. - Strips any trailing
/wp-json/…path frombase_urlto derive$storeBase. - POSTs to
{storeBase}/wp-json/{brandName}-connector/v1/connectivity-checkwithconnection_tokenandconnection_idin the request body. - Returns a
VerificationResultbased on the HTTP response status.
Endpoint¶
brand_name credential (CFX-230). conflux-connector ships as branded builds — the same plugin renamed per brand (conflux, rapidmail, user-platform, …), each registering its REST routes under its own namespace (see CONFLUX_BRAND_SLUG in brands/*.php and RestApi::NAMESPACE/AUTH_NAMESPACE) so differently-branded installs never collide on the same route. The plugin reports its slug as a plain-text brand_name credential at registration time (ConfluxApi::connect()); a private brandName(SystemConnection $connection): string helper reads credentials.brand_name and falls back to 'conflux' when it's missing or non-string — so connections paired before the plugin reported this credential keep working unchanged. The same brand_name key is used by the conflux PrestaShop module for its own branded routes — see PrestaShop Integration reference.
Request body
| Field | Type | Description |
|---|---|---|
connection_token |
string | Token from credentials['connection_token']; empty string if absent |
connection_id |
string | UUID of the SystemConnection |
Outcomes¶
| HTTP status | VerificationResult returned |
|---|---|
200 |
VerificationResult::success($storeBase) — successful = true, externalUserId = $storeBase |
401 |
VerificationResult::failure('Invalid connection token.') — successful = false, message = 'Invalid connection token.' |
| Any other status | $response->throw() raises an Illuminate\Http\Client\RequestException, caught by VerifiesConnections::verify() and converted to VerificationResult::failure($e->getMessage()) |
VerificationResult shape¶
Namespace: App\Data\VerificationResult
| Property | Type | Description |
|---|---|---|
successful |
bool |
true on success, false on any failure |
externalUserId |
?string |
The store base URL on success; null on failure |
message |
?string |
Human-readable failure reason; null on success |
Source locale resolution¶
performVerification() calls the public resolveSourceLocale(SystemConnection $connection): ?string right after the connectivity check succeeds — but only for a connection that already has a row (RemembersSourceLocale::shouldResolveSourceLocale()). WooCommerce publishes no locale of its own — the setting belongs to WordPress, and the one place the WooCommerce REST API exposes it is the system status report:
Authenticated with the store's own consumer_key/consumer_secret, the same credentials Bulk Export uses. The resolved value is environment.language (e.g. de_DE), remembered on system_connections.source_locale by the shared App\Services\Concerns\RemembersSourceLocale trait: a value that looks like a locale (a language tag, optionally with region and further subtags — de, de_DE, fr-FR) is stored; anything else — an empty string, a non-string, a body mistaken for one — leaves the previously stored value standing rather than clearing it, and the trait returns whichever value ends up in force. A transient (not yet persisted) connection is never written to: the value is set on the model and returned, so a caller reading the attribute sees it, but nothing persists it, which is why verification skips the lookup for one entirely. Registration verifies such a connection before deciding whether to insert a row and then saves a different instance, so the answer would be discarded — and this is the store's slowest endpoint, paid inside a synchronous API request on every registration attempt and retry. The locale is established on the finalizing verification instead, which runs against the persisted row long before anything delivers to it.
This is also the single most expensive read WooCommerce's REST API offers — it collects the plugin inventory and runs environment checks, and takes seconds on a large store — which is why it is capped at WooCommerceIntegration::SYSTEM_STATUS_TIMEOUT_SECONDS (10 seconds) and asked once per verification rather than once per sync run. Nothing in a bulk-export run reads the locale; it exists purely as the last fallback UserPlatformOutboundWriter uses to normalise a customer's phone number to E.164, after the customer's own billing and shipping addresses. A shop changes its language about as rarely as it changes its address, so resolving it at verification time rather than per run costs nothing a delivery would notice.
Best effort in every direction, because the locale is a fallback for one field of one writer, not something a sync depends on:
- A network error (
ConnectionException, including a timeout past the 10-second cap) or a failed HTTP response leaves the connection's storedsource_localeuntouched and returns it unchanged. - A value that is not a locale is also left alone, per the trait's rule above.
- None of this can fail verification:
resolveSourceLocale()never throws in a wayperformVerification()propagates, andVerificationResult::success()is returned regardless of what the locale lookup did.
Bulk Export¶
WooCommerceIntegration implements fetchBulkExportBatches(), the ExportsInBulk hook behind the sealed requestBulkExport()/streamBulkExport(). Supported capabilities: SystemCapability::Customers, SystemCapability::Orders, and SystemCapability::Products. The endpointAndMapper() method is exhaustive — all three capabilities are handled and there is no fall-through branch.
Capability dispatch¶
The private endpointAndMapper() helper maps a capability to its WC REST endpoint and item mapper:
| Capability | Endpoint | Mapper |
|---|---|---|
SystemCapability::Customers |
/wp-json/wc/v3/customers |
WooCommerceTransformer::mapCustomer() |
SystemCapability::Orders |
/wp-json/wc/v3/orders |
WooCommerceTransformer::mapOrder() |
SystemCapability::Products |
/wp-json/wc/v3/products |
WooCommerceTransformer::mapProduct() |
Credentials¶
Bulk export authenticates via HTTP Basic Auth using WooCommerce REST API keys read from the connection's encrypted credentials array:
| Credential key | Description |
|---|---|
consumer_key |
WooCommerce REST API consumer key |
consumer_secret |
WooCommerce REST API consumer secret |
credentials.brand_name is a third, non-secret credential — this build's brand identifier (falls back to 'conflux' if absent) — extracted by a private brandName(SystemConnection $connection): string helper and used to derive the branded REST namespaces for the connectivity-check and deletion log endpoints. Bulk export never reads it — /wp-json/wc/v3/… is WooCommerce's own unbranded namespace.
Delta sync (modified_after)¶
When ExportRequest::$modifiedAfter is set, the integration appends two query parameters to every request made by performProbeBulkExport, fetchBulkExportBatches and performFetchBulkExportPageRange: modified_after and dates_are_gmt=true. The cursor arrives as a CarbonImmutable and deltaFilterParams() renders it with toIso8601String(), which is the only place it becomes a string, so a UTC cursor goes out as 2024-06-01T07:08:09+00:00. The WooCommerce REST API filters results server-side so that only records modified later than the supplied timestamp are returned. When modifiedAfter is null both parameters are omitted and all records are returned (full sync).
Both come from the private deltaFilterParams() helper, which is the only place either is built. The three call sites differ only in page and per_page and spread the helper's result, so a site cannot send the cursor without the flag.
Why dates_are_gmt is sent¶
dates_are_gmt decides which database column WooCommerce compares the cursor against. WC_REST_CRUD_Controller::prepare_objects_query() turns modified_after into a date_query whose column is post_modified when the flag is absent and post_modified_gmt when it is set, and the parameter is registered with 'default' => false. So the flag is not a formatting hint: it selects the column.
The cursor is SyncBatch::started_at, one of Conflux's own UTC instants. Without the flag WooCommerce resolves that instant against the shop-local column, which on a shop five hours behind UTC means asking for records modified after a moment five hours later than intended. Every change inside that window is skipped, the cursor advances past it anyway, and the records reappear only when a full sync runs.
Nothing surfaces that. The response is a valid, smaller page rather than an error, so removing the flag breaks no test that does not assert on it and produces no failure in production. The tests in delta filter params assert the flag on all three call sites, and that a full run carries no date parameter at all.
The value is the literal string 'true', one of the four values WordPress's rest_is_boolean() accepts over the wire, so the query string reads dates_are_gmt=true rather than depending on a PHP boolean rendering as 1.
The customers endpoint filters nothing
dates_are_gmt and modified_after are collection parameters of WC_REST_CRUD_Controller, which backs /products and (through WC_REST_Orders_V2_Controller) /orders. WC_REST_Customers_Controller descends from WC_REST_Controller instead and registers neither, and WordPress ignores unregistered query parameters. A customers delta run therefore sends both and WooCommerce filters on neither, returning every customer. That is over-fetching rather than data loss, so it is a separate concern from the one above, and the parameters are still sent on all three endpoints because the fetch path is capability-agnostic.
The cursor is supplied by BulkSyncJob — the integration never reads or writes it directly. See Delta Sync for the cursor derivation rules.
Pagination¶
All three endpoints share the same pagination contract. The integration passes page (integer, starts at 1) and per_page (50) as query parameters, reads the X-WP-TotalPages response header to determine the total number of pages, and iterates until all pages are fetched. If the header is absent or empty it defaults to 1 page.
Error handling¶
Any non-2xx response causes fetchBulkExportBatches()/performProbeBulkExport()/performFetchBulkExportPageRange() to throw IntegrationRequestException with the HTTP status code. AbstractIntegration's sealed wrapper methods pass this through unchanged; any other Throwable is wrapped in InvalidIntegrationResponseException.
Datetime fields¶
WooCommerce sends a _gmt variant of every datetime field alongside a shop-local one. The integration reads the _gmt variant throughout, which is why WooCommerceIntegration::SOURCE_TIMEZONE is the constant 'UTC': that is the zone of the strings Conflux reads, not the zone the shop is in, so no per-connection lookup is needed. DATETIME_FORMAT is 'Y-m-d\TH:i:s', because WooCommerce writes a literal T separator and no offset. The table covers every datetime the integration reads, across the bulk export and the deletion log, so no field is discarded by accident. See ADR-0018 for the rule this table applies.
| Source field | Classification | Why |
|---|---|---|
date_created_gmt |
Parsed | The unambiguous UTC variant, read on all three resources into createdAt |
date_modified_gmt |
Parsed | Same, into updatedAt |
date_paid_gmt |
Parsed | Orders only, into datePaid |
date_created |
Deliberately unread | The shop-local twin of a field whose UTC variant is read. Reading both would store the same instant twice, one of them ambiguous |
date_modified |
Deliberately unread | Same reason |
date_paid |
Deliberately unread | Same reason |
date_completed |
Dropped | Named in WooCommerceTransformer::DROPPED_ORDER_KEYS. No consumer has asked for it and PrestaShop has no equivalent, so a column would carry one platform's field for nobody |
date_completed_gmt |
Dropped | Same, and named in the same constant |
deleted_at |
Read but unused | Deletion log only, not part of the bulk export. performFetchDeletionLog() reads it into DeletionLogRecord::$deletedAt as a raw string, and nothing consumes it: FetchDeletionLogJob soft-deletes by entity_id and stamps deleted_at itself. It reaches no column and no consumer payload, so rule 1 does not apply and it is not presented as an instant. Whoever gives it a reader must parse it through the declared-format path first, because WooCommerce sends it without an offset while the PrestaShop module sends an explicit Z |
A datetime inside meta_data |
Passed through | Customers only. A shop's own meta keys are unbounded and per shop, so the value travels as a raw string in customAttributes. Its format is unknown, and without a format a datetime string cannot be read safely, so it is not presented as an instant |
| Any other top-level datetime | Passed through | Products only. mapProductCustomAttributes() carries every top-level key absent from PRODUCT_MAPPED_KEYS whose value is scalar or null into customAttributes, and that list names no sale-date field, so a product's date_on_sale_from, date_on_sale_from_gmt, date_on_sale_to and date_on_sale_to_gmt travel as raw strings rather than being discarded. Nothing parses them, so nothing presents them as instants. Orders and customers have no such gap: every datetime those resources send is named either in ORDER_MAPPED_KEYS / CUSTOMER_MAPPED_KEYS or, for the two order completion fields, in DROPPED_ORDER_KEYS, and the attribute loop skips both lists |
All six read and unread fields appear in the resource's *_MAPPED_KEYS list, so none of them falls into customAttributes. The two dropped keys are excluded by DROPPED_ORDER_KEYS as well, which is what makes the drop a stated decision rather than the silent by-product of a mapper that never reads them.
Order and product meta_data is not passed through: it is excluded from ORDER_MAPPED_KEYS' output as a mapped key, and on products it is a non-scalar value that the attribute loop skips. Only mapCustomer() walks meta_data entries.
Mapping: WooCommerce order → OrderData¶
WooCommerceTransformer::mapOrder() is a public static method (callable via first-class callable syntax as the mapper closure).
| WooCommerce field | OrderData property |
Notes |
|---|---|---|
id |
sourceId |
Cast to string |
| (constant) | sourceSystem |
Always SystemDriver::WooCommerce |
number |
sourceOrderNumber |
Human-readable order number; cast to string; null if absent |
status |
status |
Raw string — not normalised (e.g. completed, processing) |
currency |
currency |
ISO 4217 code |
total |
total |
Integer minor currency units (round(float × 100)); defaults to 0 |
total_tax |
totalTax |
Integer minor currency units; null if absent or non-numeric |
shipping_total |
totalShipping |
Integer minor currency units; null if absent or non-numeric |
date_created_gmt |
createdAt |
Parsed to a UTC CarbonImmutable; null if absent or unreadable |
date_modified_gmt |
updatedAt |
Parsed to a UTC CarbonImmutable; null if absent or unreadable |
date_paid_gmt |
datePaid |
Parsed to a UTC CarbonImmutable; null if absent, empty or unreadable |
customer_id |
customerSourceId |
Cast to string; null if customer_id is 0 or absent (guest orders) |
billing.* |
addressBilling |
null if all billing fields are empty |
shipping.* |
addressShipping |
null if all shipping fields are empty |
line_items |
lineItems |
Collection<int, LineItemData> — see line item mapping below |
Unmapped scalar top-level fields (e.g. discount_total, payment_method) are captured as customAttributes. Fields in ORDER_MAPPED_KEYS, the two keys in DROPPED_ORDER_KEYS, and non-scalar values are excluded.
Mapping: WooCommerce line item → LineItemData¶
Each entry in the line_items array is mapped by the private mapLineItem() method and collected into OrderData::lineItems.
| WooCommerce field | LineItemData property |
Notes |
|---|---|---|
id |
sourceId |
Cast to string |
product_id |
productSourceId |
Cast to string — used by AbstractTransformer::storeOrder() to look up the previously-imported Product row by (system_connection_id, source_id) |
name |
name |
Product name at time of order |
quantity |
quantity |
Integer; defaults to 0 if absent |
price |
unitPrice |
Integer minor currency units; defaults to 0 if absent |
total |
total |
Integer minor currency units; defaults to 0 if absent |
sku |
sku |
null if absent or empty string |
Product linking
productSourceId carries the WooCommerce product_id. When AbstractTransformer::storeOrder() persists a line item it queries Product on (system_connection_id, product_source_id) and writes the resolved UUID to line_items.product_id. If the product has not been synced yet, product_id remains null until the order is re-imported after the Products sync (CFX-83) runs.
Mapping: WooCommerce customer → CustomerData¶
Field mapping lives on WooCommerceTransformer::mapCustomer() (public static), called directly by WooCommerceIntegration from each of the three fetch hooks above — it is not invoked through the TransformerContract::transform() interface.
| WooCommerce field | CustomerData property |
Notes |
|---|---|---|
id |
sourceId |
Cast to string |
| (constant) | sourceSystem |
Always SystemDriver::WooCommerce |
email |
email |
— |
date_created_gmt |
createdAt |
Parsed to a UTC CarbonImmutable; null if absent or unreadable |
date_modified_gmt |
updatedAt |
Parsed to a UTC CarbonImmutable; null if absent or unreadable |
first_name |
firstName |
null if empty |
last_name |
lastName |
null if empty |
billing.phone |
phone |
null if empty |
billing.* |
addressBilling |
null if all billing fields are empty |
shipping.* |
addressShipping |
null if all shipping fields are empty |
orders_count |
orderCount |
— |
total_spent |
totalSpent |
Converted to integer minor currency units (round(float × 100)) |
meta_data[conflux_newsletter] |
(a ConsentData of its own) |
Not part of CustomerData. mapCustomerRecords() emits one email ConsentData after the customer, and none when the key is absent — see Consent |
(remaining scalar top-level fields, plus meta_data entries) |
customAttributes |
Anything not in the mapped-keys list; see WooCommerceTransformer::CUSTOMER_MAPPED_KEYS |
Unmapped scalar top-level fields and meta_data entries with scalar values are captured as customAttributes. meta_data entries whose values are arrays or objects are silently skipped. The consent key is the one exception: it is mapped to a ConsentData and deliberately excluded from customAttributes, so it does not also land in the customer's external_data.
Mapping: WooCommerce product → ProductData¶
| WooCommerce field | ProductData property |
Notes |
|---|---|---|
id |
sourceId |
Cast to string |
| (constant) | sourceSystem |
Always SystemDriver::WooCommerce |
name |
name |
— |
status |
status |
See status mapping below |
date_created_gmt |
createdAt |
Parsed to a UTC CarbonImmutable; null if absent or unreadable |
date_modified_gmt |
updatedAt |
Parsed to a UTC CarbonImmutable; null if absent or unreadable |
sku |
sku |
null if absent or empty |
global_unique_id |
ean |
null if absent or empty. The key also stays a custom attribute |
price |
price |
Integer minor currency units (round(float × 100)); null if absent or non-numeric |
regular_price |
originalPrice |
Integer minor currency units (round(float × 100)); null if absent or non-numeric |
categories[*].name |
categories |
Flat array of category name strings; empty objects and blank names skipped |
tags[*].name |
tags |
Flat array of tag name strings; empty objects and blank names skipped |
images[0].src |
imageUrl |
First image only; null if images is empty or src is blank |
permalink |
url |
null if absent or empty |
description |
description |
HTML stripped via strip_tags(); null if absent or empty |
Status mapping¶
WooCommerce status |
ProductStatus |
|---|---|
publish |
Active |
trash |
Deleted |
(any other: draft, pending, private, …) |
Inactive |
Unmapped scalar top-level fields (e.g. weight, stock_quantity, featured) are captured as customAttributes. Fields in PRODUCT_MAPPED_KEYS and non-scalar values are excluded.
Deletion Log¶
WooCommerceIntegration implements App\Contracts\HasDeletionLog and implements performFetchDeletionLog(SystemConnection $connection, SystemCapability $capability, ?string $since): Collection, the hook of the FetchesDeletionLog trait behind the sealed fetchDeletionLog(). See Integration Contract — Deletion log endpoint contract for the query parameters and response shape shared by every integration, and Deletion Log Sync for why this exists and how FetchDeletionLogJob consumes the returned entries.
Endpoint¶
Namespaced wc-{brandName}/v1 — not {brandName}-connector/v1 like the connectivity-check endpoint — deliberately, so the route falls under WooCommerce's own REST Basic Auth recognition (routes namespaced wc-* get authenticated with a WooCommerce API key pair automatically; see WC_REST_Authentication::is_request_to_rest_api()). {brandName} is resolved the same way as the connectivity-check endpoint's, via the same private brandName() helper reading credentials.brand_name (CFX-230) — an earlier implementation hit a hardcoded wc-conflux/v1, which would have 404'd once conflux-connector actually branded its namespaces per install. Authenticated the same way as bulk export: HTTP Basic Auth using credentials.consumer_key/consumer_secret.
Consent¶
WooCommerce has no native consent field. The only consent a store can express is the newsletter
opt-in the conflux-connector plugin records as WordPress user meta, exposed on the WC REST
customer object's meta_data. That key is the contract between plugin and platform — the reasoning,
and what changing it would cost, is recorded in ADR-0026.
| Constant | Value |
|---|---|
WooCommerceTransformer::CONSENT_META_KEY |
conflux_newsletter |
Expected shape on any customer payload:
{
"id": 42,
"date_modified_gmt": "2024-06-01T12:00:00",
"meta_data": [
{ "id": 10, "key": "conflux_newsletter", "value": "1" }
]
}
Mapping: newsletter meta → ConsentData¶
WooCommerceTransformer::mapConsent() (public static) is the single interpreter of the field, used
by both inbound paths below.
ConsentData property |
Value | Notes |
|---|---|---|
sourceId |
{customerSourceId}:email |
Built by ConsentData::sourceIdFor(); parentSourceId is the customer's source id |
subscribed |
filter_var($value, FILTER_VALIDATE_BOOLEAN) |
'1', 1 and true are opt-ins; '0', 0 and '' are opt-outs |
confirmed |
null |
Always unknown — the checkbox is a single opt-in and WooCommerce has no confirmation step, so reporting false would claim an address was not confirmed. A migration set confirmed to null on the rows that already existed |
channel |
ConsentChannel::Email |
The only channel a WooCommerce store can express |
consentUpdatedAt |
date_modified_gmt |
Falls back to date_created_gmt; parsed through the run's SourceDateTimes, like every other WooCommerce datetime |
source |
ConsentSource::Integration |
The flag does not record whether a customer or an admin set it |
Return values:
| Payload | Result |
|---|---|
| Key present with a scalar value | ConsentData as above |
| Key absent | null — no signal, not a withdrawal |
| Key present with an array/object value | null — treated as no signal |
| Neither datetime readable | null — consentUpdatedAt is required because a two-way conflict between mirrors is settled by it, so an invented one would decide a conflict it cannot see |
An absent key never withdraws consent
A store without the plugin, or on a build predating the checkbox, says nothing about consent. mapCustomerRecords() then emits no ConsentData, and AbstractTransformer leaves any stored consent untouched — see Missing information is not a withdrawal.
Inbound paths¶
| Path | Trigger | Request |
|---|---|---|
| Bulk export | Any SystemCapability::Customers sync (full or delta) |
None extra — consent rides on the /wp-json/wc/v3/customers payload already being fetched |
pullConsent() |
On-demand read of a single contact | GET /wp-json/wc/v3/customers/{customerSourceId} |
WooCommerceIntegration implements CanPullConsent via PullsConsent, so pullConsent()
runs through the circuit breaker like every other outbound call. Its outcomes:
| HTTP status | Result |
|---|---|
200 |
ConsentData, or null when the customer carries no newsletter meta |
404 |
ConsentTargetNotFoundException — the store answered, so this does not count towards opening the circuit (ADR-0025) |
| Any other failure | IntegrationRequestException carrying the status code |
Persisting is not the integration's job: mapCustomerRecords() emits [CustomerData, ConsentData] and
AbstractTransformer::store() hands the ConsentData to ConsentRecorder::write(), which owns the
upsert on (system_connection_id, source_id). The consent moves its own change signal, and only when
a value changes: every customer edit re-reads the meta and moves date_modified_gmt, which moves
consentUpdatedAt and nothing else. There is no ordering across writers any more; the WooCommerce
mirror has one writer, its own connection (ADR-0041).
Outbound path: writing consent back¶
WooCommerceOutboundWriter is the store's registered outbound writer, registered for
SystemDriver::WooCommerce in AppServiceProvider::registerOutboundWriters(). It carries
customer_consents and nothing else: a consent rule into a WooCommerce connection (for example the
return rule that brings a Positive User withdrawal back to the shop) delivers through it, onto the
store customer its anchor names
(ADR-0042). The
direction matrix lists customer_consents for WooCommerce as pull_in and push_out.
It extends AbstractOutboundWriter
and writes the same meta key the read path interprets — so a write and the next pull cannot disagree
about where consent lives. The connection is a per-call argument, not constructor state, so one
writer instance serves every WooCommerce target.
It declares one verb. The other three are not refused at runtime, they are simply not on the class:
a caller narrowing to CanCreateRemoteRecords, CanDeactivateRemoteRecords or
CanDeleteRemoteRecords never reaches this writer at all — see
Capability interfaces.
| Verb | Declared by | Behaviour |
|---|---|---|
update() |
CanUpdateRemoteRecords + UpdatesRemoteRecords |
POST /wp-json/wc/v3/customers/{remoteId} with {"meta_data":[{"key":"conflux_newsletter","value":"1"|"0"}]} |
create() |
— | Not declared. A consent state is not a reason to create a WordPress user account in somebody's store |
deactivate() |
— | Not declared. deactivate may only touch lifecycle state, never a consent field (ADR-0016) |
delete() |
— | Not declared. The customer belongs to the store |
The verb is POST, not the PUT the WooCommerce REST docs show. WordPress dispatches POST, PUT and
PATCH on an editable route to the same handler, so WooCommerce cannot tell them apart — but the
layers in front of it can, and PUT and PATCH are the ones a hardened host, a WAF rule or a stock
nginx rewrite drops. The local WooCommerce box is an example rather than an exception: its nginx
rewrites /wp-json/ onto the directory index, where PUT and PATCH get a 405 from nginx and never
reach PHP.
The request names one meta key and nothing else. WooCommerce merges meta_data entry by entry, so
the store's own customer fields are left untouched — and a REST customer update advances
date_modified_gmt, which is the timestamp the state comes back on as consentUpdatedAt, the one a
two-way conflict between mirrors is settled by.
The flag is written as '1'/'0' rather than a JSON boolean: WordPress stores meta as a string
either way, and those are the values mapConsent() parses back. WooCommerce stores subscribed or
not, so a confirmed and an unconfirmed subscription both become '1'.
accepts() comes from the AcceptsConsentChannels trait. It is true only for a
customer_consents projection on a channel in supportedConsentChannels() (email) with a known
subscribed. Anything else is recorded suppressed by the delivery layer, with no request and no
budget token (see accepts()). An absent signal is not a
withdrawal, in this direction either. performUpdate() keeps the same check as a second line, and
refuses a projection of any other domain with OutboundWriteException.
Writing the meta moves date_modified_gmt. The next pull reads the same value back, so the mirror's
change signal does not move and nothing is delivered again
(ADR-0043).
Outcomes follow the outbound contract rather than the read path's exception types:
| Outcome | Result |
|---|---|
404 |
RemoteRecordGoneException — the store answered that the customer is gone, so it does not count towards opening the circuit, and the delivery layer must tell it apart from a failed write |
| Any other failure | OutboundWriteException carrying the status code — one retryable failure type, as the ledger records it |
AbstractOutboundWriter::write() supplies the circuit breaker and the outbound span around the call,
so the writer itself contains neither.
The delivery pipeline that calls it is described in Outbound Delivery.
Declared channels¶
One checkbox is one channel. A propagator pushing consent outward uses this to skip channels the
store cannot represent — see HasConsentChannels.
PHPStan note¶
base_url is declared as string|null on SystemConnection. Before passing it to preg_replace(), the integration casts it explicitly: (string) $connection->base_url. This satisfies PHPStan level 10 (CFX-80).
Test coverage¶
Tests live in tests/Unit/Integrations/WooCommerce/WooCommerceIntegrationTest.php and tests/Unit/Integrations/WooCommerce/WooCommerceTransformerTest.php. All unit tests use Http::fake() to stub the HTTP layer — no database or network access. The consent path additionally has database-backed coverage in tests/Integration/Integrations/WooCommerce/WooCommerceTransformerTest.php, which drives a real WC customer payload through mapCustomer() and transform() and asserts the resulting customer_consents row.
The outbound write has its own unit coverage in tests/Unit/Integrations/WooCommerce/WooCommerceOutboundWriterTest.php: the request shape (one meta key and nothing else), the '1'/'0' round trip through mapConsent()'s own parser, the no-op for a channel the store cannot express, accepts() for an email consent, an SMS consent, an unknown subscription and a foreign domain, the three verbs the class does not declare, a gone customer surfacing as RemoteRecordGoneException without counting against the breaker, and the breaker opening on repeated failures.
One thing no mocked test can establish is whether a real store exposes the conflux_newsletter meta on its customer payload in the first place — tests/Live/WooCommerceLiveConsentTest.php checks exactly that against a running store. tests/Live/WooCommerceLiveConsentWriteTest.php covers the write direction: that the store actually persists the key rather than dropping it as protected meta, that the update advances date_modified_gmt, and that the written flag is the one mapConsent() reads back. Both are skipped unless configured and never run in CI; see Live Smoke Tests.
performVerification tests
| Test | Stubbed response | Assertion |
|---|---|---|
returns success on 200 response |
HTTP 200 |
result->successful is true; result->externalUserId equals the store base URL |
returns failure with invalid token message on 401 |
HTTP 401 |
result->successful is false; result->message is 'Invalid connection token.' |
returns failure on network error |
HTTP 500 |
result->successful is false |
builds the URL from the branded brand_name credential |
HTTP 200 |
A connection with credentials.brand_name = 'rapidmail' posts to /wp-json/rapidmail-connector/v1/connectivity-check, not the default conflux namespace |
Source locale resolution tests (tests/Integration/Integrations/SourceLocaleResolutionTest.php, database-backed and shared with PrestaShopIntegration — see PrestaShop Integration — source locale resolution)
| Test | What it covers |
|---|---|
reads the WordPress site language out of the system status report |
GET /wp-json/wc/v3/system_status is called and environment.language is stored on source_locale |
keeps the stored locale when the store does not answer |
A failed HTTP response leaves the previously stored value in place |
keeps the stored locale when the status report times out |
A ConnectionException (the store's slowest endpoint) leaves the previously stored value in place |
refuse a value that is not a locale |
An empty string, a sentence, a number, null, and a non-scalar body all leave the stored value untouched, across both platforms |
report a locale they cannot store yet for a connection with no row |
A transient (unsaved) connection is not written to, but the resolved value is still returned |
write nothing when the locale has not changed |
Resolving the same locale a connection already carries does not touch updated_at |
WooCommerce establishes the locale while verifying |
performVerification() populates source_locale as a side effect of a successful verify |
a store that cannot answer about its locale still verifies |
A 500 from /wp-json/wc/v3/system_status still yields a successful VerificationResult, with source_locale left null |
The one thing no mocked test can establish is whether a real store's system status report actually carries a value at environment.language — tests/Live/SourceLocaleResolutionLiveTest.php checks that against a running store, alongside the same question for PrestaShop. Skipped unless configured and never run in CI; see Run the Live Tests Against a Real Shop.
probeBulkExport tests
| Test | What it covers |
|---|---|
returns total pages from API header |
X-WP-TotalPages is read from a page-1 probe request |
throws IntegrationRequestException on HTTP error |
A 5xx response raises IntegrationRequestException |
fetchBulkExport tests (exercised via requestBulkExport(), which drives fetchBulkExportBatches())
| Test | What it covers |
|---|---|
maps WooCommerce customer fields to CustomerData |
Full field mapping including billing address, phone, orderCount, and totalSpent conversion |
fetches all pages when paginating |
X-WP-TotalPages drives multi-page iteration |
sets billing address to null when all billing fields are empty |
Empty address arrays map to null on both billing and shipping |
throws IntegrationRequestException on HTTP error |
A 5xx response raises IntegrationRequestException |
probeBulkExport — Products tests
| Test | What it covers |
|---|---|
returns total pages from API header |
X-WP-TotalPages header on GET /wp-json/wc/v3/products is read and returned as BulkExportProbe.totalPages |
throws IntegrationRequestException on HTTP error |
A 5xx response raises IntegrationRequestException |
fetchBulkExport — Products tests
| Test | What it covers |
|---|---|
maps WooCommerce product fields to ProductData |
Full field mapping: sourceId, status, sku, price/originalPrice in cents, imageUrl, categories, url |
fetches all pages when paginating |
X-WP-TotalPages drives multi-page iteration for the products endpoint |
throws IntegrationRequestException on HTTP error |
A 5xx response raises IntegrationRequestException |
probeBulkExport — Orders tests
| Test | What it covers |
|---|---|
returns total pages from API header for orders |
X-WP-TotalPages header on GET /wp-json/wc/v3/orders is read and returned as BulkExportProbe.totalPages |
fetchBulkExport — Orders tests
| Test | What it covers |
|---|---|
maps WooCommerce order fields to OrderData |
Full field mapping: sourceId, sourceOrderNumber, status, currency, total in cents, customerSourceId, and lineItems collection with productSourceId |
fetches all pages when paginating |
X-WP-TotalPages drives multi-page iteration for the orders endpoint |
throws IntegrationRequestException on HTTP error |
A 5xx response raises IntegrationRequestException |
delta filter params tests
| Test | What it covers |
|---|---|
the probe sends dates_are_gmt alongside the cursor |
performProbeBulkExport puts both parameters on the same request |
every fetched page sends dates_are_gmt alongside the cursor |
Both parameters survive multi-page iteration in fetchBulkExportBatches; no request lacks the flag |
a page range sends dates_are_gmt alongside the cursor |
performFetchBulkExportPageRange puts both parameters on the same request |
a full run sends no date flag from any entry point |
With no cursor, none of the three entry points sends dates_are_gmt or modified_after |
the cursor reaches the shop as the instant rendered with its own offset |
The emitted modified_after value is asserted exactly, not merely present: a UTC instant leaves as 2024-06-01T07:08:09+00:00 |
WooCommerceTransformer::mapOrder tests
| Test | What it covers |
|---|---|
maps core fields correctly |
All primary order fields round-trip correctly, including monetary conversion to cents |
maps line items with productSourceId from WC product_id |
product_id → productSourceId; price → unitPrice in cents; sku, name, quantity |
prices are converted to cents |
total, total_tax, shipping_total converted via round(float × 100) |
customerSourceId is null when customer_id is 0 |
Guest orders (customer_id: 0) produce null |
datePaid is null when absent |
Empty date_paid maps to null |
optional monetary fields default to null when absent |
Absent total_tax, shipping_total, date_paid, customerSourceId, addresses, and line_items produce correct nulls / empty collection |
line item sku is null when empty string |
Empty sku on a line item maps to null |
captures unmapped scalar fields as customAttributes |
discount_total, payment_method, etc. appear in customAttributes |
does not capture mapped keys as customAttributes |
id, status, total, line_items, billing are excluded |
WooCommerceTransformer::mapProduct tests
| Test | What it covers |
|---|---|
maps core fields correctly |
All primary fields round-trip correctly |
status mapping (5 sub-tests) |
publish → Active; trash → Deleted; draft, pending, private → Inactive |
price is converted to cents |
"9.99" → 999, "14.99" → 1499 |
price is null when empty string |
Empty price / regular_price map to null |
categories and tags are extracted as name arrays |
Objects with name keys flatten to string arrays |
imageUrl comes from first image src |
Only images[0].src is used |
imageUrl is null when images array is empty |
Missing images produce null |
description strips HTML tags |
strip_tags() is applied |
optional fields default to null when absent |
Absent sku, price, originalPrice, imageUrl, url, description are null; categories and tags are empty arrays |
captures unmapped scalar fields as customAttributes |
weight, stock_quantity, featured appear in customAttributes |
does not capture mapped keys as customAttributes |
id, name, status, sku, price, categories, images are excluded |
fetchDeletionLog tests
| Test | What it covers |
|---|---|
maps deletion log entries and passes entity_type and since params |
Response items map to DeletionLogRecord; entity_type and since are both sent as query params |
omits since param when since is null |
No since param is sent when the cursor is null |
throws IntegrationRequestException on HTTP error |
A 5xx response raises IntegrationRequestException |
builds the URL from the branded brand_name credential |
A connection with credentials.brand_name = 'rapidmail' hits /wp-json/wc-rapidmail/v1/deletion-log, not the default conflux namespace |
Source files¶
| File | Purpose |
|---|---|
app/Integrations/WooCommerce/WooCommerceIntegration.php |
Integration implementation — performVerification, resolveSourceLocale, fetchBulkExportBatches, performProbeBulkExport, performFetchBulkExportPageRange, performFetchDeletionLog, deltaFilterParams, performPullConsent, supportedConsentChannels, endpointAndMapper |
app/Services/Concerns/RemembersSourceLocale.php |
Shared trait behind resolveSourceLocale() on both WooCommerceIntegration and PrestaShopIntegration — validates a candidate locale and remembers it on the connection |
tests/Integration/Integrations/SourceLocaleResolutionTest.php |
Database-backed tests for resolveSourceLocale() on both platforms and its wiring into performVerification() |
tests/Live/SourceLocaleResolutionLiveTest.php |
Live tests confirming a real store/shop actually answers with a locale at the field each resolver reads |
app/Integrations/WooCommerce/WooCommerceTransformer.php |
Field mapping — mapCustomerRecords(), mapCustomer(), mapOrder(), mapProduct() and mapConsent() public static methods; mapLineItem(), consentFlag() and mapOrderCustomAttributes() private static helpers |
tests/Unit/Integrations/WooCommerce/WooCommerceIntegrationTest.php |
Unit tests for verification, all three bulk-export capabilities, the deletion log, and pullConsent() |
tests/Unit/Integrations/WooCommerce/WooCommerceTransformerTest.php |
Unit tests for mapOrder(), mapProduct(), mapCustomer() and mapConsent() |
app/Integrations/WooCommerce/WooCommerceOutboundWriter.php |
Outbound consent write — performUpdate() sets the newsletter meta behind UpdatesRemoteRecords; supportedConsentChannels() declares the one channel it can write |
tests/Unit/Integrations/WooCommerce/WooCommerceOutboundWriterTest.php |
Unit tests for the outbound consent write, the verbs the writer does not declare, the gone-record outcome and the circuit breaker |
tests/Integration/Integrations/WooCommerce/WooCommerceTransformerTest.php |
Database-backed tests for the consent path: WC payload → mapCustomerRecords() → transform() → customer_consents |
See also¶
- Integration Contract reference —
AbstractIntegration,VerificationResult,IntegrationContract, and all shared DTOs - Integration Contract design — reasoning behind the verification and error-handling design
- Implement a System Integration — step-by-step guide for adding a new integration
- Transformer Contract reference —
AbstractTransformer,TransformerRegistry, and the unified Eloquent models - Transformer Pipeline design — reasoning behind the transformation and storage design
- PrestaShop Integration reference — the other registered integration, including its own customer bulk-export implementation
- Deletion Log Sync — reasoning behind
fetchDeletionLog()and howFetchDeletionLogJobconsumes it - ADR-0018: the rule every source datetime follows, and why the
_gmtvariant is the one read