Skip to content

PIPRES-589 Enable Apple Pay in Chrome, Edge and Firefox via QR code flow - #1485

Open
TLabutis wants to merge 7 commits into
developfrom
PIPRES-589-apple-pay-qr-code-flow
Open

PIPRES-589 Enable Apple Pay in Chrome, Edge and Firefox via QR code flow#1485
TLabutis wants to merge 7 commits into
developfrom
PIPRES-589-apple-pay-qr-code-flow

Conversation

@TLabutis

@TLabutis TLabutis commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What

Apple Pay Direct (product and cart buttons) now works in Chrome, Edge and Firefox on desktop via Apple's iOS 18 QR code flow. The regular checkout method list stays Safari-only on purpose (see the hosted-page finding below).

Why

Every Apple Pay gate in the module is keyed on window.ApplePaySession, which only Safari defines natively. Since iOS 18, Apple supports third-party browsers through its official Apple Pay JS SDK: the shopper clicks the button, gets a QR code sheet, and scans it with an iPhone. Mollie's merchant validation endpoint and the payment flow stay exactly the same.

How

  • mollie.php: hookActionFrontControllerSetMedia registers https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js as a deferred head script on product and cart pages, only when the applepay method is enabled. Deferred scripts finish executing before DOMContentLoaded, and the SDK bootstrap assigns the ApplePaySession polyfill synchronously at its top level, so all existing DOM-ready gates work unchanged, while an Apple CDN slowdown can no longer block first paint. Also exposes applePayLocale for the button element.
  • applePayDirectProduct.js / applePayDirectCart.js: the Direct button is now the SDK's <apple-pay-button> custom element (the old WebKit-CSS <button> renders blank outside Safari). createAppleButton creates it optimistically because the SDK registers the element asynchronously through a dynamic import(), and swaps in the legacy button after 3 seconds if registration never happens (SDK blocked/unavailable). Click handlers are bound directly on the element via bindAppleButtonClick, because the SDK button swallows click propagation so delegated handlers never fire. This also fixes a pre-existing bug: the button re-created on updatedProduct/updatedCart lost its click listener.
  • Insurance against the rolling SDK URL: 1.latest is unpinned, so if Apple ever moves the ApplePaySession assignment behind the SDK's dynamic import, whenApplePaySessionAvailable re-runs the Direct init once customElements.whenDefined('apple-pay-button') resolves. With today's SDK it short-circuits at DOM ready - this is not fixing a reproducible bug.
  • apple_pay_direct.css: sizing for the custom element via Apple's CSS custom properties; Hummingbird selectors extended to match it.

Checkout method list: settled, stays Safari-only

Mollie's hosted Apple Pay wallet page does not support the QR flow. Opened in a non-Safari browser it shows "Apple Pay is unavailable", cancels the payment and bounces the shopper back to the store (verified empirically with a test payment in Chrome). An earlier revision registered the SDK on the order page, which made the polyfill set the isApplePayMethod cookie in Chrome and advertised a method the shopper could not finish. The SDK is now product/cart only and apple_payment.js keeps its plain native-session check, so the checkout list keeps today's Safari-only behavior.

Fixes found while QA-ing the QR flow

  • Sheet payload typing: Apple's QR relay validates the payment request strictly where Safari's bridge is lenient - float amounts with binary noise, a numeric paymentFee or a numeric shipping method identifier close the sheet right after the phone scan. All amounts are now formatted strings.
  • Carrier zone pricing (pre-existing bug, also hits Safari): Apple Pay sheet addresses are soft-deleted by design, Customer::customerHasAddress filters deleted rows, so Cart::getPackageShippingCost silently priced shipping against the shop default country's zone whenever an explicit carrier id was passed to getOrderTotal. The sheet then authorized a total the webhook (which resolves the real zone) refused with "Wrong cart amount". Carrier totals are now priced through the cart's delivery option, and the first listed shipping method is persisted because the sheet preselects it without firing onshippingmethodselected. The quoting loops assign delivery_option directly instead of calling Cart::setDeliveryOption(), which would run CartRule::autoRemoveFromCart() against the shopper's real cart on every quoted carrier. Known limitation: multi-package carts (advanced stock split) use a multi-carrier delivery_option key, so their quotes fall back to the cart's current best-price option; single-package carts, the only case Apple Pay Direct produces, are exact.
  • Product row layout: the classic theme's nowrap flex row crushed the quantity spinner when the Apple Pay button was present (pre-existing with the Safari legacy button, same footprint); the row may now wrap.

