Skip to content
Open
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
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ xxxx-xx-xx - version 11.1.0
* Fix - Prevent dropped webhooks, double processing, and skipped pre-order handling when requests race for the order payment lock
* Fix - Stop two requests from reclaiming the same expired Optimized Checkout or Agentic Commerce sync lock
* Fix - Open testing and payment settings documentation links in new tabs
* Fix - Link to checkout when missing required custom fields block express checkout on other pages

2026-09-08 - version 11.0.0
* Fix - Allow express checkout payments when a wallet provides a one-word shipping name
Expand Down
92 changes: 54 additions & 38 deletions client/blocks/express-checkout/__tests__/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jest.mock( 'wcstripe/express-checkout/event-handler', () => ( {

jest.mock( 'wcstripe/express-checkout/utils', () => ( {
displayExpressCheckoutNotice: jest.fn(),
formatExpressCheckoutNotice: jest.requireActual(
'wcstripe/express-checkout/utils'
).formatExpressCheckoutNotice,
getExpressCheckoutButtonStyleSettings: jest.fn( () => ( {
paymentMethods: {},
} ) ),
Expand Down Expand Up @@ -229,44 +232,57 @@ describe( 'useExpressCheckout', () => {
// An order-side failure still has to give the wallet sheet a terminal result,
// otherwise it stays open and the shopper is left with nothing. The third
// argument is the removed `isOrderError` opt-out: passing it must change nothing.
it( 'fails the payment on the wallet sheet when the order errors', async () => {
const setExpressPaymentError = jest.fn();

const { result } = renderHook( () =>
useExpressCheckout( {
api: {},
billing: {
currency: { minorUnit: 2 },
cartTotal: { value: 7500 },
cartTotalItems: [],
},
shippingData: { needsShipping: false, shippingRates: [] },
onClick: jest.fn(),
onClose: jest.fn(),
setExpressPaymentError,
} )
);

const event = { paymentFailed: jest.fn() };
await act( async () => {
await result.current.onConfirm( event );
} );

const { abortPayment } = onConfirmHandler.mock.calls[ 0 ][ 0 ];
abortPayment( event, 'Order creation error', true );

expect( event.paymentFailed ).toHaveBeenCalledWith( {
reason: 'fail',
} );
expect( setExpressPaymentError ).toHaveBeenCalledWith(
'Order creation error'
);

// The message has to be in front of the shopper before the sheet closes.
expect(
setExpressPaymentError.mock.invocationCallOrder[ 0 ]
).toBeLessThan( event.paymentFailed.mock.invocationCallOrder[ 0 ] );
} );
it.each( [
[ 'plain message', 'Order creation error', true ],
[
'checkout link',
'Required field.\nPlease go to the <a href="https://example.com/checkout/" onclick="alert(1)">checkout page</a>.',
{ preserveLinks: true },
],
] )(
'fails the payment and shows the %s when the order errors',
async ( _name, message, options ) => {
onConfirmHandler.mockClear();
const setExpressPaymentError = jest.fn();

const { result } = renderHook( () =>
useExpressCheckout( {
api: {},
billing: {
currency: { minorUnit: 2 },
cartTotal: { value: 7500 },
cartTotalItems: [],
},
shippingData: { needsShipping: false, shippingRates: [] },
onClick: jest.fn(),
onClose: jest.fn(),
setExpressPaymentError,
} )
);

const event = { paymentFailed: jest.fn() };
await act( async () => {
await result.current.onConfirm( event );
} );

const { abortPayment } = onConfirmHandler.mock.calls[ 0 ][ 0 ];
abortPayment( event, message, options );

expect( event.paymentFailed ).toHaveBeenCalledWith( {
reason: 'fail',
} );
expect( setExpressPaymentError ).toHaveBeenCalledWith(
options.preserveLinks
? 'Required field.<br>Please go to the <a href="https://example.com/checkout/">checkout page</a>.'
: message
);

// The message has to be in front of the shopper before the sheet closes.
expect(
setExpressPaymentError.mock.invocationCallOrder[ 0 ]
).toBeLessThan( event.paymentFailed.mock.invocationCallOrder[ 0 ] );
}
);

// Blocks passes fresh billing/shippingData refs each cart tick; memoised
// outputs must stay stable so the Stripe element doesn't churn.
Expand Down
8 changes: 6 additions & 2 deletions client/blocks/express-checkout/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from 'wcstripe/express-checkout/event-handler';
import {
displayExpressCheckoutNotice,
formatExpressCheckoutNotice,
getExpressCheckoutButtonStyleSettings,
getExpressCheckoutData,
normalizeLineItems,
Expand Down Expand Up @@ -55,9 +56,12 @@ export const useExpressCheckout = ( {
}, [] );

const abortPayment = useCallback(
( onConfirmEvent, message ) => {
( onConfirmEvent, message, options = {} ) => {
// If we have a multiline message using newlines, replace them with <br>.
const formattedMessage = message.replace( /\n/g, '<br>' );
const formattedMessage =
options?.preserveLinks === true
? formatExpressCheckoutNotice( message, true )
: message.replace( /\n/g, '<br>' );
setExpressPaymentError( formattedMessage );

onAbortPaymentHandler( onConfirmEvent, message );
Expand Down
84 changes: 51 additions & 33 deletions client/entrypoints/express-checkout/__tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -515,38 +515,56 @@ describe( 'Express Checkout order failures', () => {
// The order-side abort used to skip paymentFailed(), leaving the approved wallet
// sheet open with nothing on screen. The third argument is the removed
// `isOrderError` opt-out: passing it must change nothing.
it( 'fails the wallet sheet and shows the message when the order errors', async () => {
const handlers = stubStripeButton();
loadEntrypoint();

// Resolve the mocks from the same module registry the entrypoint loaded from;
// `jest.resetModules()` hands each test its own copy.
// eslint-disable-next-line global-require
const jq = require( 'jquery' );
const {
onAbortPaymentHandler,
onConfirmHandler,
it.each( [
[ 'plain message', 'Order creation error', true ],
[
'checkout link',
'Required field.\nPlease go to the <a href="https://example.com/checkout/">checkout page</a>.',
{ preserveLinks: true },
],
] )(
'fails the wallet sheet and shows the %s when the order errors',
async ( _name, message, options ) => {
const handlers = stubStripeButton();
loadEntrypoint();

// Resolve the mocks from the same module registry the entrypoint loaded from;
// `jest.resetModules()` hands each test its own copy.
// eslint-disable-next-line global-require
} = require( 'wcstripe/express-checkout/event-handler' );

jq( document.body ).trigger( 'updated_checkout' );

const event = { paymentFailed: jest.fn() };
await handlers.confirm( event );

const { abortPayment } = onConfirmHandler.mock.calls[ 0 ][ 0 ];
abortPayment( event, 'Order creation error', true );

expect( event.paymentFailed ).toHaveBeenCalledWith( {
reason: 'fail',
} );
expect(
document.querySelector( '.woocommerce-error' ).textContent
).toBe( 'Order creation error' );

// The message has to be in front of the shopper before the sheet closes.
expect(
onAbortPaymentHandler.mock.invocationCallOrder[ 0 ]
).toBeLessThan( event.paymentFailed.mock.invocationCallOrder[ 0 ] );
} );
const jq = require( 'jquery' );
const {
onAbortPaymentHandler,
onConfirmHandler,
// eslint-disable-next-line global-require
} = require( 'wcstripe/express-checkout/event-handler' );

jq( document.body ).trigger( 'updated_checkout' );

const event = { paymentFailed: jest.fn() };
await handlers.confirm( event );

const { abortPayment } = onConfirmHandler.mock.calls[ 0 ][ 0 ];
abortPayment( event, message, options );

expect( event.paymentFailed ).toHaveBeenCalledWith( {
reason: 'fail',
} );
const notice = document.querySelector( '.woocommerce-error' );
expect(
notice.querySelector( 'a' )?.getAttribute( 'href' ) ?? null
).toBe(
options.preserveLinks ? 'https://example.com/checkout/' : null
);
expect( notice.textContent ).toBe(
options.preserveLinks
? 'Required field.Please go to the checkout page.'
: message
);

// The message has to be in front of the shopper before the sheet closes.
expect(
onAbortPaymentHandler.mock.invocationCallOrder[ 0 ]
).toBeLessThan( event.paymentFailed.mock.invocationCallOrder[ 0 ] );
}
);
} );
10 changes: 8 additions & 2 deletions client/entrypoints/express-checkout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -872,10 +872,16 @@ jQuery( function ( $ ) {
*
* @param {PaymentResponse} payment Payment response instance.
* @param {string} message Error message to display.
* @param {Object} options Optional link formatting.
*/
abortPayment: ( payment, message ) => {
abortPayment: ( payment, message, options = {} ) => {
onAbortPaymentHandler( payment, message );
displayExpressCheckoutNotice( message, 'error' );
displayExpressCheckoutNotice(
message,
'error',
undefined,
options
);

// The wallet sheet only closes once the confirm event gets a terminal
// result, so order errors must fail it too. A late call rejects an
Expand Down
18 changes: 18 additions & 0 deletions client/express-checkout/__tests__/payment-flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,24 @@ describe( 'address normalization', () => {
...params,
} );

test( 'preserves the checkout link on missing-required-field errors', async () => {
const message =
'Custom reference is required.\nPlease go to the <a href="https://example.com/store/checkout/">checkout page</a>, fill in the required fields, and complete your order from there.';
api.expressCheckoutECECreateOrder.mockRejectedValue( {
code: 'wc_stripe_express_checkout_missing_required_fields',
message,
} );

await flow();

expect( abortPayment ).toHaveBeenCalledWith(
expect.objectContaining( { expressPaymentType } ),
message,
{ preserveLinks: true }
);
expect( completePayment ).not.toHaveBeenCalled();
} );

test.each( [
[ 'null', null ],
[
Expand Down
12 changes: 12 additions & 0 deletions client/express-checkout/payment-flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ const handlePaymentFlowException = ( event, exception, abortPayment ) => {
}
}

if (
exception.code === 'wc_stripe_express_checkout_missing_required_fields'
) {
return abortPayment(
event,
getExpressCheckoutErrorMessage( errorMessage ),
{
preserveLinks: true,
}
);
}

return abortPayment(
event,
getExpressCheckoutErrorMessage(
Expand Down
63 changes: 63 additions & 0 deletions client/express-checkout/utils/__tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { screen, render } from '@testing-library/react';
import {
displayExpressCheckoutNotice,
formatExpressCheckoutNotice,
getErrorMessageFromNotice,
getExpressCheckoutButtonStyleSettings,
getExpressCheckoutData,
Expand Down Expand Up @@ -62,6 +63,7 @@ describe( 'Express checkout utils', () => {
describe( 'displayExpressCheckoutNotice', () => {
afterEach( () => {
document.getElementsByTagName( 'body' )[ 0 ].innerHTML = '';
window.wc_stripe_express_checkout_params = {};
} );

const additionalClasses = [ 'class-2', 'class-3' ];
Expand All @@ -71,6 +73,35 @@ describe( 'Express checkout utils', () => {
document.body.appendChild( wrapper );
};

test.each( [ false, true ] )(
'renders a checkout link with block layout %s',
( hasBlock ) => {
window.wc_stripe_express_checkout_params = {
has_block: hasBlock,
};
document.body.innerHTML =
'<div class="woocommerce-notices-wrapper wc-block-components-main"></div>';
displayExpressCheckoutNotice(
'Custom reference is required.\nPlease go to the <a href="https://example.com/store/checkout/">checkout page</a>, fill in the required fields, and complete your order from there.',
'error',
undefined,
{ preserveLinks: true }
);
expect(
screen.getByRole( 'link', { name: 'checkout page' } )
).toHaveAttribute(
'href',
'https://example.com/store/checkout/'
);
expect( screen.getByRole( 'note' ) ).toHaveTextContent(
'Custom reference is required.'
);
expect( screen.getByRole( 'note' ) ).toHaveTextContent(
'complete your order from there.'
);
}
);

test( 'with info', async () => {
function App() {
createWrapper();
Expand Down Expand Up @@ -119,6 +150,38 @@ describe( 'Express checkout utils', () => {
} );
} );

describe( 'formatExpressCheckoutNotice', () => {
test( 'escapes links unless explicitly enabled', () => {
expect(
formatExpressCheckoutNotice(
'<a href="https://example.com/checkout/">checkout</a>'
)
).toBe(
'&lt;a href="https://example.com/checkout/"&gt;checkout&lt;/a&gt;'
);
} );

test( 'preserves only safe links and text', () => {
const message =
'<strong>Required field</strong>\n<a href="https://example.com/checkout/?one=1&amp;two=2" onclick="alert(1)"><em>checkout page</em></a><img src=x onerror="alert(1)"><a href="javascript:alert(1)">unsafe</a><script>alert(1)</script>';
const container = document.createElement( 'div' );
container.innerHTML = formatExpressCheckoutNotice( message, true );
expect( container.querySelectorAll( 'a' ) ).toHaveLength( 1 );
expect( container.querySelector( 'a' ).outerHTML ).toBe(
'<a href="https://example.com/checkout/?one=1&amp;two=2">checkout page</a>'
);
expect(
container.querySelector(
'img, script, strong, em, [onclick], [onerror]'
)
).toBeNull();
expect( container.textContent ).toBe(
'Required fieldcheckout pageunsafealert(1)'
);
expect( container.querySelector( 'br' ) ).not.toBeNull();
} );
} );

describe( 'getPaymentMethodTypesForExpressMethod', () => {
test( 'default', () => {
const paymentMethodTypes =
Expand Down
Loading
Loading