Elite Parks CRM integration
Elite Parks (EP) is Lovat Parks’ reservation and CRM system, running on Microsoft Dynamics. It is the canonical source of truth for availability, bookings, payments, contacts and ownership stock. The website never owns a booking — it creates one in EP and mirrors just enough locally to render pages.
Everything on this site that touches money or a customer record goes through this layer: [[woocommerce-holiday-checkout]], [[legacy-holiday-checkout]], [[holiday-search-and-availability]], [[booking-reconciliation-queue]], [[ownership-property-listings]] and [[booking-portal-balance-payments]].
Why it exists
Section titled “Why it exists”Elite Parks exposes a Dynamics SOAP codeunit API rather than anything modern. There is no webhook or push channel — the site polls, and every state change must be pushed synchronously at the moment the customer acts. That constraint shapes nearly every design decision downstream: 20-minute reservation holds instead of locks, per-line retry queues instead of transactions, and a lot of defensive parsing.
There are two generations of client in the codebase, deliberately kept side by side so the WooCommerce migration can be rolled back:
- Legacy —
EPInterfaceplus procedural helpers, used by the legacy checkout, Gravity Forms sync, and ownership. - New — a namespaced, unit-tested
ClientunderLovatParks\Checkout\EliteParks, used by the WooCommerce checkout.
Both hit the same endpoint with the same method names.
Entry points
Section titled “Entry points”All paths relative to wp-content/themes/lovat-parks/.
| Path | What it does |
|---|---|
inc/lovat-checkout/src/EliteParks/Client.php:22 | The current client. OAuth, SOAP dispatch, retry, parsing. |
inc/lovat-checkout/src/EliteParks/ClientFactory.php:10 | Single construction point. Returns ClientDryRun when LP_WC_EP_DRY_RUN is on. |
inc/lovat-checkout/src/EliteParks/ClientInterface.php | The contract both real and dry-run clients implement. |
inc/eliteparks/EPinterface.php | Legacy client, still live for ownership, forms and legacy checkout. |
inc/eliteparks/checkoutAPI.php:7 | Legacy CheckoutAPI class (~2,500 lines) wrapping EP for the old checkout and the booking portal. |
inc/eliteparks/crm-credentials-admin.php | Settings screen to test CRM credentials from wp-admin. |
How it works
Section titled “How it works”Authentication
Section titled “Authentication”Client::defaultTokenFetcher() (Client.php:635) runs an OAuth2 client-credentials flow against EP_TOKEN_URL, then caches the bearer token in the WP transient lovat_ep_oauth_token for expires_in minus a 5-minute safety buffer. Concurrent requests share one round-trip.
On a 401/403 the SOAP sender appends the sentinel string EP_AUTH_FAILURE to the exception message. retryWithBackoff() sees it, clears both the in-memory token and the transient, and retries once without consuming a retry attempt (Client.php:302-312).
Request shape
Section titled “Request shape”Requests are hand-built XML, not generated from a WSDL. buildEnvelope() produces <Method xmlns="urn:microsoft-dynamics-schemas/codeunit/BookingAPI"><request>…</request></Method>, wrapped in a SOAP envelope and POSTed with Guzzle (30s timeout).
Responses are unwrapped by regex — the client extracts the contents of <return_value>…</return_value>, then parses that as JSON or XML (parseResponse()). EP returns booking fields as XML attributes, so SimpleXML nests them under @attributes; getBooking() flattens that and strips comma thousands separators from money fields so callers can read $booking['total_price'] directly.
Methods used
Section titled “Methods used”| EP method | Retries | Notes |
|---|---|---|
CreateBooking | 1 | Allocates a booking_no only. Accepts linked_booking_no to chain multi-holiday baskets. |
UpdateBookingAvailability | 1 | Fills in park/grade/dates/occupancy. Must run before TagBooking. |
TagBooking | 5 | Places the 20-minute hold. |
UntagBooking | 5 | Releases it. |
GetBooking | 1 | Authoritative booking state, pricing, tagged/cancelled flags, extras menu. |
UpdateExtras | 1 | Applies extras; EP reprices. |
ApplyPromotionalCode | 1 | Per-booking promo. Signals rejection via applied="false", not a fault. |
UpdateContact / UpdateContactPreferences / GetContactPreferences | 1 | Customer record and marketing consent. |
MakePayment | 1 | Records a payment against a booking. Returns receipt_no. |
ConfirmBooking | 5 | Final confirmation. Sends confirmed_date in US format (m/d/Y), Europe/London. |
Retry uses exponential backoff — 250ms, 500ms, 1s, 2s. EpParseError is treated as deterministic and never retried.
Exceptions
Section titled “Exceptions”EpException is the base; EpSoapFault, EpTimeout and EpParseError are the subtypes (src/EliteParks/Exceptions/). Callers catch EpException and translate to a user-facing message — customers never see EP’s wording.
Logging
Section titled “Logging”Logger (src/EliteParks/Logger.php) writes to the WooCommerce logger under source lovat-eliteparks, retained 90 days (filter set in inc/lovat-checkout/lovat-checkout.php:285). Every payload passes through PiiSanitiser:
- 13–19 digit runs redacted (card numbers) — always, even in debug.
<cvv>/<security_code>/<cv2>redacted — always.- Email addresses masked to
f****@domain— unlessLP_WC_EP_DEBUG_SOAPis on.
A second, narrower trail is appended to wp-content/uploads/logs/ep-payloads.log for booking-structure calls only (CreateBooking, UpdateBookingAvailability, UpdateExtras, GetBooking, TagBooking). UpdateContact and MakePayment are deliberately excluded from that file because they carry PII and payment references.
flowchart TD
A[Caller] --> B[ClientFactory::make]
B -->|LP_WC_EP_DRY_RUN| C[ClientDryRun - synthetic responses]
B -->|normal| D[Client]
D --> E[getToken - transient cache]
E --> F[buildEnvelope then SOAP POST]
F -->|401 or 403| G[clear token, retry once free]
F -->|fault or timeout| H[backoff 250/500/1000/2000ms]
F -->|ok| I[parseResponse JSON or XML]
I --> J[Logger and PiiSanitiser]
Configuration
Section titled “Configuration”All defined in wp-config.php. Names only — never record the values.
| Constant | Purpose |
|---|---|
EP_TOKEN_URL | OAuth token endpoint. |
EP_BOOKING_CLIENT_ID | OAuth client id. |
EP_BOOKING_CLIENT_SECRET | OAuth client secret. |
EP_SCOPE | OAuth scope. |
EP_BOOKING_ENDPOINT | SOAP endpoint for the BookingAPI codeunit. |
LP_WC_EP_DRY_RUN | true = no real EP traffic; ClientDryRun returns synthetic responses and logs are prefixed [DRY-RUN]. Defaults to false in functions.php:20. |
LP_WC_EP_DEBUG_SOAP | true = unredacted emails in logs. Card data stays redacted regardless. Defaults false, functions.php:23. |
The ownership API uses a separate codeunit namespace (…/codeunit/OwnerAPI) via new EPInterface('owner').
Invariants and gotchas
Section titled “Invariants and gotchas”CreateBookingdoes almost nothing. It allocates a booking number and nothing else. SkipUpdateBookingAvailabilityandTagBookingfails. The orderCreateBooking → UpdateBookingAvailability → TagBookingis not optional (GhostProductFactory.php:35-68).- EP’s field for dogs is
no_of_pets. There is nono_of_dogsfield. The site’s search table column isno_of_dogs, so the mapping happens at the call site — easy to lose in a refactor. ConfirmBookingwantsm/d/Y. UK-format dates are silently wrong, not rejected.ApplyPromotionalCodefailure is not a SOAP fault. It returnsapplied="false"with HTTP 200. Code that only catches exceptions will mark a rejected promo as applied. SeePromoHandler.php:61.- EP recalculates
deposit_amountafterUpdateExtrasandApplyPromotionalCodeto fold in extras, which inflates it. Both handlers deliberately freeze the deposit at its original accommodation-only value. Intentional; do not “fix” it. - EP’s
total_priceis built on EP’s standard rate, which is unaware of the website’s seasonal discount. Using it as the line total overcharges the customer. The checkout uses the WC product_priceplus EP’sextra_priceinstead. This trap is called out in three separate places in the code because it has bitten before. MakePaymentis not idempotent. Reference strings are the only guard. The WC flow uses the deterministic{order_id}-{line_index}-wc.GetBookingreturns single-element lists as associative arrays, not lists.normaliseExtras()andbookingExtras()both checkarray_keys() === range(...)to detect this. Any new list-parsing code needs the same check.
Changing it safely
Section titled “Changing it safely”- New EP calls belong on
Client, with a matching stub onClientDryRunand a method onClientInterface. Adding toCheckoutAPIextends the legacy path that is being retired. - Pick a retry budget deliberately:
1for reads and idempotent updates,5for tag/untag/confirm where a lost call strands a customer. - If the new call carries PII, add it to
PiiSanitiserand keep it out ofLogger::PAYLOAD_METHODS. - Tests live in
tests/unit/EliteParks/. Runcomposer testfrom the theme directory (PHPUnit 9 + Brain Monkey; no WordPress bootstrap needed). - Deliberately not abstracted: the raw XML string-building in
buildEnvelope(). EP’s schema is attribute-based and inconsistent, and the literal form keeps the response-shape edge cases visible. Leave it as it is.
tests/unit/EliteParks/ClientTest.php— envelope building, retry/backoff, auth refresh, parse paths. Injects$soapSenderand$tokenFetcherclosures, so no live endpoint is needed.tests/unit/EliteParks/ClientDryRunTest.php— dry-run parity with the real client’s return shapes.tests/unit/EliteParks/PiiSanitiserTest.php— redaction rules.
Not covered: the legacy EPInterface / CheckoutAPI path has no tests at all. Changes there are verified manually only.
Run just these: cd wp-content/themes/lovat-parks && vendor/bin/phpunit --filter EliteParks