Skip to content

Booking reconciliation queue

When a customer has paid but Elite Parks did not accept the booking, the order is parked on on-hold and its failed lines land in a reconciliation queue at WooCommerce → Booking Reconciliation. A cron retries the recoverable failures automatically; ops handles the rest by hand.

This is the safety net for [[woocommerce-holiday-checkout]]. It exists because EP has no transactions: money can be taken and the booking still fail.

The old flow had a single point of failure and no queue — a failed EP confirm meant someone noticed in an inbox, or nobody did. With up to three holidays per order the exposure multiplies: one line can confirm while another fails, and the confirmed line must not be rolled back.

So the design is: never reverse a successful line, never silently lose a failed one, and give ops three explicit actions.

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

PathWhat it does
Reconciliation/ReconciliationAdmin.php:12Registers the lovat-recon submenu under WooCommerce (manage_woocommerce).
Reconciliation/views/recon-queue.phpThe queue table.
Reconciliation/ReconciliationActions.php:12admin_post_lovat_recon_retry, …_resolve, …_refund.
Reconciliation/ReconciliationCron.php:32lovat_booking_reconcile — the 30-minute auto-retry.
Checkout/PaymentSuccessHandler.php:132Where lines are marked failed in the first place.

The queue is driven entirely by the order-item meta _lovat_ep_confirm_status.

ValueMeaningAuto-retried?
confirmedEP accepted the booking. Terminal.—
confirmed_manuallyOps marked it resolved. Terminal.—
refundedThe line was refunded. Terminal.—
payment_failedEP MakePayment failed. Never auto-retried — retrying a payment is not safe.No
confirm_failedPayment recorded, ConfirmBooking failed.Yes, up to 4 attempts
pendingAwaiting processing.No, but actionable

Supporting meta: _lovat_ep_confirm_attempts, _lovat_ep_confirm_error, _lovat_ep_receipt_no, _lovat_ep_booking_no. At order level, _lovat_ep_payment_complete = 1 is the “all lines settled” flag and the idempotency guard on PaymentSuccessHandler::handle().

flowchart TD
    A[Payment complete] --> B{MakePayment}
    B -->|fail| C[payment_failed<br/>order on-hold, HIGH issue logged]
    B -->|ok| D{ConfirmBooking}
    D -->|ok| E[confirmed]
    D -->|fail| F[confirm_failed<br/>order on-hold, NORMAL issue logged]
    F --> G[Cron every 30 min, max 4 attempts]
    G -->|ok| E
    G -->|4th failure| H[HIGH issue: retries exhausted]
    C --> I[Manual: Retry / Mark resolved / Refund]
    H --> I
    E --> J{All lines terminal?}
    J -->|yes| K[order → completed]

ReconciliationCron registers its own lovat_30min schedule (1800s) and first runs 30 minutes after registration. Each run:

  1. Selects up to 200 order items with _lovat_ep_confirm_status = 'confirm_failed' by direct SQL against woocommerce_order_items / woocommerce_order_itemmeta.
  2. Skips any line already at MAX_ATTEMPTS (4).
  3. Increments the attempt counter, calls ConfirmBooking, and on success flips the line to confirmed.
  4. If every line on that order is now terminal, moves the order to completed and sets _lovat_ep_payment_complete = 1.
  5. On the 4th and final failure, escalates via log_ep_issue(…, 'HIGH') to the legacy issues dashboard.

Every attempt also writes a WooCommerce order note and a booking-activity row (log_booking_activity_for_order), so an order’s history reads as a complete audit trail.

All in ReconciliationActions, all requiring manage_woocommerce plus a nonce:

  • Retry — re-runs ConfirmBooking for every confirm_failed / pending line. If all succeed, the order goes to completed. Note it does not consult the attempt counter, so it works after the cron has given up.
  • Mark resolved — sets confirmed_manually on payment_failed / confirm_failed / pending lines. For when ops has fixed the booking directly in EP. Makes no EP call.
  • Refund — creates a WooCommerce refund for the failed lines’ subtotals with refund_payment => true (a real gateway refund), then marks those lines refunded.
SettingWhereValue
ReconciliationCron::HOOKcodelovat_booking_reconcile
ReconciliationCron::MAX_ATTEMPTScode4
lovat_30min schedulecode1800s, registered via cron_schedules
Admin page slugcodelovat-recon, under woocommerce
Capabilitycodemanage_woocommerce

Nothing here is configurable from the admin; the intervals and limits are constants. EP credentials and the dry-run flag come from [[elite-parks-integration]].

  • payment_failed is never auto-retried. MakePayment is not idempotent, so a retry risks double-charging EP. Only ops can act on it, and the usual action is refund. Do not add it to the cron query.
  • A partially failed order is never rolled back. A confirmed line stays confirmed. The customer keeps the holiday that worked.
  • Order status transitions out of on-hold only when every line is terminal. allLinesConfirmed() / allLinesSettled() accept confirmed, confirmed_manually, refunded — and nothing else.
  • The cron moves settled orders to completed, not deposit-paid. An order that paid by deposit and then needed reconciliation will finish on completed, losing the deposit marker. See [[deposit-and-pay-in-full]] — if that matters for reporting, it needs fixing in ReconciliationCron.php:95 and ReconciliationActions.php:57.
  • Manual Retry ignores MAX_ATTEMPTS by design, but it also does not reset the counter — so the cron will still not pick the line up again afterwards.
  • The queue is built with raw SQL, not HPOS-aware order queries. If WooCommerce High-Performance Order Storage is enabled, woocommerce_order_items/itemmeta still exist (line items are unchanged under HPOS) but any future move of item meta would break both the queue and the cron.
  • LIMIT 200 per cron run and LIMIT 100 on the admin list are silent caps. A large backlog drains 200 lines per 30 minutes and the admin page will not show everything.
  • Refund uses $item->get_subtotal() — the pre-discount line subtotal. On a deposit order the customer paid less than that, so the refund amount needs checking before use on deposit-paid orders.
  • A new terminal state must be added to three lists: ReconciliationCron::allLinesConfirmed(), ReconciliationActions::allLinesSettled(), and the admin query in ReconciliationAdmin::fetchPendingOrders(). Missing one leaves orders stuck on on-hold forever.
  • Anything that can double-charge belongs behind a manual action, not the cron.
  • Keep writing both an order note and a log_booking_activity_for_order entry — the [[legacy-holiday-checkout]] booking activity log and issues dashboard read the second one, and ops triage depends on the two agreeing.
  • Watch it work: wp cron event run lovat_booking_reconcile (WP-CLI), then check the order notes on an affected order and the lovat-eliteparks log.
  • With LP_WC_EP_DRY_RUN=true the retries succeed synthetically, which is useful for exercising the state machine but tells you nothing about EP.
  • Deliberately not abstracted: the direct SQL. wc_get_orders() cannot filter on item meta, and loading every on-hold order to inspect its lines does not scale.

None. No file under tests/unit/ covers Reconciliation/. The state machine, the attempt cap, the terminal-state lists and the refund path are all verified manually.

This is the largest test gap in the checkout. If you touch this code, the pragmatic check is: force a failure with a bad booking number in a dry-run order, confirm it appears in the queue, exercise all three actions, and confirm the order leaves on-hold only when the last line settles.

Run the rest of the suite with cd wp-content/themes/lovat-parks && composer test.