Skip to content

Lovat Parks architecture

Lovat Parks runs a group of UK holiday parks. The site does two commercial jobs: it sells holidays (search → book → pay) and it sells holiday homes (browse stock → enquire → own). Both sit on top of a single external system — Elite Parks — which is the source of truth for everything that matters.

This page is the map. The feature pages are the territory.

A single WordPress install (WP Engine, deployed from Bitbucket) with one custom theme, lovat-parks, derived from the StrategiQ base theme. Almost all custom behaviour lives in the theme, not in plugins.

flowchart TD
    subgraph EP[Elite Parks - Microsoft Dynamics]
        EP1[BookingAPI codeunit]
        EP2[OwnerAPI codeunit]
    end

    subgraph WP[WordPress - theme lovat-parks]
        S[Holiday search]
        LC[Legacy checkout - booking CPT]
        WC[WooCommerce checkout]
        DEP[Deposit choice]
        REC[Reconciliation queue]
        OWN[Ownership listings]
        POR[Booking portal]
        FEED[Property XML feed]
    end

    subgraph PAY[Payments]
        OP[Opayo - legacy]
        CS[Cardstream]
        WCG[WooCommerce gateways]
    end

    S --> EP1
    S -->|SCID| LC
    S -->|SCID| WC
    LC --> EP1
    LC --> OP
    WC --> EP1
    WC --> WCG
    WC --> DEP
    WC --> REC
    REC --> EP1
    POR --> EP1
    POR --> CS
    OWN --> EP2
    OWN --> FEED

Elite Parks owns the truth; WordPress owns the presentation. No booking, price or unit of stock originates here. That is why:

  • Search results are a cache in custom MySQL tables, not content.
  • A reservation is a 20-minute EP “tag”, not a database lock.
  • Money can be taken and the booking still fail, which is why a reconciliation queue exists.
  • The nightly ownership sync unpublishes everything and republishes what EP still lists.
FeatureWhat it covers
[[elite-parks-integration]]The SOAP client every other feature depends on. Read this first.
[[holiday-search-and-availability]]Availability search, the SCID, the booking_search_results cache.
[[woocommerce-holiday-checkout]]The current flow: multi-holiday cart, ghost products, per-line holds, extras, promos.
[[legacy-holiday-checkout]]The original Opayo flow. Frozen, still live as the rollback path.
[[deposit-and-pay-in-full]]Deposit-or-full choice and the wc-deposit-paid status.
[[booking-reconciliation-queue]]What happens when payment succeeds and EP does not.
FeatureWhat it covers
[[ownership-property-listings]]Nightly stock sync, property pages, per-park search URLs.
[[booking-portal-balance-payments]]Cardstream portal for paying an outstanding balance.
[[property-xml-feed]]Public XML syndication of sales stock.

Real features, deliberately out of scope for the first audit pass:

  • Gravity Forms → Elite Parks lead sync (inc/eliteparks/forms.php, 5-minute cron, leadsource CPT)
  • DotDigital sync (inc/eliteparks/bookings-sync.php, inc/woocommerce/dotdigital-sync.php)
  • Reviews import (Feefo / BrightLocal, inc/reviews/, hourly + daily crons)
  • Content model — 9 CPTs and 12 taxonomies in inc/cpt.php; parks, locations, grades, offers
  • ACF block system — ~50 blocks under blocks/, registered by inc/register-blocks.php
  • Admin tooling — issues dashboard, booking activity log, bookings sync page, tagging dashboard
  • Site search, export API, build & deploy

A GUID identifying one bookable option at one price, generated when a search runs. It is the handle passed from search into either checkout, and the key booking_search_results is indexed on. A SCID is not a reservation — nothing is held in EP until a checkout tags it, and a stale SCID still resolves to a stale price.

The WooCommerce migration is flag-controlled, and the distinction between the two accessors matters more than anything else in the codebase:

