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.
Why it exists
Section titled “Why it exists”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.
Entry points
Section titled “Entry points”All paths relative to wp-content/themes/lovat-parks/inc/lovat-checkout/src/.
| Path | What it does |
|---|---|
Reconciliation/ReconciliationAdmin.php:12 | Registers the lovat-recon submenu under WooCommerce (manage_woocommerce). |
Reconciliation/views/recon-queue.php | The queue table. |
Reconciliation/ReconciliationActions.php:12 | admin_post_lovat_recon_retry, …_resolve, …_refund. |
Reconciliation/ReconciliationCron.php:32 | lovat_booking_reconcile — the 30-minute auto-retry. |
Checkout/PaymentSuccessHandler.php:132 | Where lines are marked failed in the first place. |
How it works
Section titled “How it works”Line states
Section titled “Line states”The queue is driven entirely by the order-item meta _lovat_ep_confirm_status.
| Value | Meaning | Auto-retried? |
|---|---|---|
confirmed | EP accepted the booking. Terminal. | — |
confirmed_manually | Ops marked it resolved. Terminal. | — |
refunded | The line was refunded. Terminal. | — |
payment_failed | EP MakePayment failed. Never auto-retried — retrying a payment is not safe. | No |
confirm_failed | Payment recorded, ConfirmBooking failed. | Yes, up to 4 attempts |
pending | Awaiting 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]
The cron
Section titled “The cron”ReconciliationCron registers its own lovat_30min schedule (1800s) and first runs 30 minutes after registration. Each run:
- Selects up to 200 order items with
_lovat_ep_confirm_status = 'confirm_failed'by direct SQL againstwoocommerce_order_items/woocommerce_order_itemmeta. - Skips any line already at
MAX_ATTEMPTS(4). - Increments the attempt counter, calls
ConfirmBooking, and on success flips the line toconfirmed. - If every line on that order is now terminal, moves the order to
completedand sets_lovat_ep_payment_complete = 1. - 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.
The three manual actions
Section titled “The three manual actions”All in ReconciliationActions, all requiring manage_woocommerce plus a nonce:
- Retry — re-runs
ConfirmBookingfor everyconfirm_failed/pendingline. If all succeed, the order goes tocompleted. Note it does not consult the attempt counter, so it works after the cron has given up. - Mark resolved — sets
confirmed_manuallyonpayment_failed/confirm_failed/pendinglines. 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 linesrefunded.
Configuration
Section titled “Configuration”| Setting | Where | Value |
|---|---|---|
ReconciliationCron::HOOK | code | lovat_booking_reconcile |
ReconciliationCron::MAX_ATTEMPTS | code | 4 |
lovat_30min schedule | code | 1800s, registered via cron_schedules |
| Admin page slug | code | lovat-recon, under woocommerce |
| Capability | code | manage_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]].
Invariants and gotchas
Section titled “Invariants and gotchas”payment_failedis never auto-retried.MakePaymentis 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-holdonly when every line is terminal.allLinesConfirmed()/allLinesSettled()acceptconfirmed,confirmed_manually,refunded— and nothing else. - The cron moves settled orders to
completed, notdeposit-paid. An order that paid by deposit and then needed reconciliation will finish oncompleted, losing the deposit marker. See [[deposit-and-pay-in-full]] — if that matters for reporting, it needs fixing inReconciliationCron.php:95andReconciliationActions.php:57. - Manual Retry ignores
MAX_ATTEMPTSby 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/itemmetastill exist (line items are unchanged under HPOS) but any future move of item meta would break both the queue and the cron. LIMIT 200per cron run andLIMIT 100on 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.
Changing it safely
Section titled “Changing it safely”- A new terminal state must be added to three lists:
ReconciliationCron::allLinesConfirmed(),ReconciliationActions::allLinesSettled(), and the admin query inReconciliationAdmin::fetchPendingOrders(). Missing one leaves orders stuck onon-holdforever. - 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_orderentry — 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 thelovat-eliteparkslog. - With
LP_WC_EP_DRY_RUN=truethe 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.