Skip to content

Deposit and pay-in-full choice

At the cart stage a customer can choose to pay a deposit now or pay in full. Choosing deposit reduces the amount charged today to the sum of the per-holiday deposits, and lands the order in a custom deposit-paid status with the balance due 56 days before the earliest arrival.

This is new in [[woocommerce-holiday-checkout]] — the legacy flow had no such option. The balance is later collected through [[booking-portal-balance-payments]].

Deposits were available over the phone but not online, so customers who did not want to commit the full amount abandoned the web checkout. The complication is that a basket can hold up to three holidays with different deposit amounts, different arrival dates, and promotions that may forbid one payment mode or the other — so “deposit” is a single order-level decision computed from several per-line facts.

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

PathWhat it does
blocks/deposit-choice/render.phpThe radio UI. Registered as lovat-parks/deposit-choice in lovat-checkout.php:95. Renders on the extras page, above the totals box.
blocks/deposit-choice/deposit-choice.jsWrites the lovat_payment_choice cookie on change and on init.
src/Order/DepositCalculator.php:22The whole decision: totals, eligibility, promo conflicts, balance date. Pure function, unit tested.
src/Order/DepositCartFee.php:22Applies a negative cart fee at checkout so the displayed total is the deposit.
src/Checkout/PaymentSuccessHandler.php:84capturePaymentChoice() — reads the cookie onto the order and recomputes the total.
src/Order/DepositPaidStatus.php:8Registers the wc-deposit-paid order status.
src/Order/DepositChoiceAutoRender.phpVestigial — register() is now empty; the block moved to the extras page.
flowchart TD
    A[Extras page: deposit-choice block] -->|radio change| B[lovat_payment_choice cookie]
    B --> C[DepositCartFee on woocommerce_cart_calculate_fees]
    C --> D[DepositCalculator::compute]
    D -->|eligible| E[negative fee 'Balance due later date']
    E --> F[Checkout shows deposit as the total]
    F --> G[capturePaymentChoice on order create]
    G --> H[_lovat_payment_choice meta + set_total]
    H --> I[Payment]
    I --> J[overrideStatusForDeposit: completed → deposit-paid]
    J --> K[PaymentSuccessHandler charges lineAmount per line to EP]

DepositCalculator::compute() takes every cart line as {arrival_date, deposit_amount, line_total, promo_code} plus the promo config map, and returns deposit_total, full_total, has_deposit_option, hide_pay_in_full, hide_pay_deposit and balance_due_date.

Rules, in the order they matter:

  • A line has a real deposit only if 0 < deposit_amount < line_total. Otherwise the line contributes its full total to the deposit sum and sets hasNoDepositLine.
  • The deposit option is offered only when: at least one real deposit exists, no line lacks one, the saving is at least £5, and no promo hides it. The £5 floor exists because EP sometimes returns a deposit that is 95%+ of the total on short-notice bookings, producing a saving of pence (DepositCalculator.php:59-68).
  • Promo conflicts are order-wide and sticky. If any line’s promo sets hide_pay_deposit, deposit is disabled for the whole order. Same for hide_pay_in_full. The flags OR together across lines and never un-set.
  • Balance due date is arrival minus 56 days, taking the earliest across all lines. A basket with an August and a December holiday must pay both balances on the August schedule. Intentional.

Two independent mechanisms keep the number right:

  1. DepositCartFee adds a negative fee labelled Balance due later (12 August 2026) so the WooCommerce review-order table already totals to the deposit before the customer pays.
  2. capturePaymentChoice() recomputes the deposit from order item meta at order creation and calls set_total() if it differs by more than 0.1p. This is the belt-and-braces fallback and the authoritative figure.

Note the two use different fallbacks for a line with no deposit: the calculator adds the line total and marks the whole order ineligible; capturePaymentChoice adds line_subtotal + subtotal_tax for that line and continues. In practice the eligibility gate means an ineligible basket never gets here, but the asymmetry is real (PaymentSuccessHandler.php:94-105).

