Skip to content

Booking portal balance payments

Customers who booked a holiday can log in to the booking portal with their booking number and surname, see what they owe, and pay the outstanding balance by card, Apple Pay or Google Pay. The card form stays on the Lovat Parks page — there is no redirect to a payment provider.

This is where the balance from [[deposit-and-pay-in-full]] gets collected, and where anyone with an outstanding amount on a legacy booking pays it off.

Before this, balance payments meant phoning the park. The portal is deliberately not an account system: there is no registration, no password, no customer record to breach. Booking number plus surname is the whole credential, which is a conscious trade-off — the data behind it is one booking’s own details, and the alternative was no self-service at all.

The gateway moved from Opayo to Cardstream with the WooCommerce migration, and the two implementations sit side by side behind a flag.

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

PathWhat it does
blocks/holiday-booking-portal/block-holiday-booking-portal.phpThe portal block. Vue app; branches on the Cardstream flag and enqueues either Cardstream hosted-fields or the legacy Opayo JS.
inc/lovat-checkout/src/Portal/PortalPaymentInitiator.php:34POST /wp-json/lovat/v1/portal/payment/initiate — the payment endpoint.
inc/lovat-checkout/src/Portal/PortalPaymentCallback.php:20/?wc-api=lovat_portal_cardstream — 3DS ACS postback and legacy hosted-form callback.
inc/lovat-checkout/src/Portal/PortalApplePayValidation.php:28POST /wp-json/lovat/v1/portal/applepay/validate — Apple Pay merchant session.
inc/lovat-checkout/src/Portal/CardstreamRequestBuilder.phpBuilds and signs every gateway request.
inc/eliteparks/checkoutAPI.php:2367booking_portal_login() — the auth call into EP.
inc/eliteparks/booking.php:1625getData() branches booking_portal_login, booking_portal_msk, portal_proceed_to_payment.

The Portal classes are only registered when Config::isWooCommerceFeatureActive() is true (lovat-checkout.php:89).

flowchart TD
    A[Portal: booking no + surname] --> B[CheckoutAPI::booking_portal_login]
    B --> C[Vue tokenises card via hostedfields.min.js]
    C -->|POST /portal/payment/initiate| D[Re-auth + find booking CPT + EP GetBooking]
    D --> E{outstanding_amount checks}
    E -->|0 or overpay| F[409 / 400]
    E -->|ok| G[CardstreamRequestBuilder::buildDirectRequest]
    G --> H{responseCode}
    H -->|0| I[make_part_payment + confirm_booking → success URL]
    H -->|65802| J[Return ACS details, set threeDSRef cookie]
    H -->|other| K[402 declined]
    J --> L[Vue silent-posts to ACS]
    L --> M[ACS POSTs back to wc-api callback]
    M --> N[resumeThreeDSRequest]
    N -->|65802 again| L
    N -->|0| O[verifyResponse then make_part_payment]

Every payment attempt re-authenticates. PortalPaymentInitiator::handle() calls booking_portal_login($bookingNo, $surname) against EP, then:

  1. Finds the local booking post by the booking_no meta — no post, no payment.
  2. Calls EP get_booking_by_booking_no() and reads outstanding_amount.
  3. Rejects a zero balance (409) and any amount exceeding the balance by more than a penny (400).

So the amount is validated against EP, not against anything the client sent.

Cardstream’s hostedfields.min.js tokenises the card in the browser. The server only ever sees a paymentToken. buildDirectRequest() assembles a SALE with the amount in pence, transactionUnique = {bookingNo}-{uniqid}, and a merchantData JSON blob carrying wp_booking_id, booking_no and amount_pence — that blob is how the callback recovers context, since the ACS postback carries nothing else of ours.

Credentials are not configured separately: CardstreamRequestBuilder reads get_option('woocommerce_cardstream_settings') — the same merchant account as the WooCommerce gateway, by product decision.

Response code 65802 means a 3DS step is required. It can happen twice — once for the method/fingerprint step and once for the issuer challenge — so handleThreeDSCallback() loops: if the resume also returns 65802, it refreshes the threeDSRef cookie and emits Gateway::silentPost() to send the browser on to the next ACS URL.