Verification

  • Unit suite green: 363 tests, 736 assertions (PHP 7.4 container).
  • Chrome (PS 8.2.3 / PHP 8.1, test-mode combo with PIPRES-725 Apple Pay Direct: Enable test payments without real charges #1321): full QR payments end to end via iPhone scan - product page and cart page, carrier switch on the sheet, payment fee and shipping tax included; orders created by the webhook with matching totals, real customer addresses persisted, no placeholder leak.
  • Chrome: SDK loads (deferred) on product and cart only; checkout page has no SDK, no ApplePaySession, cookie isApplePayMethod=0, method hidden - matching pre-PR behavior.
  • Safari (PS 1.7.8.9): button renders as the custom element (200x32); click wiring verified with a mocked ApplePaySession (no payment authorization attempted): session amount tracks quantity changes and variant switches; re-created button after updatedProduct keeps a working click handler; cart session amount includes voucher discounts.
  • Layout fix verified in Chrome at 1120-1172px viewports: spinner intact, button wraps to its own line; wide layouts unchanged.

Left for manual QA (needs a LIVE API key - test keys do not expose Apple Pay; use real small amounts or refund after)

  • Live QR payment end to end in Chrome, plus one in Edge and Firefox.
  • Amounts track quantity changes, combination switches and cart vouchers through the QR sheet.
  • All 3 configured button styles (black / white outline / white) render on the SDK button.
  • PS 9 / Hummingbird product + cart visual check.
  • Checkout method list per browser: visible in Safari, hidden in Chrome/Edge/Firefox.
  • Abandoned sheet (open QR, close without paying): no order, no visible placeholder address.
  • Guest and logged-in flows both complete.
  • Safari regression: native sheet on product + cart, checkout list still works end to end.

Merchant caveats to document

  • Shops with a strict CSP must allow applepay.cdn-apple.com in script-src (and QA should watch the console for connect-src/frame-src needs around the QR sheet).
  • A Safari shopper behind a CSP that blocks the SDK now sees the button appear after the 3s fallback window, where before this PR it was instant.

@TLabutis TLabutis mentioned this pull request Aug 18, 2026
Apple Pay Direct and the checkout method were gated on
window.ApplePaySession, which only Safari defines natively. Loading
Apple's official Apple Pay JS SDK as a synchronous head script defines
ApplePaySession in third-party browsers, where clicking the button shows
Apple's iOS 18 QR code sheet; the existing DOM-ready gates and the
isApplePayMethod cookie then work unchanged.

hookActionFrontControllerSetMedia registers the SDK on product, cart and
order pages only when the applepay method is enabled, so shops without
Apple Pay never hit Apple's CDN. The Direct button is now created as the
SDK's <apple-pay-button> custom element, since the old WebKit-CSS button
renders blank outside Safari: createAppleButton creates it optimistically
because the SDK registers the element asynchronously, and swaps in the
legacy button after 3 seconds if registration never happens. Click
handlers are bound directly on the element via bindAppleButtonClick
because the SDK button swallows click propagation, so delegated handlers
never fire; this also fixes the re-created button losing its click
listener after updatedProduct and updatedCart refreshes.

Merchant validation and payment creation are untouched: Mollie's
/wallets/applepay/sessions endpoint receives the validationURL exactly as
before. No validationUrl allowlist and no session version bump.
The SDK registration had no attribute, so the synchronous head script
render-blocked product, cart and order pages on applepay.cdn-apple.com;
an Apple CDN slowdown would stall first paint on the shop's three
busiest page types. defer keeps every DOM-ready guarantee, since
deferred scripts finish executing before DOMContentLoaded fires.

The DOM-ready ApplePaySession checks are safe with today's SDK: the
bootstrap assigns the polyfill synchronously at top level and only the
apple-pay-button element arrives through its dynamic import. But
1.latest is a rolling URL with no version pin, so Apple can restructure
the bootstrap into an async loader at any time. As insurance,
whenApplePaySessionAvailable gates initApplePayDirect and
setApplePayMethodCookie re-runs on customElements.whenDefined, so both
the Direct button and the isApplePayMethod cookie recover if the
polyfill ever lands after DOM ready. whenDefined resolves as a
microtask after the defining module's full top-level evaluation, which
is why a one-shot recheck suffices and no polling is needed.

The 3s legacy-button fallback in createAppleButton stays: init runs at
DOM ready before the element import lands, and in Safari behind a CSP
that blocks Apple's CDN it is the only path to a working button.
@TLabutis
TLabutis force-pushed the PIPRES-589-apple-pay-qr-code-flow branch from b684a97 to c1b9fd8 Compare August 19, 2026 08:23
Apple's QR relay for non-Safari browsers validates the payment request
strictly where Safari's native bridge is lenient: a float amount with
binary noise (47.890000000000001), a numeric paymentFee or a numeric
shipping method identifier makes the sheet close right after the phone
scan. Format every amount sent to the sheet with number_format/toFixed
and cast the carrier identifier to string in OrderTotalCollector,
ShippingMethodUtility, UpdateApplePayShippingContactHandler,
UpdateApplePayShippingMethodHandler, the applePayDirectAjax cart total
and applePayDirectProduct.js.
Apple Pay sheet addresses are soft-deleted by design, and
Customer::customerHasAddress filters deleted rows, so
Cart::getPackageShippingCost silently falls back to the shop default
country's zone whenever an explicit carrier id is passed to
getOrderTotal. Shipping gets priced off the wrong zone's ranges, the
sheet authorizes that total, and the webhook, which prices the cart
without a carrier argument and resolves the real zone, computes a
different total and refuses the order with a wrong-amount error. This
also affects the native Safari flow for any buyer whose delivery zone
differs from the shop default.

Set each candidate carrier as the cart's delivery option in memory and
call getOrderTotal without a carrier id, restoring delivery_option
afterwards, in OrderTotalCollector and ShippingMethodUtility; drop the
explicit carrier argument in UpdateApplePayShippingMethodHandler and
CreateApplePayOrderHandler, which persist the carrier on the cart
already. UpdateApplePayShippingContactHandler now also persists the
first listed shipping method, because the sheet preselects it without
firing onshippingmethodselected, so the cart must already carry it or
the authorized total diverges from the charged one.
Mollie's hosted Apple Pay checkout page does not support the QR flow:
opened in a non-Safari browser it reports Apple Pay as unavailable,
cancels the payment and sends the shopper back to the store. With the
SDK registered on the order page, the polyfill set the isApplePayMethod
cookie in Chrome and the checkout list advertised a method the shopper
could not finish. Register the SDK only on the product and cart pages,
where Apple Pay Direct renders its own button, and restore
apple_payment.js to the plain native-session check so the checkout
list stays gated to Safari. This also drops the whenDefined re-check
added earlier, which without an SDK on the checkout page could only
ever advertise the dead method.
The classic theme lays out the quantity spinner, the add-to-cart button
and the Apple Pay Direct button in a single nowrap flex row. When the
row runs out of space the quantity block is the only item allowed to
shrink, so the spinner arrows drop under the input while the 200px
button keeps its full width. Allow the row to wrap so the whole button
moves to its own line instead. The squeeze predates the QR flow: the
legacy Safari-only button has the same footprint, other browsers just
never rendered a button in this row before.
Cart::setDeliveryOption() ends by running CartRule::autoRemoveFromCart()
and CartRule::autoAddToCart(), which call removeCartRule() and
$cart->update() on the context cart. During Apple Pay sheet quoting the
context cart is the shopper's real cart, so looping every candidate
carrier through setDeliveryOption() could permanently delete a
carrier-restricted voucher and persist a mid-loop carrier while merely
rendering the sheet.

Assign the delivery_option property directly instead. Pricing is
unaffected: getTotalShippingCost() resolves the carrier through
getDeliveryOption() with use_cache=false, re-reading the in-memory
property on every call, and the PS 8 Calculator's Fees never consults
id_carrier when getOrderTotal() is called without an explicit carrier.
The handlers keep setDeliveryOption() because there the shopper actually
selected the carrier, matching core checkout semantics.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant