Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .claude/docs/payment-flow.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Payment Flow — Detailed Reference

**Last updated:** 2026-02-11
**Last updated:** 2026-02-20

This documents the exact call chain for payment operations in WooPayments. Read this when working on payment processing, refunds, or API communication.

Expand Down Expand Up @@ -93,6 +93,17 @@ Same layers: Request → API Client → HTTP → Jetpack → wpcom → Stripe.
3. WooCommerce Blocks sends this to PHP via the Store API
4. `onCheckoutSuccess` hook: handles 3DS confirmation via `stripe.handleNextAction()` or `stripe.confirmCardPayment()`

### Express Checkout in Blocks (ECE)

Express checkout buttons (Apple Pay, Google Pay, Amazon Pay) in WooCommerce block-based Cart/Checkout use a **dual data path** — bugs often arise from these paths being out of sync:

1. **Registration data** — `isPaymentRequestEnabled` from `get_payment_method_data()` → WC Blocks registry → `getUPEConfig()`. Controls whether `registerExpressPaymentMethod()` is called.
2. **Runtime data** — `wcpayExpressCheckoutParams` from `wp_localize_script()` in the Express Checkout Button Handler's `scripts()` method. Provides `enabled_methods` for the current page context.

Key difference from shortcode path: The shortcode path uses `should_show_express_checkout_button()` to prevent script loading entirely. The blocks path loads `WCPAY_BLOCKS_CHECKOUT` via `WC_Payments_Blocks_Payment_Method::get_payment_method_script_handles()` unconditionally — visibility must be controlled via `canMakePayment` callbacks and `enabled_methods`.

**Location settings model (since 10.4.0):** `express_checkout_{location}_methods` options (e.g., `express_checkout_cart_methods`). Values: `'payment_request'` = Apple Pay/Google Pay, `'amazon_pay'` = Amazon Pay.

### JS API Client (`client/checkout/api/index.js`)

- `WCPayAPI` class wraps Stripe interactions
Expand Down
4 changes: 4 additions & 0 deletions changelog/agent-woopmnt-5763
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fix

Fix express checkout buttons appearing on block-based cart when the cart location is unchecked in display settings.
189 changes: 189 additions & 0 deletions client/express-checkout/blocks/__tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* Internal dependencies
*/
import {
expressCheckoutElementApplePay,
expressCheckoutElementGooglePay,
expressCheckoutElementAmazonPay,
} from '../index';
import { checkPaymentMethodIsAvailable } from '../../utils/checkPaymentMethodIsAvailable';

jest.mock( '../../utils/checkPaymentMethodIsAvailable', () => ( {
checkPaymentMethodIsAvailable: jest.fn(),
} ) );

jest.mock( 'wcpay/utils/checkout', () => ( {
getConfig: jest.fn().mockReturnValue( [] ),
} ) );

jest.mock( 'wcpay/checkout/constants', () => ( {
PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT:
'woocommerce_payments_express_checkout',
} ) );

const mockCart = {
cartTotals: {
total_price: '1000',
currency_code: 'USD',
},
};

const mockApi = {
loadStripeForExpressCheckout: jest.fn(),
};

describe( 'Express checkout blocks registration', () => {
beforeEach( () => {
jest.clearAllMocks();
delete global.wcpayExpressCheckoutParams;
} );

describe( 'canMakePayment', () => {
describe( 'when wcpayExpressCheckoutParams is undefined', () => {
it( 'should return false for Apple Pay', () => {
const result = expressCheckoutElementApplePay(
mockApi
).canMakePayment( { cart: mockCart } );
expect( result ).toBe( false );
expect( checkPaymentMethodIsAvailable ).not.toHaveBeenCalled();
} );

it( 'should return false for Google Pay', () => {
const result = expressCheckoutElementGooglePay(
mockApi
).canMakePayment( { cart: mockCart } );
expect( result ).toBe( false );
expect( checkPaymentMethodIsAvailable ).not.toHaveBeenCalled();
} );

it( 'should return false for Amazon Pay', () => {
const result = expressCheckoutElementAmazonPay(
mockApi
).canMakePayment( { cart: mockCart } );
expect( result ).toBe( false );
expect( checkPaymentMethodIsAvailable ).not.toHaveBeenCalled();
} );
} );

describe( 'when payment_request is NOT in enabled_methods', () => {
beforeEach( () => {
global.wcpayExpressCheckoutParams = {
enabled_methods: [],
};
} );

it( 'should return false for Apple Pay', () => {
const result = expressCheckoutElementApplePay(
mockApi
).canMakePayment( { cart: mockCart } );
expect( result ).toBe( false );
expect( checkPaymentMethodIsAvailable ).not.toHaveBeenCalled();
} );

it( 'should return false for Google Pay', () => {
const result = expressCheckoutElementGooglePay(
mockApi
).canMakePayment( { cart: mockCart } );
expect( result ).toBe( false );
expect( checkPaymentMethodIsAvailable ).not.toHaveBeenCalled();
} );
} );

describe( 'when amazon_pay is NOT in enabled_methods', () => {
beforeEach( () => {
global.wcpayExpressCheckoutParams = {
enabled_methods: [ 'payment_request' ],
};
} );

it( 'should return false for Amazon Pay', () => {
const result = expressCheckoutElementAmazonPay(
mockApi
).canMakePayment( { cart: mockCart } );
expect( result ).toBe( false );
expect( checkPaymentMethodIsAvailable ).not.toHaveBeenCalled();
} );
} );

describe( 'when payment_request IS in enabled_methods', () => {
beforeEach( () => {
global.wcpayExpressCheckoutParams = {
enabled_methods: [ 'payment_request' ],
};
checkPaymentMethodIsAvailable.mockResolvedValue( true );
} );

it( 'should call checkPaymentMethodIsAvailable for Apple Pay', () => {
expressCheckoutElementApplePay( mockApi ).canMakePayment( {
cart: mockCart,
} );
expect( checkPaymentMethodIsAvailable ).toHaveBeenCalledWith(
'applePay',
mockCart,
mockApi
);
} );

it( 'should call checkPaymentMethodIsAvailable for Google Pay', () => {
expressCheckoutElementGooglePay( mockApi ).canMakePayment( {
cart: mockCart,
} );
expect( checkPaymentMethodIsAvailable ).toHaveBeenCalledWith(
'googlePay',
mockCart,
mockApi
);
} );
} );

describe( 'when amazon_pay IS in enabled_methods', () => {
beforeEach( () => {
global.wcpayExpressCheckoutParams = {
enabled_methods: [ 'amazon_pay' ],
};
checkPaymentMethodIsAvailable.mockResolvedValue( true );
} );

it( 'should call checkPaymentMethodIsAvailable for Amazon Pay', () => {
expressCheckoutElementAmazonPay( mockApi ).canMakePayment( {
cart: mockCart,
} );
expect( checkPaymentMethodIsAvailable ).toHaveBeenCalledWith(
'amazonPay',
mockCart,
mockApi
);
} );
} );

describe( 'when enabled_methods is missing from params', () => {
beforeEach( () => {
global.wcpayExpressCheckoutParams = {};
} );

it( 'should return false for Apple Pay (defaults to empty array)', () => {
const result = expressCheckoutElementApplePay(
mockApi
).canMakePayment( { cart: mockCart } );
expect( result ).toBe( false );
expect( checkPaymentMethodIsAvailable ).not.toHaveBeenCalled();
} );

it( 'should return false for Google Pay (defaults to empty array)', () => {
const result = expressCheckoutElementGooglePay(
mockApi
).canMakePayment( { cart: mockCart } );
expect( result ).toBe( false );
expect( checkPaymentMethodIsAvailable ).not.toHaveBeenCalled();
} );

it( 'should return false for Amazon Pay (defaults to empty array)', () => {
const result = expressCheckoutElementAmazonPay(
mockApi
).canMakePayment( { cart: mockCart } );
expect( result ).toBe( false );
expect( checkPaymentMethodIsAvailable ).not.toHaveBeenCalled();
} );
} );
} );
} );
19 changes: 19 additions & 0 deletions client/express-checkout/blocks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from 'wcpay/checkout/con
import { getConfig } from 'wcpay/utils/checkout';
import ExpressCheckoutContainer from './components/express-checkout-container';
import { checkPaymentMethodIsAvailable } from '../utils/checkPaymentMethodIsAvailable';
import { getExpressCheckoutData } from '../utils';
import '../compatibility/wc-order-attribution';

