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.
Why it exists
Section titled “Why it exists”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]].
Entry points
Section titled “Entry points”All paths relative to wp-content/themes/lovat-parks/.
| Path | What it does |
|---|---|
inc/lovat-checkout/lovat-checkout.php | Bootstrap. Instantiates and registers every module; loaded from functions.php:234. |
inc/lovat-checkout/src/Routing/ScidRouter.php:11 | Intercepts /holidays/checkout/?scid=… on template_redirect priority 1 and renders the loading page instead. The only public entry. |
inc/lovat-checkout/src/LoadingPage/LoadingPageController.php | Renders the interstitial that POSTs to /checkout/start. |
inc/lovat-checkout/src/Cart/CartRestEndpoints.php:22 | All eight lovat/v1 cart endpoints. |
inc/lovat-checkout/src/Extras/ExtrasPageController.php:11 | Serves /cart/extras/ — the real cart UI. |
inc/lovat-checkout/src/Checkout/PaymentSuccessHandler.php:132 | Post-payment: MakePayment + ConfirmBooking per line. |
woocommerce/checkout/ | Theme overrides for form-billing, review-order, form-pay, thankyou. |
REST endpoints (/wp-json/lovat/v1/…)
Section titled “REST endpoints (/wp-json/lovat/v1/…)”| Route | Method | Purpose |
|---|---|---|
/checkout/start | POST | Reserve a SCID in EP, create the ghost product, add to cart. |
/cart/promo/{key} | POST / DELETE | Apply or remove a promo on one line. |
/cart/extras/{key} | POST | Replace the extras on one line. |
/cart/line/{key} | DELETE | Customer removes a line. |
/cart/line-expired/{key} | POST | JS countdown reports one line expired. |
/cart/expired | POST | Page-level countdown reports the earliest hold expired. |
/cart/order-note | POST | Save 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).
How it works
Section titled “How it works”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]
1. Entry and ghost products
Section titled “1. Entry and ghost products”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):
- Checks cart eligibility before touching EP, so a rejected add never leaks a tagged reservation.
- Finds the most recent line’s
booking_noand passes it aslinked_booking_no, chaining the EP bookings in a multi-holiday basket. GhostProductFactory::createForScid()looks up the SCID row inbooking_search_results, runsCreateBooking → UpdateBookingAvailability → TagBooking, then creates a WooCommerce simple product withexclude-from-catalogandexclude-from-searchvisibility. If post creation fails after a successful reserve, it untags on a best-effort basis.- Calls
GetBookingto capture the EP-valid extras menu for this booking (codes differ by grade/type —DOGSTfor a unit,DOGTOfor a plot). - Adds to the cart with this item meta:
| Key | Meaning |
|---|---|
lovat_scid | The search result GUID. Presence of this key is what marks a line as a holiday. |
lovat_ep_booking_no | EP booking number. |
lovat_tag_date_unix | When the hold started. The clock. |
lovat_extras | Chosen extras, priced by EP. |
lovat_available_extras | EP’s valid menu for this booking. |
lovat_promo_code | Applied promo, or null. |
lovat_line_total_inc_extras | The authoritative line total. |
lovat_deposit_amount | Frozen accommodation-only deposit. |
2. Cart and holds
Section titled “2. Cart and holds”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.jscounts down and calls/cart/line-expired/{key}. - Server side —
CartTimer::stripExpired()onwoocommerce_cart_loaded_from_sessionremoves expired lines and calls EPUntagBooking. 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/.
3. Extras and promos
Section titled “3. Extras and promos”Both are per line and both treat EP as authoritative.
ExtrasHandler::update()callsUpdateExtrasthenGetBooking, and sets the line total to WC product_price+ EPextra_price— deliberately not EP’stotal_price(see [[elite-parks-integration]] for why).PromoHandler::apply()gates on a £149 minimum line total (filterable vialovat_promo_min_total), callsApplyPromotionalCode, and throws if EP returnsapplied="false". It snapshots the pre-promo_priceintolovat_pre_promo_pricesoremove()can restore it, and strips any weather-guarantee extra.- Native WooCommerce coupons are disabled whenever a holiday line is in the cart (
CartController::disableNativeCoupons).
4. Checkout and payment
Section titled “4. Checkout and payment”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:
MakePayment(bookingNo, "{order_id}-{line_index}-wc", amount)→ stores_lovat_ep_receipt_no.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]].
5. Cleanup
Section titled “5. Cleanup”GhostProductCleanup (src/Product/GhostProductCleanup.php) runs daily and deletes orphan ghost products older than 24h. Products attached to a real order are kept.
Configuration
Section titled “Configuration”| Setting | Where | Effect |
|---|---|---|
LP_USE_WOOCOMMERCE_CHECKOUT | wp-config.php constant | Hard 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_enabled | ACF options page | The normal on/off switch. |
wc_checkout_admin_only | ACF options page | When on, only manage_options users get the new checkout; everyone else gets legacy. Soft-launch gate. |
wc_max_holiday_lines / LP_MAX_HOLIDAY_LINES | ACF option / constant | Max lines. Default 3, clamped 1–10 (Config.php:71). |
LP_USE_WOOCOMMERCE_PORTAL | constant | Emergency override for [[booking-portal-balance-payments]]. |
LP_DEBUG_WC_PAYMENT_GATEWAYS | constant | Loads debug-payment-gateways.php. |
checkout_promo_code_config | ACF option (repeater) | Per-promo hide_pay_deposit / hide_pay_in_full rules. |
LP_WC_EP_DRY_RUN, LP_WC_EP_DEBUG_SOAP | constants | See [[elite-parks-integration]]. |
Invariants and gotchas
Section titled “Invariants and gotchas”isWooCommerceEnabled()vsisWooCommerceFeatureActive()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 inPaymentSuccessHandlerwould 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, sohandleRemoveLineandhandleCartExpiredexplicitly callcalculate_totals(),set_session()andsave_data(). Omit those and the line reappears on the next page load (CartRestEndpoints.php:324-333).get_cart()must be called beforeget_cart_item()in REST, because it is what hydratescart_contentsfrom 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_bookingpost. Legacy admin tools that assume abookingCPT will not see WooCommerce orders unless they were explicitly ported. - Ghost products are real published products. They are hidden by
product_visibilityterms 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=1to also release the EP holds), requiresmanage_woocommerce(lovat-checkout.php:108).
Changing it safely
Section titled “Changing it safely”- New behaviour goes in a new class under
inc/lovat-checkout/src/, registered inlovat-checkout.php. Each class has aregister()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()’sadd_to_cartarray,CartController::restoreLovatKeys()(or it will not survive session restore), andPaymentSuccessHandler::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=truefor repeatability, then a smaller real-EP pass. The repo README carries a full QA brief;docs/runbooks/woocommerce-checkout-ops.mdhas the failure triage trees anddocs/superpowers/specs/2026-04-30-woocommerce-checkout-migration-design.mdthe 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.phpExtras/CartResolverTest.php,ExtrasPageControllerTest.php,ExtrasViewModelTest.phpProduct/SearchResultMapperTest.phpOrder/DepositCalculatorTest.php,DepositPaidStatusTest.phpEliteParks/…— 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.