Skip to content

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]].

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 — EPInterface plus procedural helpers, used by the legacy checkout, Gravity Forms sync, and ownership.
  • New — a namespaced, unit-tested Client under LovatParks\Checkout\EliteParks, used by the WooCommerce checkout.

Both hit the same endpoint with the same method names.

All paths relative to wp-content/themes/lovat-parks/.

PathWhat it does
inc/lovat-checkout/src/EliteParks/Client.php:22The current client. OAuth, SOAP dispatch, retry, parsing.
inc/lovat-checkout/src/EliteParks/ClientFactory.php:10Single construction point. Returns ClientDryRun when LP_WC_EP_DRY_RUN is on.
inc/lovat-checkout/src/EliteParks/ClientInterface.phpThe contract both real and dry-run clients implement.
inc/eliteparks/EPinterface.phpLegacy client, still live for ownership, forms and legacy checkout.
inc/eliteparks/checkoutAPI.php:7Legacy CheckoutAPI class (~2,500 lines) wrapping EP for the old checkout and the booking portal.
inc/eliteparks/crm-credentials-admin.phpSettings screen to test CRM credentials from wp-admin.

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).

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.

EP methodRetriesNotes
CreateBooking1Allocates a booking_no only. Accepts linked_booking_no to chain multi-holiday baskets.
UpdateBookingAvailability1Fills in park/grade/dates/occupancy. Must run before TagBooking.
TagBooking5Places the 20-minute hold.
UntagBooking5Releases it.
GetBooking1Authoritative booking state, pricing, tagged/cancelled flags, extras menu.
UpdateExtras1Applies extras; EP reprices.
ApplyPromotionalCode1Per-booking promo. Signals rejection via applied="false", not a fault.
UpdateContact / UpdateContactPreferences / GetContactPreferences1Customer record and marketing consent.
MakePayment1Records a payment against a booking. Returns receipt_no.
ConfirmBooking5Final 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.

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.

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 — unless LP_WC_EP_DEBUG_SOAP is 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]

All defined in wp-config.php. Names only — never record the values.

ConstantPurpose
EP_TOKEN_URLOAuth token endpoint.
EP_BOOKING_CLIENT_IDOAuth client id.
EP_BOOKING_CLIENT_SECRETOAuth client secret.
EP_SCOPEOAuth scope.
EP_BOOKING_ENDPOINTSOAP endpoint for the BookingAPI codeunit.
LP_WC_EP_DRY_RUNtrue = 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_SOAPtrue = 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').

  • CreateBooking does almost nothing. It allocates a booking number and nothing else. Skip UpdateBookingAvailability and TagBooking fails. The order CreateBooking → UpdateBookingAvailability → TagBooking is not optional (GhostProductFactory.php:35-68).
  • EP’s field for dogs is no_of_pets. There is no no_of_dogs field. The site’s search table column is no_of_dogs, so the mapping happens at the call site — easy to lose in a refactor.
  • ConfirmBooking wants m/d/Y. UK-format dates are silently wrong, not rejected.
  • ApplyPromotionalCode failure is not a SOAP fault. It returns applied="false" with HTTP 200. Code that only catches exceptions will mark a rejected promo as applied. See PromoHandler.php:61.
  • EP recalculates deposit_amount after UpdateExtras and ApplyPromotionalCode to 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_price is 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 _price plus EP’s extra_price instead. This trap is called out in three separate places in the code because it has bitten before.
  • MakePayment is not idempotent. Reference strings are the only guard. The WC flow uses the deterministic {order_id}-{line_index}-wc.
  • GetBooking returns single-element lists as associative arrays, not lists. normaliseExtras() and bookingExtras() both check array_keys() === range(...) to detect this. Any new list-parsing code needs the same check.
  • New EP calls belong on Client, with a matching stub on ClientDryRun and a method on ClientInterface. Adding to CheckoutAPI extends the legacy path that is being retired.
  • Pick a retry budget deliberately: 1 for reads and idempotent updates, 5 for tag/untag/confirm where a lost call strands a customer.
  • If the new call carries PII, add it to PiiSanitiser and keep it out of Logger::PAYLOAD_METHODS.
  • Tests live in tests/unit/EliteParks/. Run composer test from 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 $soapSender and $tokenFetcher closures, 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