const LazyApplePayPreview = lazy( () =>
Expand Down Expand Up @@ -71,6 +72,12 @@ export const expressCheckoutElementApplePay = ( api ) => ( {
return false;
}

const enabledMethods =
getExpressCheckoutData( 'enabled_methods' ) ?? [];
if ( ! enabledMethods.includes( 'payment_request' ) ) {
return false;
}

return checkPaymentMethodIsAvailable( 'applePay', cart, api );
},
} );
Expand Down Expand Up @@ -100,6 +107,12 @@ export const expressCheckoutElementGooglePay = ( api ) => ( {
return false;
}

const enabledMethods =
getExpressCheckoutData( 'enabled_methods' ) ?? [];
if ( ! enabledMethods.includes( 'payment_request' ) ) {
return false;
}

return checkPaymentMethodIsAvailable( 'googlePay', cart, api );
},
} );
Expand All @@ -126,6 +139,12 @@ export const expressCheckoutElementAmazonPay = ( api ) => ( {
return false;
}

const enabledMethods =
getExpressCheckoutData( 'enabled_methods' ) ?? [];
if ( ! enabledMethods.includes( 'amazon_pay' ) ) {
return false;
}

return checkPaymentMethodIsAvailable( 'amazonPay', cart, api );
},
} );
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,18 @@ public function init() {
* @return mixed
*/
public function payment_fields_js_config( $config ) {
$config['isPaymentRequestEnabled'] = $this->gateway->is_payment_request_enabled() && $this->express_checkout_helper->is_express_checkout_method_enabled_at( 'checkout', 'payment_request' );
$config['isAmazonPayEnabled'] = $this->express_checkout_helper->can_use_amazon_pay() && $this->express_checkout_helper->is_express_checkout_method_enabled_at( 'checkout', 'amazon_pay' );
$context = $this->express_checkout_helper->get_button_context();

$config['isPaymentRequestEnabled'] = $this->gateway->is_payment_request_enabled()
&& (
empty( $context )
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the empty( $context ) fallback needed here? What scenario would break without this fallback?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_button_context() returns an empty string when this filter runs outside a frontend page load. Without the fallback, isPaymentRequestEnabled would be false during AJAX cart updates, breaking classic checkout.

|| $this->express_checkout_helper->is_express_checkout_method_enabled_at( $context, 'payment_request' )
);
$config['isAmazonPayEnabled'] = $this->express_checkout_helper->can_use_amazon_pay()
&& (
empty( $context )
|| $this->express_checkout_helper->is_express_checkout_method_enabled_at( $context, 'amazon_pay' )
);

return $config;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,92 @@ public function test_get_express_checkout_params() {
$this->assertArrayHasKey( 'store_name', $params );
$this->assertEquals( get_bloginfo( 'name' ), $params['store_name'] );
}

public function test_payment_fields_js_config_on_cart_page_with_cart_disabled() {
$this->mock_ece_button_helper
->method( 'get_button_context' )
->willReturn( 'cart' );

$this->mock_wcpay_gateway
->method( 'is_payment_request_enabled' )
->willReturn( true );

$this->mock_ece_button_helper
->method( 'is_express_checkout_method_enabled_at' )
->with( 'cart', 'payment_request' )
->willReturn( false );

$this->mock_ece_button_helper
->method( 'can_use_amazon_pay' )
->willReturn( false );

$config = $this->system_under_test->payment_fields_js_config( [] );

$this->assertFalse( $config['isPaymentRequestEnabled'] );
}

public function test_payment_fields_js_config_on_checkout_page_with_checkout_enabled() {
$this->mock_ece_button_helper
->method( 'get_button_context' )
->willReturn( 'checkout' );

$this->mock_wcpay_gateway
->method( 'is_payment_request_enabled' )
->willReturn( true );

$this->mock_ece_button_helper
->method( 'is_express_checkout_method_enabled_at' )
->with( 'checkout', 'payment_request' )
->willReturn( true );

$this->mock_ece_button_helper
->method( 'can_use_amazon_pay' )
->willReturn( false );

$config = $this->system_under_test->payment_fields_js_config( [] );

$this->assertTrue( $config['isPaymentRequestEnabled'] );
}

public function test_payment_fields_js_config_with_empty_context_defaults_to_enabled() {
$this->mock_ece_button_helper
->method( 'get_button_context' )
->willReturn( '' );

$this->mock_wcpay_gateway
->method( 'is_payment_request_enabled' )
->willReturn( true );

$this->mock_ece_button_helper
->method( 'can_use_amazon_pay' )
->willReturn( true );

$config = $this->system_under_test->payment_fields_js_config( [] );

$this->assertTrue( $config['isPaymentRequestEnabled'] );
$this->assertTrue( $config['isAmazonPayEnabled'] );
}

public function test_payment_fields_js_config_amazon_pay_on_cart_with_cart_disabled() {
$this->mock_ece_button_helper
->method( 'get_button_context' )
->willReturn( 'cart' );

$this->mock_wcpay_gateway
->method( 'is_payment_request_enabled' )
->willReturn( false );

$this->mock_ece_button_helper
->method( 'can_use_amazon_pay' )
->willReturn( true );

$this->mock_ece_button_helper
->method( 'is_express_checkout_method_enabled_at' )
->with( 'cart', 'amazon_pay' )
->willReturn( false );

$config = $this->system_under_test->payment_fields_js_config( [] );

$this->assertFalse( $config['isAmazonPayEnabled'] );
}
}
Loading