Skip to content

WooCommerce holiday checkout

The WooCommerce checkout is the current holiday booking flow. It replaces the single-holiday bespoke checkout with a real cart holding up to 3 holidays, any WooCommerce payment gateway, per-line extras and promo codes, and a deposit-or-full payment choice. Elite Parks remains the canonical booking system — every EP call the old flow made still happens, but now fanned out per cart line.

It runs behind a feature flag alongside [[legacy-holiday-checkout]], so the old flow can be restored instantly.

The bespoke checkout could only ever sell one holiday and was hard-wired to Opayo. The business wanted multi-holiday baskets, gateway freedom, and a deposit option. Rather than extend the bespoke flow, the booking was modelled as a WooCommerce order — which brings carts, gateways, refunds, order notes and the admin UI for free — while keeping EP authoritative for availability and pricing.

The hard constraint: EP holds a reservation for 20 minutes and has no transactions. With three lines in a basket that means three independent clocks and three independent failure modes, which is why so much of this feature is timers, re-validation and [[booking-reconciliation-queue]].

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

PathWhat it does
inc/lovat-checkout/lovat-checkout.phpBootstrap. Instantiates and registers every module; loaded from functions.php:234.
inc/lovat-checkout/src/Routing/ScidRouter.php:11Intercepts /holidays/checkout/?scid=… on template_redirect priority 1 and renders the loading page instead. The only public entry.
inc/lovat-checkout/src/LoadingPage/LoadingPageController.phpRenders the interstitial that POSTs to /checkout/start.
inc/lovat-checkout/src/Cart/CartRestEndpoints.php:22All eight lovat/v1 cart endpoints.
inc/lovat-checkout/src/Extras/ExtrasPageController.php:11Serves /cart/extras/ — the real cart UI.
inc/lovat-checkout/src/Checkout/PaymentSuccessHandler.php:132Post-payment: MakePayment + ConfirmBooking per line.
woocommerce/checkout/Theme overrides for form-billing, review-order, form-pay, thankyou.
RouteMethodPurpose
/checkout/startPOSTReserve a SCID in EP, create the ghost product, add to cart.
/cart/promo/{key}POST / DELETEApply or remove a promo on one line.
/cart/extras/{key}POSTReplace the extras on one line.
/cart/line/{key}DELETECustomer removes a line.
/cart/line-expired/{key}POSTJS countdown reports one line expired.
/cart/expiredPOSTPage-level countdown reports the earliest hold expired.
/cart/order-notePOSTSave the order-level customer note to the WC session.

All use permission_callback => __return_true — they act only on the caller’s own WooCommerce session, and there is no logged-in user (guest checkout).

flowchart TD
    A[Search result: /holidays/checkout/?scid=X] --> B[ScidRouter intercepts]
    B --> C[Loading page]
    C -->|POST /lovat/v1/checkout/start| D[CartController::rejectionReason<br/>max 3 lines, no duplicate SCID]
    D --> E[GhostProductFactory]
    E --> F[EP CreateBooking → UpdateBookingAvailability → TagBooking]
    F --> G[wp_insert_post 'product' - hidden from catalog]
    G --> H[add_to_cart with lovat_* item meta]
    H --> I[/cart/ redirects to /cart/extras/]
    I --> J[Extras, promo, per-line 20-min countdown]
    J --> K[/checkout/]
    K --> L[CheckoutValidation: GetBooking per line]
    L --> M[Payment gateway]
    M --> N[PaymentSuccessHandler: MakePayment + ConfirmBooking per line]
    N -->|all ok| O[completed or deposit-paid]
    N -->|any fail| P[on-hold + reconciliation queue]

ScidRouter only fires when the request is the page whose slug is checkout, a scid query arg is present, and Config::isWooCommerceEnabled() is true. It renders the loading page and exits — the legacy block never runs.

The loading page POSTs to /checkout/start. CartRestEndpoints::handleStart() (CartRestEndpoints.php:405):

  1. Checks cart eligibility before touching EP, so a rejected add never leaks a tagged reservation.
  2. Finds the most recent line’s booking_no and passes it as linked_booking_no, chaining the EP bookings in a multi-holiday basket.
  3. GhostProductFactory::createForScid() looks up the SCID row in booking_search_results, runs CreateBooking → UpdateBookingAvailability → TagBooking, then creates a WooCommerce simple product with exclude-from-catalog and exclude-from-search visibility. If post creation fails after a successful reserve, it untags on a best-effort basis.
  4. Calls GetBooking to capture the EP-valid extras menu for this booking (codes differ by grade/type — DOGST for a unit, DOGTO for a plot).
  5. Adds to the cart with this item meta:
