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.
Why it exists
Section titled “Why it exists”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.
Entry points
Section titled “Entry points”All paths relative to wp-content/themes/lovat-parks/.
| Path | What it does |
|---|---|
blocks/holiday-booking-portal/block-holiday-booking-portal.php | The 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:34 | POST /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:28 | POST /wp-json/lovat/v1/portal/applepay/validate — Apple Pay merchant session. |
inc/lovat-checkout/src/Portal/CardstreamRequestBuilder.php | Builds and signs every gateway request. |
inc/eliteparks/checkoutAPI.php:2367 | booking_portal_login() — the auth call into EP. |
inc/eliteparks/booking.php:1625 | getData() 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).
How it works
Section titled “How it works”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]
Authentication and validation
Section titled “Authentication and validation”Every payment attempt re-authenticates. PortalPaymentInitiator::handle() calls booking_portal_login($bookingNo, $surname) against EP, then:
- Finds the local
bookingpost by thebooking_nometa — no post, no payment. - Calls EP
get_booking_by_booking_no()and readsoutstanding_amount. - 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.
Card handling
Section titled “Card handling”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.
On success
Section titled “On success”verifyResponse() checks the Cardstream signature, then:
update_field('part_payment_amount' | 'part_payment_id', …)on the booking post.- Unless the transaction id already appears in
previous_part_payments_made, callCheckoutAPI::make_part_payment($transactionId)andconfirm_booking(). - 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.
Configuration
Section titled “Configuration”| Setting | Where | Purpose |
|---|---|---|
woocommerce_cardstream_settings | WP option (WooCommerce → Cardstream) | merchantID, signature, gatewayURL, merchantCountryCode (default 826), type. Names only — never record values. |
woocommerce_cardstream_googlepay_settings / …_applepay_settings | WP options | Wallet configuration. |
LP_USE_WOOCOMMERCE_PORTAL | wp-config.php | Emergency override. Otherwise follows Config::isWooCommerceEnabled() (Config.php:58). |
portal_page | ACF option | Where success and error redirects land. |
SAGEPAY_IS_PRODUCTION, OPAYO_CONFIG | wp-config.php | Legacy Opayo path only. |
Plugin woocommerce-cardstream | must be active | Provides the P3\SDK\Gateway class and the hosted-fields CSS. |
Invariants and gotchas
Section titled “Invariants and gotchas”- Booking number + surname is the entire credential. There is no rate limiting on
/portal/payment/initiate, andpermission_callbackis__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.
buildDirectRequestmultiplies by 100; the callback divides by 100 out ofmerchantData. Mixing them is a factor-of-100 error in production. merchantDatais 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_paymenton a retried callback. 65802can 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.comunconditionally when Cardstream is on, and the hosted-fields CSS directly from the plugin directory.
Changing it safely
Section titled “Changing it safely”- 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 inPortalPaymentCallback::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 sourcelovat-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.