Commit ffce77d0cb7 for woocommerce
commit ffce77d0cb7dbcc2214e196f14d9073e50552628
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date: Thu Mar 19 15:10:24 2026 +0200
Add design-aligned gateway settings schemas for offline payment methods (#63734)
* Add design-aligned gateway settings schemas for offline payment methods
The v4 payment gateway settings API returns generic field labels from
WC_Gateway form_fields (e.g., "Title", "Description"). The CIAB admin
needs design-specific labels ("Checkout label", "Checkout instructions")
to match the Figma designs without frontend overrides.
Add get_custom_groups_for_gateway() overrides for all three offline
payment gateways (cheque, BACS, COD) with curated field labels,
descriptions, and group metadata. This moves label ownership to the API
layer so any consumer gets design-aligned copy.
Refs WOOPRD-3080
* Add changelog for offline payment gateway settings schemas
* chore(bacs): remove unreachable get_special_field_schemas override
The new get_custom_groups_for_gateway() override returns a non-empty
array, which causes AbstractPaymentGatewaySettingsSchema::get_groups()
to short-circuit before calling get_default_group() — the only call
site for get_special_field_schemas(). This makes the BACS override
unreachable and it duplicates the account_details field definition
already present in get_custom_groups_for_gateway().
* refactor(cheque,cod): remove unused init_form_fields and align array style
Cheque and COD gateway schemas call $gateway->init_form_fields() but
never access $gateway->form_fields — all field metadata is hardcoded
inline. The abstract class already calls init_form_fields() in
get_values() and validate_and_sanitize_settings(), so the call is
redundant. Also aligns ChequeGatewaySettingsSchema to use inline array
initialization matching sibling classes.
* chore(cod): remove unreachable get_field_options override
The new get_custom_groups_for_gateway() override short-circuits
get_groups() before it reaches get_default_group(), which is the only
path to transform_field_to_schema() and get_field_options(). The
shipping method options are already inlined via
load_shipping_method_options() in get_custom_groups_for_gateway().
* docs(cheque): add comment for intentional instructions label difference
The cheque gateway uses a different label for the instructions field
than BACS/COD per design spec. Adding a comment to prevent reviewers
from flagging it as an inconsistency.
* refactor: derive gateway schema fields from form_fields
The custom group overrides previously returned a hardcoded field
whitelist, which would silently drop any settings injected by
extensions via form_fields filters.
Add build_fields_from_form_fields() to the abstract schema class that
iterates the gateway's form_fields, applies design-aligned label
overrides for known core IDs, and appends any extension-injected fields.
All three offline gateway schemas now use this helper.
* refactor: centralize init_form_fields() in abstract gateway schema
All three gateway schema subclasses called $gateway->init_form_fields()
as their first statement in get_custom_groups_for_gateway(), and
get_default_group() called it independently. This created an implicit
requirement for future subclass authors and duplicated logic.
Move the call into get_groups() before delegating to either
get_custom_groups_for_gateway() or get_default_group(), so field
initialization happens exactly once regardless of path.
* fix: cache COD shipping options and add array to field type enum
CodGatewaySettingsSchema::load_shipping_method_options() loads all
shipping zones and iterates zones × methods on every call. Add a
nullable property cache to prevent redundant computation if the method
is called more than once per request.
Also add 'array' to the field type enum in the abstract schema so the
BACS account_details field (type 'array') passes REST API schema
validation without warnings.
* test: add unit tests for build_fields_from_form_fields helper
All three gateway schemas depend on build_fields_from_form_fields()
which has several distinct code paths. Add targeted tests covering:
synthetic order field, extension field preservation, skip_field_ids,
override label application, multiselect options fallback, override
options precedence, non-data field skipping, missing form_fields
handling, and field ordering.
* chore: address review nits from approval
Document why is_accessing_settings() guard is intentionally omitted
in CodGatewaySettingsSchema::load_shipping_method_options() — the REST
API always needs options, and the instance cache handles the cost.
Remove unused WP_REST_Request import from the test file.
---------
Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/63734-add-cheque-gateway-settings-schema b/plugins/woocommerce/changelog/63734-add-cheque-gateway-settings-schema
new file mode 100644
index 00000000000..e18a9d8fa60
--- /dev/null
+++ b/plugins/woocommerce/changelog/63734-add-cheque-gateway-settings-schema
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add design-aligned grouped settings schemas for offline payment gateways (cheque, BACS, COD) in the v4 settings API.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Controller.php
index 1b334c07634..60c29078f5f 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Controller.php
@@ -14,6 +14,7 @@ namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGate
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractController;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\AbstractPaymentGatewaySettingsSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\BacsGatewaySettingsSchema;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\ChequeGatewaySettingsSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\CodGatewaySettingsSchema;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\PaymentGatewaySettingsSchema;
use WC_Payment_Gateway;
@@ -185,6 +186,8 @@ class Controller extends AbstractController {
switch ( $gateway_id ) {
case 'bacs':
return new BacsGatewaySettingsSchema();
+ case 'cheque':
+ return new ChequeGatewaySettingsSchema();
case 'cod':
return new CodGatewaySettingsSchema();
default:
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 57bcbfbd806..1e0ab7eed08 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
@@ -170,7 +170,7 @@ abstract class AbstractPaymentGatewaySettingsSchema extends AbstractSchema {
'type' => array(
'description' => __( 'Setting field type.', 'woocommerce' ),
'type' => 'string',
- 'enum' => array( 'text', 'number', 'select', 'multiselect', 'checkbox' ),
+ 'enum' => array( 'text', 'number', 'select', 'multiselect', 'checkbox', 'array' ),
'context' => array( 'view', 'edit' ),
),
'options' => array(
@@ -235,6 +235,8 @@ abstract class AbstractPaymentGatewaySettingsSchema extends AbstractSchema {
* @return array
*/
private function get_groups( WC_Payment_Gateway $gateway ): array {
+ $gateway->init_form_fields();
+
// Check if gateway has custom grouping.
$custom_groups = $this->get_custom_groups_for_gateway( $gateway );
if ( ! empty( $custom_groups ) ) {
@@ -264,8 +266,6 @@ abstract class AbstractPaymentGatewaySettingsSchema extends AbstractSchema {
* @return array
*/
private function get_default_group( WC_Payment_Gateway $gateway ): array {
- $gateway->init_form_fields();
-
$group = array(
'title' => __( 'Settings', 'woocommerce' ),
'description' => '',
@@ -387,6 +387,66 @@ abstract class AbstractPaymentGatewaySettingsSchema extends AbstractSchema {
return array();
}
+ /**
+ * Build fields array from gateway form_fields with design-aligned overrides.
+ *
+ * Iterates the gateway's form_fields to build the schema fields list.
+ * Known core fields use the provided overrides for labels and descriptions.
+ * Extension-injected fields are preserved and transformed to schema format.
+ * The 'order' field (display position) is always appended as it does not
+ * come from form_fields.
+ *
+ * @param WC_Payment_Gateway $gateway Gateway instance.
+ * @param array $core_field_overrides Map of field_id => array( 'label', 'type', 'desc' ).
+ * @param array $skip_field_ids Field IDs to skip (e.g., special fields handled elsewhere).
+ * @return array Schema fields list.
+ */
+ protected function build_fields_from_form_fields( WC_Payment_Gateway $gateway, array $core_field_overrides, array $skip_field_ids = array() ): array {
+ $fields = array();
+
+ // Add core fields in the order defined by overrides first.
+ foreach ( $core_field_overrides as $field_id => $override ) {
+ // The 'order' field is synthetic (not in form_fields), always add it from overrides.
+ if ( 'order' === $field_id ) {
+ $fields[] = array_merge( array( 'id' => $field_id ), $override );
+ continue;
+ }
+
+ if ( ! isset( $gateway->form_fields[ $field_id ] ) ) {
+ continue;
+ }
+
+ $field = $gateway->form_fields[ $field_id ];
+ $schema_field = array_merge( array( 'id' => $field_id ), $override );
+
+ // Preserve options from form_fields for select/multiselect fields.
+ if ( in_array( $schema_field['type'] ?? '', array( 'select', 'multiselect' ), true ) && ! isset( $schema_field['options'] ) ) {
+ if ( ! empty( $field['options'] ) ) {
+ $schema_field['options'] = $field['options'];
+ }
+ }
+
+ $fields[] = $schema_field;
+ }
+
+ // Append any extension-injected fields not in core overrides or skip list.
+ foreach ( $gateway->form_fields as $field_id => $field ) {
+ $field_type = $field['type'] ?? '';
+
+ // Skip non-data fields, already-handled core fields, and explicitly skipped fields.
+ if ( in_array( $field_type, array( 'title', 'sectionend' ), true ) ||
+ isset( $core_field_overrides[ $field_id ] ) ||
+ in_array( $field_id, $skip_field_ids, true ) ||
+ $this->is_special_field( $field_id ) ) {
+ continue;
+ }
+
+ $fields[] = $this->transform_field_to_schema( $field_id, $field, $gateway );
+ }
+
+ return $fields;
+ }
+
/**
* Normalize WooCommerce field types to standard REST API types.
*
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 1f65c7a33ec..87dced5be28 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
@@ -17,40 +17,92 @@ use WP_Error;
/**
* BacsGatewaySettingsSchema class.
*
- * Extends AbstractPaymentGatewaySettingsSchema to handle BACS-specific settings.
+ * Extends AbstractPaymentGatewaySettingsSchema to handle BACS-specific settings
+ * with design-aligned field labels and descriptions.
*/
class BacsGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
+
/**
- * Get values for BACS-specific special fields.
+ * Get custom groups for the BACS gateway.
+ *
+ * Provides design-aligned labels and descriptions, and separates
+ * account details into its own group. Derives fields from the gateway's
+ * form_fields to preserve any extension-injected settings.
*
* @param WC_Payment_Gateway $gateway Gateway instance.
- * @return array
+ * @return array Custom group structure.
*/
- protected function get_special_field_values( WC_Payment_Gateway $gateway ): array {
+ protected function get_custom_groups_for_gateway( WC_Payment_Gateway $gateway ): array {
+ // Design-aligned overrides for core fields.
+ $core_field_overrides = array(
+ 'enabled' => array(
+ 'label' => __( 'Enable/Disable', 'woocommerce' ),
+ 'type' => 'checkbox',
+ 'desc' => __( 'Enable Direct bank transfer at checkout', 'woocommerce' ),
+ ),
+ 'title' => array(
+ 'label' => __( 'Checkout label', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc' => __( 'Shown to customers on the payment methods list at checkout.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'label' => __( 'Checkout instructions', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc' => __( 'Shown below the checkout label.', 'woocommerce' ),
+ ),
+ 'order' => array(
+ 'label' => __( 'Order', 'woocommerce' ),
+ 'type' => 'number',
+ 'desc' => __( 'Determines the display order of payment gateways during checkout.', 'woocommerce' ),
+ ),
+ 'instructions' => array(
+ 'label' => __( 'Order confirmation instructions', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc' => __( 'Shown on the order confirmation page and in order emails.', 'woocommerce' ),
+ ),
+ );
+
+ // 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' ) );
+
+ $settings_group = array(
+ 'title' => __( 'Direct bank transfer settings', 'woocommerce' ),
+ 'description' => __( 'Manage how Direct bank transfer appears at checkout and in order emails.', 'woocommerce' ),
+ 'order' => 1,
+ 'fields' => $fields,
+ );
+
+ $field = $gateway->form_fields['account_details'] ?? array();
+
+ $account_details_group = array(
+ 'title' => __( 'Bank account details', 'woocommerce' ),
+ 'description' => __( 'Manage the bank accounts customers can use to pay by bank transfer.', 'woocommerce' ),
+ 'order' => 2,
+ 'fields' => array(
+ array(
+ 'id' => 'account_details',
+ 'label' => $field['title'] ?? __( 'Account details', 'woocommerce' ),
+ 'type' => 'array',
+ 'desc' => $field['description'] ?? __( 'Bank account details for direct bank transfer.', 'woocommerce' ),
+ ),
+ ),
+ );
+
return array(
- 'account_details' => get_option( 'woocommerce_bacs_accounts', array() ),
+ 'settings' => $settings_group,
+ 'account_details' => $account_details_group,
);
}
/**
- * Get field schemas for BACS-specific special fields.
+ * Get values for BACS-specific special fields.
*
* @param WC_Payment_Gateway $gateway Gateway instance.
* @return array
*/
- protected function get_special_field_schemas( WC_Payment_Gateway $gateway ): array {
- $gateway->init_form_fields();
-
- // Start with information from the gateway's form_fields if available.
- $field = $gateway->form_fields['account_details'] ?? array();
-
+ protected function get_special_field_values( WC_Payment_Gateway $gateway ): array {
return array(
- array(
- 'id' => 'account_details',
- 'label' => $field['title'] ?? __( 'Account details', 'woocommerce' ),
- 'type' => 'array',
- 'desc' => $field['description'] ?? __( 'Bank account details for direct bank transfer.', 'woocommerce' ),
- ),
+ 'account_details' => get_option( 'woocommerce_bacs_accounts', array() ),
);
}
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
new file mode 100644
index 00000000000..ed45495b2eb
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/ChequeGatewaySettingsSchema.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * ChequeGatewaySettingsSchema class.
+ *
+ * @package WooCommerce\RestApi
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema;
+
+defined( 'ABSPATH' ) || exit;
+
+use WC_Payment_Gateway;
+
+/**
+ * ChequeGatewaySettingsSchema class.
+ *
+ * Extends AbstractPaymentGatewaySettingsSchema for the Check payment gateway
+ * with design-aligned field labels and descriptions.
+ */
+class ChequeGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
+
+ /**
+ * Get custom groups for the cheque gateway.
+ *
+ * Provides design-aligned labels and descriptions for the check payment
+ * settings form fields. Derives fields from the gateway's form_fields
+ * to preserve any extension-injected settings.
+ *
+ * @param WC_Payment_Gateway $gateway Gateway instance.
+ * @return array Custom group structure.
+ */
+ protected function get_custom_groups_for_gateway( WC_Payment_Gateway $gateway ): array {
+ // Design-aligned overrides for core fields.
+ $core_field_overrides = array(
+ 'enabled' => array(
+ 'label' => __( 'Enable/Disable', 'woocommerce' ),
+ 'type' => 'checkbox',
+ 'desc' => __( 'Enable check payments at checkout', 'woocommerce' ),
+ ),
+ 'title' => array(
+ 'label' => __( 'Checkout label', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc' => __( 'Shown to customers on the payment methods list at checkout.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'label' => __( 'Checkout instructions', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc' => __( 'Shown below the checkout label.', 'woocommerce' ),
+ ),
+ 'order' => array(
+ 'label' => __( 'Order', 'woocommerce' ),
+ 'type' => 'number',
+ 'desc' => __( 'Determines the display order of payment gateways during checkout.', 'woocommerce' ),
+ ),
+ // Intentionally differs from BACS/COD ("Order confirmation instructions") per design spec.
+ 'instructions' => array(
+ 'label' => __( 'Instructions shown after checkout', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc' => __( 'Shown on the order confirmation page and in order emails.', 'woocommerce' ),
+ ),
+ );
+
+ $fields = $this->build_fields_from_form_fields( $gateway, $core_field_overrides );
+
+ $group = array(
+ 'title' => __( 'Check payment settings', 'woocommerce' ),
+ 'description' => __( 'Manage how check payments appear at checkout and in order emails.', 'woocommerce' ),
+ 'order' => 1,
+ 'fields' => $fields,
+ );
+
+ return array( 'settings' => $group );
+ }
+}
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 43fe8a55e0b..c79f9c81f57 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
@@ -12,31 +12,85 @@ 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.
*
- * Extends AbstractPaymentGatewaySettingsSchema for Cash on Delivery payment gateway.
- *
- * Note: The COD gateway has enable_for_methods and enable_for_virtual fields
- * which are standard fields stored in gateway settings.
+ * Extends AbstractPaymentGatewaySettingsSchema for Cash on Delivery payment gateway
+ * with design-aligned field labels and descriptions.
*/
class CodGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
/**
- * Get options for specific COD gateway fields.
+ * Cached shipping method options.
*
- * @param string $field_id Field ID.
- * @return array Field options.
+ * @var ?array
*/
- protected function get_field_options( string $field_id ): array {
- switch ( $field_id ) {
- case 'enable_for_methods':
- return $this->load_shipping_method_options();
- default:
- return array();
- }
+ private ?array $shipping_method_options = null;
+
+ /**
+ * Get custom groups for the COD gateway.
+ *
+ * Provides design-aligned labels and descriptions for the cash on delivery
+ * settings form fields. Derives fields from the gateway's form_fields
+ * to preserve any extension-injected settings.
+ *
+ * @param WC_Payment_Gateway $gateway Gateway instance.
+ * @return array Custom group structure.
+ */
+ protected function get_custom_groups_for_gateway( WC_Payment_Gateway $gateway ): array {
+ // Design-aligned overrides for core fields.
+ $core_field_overrides = array(
+ 'enabled' => array(
+ 'label' => __( 'Enable/Disable', 'woocommerce' ),
+ 'type' => 'checkbox',
+ 'desc' => __( 'Enable Cash on delivery at checkout', 'woocommerce' ),
+ ),
+ 'title' => array(
+ 'label' => __( 'Checkout label', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc' => __( 'Shown to customers on the payment methods list at checkout.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'label' => __( 'Checkout instructions', 'woocommerce' ),
+ 'type' => 'text',
+ 'desc' => __( 'Shown below the checkout label.', 'woocommerce' ),
+ ),
+ 'order' => array(
+ 'label' => __( 'Order', 'woocommerce' ),
+ 'type' => 'number',
+ 'desc' => __( 'Determines the display order of payment gateways during checkout.', 'woocommerce' ),
+ ),
+ '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' => '',
+ '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' ),
+ ),
+ );
+
+ $fields = $this->build_fields_from_form_fields( $gateway, $core_field_overrides );
+
+ $group = array(
+ 'title' => __( 'Cash on delivery settings', 'woocommerce' ),
+ 'description' => __( 'Manage how Cash on delivery appears at checkout and in order emails.', 'woocommerce' ),
+ 'order' => 1,
+ 'fields' => $fields,
+ );
+
+ return array( 'settings' => $group );
}
/**
@@ -45,9 +99,17 @@ class CodGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
* 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();
@@ -89,6 +151,8 @@ class CodGatewaySettingsSchema extends AbstractPaymentGatewaySettingsSchema {
}
}
+ $this->shipping_method_options = $options;
+
return $options;
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchemaTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchemaTest.php
new file mode 100644
index 00000000000..4b9f6a33808
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/PaymentGateways/Schema/AbstractPaymentGatewaySettingsSchemaTest.php
@@ -0,0 +1,432 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema;
+
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Settings\PaymentGateways\Schema\AbstractPaymentGatewaySettingsSchema;
+use WC_Payment_Gateway;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the AbstractPaymentGatewaySettingsSchema class.
+ *
+ * Focuses on the build_fields_from_form_fields() helper method which all
+ * gateway schemas depend on.
+ */
+class AbstractPaymentGatewaySettingsSchemaTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var AbstractPaymentGatewaySettingsSchema
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = $this->create_concrete_schema();
+ }
+
+ /**
+ * @testdox build_fields_from_form_fields should include synthetic order field even when not in form_fields.
+ */
+ public function test_build_fields_includes_synthetic_order_field(): void {
+ $gateway = $this->create_mock_gateway(
+ array(
+ 'enabled' => array(
+ 'title' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'label' => 'Enable test gateway',
+ ),
+ )
+ );
+ $gateway->init_form_fields();
+
+ $overrides = array(
+ 'enabled' => array(
+ 'label' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'desc' => 'Enable test gateway',
+ ),
+ 'order' => array(
+ 'label' => 'Order',
+ 'type' => 'number',
+ 'desc' => 'Display order.',
+ ),
+ );
+
+ $fields = $this->invoke_build_fields( $gateway, $overrides );
+
+ $order_field = $this->find_field_by_id( $fields, 'order' );
+ $this->assertNotNull( $order_field, 'Synthetic order field should be present' );
+ $this->assertSame( 'number', $order_field['type'] );
+ $this->assertSame( 'Order', $order_field['label'] );
+ }
+
+ /**
+ * @testdox build_fields_from_form_fields should preserve extension-injected fields not in core overrides.
+ */
+ public function test_build_fields_preserves_extension_injected_fields(): void {
+ $gateway = $this->create_mock_gateway(
+ array(
+ 'enabled' => array(
+ 'title' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'label' => 'Enable',
+ ),
+ 'custom_ext_field' => array(
+ 'title' => 'Extension Setting',
+ 'type' => 'text',
+ 'description' => 'Added by an extension.',
+ ),
+ )
+ );
+ $gateway->init_form_fields();
+
+ $overrides = array(
+ 'enabled' => array(
+ 'label' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'desc' => 'Enable',
+ ),
+ );
+
+ $fields = $this->invoke_build_fields( $gateway, $overrides );
+
+ $ext_field = $this->find_field_by_id( $fields, 'custom_ext_field' );
+ $this->assertNotNull( $ext_field, 'Extension-injected field should be preserved' );
+ $this->assertSame( 'Extension Setting', $ext_field['label'] );
+ $this->assertSame( 'text', $ext_field['type'] );
+ }
+
+ /**
+ * @testdox build_fields_from_form_fields should skip fields in skip_field_ids.
+ */
+ public function test_build_fields_skips_specified_field_ids(): void {
+ $gateway = $this->create_mock_gateway(
+ array(
+ 'enabled' => array(
+ 'title' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'label' => 'Enable',
+ ),
+ 'account_details' => array(
+ 'title' => 'Account details',
+ 'type' => 'array',
+ ),
+ )
+ );
+ $gateway->init_form_fields();
+
+ $overrides = array(
+ 'enabled' => array(
+ 'label' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'desc' => 'Enable',
+ ),
+ );
+
+ $fields = $this->invoke_build_fields( $gateway, $overrides, array( 'account_details' ) );
+
+ $skipped_field = $this->find_field_by_id( $fields, 'account_details' );
+ $this->assertNull( $skipped_field, 'Fields in skip_field_ids should be excluded' );
+ }
+
+ /**
+ * @testdox build_fields_from_form_fields should use override labels instead of gateway form_fields labels.
+ */
+ public function test_build_fields_applies_override_labels(): void {
+ $gateway = $this->create_mock_gateway(
+ array(
+ 'title' => array(
+ 'title' => 'Original Title Label',
+ 'type' => 'text',
+ 'description' => 'Original description.',
+ ),
+ )
+ );
+ $gateway->init_form_fields();
+
+ $overrides = array(
+ 'title' => array(
+ 'label' => 'Checkout label',
+ 'type' => 'text',
+ 'desc' => 'Shown to customers.',
+ ),
+ );
+
+ $fields = $this->invoke_build_fields( $gateway, $overrides );
+
+ $title_field = $this->find_field_by_id( $fields, 'title' );
+ $this->assertNotNull( $title_field, 'Title field should exist' );
+ $this->assertSame( 'Checkout label', $title_field['label'], 'Override label should be used' );
+ $this->assertSame( 'Shown to customers.', $title_field['desc'], 'Override description should be used' );
+ }
+
+ /**
+ * @testdox build_fields_from_form_fields should preserve options from form_fields for multiselect when override has no options.
+ */
+ public function test_build_fields_preserves_form_field_options_for_multiselect(): void {
+ $gateway = $this->create_mock_gateway(
+ array(
+ 'shipping_methods' => array(
+ 'title' => 'Shipping Methods',
+ 'type' => 'multiselect',
+ 'options' => array(
+ 'flat_rate' => 'Flat rate',
+ 'free' => 'Free shipping',
+ ),
+ ),
+ )
+ );
+ $gateway->init_form_fields();
+
+ $overrides = array(
+ 'shipping_methods' => array(
+ 'label' => 'Available shipping methods',
+ 'type' => 'multiselect',
+ 'desc' => '',
+ ),
+ );
+
+ $fields = $this->invoke_build_fields( $gateway, $overrides );
+
+ $field = $this->find_field_by_id( $fields, 'shipping_methods' );
+ $this->assertNotNull( $field, 'Shipping methods field should exist' );
+ $this->assertArrayHasKey( 'options', $field, 'Options should be preserved from form_fields' );
+ $this->assertSame( 'Flat rate', $field['options']['flat_rate'] );
+ }
+
+ /**
+ * @testdox build_fields_from_form_fields should use override options when explicitly provided.
+ */
+ public function test_build_fields_uses_override_options_when_provided(): void {
+ $gateway = $this->create_mock_gateway(
+ array(
+ 'shipping_methods' => array(
+ 'title' => 'Shipping Methods',
+ 'type' => 'multiselect',
+ 'options' => array(
+ 'old' => 'Old option',
+ ),
+ ),
+ )
+ );
+ $gateway->init_form_fields();
+
+ $custom_options = array(
+ 'new_flat_rate' => 'New Flat rate',
+ 'new_free' => 'New Free shipping',
+ );
+ $overrides = array(
+ 'shipping_methods' => array(
+ 'label' => 'Shipping methods',
+ 'type' => 'multiselect',
+ 'desc' => '',
+ 'options' => $custom_options,
+ ),
+ );
+
+ $fields = $this->invoke_build_fields( $gateway, $overrides );
+
+ $field = $this->find_field_by_id( $fields, 'shipping_methods' );
+ $this->assertNotNull( $field, 'Shipping methods field should exist' );
+ $this->assertSame( $custom_options, $field['options'], 'Override options should take precedence' );
+ }
+
+ /**
+ * @testdox build_fields_from_form_fields should skip non-data fields like title and sectionend from extension fields.
+ */
+ public function test_build_fields_skips_non_data_extension_fields(): void {
+ $gateway = $this->create_mock_gateway(
+ array(
+ 'enabled' => array(
+ 'title' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'label' => 'Enable',
+ ),
+ 'section_title' => array(
+ 'title' => 'Section Header',
+ 'type' => 'title',
+ ),
+ 'section_end' => array(
+ 'type' => 'sectionend',
+ ),
+ )
+ );
+ $gateway->init_form_fields();
+
+ $overrides = array(
+ 'enabled' => array(
+ 'label' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'desc' => 'Enable',
+ ),
+ );
+
+ $fields = $this->invoke_build_fields( $gateway, $overrides );
+
+ $this->assertNull( $this->find_field_by_id( $fields, 'section_title' ), 'Title type fields should be skipped' );
+ $this->assertNull( $this->find_field_by_id( $fields, 'section_end' ), 'Sectionend type fields should be skipped' );
+ }
+
+ /**
+ * @testdox build_fields_from_form_fields should skip override fields not present in form_fields (except order).
+ */
+ public function test_build_fields_skips_overrides_missing_from_form_fields(): void {
+ $gateway = $this->create_mock_gateway(
+ array(
+ 'enabled' => array(
+ 'title' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'label' => 'Enable',
+ ),
+ )
+ );
+ $gateway->init_form_fields();
+
+ $overrides = array(
+ 'enabled' => array(
+ 'label' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'desc' => 'Enable',
+ ),
+ 'instructions' => array(
+ 'label' => 'Instructions',
+ 'type' => 'text',
+ 'desc' => 'Order confirmation instructions.',
+ ),
+ );
+
+ $fields = $this->invoke_build_fields( $gateway, $overrides );
+
+ $this->assertNull(
+ $this->find_field_by_id( $fields, 'instructions' ),
+ 'Override fields not in form_fields should be skipped'
+ );
+ }
+
+ /**
+ * @testdox build_fields_from_form_fields should maintain core field order from overrides, then extension fields.
+ */
+ public function test_build_fields_maintains_override_order_then_extensions(): void {
+ $gateway = $this->create_mock_gateway(
+ array(
+ 'custom_ext' => array(
+ 'title' => 'Extension Field',
+ 'type' => 'text',
+ ),
+ 'enabled' => array(
+ 'title' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'label' => 'Enable',
+ ),
+ 'title' => array(
+ 'title' => 'Title',
+ 'type' => 'text',
+ ),
+ )
+ );
+ $gateway->init_form_fields();
+
+ $overrides = array(
+ 'enabled' => array(
+ 'label' => 'Enable/Disable',
+ 'type' => 'checkbox',
+ 'desc' => 'Enable',
+ ),
+ 'title' => array(
+ 'label' => 'Title',
+ 'type' => 'text',
+ 'desc' => 'Title desc.',
+ ),
+ );
+
+ $fields = $this->invoke_build_fields( $gateway, $overrides );
+
+ $field_ids = array_column( $fields, 'id' );
+ $this->assertSame( 'enabled', $field_ids[0], 'First field should follow override order' );
+ $this->assertSame( 'title', $field_ids[1], 'Second field should follow override order' );
+ $this->assertSame( 'custom_ext', $field_ids[2], 'Extension field should come after core overrides' );
+ }
+
+ /**
+ * Create a concrete implementation of the abstract schema for testing.
+ *
+ * @return AbstractPaymentGatewaySettingsSchema
+ */
+ private function create_concrete_schema(): AbstractPaymentGatewaySettingsSchema {
+ return new class() extends AbstractPaymentGatewaySettingsSchema {
+ /**
+ * Expose build_fields_from_form_fields for testing.
+ *
+ * @param WC_Payment_Gateway $gateway Gateway instance.
+ * @param array $core_field_overrides Core field overrides.
+ * @param array $skip_field_ids Field IDs to skip.
+ * @return array
+ */
+ public function public_build_fields( WC_Payment_Gateway $gateway, array $core_field_overrides, array $skip_field_ids = array() ): array {
+ return $this->build_fields_from_form_fields( $gateway, $core_field_overrides, $skip_field_ids );
+ }
+ };
+ }
+
+ /**
+ * Create a mock gateway with specified form fields.
+ *
+ * @param array $form_fields Form fields definition.
+ * @return WC_Payment_Gateway
+ */
+ private function create_mock_gateway( array $form_fields ): WC_Payment_Gateway {
+ $gateway = new class() extends WC_Payment_Gateway {
+ /**
+ * Fields to use in init_form_fields.
+ *
+ * @var array
+ */
+ public array $test_form_fields = array();
+
+ /**
+ * Initialize form fields from test data.
+ */
+ public function init_form_fields() {
+ $this->form_fields = $this->test_form_fields;
+ }
+ };
+
+ $gateway->test_form_fields = $form_fields;
+
+ return $gateway;
+ }
+
+ /**
+ * Invoke build_fields_from_form_fields on the SUT.
+ *
+ * @param WC_Payment_Gateway $gateway Gateway instance.
+ * @param array $core_field_overrides Core field overrides.
+ * @param array $skip_field_ids Field IDs to skip.
+ * @return array
+ */
+ private function invoke_build_fields( WC_Payment_Gateway $gateway, array $core_field_overrides, array $skip_field_ids = array() ): array {
+ return $this->sut->public_build_fields( $gateway, $core_field_overrides, $skip_field_ids );
+ }
+
+ /**
+ * Find a field by ID in a fields array.
+ *
+ * @param array $fields Fields array.
+ * @param string $field_id Field ID to find.
+ * @return array|null The field or null if not found.
+ */
+ private function find_field_by_id( array $fields, string $field_id ): ?array {
+ foreach ( $fields as $field ) {
+ if ( ( $field['id'] ?? '' ) === $field_id ) {
+ return $field;
+ }
+ }
+ return null;
+ }
+}