Commit 8c108e16191 for woocommerce

commit 8c108e161917c98b02ff9269f42cb73b33bb5ee8
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Wed Oct 21 18:07:30 2026 +0200

    Add a filter for the default payment method in the Checkout block (#69786)

    * Add a preselect-payment-method option and expose it to the Checkout block

    * Stop the Checkout block preselecting a payment method when the option is off

    * Ask shoppers to select a payment method before placing a block checkout order

    * Keep only the shopper's own payment choice when preselection is off

    * Add the payment method preselection checkbox to the General settings tab

    * Add a filter that decides which payment method the Checkout block starts with

    woocommerce_default_payment_method receives the gateway WooCommerce would
    preselect, the customer's default saved card's gateway or else the first
    available one, and the gateways available for the cart. A callback returns a
    gateway id to preselect or an empty string to select nothing; anything else
    counts as nothing. The shopper's own choice from the session always wins and
    never goes through the filter. Without a callback the existing order is
    untouched.

    * Forget the shopper's payment choice after a purchase only on stores that use the filter

    Stores with a callback on woocommerce_checkout_default_payment_method don't
    want the last-used gateway coming back on the next purchase, so the session
    choice is dropped when the cart becomes empty. Without a callback nothing
    changes.

    * Leave the Checkout block without a payment method when the server preselected none

    An empty hydrated payment method now stays empty instead of the block picking
    the first available method or a saved card. Until now that only happened on
    stores with no gateway enabled, where there was nothing to pick anyway. Without
    a server answer, as in the editor, the fallback still runs.

    * Ask for a payment method with an inline validation error instead of a notice

    On stores where a filter decides the starting method, the payment section
    registers a hidden validation error while nothing is selected, the same way
    the terms checkbox does. Place order reveals it inside Payment options with
    the other field errors, and picking a method clears it. The notice-based
    check, its observer helper, the notice id and the thunk change it needed are
    gone.

    * Drop the preselect-payment-method setting in favour of the filter

    The General settings checkbox, its option and the helper that read it are
    gone; a store that wants no preselection attaches a callback to the filter
    instead. The class that forgets the session choice is renamed from
    PaymentMethodPreselection to DefaultPaymentMethod, after the filter it serves.

    * Document the default payment method filter

    A dev-docs page explains the filter's contract with the Finnish and Swedish
    callbacks as examples, the generated hook reference picks it up, and the
    changelog entry describes the filter instead of the setting.

    * Scope the empty server answer to its own Jest describe

    The module mock set payment_method to an empty string for the whole file,
    which forced edits to the two trunk tests. The describe sets it in a
    beforeEach instead, the two fallback cases move out of it, and the case for
    the early return that trunk already covers is gone.

    * Run the filter last instead of branching on whether a callback exists

    PaymentUtils keeps its existing order, saved card, then session, then first
    enabled gateway, and applies woocommerce_default_payment_method to that
    answer. A changed answer must be an available gateway; an unchanged one passes
    through as before, so stores without a callback behave exactly as on trunk.
    CheckoutSchema goes back to trunk, since it no longer needs to know about the
    filter.

    * Leave the shopper's payment choice alone after a purchase

    The class that cleared the session choice when the cart emptied is gone,
    with its test and registration. The choice the shopper made is theirs to
    keep, as on every other store. A store that must have the shopper choose
    anew for each purchase can clear it on the cart hooks; the docs page shows
    how, including the last-item-removed case that woocommerce_cart_emptied
    does not cover.

    * Keep pushing order notes and fields while no payment method is selected

    push-changes.ts returned early on an empty active payment method, so notes
    and additional fields typed before picking a method never reached the
    session. The Store API already treats a missing payment_method as no change,
    so the early return goes and payment_method is sent only when one is set.

    * Turn the payment radio circles red while the shopper is asked to pick one

    While "Please select a payment method." shows, the radio circles take the
    alert red, as the border of a text field in error does. The list border and
    the labels keep their colour.

    * Keep the shopper's payment choice on the draft order when the cart changes

    After a failed payment, a cart change syncs the draft order and wrote the
    filtered default as its payment method. On a store whose callback returns
    nothing, that blanked the method, and the next checkout load, built from the
    draft order, started with nothing selected. The sync now uses the same
    rule as the draft response, the session choice while its gateway is enabled,
    then the default, through one PaymentUtils helper both call.

    For stores without a callback this changes one case: a customer with a
    default saved card who picked another method keeps that pick on the draft
    order instead of having the saved card written over it.

    * Mark the default payment method filter as new in 11.4.0

    The PR will not make 11.3.0.

    * Keep the shopper's payment choice only while the cart can still use it

    The checkout carried a remembered choice while its gateway was enabled, which
    says nothing about the current cart. Cash on delivery stays enabled after the
    shopper switches to a shipping method it does not allow, so the choice was
    hydrated, the block could not find it in its list, and the old fallback
    ticked the first method on a store whose callback asked for none.

    The check is now against the gateways available for the cart. A choice the
    cart cannot use falls through to the default and the filter decides. Stores
    without a callback see no difference: the default still carries the raw
    session choice, so the block falls back as before.

diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/checkout-payment-methods/checkout-flow-and-events.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/checkout-payment-methods/checkout-flow-and-events.md
index b14141645fc..5bf19a8b75d 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/checkout-payment-methods/checkout-flow-and-events.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/checkout-payment-methods/checkout-flow-and-events.md
@@ -205,7 +205,7 @@ import {

 The helper functions are described below:

--   `isSuccessResponse`, `isErrorResponse` and `isFailResponse`: These are helper functions that receive a value and report via boolean whether the object is a type of response expected. For event emitters that receive responses from registered observers, a `type` property on the returned object from the observer indicates what type of response it is and event emitters will react according to that type. So for instance if an observer returned `{ type: 'success' }` the emitter could feed that to `isSuccessResponse` and it would return `true`. You can see an example of this being implemented for the [payment processing emitted event here](https://github.com/woocommerce/woocommerce/blob/df02d62e2d41e9007da44cb87fea2b7a9551f55c/plugins/woocommerce/client/blocks/packages/public-api/types/type-guards/observers.ts#L41-L65).
+-   `isSuccessResponse`, `isErrorResponse` and `isFailResponse`: These are helper functions that receive a value and report via boolean whether the object is a type of response expected. For event emitters that receive responses from registered observers, a `type` property on the returned object from the observer indicates what type of response it is and event emitters will react according to that type. So for instance if an observer returned `{ type: 'success' }` the emitter could feed that to `isSuccessResponse` and it would return `true`. You can see an example of this being implemented for the [payment processing emitted event here](https://github.com/woocommerce/woocommerce/blob/b311f5a6c3594bac6b939be7183c87d42fe32fc6/plugins/woocommerce/client/blocks/packages/public-api/types/type-guards/observers.ts#L41-L65).
 -   `noticeContexts`: This is an object containing properties referencing areas where notices can be targeted in the checkout. The object has the following properties:
     -   `PAYMENTS`: This is a reference to the notice area in the payment methods step.
     -   `EXPRESS_PAYMENTS`: This is a reference to the notice area in the express payment methods step.
@@ -228,6 +228,7 @@ The following event emitters are available to extensions to register observers t
 Observers registered to this event emitter will receive nothing as an argument. Also, all observers will be executed before the checkout handles the responses from the emitters. Observers registered to this emitter can return `true` if they have nothing to communicate back to checkout, `false` if they want checkout to go back to `IDLE` status state, or an object with any of the following properties:

 -   `errorMessage`: This will be added as an error notice on the checkout context.
+-   `context`: The notice context to show `errorMessage` in, for example `noticeContexts.PAYMENTS`. Defaults to `wc/checkout`.
 -   `validationErrors`: This will be set as inline validation errors on checkout fields. If your observer wants to trigger validation errors it can use the following shape for the errors:
     -   This is an object where keys are the property names the validation error is for (that correspond to a checkout field, eg `country` or `coupon`) and values are the error message describing the validation problem.

diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/checkout-payment-methods/default-payment-method.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/checkout-payment-methods/default-payment-method.md
new file mode 100644
index 00000000000..ed36a023096
--- /dev/null
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/checkout-payment-methods/default-payment-method.md
@@ -0,0 +1,91 @@
+---
+post_title: Choosing the default payment method
+sidebar_label: Default payment method
+
+---
+
+# Choosing the default payment method
+
+By default, the Checkout block loads with a payment method already selected. WooCommerce picks the first of these that exists: the customer's default saved card, the method they picked earlier in the same session, or the first enabled gateway. Some stores have requirements on whether a method may be preselected, or on which kind: for example, that the shopper must choose one themselves, or that a card is never selected for them.
+
+The `woocommerce_default_payment_method` filter lets a store decide what the Checkout block starts with when the shopper hasn't picked a method yet. It works with gateway ids. It is not the default saved card a customer picks in My Account: that card only decides which gateway WooCommerce would otherwise preselect.
+
+## The filter
+
+```php
+apply_filters( 'woocommerce_default_payment_method', $payment_method, $available_gateways );
+```
+
+| Argument | Type | Description |
+| --- | --- | --- |
+| `$payment_method` | `string` | The gateway id WooCommerce would preselect: the customer's default saved card's gateway, the method remembered in the session, or the first enabled gateway, or `''` when there is none. |
+| `$available_gateways` | `array` | The gateways available for the current cart, keyed by gateway id. |
+
+Return a gateway id from `$available_gateways` to preselect it, or an empty string to select nothing. Any other value selects nothing. Returning `$payment_method` unchanged keeps WooCommerce's answer as it is.
+
+Attach the callback by `init` at the latest, so it is in place for every Store API checkout request.
+
+## What changes for shoppers
+
+Once a callback is attached:
+
+- The Checkout block keeps the shopper's own choice while that gateway is enabled, and it survives a reload. The filter decides what the block starts with before they choose.
+- The block follows the server. When the filter returns nothing, nothing is selected, and the block doesn't fall back to the first method. When it returns a gateway, that gateway is selected as it would be today, including the customer's default saved card for it.
+
+Without a callback, nothing changes.
+
+## Examples
+
+Never preselect a payment method:
+
+```php
+add_filter( 'woocommerce_default_payment_method', '__return_empty_string' );
+```
+
+Never preselect a credit card. WooCommerce has no "is a credit card" flag on gateways, so the callback names the card gateway. A saved card is always a card, and the customer's default one is what WooCommerce would preselect, so the callback checks for it too:
+
+```php
+add_filter(
+	'woocommerce_default_payment_method',
+	function ( $payment_method ) {
+		if ( 'stripe' === $payment_method ) {
+			return '';
+		}
+		$default_token = WC_Payment_Tokens::get_customer_default_token( get_current_user_id() );
+		if ( $default_token && $default_token->get_gateway_id() === $payment_method ) {
+			return '';
+		}
+		return $payment_method;
+	}
+);
+```
+
+## Forgetting the choice after each purchase
+
+WooCommerce remembers the shopper's choice in the session until it expires, so their next purchase in the same session starts with it. A store that must have the shopper choose anew for each purchase can clear it when the cart becomes empty. Both hooks are needed: removing the last item by hand doesn't fire `woocommerce_cart_emptied`.
+
+```php
+add_action( 'woocommerce_cart_emptied', 'myshop_forget_payment_choice' );
+add_action(
+	'woocommerce_cart_item_removed',
+	function ( $cart_item_key, $cart ) {
+		if ( $cart->is_empty() ) {
+			myshop_forget_payment_choice();
+		}
+	},
+	10,
+	2
+);
+
+function myshop_forget_payment_choice() {
+	if ( WC()->session instanceof WC_Session ) {
+		WC()->session->set( 'chosen_payment_method', null );
+	}
+}
+```
+
+## Things to keep in mind
+
+- The callback must give the same answer for the same shopper on every request. The checkout page and the Store API both ask it.
+- Logic based on the shopper's IP address needs the checkout page excluded from full-page caching, or one visitor's answer is served to everyone.
+- The filter only covers the Checkout block. The classic checkout shortcode and the pay-for-order page still preselect a gateway.
diff --git a/plugins/woocommerce/changelog/wooplug-6902-preselect-payment-method-block b/plugins/woocommerce/changelog/wooplug-6902-preselect-payment-method-block
new file mode 100644
index 00000000000..5b3ed36c259
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6902-preselect-payment-method-block
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a filter that lets stores choose which payment method the Checkout block preselects, or none.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-required-error.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-required-error.tsx
new file mode 100644
index 00000000000..b54ab6a6e38
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-required-error.tsx
@@ -0,0 +1,71 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useEffect } from '@wordpress/element';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { paymentStore, validationStore } from '@woocommerce/block-data';
+import { ValidationInputError } from '@woocommerce/blocks-components';
+import type { ReactNode } from 'react';
+
+const ERROR_ID = 'payment-method';
+
+/**
+ * Asks the shopper to select a payment method when the server preselected nothing.
+ *
+ * The error is registered only while nothing is selected and stays hidden until Place order
+ * reveals it with the other field errors. The `has-error` wrapper lets the checkout focus the
+ * first option, and the message sits below the list so it stays in view after that focus.
+ */
+const PaymentMethodRequiredError = ( {
+	children,
+}: {
+	children: ReactNode;
+} ): JSX.Element => {
+	const { paymentMethodRequired, visibleMessage } = useSelect( ( select ) => {
+		const payment = select( paymentStore );
+		const error = select( validationStore ).getValidationError( ERROR_ID );
+		return {
+			paymentMethodRequired:
+				payment.paymentMethodsInitialized() &&
+				Object.keys( payment.getAvailablePaymentMethods() ).length >
+					0 &&
+				! payment.getActivePaymentMethod(),
+			// A string, so the component re-renders when the error is revealed.
+			visibleMessage: error && ! error.hidden ? error.message : '',
+		};
+	}, [] );
+	const { setValidationErrors, clearValidationError } =
+		useDispatch( validationStore );
+
+	useEffect( () => {
+		if ( paymentMethodRequired ) {
+			void setValidationErrors( {
+				[ ERROR_ID ]: {
+					message: __(
+						'Please select a payment method.',
+						'woocommerce'
+					),
+					hidden: true,
+				},
+			} );
+		}
+		return () => {
+			void clearValidationError( ERROR_ID );
+		};
+	}, [ paymentMethodRequired, setValidationErrors, clearValidationError ] );
+
+	// The wrapper is always there so the options don't remount when the error comes and goes.
+	return (
+		<>
+			<div className={ visibleMessage ? 'has-error' : undefined }>
+				{ children }
+			</div>
+			{ visibleMessage && (
+				<ValidationInputError errorMessage={ visibleMessage } />
+			) }
+		</>
+	);
+};
+
+export default PaymentMethodRequiredError;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-methods.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-methods.tsx
index 075e9c04986..deae75006da 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-methods.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/payment-methods.tsx
@@ -14,6 +14,7 @@ import { DelayedContentWithSkeleton } from '@woocommerce/base-components/delayed
 import NoPaymentMethods from './no-payment-methods';
 import OnlyExpressPayments from './only-express-payments';
 import PaymentMethodOptions from './payment-method-options';
+import PaymentMethodRequiredError from './payment-method-required-error';
 import SavedPaymentMethodOptions from './saved-payment-method-options';

 import './style-legacy.scss';
@@ -94,7 +95,9 @@ const PaymentMethods = ( {
 					} }
 				/>
 			) }
-			<PaymentMethodOptions />
+			<PaymentMethodRequiredError>
+				<PaymentMethodOptions />
+			</PaymentMethodRequiredError>
 		</DelayedContentWithSkeleton>
 	);
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/style.scss
index dc5e546e520..7ad03d7bec5 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/style.scss
@@ -45,6 +45,15 @@
 		padding-top: $gap-small;
 		padding-bottom: $gap-smaller;
 	}