overrideStatusForDeposit() hooks woocommerce_payment_complete_order_status and rewrites completed → deposit-paid when the choice was deposit. DepositPaidStatus registers the post status and inserts “Deposit paid” into the admin dropdown immediately after “Processing”.

Per line, PaymentSuccessHandler::lineAmount() decides what is sent to EP’s MakePayment: the line’s _lovat_deposit_amount when the choice is deposit and that amount is greater than zero, otherwise the full line subtotal. So a mixed basket bills deposit for eligible lines and full for the rest, in one order.

lovat_deposit_amount is captured once, from GetBooking at the moment the holiday is added to the cart, and then never updated. Both ExtrasHandler::update() and PromoHandler::apply() explicitly re-write the original value back after their EP calls, because EP recalculates deposit_amount to include extras — which would inflate the deposit the customer pays today. This is called out in comments in both files; it is a decision, not an oversight.

SettingWherePurpose
checkout_promo_code_configACF options repeaterRows of code, hide_pay_in_full, hide_pay_deposit. Read in DepositCartFee.php:50.
lovat_payment_choiceCookiedeposit or full. Anything else is treated as full.
_lovat_payment_choiceOrder metaThe persisted choice.
_lovat_deposit_amountCart item meta → order item metaPer-line frozen deposit.
wc-deposit-paidOrder status keyConstant DepositPaidStatus::STATUS_KEY.

Deposit amounts themselves come from EP; there is no site-side deposit rule or percentage.

  • The choice travels in a cookie, not a form field. It is set on the extras page and read at checkout and at order creation. Cookie-blocking, a cross-domain checkout, or an aggressive privacy setting silently degrades to “full” — which overcharges relative to what the customer selected. Any change to the checkout host or cookie policy must be tested against this.
  • wc-deposit-paid is not completed. Anything filtering orders by status — reporting, exports, the DotDigital sync, fulfilment — must include it explicitly or deposit orders vanish.
  • The 56-day balance window is hard-coded at DepositCalculator.php:73 (modify('-56 days')). It is not configurable.
  • The £5 minimum saving is hard-coded at DepositCalculator.php:64.
  • Promo conflict flags OR across lines and are order-wide. One restricted promo on one line changes the payment options for the entire basket.
  • Do not re-sync lovat_deposit_amount from EP after extras or promos. See above.
  • DepositChoiceAutoRender::register() is intentionally empty. The class is kept for the documented history of where the block used to render and what would be needed to move it back into the Block checkout.
  • All new deposit logic belongs in DepositCalculator::compute(). It takes plain arrays and returns plain arrays, has no WordPress dependencies, and is the one place unit tests can reach. Resist adding rules into DepositCartFee or PaymentSuccessHandler.
  • If you change the shape of a cart line, update both callers: DepositCartFee::applyDepositDiscount() (builds lines from the cart) and capturePaymentChoice() (builds them from order items).
  • A new promo restriction needs: an ACF sub-field on checkout_promo_code_config, mapping in DepositCartFee.php:56, and handling in compute().
  • Run cd wp-content/themes/lovat-parks && vendor/bin/phpunit --filter Deposit.
  • Manual check, per the QA brief in the repo README: all-eligible basket → deposit reduces the total and the order lands deposit-paid; one ineligible line → that line bills full and the rest bill deposit; a hide_pay_deposit promo on any line → deposit disabled entirely; balance date shows the earliest across lines.
  • Deliberately not abstracted: the negative cart fee. It looks like a hack next to set_total(), but it is what makes the review-order table, the gateway’s amount, and Apple/Google Pay sheets all agree before the order exists. Removing it makes the displayed total wrong until after payment.
  • tests/unit/Order/DepositCalculatorTest.php — eligibility, mixed baskets, the £5 floor, promo conflicts, earliest balance date.
  • tests/unit/Order/DepositPaidStatusTest.php — status registration and dropdown ordering.
  • tests/unit/Checkout/PaymentSuccessHandlerTest.php — covers lineAmount() and the split helpers.

Not covered: DepositCartFee (needs a live WC_Cart), the cookie round-trip, and the block’s JS. All verified by hand.