KeyMeaning
lovat_scidThe search result GUID. Presence of this key is what marks a line as a holiday.
lovat_ep_booking_noEP booking number.
lovat_tag_date_unixWhen the hold started. The clock.
lovat_extrasChosen extras, priced by EP.
lovat_available_extrasEP’s valid menu for this booking.
lovat_promo_codeApplied promo, or null.
lovat_line_total_inc_extrasThe authoritative line total.
lovat_deposit_amountFrozen accommodation-only deposit.

CartTimer::HOLD_SECONDS is 1200 (20 minutes), per line, from lovat_tag_date_unix. Adding a second holiday does not reset the first line’s clock.

Expiry is enforced twice:

  • Client side — assets/cart-timer.js counts down and calls /cart/line-expired/{key}.
  • Server side — CartTimer::stripExpired() on woocommerce_cart_loaded_from_session removes expired lines and calls EP UntagBooking. This is the fallback when JS is off or a tab is left closed.

/cart/ is not the cart UI. A template_redirect at priority 5 (lovat-checkout.php:165) bounces any non-empty cart to /cart/extras/, which ExtrasPageController renders. That page sets nocache_headers() plus DONOTCACHEPAGE/OBJECT/DB so WP Engine and WP Rocket never serve it from cache, and purges expired lines before rendering to avoid a redirect loop between /cart/?timeout=1 and /cart/extras/.

Both are per line and both treat EP as authoritative.

  • ExtrasHandler::update() calls UpdateExtras then GetBooking, and sets the line total to WC product _price + EP extra_price — deliberately not EP’s total_price (see [[elite-parks-integration]] for why).
  • PromoHandler::apply() gates on a £149 minimum line total (filterable via lovat_promo_min_total), calls ApplyPromotionalCode, and throws if EP returns applied="false". It snapshots the pre-promo _price into lovat_pre_promo_price so remove() can restore it, and strips any weather-guarantee extra.
  • Native WooCommerce coupons are disabled whenever a holiday line is in the cart (CartController::disableNativeCoupons).

CheckoutValidation hooks woocommerce_after_checkout_validation and calls GetBooking for every line immediately before payment. If a booking is no longer tagged, or is cancelled, the order is blocked with a per-line error naming the booking number. If EP is unreachable, the whole order is blocked.

PaymentSuccessHandler runs on woocommerce_payment_complete and, per line:

  1. MakePayment(bookingNo, "{order_id}-{line_index}-wc", amount) → stores _lovat_ep_receipt_no.
  2. ConfirmBooking(bookingNo) → sets _lovat_ep_confirm_status = confirmed.

Every call writes both a WooCommerce order note (OrderNotes::logEpCall) and a structured log entry. Failures set _lovat_ep_confirm_status to payment_failed or confirm_failed and push the order to on-hold. Only when all lines succeed is _lovat_ep_payment_complete = 1 set — the idempotency guard that stops a re-fired hook double-paying EP.

lovatLinesDoNotNeedProcessing() tells WooCommerce holiday lines need no fulfilment, so a fully-holiday order lands on completed rather than processing.

Deposit handling is documented separately in [[deposit-and-pay-in-full]].

GhostProductCleanup (src/Product/GhostProductCleanup.php) runs daily and deletes orphan ghost products older than 24h. Products attached to a real order are kept.