+
+	// The radio circles turn red while the error shows.
+	.has-error .wc-block-components-radio-control__input {
+		border-color: $alert-red;
+
+		.has-dark-controls & {
+			border-color: color.adjust($alert-red, $lightness: 30%);
+		}
+	}
 }

 // Default size and alignment for payment method label images (e.g. gateway
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/payment-method-required.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/payment-method-required.tsx
new file mode 100644
index 00000000000..185b9901fcc
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart-checkout-shared/payment-methods/test/payment-method-required.tsx
@@ -0,0 +1,189 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { dispatch, select } from '@wordpress/data';
+import {
+	paymentStore,
+	cartStore,
+	validationStore,
+} from '@woocommerce/block-data';
+import { previewCart } from '@woocommerce/resource-previews';
+import {
+	registerPaymentMethod,
+	registerExpressPaymentMethod,
+	__experimentalDeRegisterPaymentMethod,
+	__experimentalDeRegisterExpressPaymentMethod,
+} from '@woocommerce/blocks-registry';
+
+/**
+ * Internal dependencies
+ */
+import PaymentMethods from '../payment-methods';
+
+const METHODS = [ 'cod', 'bacs' ];
+const EXPRESS_METHOD = 'dummy-express';
+const ERROR_ID = 'payment-method';
+const MESSAGE = 'Please select a payment method.';
+
+// A component, not a bare element, so the payment method interface props cloned onto it don't land on a DOM node.
+const PaymentContent = () => <div>A payment method</div>;
+
+/**
+ * Registers two gateways that can pay, lets the store initialise, then leaves nothing selected,
+ * which is the state a shopper sees when the server decided to preselect nothing.
+ */
+const seedStoreWithNothingSelected = async ( {
+	withExpress = false,
+}: { withExpress?: boolean } = {} ) => {
+	METHODS.forEach( ( name ) => {
+		registerPaymentMethod( {
+			name,
+			label: name,
+			content: <PaymentContent />,
+			edit: <PaymentContent />,
+			icons: null,
+			canMakePayment: () => true,
+			supports: { features: [ 'products' ] },
+			ariaLabel: name,
+		} );
+	} );
+	if ( withExpress ) {
+		registerExpressPaymentMethod( {
+			name: EXPRESS_METHOD,
+			title: 'Dummy express',
+			description: 'Dummy express',
+			gatewayId: EXPRESS_METHOD,
+			content: <PaymentContent />,
+			edit: <PaymentContent />,
+			canMakePayment: () => true,
+			paymentMethodId: EXPRESS_METHOD,
+			supports: { features: [ 'products' ] },
+		} );
+	}
+	await act( async () => {
+		dispatch( cartStore ).receiveCart( previewCart );
+		await dispatch(
+			paymentStore
+		).__internalUpdateAvailablePaymentMethods();
+		dispatch( paymentStore ).__internalSetActivePaymentMethod( '' );
+	} );
+};
+
+const getError = () => select( validationStore ).getValidationError( ERROR_ID );
+
+describe( 'PaymentMethods: asking the shopper to select a payment method', () => {
+	afterEach( () => {
+		// The component is still mounted here, so these store updates need act().
+		act( () => {
+			METHODS.forEach( ( name ) =>
+				__experimentalDeRegisterPaymentMethod( name )
+			);
+			__experimentalDeRegisterExpressPaymentMethod( EXPRESS_METHOD );
+			dispatch( validationStore ).clearValidationError( ERROR_ID );
+		} );
+	} );
+
+	it( 'registers a hidden error while nothing is selected', async () => {
+		await seedStoreWithNothingSelected();
+
+		render( <PaymentMethods /> );
+
+		await waitFor( () =>
+			expect( getError() ).toEqual( { message: MESSAGE, hidden: true } )
+		);
+		expect( screen.queryByText( MESSAGE ) ).not.toBeInTheDocument();
+	} );
+
+	it( 'shows the error inline once all errors are revealed', async () => {
+		await seedStoreWithNothingSelected();
+
+		render( <PaymentMethods /> );
+		await waitFor( () => expect( getError() ).toBeDefined() );
+		const firstOption = screen.getByRole( 'radio', {
+			name: ( name ) => name.startsWith( 'cod' ),
+		} );
+		expect( firstOption.closest( '.has-error' ) ).toBeNull();
+
+		act( () => {
+			dispatch( validationStore ).showAllValidationErrors();
+		} );
+
+		expect( await screen.findByRole( 'alert' ) ).toHaveTextContent(
+			MESSAGE
+		);
+		// The checkout focuses the first input inside `.has-error` after Place order, as it does for a field.
+		expect( firstOption.closest( '.has-error' ) ).not.toBeNull();
+	} );
+
+	it( 'clears the error when the shopper picks a method', async () => {
+		await seedStoreWithNothingSelected();
+
+		render( <PaymentMethods /> );
+		await waitFor( () => expect( getError() ).toBeDefined() );
+
+		// eslint-disable-next-line testing-library/no-unnecessary-act -- The store updates the click triggers settle after the event, so act() keeps React quiet.
+		await act( async () => {
+			await userEvent.click(
+				screen.getByRole( 'radio', {
+					name: ( name ) => name.startsWith( 'bacs' ),
+				} )
+			);
+		} );
+
+		await waitFor( () => expect( getError() ).toBeUndefined() );
+		expect( select( paymentStore ).getActivePaymentMethod() ).toBe(
+			'bacs'
+		);
+	} );
+
+	it( 'registers nothing while a method is selected', async () => {
+		await seedStoreWithNothingSelected();
+		act( () => {
+			dispatch( paymentStore ).__internalSetActivePaymentMethod( 'bacs' );
+		} );
+
+		render( <PaymentMethods /> );
+		await screen.findByRole( 'radio', {
+			name: ( name ) => name.startsWith( 'bacs' ),
+		} );
+
+		expect( getError() ).toBeUndefined();
+		expect(
+			screen
+				.getByRole( 'radio', {
+					name: ( name ) => name.startsWith( 'cod' ),
+				} )
+				.closest( '.has-error' )
+		).toBeNull();
+	} );
+
+	it( 'does not register the error while an express method is active', async () => {
+		await seedStoreWithNothingSelected( { withExpress: true } );
+		act( () => {
+			dispatch( paymentStore ).__internalSetActivePaymentMethod(
+				EXPRESS_METHOD
+			);
+		} );
+
+		render( <PaymentMethods /> );
+		await waitFor( () =>
+			expect(
+				select( paymentStore ).isExpressPaymentMethodActive()
+			).toBe( true )
+		);
+
+		expect( getError() ).toBeUndefined();
+	} );
+
+	it( 'clears the error on unmount', async () => {
+		await seedStoreWithNothingSelected();
+
+		const { unmount } = render( <PaymentMethods /> );
+		await waitFor( () => expect( getError() ).toBeDefined() );
+		unmount();
+
+		expect( getError() ).toBeUndefined();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index 7ddd6a3128b..573b38260f2 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -36,6 +36,7 @@
 - [woocommerce_cart_contents_changed](#woocommerce_cart_contents_changed)
 - [woocommerce_cart_item_permalink](#woocommerce_cart_item_permalink)
 - [woocommerce_checkout_must_be_logged_in_message](#woocommerce_checkout_must_be_logged_in_message)
+- [woocommerce_default_payment_method](#woocommerce_default_payment_method)
 - [woocommerce_delete_expired_draft_orders_batch_size](#woocommerce_delete_expired_draft_orders_batch_size)
 - [woocommerce_disable_compatibility_layer](#woocommerce_disable_compatibility_layer)
 - [woocommerce_filter_fields_for_order_confirmation](#woocommerce_filter_fields_for_order_confirmation)
@@ -889,6 +890,32 @@ apply_filters( 'woocommerce_checkout_must_be_logged_in_message', string $message

 ---

+## woocommerce_default_payment_method
+
+
+Filters the payment method the Checkout block starts with.
+
+```php
+apply_filters( 'woocommerce_default_payment_method', string $payment_method, array $available_gateways )
+```
+
+### Description
+
+Return a gateway id from `$available_gateways` to preselect it, or an empty string to select nothing; any other value selects nothing. Attach the callback by `init` at the latest.
+
+### Parameters
+
+| Argument | Type | Description |
+| -------- | ---- | ----------- |
+| $payment_method | string | Gateway id WooCommerce would preselect: the customer's default saved card's gateway, the method remembered in the session, or the first enabled gateway; '' when there is none. |
+| $available_gateways | array | Gateways available for this cart, keyed by id. |
+
+### Source
+
+- [StoreApi/Utilities/PaymentUtils.php](../../../../../../src/StoreApi/Utilities/PaymentUtils.php)
+
+---
+
 ## woocommerce_delete_expired_draft_orders_batch_size


diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/checkout/push-changes.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/checkout/push-changes.ts
index baf52a0cd05..bf018284aad 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/checkout/push-changes.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/checkout/push-changes.ts
@@ -89,11 +89,6 @@ const updateCheckoutData = (): void => {
 		activePaymentMethod: paymentStore.getActivePaymentMethod(),
 	};

-	// Don't push changes if the active payment method is empty
-	if ( newCheckoutData.activePaymentMethod === '' ) {
-		localState.doingPush = false;
-		return;
-	}
 	// Figure out which additional fields have changed and only send those to the server
 	const changedFields = Object.keys( newCheckoutData.additionalFields )
 		.filter( ( key ) => {
@@ -148,9 +143,11 @@ const updateCheckoutData = (): void => {
 		requestData.order_notes = newCheckoutData.orderNotes;
 	}

+	// With no method selected there is nothing to send; the server treats a missing payment_method as no change.
 	if (
+		newCheckoutData.activePaymentMethod !== '' &&
 		newCheckoutData.activePaymentMethod !==
-		localState.checkoutData.activePaymentMethod
+			localState.checkoutData.activePaymentMethod
 	) {
 		requestData.payment_method = newCheckoutData.activePaymentMethod;
 	}
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/checkout/test/push-changes.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/checkout/test/push-changes.ts
new file mode 100644
index 00000000000..44439085b19
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/checkout/test/push-changes.ts
@@ -0,0 +1,109 @@
+/**
+ * External dependencies
+ */
+import { select, dispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { pushChanges } from '../push-changes';
+import { STORE_KEY as CHECKOUT_STORE_KEY } from '../constants';
+import { STORE_KEY as PAYMENT_STORE_KEY } from '../../payment/constants';
+
+const originalSelect = jest.requireActual( '@wordpress/data' ).select;
+const originalDispatch = jest.requireActual( '@wordpress/data' ).dispatch;
+
+jest.mock( '@wordpress/data', () => {
+	return {
+		...jest.requireActual( '@wordpress/data' ),
+		select: jest.fn(),
+		dispatch: jest.fn(),
+	};
+} );
+
+// pushChanges only runs on a page with the Checkout block.
+jest.mock( '@woocommerce/settings', () => {
+	const actual = jest.requireActual( '@woocommerce/settings' );
+	return {
+		...actual,
+		getSetting: ( name: string, fallback: unknown ) =>
+			name === 'isCheckoutBlock'
+				? true
+				: actual.getSetting( name, fallback ),
+	};
+} );
+
+describe( 'pushChanges', () => {
+	const updateDraftOrder = jest.fn().mockResolvedValue( undefined );
+	const state = {
+		orderNotes: '',
+		activePaymentMethod: '',
+	};
+
+	const mockStores = () => {
+		( select as jest.Mock ).mockImplementation( ( storeName ) => {
+			const originalStore = originalSelect( storeName );
+			if ( storeName === CHECKOUT_STORE_KEY ) {
+				return {
+					...originalStore,
+					getOrderNotes: () => state.orderNotes,
+					getAdditionalFields: () => ( {} ),
+				};
+			}
+			if ( storeName === PAYMENT_STORE_KEY ) {
+				return {
+					...originalStore,
+					getActivePaymentMethod: () => state.activePaymentMethod,
+					isExpressPaymentStarted: () => false,
+				};
+			}
+			return originalStore;
+		} );
+		( dispatch as jest.Mock ).mockImplementation( ( storeName ) => {
+			const originalStore = originalDispatch( storeName );
+			if ( storeName === CHECKOUT_STORE_KEY ) {
+				return { ...originalStore, updateDraftOrder };
+			}
+			return originalStore;
+		} );
+	};
+
+	beforeAll( () => {
+		// pushChanges needs a cart session to push at all.
+		document.cookie = 'woocommerce_cart_hash=abc';
+		mockStores();
+		// The first call only records the starting data.
+		pushChanges( false );
+	} );
+
+	afterEach( () => {
+		updateDraftOrder.mockClear();
+	} );
+
+	it( 'sends order notes while no payment method is selected, without a payment_method', () => {
+		state.orderNotes = 'Leave at the door';
+
+		pushChanges( false );
+
+		expect( updateDraftOrder ).toHaveBeenCalledTimes( 1 );
+		expect( updateDraftOrder ).toHaveBeenCalledWith( {
+			order_notes: 'Leave at the door',
+		} );
+	} );
+
+	it( 'sends nothing when nothing changed', () => {
+		pushChanges( false );
+
+		expect( updateDraftOrder ).not.toHaveBeenCalled();
+	} );
+
+	it( 'sends the payment method once the shopper selects one', () => {
+		state.activePaymentMethod = 'cod';
+
+		pushChanges( false );
+
+		expect( updateDraftOrder ).toHaveBeenCalledWith( {
+			payment_method: 'cod',
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/payment/test/set-default-payment-method.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/payment/test/set-default-payment-method.ts
index cf05d2d99f9..6c047b0fc42 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/payment/test/set-default-payment-method.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/payment/test/set-default-payment-method.ts
@@ -10,6 +10,7 @@ import { setDefaultPaymentMethod } from '../utils/set-default-payment-method';
 import { PlainPaymentMethods } from '../../../types';
 import '../../checkout';
 import { store as paymentStore } from '..';
+import { checkoutData } from '../../checkout/constants';

 const originalSelect = jest.requireActual( '@wordpress/data' ).select;
 const originalDispatch = jest.requireActual( '@wordpress/data' ).dispatch;
@@ -28,8 +29,12 @@ jest.mock( '@woocommerce/utils', () => {
 	};
 } );

+// What the server hydrated as the checkout's payment method. Unset here, as in the editor.
+const hydratedCheckoutData = checkoutData as { payment_method?: string };
+
 describe( 'setDefaultPaymentMethod', () => {
 	afterEach( () => {
+		delete hydratedCheckoutData.payment_method;
 		jest.resetAllMocks();
 		jest.resetModules();
 	} );
@@ -43,6 +48,104 @@ describe( 'setDefaultPaymentMethod', () => {
 		},
 	};

+	const mockStores = ( {
+		activePaymentMethod = '',
+		savedPaymentMethods = {},
+	}: {
+		activePaymentMethod?: string;
+		savedPaymentMethods?: Record< string, unknown[] >;
+	} ) => {
+		const setActivePaymentMethodMock = jest.fn();
+		( select as jest.Mock ).mockImplementation( ( storeName ) => {
+			const originalStore = originalSelect( storeName );
+			if ( storeName === paymentStore ) {
+				return {
+					...originalStore,
+					getActivePaymentMethod: () => activePaymentMethod,
+					getAvailableExpressPaymentMethods: () => ( {} ),
+					getSavedPaymentMethods: () => savedPaymentMethods,
+				};
+			}
+			return originalStore;
+		} );
+		( dispatch as jest.Mock ).mockImplementation( ( storeName ) => {
+			const originalStore = originalDispatch( storeName );
+			if ( storeName === paymentStore ) {
+				return {
+					...originalStore,
+					__internalSetActivePaymentMethod:
+						setActivePaymentMethodMock,
+					__internalSetPaymentIdle: () => void 0,
+				};
+			}
+			return originalStore;
+		} );
+		return setActivePaymentMethodMock;
+	};
+
+	describe( 'when the server preselected nothing', () => {
+		beforeEach( () => {
+			hydratedCheckoutData.payment_method = '';
+		} );
+
+		it( 'leaves no payment method selected', async () => {
+			const setActivePaymentMethodMock = mockStores( {} );
+
+			await setDefaultPaymentMethod( paymentMethods );
+
+			expect( setActivePaymentMethodMock ).not.toHaveBeenCalled();
+		} );
+
+		it( 'does not select a saved payment method either', async () => {
+			const setActivePaymentMethodMock = mockStores( {
+				savedPaymentMethods: {
+					cc: [
+						{
+							method: { gateway: 'saved-method' },
+							is_default: true,
+							tokenId: 2,
+						},
+					],
+				},
+			} );
+
+			await setDefaultPaymentMethod( paymentMethods );
+
+			expect( setActivePaymentMethodMock ).not.toHaveBeenCalled();
+		} );
+
+		it( 'clears a selected method that is no longer available instead of picking another', async () => {
+			const setActivePaymentMethodMock = mockStores( {
+				activePaymentMethod: 'wc-payment-gateway-removed',
+			} );
+
+			await setDefaultPaymentMethod( paymentMethods );
+
+			expect( setActivePaymentMethodMock ).toHaveBeenCalledWith( '' );
+		} );
+	} );
+
+	it( 'still picks the first method when the server preselected one and the store lost it', async () => {
+		hydratedCheckoutData.payment_method = 'wc-payment-gateway-2';
+		const setActivePaymentMethodMock = mockStores( {} );
+
+		await setDefaultPaymentMethod( paymentMethods );
+
+		expect( setActivePaymentMethodMock ).toHaveBeenCalledWith(
+			'wc-payment-gateway-1'
+		);
+	} );
+
+	it( 'still picks the first method where the server gave no answer, as in the editor', async () => {
+		const setActivePaymentMethodMock = mockStores( {} );
+
+		await setDefaultPaymentMethod( paymentMethods );
+
+		expect( setActivePaymentMethodMock ).toHaveBeenCalledWith(
+			'wc-payment-gateway-1'
+		);
+	} );
+
 	it( 'correctly sets the first payment method in the list of available payment methods', async () => {
 		const setActivePaymentMethodMock = jest.fn();
 		( select as jest.Mock ).mockImplementation( ( storeName ) => {
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/payment/utils/set-default-payment-method.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/payment/utils/set-default-payment-method.ts
index 136a76fca48..cb17e0e3f20 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/payment/utils/set-default-payment-method.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/payment/utils/set-default-payment-method.ts
@@ -8,6 +8,7 @@ import { PlainPaymentMethods } from '@woocommerce/types';
  * Internal dependencies
  */
 import { store as paymentStore } from '../index';
+import { checkoutData } from '../../checkout/constants';

 export const setDefaultPaymentMethod = async (
 	paymentMethods: PlainPaymentMethods
@@ -30,6 +31,19 @@ export const setDefaultPaymentMethod = async (
 		return;
 	}

+	// When the server answered with no payment method, it decided to preselect nothing: never pick one
+	// here, and clear a selected method that the browser can no longer use rather than swap it for
+	// another. Without a server answer, as in the editor, the fallback below runs.
+	if ( checkoutData.payment_method === '' ) {
+		if ( activePaymentMethod ) {
+			void dispatch( paymentStore ).__internalSetPaymentIdle();
+			void dispatch( paymentStore ).__internalSetActivePaymentMethod(
+				''
+			);
+		}
+		return;
+	}
+
 	const savedPaymentMethods = select( paymentStore ).getSavedPaymentMethods();
 	const flatSavedPaymentMethods = Object.keys( savedPaymentMethods ).flatMap(
 		( type ) => savedPaymentMethods[ type ]
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
index 427dfc13910..46247731527 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/CheckoutSchema.php
@@ -261,12 +261,8 @@ class CheckoutSchema extends AbstractSchema {
 	 * @return array
 	 */
 	public function get_draft_response( \WC_Cart $cart, \WC_Customer $customer ) {
-		// Use the shopper's session-stored selection so a PATCH-time choice survives the next render — but only if the gateway is still enabled, since the slug can outlive the gateway (admin disables it, payment plugin deactivated).
-		$session_payment_method = (string) WC()->session->get( 'chosen_payment_method' );
-		$enabled_gateways       = PaymentUtils::get_enabled_payment_gateways();
-		$payment_method         = ( '' !== $session_payment_method && isset( $enabled_gateways[ $session_payment_method ] ) )
-			? $session_payment_method
-			: (string) PaymentUtils::get_default_payment_method();
+		// The shopper's session-stored selection survives the next render while its gateway is still available for the cart; see PaymentUtils.
+		$payment_method = PaymentUtils::get_checkout_payment_method();

 		return [
 			'order_id'           => 0,
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
index 0bfb0dbfc95..acd82dd422c 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
@@ -87,7 +87,7 @@ class OrderController {
 		$order->set_customer_id( get_current_user_id() );
 		$order->set_customer_ip_address( \WC_Geolocation::get_ip_address() );
 		$order->set_customer_user_agent( wc_get_user_agent() );
-		$order->set_payment_method( PaymentUtils::get_default_payment_method() );
+		$order->set_payment_method( PaymentUtils::get_checkout_payment_method() );
 		$order->update_meta_data( 'is_vat_exempt', wc_bool_to_string( wc()->cart->get_customer()->get_is_vat_exempt() ) );
 		$order->calculate_totals();
 	}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/PaymentUtils.php b/plugins/woocommerce/src/StoreApi/Utilities/PaymentUtils.php
index b6476e75171..81c0e5eec76 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/PaymentUtils.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/PaymentUtils.php
@@ -85,11 +85,63 @@ class PaymentUtils {
 	}

 	/**
-	 * Returns the default payment method for a customer.
+	 * Returns the payment method the checkout carries: the shopper's session choice while that gateway
+	 * is available for the cart, otherwise the default from get_default_payment_method(). The draft
+	 * response and the draft order sync both use this, so they agree.
+	 *
+	 * @return string
+	 */
+	public static function get_checkout_payment_method(): string {
+		$chosen_payment_method = (string) WC()->session->get( 'chosen_payment_method' );
+
+		if ( '' !== $chosen_payment_method && isset( WC()->payment_gateways()->get_available_payment_gateways()[ $chosen_payment_method ] ) ) {
+			return $chosen_payment_method;
+		}
+
+		return (string) self::get_default_payment_method();
+	}
+
+	/**
+	 * Returns the default payment method for a customer: the default saved card, then the method
+	 * remembered in the session, then the first enabled gateway. The `woocommerce_default_payment_method`
+	 * filter runs last and can replace that answer.
 	 *
 	 * @return string
 	 */
 	public static function get_default_payment_method() {
+		$payment_method     = self::get_unfiltered_default_payment_method();
+		$available_gateways = WC()->payment_gateways()->get_available_payment_gateways();
+
+		/**
+		 * Filters the payment method the Checkout block starts with.
+		 *
+		 * Return a gateway id from `$available_gateways` to preselect it, or an empty string to select nothing;
+		 * any other value selects nothing. Attach the callback by `init` at the latest.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param string $payment_method     Gateway id WooCommerce would preselect: the customer's default saved
+		 *                                   card's gateway, the method remembered in the session, or the first
+		 *                                   enabled gateway; '' when there is none.
+		 * @param array  $available_gateways Gateways available for this cart, keyed by id.
+		 */
+		$filtered = apply_filters( 'woocommerce_default_payment_method', $payment_method, $available_gateways );
+
+		// An unchanged answer passes through as before, even when that gateway isn't available for the cart;
+		// the block then falls back to the first method it can use. A callback's answer must be available.
+		if ( $filtered === $payment_method ) {
+			return $payment_method;
+		}
+
+		return is_string( $filtered ) && isset( $available_gateways[ $filtered ] ) ? $filtered : '';
+	}
+
+	/**
+	 * The default payment method before the filter: saved card, session, then the first enabled gateway.
+	 *
+	 * @return string
+	 */
+	private static function get_unfiltered_default_payment_method() {
 		$saved_payment_methods = self::get_saved_payment_methods();
 		// A saved payment method exists, set as default.
 		if ( $saved_payment_methods && ! empty( $saved_payment_methods['default'] ) ) {
@@ -110,8 +162,8 @@ class PaymentUtils {
 			return '';
 		}

-		$first_key                = array_key_first( $enabled_payment_gateways );
-		$first_payment_method     = $enabled_payment_gateways[ $first_key ];
+		$first_key            = array_key_first( $enabled_payment_gateways );
+		$first_payment_method = $enabled_payment_gateways[ $first_key ];
 		return $first_payment_method->id ?? '';
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
index 2a6dbb18d9e..136cb1e7b06 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
@@ -504,6 +504,57 @@ class OrderControllerTests extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox update_order_from_cart() keeps the shopper's session choice on the order even when the default payment method filter returns nothing.
+	 */
+	public function test_update_order_from_cart_keeps_the_session_payment_choice_with_a_callback(): void {
+		$order = $this->sync_order_from_cart_with_cod_enabled( 'cod' );
+
+		$this->assertSame( 'cod', $order->get_payment_method() );
+	}
+
+	/**
+	 * @testdox update_order_from_cart() writes the filtered default on the order when the shopper has not chosen a method.
+	 */
+	public function test_update_order_from_cart_writes_the_filtered_default_without_a_session_choice(): void {
+		$order = $this->sync_order_from_cart_with_cod_enabled( null );
+
+		$this->assertSame( '', $order->get_payment_method() );
+	}
+
+	/**
+	 * Sync an order from a one-item cart with COD enabled, the given session choice and a callback that returns nothing.
+	 *
+	 * Gateway instances and the session choice are restored afterwards; the base class does not reset them.
+	 *
+	 * @param string|null $chosen_payment_method What the shopper chose, or null for nothing.
+	 * @return \WC_Order
+	 */
+	private function sync_order_from_cart_with_cod_enabled( ?string $chosen_payment_method ): \WC_Order {
+		$gateways_before         = WC()->payment_gateways()->payment_gateways;
+		$cod_settings            = (array) get_option( 'woocommerce_cod_settings', array() );
+		$cod_settings['enabled'] = 'yes';
+		update_option( 'woocommerce_cod_settings', $cod_settings );
+		WC()->payment_gateways()->init();
+		WC()->session->set( 'chosen_payment_method', $chosen_payment_method );
+		add_filter( 'woocommerce_default_payment_method', '__return_empty_string' );
+
+		try {
+			WC()->cart->empty_cart();
+			WC()->cart->add_to_cart( WC_Helper_Product::create_simple_product()->get_id() );
+			$order = WC_Helper_Order::create_order();
+
+			$this->sut->update_order_from_cart( $order );
+
+			return $order;
+		} finally {
+			WC()->cart->empty_cart();
+			wp_cache_delete( 'woocommerce_cod_settings', 'options' );
+			WC()->payment_gateways()->payment_gateways = $gateways_before;
+			WC()->session->set( 'chosen_payment_method', null );
+		}
+	}
+
 	/**
 	 * Helper method to set shipping address on an order.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/PaymentUtilsTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/PaymentUtilsTest.php
new file mode 100644
index 00000000000..ffe720e3fa7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/PaymentUtilsTest.php
@@ -0,0 +1,306 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Utilities;
+
+use Automattic\WooCommerce\StoreApi\Utilities\PaymentUtils;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the PaymentUtils class.
+ */
+class PaymentUtilsTest extends WC_Unit_Test_Case {
+
+	const FILTER = 'woocommerce_default_payment_method';
+
+	/**
+	 * Gateway instances before the test; they cache their settings, which the rollback doesn't reset.
+	 *
+	 * @var array
+	 */
+	private $payment_gateways_before_test = array();
+
+	/**
+	 * Enable two gateways and remember the second one in the session, as a returning shopper would have.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->payment_gateways_before_test = WC()->payment_gateways()->payment_gateways;
+
+		foreach ( array( 'woocommerce_bacs_settings', 'woocommerce_cod_settings' ) as $option_name ) {
+			$settings            = (array) get_option( $option_name, array() );
+			$settings['enabled'] = 'yes';
+			update_option( $option_name, $settings );
+		}
+		WC()->payment_gateways()->init();
+
+		WC()->session->set( 'chosen_payment_method', 'cod' );
+	}
+
+	/**
+	 * Put back the in-memory state the base class doesn't reset: the gateway instances and the session choice.
+	 */
+	public function tearDown(): void {
+		try {
+			parent::tearDown();
+		} finally {
+			wp_cache_delete( 'woocommerce_bacs_settings', 'options' );
+			wp_cache_delete( 'woocommerce_cod_settings', 'options' );
+			WC()->payment_gateways()->payment_gateways = $this->payment_gateways_before_test;
+			WC()->session->set( 'chosen_payment_method', null );
+		}
+	}
+
+	/**
+	 * @testdox Should return the gateway remembered in the session without a callback, as before.
+	 */
+	public function test_default_payment_method_follows_trunk_without_a_callback(): void {
+		$this->assertSame( 'cod', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should prefer the customer's default saved card over the session without a callback, as before.
+	 */
+	public function test_default_payment_method_prefers_the_saved_card_without_a_callback(): void {
+		$this->create_default_saved_card( $this->login_as_role( 'customer' ), 'bacs' );
+
+		$this->assertSame( 'bacs', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should fall back to the first enabled gateway without a saved card or a session choice, as before.
+	 */
+	public function test_default_payment_method_falls_back_to_the_first_enabled_gateway_without_a_callback(): void {
+		WC()->session->set( 'chosen_payment_method', null );
+
+		$this->assertSame( 'bacs', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should keep an unavailable session choice without a callback, as before.
+	 */
+	public function test_default_payment_method_keeps_an_unavailable_session_choice_without_a_callback(): void {
+		add_filter( 'woocommerce_available_payment_gateways', array( $this, 'hide_cod' ) );
+
+		$this->assertSame( 'cod', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should return nothing when the callback returns nothing, even over a session choice.
+	 */
+	public function test_default_payment_method_is_empty_when_the_callback_returns_nothing(): void {
+		add_filter( self::FILTER, '__return_empty_string' );
+
+		$this->assertSame( '', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should return the available gateway the callback picks.
+	 */
+	public function test_default_payment_method_is_the_gateway_the_callback_picks(): void {
+		add_filter(
+			self::FILTER,
+			function () {
+				return 'bacs';
+			}
+		);
+
+		$this->assertSame( 'bacs', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should return nothing when the callback answers with something that isn't a gateway id.
+	 *
+	 * @testWith ["nope"]
+	 *           [null]
+	 *           [false]
+	 *           [0]
+	 *           [["bacs"]]
+	 *
+	 * @param mixed $answer What the callback returns.
+	 */
+	public function test_default_payment_method_returns_nothing_when_the_callback_answer_is_invalid( $answer ): void {
+		add_filter(
+			self::FILTER,
+			function () use ( $answer ) {
+				return $answer;
+			}
+		);
+
+		$this->assertSame( '', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should return nothing when the callback picks a gateway that is not enabled.
+	 */
+	public function test_default_payment_method_returns_nothing_when_the_callback_picks_a_disabled_gateway(): void {
+		add_filter(
+			self::FILTER,
+			function () {
+				return 'paypal';
+			}
+		);
+
+		$this->assertSame( '', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should return nothing when the callback picks a gateway that is enabled but not available for this cart.
+	 */
+	public function test_default_payment_method_returns_nothing_when_the_callback_picks_an_unavailable_gateway(): void {
+		WC()->session->set( 'chosen_payment_method', null );
+		add_filter( 'woocommerce_available_payment_gateways', array( $this, 'hide_cod' ) );
+		add_filter(
+			self::FILTER,
+			function () {
+				return 'cod';
+			}
+		);
+
+		$this->assertSame( '', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should pass an unchanged answer through even when that gateway is not available, as without a callback.
+	 */
+	public function test_default_payment_method_passes_an_unchanged_answer_through(): void {
+		add_filter( 'woocommerce_available_payment_gateways', array( $this, 'hide_cod' ) );
+		add_filter(
+			self::FILTER,
+			function ( $payment_method ) {
+				return $payment_method;
+			}
+		);
+
+		$this->assertSame( 'cod', PaymentUtils::get_default_payment_method() );
+	}
+
+	/**
+	 * @testdox Should pass the session choice and the available gateways to the callback.
+	 */
+	public function test_callback_receives_the_session_choice_and_the_available_gateways(): void {
+		$received = array();
+		add_filter(
+			self::FILTER,
+			function ( $payment_method, $available_gateways ) use ( &$received ) {
+				$received = array( $payment_method, $available_gateways );
+				return $payment_method;
+			},
+			10,
+			2
+		);
+
+		PaymentUtils::get_default_payment_method();
+
+		$this->assertSame( 'cod', $received[0] );
+		$this->assertSame( array( 'bacs', 'cod' ), array_keys( $received[1] ) );
+		$this->assertContainsOnlyInstancesOf( \WC_Payment_Gateway::class, $received[1] );
+	}
+
+	/**
+	 * @testdox Should pass the saved card's gateway to the callback when the customer has one.
+	 */
+	public function test_callback_receives_the_saved_card_gateway(): void {
+		$this->create_default_saved_card( $this->login_as_role( 'customer' ), 'bacs' );
+		$received = '';
+		add_filter(
+			self::FILTER,
+			function ( $payment_method ) use ( &$received ) {
+				$received = $payment_method;
+				return $payment_method;
+			}
+		);
+
+		PaymentUtils::get_default_payment_method();
+
+		$this->assertSame( 'bacs', $received );
+	}
+
+	/**
+	 * @testdox Should pass the first enabled gateway to the callback without a saved card or a session choice.
+	 */
+	public function test_callback_receives_the_first_enabled_gateway(): void {
+		WC()->session->set( 'chosen_payment_method', null );
+		$received = '';
+		add_filter(
+			self::FILTER,
+			function ( $payment_method ) use ( &$received ) {
+				$received = $payment_method;
+				return $payment_method;
+			}
+		);
+
+		PaymentUtils::get_default_payment_method();
+
+		$this->assertSame( 'bacs', $received );
+	}
+
+	/**
+	 * @testdox Should carry the shopper's session choice through the checkout even when the callback returns nothing.
+	 */
+	public function test_checkout_payment_method_keeps_the_session_choice_with_a_callback(): void {
+		add_filter( self::FILTER, '__return_empty_string' );
+
+		$this->assertSame( 'cod', PaymentUtils::get_checkout_payment_method() );
+	}
+
+	/**
+	 * @testdox Should fall back to the default when the session choice is enabled but not available for this cart.
+	 */
+	public function test_checkout_payment_method_ignores_an_unavailable_session_choice(): void {
+		add_filter( 'woocommerce_available_payment_gateways', array( $this, 'hide_cod' ) );
+		add_filter( self::FILTER, '__return_empty_string' );
+
+		$this->assertSame( '', PaymentUtils::get_checkout_payment_method() );
+	}
+
+	/**
+	 * @testdox Should fall back to the default when the session choice is a disabled gateway.
+	 */
+	public function test_checkout_payment_method_ignores_a_disabled_session_choice(): void {
+		WC()->session->set( 'chosen_payment_method', 'paypal' );
+		add_filter( self::FILTER, '__return_empty_string' );
+
+		$this->assertSame( '', PaymentUtils::get_checkout_payment_method() );
+	}
+
+	/**
+	 * @testdox Should fall back to the default without a session choice.
+	 */
+	public function test_checkout_payment_method_falls_back_to_the_default_without_a_session_choice(): void {
+		WC()->session->set( 'chosen_payment_method', null );
+
+		$this->assertSame( 'bacs', PaymentUtils::get_checkout_payment_method() );
+	}
+
+	/**
+	 * Hide COD from the available gateways, as a shipping restriction would, while it stays enabled.
+	 *
+	 * @param array $gateways Available gateways keyed by id.
+	 * @return array
+	 */
+	public function hide_cod( $gateways ) {
+		unset( $gateways['cod'] );
+		return $gateways;
+	}
+
+	/**
+	 * Save a default card for the customer on the given gateway.
+	 *
+	 * @param int    $user_id    Customer id.
+	 * @param string $gateway_id Gateway the card belongs to.
+	 */
+	private function create_default_saved_card( int $user_id, string $gateway_id ): void {
+		$token = new \WC_Payment_Token_CC();
+		$token->set_gateway_id( $gateway_id );
+		$token->set_user_id( $user_id );
+		$token->set_token( 'tok_test' );
+		$token->set_last4( '4242' );
+		$token->set_expiry_month( '12' );
+		$token->set_expiry_year( (string) ( (int) gmdate( 'Y' ) + 2 ) );
+		$token->set_card_type( 'visa' );
+		$token->set_default( true );
+		$token->save();
+	}
+}