Commit a3f5a2f8d47 for woocommerce

commit a3f5a2f8d47dc31a5381d6175451a20279bc0895
Author: Alefe Souza <contact@alefesouza.com>
Date:   Wed Sep 16 22:01:38 2026 -0300

    Let offline payment gateways restrict availability by shipping method (#68624)

diff --git a/plugins/woocommerce/changelog/wooplug-2880-shipping-method-restrictions-offline-gateways b/plugins/woocommerce/changelog/wooplug-2880-shipping-method-restrictions-offline-gateways
new file mode 100644
index 00000000000..98154f67966
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-2880-shipping-method-restrictions-offline-gateways
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the "Enable for shipping methods" and "Accept for virtual orders" settings to the Direct bank transfer and Check payments gateways, sharing the Cash on delivery implementation through the new ShippingMethodRestrictionsTrait that custom gateways can also use.
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-bacs.tsx b/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-bacs.tsx
index 5939d979bdf..1ec3342fc68 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-bacs.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-bacs.tsx
@@ -27,6 +27,11 @@ import {
 	TextareaEdit,
 	type OfflineFormValues,
 } from './dataform-controls';
+import {
+	getShippingRestrictionFields,
+	getShippingRestrictionSettings,
+	getShippingRestrictionValues,
+} from './shipping-restriction-fields';

 /**
  * Reads a country code out of a location setting.
@@ -105,6 +110,7 @@ export const SettingsPaymentsBacs = () => {
 				title: bacsSettings.settings.title.value,
 				description: bacsSettings.description,
 				instructions: bacsSettings.settings.instructions.value,
+				...getShippingRestrictionValues( bacsSettings ),
 			} );
 			setHasChanges( false );
 		}
@@ -159,8 +165,12 @@ export const SettingsPaymentsBacs = () => {
 				),
 				Edit: TextareaEdit,
 			},
+			...getShippingRestrictionFields(
+				bacsSettings,
+				__( 'direct bank transfer', 'woocommerce' )
+			),
 		],
-		[]
+		[ bacsSettings ]
 	);

 	const saveSettings = async () => {
@@ -172,6 +182,7 @@ export const SettingsPaymentsBacs = () => {
 		const settings: Record< string, string | string[] > = {
 			title: String( formValues.title ),
 			instructions: String( formValues.instructions ),
+			...getShippingRestrictionSettings( formValues ),
 		};

 		try {
@@ -242,6 +253,8 @@ export const SettingsPaymentsBacs = () => {
 								<FieldPlaceholder size="medium" />
 								<FieldPlaceholder size="large" />
 								<FieldPlaceholder size="large" />
+								<FieldPlaceholder size="medium" />
+								<FieldPlaceholder size="small" />
 							</>
 						) : (
 							<DataForm
@@ -254,6 +267,8 @@ export const SettingsPaymentsBacs = () => {
 										'title',
 										'description',
 										'instructions',
+										'enable_for_methods',
+										'enable_for_virtual',
 									],
 								} }
 								onChange={ ( edits: OfflineFormValues ) => {
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-cheque.tsx b/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-cheque.tsx
index a126c72ddf0..27201528c3d 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-cheque.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-cheque.tsx
@@ -21,6 +21,11 @@ import {
 	TextareaEdit,
 	type OfflineFormValues,
 } from './dataform-controls';
+import {
+	getShippingRestrictionFields,
+	getShippingRestrictionSettings,
+	getShippingRestrictionValues,
+} from './shipping-restriction-fields';

 /**
  * This page is used to manage the settings for the Cheque payment gateway.
@@ -62,6 +67,7 @@ export const SettingsPaymentsCheque = () => {
 				title: chequeSettings.settings.title.value,
 				description: chequeSettings.description,
 				instructions: chequeSettings.settings.instructions.value,
+				...getShippingRestrictionValues( chequeSettings ),
 			} );
 		}
 	}, [ chequeSettings ] );
@@ -101,8 +107,12 @@ export const SettingsPaymentsCheque = () => {
 				),
 				Edit: TextareaEdit,
 			},
+			...getShippingRestrictionFields(
+				chequeSettings,
+				__( 'check payments', 'woocommerce' )
+			),
 		],
-		[]
+		[ chequeSettings ]
 	);

 	const saveSettings = () => {
@@ -112,9 +122,10 @@ export const SettingsPaymentsCheque = () => {

 		setIsSaving( true );

-		const settings: Record< string, string > = {
+		const settings: Record< string, string | string[] > = {
 			title: String( formValues.title ),
 			instructions: String( formValues.instructions ),
+			...getShippingRestrictionSettings( formValues ),
 		};

 		updatePaymentGateway( 'cheque', {
@@ -167,6 +178,8 @@ export const SettingsPaymentsCheque = () => {
 								<FieldPlaceholder size="medium" />
 								<FieldPlaceholder size="large" />
 								<FieldPlaceholder size="large" />
+								<FieldPlaceholder size="medium" />
+								<FieldPlaceholder size="small" />
 							</>
 						) : (
 							<DataForm
@@ -179,6 +192,8 @@ export const SettingsPaymentsCheque = () => {
 										'title',
 										'description',
 										'instructions',
+										'enable_for_methods',
+										'enable_for_virtual',
 									],
 								} }
 								onChange={ ( edits: OfflineFormValues ) => {
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-cod.tsx b/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-cod.tsx
index b71eae7cf63..5a581ba3ea7 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-cod.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/offline/settings-payments-cod.tsx
@@ -2,7 +2,6 @@
  * External dependencies
  */
 import { Button } from '@wordpress/components';
-import { TreeSelectControl } from '@woocommerce/components';
 import { __ } from '@wordpress/i18n';
 import { useDispatch, useSelect } from '@wordpress/data';
 import { paymentGatewaysStore, paymentSettingsStore } from '@woocommerce/data';
@@ -14,7 +13,6 @@ import type { Field } from '@wordpress/dataviews';
  * Internal dependencies
  */
 import '../settings-payments-body.scss';
-import { mapShippingMethodsOptions } from '~/settings-payments/offline/utils';
 import { Settings } from '~/settings-payments/components/settings';
 import { FieldPlaceholder } from '~/settings-payments/components/field-placeholder';
 import {
@@ -23,6 +21,11 @@ import {
 	TextareaEdit,
 	type OfflineFormValues,
 } from './dataform-controls';
+import {
+	getShippingRestrictionFields,
+	getShippingRestrictionSettings,
+	getShippingRestrictionValues,
+} from './shipping-restriction-fields';

 /**
  * This page is used to manage the settings for the Cash on delivery payment gateway.
@@ -62,28 +65,12 @@ export const SettingsPaymentsCod = () => {
 				title: codSettings.settings.title.value,
 				description: codSettings.description,
 				instructions: codSettings.settings.instructions.value,
-				enable_for_methods: Array.isArray(
-					codSettings.settings.enable_for_methods.value
-				)
-					? codSettings.settings.enable_for_methods.value
-					: [],
-				enable_for_virtual:
-					codSettings.settings.enable_for_virtual.value === 'yes',
+				...getShippingRestrictionValues( codSettings ),
 			} );
 			setHasChanges( false );
 		}
 	}, [ codSettings ] );

-	const shippingMethodsOptions = useMemo(
-		() =>
-			codSettings?.settings.enable_for_methods?.options
-				? mapShippingMethodsOptions(
-						codSettings.settings.enable_for_methods.options
-				  )
-				: [],
-		[ codSettings ]
-	);
-
 	const fields: Field< OfflineFormValues >[] = useMemo(
 		() => [
 			{
@@ -119,42 +106,12 @@ export const SettingsPaymentsCod = () => {
 				),
 				Edit: TextareaEdit,
 			},
-			{
-				id: 'enable_for_methods',
-				label: __( 'Enable for shipping methods', 'woocommerce' ),
-				description: __(
-					'Select shipping methods for which this payment method is enabled.',
-					'woocommerce'
-				),
-				// COD-specific edit control: renders the shipping methods
-				// multi-select using the options that ship with the gateway.
-				Edit: ( { data, field, onChange } ) => {
-					const value = field.getValue( { item: data } );
-					return (
-						<TreeSelectControl
-							label={ field.label }
-							help={ field.description }
-							options={ shippingMethodsOptions }
-							value={ Array.isArray( value ) ? value : [] }
-							onChange={ ( newValue: string[] ) =>
-								onChange( { [ field.id ]: newValue } )
-							}
-							selectAllLabel={ false }
-						/>
-					);
-				},
-			},
-			{
-				id: 'enable_for_virtual',
-				label: __( 'Accept for virtual orders', 'woocommerce' ),
-				description: __(
-					'Accept cash on delivery if the order is virtual',
-					'woocommerce'
-				),
-				Edit: CheckboxEdit,
-			},
+			...getShippingRestrictionFields(
+				codSettings,
+				__( 'cash on delivery', 'woocommerce' )
+			),
 		],
-		[ shippingMethodsOptions ]
+		[ codSettings ]
 	);

 	const saveSettings = () => {
@@ -167,10 +124,7 @@ export const SettingsPaymentsCod = () => {
 		const settings: Record< string, string | string[] > = {
 			title: String( formValues.title ),
 			instructions: String( formValues.instructions ),
-			enable_for_methods: Array.isArray( formValues.enable_for_methods )
-				? formValues.enable_for_methods
-				: [],
-			enable_for_virtual: formValues.enable_for_virtual ? 'yes' : 'no',
+			...getShippingRestrictionSettings( formValues ),
 		};

 		updatePaymentGateway( 'cod', {
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/offline/shipping-restriction-fields.tsx b/plugins/woocommerce/client/admin/client/settings-payments/offline/shipping-restriction-fields.tsx
new file mode 100644
index 00000000000..f28d1548797
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/offline/shipping-restriction-fields.tsx
@@ -0,0 +1,101 @@
+/**
+ * External dependencies
+ */
+import { TreeSelectControl } from '@woocommerce/components';
+import { __, sprintf } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+import type { PaymentGateway } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import { mapShippingMethodsOptions } from './utils';
+import { CheckboxEdit, type OfflineFormValues } from './dataform-controls';
+
+/**
+ * Reads the shipping method restriction settings of an offline payment gateway
+ * into the form values shape.
+ *
+ * @param gateway The gateway as returned by the payment gateways store.
+ */
+export const getShippingRestrictionValues = (
+	gateway: PaymentGateway
+): OfflineFormValues => ( {
+	enable_for_methods: Array.isArray(
+		gateway.settings.enable_for_methods?.value
+	)
+		? gateway.settings.enable_for_methods.value
+		: [],
+	enable_for_virtual: gateway.settings.enable_for_virtual?.value === 'yes',
+} );
+
+/**
+ * Serializes the shipping method restriction form values into the settings
+ * payload accepted by the payment gateways REST API.
+ *
+ * @param formValues The current form values.
+ */
+export const getShippingRestrictionSettings = (
+	formValues: OfflineFormValues
+): Record< string, string | string[] > => ( {
+	enable_for_methods: Array.isArray( formValues.enable_for_methods )
+		? formValues.enable_for_methods
+		: [],
+	enable_for_virtual: formValues.enable_for_virtual ? 'yes' : 'no',
+} );
+
+/**
+ * Builds the DataForm fields for the "Enable for shipping methods" and
+ * "Accept for virtual orders" settings shared by the offline payment gateways.
+ *
+ * @param gateway    The gateway as returned by the payment gateways store, used for the shipping method options.
+ * @param methodName Lowercase payment method name used in the virtual orders description, e.g. "cash on delivery".
+ */
+export const getShippingRestrictionFields = (
+	gateway: PaymentGateway | undefined,
+	methodName: string
+): Field< OfflineFormValues >[] => {
+	const shippingMethodsOptions = gateway?.settings.enable_for_methods?.options
+		? mapShippingMethodsOptions(
+				gateway.settings.enable_for_methods.options
+		  )
+		: [];
+
+	return [
+		{
+			id: 'enable_for_methods',
+			label: __( 'Enable for shipping methods', 'woocommerce' ),
+			description: __(
+				'Select shipping methods for which this payment method is enabled.',
+				'woocommerce'
+			),
+			// Renders the shipping methods multi-select using the options
+			// that ship with the gateway.
+			Edit: ( { data, field, onChange } ) => {
+				const value = field.getValue( { item: data } );
+				return (
+					<TreeSelectControl
+						label={ field.label }
+						help={ field.description }
+						options={ shippingMethodsOptions }
+						value={ Array.isArray( value ) ? value : [] }
+						onChange={ ( newValue: string[] ) =>
+							onChange( { [ field.id ]: newValue } )
+						}
+						selectAllLabel={ false }
+					/>
+				);
+			},
+		},
+		{
+			id: 'enable_for_virtual',
+			label: __( 'Accept for virtual orders', 'woocommerce' ),
+			description: sprintf(
+				/* translators: %s: payment method name, e.g. "cash on delivery". */
+				__( 'Accept %s if the order is virtual', 'woocommerce' ),
+				methodName
+			),
+			Edit: CheckboxEdit,
+		},
+	];
+};
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/offline/test/settings-payments-bacs.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/offline/test/settings-payments-bacs.test.tsx
index b6eccb713f7..b5e90fe72c2 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/offline/test/settings-payments-bacs.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/offline/test/settings-payments-bacs.test.tsx
@@ -31,6 +31,18 @@ const bacsSettings = {
 	settings: {
 		title: { value: 'Direct bank transfer' },
 		instructions: { value: 'Use your order ID as the payment reference.' },
+		enable_for_methods: {
+			value: [ 'flat_rate:1' ],
+			options: {
+				'Flat rate': {
+					'flat_rate:1': 'Flat rate (#1)',
+				},
+				'Free shipping': {
+					'free_shipping:2': 'Free shipping (#2)',
+				},
+			},
+		},
+		enable_for_virtual: { value: 'yes' },
 	},
 };

@@ -85,6 +97,14 @@ describe( 'SettingsPaymentsBacs', () => {
 		expect( screen.getByLabelText( 'Instructions' ) ).toHaveValue(
 			'Use your order ID as the payment reference.'
 		);
+		expect(
+			screen.getByLabelText( 'Enable for shipping methods' )
+		).toBeInTheDocument();
+		// The stored shipping method selection is rendered as a tag.
+		expect( screen.getByText( 'Flat rate (#1)' ) ).toBeInTheDocument();
+		expect(
+			screen.getByLabelText( 'Accept for virtual orders' )
+		).toBeChecked();
 	} );

 	it( 'renders the bank accounts section', () => {
@@ -250,6 +270,7 @@ describe( 'SettingsPaymentsBacs', () => {
 		fireEvent.click(
 			screen.getByLabelText( 'Enable direct bank transfers' )
 		);
+		fireEvent.click( screen.getByLabelText( 'Accept for virtual orders' ) );
 		fireEvent.click(
 			screen.getByRole( 'button', { name: 'Save changes' } )
 		);
@@ -262,6 +283,8 @@ describe( 'SettingsPaymentsBacs', () => {
 				settings: {
 					title: 'Bank transfer payments',
 					instructions: 'Use your order ID as the payment reference.',
+					enable_for_methods: [ 'flat_rate:1' ],
+					enable_for_virtual: 'no',
 				},
 			} );
 		} );
@@ -316,10 +339,19 @@ describe( 'SettingsPaymentsBacs', () => {
 		expect( screen.getByLabelText( 'Description' ) ).toHaveFocus();
 		await userEvent.tab();
 		expect( screen.getByLabelText( 'Instructions' ) ).toHaveFocus();
-		await userEvent.tab();
-		expect(
-			screen.getByRole( 'button', { name: 'Save changes' } )
-		).toHaveFocus();
+		// The shipping methods tree select and the virtual orders checkbox
+		// sit between Instructions and Save; tab until Save receives focus.
+		const saveButton = screen.getByRole( 'button', {
+			name: 'Save changes',
+		} );
+		for (
+			let i = 0;
+			i < 6 && saveButton.ownerDocument.activeElement !== saveButton;
+			i++
+		) {
+			await userEvent.tab();
+		}
+		expect( saveButton ).toHaveFocus();
 	} );

 	it( 'shows an error notice when saving fails', async () => {
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/offline/test/settings-payments-cheque.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/offline/test/settings-payments-cheque.test.tsx
index 0a0dbde35b7..1493118580b 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/offline/test/settings-payments-cheque.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/offline/test/settings-payments-cheque.test.tsx
@@ -22,6 +22,18 @@ const chequeSettings = {
 	settings: {
 		title: { value: 'Check payments' },
 		instructions: { value: 'Send the check to our address.' },
+		enable_for_methods: {
+			value: [ 'flat_rate:1' ],
+			options: {
+				'Flat rate': {
+					'flat_rate:1': 'Flat rate (#1)',
+				},
+				'Free shipping': {
+					'free_shipping:2': 'Free shipping (#2)',
+				},
+			},
+		},
+		enable_for_virtual: { value: 'yes' },
 	},
 };

@@ -58,6 +70,14 @@ describe( 'SettingsPaymentsCheque', () => {
 		expect( screen.getByLabelText( 'Instructions' ) ).toHaveValue(
 			'Send the check to our address.'
 		);
+		expect(
+			screen.getByLabelText( 'Enable for shipping methods' )
+		).toBeInTheDocument();
+		// The stored shipping method selection is rendered as a tag.
+		expect( screen.getByText( 'Flat rate (#1)' ) ).toBeInTheDocument();
+		expect(
+			screen.getByLabelText( 'Accept for virtual orders' )
+		).toBeChecked();
 	} );

 	it( 'renders placeholders while loading', () => {
@@ -98,6 +118,7 @@ describe( 'SettingsPaymentsCheque', () => {
 			target: { value: 'Cheque payments' },
 		} );
 		fireEvent.click( screen.getByLabelText( 'Enable check payments' ) );
+		fireEvent.click( screen.getByLabelText( 'Accept for virtual orders' ) );
 		fireEvent.click(
 			screen.getByRole( 'button', { name: 'Save changes' } )
 		);
@@ -109,6 +130,8 @@ describe( 'SettingsPaymentsCheque', () => {
 				settings: {
 					title: 'Cheque payments',
 					instructions: 'Send the check to our address.',
+					enable_for_methods: [ 'flat_rate:1' ],
+					enable_for_virtual: 'no',
 				},
 			} );
 		} );
@@ -149,10 +172,19 @@ describe( 'SettingsPaymentsCheque', () => {
 		expect( screen.getByLabelText( 'Description' ) ).toHaveFocus();
 		userEvent.tab();
 		expect( screen.getByLabelText( 'Instructions' ) ).toHaveFocus();
-		userEvent.tab();
-		expect(
-			screen.getByRole( 'button', { name: 'Save changes' } )
-		).toHaveFocus();
+		// The shipping methods tree select and the virtual orders checkbox
+		// sit between Instructions and Save; tab until Save receives focus.
+		const saveButton = screen.getByRole( 'button', {
+			name: 'Save changes',
+		} );
+		for (
+			let i = 0;
+			i < 6 && saveButton.ownerDocument.activeElement !== saveButton;
+			i++
+		) {
+			userEvent.tab();
+		}
+		expect( saveButton ).toHaveFocus();
 	} );

 	it( 'shows an error notice when saving fails', async () => {
diff --git a/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/bacs/index.js b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/bacs/index.js
index fb684ec2249..de6e4b39d63 100644
--- a/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/bacs/index.js
+++ b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/bacs/index.js
@@ -12,6 +12,7 @@ import { RawHTML } from '@wordpress/element';
  * Internal dependencies
  */
 import { PAYMENT_METHOD_NAME } from './constants';
+import { canMakePaymentForShippingMethods } from '../utils/shipping-method-restrictions';

 const settings = getPaymentMethodData( 'bacs', {} );
 const defaultLabel = __( 'Direct bank transfer', 'woocommerce' );
@@ -42,7 +43,7 @@ const bankTransferPaymentMethod = {
 	label: <Label />,
 	content: <Content />,
 	edit: <Content />,
-	canMakePayment: () => true,
+	canMakePayment: canMakePaymentForShippingMethods( settings ),
 	ariaLabel: label,
 	supports: {
 		features: settings?.supports ?? [],
diff --git a/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/cheque/index.js b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/cheque/index.js
index 3c9a2dd64d5..ad577981c9c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/cheque/index.js
+++ b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/cheque/index.js
@@ -12,6 +12,7 @@ import { RawHTML } from '@wordpress/element';
  * Internal dependencies
  */
 import { PAYMENT_METHOD_NAME } from './constants';
+import { canMakePaymentForShippingMethods } from '../utils/shipping-method-restrictions';

 const settings = getPaymentMethodData( 'cheque', {} );
 const defaultLabel = __( 'Check payment', 'woocommerce' );
@@ -42,7 +43,7 @@ const offlineChequePaymentMethod = {
 	label: <Label />,
 	content: <Content />,
 	edit: <Content />,
-	canMakePayment: () => true,
+	canMakePayment: canMakePaymentForShippingMethods( settings ),
 	ariaLabel: label,
 	supports: {
 		features: settings?.supports ?? [],
diff --git a/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/cod/index.js b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/cod/index.js
index 28b64db3fc8..83d2bc549bc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/cod/index.js
+++ b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/cod/index.js
@@ -12,6 +12,7 @@ import { RawHTML } from '@wordpress/element';
  * Internal dependencies
  */
 import { PAYMENT_METHOD_NAME } from './constants';
+import { canMakePaymentForShippingMethods } from '../utils/shipping-method-restrictions';

 const settings = getPaymentMethodData( 'cod', {} );
 const defaultLabel = __( 'Cash on delivery', 'woocommerce' );
@@ -34,44 +35,6 @@ const Label = ( props ) => {
 	return <PaymentMethodLabel text={ label } />;
 };

-/**
- * Determine whether COD is available for this cart/order.
- *
- * @param {Object}  props                         Incoming props for the component.
- * @param {boolean} props.cartNeedsShipping       True if the cart contains any physical/shippable products.
- * @param {boolean} props.selectedShippingMethods
- *
- * @return {boolean}  True if COD payment method should be displayed as a payment option.
- */
-const canMakePayment = ( { cartNeedsShipping, selectedShippingMethods } ) => {
-	if ( settings.enableForVirtual && ! cartNeedsShipping ) {
-		// Store allows COD for virtual orders.
-		return true;
-	}
-
-	if ( ! settings.enableForShippingMethods.length ) {
-		// Store does not limit COD to specific shipping methods.
-		return true;
-	}
-
-	// Look for a supported shipping method in the user's selected
-	// shipping methods. If one is found, then COD is allowed.
-	const selectedMethods = Object.values( selectedShippingMethods );
-
-	// Enable until proven unavailable.
-	if ( selectedMethods.length === 0 ) {
-		return true;
-	}
-
-	// supported shipping methods might be global (eg. "Any flat rate"), hence
-	// this is doing a `String.prototype.includes` match vs a `Array.prototype.includes` match.
-	return settings.enableForShippingMethods.some( ( shippingMethodId ) => {
-		return selectedMethods.some( ( selectedMethod ) => {
-			return selectedMethod.includes( shippingMethodId );
-		} );
-	} );
-};
-
 /**
  * Cash on Delivery (COD) payment method config object.
  */
@@ -80,7 +43,7 @@ const cashOnDeliveryPaymentMethod = {
 	label: <Label />,
 	content: <Content />,
 	edit: <Content />,
-	canMakePayment,
+	canMakePayment: canMakePaymentForShippingMethods( settings ),
 	ariaLabel: label,
 	supports: {
 		features: settings?.supports ?? [],
diff --git a/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/utils/shipping-method-restrictions.ts b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/utils/shipping-method-restrictions.ts
new file mode 100644
index 00000000000..4c444721cd1
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/utils/shipping-method-restrictions.ts
@@ -0,0 +1,63 @@
+/**
+ * External dependencies
+ */
+import type { CanMakePaymentArgument } from '@woocommerce/types';
+
+/**
+ * Settings exposed by offline payment methods that can be restricted to
+ * selected shipping methods (see ShippingMethodRestrictionsTrait in PHP).
+ */
+export interface ShippingMethodRestrictionSettings {
+	enableForVirtual?: boolean;
+	enableForShippingMethods?: string[];
+}
+
+/**
+ * Determine whether a payment method restricted by shipping method is
+ * available for the current cart, mirroring the server-side check in
+ * `ShippingMethodRestrictionsTrait::is_available()`.
+ *
+ * @param settings Payment method settings.
+ * @return A `canMakePayment` callback for `registerPaymentMethod`.
+ */
+export const canMakePaymentForShippingMethods =
+	( settings: ShippingMethodRestrictionSettings ) =>
+	( {
+		cartNeedsShipping,
+		selectedShippingMethods,
+	}: Pick<
+		CanMakePaymentArgument,
+		'cartNeedsShipping' | 'selectedShippingMethods'
+	> ): boolean => {
+		const enableForShippingMethods =
+			settings.enableForShippingMethods ?? [];
+
+		if ( settings.enableForVirtual && ! cartNeedsShipping ) {
+			// Store allows the payment method for virtual orders.
+			return true;
+		}
+
+		if ( ! enableForShippingMethods.length ) {
+			// Store does not limit the payment method to specific shipping methods.
+			return true;
+		}
+
+		// Look for a supported shipping method in the user's selected
+		// shipping methods. If one is found, then the payment method is allowed.
+		const selectedMethods = Object.values( selectedShippingMethods );
+
+		// Enable until proven unavailable.
+		if ( selectedMethods.length === 0 ) {
+			return true;
+		}
+
+		// Supported shipping methods might be global (eg. "Any flat rate"), hence
+		// this is doing a `String.prototype.includes` match vs a `Array.prototype.includes` match.
+		return enableForShippingMethods.some( ( shippingMethodId ) =>
+			selectedMethods.some(
+				( selectedMethod ) =>
+					typeof selectedMethod === 'string' &&
+					selectedMethod.includes( shippingMethodId )
+			)
+		);
+	};
diff --git a/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/utils/test/shipping-method-restrictions.test.ts b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/utils/test/shipping-method-restrictions.test.ts
new file mode 100644
index 00000000000..2dc480485ca
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/extensions/payment-methods/utils/test/shipping-method-restrictions.test.ts
@@ -0,0 +1,94 @@
+/**
+ * Internal dependencies
+ */
+import { canMakePaymentForShippingMethods } from '../shipping-method-restrictions';
+
+describe( 'canMakePaymentForShippingMethods', () => {
+	const cartWithMethod = ( ...methods: string[] ) => ( {
+		cartNeedsShipping: true,
+		selectedShippingMethods: Object.fromEntries(
+			methods.map( ( method, index ) => [ String( index ), method ] )
+		),
+	} );
+
+	it( 'is available for any shipping method when no restriction is set', () => {
+		const canMakePayment = canMakePaymentForShippingMethods( {
+			enableForVirtual: false,
+			enableForShippingMethods: [],
+		} );
+
+		expect( canMakePayment( cartWithMethod( 'flat_rate:1' ) ) ).toBe(
+			true
+		);
+	} );
+
+	it( 'treats missing settings as no restriction', () => {
+		const canMakePayment = canMakePaymentForShippingMethods( {} );
+
+		expect( canMakePayment( cartWithMethod( 'flat_rate:1' ) ) ).toBe(
+			true
+		);
+	} );
+
+	it( 'is only available when a selected shipping method matches', () => {
+		const canMakePayment = canMakePaymentForShippingMethods( {
+			enableForVirtual: true,
+			enableForShippingMethods: [ 'flat_rate:1' ],
+		} );
+
+		expect( canMakePayment( cartWithMethod( 'flat_rate:1' ) ) ).toBe(
+			true
+		);
+		expect( canMakePayment( cartWithMethod( 'flat_rate:2' ) ) ).toBe(
+			false
+		);
+		expect( canMakePayment( cartWithMethod( 'free_shipping:1' ) ) ).toBe(
+			false
+		);
+	} );
+
+	it( 'matches any instance of a shipping method when restricted by method id', () => {
+		const canMakePayment = canMakePaymentForShippingMethods( {
+			enableForVirtual: true,
+			enableForShippingMethods: [ 'flat_rate' ],
+		} );
+
+		expect( canMakePayment( cartWithMethod( 'flat_rate:7' ) ) ).toBe(
+			true
+		);
+		expect( canMakePayment( cartWithMethod( 'local_pickup:1' ) ) ).toBe(
+			false
+		);
+	} );
+
+	it( 'stays available until a shipping method has been selected', () => {
+		const canMakePayment = canMakePaymentForShippingMethods( {
+			enableForVirtual: false,
+			enableForShippingMethods: [ 'flat_rate:1' ],
+		} );
+
+		expect( canMakePayment( cartWithMethod() ) ).toBe( true );
+	} );
+
+	it( 'honors the virtual orders setting when the cart needs no shipping', () => {
+		const virtualCart = {
+			cartNeedsShipping: false,
+			selectedShippingMethods: {},
+		};
+
+		expect(
+			canMakePaymentForShippingMethods( {
+				enableForVirtual: true,
+				enableForShippingMethods: [ 'flat_rate:1' ],
+			} )( virtualCart )
+		).toBe( true );
+		// Without shipping there is nothing to match against, so the
+		// server-side check decides; the client stays permissive.
+		expect(
+			canMakePaymentForShippingMethods( {
+				enableForVirtual: false,
+				enableForShippingMethods: [ 'flat_rate:1' ],
+			} )( virtualCart )
+		).toBe( true );
+	} );
+} );
diff --git a/plugins/woocommerce/includes/gateways/bacs/class-wc-gateway-bacs.php b/plugins/woocommerce/includes/gateways/bacs/class-wc-gateway-bacs.php
index 90a70af0011..25fc18c59c6 100644
--- a/plugins/woocommerce/includes/gateways/bacs/class-wc-gateway-bacs.php
+++ b/plugins/woocommerce/includes/gateways/bacs/class-wc-gateway-bacs.php
@@ -6,6 +6,7 @@
  */

 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Gateways\ShippingMethodRestrictionsTrait;
 use Automattic\WooCommerce\Internal\Admin\Settings\Utils as SettingsUtils;

 if ( ! defined( 'ABSPATH' ) ) {
@@ -24,6 +25,8 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class WC_Gateway_BACS extends WC_Payment_Gateway {

+	use ShippingMethodRestrictionsTrait;
+
 	/**
 	 * Unique ID for this gateway.
 	 *
@@ -71,6 +74,7 @@ class WC_Gateway_BACS extends WC_Payment_Gateway {
 		$this->title        = $this->get_option( 'title' );
 		$this->description  = $this->get_option( 'description' );
 		$this->instructions = $this->get_option( 'instructions' );
+		$this->init_shipping_method_restrictions();

 		// BACS account fields shown on the thanks page and in emails.
 		$this->account_details = get_option(
@@ -101,37 +105,42 @@ class WC_Gateway_BACS extends WC_Payment_Gateway {
 	 */
 	public function init_form_fields() {

-		$this->form_fields = array(
-			'enabled'         => array(
-				'title'   => __( 'Enable/Disable', 'woocommerce' ),
-				'type'    => 'checkbox',
-				'label'   => __( 'Enable bank transfer', 'woocommerce' ),
-				'default' => 'no',
-			),
-			'title'           => array(
-				'title'       => __( 'Title', 'woocommerce' ),
-				'type'        => 'safe_text',
-				'description' => __( 'This controls the title which the user sees during checkout.', 'woocommerce' ),
-				'default'     => __( 'Direct bank transfer', 'woocommerce' ),
-				'desc_tip'    => true,
-			),
-			'description'     => array(
-				'title'       => __( 'Description', 'woocommerce' ),
-				'type'        => 'textarea',
-				'description' => __( 'Payment method description that the customer will see on your checkout.', 'woocommerce' ),
-				'default'     => __( 'Make your payment directly into our bank account. Please use your Order ID as the payment reference. Your order will not be shipped until the funds have cleared in our account.', 'woocommerce' ),
-				'desc_tip'    => true,
-			),
-			'instructions'    => array(
-				'title'       => __( 'Instructions', 'woocommerce' ),
-				'type'        => 'textarea',
-				'description' => __( 'Instructions that will be added to the thank you page and emails.', 'woocommerce' ),
-				'default'     => '',
-				'desc_tip'    => true,
-			),
-			'account_details' => array(
-				'type' => 'account_details',
+		$this->form_fields = array_merge(
+			array(
+				'enabled'      => array(
+					'title'   => __( 'Enable/Disable', 'woocommerce' ),
+					'type'    => 'checkbox',
+					'label'   => __( 'Enable bank transfer', 'woocommerce' ),
+					'default' => 'no',
+				),
+				'title'        => array(
+					'title'       => __( 'Title', 'woocommerce' ),
+					'type'        => 'safe_text',
+					'description' => __( 'This controls the title which the user sees during checkout.', 'woocommerce' ),
+					'default'     => __( 'Direct bank transfer', 'woocommerce' ),
+					'desc_tip'    => true,
+				),
+				'description'  => array(
+					'title'       => __( 'Description', 'woocommerce' ),
+					'type'        => 'textarea',
+					'description' => __( 'Payment method description that the customer will see on your checkout.', 'woocommerce' ),
+					'default'     => __( 'Make your payment directly into our bank account. Please use your Order ID as the payment reference. Your order will not be shipped until the funds have cleared in our account.', 'woocommerce' ),
+					'desc_tip'    => true,
+				),
+				'instructions' => array(
+					'title'       => __( 'Instructions', 'woocommerce' ),
+					'type'        => 'textarea',
+					'description' => __( 'Instructions that will be added to the thank you page and emails.', 'woocommerce' ),
+					'default'     => '',
+					'desc_tip'    => true,
+				),
 			),
+			$this->get_shipping_method_restrictions_form_fields(),
+			array(
+				'account_details' => array(
+					'type' => 'account_details',
+				),
+			)
 		);
 	}

diff --git a/plugins/woocommerce/includes/gateways/cheque/class-wc-gateway-cheque.php b/plugins/woocommerce/includes/gateways/cheque/class-wc-gateway-cheque.php
index 3997d7f2d9c..3d91fcad3eb 100644
--- a/plugins/woocommerce/includes/gateways/cheque/class-wc-gateway-cheque.php
+++ b/plugins/woocommerce/includes/gateways/cheque/class-wc-gateway-cheque.php
@@ -6,6 +6,7 @@
  */

 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Gateways\ShippingMethodRestrictionsTrait;
 use Automattic\WooCommerce\Internal\Admin\Settings\Utils as SettingsUtils;

 if ( ! defined( 'ABSPATH' ) ) {
@@ -24,6 +25,8 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class WC_Gateway_Cheque extends WC_Payment_Gateway {

+	use ShippingMethodRestrictionsTrait;
+
 	/**
 	 * Unique ID for this gateway.
 	 *
@@ -56,6 +59,7 @@ class WC_Gateway_Cheque extends WC_Payment_Gateway {
 		$this->title        = $this->get_option( 'title' );
 		$this->description  = $this->get_option( 'description' );
 		$this->instructions = $this->get_option( 'instructions' );
+		$this->init_shipping_method_restrictions();

 		// Actions.
 		add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, array( $this, 'process_admin_options' ) );
@@ -70,34 +74,37 @@ class WC_Gateway_Cheque extends WC_Payment_Gateway {
 	 */
 	public function init_form_fields() {

-		$this->form_fields = array(
-			'enabled'      => array(
-				'title'   => __( 'Enable/Disable', 'woocommerce' ),
-				'type'    => 'checkbox',
-				'label'   => __( 'Enable check payments', 'woocommerce' ),
-				'default' => 'no',
-			),
-			'title'        => array(
-				'title'       => __( 'Title', 'woocommerce' ),
-				'type'        => 'safe_text',
-				'description' => __( 'This controls the title which the user sees during checkout.', 'woocommerce' ),
-				'default'     => _x( 'Check payments', 'Check payment method', 'woocommerce' ),
-				'desc_tip'    => true,
-			),
-			'description'  => array(
-				'title'       => __( 'Description', 'woocommerce' ),
-				'type'        => 'textarea',
-				'description' => __( 'Payment method description that the customer will see on your checkout.', 'woocommerce' ),
-				'default'     => __( 'Please send a check to Store Name, Store Street, Store Town, Store State / County, Store Postcode.', 'woocommerce' ),
-				'desc_tip'    => true,
-			),
-			'instructions' => array(
-				'title'       => __( 'Instructions', 'woocommerce' ),
-				'type'        => 'textarea',
-				'description' => __( 'Instructions that will be added to the thank you page and emails.', 'woocommerce' ),
-				'default'     => '',
-				'desc_tip'    => true,
+		$this->form_fields = array_merge(
+			array(
+				'enabled'      => array(
+					'title'   => __( 'Enable/Disable', 'woocommerce' ),
+					'type'    => 'checkbox',
+					'label'   => __( 'Enable check payments', 'woocommerce' ),
+					'default' => 'no',
+				),
+				'title'        => array(
+					'title'       => __( 'Title', 'woocommerce' ),
+					'type'        => 'safe_text',
+					'description' => __( 'This controls the title which the user sees during checkout.', 'woocommerce' ),
+					'default'     => _x( 'Check payments', 'Check payment method', 'woocommerce' ),
+					'desc_tip'    => true,
+				),
+				'description'  => array(
+					'title'       => __( 'Description', 'woocommerce' ),
+					'type'        => 'textarea',
+					'description' => __( 'Payment method description that the customer will see on your checkout.', 'woocommerce' ),
+					'default'     => __( 'Please send a check to Store Name, Store Street, Store Town, Store State / County, Store Postcode.', 'woocommerce' ),
+					'desc_tip'    => true,
+				),
+				'instructions' => array(
+					'title'       => __( 'Instructions', 'woocommerce' ),
+					'type'        => 'textarea',
+					'description' => __( 'Instructions that will be added to the thank you page and emails.', 'woocommerce' ),
+					'default'     => '',
+					'desc_tip'    => true,
+				),
 			),
+			$this->get_shipping_method_restrictions_form_fields()
 		);
 	}

diff --git a/plugins/woocommerce/includes/gateways/cod/class-wc-gateway-cod.php b/plugins/woocommerce/includes/gateways/cod/class-wc-gateway-cod.php
index e131cc670ff..c207f6b4fdc 100644
--- a/plugins/woocommerce/includes/gateways/cod/class-wc-gateway-cod.php
+++ b/plugins/woocommerce/includes/gateways/cod/class-wc-gateway-cod.php
@@ -5,8 +5,8 @@
  * @package WooCommerce\Gateways
  */

-use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Gateways\ShippingMethodRestrictionsTrait;
 use Automattic\WooCommerce\Internal\Admin\Settings\Utils as SettingsUtils;

 if ( ! defined( 'ABSPATH' ) ) {
@@ -25,6 +25,8 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 class WC_Gateway_COD extends WC_Payment_Gateway {

+	use ShippingMethodRestrictionsTrait;
+
 	/**
 	 * Unique ID for this gateway.
 	 *
@@ -39,20 +41,6 @@ class WC_Gateway_COD extends WC_Payment_Gateway {
 	 */
 	public $instructions;

-	/**
-	 * Enable for shipping methods.
-	 *
-	 * @var array
-	 */
-	public $enable_for_methods;
-
-	/**
-	 * Enable for virtual products.
-	 *
-	 * @var bool
-	 */
-	public $enable_for_virtual;
-
 	/**
 	 * Constructor for the gateway.
 	 */
@@ -65,11 +53,10 @@ class WC_Gateway_COD extends WC_Payment_Gateway {
 		$this->init_settings();

 		// Get settings.
-		$this->title              = $this->get_option( 'title' );
-		$this->description        = $this->get_option( 'description' );
-		$this->instructions       = $this->get_option( 'instructions' );
-		$this->enable_for_methods = $this->get_option( 'enable_for_methods', array() );
-		$this->enable_for_virtual = $this->get_option( 'enable_for_virtual', 'yes' ) === 'yes';
+		$this->title        = $this->get_option( 'title' );
+		$this->description  = $this->get_option( 'description' );
+		$this->instructions = $this->get_option( 'instructions' );
+		$this->init_shipping_method_restrictions();

 		// Actions.
 		add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, array( $this, 'process_admin_options' ) );
@@ -95,213 +82,41 @@ class WC_Gateway_COD extends WC_Payment_Gateway {
 	 * Initialise Gateway Settings Form Fields.
 	 */
 	public function init_form_fields() {
-		$this->form_fields = array(
-			'enabled'            => array(
-				'title'       => __( 'Enable/Disable', 'woocommerce' ),
-				'label'       => __( 'Enable cash on delivery', 'woocommerce' ),
-				'type'        => 'checkbox',
-				'description' => '',
-				'default'     => 'no',
-			),
-			'title'              => array(
-				'title'       => __( 'Title', 'woocommerce' ),
-				'type'        => 'safe_text',
-				'description' => __( 'Payment method description that the customer will see on your checkout.', 'woocommerce' ),
-				'default'     => __( 'Cash on delivery', 'woocommerce' ),
-				'desc_tip'    => true,
-			),
-			'description'        => array(
-				'title'       => __( 'Description', 'woocommerce' ),
-				'type'        => 'textarea',
-				'description' => __( 'Payment method description that the customer will see on your website.', 'woocommerce' ),
-				'default'     => __( 'Pay with cash upon delivery.', 'woocommerce' ),
-				'desc_tip'    => true,
-			),
-			'instructions'       => array(
-				'title'       => __( 'Instructions', 'woocommerce' ),
-				'type'        => 'textarea',
-				'description' => __( 'Instructions that will be added to the thank you page.', 'woocommerce' ),
-				'default'     => __( 'Pay with cash upon delivery.', 'woocommerce' ),
-				'desc_tip'    => true,
-			),
-			'enable_for_methods' => array(
-				'title'             => __( 'Enable for shipping methods', 'woocommerce' ),
-				'type'              => 'multiselect',
-				'class'             => 'wc-enhanced-select',
-				'css'               => 'width: 400px;',
-				'default'           => '',
-				'description'       => __( 'If COD is only available for certain methods, set it up here. Leave blank to enable for all methods.', 'woocommerce' ),
-				'options'           => $this->load_shipping_method_options(),
-				'desc_tip'          => true,
-				'custom_attributes' => array(
-					'data-placeholder' => __( 'Select shipping methods', 'woocommerce' ),
+		$this->form_fields = array_merge(
+			array(
+				'enabled'      => array(
+					'title'       => __( 'Enable/Disable', 'woocommerce' ),
+					'label'       => __( 'Enable cash on delivery', 'woocommerce' ),
+					'type'        => 'checkbox',
+					'description' => '',
+					'default'     => 'no',
+				),
+				'title'        => array(
+					'title'       => __( 'Title', 'woocommerce' ),
+					'type'        => 'safe_text',
+					'description' => __( 'Payment method description that the customer will see on your checkout.', 'woocommerce' ),
+					'default'     => __( 'Cash on delivery', 'woocommerce' ),
+					'desc_tip'    => true,
+				),
+				'description'  => array(
+					'title'       => __( 'Description', 'woocommerce' ),
+					'type'        => 'textarea',
+					'description' => __( 'Payment method description that the customer will see on your website.', 'woocommerce' ),
+					'default'     => __( 'Pay with cash upon delivery.', 'woocommerce' ),
+					'desc_tip'    => true,
+				),
+				'instructions' => array(
+					'title'       => __( 'Instructions', 'woocommerce' ),
+					'type'        => 'textarea',
+					'description' => __( 'Instructions that will be added to the thank you page.', 'woocommerce' ),
+					'default'     => __( 'Pay with cash upon delivery.', 'woocommerce' ),
+					'desc_tip'    => true,
 				),
 			),
-			'enable_for_virtual' => array(
-				'title'   => __( 'Accept for virtual orders', 'woocommerce' ),
-				'label'   => __( 'Accept COD if the order is virtual', 'woocommerce' ),
-				'type'    => 'checkbox',
-				'default' => 'yes',
-			),
+			$this->get_shipping_method_restrictions_form_fields()
 		);
 	}

-	/**
-	 * Check If The Gateway Is Available For Use.
-	 *
-	 * @since 10.7.0 Added early return when gateway is disabled.
-	 * @return bool
-	 */
-	public function is_available() {
-		if ( 'yes' !== $this->enabled ) {
-			return false;
-		}
-
-		$is_virtual       = true;
-		$shipping_methods = array();
-
-		// Get shipping methods from the cart or order.
-		if ( is_wc_endpoint_url( 'order-pay' ) ) {
-			$order            = wc_get_order( absint( get_query_var( 'order-pay' ) ) );
-			$shipping_methods = $order ? $order->get_shipping_methods() : array();
-			$is_virtual       = ! count( $shipping_methods );
-		} elseif ( WC()->cart && WC()->cart->needs_shipping() ) {
-			$shipping_methods = WC()->cart->get_shipping_methods();
-			$is_virtual       = false;
-		}
-
-		// If COD is not enabled for virtual orders and the order does not need shipping, return false.
-		if ( ! $this->enable_for_virtual && $is_virtual ) {
-			return false;
-		}
-
-		// Return early if:
-		// - There are no shipping methods resrictions in place.
-		// - The order is virtual so needs no shipping.
-		// - Shipping methods are not set yet.
-		if ( empty( $this->enable_for_methods ) || $is_virtual || ! $shipping_methods ) {
-			return parent::is_available();
-		}
-
-		// Get the selected shipping method ids. This works on both WC_Shipping_Rate and WC_Order_Item_Shipping class instances.
-		$canonical_rate_ids = array_unique(
-			array_values(
-				array_map(
-					function ( $shipping_method ) {
-						return $shipping_method && is_callable( array( $shipping_method, 'get_method_id' ) ) && is_callable( array( $shipping_method, 'get_instance_id' ) ) ? $shipping_method->get_method_id() . ':' . $shipping_method->get_instance_id() : null;
-					},
-					$shipping_methods
-				)
-			)
-		);
-
-		if ( ! count( $this->get_matching_rates( $canonical_rate_ids ) ) ) {
-			return false;
-		}
-
-		return parent::is_available();
-	}
-
-	/**
-	 * Checks to see whether or not the admin settings are being accessed by the current request.
-	 *
-	 * @return bool
-	 */
-	private function is_accessing_settings() {
-		if ( is_admin() ) {
-			if ( ! is_wc_admin_settings_page() ) {
-				return false;
-			}
-			// phpcs:disable WordPress.Security.NonceVerification
-			if ( ! isset( $_REQUEST['tab'] ) || 'checkout' !== $_REQUEST['tab'] ) {
-				return false;
-			}
-			if ( ! isset( $_REQUEST['section'] ) || self::ID !== $_REQUEST['section'] ) {
-				return false;
-			}
-			// phpcs:enable WordPress.Security.NonceVerification
-
-			return true;
-		}
-
-		if ( Constants::is_true( 'REST_REQUEST' ) ) {
-			global $wp;
-			if ( isset( $wp->query_vars['rest_route'] ) && false !== strpos( $wp->query_vars['rest_route'], '/payment_gateways' ) ) {
-				return true;
-			}
-		}
-
-		return false;
-	}
-
-	/**
-	 * Loads all of the shipping method options for the enable_for_methods field.
-	 *
-	 * @return array
-	 */
-	private function load_shipping_method_options() {
-		// Since this is expensive, we only want to do it if we're actually on the settings page.
-		if ( ! $this->is_accessing_settings() ) {
-			return array();
-		}
-
-		$data_store = WC_Data_Store::load( 'shipping-zone' );
-		$raw_zones  = $data_store->get_zones();
-		$zones      = array();
-
-		foreach ( $raw_zones as $raw_zone ) {
-			$zones[] = new WC_Shipping_Zone( $raw_zone );
-		}
-
-		$zones[] = new WC_Shipping_Zone( 0 );
-
-		$options = array();
-		foreach ( WC()->shipping()->load_shipping_methods() as $method ) {
-
-			$options[ $method->get_method_title() ] = array();
-
-			// Translators: %1$s shipping method name.
-			$options[ $method->get_method_title() ][ $method->id ] = sprintf( __( 'Any &quot;%1$s&quot; method', 'woocommerce' ), $method->get_method_title() );
-
-			foreach ( $zones as $zone ) {
-
-				$shipping_method_instances = $zone->get_shipping_methods();
-
-				foreach ( $shipping_method_instances as $shipping_method_instance_id => $shipping_method_instance ) {
-
-					if ( $shipping_method_instance->id !== $method->id ) {
-						continue;
-					}
-
-					$option_id = $shipping_method_instance->get_rate_id();
-
-					// Translators: %1$s shipping method title, %2$s shipping method id.
-					$option_instance_title = sprintf( __( '%1$s (#%2$s)', 'woocommerce' ), $shipping_method_instance->get_title(), $shipping_method_instance_id );
-
-					// Translators: %1$s zone name, %2$s shipping method instance name.
-					$option_title = sprintf( __( '%1$s &ndash; %2$s', 'woocommerce' ), $zone->get_id() ? $zone->get_zone_name() : __( 'Other locations', 'woocommerce' ), $option_instance_title );
-
-					$options[ $method->get_method_title() ][ $option_id ] = $option_title;
-				}
-			}
-		}
-
-		return $options;
-	}
-
-	/**
-	 * Indicates whether a rate exists in an array of canonically-formatted rate IDs that activates this gateway.
-	 *
-	 * @since  3.4.0
-	 *
-	 * @param array $rate_ids Rate ids to check.
-	 * @return array
-	 */
-	private function get_matching_rates( $rate_ids ) {
-		// First, match entries in 'method_id:instance_id' format. Then, match entries in 'method_id' format by stripping off the instance ID from the candidates.
-		return array_unique( array_merge( array_intersect( $this->enable_for_methods, $rate_ids ), array_intersect( $this->enable_for_methods, array_unique( array_map( 'wc_get_string_before_colon', $rate_ids ) ) ) ) );
-	}
-
 	/**
 	 * Process the payment and return the result.
 	 *
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index f811f723455..5bce451c237 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -19369,24 +19369,6 @@ parameters:
 			count: 1
 			path: includes/gateways/cod/class-wc-gateway-cod.php

-		-
-			message: '#^Call to an undefined method WC_Data_Store\:\:get_zones\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: includes/gateways/cod/class-wc-gateway-cod.php
-
-		-
-			message: '#^Cannot call method get_instance_id\(\) on class\-string\|object\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/gateways/cod/class-wc-gateway-cod.php
-
-		-
-			message: '#^Cannot call method get_method_id\(\) on class\-string\|object\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/gateways/cod/class-wc-gateway-cod.php
-
 		-
 			message: '#^Cannot call method get_total\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
 			identifier: method.nonObject
@@ -19453,12 +19435,6 @@ parameters:
 			count: 1
 			path: includes/gateways/cod/class-wc-gateway-cod.php

-		-
-			message: '#^Property WC_Gateway_COD\:\:\$enable_for_methods \(array\) does not accept string\.$#'
-			identifier: assign.propertyType
-			count: 1
-			path: includes/gateways/cod/class-wc-gateway-cod.php
-
 		-
 			message: '#^Access to an undefined property object\:\:\$ACK\.$#'
 			identifier: property.notFound
@@ -65125,12 +65101,6 @@ parameters:
 			count: 1
 			path: src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchema.php

-		-
-			message: '#^Call to an undefined method WC_Data_Store\:\:get_zones\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/CodGatewaySettingsSchema.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Settings\\Products\\Controller\:\:get_item\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
 			identifier: missingType.generics
diff --git a/plugins/woocommerce/src/Blocks/Payments/Integrations/BankTransfer.php b/plugins/woocommerce/src/Blocks/Payments/Integrations/BankTransfer.php
index 07d8c74dcbe..6ad645ffd9e 100644
--- a/plugins/woocommerce/src/Blocks/Payments/Integrations/BankTransfer.php
+++ b/plugins/woocommerce/src/Blocks/Payments/Integrations/BankTransfer.php
@@ -10,6 +10,8 @@ use WC_Gateway_BACS;
  * @since 3.0.0
  */
 final class BankTransfer extends AbstractPaymentMethodType {
+	use ShippingRestrictionsSettingsTrait;
+
 	/**
 	 * Payment method name/id/slug (matches id in WC_Gateway_BACS in core).
 	 *
@@ -69,9 +71,11 @@ final class BankTransfer extends AbstractPaymentMethodType {
 	 */
 	public function get_payment_method_data() {
 		return [
-			'title'       => $this->get_setting( 'title' ),
-			'description' => $this->get_setting( 'description' ),
-			'supports'    => $this->get_supported_features(),
+			'title'                    => $this->get_setting( 'title' ),
+			'description'              => $this->get_setting( 'description' ),
+			'enableForVirtual'         => $this->get_enable_for_virtual(),
+			'enableForShippingMethods' => $this->get_enable_for_methods(),
+			'supports'                 => $this->get_supported_features(),
 		];
 	}
 }
diff --git a/plugins/woocommerce/src/Blocks/Payments/Integrations/CashOnDelivery.php b/plugins/woocommerce/src/Blocks/Payments/Integrations/CashOnDelivery.php
index cd7c095350d..a5715058da5 100644
--- a/plugins/woocommerce/src/Blocks/Payments/Integrations/CashOnDelivery.php
+++ b/plugins/woocommerce/src/Blocks/Payments/Integrations/CashOnDelivery.php
@@ -10,6 +10,8 @@ use WC_Gateway_COD;
  * @since 3.0.0
  */
 final class CashOnDelivery extends AbstractPaymentMethodType {
+	use ShippingRestrictionsSettingsTrait;
+
 	/**
 	 * Payment method name/id/slug (matches id in WC_Gateway_COD in core).
 	 *
@@ -49,29 +51,6 @@ final class CashOnDelivery extends AbstractPaymentMethodType {
 		return filter_var( $this->get_setting( 'enabled', false ), FILTER_VALIDATE_BOOLEAN );
 	}

-	/**
-	 * Return enable_for_virtual option.
-	 *
-	 * @return boolean True if store allows COD payment for orders containing only virtual products.
-	 */
-	private function get_enable_for_virtual() {
-		return filter_var( $this->get_setting( 'enable_for_virtual', false ), FILTER_VALIDATE_BOOLEAN );
-	}
-
-	/**
-	 * Return enable_for_methods option.
-	 *
-	 * @return array Array of shipping methods (string ids) that allow COD. (If empty, all support COD.)
-	 */
-	private function get_enable_for_methods() {
-		$enable_for_methods = $this->get_setting( 'enable_for_methods', [] );
-		if ( '' === $enable_for_methods ) {
-			return [];
-		}
-		return $enable_for_methods;
-	}
-
-
 	/**
 	 * Returns an array of scripts/handles to be registered for this payment method.
 	 *
diff --git a/plugins/woocommerce/src/Blocks/Payments/Integrations/Cheque.php b/plugins/woocommerce/src/Blocks/Payments/Integrations/Cheque.php
index 7cffaf13fb7..54d91c1d2b6 100644
--- a/plugins/woocommerce/src/Blocks/Payments/Integrations/Cheque.php
+++ b/plugins/woocommerce/src/Blocks/Payments/Integrations/Cheque.php
@@ -11,6 +11,8 @@ use WC_Gateway_Cheque;
  * @since 2.6.0
  */
 final class Cheque extends AbstractPaymentMethodType {
+	use ShippingRestrictionsSettingsTrait;
+
 	/**
 	 * Payment method name defined by payment methods extending this class.
 	 *
@@ -70,9 +72,11 @@ final class Cheque extends AbstractPaymentMethodType {
 	 */
 	public function get_payment_method_data() {
 		return [
-			'title'       => $this->get_setting( 'title' ),
-			'description' => $this->get_setting( 'description' ),
-			'supports'    => $this->get_supported_features(),
+			'title'                    => $this->get_setting( 'title' ),
+			'description'              => $this->get_setting( 'description' ),
+			'enableForVirtual'         => $this->get_enable_for_virtual(),
+			'enableForShippingMethods' => $this->get_enable_for_methods(),
+			'supports'                 => $this->get_supported_features(),
 		];
 	}
 }
diff --git a/plugins/woocommerce/src/Blocks/Payments/Integrations/ShippingRestrictionsSettingsTrait.php b/plugins/woocommerce/src/Blocks/Payments/Integrations/ShippingRestrictionsSettingsTrait.php
new file mode 100644
index 00000000000..5e808c66947
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Payments/Integrations/ShippingRestrictionsSettingsTrait.php
@@ -0,0 +1,40 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Blocks\Payments\Integrations;
+
+/**
+ * Reads the shipping method restriction settings shared by the offline payment
+ * gateways (COD, BACS and Cheque) for use in the block checkout.
+ *
+ * @internal
+ * @since 11.3.0
+ */
+trait ShippingRestrictionsSettingsTrait {
+
+	/**
+	 * Return enable_for_virtual option.
+	 *
+	 * The settings come straight from the saved option, so the key is missing on
+	 * gateways that never saved it. Fall back to `true` to match the `yes` default
+	 * of the gateway form field, which is what `is_available()` enforces server-side.
+	 *
+	 * @return boolean True if the store allows this payment method for orders containing only virtual products.
+	 */
+	private function get_enable_for_virtual() {
+		return filter_var( $this->get_setting( 'enable_for_virtual', true ), FILTER_VALIDATE_BOOLEAN );
+	}
+
+	/**
+	 * Return enable_for_methods option.
+	 *
+	 * @return array Array of shipping methods (string ids) that allow this payment method. (If empty, all support it.)
+	 */
+	private function get_enable_for_methods() {
+		$enable_for_methods = $this->get_setting( 'enable_for_methods', [] );
+		if ( ! is_array( $enable_for_methods ) ) {
+			return [];
+		}
+		return $enable_for_methods;
+	}
+}
diff --git a/plugins/woocommerce/src/Gateways/ShippingMethodRestrictionsTrait.php b/plugins/woocommerce/src/Gateways/ShippingMethodRestrictionsTrait.php
new file mode 100644
index 00000000000..6817d63590b
--- /dev/null
+++ b/plugins/woocommerce/src/Gateways/ShippingMethodRestrictionsTrait.php
@@ -0,0 +1,279 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Gateways;
+
+use Automattic\Jetpack\Constants;
+use WC_Cache_Helper;
+use WC_Shipping_Zone;
+use WC_Shipping_Zones;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Lets a payment gateway restrict its availability to selected shipping methods.
+ *
+ * Adds the "Enable for shipping methods" and "Accept for virtual orders" settings
+ * and enforces them in `is_available()`. The gateway must call
+ * `init_shipping_method_restrictions()` after `init_settings()` and merge
+ * `get_shipping_method_restrictions_form_fields()` into its form fields.
+ *
+ * @since 11.3.0
+ */
+trait ShippingMethodRestrictionsTrait {
+
+	/**
+	 * Enable for shipping methods.
+	 *
+	 * @var array
+	 */
+	public $enable_for_methods;
+
+	/**
+	 * Enable for virtual products.
+	 *
+	 * @var bool
+	 */
+	public $enable_for_virtual;
+
+	/**
+	 * Load the shipping method restriction settings into the gateway properties.
+	 *
+	 * Gateways that have never saved these settings default to no restriction.
+	 *
+	 * @since 11.3.0
+	 */
+	protected function init_shipping_method_restrictions(): void {
+		$enable_for_methods = $this->get_option( 'enable_for_methods', array() );
+
+		$this->enable_for_methods = is_array( $enable_for_methods ) ? $enable_for_methods : array();
+		$this->enable_for_virtual = 'yes' === $this->get_option( 'enable_for_virtual', 'yes' );
+	}
+
+	/**
+	 * Get the form fields for the shipping method restriction settings.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @return array Form fields keyed by setting id.
+	 */
+	protected function get_shipping_method_restrictions_form_fields(): array {
+		$method_title = $this->get_method_title();
+
+		return array(
+			'enable_for_methods' => array(
+				'title'             => __( 'Enable for shipping methods', 'woocommerce' ),
+				'type'              => 'multiselect',
+				'class'             => 'wc-enhanced-select',
+				'css'               => 'width: 400px;',
+				'default'           => '',
+				/* translators: %s: payment method title. */
+				'description'       => sprintf( __( 'If %s is only available for certain methods, set it up here. Leave blank to enable for all methods.', 'woocommerce' ), $method_title ),
+				'options'           => $this->load_shipping_method_options(),
+				'desc_tip'          => true,
+				'custom_attributes' => array(
+					'data-placeholder' => __( 'Select shipping methods', 'woocommerce' ),
+				),
+			),
+			'enable_for_virtual' => array(
+				'title'   => __( 'Accept for virtual orders', 'woocommerce' ),
+				/* translators: %s: payment method title. */
+				'label'   => sprintf( __( 'Accept %s if the order is virtual', 'woocommerce' ), $method_title ),
+				'type'    => 'checkbox',
+				'default' => 'yes',
+			),
+		);
+	}
+
+	/**
+	 * Check If The Gateway Is Available For Use.
+	 *
+	 * @since 10.7.0 Added early return when gateway is disabled.
+	 * @since 11.3.0 Moved here from WC_Gateway_COD.
+	 *
+	 * @return bool
+	 */
+	public function is_available() {
+		if ( 'yes' !== $this->enabled ) {
+			return false;
+		}
+
+		$is_virtual       = true;
+		$shipping_methods = array();
+
+		// Get shipping methods from the cart or order.
+		if ( is_wc_endpoint_url( 'order-pay' ) ) {
+			$order            = wc_get_order( absint( get_query_var( 'order-pay' ) ) );
+			$shipping_methods = $order ? $order->get_shipping_methods() : array();
+			$is_virtual       = ! count( $shipping_methods );
+		} elseif ( WC()->cart && WC()->cart->needs_shipping() ) {
+			$shipping_methods = WC()->cart->get_shipping_methods();
+			$is_virtual       = false;
+		}
+
+		// If the gateway is not enabled for virtual orders and the order does not need shipping, return false.
+		if ( ! $this->enable_for_virtual && $is_virtual ) {
+			return false;
+		}
+
+		// Return early if:
+		// - There are no shipping methods restrictions in place.
+		// - The order is virtual so needs no shipping.
+		// - Shipping methods are not set yet.
+		if ( empty( $this->enable_for_methods ) || $is_virtual || ! $shipping_methods ) {
+			return parent::is_available();
+		}
+
+		// Get the selected shipping method ids. This works on both WC_Shipping_Rate and WC_Order_Item_Shipping class instances.
+		$canonical_rate_ids = array_unique(
+			array_values(
+				array_map(
+					function ( $shipping_method ) {
+						return is_object( $shipping_method ) && method_exists( $shipping_method, 'get_method_id' ) && method_exists( $shipping_method, 'get_instance_id' ) ? $shipping_method->get_method_id() . ':' . $shipping_method->get_instance_id() : null;
+					},
+					$shipping_methods
+				)
+			)
+		);
+
+		if ( ! count( $this->get_matching_rates( $canonical_rate_ids ) ) ) {
+			return false;
+		}
+
+		return parent::is_available();
+	}
+
+	/**
+	 * Checks to see whether or not the admin settings are being accessed by the current request.
+	 *
+	 * @since 11.3.0 Moved here from WC_Gateway_COD.
+	 *
+	 * @return bool
+	 */
+	protected function is_accessing_settings() {
+		if ( is_admin() ) {
+			if ( ! is_wc_admin_settings_page() ) {
+				return false;
+			}
+			// phpcs:disable WordPress.Security.NonceVerification
+			if ( ! isset( $_REQUEST['tab'] ) || 'checkout' !== $_REQUEST['tab'] ) {
+				return false;
+			}
+			if ( ! isset( $_REQUEST['section'] ) || $this->id !== $_REQUEST['section'] ) {
+				return false;
+			}
+			// phpcs:enable WordPress.Security.NonceVerification
+
+			return true;
+		}
+
+		if ( Constants::is_true( 'REST_REQUEST' ) ) {
+			global $wp;
+			if ( isset( $wp->query_vars['rest_route'] ) && false !== strpos( $wp->query_vars['rest_route'], '/payment_gateways' ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Loads all of the shipping method options for the enable_for_methods field.
+	 *
+	 * @since 11.3.0 Moved here from WC_Gateway_COD.
+	 *
+	 * @return array
+	 */
+	protected function load_shipping_method_options() {
+		// Since this is expensive, we only want to do it if we're actually on the settings page.
+		if ( ! $this->is_accessing_settings() ) {
+			return array();
+		}
+
+		return $this->get_shipping_method_options();
+	}
+
+	/**
+	 * Get all of the shipping method options for the enable_for_methods field, grouped by shipping method.
+	 *
+	 * Unlike `load_shipping_method_options()`, this always returns the options, so
+	 * only call it when they are actually needed. The zone lookup is expensive, so
+	 * the result is cached for the rest of the request and shared by every gateway
+	 * using this trait; saving a shipping zone or method invalidates it.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @return array Options keyed by shipping method title, each an array of rate id => option label.
+	 */
+	public function get_shipping_method_options(): array {
+		$cache_group = 'wc_shipping_method_options';
+		$cache_key   = 'options_' . WC_Cache_Helper::get_transient_version( 'shipping' ) . '_' . get_locale();
+
+		wp_cache_add_non_persistent_groups( array( $cache_group ) );
+
+		$options = wp_cache_get( $cache_key, $cache_group );
+		if ( is_array( $options ) ) {
+			return $options;
+		}
+
+		$zones   = WC_Shipping_Zones::get_shipping_zones();
+		$zones[] = new WC_Shipping_Zone( 0 );
+
+		// Load each zone's method instances once, since the loop below runs per registered method.
+		$zone_instances = array();
+		foreach ( $zones as $zone ) {
+			$zone_instances[] = array( $zone, $zone->get_shipping_methods() );
+		}
+
+		$options = array();
+		foreach ( WC()->shipping()->load_shipping_methods() as $method ) {
+
+			// Block and classic local pickup share a title, so keep both under one group instead of the later one wiping the earlier.
+			if ( ! isset( $options[ $method->get_method_title() ] ) ) {
+				$options[ $method->get_method_title() ] = array();
+			}
+
+			// Translators: %1$s shipping method name.
+			$options[ $method->get_method_title() ][ $method->id ] = sprintf( __( 'Any &quot;%1$s&quot; method', 'woocommerce' ), $method->get_method_title() );
+
+			foreach ( $zone_instances as list( $zone, $shipping_method_instances ) ) {
+
+				foreach ( $shipping_method_instances as $shipping_method_instance_id => $shipping_method_instance ) {
+
+					if ( $shipping_method_instance->id !== $method->id ) {
+						continue;
+					}
+
+					$option_id = $shipping_method_instance->get_rate_id();
+
+					// Translators: %1$s shipping method title, %2$s shipping method id.
+					$option_instance_title = sprintf( __( '%1$s (#%2$s)', 'woocommerce' ), $shipping_method_instance->get_title(), $shipping_method_instance_id );
+
+					// Translators: %1$s zone name, %2$s shipping method instance name.
+					$option_title = sprintf( __( '%1$s &ndash; %2$s', 'woocommerce' ), $zone->get_id() ? $zone->get_zone_name() : __( 'Other locations', 'woocommerce' ), $option_instance_title );
+
+					$options[ $method->get_method_title() ][ $option_id ] = $option_title;
+				}
+			}
+		}
+
+		wp_cache_set( $cache_key, $options, $cache_group );
+
+		return $options;
+	}
+
+	/**
+	 * Indicates whether a rate exists in an array of canonically-formatted rate IDs that activates this gateway.
+	 *
+	 * @since 3.4.0
+	 * @since 11.3.0 Moved here from WC_Gateway_COD.
+	 *
+	 * @param array $rate_ids Rate ids to check.
+	 * @return array
+	 */
+	protected function get_matching_rates( $rate_ids ) {
+		// First, match entries in 'method_id:instance_id' format. Then, match entries in 'method_id' format by stripping off the instance ID from the candidates.
+		return array_unique( array_merge( array_intersect( $this->enable_for_methods, $rate_ids ), array_intersect( $this->enable_for_methods, array_unique( array_map( 'wc_get_string_before_colon', $rate_ids ) ) ) ) );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchema.php
index c4457fec7b0..e15aa54b863 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchema.php
@@ -387,6 +387,36 @@ abstract class AbstractPaymentGatewaySettingsSchema extends AbstractSchema {
 		return array();
 	}

+	/**
+	 * Get design-aligned overrides for the shipping method restriction fields
+	 * shared by the offline gateways (COD, BACS and Cheque).
+	 *
+	 * The gateway's own multiselect options are only loaded on the classic settings
+	 * page, so the options are loaded here for the REST API.
+	 *
+	 * @param WC_Payment_Gateway $gateway Gateway instance.
+	 * @return array Map of field_id => override, to merge into the core field overrides.
+	 */
+	protected function get_shipping_method_restriction_field_overrides( WC_Payment_Gateway $gateway ): array {
+		$method_title = $gateway->get_method_title();
+
+		return array(
+			'enable_for_methods' => array(
+				'label'   => __( 'Available for shipping methods', 'woocommerce' ),
+				'type'    => 'multiselect',
+				/* translators: %s: payment method title. */
+				'desc'    => sprintf( __( 'Choose which shipping methods support %s.', 'woocommerce' ), $method_title ),
+				'options' => method_exists( $gateway, 'get_shipping_method_options' ) ? $gateway->get_shipping_method_options() : array(),
+			),
+			'enable_for_virtual' => array(
+				'label' => __( 'Accept for virtual orders', 'woocommerce' ),
+				'type'  => 'checkbox',
+				/* translators: %s: payment method title. */
+				'desc'  => sprintf( __( 'Accept %s if the order is virtual', 'woocommerce' ), $method_title ),
+			),
+		);
+	}
+
 	/**
 	 * Build fields array from gateway form_fields with design-aligned overrides.
 	 *
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/BacsGatewaySettingsSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/BacsGatewaySettingsSchema.php
index 87dced5be28..b0b66a9e486 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/BacsGatewaySettingsSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/BacsGatewaySettingsSchema.php
@@ -62,6 +62,8 @@ class BacsGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
 			),
 		);

+		$core_field_overrides = array_merge( $core_field_overrides, $this->get_shipping_method_restriction_field_overrides( $gateway ) );
+
 		// account_details is handled in a separate group, skip it in the main fields.
 		$fields = $this->build_fields_from_form_fields( $gateway, $core_field_overrides, array( 'account_details' ) );

diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/ChequeGatewaySettingsSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/ChequeGatewaySettingsSchema.php
index ed45495b2eb..72f1cebe7fb 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/ChequeGatewaySettingsSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/ChequeGatewaySettingsSchema.php
@@ -62,6 +62,8 @@ class ChequeGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
 			),
 		);

+		$core_field_overrides = array_merge( $core_field_overrides, $this->get_shipping_method_restriction_field_overrides( $gateway ) );
+
 		$fields = $this->build_fields_from_form_fields( $gateway, $core_field_overrides );

 		$group = array(
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/CodGatewaySettingsSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/CodGatewaySettingsSchema.php
index 586ec71a37d..9a93b6b5d86 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/CodGatewaySettingsSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/CodGatewaySettingsSchema.php
@@ -11,9 +11,7 @@ namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGate

 defined( 'ABSPATH' ) || exit;

-use WC_Data_Store;
 use WC_Payment_Gateway;
-use WC_Shipping_Zone;

 /**
  * CodGatewaySettingsSchema class.
@@ -23,13 +21,6 @@ use WC_Shipping_Zone;
  */
 class CodGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {

-	/**
-	 * Cached shipping method options.
-	 *
-	 * @var ?array
-	 */
-	private ?array $shipping_method_options = null;
-
 	/**
 	 * Get custom groups for the COD gateway.
 	 *
@@ -43,44 +34,35 @@ class CodGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
 	protected function get_custom_groups_for_gateway( WC_Payment_Gateway $gateway ): array {
 		// Design-aligned overrides for core fields.
 		$core_field_overrides = array(
-			'enabled'            => array(
+			'enabled'      => array(
 				'label' => __( 'Enable/Disable', 'woocommerce' ),
 				'type'  => 'checkbox',
 				'desc'  => __( 'Enable Cash on delivery at checkout', 'woocommerce' ),
 			),
-			'title'              => array(
+			'title'        => array(
 				'label' => __( 'Checkout label', 'woocommerce' ),
 				'type'  => 'text',
 				'desc'  => __( 'Shown to customers on the payment methods list at checkout.', 'woocommerce' ),
 			),
-			'description'        => array(
+			'description'  => array(
 				'label' => __( 'Checkout instructions', 'woocommerce' ),
 				'type'  => 'text',
 				'desc'  => __( 'Shown below the checkout label.', 'woocommerce' ),
 			),
-			'order'              => array(
+			'order'        => array(
 				'label' => __( 'Order', 'woocommerce' ),
 				'type'  => 'number',
 				'desc'  => __( 'Determines the display order of payment gateways during checkout.', 'woocommerce' ),
 			),
-			'instructions'       => array(
+			'instructions' => array(
 				'label' => __( 'Order confirmation instructions', 'woocommerce' ),
 				'type'  => 'text',
 				'desc'  => __( 'Shown on the order confirmation page and in order emails.', 'woocommerce' ),
 			),
-			'enable_for_methods' => array(
-				'label'   => __( 'Available for shipping methods', 'woocommerce' ),
-				'type'    => 'multiselect',
-				'desc'    => __( 'Choose which shipping methods support Cash on delivery.', 'woocommerce' ),
-				'options' => $this->load_shipping_method_options(),
-			),
-			'enable_for_virtual' => array(
-				'label' => __( 'Accept for virtual orders', 'woocommerce' ),
-				'type'  => 'checkbox',
-				'desc'  => __( 'Accept COD if the order is virtual', 'woocommerce' ),
-			),
 		);

+		$core_field_overrides = array_merge( $core_field_overrides, $this->get_shipping_method_restriction_field_overrides( $gateway ) );
+
 		$fields = $this->build_fields_from_form_fields( $gateway, $core_field_overrides );

 		$group = array(
@@ -92,67 +74,4 @@ class CodGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {

 		return array( 'settings' => $group );
 	}
-
-	/**
-	 * Load all shipping method options for the enable_for_methods field.
-	 *
-	 * This method replicates the logic from WC_Gateway_COD::load_shipping_method_options()
-	 * to provide shipping method options for the REST API without relying on the gateway class.
-	 *
-	 * Unlike the original, the is_accessing_settings() guard is intentionally omitted:
-	 * the REST API endpoint always needs options populated, and the instance-level cache
-	 * prevents redundant computation within a single request.
-	 *
-	 * @return array Nested array of shipping method options.
-	 */
-	private function load_shipping_method_options(): array {
-		if ( null !== $this->shipping_method_options ) {
-			return $this->shipping_method_options;
-		}
-
-		$data_store = WC_Data_Store::load( 'shipping-zone' );
-		$raw_zones  = $data_store->get_zones();
-		$zones      = array();
-
-		foreach ( $raw_zones as $raw_zone ) {
-			$zones[] = new WC_Shipping_Zone( $raw_zone );
-		}
-
-		$zones[] = new WC_Shipping_Zone( 0 );
-
-		$options = array();
-		foreach ( WC()->shipping()->load_shipping_methods() as $method ) {
-
-			$options[ $method->get_method_title() ] = array();
-
-			// Translators: %1$s shipping method name.
-			$options[ $method->get_method_title() ][ $method->id ] = sprintf( __( 'Any &quot;%1$s&quot; method', 'woocommerce' ), $method->get_method_title() );
-
-			foreach ( $zones as $zone ) {
-
-				$shipping_method_instances = $zone->get_shipping_methods();
-
-				foreach ( $shipping_method_instances as $shipping_method_instance_id => $shipping_method_instance ) {
-
-					if ( $shipping_method_instance->id !== $method->id ) {
-						continue;
-					}
-
-					$option_id = $shipping_method_instance->get_rate_id();
-
-					// Translators: %1$s shipping method title, %2$s shipping method id.
-					$option_instance_title = sprintf( __( '%1$s (#%2$s)', 'woocommerce' ), $shipping_method_instance->get_title(), $shipping_method_instance_id );
-
-					// Translators: %1$s zone name, %2$s shipping method instance name.
-					$option_title = sprintf( __( '%1$s &ndash; %2$s', 'woocommerce' ), $zone->get_id() ? $zone->get_zone_name() : __( 'Other locations', 'woocommerce' ), $option_instance_title );
-
-					$options[ $method->get_method_title() ][ $option_id ] = $option_title;
-				}
-			}
-		}
-
-		$this->shipping_method_options = $options;
-
-		return $options;
-	}
 }
diff --git a/plugins/woocommerce/tests/e2e/tests/api-tests/payment-gateways/payment-gateways-crud.test.ts b/plugins/woocommerce/tests/e2e/tests/api-tests/payment-gateways/payment-gateways-crud.test.ts
index 36d92f1d61f..f154c3b426c 100644
--- a/plugins/woocommerce/tests/e2e/tests/api-tests/payment-gateways/payment-gateways-crud.test.ts
+++ b/plugins/woocommerce/tests/e2e/tests/api-tests/payment-gateways/payment-gateways-crud.test.ts
@@ -6,10 +6,49 @@ import { resetGatewayOrder } from '../../../utils/payments-settings';

 const { BASE_URL } = process.env;

+/**
+ * Expected `enable_for_methods` setting for an offline gateway.
+ *
+ * The shared site setup attaches a free shipping method instance to zone 0,
+ * which adds a per-instance entry (free_shipping:<id>) alongside the base
+ * option. The instance ID is non-deterministic, so only assert the base
+ * option and tolerate the baseline instance.
+ *
+ * @param {string} methodTitle Gateway method title used in the field copy.
+ * @return {Object} Expected setting object.
+ */
+const enableForMethodsSetting = ( methodTitle ) => {
+	const description = `If ${ methodTitle } is only available for certain methods, set it up here. Leave blank to enable for all methods.`;
+
+	return {
+		id: 'enable_for_methods',
+		label: 'Enable for shipping methods',
+		description,
+		type: 'multiselect',
+		value: '',
+		default: '',
+		tip: description,
+		placeholder: '',
+		options: expect.objectContaining( {
+			'Flat rate': {
+				flat_rate: 'Any &quot;Flat rate&quot; method',
+			},
+			'Free shipping': expect.objectContaining( {
+				free_shipping: 'Any &quot;Free shipping&quot; method',
+			} ),
+			'Local pickup': {
+				local_pickup: 'Any &quot;Local pickup&quot; method',
+				pickup_location: 'Any &quot;Local pickup&quot; method',
+			},
+		} ),
+	};
+};
+
 test.describe( 'Payment Gateways API tests', () => {
 	test.beforeAll( async () => {
 		await resetGatewayOrder( BASE_URL );
 	} );
+
 	test( 'can view all payment gateways', async ( { request } ) => {
 		// call API to retrieve the payment gateways
 		const response = await request.get(
@@ -56,6 +95,19 @@ test.describe( 'Payment Gateways API tests', () => {
 							tip: 'Instructions that will be added to the thank you page and emails.',
 							placeholder: '',
 						},
+						enable_for_methods: enableForMethodsSetting(
+							'Direct bank transfer'
+						),
+						enable_for_virtual: {
+							id: 'enable_for_virtual',
+							label: 'Accept Direct bank transfer if the order is virtual',
+							description: '',
+							type: 'checkbox',
+							value: 'yes',
+							default: 'yes',
+							tip: '',
+							placeholder: '',
+						},
 					},
 				} ),
 			] )
@@ -97,6 +149,18 @@ test.describe( 'Payment Gateways API tests', () => {
 							tip: 'Instructions that will be added to the thank you page and emails.',
 							placeholder: '',
 						},
+						enable_for_methods:
+							enableForMethodsSetting( 'Check payments' ),
+						enable_for_virtual: {
+							id: 'enable_for_virtual',
+							label: 'Accept Check payments if the order is virtual',
+							description: '',
+							type: 'checkbox',
+							value: 'yes',
+							default: 'yes',
+							tip: '',
+							placeholder: '',
+						},
 					},
 				} ),
 			] )
@@ -147,38 +211,11 @@ test.describe( 'Payment Gateways API tests', () => {
 					tip: 'Instructions that will be added to the thank you page.',
 					placeholder: '',
 				},
-				enable_for_methods: {
-					id: 'enable_for_methods',
-					label: 'Enable for shipping methods',
-					description:
-						'If COD is only available for certain methods, set it up here. Leave blank to enable for all methods.',
-					type: 'multiselect',
-					value: '',
-					default: '',
-					tip: 'If COD is only available for certain methods, set it up here. Leave blank to enable for all methods.',
-					placeholder: '',
-					options: expect.objectContaining( {
-						'Flat rate': {
-							flat_rate: 'Any &quot;Flat rate&quot; method',
-						},
-						// The shared site setup attaches a free shipping method
-						// instance to zone 0, which adds a per-instance entry
-						// (free_shipping:<id>) alongside the base option. The
-						// instance ID is non-deterministic, so only assert the
-						// base option and tolerate the baseline instance.
-						'Free shipping': expect.objectContaining( {
-							free_shipping:
-								'Any &quot;Free shipping&quot; method',
-						} ),
-						'Local pickup': {
-							pickup_location:
-								'Any &quot;Local pickup&quot; method',
-						},
-					} ),
-				},
+				enable_for_methods:
+					enableForMethodsSetting( 'Cash on delivery' ),
 				enable_for_virtual: {
 					id: 'enable_for_virtual',
-					label: 'Accept COD if the order is virtual',
+					label: 'Accept Cash on delivery if the order is virtual',
 					description: '',
 					type: 'checkbox',
 					value: 'yes',
@@ -235,6 +272,19 @@ test.describe( 'Payment Gateways API tests', () => {
 						tip: 'Instructions that will be added to the thank you page and emails.',
 						placeholder: '',
 					},
+					enable_for_methods: enableForMethodsSetting(
+						'Direct bank transfer'
+					),
+					enable_for_virtual: {
+						id: 'enable_for_virtual',
+						label: 'Accept Direct bank transfer if the order is virtual',
+						description: '',
+						type: 'checkbox',
+						value: 'yes',
+						default: 'yes',
+						tip: '',
+						placeholder: '',
+					},
 				},
 			} )
 		);
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/payment-gateways.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/payment-gateways.php
index f4b449542e1..84fd83f8e50 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/payment-gateways.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/payment-gateways.php
@@ -350,7 +350,7 @@ class Payment_Gateways_V2 extends WC_REST_Unit_Test_Case {
 				'label'       => empty( $field['label'] ) ? $field['title'] : $field['label'],
 				'description' => empty( $field['description'] ) ? '' : $field['description'],
 				'type'        => $field['type'],
-				'value'       => $gateway->settings[ $id ],
+				'value'       => empty( $gateway->settings[ $id ] ) ? '' : $gateway->settings[ $id ],
 				'default'     => empty( $field['default'] ) ? '' : $field['default'],
 				'tip'         => empty( $field['description'] ) ? '' : $field['description'],
 				'placeholder' => empty( $field['placeholder'] ) ? '' : $field['placeholder'],
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/payment-gateways.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/payment-gateways.php
index ae1fc927f16..003de832293 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/payment-gateways.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/payment-gateways.php
@@ -358,7 +358,7 @@ class Payment_Gateways extends WC_REST_Unit_Test_Case {
 				'label'       => empty( $field['label'] ) ? $field['title'] : $field['label'],
 				'description' => empty( $field['description'] ) ? '' : $field['description'],
 				'type'        => $field['type'],
-				'value'       => $gateway->settings[ $id ],
+				'value'       => empty( $gateway->settings[ $id ] ) ? '' : $gateway->settings[ $id ],
 				'default'     => empty( $field['default'] ) ? '' : $field['default'],
 				'tip'         => empty( $field['description'] ) ? '' : $field['description'],
 				'placeholder' => empty( $field['placeholder'] ) ? '' : $field['placeholder'],
diff --git a/plugins/woocommerce/tests/php/src/Gateways/ShippingMethodRestrictionsTraitTest.php b/plugins/woocommerce/tests/php/src/Gateways/ShippingMethodRestrictionsTraitTest.php
new file mode 100644
index 00000000000..07819243536
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Gateways/ShippingMethodRestrictionsTraitTest.php
@@ -0,0 +1,349 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Gateways;
+
+use Automattic\WooCommerce\Blocks\Shipping\PickupLocation;
+use WC_Cache_Helper;
+use WC_Gateway_BACS;
+use WC_Gateway_Cheque;
+use WC_Gateway_COD;
+use WC_Helper_Product;
+use WC_Helper_Shipping;
+use WC_Payment_Gateway;
+use WC_Shipping_Zone;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ShippingMethodRestrictionsTrait, through the offline gateways that use it.
+ */
+class ShippingMethodRestrictionsTraitTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Shipping zone matching the test customer's address.
+	 *
+	 * @var WC_Shipping_Zone
+	 */
+	private $zone;
+
+	/**
+	 * Canonical rate ids ("method_id:instance_id") of the zone's methods, keyed by a symbolic name.
+	 *
+	 * @var array<string, string>
+	 */
+	private $rate_ids = array();
+
+	/**
+	 * Shipping enabled state before the test.
+	 *
+	 * @var bool
+	 */
+	private $shipping_was_enabled;
+
+	/**
+	 * Set up a zone with several shipping methods so the real cart can resolve chosen rates.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->shipping_was_enabled = WC()->shipping()->enabled;
+		WC()->shipping()->enabled   = true;
+		WC_Helper_Shipping::force_customer_us_address();
+
+		$this->zone = new WC_Shipping_Zone();
+		$this->zone->set_zone_name( 'Restrictions zone' );
+		$this->zone->add_location( 'US', 'country' );
+		$this->zone->save();
+
+		$this->rate_ids = array(
+			'flat_rate_a'   => 'flat_rate:' . $this->zone->add_shipping_method( 'flat_rate' ),
+			'flat_rate_b'   => 'flat_rate:' . $this->zone->add_shipping_method( 'flat_rate' ),
+			'free_shipping' => 'free_shipping:' . $this->zone->add_shipping_method( 'free_shipping' ),
+		);
+
+		WC_Cache_Helper::get_transient_version( 'shipping', true );
+		WC()->shipping()->load_shipping_methods();
+	}
+
+	/**
+	 * Restore the state the base test case does not reset; the zone itself is rolled back with the database transaction.
+	 */
+	public function tearDown(): void {
+		try {
+			WC()->session->set( 'chosen_shipping_methods', null );
+			WC_Cache_Helper::get_transient_version( 'shipping', true );
+			WC()->shipping()->enabled = $this->shipping_was_enabled;
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * Data provider with the gateway classes using the trait.
+	 *
+	 * @return array
+	 */
+	public function gateway_classes(): array {
+		return array(
+			'cod'    => array( WC_Gateway_COD::class ),
+			'bacs'   => array( WC_Gateway_BACS::class ),
+			'cheque' => array( WC_Gateway_Cheque::class ),
+		);
+	}
+
+	/**
+	 * @testdox Should add the shipping method restriction fields to the gateway settings form.
+	 * @dataProvider gateway_classes
+	 *
+	 * @param string $gateway_class Gateway class name.
+	 */
+	public function test_form_fields_include_shipping_method_restrictions( string $gateway_class ): void {
+		$form_fields = ( new $gateway_class() )->get_form_fields();
+
+		$this->assertSame( 'multiselect', $form_fields['enable_for_methods']['type'] ?? null, "$gateway_class should have the enable_for_methods multiselect" );
+		$this->assertSame( 'checkbox', $form_fields['enable_for_virtual']['type'] ?? null, "$gateway_class should have the enable_for_virtual checkbox" );
+		$this->assertSame( 'yes', $form_fields['enable_for_virtual']['default'], 'Virtual orders should be accepted by default' );
+	}
+
+	/**
+	 * @testdox Should default to no restriction when the gateway settings were saved before the fields existed.
+	 * @dataProvider gateway_classes
+	 *
+	 * @param string $gateway_class Gateway class name.
+	 */
+	public function test_defaults_to_no_restriction_for_previously_saved_settings( string $gateway_class ): void {
+		$gateway = $this->create_gateway(
+			$gateway_class,
+			array(
+				'enabled' => 'yes',
+				'title'   => 'Offline payment',
+			)
+		);
+		$this->fill_cart( 'free_shipping' );
+
+		$this->assertSame( array(), $gateway->enable_for_methods, 'No shipping method restriction should be applied' );
+		$this->assertTrue( $gateway->enable_for_virtual, 'Virtual orders should be accepted' );
+		$this->assertTrue( $gateway->is_available(), 'The gateway should be available for any shipping method' );
+	}
+
+	/**
+	 * @testdox Should only be available when a selected shipping method matches the restriction.
+	 * @dataProvider availability_scenarios
+	 *
+	 * @param array  $enable_for_methods Restriction saved in the settings, as symbolic rate names or bare method ids.
+	 * @param string $chosen_rate        Symbolic name of the shipping rate selected in the cart.
+	 * @param bool   $expected           Expected availability.
+	 */
+	public function test_is_available_respects_shipping_method_restrictions( array $enable_for_methods, string $chosen_rate, bool $expected ): void {
+		$gateway = $this->create_gateway(
+			WC_Gateway_BACS::class,
+			array(
+				'enabled'            => 'yes',
+				'enable_for_methods' => array_map( array( $this, 'resolve_rate_id' ), $enable_for_methods ),
+				'enable_for_virtual' => 'yes',
+			)
+		);
+		$this->fill_cart( $chosen_rate );
+
+		$this->assertSame( $expected, $gateway->is_available() );
+	}
+
+	/**
+	 * Data provider for the shipping method restriction scenarios.
+	 *
+	 * Rates are named symbolically because the zone's instance ids are only known once it is created in setUp().
+	 *
+	 * @return array
+	 */
+	public function availability_scenarios(): array {
+		return array(
+			'matching instance'          => array( array( 'flat_rate_a' ), 'flat_rate_a', true ),
+			'other instance'             => array( array( 'flat_rate_a' ), 'flat_rate_b', false ),
+			'other method'               => array( array( 'flat_rate_a' ), 'free_shipping', false ),
+			'any instance of the method' => array( array( 'flat_rate' ), 'flat_rate_b', true ),
+			'one of several'             => array( array( 'free_shipping', 'flat_rate_a' ), 'flat_rate_a', true ),
+		);
+	}
+
+	/**
+	 * @testdox Should honor the "Accept for virtual orders" setting when the cart needs no shipping.
+	 * @testWith ["yes", true]
+	 *           ["no", false]
+	 *
+	 * @param string $enable_for_virtual Saved setting value.
+	 * @param bool   $expected           Expected availability.
+	 */
+	public function test_is_available_respects_enable_for_virtual( string $enable_for_virtual, bool $expected ): void {
+		$gateway = $this->create_gateway(
+			WC_Gateway_Cheque::class,
+			array(
+				'enabled'            => 'yes',
+				'enable_for_methods' => array( $this->rate_ids['flat_rate_a'] ),
+				'enable_for_virtual' => $enable_for_virtual,
+			)
+		);
+		$this->fill_cart( null );
+
+		$this->assertSame( $expected, $gateway->is_available() );
+	}
+
+	/**
+	 * @testdox Should not consult the cart when the gateway is disabled.
+	 * @dataProvider gateway_classes
+	 *
+	 * @param string $gateway_class Gateway class name.
+	 */
+	public function test_is_available_returns_false_when_disabled( string $gateway_class ): void {
+		$gateway = $this->create_gateway( $gateway_class, array( 'enabled' => 'no' ) );
+		$this->fill_cart( 'flat_rate_a' );
+
+		$needs_shipping_calls = 0;
+		add_filter(
+			'woocommerce_cart_needs_shipping',
+			function ( $needs_shipping ) use ( &$needs_shipping_calls ) {
+				++$needs_shipping_calls;
+				return $needs_shipping;
+			}
+		);
+
+		$this->assertFalse( $gateway->is_available() );
+		$this->assertSame( 0, $needs_shipping_calls, 'The cart should not be queried for disabled gateways' );
+	}
+
+	/**
+	 * @testdox Should load the shipping method options only when the gateway settings page is requested.
+	 * @dataProvider gateway_classes
+	 *
+	 * @param string $gateway_class Gateway class name.
+	 */
+	public function test_shipping_method_options_load_only_on_settings_page( string $gateway_class ): void {
+		$this->assertEmpty( ( new $gateway_class() )->get_form_fields()['enable_for_methods']['options'], 'Options should not be loaded outside the settings page' );
+
+		set_current_screen( 'woocommerce_page_wc-settings' );
+		$_REQUEST['page']    = 'wc-settings';
+		$_REQUEST['tab']     = 'checkout';
+		$_REQUEST['section'] = $gateway_class::ID;
+
+		try {
+			$options = ( new $gateway_class() )->get_form_fields()['enable_for_methods']['options'];
+		} finally {
+			$GLOBALS['current_screen'] = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+			unset( $_REQUEST['page'], $_REQUEST['tab'], $_REQUEST['section'] );
+		}
+
+		$this->assertNotEmpty( $options, 'Options should be loaded on the gateway settings page' );
+	}
+
+	/**
+	 * @testdox Should share the shipping method options between gateways for the rest of the request and refresh them when a zone changes.
+	 */
+	public function test_shipping_method_options_are_cached_per_request_and_invalidated_on_zone_change(): void {
+		$cod_options = ( new WC_Gateway_COD() )->get_shipping_method_options();
+		$this->assertArrayHasKey( $this->rate_ids['flat_rate_a'], $cod_options['Flat rate'] );
+
+		$zone_query_count = 0;
+		$count_queries    = function ( $query ) use ( &$zone_query_count ) {
+			if ( false !== strpos( $query, 'woocommerce_shipping_zone_methods' ) ) {
+				++$zone_query_count;
+			}
+			return $query;
+		};
+		add_filter( 'query', $count_queries );
+
+		try {
+			$bacs_options = ( new WC_Gateway_BACS() )->get_shipping_method_options();
+		} finally {
+			remove_filter( 'query', $count_queries );
+		}
+
+		$this->assertSame( $cod_options, $bacs_options );
+		$this->assertSame( 0, $zone_query_count, 'Another gateway should reuse the options loaded earlier in the request' );
+
+		$new_instance_id = $this->zone->add_shipping_method( 'local_pickup' );
+
+		$this->assertArrayHasKey( 'local_pickup:' . $new_instance_id, ( new WC_Gateway_Cheque() )->get_shipping_method_options()['Local pickup'], 'Changing a zone should refresh the options' );
+	}
+
+	/**
+	 * @testdox Should keep the classic and block local pickup methods under the same option group since they share a title.
+	 */
+	public function test_shipping_method_options_group_block_and_classic_local_pickup_together(): void {
+		$local_pickup_instance_id = $this->zone->add_shipping_method( 'local_pickup' );
+
+		// The block checkout registers its own "Local pickup" method after the classic one; keep the registration to this test.
+		$register_pickup_location = function ( $methods ) {
+			$methods['pickup_location'] = new PickupLocation();
+			return $methods;
+		};
+		add_filter( 'woocommerce_shipping_methods', $register_pickup_location );
+
+		try {
+			WC()->shipping()->load_shipping_methods();
+			$options = ( new WC_Gateway_COD() )->get_shipping_method_options();
+		} finally {
+			remove_filter( 'woocommerce_shipping_methods', $register_pickup_location );
+			WC()->shipping()->load_shipping_methods();
+		}
+
+		$this->assertArrayHasKey( 'local_pickup', $options['Local pickup'], 'The classic "any" option should survive registering the block method' );
+		$this->assertArrayHasKey( 'local_pickup:' . $local_pickup_instance_id, $options['Local pickup'], 'The classic instance should survive registering the block method' );
+		$this->assertArrayHasKey( 'pickup_location', $options['Local pickup'], 'The block "any" option should be listed too' );
+	}
+
+	/**
+	 * Save the given settings for a gateway and instantiate it.
+	 *
+	 * @param string $gateway_class Gateway class name.
+	 * @param array  $settings      Settings to save.
+	 * @return WC_Payment_Gateway
+	 */
+	private function create_gateway( string $gateway_class, array $settings ): WC_Payment_Gateway {
+		update_option( 'woocommerce_' . $gateway_class::ID . '_settings', $settings );
+
+		return new $gateway_class();
+	}
+
+	/**
+	 * Translate a symbolic rate name from the data providers into the zone's real rate id.
+	 *
+	 * Bare method ids such as "flat_rate" (any instance) are passed through unchanged.
+	 *
+	 * @param string $name Symbolic rate name or method id.
+	 * @return string
+	 */
+	private function resolve_rate_id( string $name ): string {
+		return $this->rate_ids[ $name ] ?? $name;
+	}
+
+	/**
+	 * Put a product in the real cart and select a shipping rate for it.
+	 *
+	 * @param string|null $chosen_rate Symbolic name of the rate to select, or null for a virtual-only cart that needs no shipping.
+	 */
+	private function fill_cart( ?string $chosen_rate ): void {
+		WC()->cart->empty_cart();
+
+		$product = WC_Helper_Product::create_simple_product( true, array( 'virtual' => null === $chosen_rate ) );
+		WC()->cart->add_to_cart( $product->get_id() );
+
+		if ( null === $chosen_rate ) {
+			WC()->cart->calculate_totals();
+			$this->assertFalse( WC()->cart->needs_shipping(), 'Precondition: a virtual-only cart should not need shipping' );
+			return;
+		}
+
+		WC()->session->set( 'chosen_shipping_methods', array( $this->rate_ids[ $chosen_rate ] ) );
+		WC()->cart->calculate_totals();
+
+		$this->assertSame(
+			array( $this->rate_ids[ $chosen_rate ] ),
+			array_map(
+				function ( $rate ) {
+					return $rate->get_id();
+				},
+				array_values( WC()->cart->get_shipping_methods() )
+			),
+			'Precondition: the real cart should resolve the chosen shipping rate'
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/PaymentGatewaysSettingsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/PaymentGatewaysSettingsControllerTest.php
index 964ade9dafb..fad7c3c4687 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/PaymentGatewaysSettingsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/PaymentGatewaysSettingsControllerTest.php
@@ -877,11 +877,27 @@ class PaymentGatewaysSettingsControllerTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * Test that COD gateway enable_for_methods field has options populated.
+	 * Data provider with the offline gateways that support shipping method restrictions.
+	 *
+	 * @return array
+	 */
+	public function offline_gateway_ids(): array {
+		return array(
+			'cod'    => array( 'cod' ),
+			'bacs'   => array( 'bacs' ),
+			'cheque' => array( 'cheque' ),
+		);
+	}
+
+	/**
+	 * @testdox Should populate the enable_for_methods options for the offline gateways.
+	 * @dataProvider offline_gateway_ids
+	 *
+	 * @param string $gateway_id Gateway ID.
 	 */
-	public function test_cod_gateway_enable_for_methods_has_options() {
+	public function test_offline_gateway_enable_for_methods_has_options( string $gateway_id ) {
 		// Act.
-		$request  = new WP_REST_Request( 'GET', self::ENDPOINT . '/cod' );
+		$request  = new WP_REST_Request( 'GET', self::ENDPOINT . '/' . $gateway_id );
 		$response = $this->server->dispatch( $request );

 		// Assert.
@@ -902,7 +918,7 @@ class PaymentGatewaysSettingsControllerTest extends WC_Unit_Test_Case {
 		}

 		// Verify the field exists.
-		$this->assertNotNull( $enable_for_methods_field, 'enable_for_methods field should exist in COD gateway fields' );
+		$this->assertNotNull( $enable_for_methods_field, "enable_for_methods field should exist in $gateway_id gateway fields" );

 		// Verify field metadata.
 		$this->assertSame( 'enable_for_methods', $enable_for_methods_field['id'] );