SettingWhereEffect
LP_USE_WOOCOMMERCE_CHECKOUTwp-config.php constantHard override. true = new flow, false = legacy. Deliberately not defined in functions.php so its absence lets the ACF toggle win (functions.php:12-19).
wc_checkout_enabledACF options pageThe normal on/off switch.
wc_checkout_admin_onlyACF options pageWhen on, only manage_options users get the new checkout; everyone else gets legacy. Soft-launch gate.
wc_max_holiday_lines / LP_MAX_HOLIDAY_LINESACF option / constantMax lines. Default 3, clamped 1–10 (Config.php:71).
LP_USE_WOOCOMMERCE_PORTALconstantEmergency override for [[booking-portal-balance-payments]].
LP_DEBUG_WC_PAYMENT_GATEWAYSconstantLoads debug-payment-gateways.php.
checkout_promo_code_configACF option (repeater)Per-promo hide_pay_deposit / hide_pay_in_full rules.
LP_WC_EP_DRY_RUN, LP_WC_EP_DEBUG_SOAPconstantsSee [[elite-parks-integration]].
  • isWooCommerceEnabled() vs isWooCommerceFeatureActive() are not interchangeable. The first applies the admin-only user gate and is for rendering; the second skips it and is for server-side processing (payment hooks, cron, gateway callbacks) where there is no logged-in user. Using the first in PaymentSuccessHandler would silently stop EP calls for real customers during a soft launch (Config.php:29-36).
  • The routing flag is only checked at SCID entry. Flipping the flag off mid-session leaves in-flight WooCommerce carts to complete via WooCommerce. That is the intended rollback behaviour.
  • remove_cart_item() in a REST request does not persist. WooCommerce never re-syncs the session on a REST call, so handleRemoveLine and handleCartExpired explicitly call calculate_totals(), set_session() and save_data(). Omit those and the line reappears on the next page load (CartRestEndpoints.php:324-333).
  • get_cart() must be called before get_cart_item() in REST, because it is what hydrates cart_contents from the session. Skipping it makes removals silently no-op.
  • Per-line clocks do not reset. Adding a third holiday does not buy more time for the first.
  • The order is canonical; there is no wp_booking post. Legacy admin tools that assume a booking CPT will not see WooCommerce orders unless they were explicitly ported.
  • Ghost products are real published products. They are hidden by product_visibility terms only. Anything that enumerates products without respecting visibility (feeds, sitemaps, exports) will surface them.
  • /cart/extras/ is not a real WordPress page. fixQueryState() fakes the query so the title and breadcrumbs are not a 404.
  • An admin-only escape hatch exists for stuck carts: /?lovat_force_empty=1 (add &untag=1 to also release the EP holds), requires manage_woocommerce (lovat-checkout.php:108).
  • New behaviour goes in a new class under inc/lovat-checkout/src/, registered in lovat-checkout.php. Each class has a register() that adds its own hooks — keep that shape; it is what makes the whole feature switch-offable.
  • Anything reading cart lines must skip non-holiday lines with if ( empty( $item['lovat_scid'] ) ) continue;.
  • Adding item meta means updating three places: handleStart()’s add_to_cart array, CartController::restoreLovatKeys() (or it will not survive session restore), and PaymentSuccessHandler::copyCartItemMetaToOrderLine() (or it will not reach the order).
  • Anything touching money must be tested with the flag off too — the legacy flow is the rollback net and must stay bit-identical.
  • Run cd wp-content/themes/lovat-parks && composer test. Front-end assets: npx gulp watch.
  • QA with LP_WC_EP_DRY_RUN=true for repeatability, then a smaller real-EP pass. The repo README carries a full QA brief; docs/runbooks/woocommerce-checkout-ops.md has the failure triage trees and docs/superpowers/specs/2026-04-30-woocommerce-checkout-migration-design.md the locked decisions.
  • Deliberately not abstracted: the ghost product. Modelling a holiday as a throwaway WooCommerce product is what lets standard gateways, tax, refunds and the admin order screen work untouched. A custom cart item type was considered and rejected.

tests/unit/ (PHPUnit 9 + Brain Monkey + Mockery, no WordPress bootstrap):

  • Checkout/CheckoutAppearanceTest.php, Checkout/PaymentSuccessHandlerTest.php
  • Extras/CartResolverTest.php, ExtrasPageControllerTest.php, ExtrasViewModelTest.php
  • Product/SearchResultMapperTest.php
  • Order/DepositCalculatorTest.php, DepositPaidStatusTest.php
  • EliteParks/… — see [[elite-parks-integration]]

Not covered: CartRestEndpoints, CartTimer, PromoHandler, ExtrasHandler, ScidRouter, GhostProductFactory, and the whole reconciliation path. Cart-session behaviour in particular is verified only by hand.

Run: composer test, or vendor/bin/phpunit --filter Checkout. A load harness for a 3-line cart lives at docs/load-tests/checkout-3line.k6.js.