ddtcorex

02 Sept 2026 · 3 min read

Case study: Apple Pay placed the order twice

A Hyva Checkout payment method bound its activation listener once and on the wrong step, so Apple Pay disabled the real button and double-fired the order.

Field notes — a real production bug, project name withheld. Symptom → investigation → fix → lesson.

The symptom

On a Hyva Checkout page with an Apple Pay payment method, two things went wrong: selecting Apple Pay sometimes left the standard "Place Order" button disabled (so the customer couldn't check out at all), and when Apple Pay did fire, it occasionally placed the order twice. Both behaviors were intermittent and only appeared in the live checkout, never in isolated testing.

Investigation

The Apple Pay module wired itself into the Hyva Checkout lifecycle through global window events. Two mistakes compounded.

First, the method-activation listener was registered with { once: true } and fired on any checkout:step:loaded event:

window.addEventListener('checkout:step:loaded', () => {
    if (currentMethod && document.getElementById('payment-method-list')) {
        window.dispatchEvent(new CustomEvent('checkout:payment:method-activate',
            { detail: { method: currentMethod } }));
    }
}, { once: true });

The { once: true } meant the listener removed itself after the very first step load — so if the first checkout:step:loaded wasn't the payment step, the activate event never fired again. And because it didn't check which step had loaded, even when it did fire it could target the wrong step.

Second, the Apple Pay button handler was attached to the literal <apple-pay-button> element and, on initialize, disabled the standard place-order button by querying .btn-place-order:

document.querySelector('apple-pay-button').addEventListener('click', () => {
    hyvaCheckout.main.getWireComponent().placeOrder();
    this.initApplePay();
});
// ...
btnPlaceOrder = document.querySelector('.btn-place-order');
if (btnPlaceOrder) { btnPlaceOrder.disabled = true; }

The Apple Pay button element was rendered through a Magewire template that got removed during the fix, so querySelector('apple-pay-button') could return null and the click handler never attached — meaning the order placement went through a different, repeated path. Disabling .btn-place-order by class was also fragile: if that selector didn't match, the standard button stayed live alongside Apple Pay, and two placements could race.

Root cause

The bug was a fragile event lifecycle plus fragile DOM targeting. Binding to a once-style listener on a global step event meant activation was a one-shot gamble, and binding the order action to a web-component element that could be absent left the real handler unattached. The fix had to make both the activation and the order action deterministic and idempotent.

The fix

Scope the activation to the payment step explicitly, and keep the listener alive (drop { once: true }):

window.addEventListener('checkout:step:loaded', (event) => {
    if (
        event.detail.name === 'payment' &&
        currentMethod &&
        document.getElementById('payment-method-list')
    ) {
        window.dispatchEvent(new CustomEvent('checkout:payment:method-activate',
            { detail: { method: currentMethod } }));
    }
});

Replace the web-component button with a real, stable DOM id, attach the handler once with ??=, and toggle the two place-order buttons by id instead of disabling by class:

this.applePayButton = document.getElementById('btn_place_order_apple_pay');
this.applePayClickHandler ??= () => {
    hyvaCheckout.main.getWireComponent().placeOrder();
    this.initApplePay();
};
this.applePayButton?.addEventListener('click', this.applePayClickHandler);
const togglePlaceOrderButtons = isApplePay => {
    document.getElementById('btn_place_order')?.classList.toggle('hidden', isApplePay);
    document.getElementById('btn_place_order_apple_pay')?.classList.toggle('hidden', !isApplePay);
};

And in uninitialize, detach the exact handler and restore the standard button — no more orphaned listeners, no more class-guessing:

uninitialize: async function () {
    this.applePayButton?.removeEventListener('click', this.applePayClickHandler);
    togglePlaceOrderButtons(false);
}

The deleted Magewire apple-pay.phtml web-component was replaced by this id-based toggle, removing the missing-element risk entirely.

Lesson

In Hyva Checkout, treat checkout:step:loaded as a stream of steps, not a one-time signal — always check event.detail.name, and never use { once: true } for something that must survive step transitions. Bind order actions to stable element ids and toggle visibility, not to web-component tags that may not render, and use ??= + removeEventListener so a handler is attached exactly once and cleaned up on uninitialize. Idempotent, id-based binding is what turns an intermittent double-charge into a non-event.

magento2hyvacheckoutpaymentsalpine