AccessorApplies admin-only gate?Use for
Config::isWooCommerceEnabled()YesFront-end rendering and routing
Config::isWooCommerceFeatureActive()NoPayment hooks, cron, gateway callbacks — anywhere with no logged-in user

Resolution order for both: the LP_USE_WOOCOMMERCE_CHECKOUT constant wins if defined; otherwise the ACF option wc_checkout_enabled, optionally narrowed by wc_checkout_admin_only. functions.php deliberately does not define the constant so the ACF toggle can work.

Not everything is a post. {prefix}booking_searches, {prefix}booking_search_results, {prefix}booking_grades and the unprefixed bookingseasons are plain MySQL, created by dbDelta and evolved by ad-hoc ALTER TABLE guards on init. There are no migrations.

HookIntervalFeature
untagBookingsFromEP1 min[[legacy-holiday-checkout]]
check_for_new_form_entries_event5 minForms → EP sync
lovat_booking_reconcile30 min[[booking-reconciliation-queue]]
reviews_cron_hookhourlyReviews import
cleanup_results_cronhourly[[holiday-search-and-availability]]
daily_booking_crondailyEP core data refresh
daily_ownership_crondaily[[ownership-property-listings]]
average_score_cron_hookdailyReviews
lovat_ghost_product_cleanupdaily[[woocommerce-holiday-checkout]]

WP-Crontrol is installed for inspecting these.

Two conventions coexist:

  • New code — the WooCommerce logger, wp-content/uploads/wc-logs/, sources lovat-checkout, lovat-eliteparks (90-day retention, PII-sanitised), lovat-portal-cardstream.
  • Legacy code — hand-rolled files in wp-content/uploads/logs/: strategiq-booking.log, strategiq-checkout.log, strategiq-tagging.log, strategiq-ownership.log, strategiq-cleanup.log, ep-payloads.log.

When debugging a booking, check both.

wp-content/themes/lovat-parks/
├── blocks/ ~50 ACF Gutenberg blocks (php + js + scss per block)
├── inc/
│ ├── cpt.php 9 CPTs, 12 taxonomies
│ ├── eliteparks/ Legacy EP integration — booking, ownership, forms, admin tools
│ ├── lovat-checkout/ New WooCommerce flow (PSR-4, namespaced, tested)
│ │ └── src/ Cart, Checkout, EliteParks, Extras, Order, Portal, Product, Reconciliation, Routing
│ ├── opayo/ Legacy gateway
│ ├── reviews/, cleanup.php, export.php, property-xml-feed.php, search.php …
├── partials/, parts/, templates/
├── tests/unit/ PHPUnit — covers inc/lovat-checkout/src only
├── cli/ WP-CLI block scaffolding + refactor tools
└── resources/ + assets/ SCSS/JS source → compiled by gulp

The split is generational, not architectural: inc/eliteparks/ is procedural legacy, inc/lovat-checkout/ is the modern PSR-4 code with tests. New work belongs in the second.

Honest notes for whoever inherits this:

  • composer test is currently red — 91 tests, 15 errors, 2 failures on master. The failures are test drift, not broken product code (for example DepositCalculatorTest::testMultiLineMixedEligibility predates the “no line may lack a deposit” and £5-minimum rules; ExtrasPageControllerTest lacks a Brain Monkey stub for nocache_headers()). Fix the tests before relying on the suite as a gate.
  • Test coverage is narrow. Reconciliation, the portal, the cart REST endpoints, all of search and everything legacy have no tests at all.
  • inc/eliteparks/booking.php is ~4,100 lines and checkoutAPI.php ~2,570. Both far exceed any sane file limit. They are frozen rather than refactored, on purpose.
  • Two checkouts, two gateways and two EP clients run in parallel. That is the cost of a safe migration; it is not permanent. When the WooCommerce flow is confirmed stable the legacy path should be removed, and these pages updated.

See [[onboarding]].