The threeDSRef cookie (10 min) is the only state carried across the round trip. After the fingerprint step Cardstream returns the browser by GET with no POST body, so the callback treats “no cres/threeDSMethodData but a threeDSRef cookie exists” as a resume (PortalPaymentCallback.php:41-46).

Both cookies (xref, threeDSRef) are set secure, httponly => false, samesite => None — the ACS is a third-party origin, so None is required and the JS needs to read them.

verifyResponse() checks the Cardstream signature, then:

  1. update_field('part_payment_amount' | 'part_payment_id', …) on the booking post.
  2. Unless the transaction id already appears in previous_part_payments_made, call CheckoutAPI::make_part_payment($transactionId) and confirm_booking().
  3. Redirect to the portal page with ?payment_success=1&payment_id=….

That previous_part_payments_made check is the only duplicate-payment protection — a comma-joined string, membership tested with in_array(explode(',')).

Failures write a human-readable note to last_portal_payment_failure and redirect with ?payment_error=1&reason=…. Reasons: invalid_response, signature, threeds_ref_missing, threeds_resume_failed, missing_data, declined, api_unavailable.

SettingWherePurpose
woocommerce_cardstream_settingsWP option (WooCommerce → Cardstream)merchantID, signature, gatewayURL, merchantCountryCode (default 826), type. Names only — never record values.
woocommerce_cardstream_googlepay_settings / …_applepay_settingsWP optionsWallet configuration.
LP_USE_WOOCOMMERCE_PORTALwp-config.phpEmergency override. Otherwise follows Config::isWooCommerceEnabled() (Config.php:58).
portal_pageACF optionWhere success and error redirects land.
SAGEPAY_IS_PRODUCTION, OPAYO_CONFIGwp-config.phpLegacy Opayo path only.
Plugin woocommerce-cardstreammust be activeProvides the P3\SDK\Gateway class and the hosted-fields CSS.
  • Booking number + surname is the entire credential. There is no rate limiting on /portal/payment/initiate, and permission_callback is __return_true. Adding throttling is a sensible hardening step; raise it as a ticket rather than assuming it exists.
  • The gateway is shared with WooCommerce. Changing the Cardstream merchant settings changes the main checkout too.
  • Amounts are pence, everywhere in the gateway layer, and pounds everywhere else. buildDirectRequest multiplies by 100; the callback divides by 100 out of merchantData. Mixing them is a factor-of-100 error in production.
  • merchantData is the only context that survives the 3DS round trip. Anything the callback needs must be inside it.
  • Duplicate protection is a comma-joined ACF string shared with [[legacy-holiday-checkout]]. It is fragile, and it is the only thing preventing a double make_part_payment on a retried callback.
  • 65802 can occur twice. Code that handles one 3DS step will strand customers at the challenge.
  • The Cardstream classes are only registered when the WooCommerce feature is active. Turning the checkout flag off also disables the portal’s Cardstream endpoints — the block falls back to Opayo. Verify both when flipping the flag.
  • The block enqueues Google Pay’s SDK from pay.google.com unconditionally when Cardstream is on, and the hosted-fields CSS directly from the plugin directory.
  • All gateway request building belongs in CardstreamRequestBuilder. It is the only place that reads credentials, and keeping it single-purpose is what makes the merchant-account sharing auditable.
  • New response codes get handled in PortalPaymentInitiator::handle() and mirrored in PortalPaymentCallback::handleThreeDSCallback() — the two paths must agree or a 3DS customer gets different behaviour from a frictionless one.
  • Never log a paymentToken, cres, or raw POST body. Existing logging deliberately records only keys, codes and refs (PortalPaymentCallback.php:24-30).
  • Watch it live: wp-content/uploads/wc-logs/ under source lovat-portal-cardstream.
  • Testing needs Cardstream sandbox credentials and a card that forces a 3DS challenge. Verify the frictionless path, the single-step 3DS path, the two-step 3DS path, and a decline. Wallet payments must be tested on real devices — Apple Pay will not run on localhost without a validated merchant domain.
  • Deliberately not abstracted: the portal does not go through WooCommerce orders. A balance payment is not a new sale, it is a payment against an existing EP booking, and forcing it into an order would create phantom revenue in reporting.

None. Nothing under tests/unit/Portal/. The payment path, 3DS resume loop, signature verification and duplicate guard are all verified manually against the Cardstream sandbox.

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