From d1e73829bb6a5afb46a3e912ebcec7e3ce72b173 Mon Sep 17 00:00:00 2001 From: Miguel Gasca Date: Thu, 30 Oct 2025 16:08:21 +0100 Subject: [PATCH 01/32] Fix WCPay Subscriptions toggle not saving when unchecked (#11113) --- ...fix-wcpay-subscriptions-toggle-not-saving-when-unchecked | 4 ++++ .../admin/class-wc-rest-payments-settings-controller.php | 6 +++--- .../test-class-wc-rest-payments-settings-controller.php | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 changelog/fix-woopmnt-5394-fix-wcpay-subscriptions-toggle-not-saving-when-unchecked diff --git a/changelog/fix-woopmnt-5394-fix-wcpay-subscriptions-toggle-not-saving-when-unchecked b/changelog/fix-woopmnt-5394-fix-wcpay-subscriptions-toggle-not-saving-when-unchecked new file mode 100644 index 00000000000..63e709651b7 --- /dev/null +++ b/changelog/fix-woopmnt-5394-fix-wcpay-subscriptions-toggle-not-saving-when-unchecked @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix - WCPay Subscriptions setting not persisting when unchecked diff --git a/includes/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php index b58ba262a76..d70c08f29d5 100644 --- a/includes/admin/class-wc-rest-payments-settings-controller.php +++ b/includes/admin/class-wc-rest-payments-settings-controller.php @@ -134,7 +134,7 @@ public function register_routes() { 'type' => 'boolean', 'validate_callback' => 'rest_validate_request_arg', ], - 'is_wcpay_subscription_enabled' => [ + 'is_wcpay_subscriptions_enabled' => [ 'description' => sprintf( /* translators: %s: WooPayments */ __( '%s Subscriptions feature flag setting.', 'woocommerce-payments' ), @@ -803,11 +803,11 @@ private function update_is_multi_currency_enabled( WP_REST_Request $request ) { * @param WP_REST_Request $request Request object. */ private function update_is_wcpay_subscriptions_enabled( WP_REST_Request $request ) { - if ( ! $request->has_param( 'is_wcpay_subscription_enabled' ) ) { + if ( ! $request->has_param( 'is_wcpay_subscriptions_enabled' ) ) { return; } - $is_wcpay_subscriptions_enabled = $request->get_param( 'is_wcpay_subscription_enabled' ); + $is_wcpay_subscriptions_enabled = $request->get_param( 'is_wcpay_subscriptions_enabled' ); // Prevent enabling bundled subscriptions - feature has been removed in 10.2.0. // Only allow disabling the feature if it was previously enabled. diff --git a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php index 8563fc85fe7..a24e1c62d98 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php @@ -654,7 +654,7 @@ public function test_update_settings_disables_wcpay_subscriptions() { ->method( 'store_setup_sync' ); $request = new WP_REST_Request(); - $request->set_param( 'is_wcpay_subscription_enabled', false ); + $request->set_param( 'is_wcpay_subscriptions_enabled', false ); $this->controller->update_settings( $request ); @@ -670,7 +670,7 @@ public function test_update_settings_does_not_enable_wcpay_subscriptions() { ->method( 'store_setup_sync' ); $request = new WP_REST_Request(); - $request->set_param( 'is_wcpay_subscription_enabled', true ); + $request->set_param( 'is_wcpay_subscriptions_enabled', true ); $this->controller->update_settings( $request ); From ed9860fbb186c630ada4e6b5f234ce071a1f4226 Mon Sep 17 00:00:00 2001 From: Rafael Meneses Date: Thu, 30 Oct 2025 15:17:42 -0300 Subject: [PATCH 02/32] Fix JCB logo (#11112) --- changelog/fix-jcb-logo | 4 ++++ client/checkout/blocks/payment-methods-logos/style.scss | 6 ++++++ client/utils/card-brands.ts | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 changelog/fix-jcb-logo diff --git a/changelog/fix-jcb-logo b/changelog/fix-jcb-logo new file mode 100644 index 00000000000..123b2e115f0 --- /dev/null +++ b/changelog/fix-jcb-logo @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Comment: fix JCB logo. diff --git a/client/checkout/blocks/payment-methods-logos/style.scss b/client/checkout/blocks/payment-methods-logos/style.scss index 410d53a6392..7451565c99b 100644 --- a/client/checkout/blocks/payment-methods-logos/style.scss +++ b/client/checkout/blocks/payment-methods-logos/style.scss @@ -53,3 +53,9 @@ box-shadow: 0 0 0 1px rgba( 0, 0, 0, 0.1 ); } } + +.payment-methods--logos-popover { + > img { + object-position: center !important; + } +} diff --git a/client/utils/card-brands.ts b/client/utils/card-brands.ts index c3ea8b74bb5..b1226463d7d 100644 --- a/client/utils/card-brands.ts +++ b/client/utils/card-brands.ts @@ -5,7 +5,7 @@ import Visa from 'assets/images/payment-method-icons/visa.svg?asset'; import Mastercard from 'assets/images/payment-method-icons/mastercard.svg?asset'; import Amex from 'assets/images/payment-method-icons/amex.svg?asset'; import Discover from 'assets/images/payment-method-icons/discover.svg?asset'; -import Jcb from 'assets/images/payment-method-icons/jcb.svg?asset'; +import Jcb from 'assets/images/cards/jcb.svg?asset'; import UnionPay from 'assets/images/cards/unionpay.svg?asset'; import Cartebancaire from 'assets/images/cards/cartes_bancaires.svg?asset'; import { getUPEConfig } from 'wcpay/utils/checkout'; From f71962d0fa01592f95ae43443c2a6bed0906253c Mon Sep 17 00:00:00 2001 From: Miguel Gasca Date: Fri, 31 Oct 2025 18:43:26 +0100 Subject: [PATCH 03/32] Fix payment method logos overflow in shortcode checkout (#11117) --- ...sion-pm-logos-overflow-in-short-code-checkout | 4 ++++ client/checkout/classic/event-handlers.js | 16 ++++++---------- client/checkout/classic/style.scss | 9 +++++++++ 3 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 changelog/fix-woopmnt-5397-regression-pm-logos-overflow-in-short-code-checkout diff --git a/changelog/fix-woopmnt-5397-regression-pm-logos-overflow-in-short-code-checkout b/changelog/fix-woopmnt-5397-regression-pm-logos-overflow-in-short-code-checkout new file mode 100644 index 00000000000..44ebe78a541 --- /dev/null +++ b/changelog/fix-woopmnt-5397-regression-pm-logos-overflow-in-short-code-checkout @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix payment method logos overflow in shortcode checkout after adding JCB and UnionPay logos. diff --git a/client/checkout/classic/event-handlers.js b/client/checkout/classic/event-handlers.js index 2419d63279f..1cd43c1569f 100644 --- a/client/checkout/classic/event-handlers.js +++ b/client/checkout/classic/event-handlers.js @@ -181,19 +181,15 @@ jQuery( function ( $ ) { const paymentMethods = getCardBrands(); function getMaxElements() { - const paymentMethodElement = document.querySelector( - '.payment_method_woocommerce_payments' - ); - if ( ! paymentMethodElement ) { - return 4; // Default fallback - } + // Use viewport width as primary indicator (similar to blocks checkout) + const viewportWidth = window.innerWidth; - const elementWidth = paymentMethodElement.offsetWidth; - if ( elementWidth <= 300 ) { + // Specific tablet viewport range (768-781px) - needs room for Test Mode badge + if ( viewportWidth >= 768 && viewportWidth <= 900 ) { return 1; - } else if ( elementWidth <= 330 ) { - return 2; } + // Default - show 3 logos + counter badge = 4 visual elements total + return 3; } function shouldHavePopover() { diff --git a/client/checkout/classic/style.scss b/client/checkout/classic/style.scss index d4ade1e1f6e..2f2edee4e26 100644 --- a/client/checkout/classic/style.scss +++ b/client/checkout/classic/style.scss @@ -41,6 +41,15 @@ img:last-of-type { margin-right: 0; } + + &-count { + margin-left: 4px; + + // Tighter spacing on tablet viewports (768-900px) to conserve space for Test Mode badge + @media ( min-width: 768px ) and ( max-width: 900px ) { + margin-left: 0; + } + } } img { float: right; From dd2c9c6605eef59e8895392fd5e3c775f3d4b8ce Mon Sep 17 00:00:00 2001 From: Daniel Mallory Date: Fri, 31 Oct 2025 18:58:46 +0000 Subject: [PATCH 04/32] Update to the copy on the WooPayments delete test orders tool. (#11118) --- changelog/dev-test-mode-text-fix | 4 ++++ includes/class-wc-payments-status.php | 8 ++++++-- tests/unit/test-class-wc-payments-status.php | 8 ++++---- 3 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 changelog/dev-test-mode-text-fix diff --git a/changelog/dev-test-mode-text-fix b/changelog/dev-test-mode-text-fix new file mode 100644 index 00000000000..25036cf8d87 --- /dev/null +++ b/changelog/dev-test-mode-text-fix @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Minor copy update to the delete test orders tool. diff --git a/includes/class-wc-payments-status.php b/includes/class-wc-payments-status.php index 916a9b55901..8309fc2c751 100644 --- a/includes/class-wc-payments-status.php +++ b/includes/class-wc-payments-status.php @@ -81,11 +81,15 @@ public function debug_tools( $tools ) { 'callback' => [ $this->account, 'refresh_account_data' ], ], 'delete_wcpay_test_orders' => [ - 'name' => __( 'Delete test orders', 'woocommerce-payments' ), + 'name' => sprintf( + /* translators: %s: WooPayments */ + __( 'Delete %s test orders', 'woocommerce-payments' ), + 'WooPayments' + ), 'button' => __( 'Delete', 'woocommerce-payments' ), 'desc' => sprintf( /* translators: %s: WooPayments */ - __( 'Note: This option will delete ALL orders created while %s test mode was enabled, use with caution. This action cannot be reversed.', 'woocommerce-payments' ), + __( 'Note: This option deletes all test mode orders placed via %s. Orders placed via other gateways will not be affected. Use with caution, as this action cannot be undone.', 'woocommerce-payments' ), 'WooPayments' ), 'callback' => [ $this, 'delete_test_orders' ], diff --git a/tests/unit/test-class-wc-payments-status.php b/tests/unit/test-class-wc-payments-status.php index df61ac8daae..f418875195d 100644 --- a/tests/unit/test-class-wc-payments-status.php +++ b/tests/unit/test-class-wc-payments-status.php @@ -94,13 +94,13 @@ public function test_delete_test_orders_tool_structure() { $this->assertArrayHasKey( 'desc', $delete_tool ); $this->assertArrayHasKey( 'callback', $delete_tool ); - $this->assertEquals( 'Delete test orders', $delete_tool['name'] ); + $this->assertEquals( 'Delete WooPayments test orders', $delete_tool['name'] ); $this->assertEquals( 'Delete', $delete_tool['button'] ); $this->assertStringContainsString( 'Note:', $delete_tool['desc'] ); $this->assertStringContainsString( 'strong class="red"', $delete_tool['desc'] ); - $this->assertStringContainsString( 'delete ALL orders created while', $delete_tool['desc'] ); - $this->assertStringContainsString( 'test mode was enabled', $delete_tool['desc'] ); - $this->assertStringContainsString( 'cannot be reversed', $delete_tool['desc'] ); + $this->assertStringContainsString( 'deletes all test mode orders placed via', $delete_tool['desc'] ); + $this->assertStringContainsString( 'Orders placed via other gateways will not be affected', $delete_tool['desc'] ); + $this->assertStringContainsString( 'cannot be undone', $delete_tool['desc'] ); } /** From c3a8579e7343c429c4dbe69df79d845fc5a4a421 Mon Sep 17 00:00:00 2001 From: Vlad Olaru Date: Tue, 4 Nov 2025 08:52:10 +0200 Subject: [PATCH 05/32] Replace old docs subscriptions URLs with new ones. (#11119) --- changelog/fix-subscription-urls | 5 +++++ .../stripe-billing-notices/migrate-option-notice.tsx | 2 +- .../templates/html-wcpay-deactivate-warning.php | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelog/fix-subscription-urls diff --git a/changelog/fix-subscription-urls b/changelog/fix-subscription-urls new file mode 100644 index 00000000000..1f79e52e375 --- /dev/null +++ b/changelog/fix-subscription-urls @@ -0,0 +1,5 @@ +Significance: patch +Type: update +Comment: Updated old docs URLs to new ones. + + diff --git a/client/settings/advanced-settings/stripe-billing-notices/migrate-option-notice.tsx b/client/settings/advanced-settings/stripe-billing-notices/migrate-option-notice.tsx index c4f239a25a4..003aade9144 100644 --- a/client/settings/advanced-settings/stripe-billing-notices/migrate-option-notice.tsx +++ b/client/settings/advanced-settings/stripe-billing-notices/migrate-option-notice.tsx @@ -135,7 +135,7 @@ const MigrateOptionNotice: React.FC< Props > = ( { learnMoreLink: ( // eslint-disable-next-line max-len // @ts-expect-error: children is provided when interpolating the component - + ), }, } ) } diff --git a/includes/subscriptions/templates/html-wcpay-deactivate-warning.php b/includes/subscriptions/templates/html-wcpay-deactivate-warning.php index 7f54f324be6..7b5dd96d50d 100644 --- a/includes/subscriptions/templates/html-wcpay-deactivate-warning.php +++ b/includes/subscriptions/templates/html-wcpay-deactivate-warning.php @@ -22,7 +22,7 @@ printf( // translators: $1 $2 $3 placeholders are opening and closing HTML link tags, linking to documentation. $4 $5 placeholders are opening and closing strong HTML tags. $6 is WooPayments. esc_html__( 'Your store has active subscriptions using the built-in %6$s functionality. Due to the %1$soff-site billing engine%3$s these subscriptions use, %4$sthey will continue to renew even after you deactivate %6$s%5$s. %2$sLearn more%3$s.', 'woocommerce-payments' ), - '', + '', '', '', '', From be4da0bbc5f7b5cf86a428436d0a281ac09ecc9f Mon Sep 17 00:00:00 2001 From: Rafael Meneses Date: Wed, 5 Nov 2025 03:40:11 -0300 Subject: [PATCH 06/32] Fix styling for Test Mode badge in block checkout (#11123) Co-authored-by: Dat Hoang --- changelog/fix-missing-badge | 5 +++++ client/checkout/blocks/style.scss | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelog/fix-missing-badge diff --git a/changelog/fix-missing-badge b/changelog/fix-missing-badge new file mode 100644 index 00000000000..41a346b57e4 --- /dev/null +++ b/changelog/fix-missing-badge @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Fix regression caused by PR #11085 + + diff --git a/client/checkout/blocks/style.scss b/client/checkout/blocks/style.scss index 049a92056a5..7b73d3f94ab 100644 --- a/client/checkout/blocks/style.scss +++ b/client/checkout/blocks/style.scss @@ -45,7 +45,7 @@ button.wcpay-stripelink-modal-trigger:hover { .wc-block-checkout__payment-method { input:checked ~ div { /* stylelint-disable-next-line selector-id-pattern */ - .wc-block-components-radio-control__label:where( #radio-control-wc-payment-method-options-woocommerce_payments__label ) { + .wc-block-components-radio-control__label:where( [id^='radio-control-wc-payment-method-options-woocommerce_payments'][id$='__label'] ) { > .payment-method-label { .test-mode.badge { // hiding the badge when the payment method is not selected @@ -56,7 +56,7 @@ button.wcpay-stripelink-modal-trigger:hover { } /* stylelint-disable-next-line selector-id-pattern */ - .wc-block-components-radio-control__label:where( #radio-control-wc-payment-method-options-woocommerce_payments__label ) { + .wc-block-components-radio-control__label:where( [id^='radio-control-wc-payment-method-options-woocommerce_payments'][id$='__label'] ) { width: 100%; display: block !important; From 5cdf6f727f925aa5a1a668e0d7839ad412e27193 Mon Sep 17 00:00:00 2001 From: Vlad Olaru Date: Wed, 5 Nov 2025 13:45:56 +0200 Subject: [PATCH 07/32] Enforce strict user capability checks when deleting test orders (#11122) --- ...rify-user-access-when-deleting-test-orders | 5 + includes/class-wc-payments-status.php | 7 +- tests/unit/test-class-wc-payments-status.php | 95 +++++++++++++------ 3 files changed, 76 insertions(+), 31 deletions(-) create mode 100644 changelog/update-verify-user-access-when-deleting-test-orders diff --git a/changelog/update-verify-user-access-when-deleting-test-orders b/changelog/update-verify-user-access-when-deleting-test-orders new file mode 100644 index 00000000000..8c89f6880eb --- /dev/null +++ b/changelog/update-verify-user-access-when-deleting-test-orders @@ -0,0 +1,5 @@ +Significance: patch +Type: update +Comment: Enforce strict capability checks when deleting test orders. + + diff --git a/includes/class-wc-payments-status.php b/includes/class-wc-payments-status.php index 8309fc2c751..b9b7d5222f0 100644 --- a/includes/class-wc-payments-status.php +++ b/includes/class-wc-payments-status.php @@ -106,6 +106,11 @@ public function debug_tools( $tools ) { * @return string Success or error message. */ public function delete_test_orders() { + // Add explicit capability check. + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return __( 'You do not have permission to delete orders.', 'woocommerce-payments' ); + } + try { // Get all orders with test mode meta. $test_orders = wc_get_orders( @@ -126,7 +131,7 @@ public function delete_test_orders() { $deleted_count = 0; foreach ( $test_orders as $order ) { // Permanently delete the order (skip trash). - if ( $order->delete() ) { + if ( $order->delete( true ) ) { ++$deleted_count; } } diff --git a/tests/unit/test-class-wc-payments-status.php b/tests/unit/test-class-wc-payments-status.php index f418875195d..383dbcd8b32 100644 --- a/tests/unit/test-class-wc-payments-status.php +++ b/tests/unit/test-class-wc-payments-status.php @@ -43,6 +43,9 @@ class WC_Payments_Status_Test extends WCPAY_UnitTestCase { public function set_up() { parent::set_up(); + // Set up an admin user with proper capabilities. + wp_set_current_user( 1 ); + $this->mock_gateway = $this->createMock( WC_Payment_Gateway_WCPay::class ); $this->mock_http = $this->createMock( WC_Payments_Http_Interface::class ); $this->mock_account = $this->createMock( WC_Payments_Account::class ); @@ -108,21 +111,20 @@ public function test_delete_test_orders_tool_structure() { */ public function test_delete_test_orders_with_no_orders() { // Mock wc_get_orders to return empty array. - add_filter( - 'woocommerce_order_data_store_cpt_get_orders_query', - function ( $query, $query_vars ) { - if ( isset( $query_vars['meta_key'] ) && '_wcpay_mode' === $query_vars['meta_key'] ) { - $query['post__in'] = [ 0 ]; // Force no results. - } - return $query; - }, - 10, - 2 - ); + $filter_callback = function ( $query, $query_vars ) { + if ( isset( $query_vars['meta_key'] ) && '_wcpay_mode' === $query_vars['meta_key'] ) { + $query['post__in'] = [ 0 ]; // Force no results. + } + return $query; + }; + add_filter( 'woocommerce_order_data_store_cpt_get_orders_query', $filter_callback, 10, 2 ); $result = $this->status->delete_test_orders(); $this->assertEquals( 'No test orders found.', $result ); + + // Clean up filter. + remove_filter( 'woocommerce_order_data_store_cpt_get_orders_query', $filter_callback, 10 ); } /** @@ -148,14 +150,12 @@ public function test_delete_test_orders_deletes_orders() { // Verify result message. $this->assertStringContainsString( '2 test orders have been permanently deleted.', $result ); - // Verify test orders were moved to trash. - $trashed_order1 = wc_get_order( $order1->get_id() ); - $this->assertInstanceOf( WC_Order::class, $trashed_order1 ); - $this->assertEquals( 'trash', $trashed_order1->get_status() ); + // Verify test orders were permanently deleted (no longer exist). + $deleted_order1 = wc_get_order( $order1->get_id() ); + $this->assertFalse( $deleted_order1, 'Test order 1 should be permanently deleted' ); - $trashed_order2 = wc_get_order( $order2->get_id() ); - $this->assertInstanceOf( WC_Order::class, $trashed_order2 ); - $this->assertEquals( 'trash', $trashed_order2->get_status() ); + $deleted_order2 = wc_get_order( $order2->get_id() ); + $this->assertFalse( $deleted_order2, 'Test order 2 should be permanently deleted' ); // Verify non-test order was not deleted. $order3_check = wc_get_order( $order3->get_id() ); @@ -176,10 +176,9 @@ public function test_delete_test_orders_singular_message() { $this->assertStringContainsString( '1 test order has been permanently deleted.', $result ); - // Verify order was moved to trash. - $trashed_order = wc_get_order( $order->get_id() ); - $this->assertInstanceOf( WC_Order::class, $trashed_order ); - $this->assertEquals( 'trash', $trashed_order->get_status() ); + // Verify order was permanently deleted (no longer exists). + $deleted_order = wc_get_order( $order->get_id() ); + $this->assertFalse( $deleted_order, 'Test order should be permanently deleted' ); } /** @@ -187,18 +186,54 @@ public function test_delete_test_orders_singular_message() { */ public function test_delete_test_orders_handles_exception() { // Mock wc_get_orders to throw an exception. - add_filter( - 'woocommerce_order_data_store_cpt_get_orders_query', - function ( $query, $query_vars ) { - throw new Exception( 'Database error' ); - }, - 10, - 2 - ); + $filter_callback = function () { + throw new Exception( 'Database error' ); + }; + add_filter( 'woocommerce_order_data_store_cpt_get_orders_query', $filter_callback, 10, 2 ); $result = $this->status->delete_test_orders(); $this->assertStringContainsString( 'Error deleting test orders:', $result ); $this->assertStringContainsString( 'Database error', $result ); + + // Clean up filter. + remove_filter( 'woocommerce_order_data_store_cpt_get_orders_query', $filter_callback, 10 ); + } + + /** + * Test delete_test_orders denies access for users without manage_woocommerce capability. + */ + public function test_delete_test_orders_requires_manage_woocommerce_capability() { + // Create test orders with _wcpay_mode meta. + $order1 = wc_create_order(); + $order1->update_meta_data( '_wcpay_mode', 'test' ); + $order1->save(); + + $order2 = wc_create_order(); + $order2->update_meta_data( '_wcpay_mode', 'test' ); + $order2->save(); + + // Mock that the current user is missing the manage_woocommerce capability. + $filter_callback = function ( $allcaps ) { + $allcaps['manage_woocommerce'] = false; + + return $allcaps; + }; + add_filter( 'user_has_cap', $filter_callback ); + + $result = $this->status->delete_test_orders(); + + // Verify permission denied message. + $this->assertEquals( 'You do not have permission to delete orders.', $result ); + + // Verify orders were NOT deleted. + $order1_check = wc_get_order( $order1->get_id() ); + $this->assertInstanceOf( WC_Order::class, $order1_check ); + + $order2_check = wc_get_order( $order2->get_id() ); + $this->assertInstanceOf( WC_Order::class, $order2_check ); + + // Clean up filter. + remove_filter( 'user_has_cap', $filter_callback ); } } From 95546f6594ed245ba9887e772e795d976c9c24a5 Mon Sep 17 00:00:00 2001 From: Radoslav Georgiev Date: Fri, 7 Nov 2025 17:46:48 +0200 Subject: [PATCH 08/32] Make the WooPay button relative to contain the spinner (#11127) --- ...98-woopay-button-spinner-and-label-is-not-visible-on-click | 4 ++++ client/checkout/woopay/express-button/style.scss | 1 + 2 files changed, 5 insertions(+) create mode 100644 changelog/woopmnt-5398-woopay-button-spinner-and-label-is-not-visible-on-click diff --git a/changelog/woopmnt-5398-woopay-button-spinner-and-label-is-not-visible-on-click b/changelog/woopmnt-5398-woopay-button-spinner-and-label-is-not-visible-on-click new file mode 100644 index 00000000000..bff68f24a29 --- /dev/null +++ b/changelog/woopmnt-5398-woopay-button-spinner-and-label-is-not-visible-on-click @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix styling of the WooPay button to make sure that the spinner is visible when loading. diff --git a/client/checkout/woopay/express-button/style.scss b/client/checkout/woopay/express-button/style.scss index cc33e471acd..a972d9ef4da 100644 --- a/client/checkout/woopay/express-button/style.scss +++ b/client/checkout/woopay/express-button/style.scss @@ -18,6 +18,7 @@ text-transform: none; list-style-type: none; min-height: auto; + position: relative; .button-content { display: flex; From e77626d5ca29bb3dddd54c706c12444c9659062c Mon Sep 17 00:00:00 2001 From: Miguel Gasca Date: Mon, 10 Nov 2025 11:00:10 +0100 Subject: [PATCH 09/32] [E2E][QIT] Add QIT foundation with basic connectivity tests (#11047) --- changelog/dev-qit-e2e-foundation | 5 +++ composer.json | 2 +- composer.lock | 16 ++++----- package.json | 1 + tests/js/jest.config.js | 2 ++ tests/qit/config/default.env | 12 +++++-- tests/qit/e2e-runner.sh | 55 ++++++++++++++++++++++++++++ tests/qit/e2e/.eslintrc.js | 26 ++++++++++++++ tests/qit/e2e/basic.spec.js | 62 ++++++++++++++++++++++++++++++++ tests/qit/e2e/bootstrap/setup.sh | 59 ++++++++++++++++++++++++++++++ tests/qit/qit.yml | 15 ++++++++ 11 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 changelog/dev-qit-e2e-foundation create mode 100755 tests/qit/e2e-runner.sh create mode 100644 tests/qit/e2e/.eslintrc.js create mode 100644 tests/qit/e2e/basic.spec.js create mode 100755 tests/qit/e2e/bootstrap/setup.sh create mode 100644 tests/qit/qit.yml diff --git a/changelog/dev-qit-e2e-foundation b/changelog/dev-qit-e2e-foundation new file mode 100644 index 00000000000..3db34d37454 --- /dev/null +++ b/changelog/dev-qit-e2e-foundation @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Add foundation to run E2E tests with QIT and basic tests. + + diff --git a/composer.json b/composer.json index e8ea75f318a..7eb4e8b4567 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ "cweagans/composer-patches": "1.7.1", "automattic/jetpack-changelogger": "3.3.2", "spatie/phpunit-watcher": "1.23.6", - "woocommerce/qit-cli": "0.4.0", + "woocommerce/qit-cli": "0.10.0", "slevomat/coding-standard": "8.15.0", "dg/bypass-finals": "1.5.1", "sirbrillig/phpcs-variable-analysis": "^2.11", diff --git a/composer.lock b/composer.lock index 553997998ff..435991b7ba4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "82ae2b86cd431fd736751ad4c4460abb", + "content-hash": "dc2e50629f4ea7ed368df386842eac1b", "packages": [ { "name": "automattic/jetpack-a8c-mc-stats", @@ -6746,16 +6746,16 @@ }, { "name": "woocommerce/qit-cli", - "version": "0.4.0", + "version": "0.10.0", "source": { "type": "git", "url": "https://github.com/woocommerce/qit-cli.git", - "reference": "8c71a1ffd67879d43bde45512bb7fe3ff399814b" + "reference": "42c4722bb71940dc0435103775439588e923e1cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/woocommerce/qit-cli/zipball/8c71a1ffd67879d43bde45512bb7fe3ff399814b", - "reference": "8c71a1ffd67879d43bde45512bb7fe3ff399814b", + "url": "https://api.github.com/repos/woocommerce/qit-cli/zipball/42c4722bb71940dc0435103775439588e923e1cd", + "reference": "42c4722bb71940dc0435103775439588e923e1cd", "shasum": "" }, "require": { @@ -6773,9 +6773,9 @@ "description": "A command line interface for WooCommerce Quality Insights Toolkit (QIT).", "support": { "issues": "https://github.com/woocommerce/qit-cli/issues", - "source": "https://github.com/woocommerce/qit-cli/tree/0.4.0" + "source": "https://github.com/woocommerce/qit-cli/tree/0.10.0" }, - "time": "2024-01-29T16:27:45+00:00" + "time": "2025-05-20T15:58:42+00:00" }, { "name": "woocommerce/woocommerce-sniffs", @@ -7023,5 +7023,5 @@ "platform-overrides": { "php": "7.3" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/package.json b/package.json index 5d2374b25ae..7cd84dbd036 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "test:qit-phpstan": "npm run build:release && ./tests/qit/phpstan.sh", "test:qit-phpstan-local": "npm run build:release && ./tests/qit/phpstan.sh --local", "test:qit-malware": "npm run build:release && ./tests/qit/malware.sh --local", + "test:qit-e2e": "./tests/qit/e2e-runner.sh", "watch": "webpack --watch", "hmr": "webpack server", "start": "npm run watch", diff --git a/tests/js/jest.config.js b/tests/js/jest.config.js index 81431f5f527..0918377defd 100644 --- a/tests/js/jest.config.js +++ b/tests/js/jest.config.js @@ -46,6 +46,7 @@ module.exports = { '/.*/build-module/', '/docker/', '/tests/e2e', + '/tests/qit/e2e', ], watchPathIgnorePatterns: [ '/node_modules/', @@ -54,6 +55,7 @@ module.exports = { '/.*/build-module/', '/docker/', '/tests/e2e', + '/tests/qit/e2e', ], transform: { ...tsjPreset.transform, diff --git a/tests/qit/config/default.env b/tests/qit/config/default.env index 2b460fbdfdc..343592c59e8 100644 --- a/tests/qit/config/default.env +++ b/tests/qit/config/default.env @@ -1,3 +1,9 @@ -# Create `local.env` and supply actual values. -QIT_USER="" -QIT_PASSWORD="" +# QIT Configuration for WooCommerce Payments +# Copy this file to local.env and update with your values + +# =========================================== +# QIT CLI CREDENTIALS (for security, phpstan, malware, custom e2e tests) +# =========================================== +QIT_USER=your_qit_username +QIT_PASSWORD=your_qit_application_password + diff --git a/tests/qit/e2e-runner.sh b/tests/qit/e2e-runner.sh new file mode 100755 index 00000000000..e72b4ccbe53 --- /dev/null +++ b/tests/qit/e2e-runner.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +# Enable strict error handling and safe field splitting for reliability +set -euo pipefail +IFS=$'\n\t' + +# E2E test runner for WooPayments using QIT +cwd=$(pwd) +WCP_ROOT="$cwd" +QIT_ROOT="$cwd/tests/qit" + +# Load local env variables if present +if [[ -f "$QIT_ROOT/config/local.env" ]]; then + . "$QIT_ROOT/config/local.env" +fi + +# If QIT_BINARY is not set, default to ./vendor/bin/qit +QIT_BINARY=${QIT_BINARY:-./vendor/bin/qit} + +echo "Running E2E tests..." + +# Change to project root directory to build plugin +cd "$WCP_ROOT" + +# Foundation version: Simplified build process for easier testing +# For this foundation PR, we'll always build to avoid complex signature computation issues + +BUILD_HASH_FILE="$WCP_ROOT/woocommerce-payments.zip.hash" + +# For this foundation PR, always build if zip doesn't exist or if forced +if [[ -n "${WCP_FORCE_BUILD:-}" ]] || [[ ! -f "woocommerce-payments.zip" ]]; then + echo "Building WooPayments plugin..." + npm run build:release + echo "foundation-build-$(date +%s)" > "$BUILD_HASH_FILE" +else + echo "Using existing woocommerce-payments.zip" +fi + +# Change to QIT directory so qit.yml is automatically found +cd "$QIT_ROOT" + +# Convert relative QIT_BINARY path to absolute for directory change compatibility +if [[ "$QIT_BINARY" = ./* ]]; then + QIT_CMD="$WCP_ROOT/$QIT_BINARY" +else + QIT_CMD="$QIT_BINARY" +fi + +echo "Running QIT E2E foundation tests (no Jetpack credentials)..." + +# Run our QIT E2E tests (qit.yml automatically loaded from current directory) +"$QIT_CMD" run:e2e woocommerce-payments ./e2e \ + --source "$WCP_ROOT/woocommerce-payments.zip" + +echo "QIT E2E foundation tests completed!" diff --git a/tests/qit/e2e/.eslintrc.js b/tests/qit/e2e/.eslintrc.js new file mode 100644 index 00000000000..486b1448ffc --- /dev/null +++ b/tests/qit/e2e/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + env: { + node: true, + }, + globals: { + page: 'readonly', + browser: 'readonly', + context: 'readonly', + }, + rules: { + // Disable Jest-specific rules that conflict with Playwright + 'jest/no-done-callback': 'off', + 'jest/expect-expect': 'off', + // Allow QIT-specific imports that ESLint can't resolve + 'import/no-unresolved': [ 'error', { ignore: [ '/qitHelpers' ] } ], + }, + overrides: [ + { + files: [ '*.spec.js', '*.test.js' ], + rules: { + // Playwright test specific overrides + 'jest/no-done-callback': 'off', + }, + }, + ], +}; diff --git a/tests/qit/e2e/basic.spec.js b/tests/qit/e2e/basic.spec.js new file mode 100644 index 00000000000..8600710c580 --- /dev/null +++ b/tests/qit/e2e/basic.spec.js @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { test, expect } from '@playwright/test'; +import qit from '/qitHelpers'; + +/** + * Simple QIT E2E test - bare minimum to verify QIT works + */ +test( 'Load home page', async ( { page } ) => { + await page.goto( '/' ); + + // Just check that we can load the page and title exists + await expect( page ).toHaveTitle( /.*/ ); +} ); + +/** + * Test WooCommerce Payments onboarding flow access + * Since we're running in development mode without Jetpack connection, + * we expect to always land on the onboarding flow. + */ +test( 'Access WooCommerce Payments onboarding as admin', async ( { page } ) => { + // Use QIT helper to login as admin + await qit.loginAsAdmin( page ); + + // Navigate to WooCommerce Payments settings + await page.goto( + '/wp-admin/admin.php?page=wc-admin&path=%2Fpayments%2Foverview' + ); + + // We should see the Payments admin route load + await expect( + page.locator( 'h1:not(.screen-reader-text)' ).first() + ).toContainText( /Settings|Payments|Overview/, { timeout: 15000 } ); + + // In development mode without Jetpack connection, we should be on onboarding + expect( page.url() ).toContain( 'onboarding' ); + + // The onboarding page should load without errors + await expect( page.locator( 'body' ) ).not.toHaveText( + /500|404|Fatal error/ + ); +} ); + +/** + * Test plugin activation and basic WooCommerce functionality + */ +test( 'Verify WooCommerce Payments plugin activation', async ( { page } ) => { + await qit.loginAsAdmin( page ); + + // Check plugins page to verify WooCommerce Payments is active + await page.goto( '/wp-admin/plugins.php' ); + + // Look for the WooCommerce Payments plugin row (exclude update row) + const pluginRow = page.locator( + 'tr[data-plugin*="woocommerce-payments"]:not(.plugin-update-tr)' + ); + await expect( pluginRow ).toBeVisible(); + + // Verify it shows as activated + await expect( pluginRow.locator( '.deactivate' ) ).toBeVisible(); +} ); diff --git a/tests/qit/e2e/bootstrap/setup.sh b/tests/qit/e2e/bootstrap/setup.sh new file mode 100755 index 00000000000..7f1c923eaf2 --- /dev/null +++ b/tests/qit/e2e/bootstrap/setup.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +set -euo pipefail +IFS=$'\n\t' + +# QIT Bootstrap Setup for WooCommerce Payments E2E Tests +# This script runs before tests to configure the plugin environment + +echo "Setting up WooCommerce Payments for E2E testing..." + +# Ensure environment is marked as local so dev-only CLI commands are available +wp config set WP_ENVIRONMENT_TYPE local --quiet 2>/dev/null || true + +# Create a test product for payment testing +PRODUCT_ID=$(wp post create \ + --post_title="Test Product for Payments" \ + --post_content="A simple test product for QIT payment testing" \ + --post_status=publish \ + --post_type=product \ + --porcelain) + +# Set product meta data properly +wp post meta update $PRODUCT_ID _price "10.00" +wp post meta update $PRODUCT_ID _regular_price "10.00" +wp post meta update $PRODUCT_ID _virtual "yes" +wp post meta update $PRODUCT_ID _manage_stock "no" + +# Ensure WooCommerce checkout page exists and is properly configured +wp option update woocommerce_checkout_page_id $(wp post list --post_type=page --post_name=checkout --field=ID --format=ids) + +# Configure WooCommerce for testing +wp option update woocommerce_currency "USD" +wp option update woocommerce_enable_guest_checkout "yes" +wp option update woocommerce_force_ssl_checkout "no" + +# Create a test customer +wp user create testcustomer test@example.com \ + --role=customer \ + --user_pass=testpass123 \ + --first_name="Test" \ + --last_name="Customer" \ + --quiet + +echo "Setting up WooCommerce Payments configuration..." + +# NOTE: Jetpack connection setup will be added in future PRs +# For now, WooPayments will run in development mode +echo "Running WooPayments without a Jetpack connection (Jetpack connection setup in upcoming PRs)" + +# Enable development/test mode for better testing experience +wp option set wcpay_dev_mode 1 --quiet 2>/dev/null || true + +# Disable proxy mode (we want direct production API access) +wp option set wcpaydev_proxy 0 --quiet 2>/dev/null || true + +# Disable onboarding redirect for E2E testing +wp option set wcpay_should_redirect_to_onboarding 0 --quiet 2>/dev/null || true + +echo "WooPayments configuration completed" diff --git a/tests/qit/qit.yml b/tests/qit/qit.yml new file mode 100644 index 00000000000..5c19ef14c16 --- /dev/null +++ b/tests/qit/qit.yml @@ -0,0 +1,15 @@ +# QIT Configuration for WooPayments +# This configuration defines how QIT runs custom E2E tests for WooPayments + +# Extension to test (System Under Test) +woo_extension: woocommerce-payments + +# Test against various WC versions for compatibility +woo: "stable" +wp: "stable" +php_version: "8.3" + +# Dependencies and additional plugins for compatibility testing +plugin: + - "woocommerce" + - "jetpack" From b8d137d35ca52798d715bb3065172e4e93cbb237 Mon Sep 17 00:00:00 2001 From: Dat Hoang Date: Mon, 10 Nov 2025 17:00:13 +0700 Subject: [PATCH 10/32] Update payout texts for New Account Waiting Period (#11120) --- ...-5449-update-payout-banner-in-the-settings | 4 +++ .../__tests__/index.test.tsx | 26 +++++++++++++------ .../deposits-overview/deposit-notices.tsx | 4 +-- .../settings/deposits/__tests__/index.test.js | 2 +- client/settings/deposits/index.js | 5 ++-- 5 files changed, 28 insertions(+), 13 deletions(-) create mode 100644 changelog/woopmnt-5449-update-payout-banner-in-the-settings diff --git a/changelog/woopmnt-5449-update-payout-banner-in-the-settings b/changelog/woopmnt-5449-update-payout-banner-in-the-settings new file mode 100644 index 00000000000..2b8228b2969 --- /dev/null +++ b/changelog/woopmnt-5449-update-payout-banner-in-the-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Change payout texts for New Account Waiting Period to be consistent with new Account Details diff --git a/client/components/deposits-overview/__tests__/index.test.tsx b/client/components/deposits-overview/__tests__/index.test.tsx index 920adf540c7..1c5118fc0fd 100644 --- a/client/components/deposits-overview/__tests__/index.test.tsx +++ b/client/components/deposits-overview/__tests__/index.test.tsx @@ -306,9 +306,12 @@ describe( 'Deposits Overview information', () => { setSelectedCurrency: mockSetSelectedCurrency, } ); const { getByText, queryByText } = render( ); - getByText( /Your first payout is held for/, { - ignore: '.a11y-speak-region', - } ); + getByText( + /Payout scheduling becomes available after the standard 7-day waiting period for new accounts is complete/, + { + ignore: '.a11y-speak-region', + } + ); expect( queryByText( 'Change deposit schedule' ) ).toBeFalsy(); expect( queryByText( 'View full deposits history' ) ).toBeFalsy(); } ); @@ -426,7 +429,11 @@ describe( 'Deposits Overview information', () => { } ); const { queryByText } = render( ); - expect( queryByText( /Your first payout is held for/ ) ).toBeFalsy(); + expect( + queryByText( + /Payout scheduling becomes available after the standard 7-day waiting period for new accounts is complete/ + ) + ).toBeFalsy(); } ); test( 'Confirm new account waiting period notice shows if within waiting period', () => { @@ -444,10 +451,13 @@ describe( 'Deposits Overview information', () => { } ); const { getByText, getByRole } = render( ); - getByText( /Your first payout is held for/, { - ignore: '.a11y-speak-region', - } ); - expect( getByRole( 'link', { name: /Why\?/ } ) ).toHaveAttribute( + getByText( + /Payout scheduling becomes available after the standard 7-day waiting period for new accounts is complete/, + { + ignore: '.a11y-speak-region', + } + ); + expect( getByRole( 'link', { name: /Learn more/ } ) ).toHaveAttribute( 'href', 'https://woocommerce.com/document/woopayments/payouts/payout-schedule/#new-accounts' ); diff --git a/client/components/deposits-overview/deposit-notices.tsx b/client/components/deposits-overview/deposit-notices.tsx index 0b634df9a27..6c89e06df5a 100644 --- a/client/components/deposits-overview/deposit-notices.tsx +++ b/client/components/deposits-overview/deposit-notices.tsx @@ -58,11 +58,11 @@ export const NewAccountWaitingPeriodNotice: React.FC = () => ( > { interpolateComponents( { mixedString: __( - 'Your first payout is held for 7-14 days. {{whyLink}}Why?{{/whyLink}}', + 'Payout scheduling becomes available after the standard 7-day waiting period for new accounts is complete. {{learnMoreLink}}Learn more{{/learnMoreLink}}', 'woocommerce-payments' ), components: { - whyLink: ( + learnMoreLink: ( // Link content is in the format string above. Consider disabling jsx-a11y/anchor-has-content. // eslint-disable-next-line jsx-a11y/anchor-has-content { ); const depositsMessage = screen.getByText( - /Your first payout will be held for/, + /Payout scheduling becomes available after the standard 7-day waiting period for new accounts is complete/, { ignore: '.a11y-speak-region', } diff --git a/client/settings/deposits/index.js b/client/settings/deposits/index.js index 7f30086191b..f4770ee39e4 100644 --- a/client/settings/deposits/index.js +++ b/client/settings/deposits/index.js @@ -189,8 +189,9 @@ const DepositsSchedule = () => { { interpolateComponents( { mixedString: __( - 'Your first payout will be held for 7-14 days. ' + - 'Payout scheduling will be available after this period. {{learnMoreLink}}Learn more{{/learnMoreLink}}', + 'Payout scheduling becomes available after the standard 7-day waiting period for new accounts is complete.' + + ' ' + + '{{learnMoreLink}}Learn more{{/learnMoreLink}}', 'woocommerce-payments' ), components: { From 5c795a1f1cc7349df67b6148dae82543c09cf41c Mon Sep 17 00:00:00 2001 From: Miguel Gasca Date: Mon, 10 Nov 2025 17:44:31 +0100 Subject: [PATCH 11/32] Fix WooPay express button text clipping (#11126) --- .stylelintrc.json | 9 ++ ...ll-shaped-button-is-cutoff-on-checkout-alt | 4 + .../checkout/woopay/express-button/style.scss | 117 +++++++++++++++++- 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 changelog/woopmnt-5396-woopay-pill-shaped-button-is-cutoff-on-checkout-alt diff --git a/.stylelintrc.json b/.stylelintrc.json index c4fa00b6a7b..a11fbab36e7 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -15,6 +15,15 @@ "media-feature-parentheses-space-inside": "always", "no-descending-specificity": null, "no-duplicate-selectors": null, + "property-no-unknown": [ + true, + { + "ignoreProperties": [ + "container-type", + "container-name" + ] + } + ], "rule-empty-line-before": null, "selector-class-pattern": null, "selector-pseudo-class-parentheses-space-inside": "always", diff --git a/changelog/woopmnt-5396-woopay-pill-shaped-button-is-cutoff-on-checkout-alt b/changelog/woopmnt-5396-woopay-pill-shaped-button-is-cutoff-on-checkout-alt new file mode 100644 index 00000000000..47f0d0c2bc2 --- /dev/null +++ b/changelog/woopmnt-5396-woopay-pill-shaped-button-is-cutoff-on-checkout-alt @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix WooPay express button text clipping diff --git a/client/checkout/woopay/express-button/style.scss b/client/checkout/woopay/express-button/style.scss index a972d9ef4da..c4bb33c40f7 100644 --- a/client/checkout/woopay/express-button/style.scss +++ b/client/checkout/woopay/express-button/style.scss @@ -1,4 +1,8 @@ #wcpay-woopay-button { + // Enable container queries on the button wrapper + container-type: inline-size; + container-name: woopay-button; + .woopay-express-button { font-size: 18px; font-weight: 500; @@ -18,13 +22,15 @@ text-transform: none; list-style-type: none; min-height: auto; + overflow: hidden; position: relative; .button-content { display: flex; - align-content: center; + align-items: center; justify-content: center; transform: scale( 0.9 ); + max-width: 100%; } &:not( :disabled ) { @@ -57,11 +63,42 @@ &[data-type='buy'], &[data-type='book'], &[data-type='donate'] { - min-width: 200px; + min-width: 150px; svg { margin-left: 5px; } + + // Container queries for small buttons (40px height) + @container woopay-button (max-width: 280px) { + font-size: 14px; + letter-spacing: 0.5px; + + svg { + width: 88px; + margin-left: 4px; + } + } + + @container woopay-button (max-width: 240px) { + font-size: 12px; + letter-spacing: 0.3px; + + svg { + width: 84px; + margin-left: 3px; + } + } + + @container woopay-button (max-width: 200px) { + font-size: 10px; + letter-spacing: 0.2px; + + svg { + width: 80px; + margin-left: 2px; + } + } } &[data-theme='dark'] { @@ -91,7 +128,46 @@ &[data-type='buy'], &[data-type='book'], &[data-type='donate'] { - min-width: 229px; + min-width: 150px; + + // Container queries: respond to actual button container width + // These trigger based on the button's actual width in the layout + + // Default state for wide containers (> 280px) + // Uses default 18px font, 99px logo from parent + + // When container is moderately constrained + @container woopay-button (max-width: 280px) { + font-size: 15px; + letter-spacing: 0.5px; + + svg { + width: 90px; + margin-left: 4px; + } + } + + // When container is squeezed (common in 3-button horizontal layouts) + @container woopay-button (max-width: 240px) { + font-size: 13px; + letter-spacing: 0.3px; + + svg { + width: 86px; + margin-left: 3px; + } + } + + // When container is very tight + @container woopay-button (max-width: 200px) { + font-size: 11px; + letter-spacing: 0.2px; + + svg { + width: 82px; + margin-left: 2px; + } + } } .button-content { @@ -105,7 +181,40 @@ &[data-type='buy'], &[data-type='book'], &[data-type='donate'] { - min-width: 229px; + min-width: 150px; + + // Container queries for large buttons + // Slightly larger than medium buttons at each breakpoint + + @container woopay-button (max-width: 280px) { + font-size: 16px; + letter-spacing: 0.6px; + + svg { + width: 93px; + margin-left: 4px; + } + } + + @container woopay-button (max-width: 240px) { + font-size: 14px; + letter-spacing: 0.4px; + + svg { + width: 89px; + margin-left: 3px; + } + } + + @container woopay-button (max-width: 200px) { + font-size: 12px; + letter-spacing: 0.3px; + + svg { + width: 85px; + margin-left: 2px; + } + } } .button-content { From 1b5a4f7190ca7cefaaed3a3b837bb2dfc97506c4 Mon Sep 17 00:00:00 2001 From: Miguel Gasca Date: Mon, 10 Nov 2025 18:34:50 +0100 Subject: [PATCH 12/32] [E2E] [QIT] Add Jetpack connection support for QIT E2E tests (#11049) --- changelog/dev-qit-e2e-basic-checkout | 3 + tests/qit/config/default.env | 8 + tests/qit/e2e-runner.sh | 82 ++++++- tests/qit/e2e/basic.spec.js | 28 ++- .../class-wp-cli-qit-dev-command.php | 205 ++++++++++++++++++ .../e2e/bootstrap/qit-jetpack-connection.php | 43 ++++ .../qit/e2e/bootstrap/qit-jetpack-status.php | 26 +++ tests/qit/e2e/bootstrap/setup.sh | 44 +++- tests/qit/e2e/checkout.spec.js | 123 +++++++++++ tests/qit/qit.yml | 6 + 10 files changed, 539 insertions(+), 29 deletions(-) create mode 100644 changelog/dev-qit-e2e-basic-checkout create mode 100644 tests/qit/e2e/bootstrap/class-wp-cli-qit-dev-command.php create mode 100644 tests/qit/e2e/bootstrap/qit-jetpack-connection.php create mode 100644 tests/qit/e2e/bootstrap/qit-jetpack-status.php create mode 100644 tests/qit/e2e/checkout.spec.js diff --git a/changelog/dev-qit-e2e-basic-checkout b/changelog/dev-qit-e2e-basic-checkout new file mode 100644 index 00000000000..a1d72601ed5 --- /dev/null +++ b/changelog/dev-qit-e2e-basic-checkout @@ -0,0 +1,3 @@ +Significance: patch +Type: dev +Comment: Dev only changes to run E2E tests with QIT. diff --git a/tests/qit/config/default.env b/tests/qit/config/default.env index 343592c59e8..b6475692b83 100644 --- a/tests/qit/config/default.env +++ b/tests/qit/config/default.env @@ -7,3 +7,11 @@ QIT_USER=your_qit_username QIT_PASSWORD=your_qit_application_password +# =========================================== +# E2E TEST CREDENTIALS (optional - for basic connectivity testing) +# =========================================== +# These provide basic WooPayments plugin connectivity for E2E tests +E2E_JP_SITE_ID=your_site_id_here +E2E_JP_BLOG_TOKEN=your_blog_token_here +E2E_JP_USER_TOKEN=your_user_token_here + diff --git a/tests/qit/e2e-runner.sh b/tests/qit/e2e-runner.sh index e72b4ccbe53..fff26773f1e 100755 --- a/tests/qit/e2e-runner.sh +++ b/tests/qit/e2e-runner.sh @@ -22,18 +22,63 @@ echo "Running E2E tests..." # Change to project root directory to build plugin cd "$WCP_ROOT" -# Foundation version: Simplified build process for easier testing -# For this foundation PR, we'll always build to avoid complex signature computation issues +# Compute a signature of sources relevant to the release build and +# skip rebuilding if nothing has changed since the last build. +compute_build_signature() { + # Hash tracked files that affect the release artifact. This includes + # sources packaged in the zip and build/config files that affect the output. + git ls-files -z -- \ + assets \ + i18n \ + includes \ + languages \ + lib \ + src \ + templates \ + client \ + tasks/release.js \ + webpack \ + webpack.config.js \ + babel.config.js \ + package.json \ + package-lock.json \ + composer.json \ + composer.lock \ + woocommerce-payments.php \ + changelog.txt \ + readme.txt \ + SECURITY.md \ + 2>/dev/null \ + | xargs -0 shasum -a 256 2>/dev/null \ + | shasum -a 256 \ + | awk '{print $1}' + + # Explicitly return 0 to avoid pipefail issues + return 0 +} BUILD_HASH_FILE="$WCP_ROOT/woocommerce-payments.zip.hash" -# For this foundation PR, always build if zip doesn't exist or if forced -if [[ -n "${WCP_FORCE_BUILD:-}" ]] || [[ ! -f "woocommerce-payments.zip" ]]; then - echo "Building WooPayments plugin..." +CURRENT_SIG="$(compute_build_signature)" + +# If WCP_FORCE_BUILD is set, always rebuild +if [[ -n "${WCP_FORCE_BUILD:-}" ]]; then + echo "WCP_FORCE_BUILD set; forcing build of WooPayments plugin..." npm run build:release - echo "foundation-build-$(date +%s)" > "$BUILD_HASH_FILE" + echo "$CURRENT_SIG" > "$BUILD_HASH_FILE" +elif [[ -f "woocommerce-payments.zip" && -f "$BUILD_HASH_FILE" ]]; then + LAST_SIG="$(cat "$BUILD_HASH_FILE" 2>/dev/null || true)" + if [[ "$CURRENT_SIG" == "$LAST_SIG" && -n "$CURRENT_SIG" ]]; then + echo "No relevant changes detected since last build; skipping build." + else + echo "Changes detected; rebuilding WooPayments plugin..." + npm run build:release + echo "$CURRENT_SIG" > "$BUILD_HASH_FILE" + fi else - echo "Using existing woocommerce-payments.zip" + echo "Building WooPayments plugin..." + npm run build:release + echo "$CURRENT_SIG" > "$BUILD_HASH_FILE" fi # Change to QIT directory so qit.yml is automatically found @@ -46,10 +91,27 @@ else QIT_CMD="$QIT_BINARY" fi -echo "Running QIT E2E foundation tests (no Jetpack credentials)..." +# Pass basic Jetpack environment variables +env_args=() +if [[ -n "${E2E_JP_SITE_ID:-}" ]]; then + env_args+=( --env "E2E_JP_SITE_ID=${E2E_JP_SITE_ID}" ) +fi +if [[ -n "${E2E_JP_BLOG_TOKEN:-}" ]]; then + env_args+=( --env "E2E_JP_BLOG_TOKEN=${E2E_JP_BLOG_TOKEN}" ) +fi +if [[ -n "${E2E_JP_USER_TOKEN:-}" ]]; then + env_args+=( --env "E2E_JP_USER_TOKEN=${E2E_JP_USER_TOKEN}" ) +fi # Run our QIT E2E tests (qit.yml automatically loaded from current directory) -"$QIT_CMD" run:e2e woocommerce-payments ./e2e \ - --source "$WCP_ROOT/woocommerce-payments.zip" +echo "Running QIT E2E tests with Jetpack functionality..." +if [ ${#env_args[@]} -eq 0 ]; then + "$QIT_CMD" run:e2e woocommerce-payments ./e2e \ + --source "$WCP_ROOT/woocommerce-payments.zip" +else + "$QIT_CMD" run:e2e woocommerce-payments ./e2e \ + --source "$WCP_ROOT/woocommerce-payments.zip" \ + "${env_args[@]}" +fi echo "QIT E2E foundation tests completed!" diff --git a/tests/qit/e2e/basic.spec.js b/tests/qit/e2e/basic.spec.js index 8600710c580..c8582492925 100644 --- a/tests/qit/e2e/basic.spec.js +++ b/tests/qit/e2e/basic.spec.js @@ -15,15 +15,13 @@ test( 'Load home page', async ( { page } ) => { } ); /** - * Test WooCommerce Payments onboarding flow access - * Since we're running in development mode without Jetpack connection, - * we expect to always land on the onboarding flow. + * Test admin authentication and WooPayments plugin access */ -test( 'Access WooCommerce Payments onboarding as admin', async ( { page } ) => { +test( 'Access WooPayments as admin', async ( { page } ) => { // Use QIT helper to login as admin await qit.loginAsAdmin( page ); - // Navigate to WooCommerce Payments settings + // Navigate to WooPayments settings await page.goto( '/wp-admin/admin.php?page=wc-admin&path=%2Fpayments%2Foverview' ); @@ -33,13 +31,21 @@ test( 'Access WooCommerce Payments onboarding as admin', async ( { page } ) => { page.locator( 'h1:not(.screen-reader-text)' ).first() ).toContainText( /Settings|Payments|Overview/, { timeout: 15000 } ); - // In development mode without Jetpack connection, we should be on onboarding - expect( page.url() ).toContain( 'onboarding' ); + // Check that we can successfully load the WooPayments interface + // Either we get the overview (if fully connected) OR the onboarding. + const isOnboarding = page.url().includes( 'onboarding' ); + const isOverview = page.url().includes( 'payments' ); - // The onboarding page should load without errors - await expect( page.locator( 'body' ) ).not.toHaveText( - /500|404|Fatal error/ - ); + // We should be on either the onboarding or overview page (both indicate success) + expect( isOnboarding || isOverview ).toBe( true ); + + // If we're on onboarding, it should be functional (not errored) + if ( isOnboarding ) { + // The onboarding page should load without errors + await expect( page.locator( 'body' ) ).not.toHaveText( + /500|404|Fatal error/ + ); + } } ); /** diff --git a/tests/qit/e2e/bootstrap/class-wp-cli-qit-dev-command.php b/tests/qit/e2e/bootstrap/class-wp-cli-qit-dev-command.php new file mode 100644 index 00000000000..da39632b543 --- /dev/null +++ b/tests/qit/e2e/bootstrap/class-wp-cli-qit-dev-command.php @@ -0,0 +1,205 @@ + + * : Numeric blog ID from WordPress.com. + * + * [--blog_token=] + * : Jetpack blog token. + * + * [--user_token=] + * : Jetpack user token. + * + * ## EXAMPLES + * wp woopayments qit_jetpack_connection 248403234 --blog_token=abc123 --user_token=def456 + * + * @param array $args Positional arguments passed to the command. + * @param array $assoc_args Associative arguments passed to the command. + */ + public function qit_jetpack_connection( array $args, array $assoc_args ): void { + // Safety check: Only allow in local/development environments. + $environment_type = function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production'; + if ( 'local' !== $environment_type && 'development' !== $environment_type ) { + \WP_CLI::error( 'This command can only be run in local or development environments for safety.' ); + } + + if ( empty( $args[0] ) || ! is_numeric( $args[0] ) ) { + \WP_CLI::error( 'Please provide a numeric blog ID.' ); + } + + if ( ! class_exists( 'Jetpack_Options' ) ) { + \WP_CLI::error( 'Jetpack_Options class does not exist. Ensure Jetpack is installed and active.' ); + } + + $blog_id = (int) $args[0]; + $blog_token = isset( $assoc_args['blog_token'] ) ? (string) $assoc_args['blog_token'] : '123.ABC.QIT'; + $user_token = isset( $assoc_args['user_token'] ) ? (string) $assoc_args['user_token'] : '123.ABC.QIT.1'; + + // Force test mode BEFORE any other operations (since this is a test account). + $this->force_test_mode(); + + // Set up Jetpack connection. + $this->setup_jetpack_connection( $blog_id, $blog_token, $user_token ); + + // Enable dev mode (like WCP Dev Tools plugin does). + $this->enable_dev_mode(); + + // Refresh account data to get real account info from server (like regular E2E tests). + if ( class_exists( 'WC_Payments' ) ) { + $this->refresh_account_data(); + } + + \WP_CLI::success( "Jetpack connection established for blog ID {$blog_id}" ); + \WP_CLI::line( 'Account data fetched from server based on Jetpack connection' ); + } + + /** + * Shows Jetpack connection status for WooPayments QIT testing. + * + * @when after_wp_load + */ + public function qit_jetpack_status(): void { + // Safety check: Only allow in local/development environments. + $environment_type = function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production'; + if ( 'local' !== $environment_type && 'development' !== $environment_type ) { + \WP_CLI::error( 'This command can only be run in local or development environments for safety.' ); + } + + \WP_CLI::line( '=== QIT Jetpack Connection Status ===' ); + + if ( class_exists( 'Jetpack_Options' ) ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + \WP_CLI::line( 'Blog ID: ' . ( $blog_id ? $blog_id : 'Not Set' ) ); + } + + if ( class_exists( 'WC_Payments' ) ) { + $database_cache = \WC_Payments::get_database_cache(); + if ( $database_cache ) { + $account_data = $database_cache->get( Database_Cache::ACCOUNT_KEY ); + \WP_CLI::line( 'Account Data: ' . ( $account_data ? 'Present' : 'Not Set' ) ); + } + } + + \WP_CLI::line( 'Dev Mode: ' . ( get_option( 'wcpaydev_dev_mode' ) ? 'Enabled' : 'Disabled' ) ); + } + + /** + * Configures Jetpack connection options. + * + * @param int $blog_id WordPress.com blog ID. + * @param string $blog_token Jetpack blog token. + * @param string $user_token Jetpack user token. + */ + private function setup_jetpack_connection( int $blog_id, string $blog_token, string $user_token ): void { + $user_tokens = [ 1 => $user_token ]; + + Jetpack_Options::update_option( 'id', $blog_id ); + Jetpack_Options::update_option( 'master_user', 1 ); + Jetpack_Options::update_option( 'blog_token', $blog_token ); + Jetpack_Options::update_option( 'user_tokens', $user_tokens ); + + \WP_CLI::log( "Jetpack connection configured for blog ID {$blog_id}" ); + } + + /** + * Enables WCP development mode like the WCP Dev Tools plugin. + */ + private function enable_dev_mode(): void { + // Enable dev mode like WCP Dev Tools plugin does. + update_option( 'wcpaydev_dev_mode', '1' ); + + // Add the dev mode filter like WCP Dev Tools plugin does. + add_filter( 'wcpay_dev_mode', '__return_true' ); + + \WP_CLI::log( 'Enabled WCPay dev mode with filter' ); + } + + /** + * Forces WCP test mode by setting filters and gateway settings. + * + * DEFENSE IN DEPTH STRATEGY: + * This method uses multiple independent mechanisms to ensure test mode is active. + * While WP_ENVIRONMENT_TYPE=development automatically enables dev mode (see WCPay\Core\Mode), + * we explicitly set test mode through multiple layers for maximum safety: + * + * 1. WordPress filters - Override mode detection at runtime + * 2. Gateway settings - Persist test mode in database + * 3. Onboarding service - Set test mode at service layer + * + * This redundancy protects against: + * - Changes to Mode class logic + * - Filter overrides by other code + * - Environment variable changes + * - Accidental live mode activation + * + * All mechanisms must fail for live mode to activate - acceptable tradeoff for test safety. + */ + private function force_test_mode(): void { + // Force test mode onboarding and test mode since we're using a test account. + add_filter( 'wcpay_test_mode_onboarding', '__return_true' ); + add_filter( 'wcpay_test_mode', '__return_true' ); + + // Also try setting the gateway settings to enable test mode. + $gateway_settings = get_option( 'woocommerce_woocommerce_payments_settings', [] ); + $gateway_settings['test_mode'] = 'yes'; + update_option( 'woocommerce_woocommerce_payments_settings', $gateway_settings ); + + // CRITICAL: Use WC_Payments_Onboarding_Service to set test mode (this sets test_mode_onboarding). + if ( class_exists( 'WC_Payments_Onboarding_Service' ) ) { + \WC_Payments_Onboarding_Service::set_test_mode( true ); + \WP_CLI::log( 'Set WC_Payments_Onboarding_Service test mode - this enables test_mode_onboarding' ); + } + + \WP_CLI::log( 'Forced WCPay test mode for test account (filters + gateway settings + onboarding service)' ); + } + + /** + * Refreshes account data from the WCP server and validates the connection. + */ + private function refresh_account_data(): void { + if ( ! class_exists( 'WC_Payments' ) ) { + \WP_CLI::log( 'WC_Payments not available - skipping account refresh' ); + return; + } + + try { + $account_service = \WC_Payments::get_account_service(); + \WP_CLI::log( 'Attempting to refresh account data...' ); + + $result = $account_service->refresh_account_data(); + + // Check if data was actually set. + $database_cache = \WC_Payments::get_database_cache(); + $account_data = $database_cache ? $database_cache->get( Database_Cache::ACCOUNT_KEY ) : null; + + if ( $account_data ) { + \WP_CLI::log( 'Account data refreshed successfully from server' ); + // Verify key fields exist without exposing sensitive data. + $has_account_id = isset( $account_data['account_id'] ) && ! empty( $account_data['account_id'] ); + $has_keys = isset( $account_data['live_publishable_key'] ) || isset( $account_data['test_publishable_key'] ); + $status = $account_data['status'] ?? 'unknown'; + \WP_CLI::log( 'Account validation: ID=' . ( $has_account_id ? 'present' : 'missing' ) . ', Keys=' . ( $has_keys ? 'present' : 'missing' ) . ', Status=' . $status ); + } else { + \WP_CLI::warning( 'Account refresh completed but no account data cached - connection may be invalid' ); + } + } catch ( \Exception $e ) { + \WP_CLI::warning( 'Account refresh failed: ' . $e->getMessage() ); + } + } +} diff --git a/tests/qit/e2e/bootstrap/qit-jetpack-connection.php b/tests/qit/e2e/bootstrap/qit-jetpack-connection.php new file mode 100644 index 00000000000..308bcbd25ff --- /dev/null +++ b/tests/qit/e2e/bootstrap/qit-jetpack-connection.php @@ -0,0 +1,43 @@ +qit_jetpack_connection( + [ $site_id ], + [ + 'blog_token' => $blog_token, + 'user_token' => $user_token, + ] +); diff --git a/tests/qit/e2e/bootstrap/qit-jetpack-status.php b/tests/qit/e2e/bootstrap/qit-jetpack-status.php new file mode 100644 index 00000000000..1596ab4adc2 --- /dev/null +++ b/tests/qit/e2e/bootstrap/qit-jetpack-status.php @@ -0,0 +1,26 @@ +qit_jetpack_status(); diff --git a/tests/qit/e2e/bootstrap/setup.sh b/tests/qit/e2e/bootstrap/setup.sh index 7f1c923eaf2..b1c2861ed0d 100755 --- a/tests/qit/e2e/bootstrap/setup.sh +++ b/tests/qit/e2e/bootstrap/setup.sh @@ -3,13 +3,13 @@ set -euo pipefail IFS=$'\n\t' -# QIT Bootstrap Setup for WooCommerce Payments E2E Tests +# QIT Bootstrap Setup for WooPayments E2E Tests # This script runs before tests to configure the plugin environment -echo "Setting up WooCommerce Payments for E2E testing..." +echo "Setting up WooPayments for E2E testing..." -# Ensure environment is marked as local so dev-only CLI commands are available -wp config set WP_ENVIRONMENT_TYPE local --quiet 2>/dev/null || true +# Ensure environment is marked as development so dev-only CLI commands are available +wp config set WP_ENVIRONMENT_TYPE development --quiet 2>/dev/null || true # Create a test product for payment testing PRODUCT_ID=$(wp post create \ @@ -41,11 +41,39 @@ wp user create testcustomer test@example.com \ --last_name="Customer" \ --quiet -echo "Setting up WooCommerce Payments configuration..." +echo "Setting up WooPayments configuration..." -# NOTE: Jetpack connection setup will be added in future PRs -# For now, WooPayments will run in development mode -echo "Running WooPayments without a Jetpack connection (Jetpack connection setup in upcoming PRs)" +# Enable WooPayments settings (same as main E2E tests) +echo "Creating/updating WooPayments settings" +wp option set woocommerce_woocommerce_payments_settings --format=json '{"enabled":"yes"}' + +# Check required environment variables for basic Jetpack authentication +if [ -n "${E2E_JP_SITE_ID:-}" ] && [ -n "${E2E_JP_BLOG_TOKEN:-}" ] && [ -n "${E2E_JP_USER_TOKEN:-}" ]; then + echo "Configuring WCPay with Jetpack authentication..." + + # Set up Jetpack connection and refresh account data from server + # Environment variables are automatically available to PHP via getenv() + # Note: /qit/bootstrap is a volume mount defined in qit.yml pointing to ./e2e/bootstrap + wp eval-file /qit/bootstrap/qit-jetpack-connection.php + + echo "✅ WooPayments connection configured - account data fetched from server" + +else + echo "No Jetpack credentials configured - WooPayments will show Connect screen" + echo "WooPayments will show Connect screen" + echo "" + echo "For basic connectivity testing, set in tests/qit/config/local.env:" + echo " E2E_JP_SITE_ID=123456789" + echo " E2E_JP_BLOG_TOKEN=123.ABC.QIT" + echo " E2E_JP_USER_TOKEN=123.ABC.QIT.1" + echo "" +fi + +# Always check the setup status +echo "" +echo "Current WooPayments setup status:" +# Note: /qit/bootstrap is a volume mount defined in qit.yml pointing to ./e2e/bootstrap +wp eval-file /qit/bootstrap/qit-jetpack-status.php # Enable development/test mode for better testing experience wp option set wcpay_dev_mode 1 --quiet 2>/dev/null || true diff --git a/tests/qit/e2e/checkout.spec.js b/tests/qit/e2e/checkout.spec.js new file mode 100644 index 00000000000..301c0b2de3e --- /dev/null +++ b/tests/qit/e2e/checkout.spec.js @@ -0,0 +1,123 @@ +/** + * External dependencies + */ +const { test, expect } = require( '@playwright/test' ); +import qit from '/qitHelpers'; + +/** + * WooPayments Connection Validation Tests + * + * These tests verify that WooPayments is properly connected and configured + * in the QIT E2E testing environment. They validate the core connection without + * testing actual checkout functionality. + */ +test.describe( 'WooPayments Connection Status', () => { + test( 'should verify WooPayments is connected (not showing Connect screen)', async ( { + page, + } ) => { + // Login as admin first + await qit.loginAsAdmin( page ); + + // Navigate directly to WooPayments settings + await page.goto( + '/wp-admin/admin.php?page=wc-settings&tab=checkout§ion=woocommerce_payments' + ); + + const pageContent = await page.textContent( 'body' ); + + // If we see primary "Connect" buttons, we're NOT connected + if ( + pageContent.includes( 'Connect your store' ) || + pageContent.includes( 'Connect WooPayments' ) || + pageContent.includes( 'Set up WooPayments' ) + ) { + throw new Error( + 'WooPayments is NOT connected - showing Connect screen' + ); + } + + // Look for connected account configuration options + // These elements only appear when WC Payments is connected and configured + const hasConnectedFeatures = + pageContent.includes( 'Enable WooPayments' ) || + pageContent.includes( 'Enable/disable' ) || + pageContent.includes( 'Payment methods' ) || + pageContent.includes( 'Capture charges automatically' ) || + pageContent.includes( 'Manual capture' ) || + pageContent.includes( 'Test mode' ) || + pageContent.includes( 'Debug mode' ); + + if ( ! hasConnectedFeatures ) { + throw new Error( + 'No WooPayments configuration options found - may not be properly connected' + ); + } + + expect( hasConnectedFeatures ).toBe( true ); + + // Additional verification: Should not see primary connection prompts + expect( pageContent ).not.toContain( 'Connect your store' ); + expect( pageContent ).not.toContain( 'Connect WooPayments' ); + } ); + + test( 'should verify account data is fetched from server', async ( { + page, + } ) => { + // Login as admin first + await qit.loginAsAdmin( page ); + + // Navigate to WooPayments overview page to check account status + await page.goto( + '/wp-admin/admin.php?page=wc-admin&path=%2Fpayments%2Foverview' + ); + + const pageContent = await page.textContent( 'body' ); + + // Account should be connected via Jetpack + // Look for specific indicators that account data was fetched from test account + const hasAccountData = + pageContent.includes( 'Test account' ) || + pageContent.includes( 'Live account' ) || + pageContent.includes( 'Account status' ) || + pageContent.includes( 'Payments' ) || + pageContent.includes( 'Overview' ) || + pageContent.includes( 'Deposits' ) || + pageContent.includes( 'Transactions' ) || + // Test account specific indicators + pageContent.includes( 'acct_' ) || // Stripe account ID + pageContent.includes( 'Your store is connected' ) || + pageContent.includes( 'Payment methods' ); + + if ( ! hasAccountData ) { + // Check if we're seeing an error or connection issue + if ( + pageContent.includes( 'Connect' ) || + pageContent.includes( 'Set up' ) + ) { + throw new Error( + 'Account is not connected - showing setup/connect screen' + ); + } + + if ( + pageContent.includes( 'Error' ) || + pageContent.includes( 'Unable to connect' ) + ) { + throw new Error( + 'Connection error detected - account data not fetched' + ); + } + + throw new Error( + 'No account data indicators found - connection may have failed' + ); + } + + // Verify Jetpack connection is working + expect( hasAccountData ).toBe( true ); + + // Additional check: Should not see connection errors + expect( pageContent ).not.toContain( 'Unable to connect' ); + expect( pageContent ).not.toContain( 'Connection failed' ); + } ); +} ); diff --git a/tests/qit/qit.yml b/tests/qit/qit.yml index 5c19ef14c16..8f00da72034 100644 --- a/tests/qit/qit.yml +++ b/tests/qit/qit.yml @@ -13,3 +13,9 @@ php_version: "8.3" plugin: - "woocommerce" - "jetpack" + +# Mount bootstrap directory for easier access in setup scripts. +# This mounts ./e2e/bootstrap (relative to this qit.yml file) to /qit/bootstrap +# inside the QIT test container (read-only for safety). +volumes: + - "./e2e/bootstrap:/qit/bootstrap:ro" From c9b165adf5481b4e690f811d1040a826e0f09886 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 11 Nov 2025 10:40:54 +0100 Subject: [PATCH 13/32] chore: add Amazon Pay feature flag (#11128) --- changelog/chore-add-amazon-pay-feature-flag | 4 ++++ includes/class-wc-payments-features.php | 10 +++++++++ .../unit/test-class-wc-payments-features.php | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 changelog/chore-add-amazon-pay-feature-flag diff --git a/changelog/chore-add-amazon-pay-feature-flag b/changelog/chore-add-amazon-pay-feature-flag new file mode 100644 index 00000000000..bb656d14bba --- /dev/null +++ b/changelog/chore-add-amazon-pay-feature-flag @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +chore: add amazon pay feature flag. diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index afcec33bf21..6b168e32f7f 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -30,6 +30,7 @@ class WC_Payments_Features { const WOOPAY_GLOBAL_THEME_SUPPORT_FLAG_NAME = '_wcpay_feature_woopay_global_theme_support'; const WCPAY_DYNAMIC_CHECKOUT_PLACE_ORDER_BUTTON_FLAG_NAME = '_wcpay_feature_dynamic_checkout_place_order_button'; const ACCOUNT_DETAILS_FLAG_NAME = '_wcpay_feature_account_details'; + const AMAZON_PAY_FLAG_NAME = '_wcpay_feature_amazon_pay'; /** * Indicates whether card payments are enabled for this (Stripe) account. @@ -350,6 +351,15 @@ public static function is_account_details_enabled(): bool { return '1' === get_option( self::ACCOUNT_DETAILS_FLAG_NAME, '0' ); } + /** + * Checks whether Amazon Pay is enabled. + * + * @return bool + */ + public static function is_amazon_pay_enabled(): bool { + return '1' === get_option( self::AMAZON_PAY_FLAG_NAME, '0' ); + } + /** * Returns feature flags as an array suitable for display on the front-end. * diff --git a/tests/unit/test-class-wc-payments-features.php b/tests/unit/test-class-wc-payments-features.php index 98ba24108e6..a719cff4aa6 100644 --- a/tests/unit/test-class-wc-payments-features.php +++ b/tests/unit/test-class-wc-payments-features.php @@ -348,4 +348,26 @@ public function test_to_array_includes_account_details_flag() { $this->assertArrayHasKey( 'isAccountDetailsEnabled', $result ); $this->assertTrue( $result['isAccountDetailsEnabled'] ); } + + public function test_is_amazon_pay_enabled_returns_false_when_disabled() { + $this->set_feature_flag_option( WC_Payments_Features::AMAZON_PAY_FLAG_NAME, '0' ); + + $result = WC_Payments_Features::is_amazon_pay_enabled(); + + $this->assertFalse( $result ); + } + + public function test_is_amazon_pay_enabled_returns_true_when_enabled() { + $this->set_feature_flag_option( WC_Payments_Features::AMAZON_PAY_FLAG_NAME, '1' ); + + $result = WC_Payments_Features::is_amazon_pay_enabled(); + + $this->assertTrue( $result ); + } + + public function test_is_amazon_pay_enabled_returns_false_by_default() { + $result = WC_Payments_Features::is_amazon_pay_enabled(); + + $this->assertFalse( $result ); + } } From 87a381e719c22dd52c9889c0634164b28e23ad2d Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 11 Nov 2025 13:01:25 +0100 Subject: [PATCH 14/32] fix: text color contrast of payment methods w/ dark bg (#11124) --- changelog/fix-payment-methods-logos-overflow-text-color | 4 ++++ client/checkout/blocks/payment-methods-logos/style.scss | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog/fix-payment-methods-logos-overflow-text-color diff --git a/changelog/fix-payment-methods-logos-overflow-text-color b/changelog/fix-payment-methods-logos-overflow-text-color new file mode 100644 index 00000000000..e2268dc3f91 --- /dev/null +++ b/changelog/fix-payment-methods-logos-overflow-text-color @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +fix: text color of payment method icons on checkout page when a dark background is used diff --git a/client/checkout/blocks/payment-methods-logos/style.scss b/client/checkout/blocks/payment-methods-logos/style.scss index 7451565c99b..02b15df4957 100644 --- a/client/checkout/blocks/payment-methods-logos/style.scss +++ b/client/checkout/blocks/payment-methods-logos/style.scss @@ -21,7 +21,7 @@ width: 38px; height: 24px; background-color: rgba( $gray-700, 0.1 ); - color: $gray-900; + color: var( --wp--preset--color--contrast, $gray-900 ); text-align: center; line-height: 24px; border-radius: 3px; From 39c94b6accc7033dc30c3a952f736030fe88ae69 Mon Sep 17 00:00:00 2001 From: Alefe Souza Date: Tue, 11 Nov 2025 15:10:47 -0300 Subject: [PATCH 15/32] Fix WooPay phone field on Blocks Checkout (#11125) --- changelog/fix-woopay-blocks-field | 4 +++ client/checkout/blocks/style.scss | 13 ++----- .../save-user/checkout-page-save-user.js | 35 ++++++++++--------- .../components/woopay/save-user/container.js | 8 ++++- client/components/woopay/save-user/style.scss | 7 ---- client/settings/phone-input/style.scss | 14 +++----- 6 files changed, 35 insertions(+), 46 deletions(-) create mode 100644 changelog/fix-woopay-blocks-field diff --git a/changelog/fix-woopay-blocks-field b/changelog/fix-woopay-blocks-field new file mode 100644 index 00000000000..3ddc4e36e48 --- /dev/null +++ b/changelog/fix-woopay-blocks-field @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +WooPay component spacing issues on blocks and classic checkout. diff --git a/client/checkout/blocks/style.scss b/client/checkout/blocks/style.scss index 7b73d3f94ab..dfb310833c9 100644 --- a/client/checkout/blocks/style.scss +++ b/client/checkout/blocks/style.scss @@ -16,10 +16,6 @@ padding: 0; } -#contact-fields { - padding-bottom: 1.5em; -} - .wc-block-components-text-input button.wcpay-stripelink-modal-trigger { top: 50%; transform: translateY( -50% ); @@ -96,13 +92,8 @@ button.wcpay-stripelink-modal-trigger:hover { } } -#remember-me { - h2 { - font-size: 18px; - font-weight: 600; - line-height: 21.6px; - letter-spacing: -0.01em; - } +#remember-me:empty { + margin-bottom: 0; } #payment-method { diff --git a/client/components/woopay/save-user/checkout-page-save-user.js b/client/components/woopay/save-user/checkout-page-save-user.js index 85043da5109..064a35d0029 100644 --- a/client/components/woopay/save-user/checkout-page-save-user.js +++ b/client/components/woopay/save-user/checkout-page-save-user.js @@ -385,24 +385,25 @@ const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { } } isBlocksCheckout={ isBlocksCheckout } /> + + { isBlocksCheckout && ( + + ) } + { ! isBlocksCheckout && ! isPhoneValid && ( + + ) } - { isBlocksCheckout && ( - - ) } - { ! isBlocksCheckout && ! isPhoneValid && ( - - ) } diff --git a/client/components/woopay/save-user/container.js b/client/components/woopay/save-user/container.js index 7a032fe795f..8e05f502f89 100644 --- a/client/components/woopay/save-user/container.js +++ b/client/components/woopay/save-user/container.js @@ -8,7 +8,13 @@ const Container = ( { children, isBlocksCheckout } ) => { return ( <>
-

{ __( 'Save my info' ) }

+
+
+

+ { __( 'Save my info' ) } +

+
+
{ children }
diff --git a/client/components/woopay/save-user/style.scss b/client/components/woopay/save-user/style.scss index 56f35162d33..8520b2a4ed0 100644 --- a/client/components/woopay/save-user/style.scss +++ b/client/components/woopay/save-user/style.scss @@ -1,9 +1,3 @@ -.woocommerce-checkout-payment { - .woopay-save-new-user-container { - padding: 1.41575em; - } -} - .woopay-save-new-user-container { .save-details { .wc-block-components-text-input input:-webkit-autofill { @@ -37,7 +31,6 @@ flex: 1; label { - gap: unset; display: flex !important; align-items: flex-start; padding: 0; diff --git a/client/settings/phone-input/style.scss b/client/settings/phone-input/style.scss index 0c99aea84d2..9705a4623ce 100644 --- a/client/settings/phone-input/style.scss +++ b/client/settings/phone-input/style.scss @@ -1,10 +1,6 @@ @import '/node_modules/intl-tel-input/build/css/intlTelInput.css'; .woopay-save-new-user-container { - display: flex; - flex-direction: column; - gap: $gap; - &:not( :empty ) { #payment .wc_payment_methods.payment_methods.methods + & { margin-top: $gap-large; @@ -63,20 +59,19 @@ } .additional-information { - font-size: 14px; - font-weight: 400; + font-size: var( --wp--preset--font-size--small, 14px ); line-height: 21px; text-align: left; } .tos { - font-size: 12px; + font-size: 13px; } #validate-error-invalid-woopay-phone-number { // Using rems to base this on the theme styles. - font-size: 0.875rem; - line-height: 1.5rem; + font-size: 13px; + line-height: 1; margin-bottom: 0; color: $alert-red; } @@ -91,7 +86,6 @@ box-shadow: none; border: 1px solid $gray-300; border-radius: 5px; - margin-left: 0.1rem; width: calc( 100% - 0.25rem ); &::placeholder { From 59c5b9cb2d7e270317e844599d3480b287c7db6b Mon Sep 17 00:00:00 2001 From: Dat Hoang Date: Wed, 12 Nov 2025 17:01:16 +0700 Subject: [PATCH 16/32] New AccountDetals: Remove the payout icon status (#11093) --- changelog/update-remove-payout-status-icon | 5 +++++ .../components/account-details/__tests__/index.test.tsx | 7 ------- client/components/account-details/index.tsx | 2 +- .../components/account-details/payout-status-wrapper.tsx | 8 ++------ client/types/account/account-details.d.ts | 5 ++--- 5 files changed, 10 insertions(+), 17 deletions(-) create mode 100644 changelog/update-remove-payout-status-icon diff --git a/changelog/update-remove-payout-status-icon b/changelog/update-remove-payout-status-icon new file mode 100644 index 00000000000..aa0f1309af1 --- /dev/null +++ b/changelog/update-remove-payout-status-icon @@ -0,0 +1,5 @@ +Significance: patch +Type: update +Comment: New Account Details: Remove the payout icon status to be consistent with account status (without icon) + + diff --git a/client/components/account-details/__tests__/index.test.tsx b/client/components/account-details/__tests__/index.test.tsx index d4d44b5bc04..1225fa67ee9 100644 --- a/client/components/account-details/__tests__/index.test.tsx +++ b/client/components/account-details/__tests__/index.test.tsx @@ -47,7 +47,6 @@ describe( 'AccountDetails', () => { payout_status: { text: 'Available', background_color: 'green', - icon: 'published', }, banner: null, }; @@ -70,7 +69,6 @@ describe( 'AccountDetails', () => { payout_status: { text: 'Disabled', background_color: 'red', - icon: 'error', }, banner: { text: 'Your account has been restricted by Stripe.', @@ -98,7 +96,6 @@ describe( 'AccountDetails', () => { payout_status: { text: 'Disabled', background_color: 'red', - icon: 'error', }, banner: { text: 'Your account has been rejected.', @@ -127,7 +124,6 @@ describe( 'AccountDetails', () => { payout_status: { text: 'Available in 2 days', background_color: 'yellow', - icon: 'caution', popover: { text: 'Payouts are processed within 2 business days.', cta_text: 'Learn more', @@ -154,7 +150,6 @@ describe( 'AccountDetails', () => { payout_status: { text: 'Available', background_color: 'green', - icon: 'published', }, banner: null, }; @@ -180,7 +175,6 @@ describe( 'AccountDetails', () => { payout_status: { text: 'Available', background_color: 'green', - icon: 'published', }, banner: null, }; @@ -206,7 +200,6 @@ describe( 'AccountDetails', () => { payout_status: { text: 'Available', background_color: 'green', - icon: 'published', }, banner: null, }; diff --git a/client/components/account-details/index.tsx b/client/components/account-details/index.tsx index f432cc95d7b..82cb71b193a 100644 --- a/client/components/account-details/index.tsx +++ b/client/components/account-details/index.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { __ } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; -import { Card, CardBody, CardHeader, Flex } from '@wordpress/components'; +import { Card, CardBody, CardHeader } from '@wordpress/components'; /** * Internal dependencies diff --git a/client/components/account-details/payout-status-wrapper.tsx b/client/components/account-details/payout-status-wrapper.tsx index 867d0b4f922..76e049b7034 100644 --- a/client/components/account-details/payout-status-wrapper.tsx +++ b/client/components/account-details/payout-status-wrapper.tsx @@ -14,7 +14,7 @@ import HelpOutlineIcon from 'gridicons/dist/help-outline'; import Chip from 'wcpay/components/chip'; import { ClickTooltip } from 'wcpay/components/tooltip'; import { AccountDetailsData } from 'wcpay/types/account/account-details'; -import { getChipTypeFromColor, getIconByName } from './utils'; +import { getChipTypeFromColor } from './utils'; const PayoutStatus: React.FC< { payoutStatus: AccountDetailsData[ 'payout_status' ]; @@ -23,11 +23,7 @@ const PayoutStatus: React.FC< { return ( - + { payoutStatus.popover && ( Date: Wed, 12 Nov 2025 21:57:12 +0100 Subject: [PATCH 17/32] Add feature flag and backend support for additional dispute evidence types (#11129) --- ...end-update-backend-for-new-challenge-forms | 4 ++ includes/class-wc-payments-features.php | 13 ++++ .../class-wc-payments-api-client.php | 28 ++++++-- .../test-class-wc-payments-api-client.php | 68 +++++++++++++++---- 4 files changed, 93 insertions(+), 20 deletions(-) create mode 100644 changelog/woopmnt-5447-backend-update-backend-for-new-challenge-forms diff --git a/changelog/woopmnt-5447-backend-update-backend-for-new-challenge-forms b/changelog/woopmnt-5447-backend-update-backend-for-new-challenge-forms new file mode 100644 index 00000000000..5d71a29b10c --- /dev/null +++ b/changelog/woopmnt-5447-backend-update-backend-for-new-challenge-forms @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add backend support for additional dispute evidence types (event, booking, other) behind feature flag. diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index 6b168e32f7f..f8ab8eeddc1 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -27,6 +27,7 @@ class WC_Payments_Features { const WOOPAY_FIRST_PARTY_AUTH_FLAG_NAME = '_wcpay_feature_woopay_first_party_auth'; const WOOPAY_DIRECT_CHECKOUT_FLAG_NAME = '_wcpay_feature_woopay_direct_checkout'; const DISPUTE_ISSUER_EVIDENCE = '_wcpay_feature_dispute_issuer_evidence'; + const DISPUTE_ADDITIONAL_EVIDENCE_TYPES = '_wcpay_feature_dispute_additional_evidence_types'; const WOOPAY_GLOBAL_THEME_SUPPORT_FLAG_NAME = '_wcpay_feature_woopay_global_theme_support'; const WCPAY_DYNAMIC_CHECKOUT_PLACE_ORDER_BUTTON_FLAG_NAME = '_wcpay_feature_dynamic_checkout_place_order_button'; const ACCOUNT_DETAILS_FLAG_NAME = '_wcpay_feature_account_details'; @@ -324,6 +325,17 @@ public static function is_dispute_issuer_evidence_enabled(): bool { return '1' === get_option( self::DISPUTE_ISSUER_EVIDENCE, '0' ); } + /** + * Checks whether Dispute Additional Evidence Types feature should be enabled. Disabled by default. + * + * This gates the new evidence form types (event, booking_reservation, other) for dispute challenges. + * + * @return bool + */ + public static function is_dispute_additional_evidence_types_enabled(): bool { + return '1' === get_option( self::DISPUTE_ADDITIONAL_EVIDENCE_TYPES, '0' ); + } + /** * Checks whether the next deposit notice on the deposits list screen has been dismissed. * @@ -373,6 +385,7 @@ public static function to_array() { 'documents' => self::is_documents_section_enabled(), 'woopayExpressCheckout' => self::is_woopay_express_checkout_enabled(), 'isDisputeIssuerEvidenceEnabled' => self::is_dispute_issuer_evidence_enabled(), + 'isDisputeAdditionalEvidenceTypesEnabled' => self::is_dispute_additional_evidence_types_enabled(), 'isFRTReviewFeatureActive' => self::is_frt_review_feature_active(), 'isDynamicCheckoutPlaceOrderButtonEnabled' => self::is_dynamic_checkout_place_order_button_enabled(), 'isAccountDetailsEnabled' => self::is_account_details_enabled(), diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index f4e7443a2a4..abc6da0aa45 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -2962,9 +2962,9 @@ private function determine_suggested_product_type( WC_Order $order ): string { return 'physical_product'; } - $virtual_products = 0; - $physical_products = 0; - $product_count = 0; + $virtual_products = 0; + $product_count = 0; + $product_type = null; foreach ( $items as $item ) { // Only process product items. @@ -2979,20 +2979,34 @@ private function determine_suggested_product_type( WC_Order $order ): string { ++$product_count; + // Capture first product's type (only used for single-product orders). + if ( null === $product_type ) { + $product_type = $product->get_type(); + } + if ( $product->is_virtual() ) { ++$virtual_products; - } else { - ++$physical_products; } } + // If no valid products found, default to physical. + if ( 0 === $product_count ) { + return 'physical_product'; + } + // If more than one product, suggest multiple. if ( $product_count > 1 ) { return 'multiple'; } - // If only one product and it's virtual, suggest digital. - if ( 1 === $product_count && 1 === $virtual_products ) { + // At this point, we know there's exactly one product. + // Check for specific product types (gated by feature flag). + if ( WC_Payments_Features::is_dispute_additional_evidence_types_enabled() && 'booking' === $product_type ) { + return 'booking_reservation'; + } + + // Check if it's virtual (digital product or service). + if ( 1 === $virtual_products ) { return 'digital_product_or_service'; } diff --git a/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php b/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php index a69ae5ce736..2cc818a7b28 100644 --- a/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php +++ b/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php @@ -1304,7 +1304,10 @@ private function create_woocommerce_default_pages(): array { * * @dataProvider data_determine_suggested_product_type */ - public function test_determine_suggested_product_type( $order_items, $expected_product_type ) { + public function test_determine_suggested_product_type( $order_items, $expected_product_type, $evidence_types_flag_enabled = true ) { + // Set the feature flag option. + update_option( WC_Payments_Features::DISPUTE_ADDITIONAL_EVIDENCE_TYPES, $evidence_types_flag_enabled ? '1' : '0' ); + // Create a mock order. $mock_order = $this->getMockBuilder( 'WC_Order' ) ->disableOriginalConstructor() @@ -1328,73 +1331,112 @@ public function test_determine_suggested_product_type( $order_items, $expected_p */ public function data_determine_suggested_product_type() { return [ - 'empty_order' => [ + 'empty_order' => [ 'order_items' => [], 'expected_product_type' => 'physical_product', ], - 'single_physical_product' => [ + 'single_physical_product' => [ 'order_items' => [ $this->create_mock_order_item_product( false ), // not virtual. ], 'expected_product_type' => 'physical_product', ], - 'single_virtual_product' => [ + 'single_virtual_product' => [ 'order_items' => [ $this->create_mock_order_item_product( true ), // virtual. ], 'expected_product_type' => 'digital_product_or_service', ], - 'multiple_products_mixed' => [ + 'multiple_products_mixed' => [ 'order_items' => [ $this->create_mock_order_item_product( false ), // physical. $this->create_mock_order_item_product( true ), // virtual. ], 'expected_product_type' => 'multiple', ], - 'multiple_physical_products' => [ + 'multiple_physical_products' => [ 'order_items' => [ $this->create_mock_order_item_product( false ), // physical. $this->create_mock_order_item_product( false ), // physical. ], 'expected_product_type' => 'multiple', ], - 'multiple_virtual_products' => [ + 'multiple_virtual_products' => [ 'order_items' => [ $this->create_mock_order_item_product( true ), // virtual. $this->create_mock_order_item_product( true ), // virtual. ], 'expected_product_type' => 'multiple', ], - 'order_with_non_product_items' => [ + 'order_with_non_product_items' => [ 'order_items' => [ $this->create_mock_order_item_product( true ), // virtual product. $this->create_mock_order_item_shipping(), // shipping item (not a product). ], 'expected_product_type' => 'digital_product_or_service', ], - 'order_with_invalid_product' => [ + 'order_with_invalid_product' => [ 'order_items' => [ $this->create_mock_order_item_product( true, false ), // virtual but invalid product. ], 'expected_product_type' => 'physical_product', ], + 'single_booking_product' => [ + 'order_items' => [ + $this->create_mock_order_item_product( true, true, 'booking' ), // booking product. + ], + 'expected_product_type' => 'booking_reservation', + 'evidence_types_flag_enabled' => true, + ], + 'single_booking_product_flag_off' => [ + 'order_items' => [ + $this->create_mock_order_item_product( true, true, 'booking' ), // booking product (virtual). + ], + 'expected_product_type' => 'digital_product_or_service', // Falls back to virtual detection. + 'evidence_types_flag_enabled' => false, + ], + 'single_booking_product_physical_flag_off' => [ + 'order_items' => [ + $this->create_mock_order_item_product( false, true, 'booking' ), // booking product (not virtual). + ], + 'expected_product_type' => 'physical_product', // Falls back to physical detection. + 'evidence_types_flag_enabled' => false, + ], + 'multiple_booking_products' => [ + 'order_items' => [ + $this->create_mock_order_item_product( true, true, 'booking' ), // booking. + $this->create_mock_order_item_product( true, true, 'booking' ), // booking. + ], + 'expected_product_type' => 'multiple', + 'evidence_types_flag_enabled' => true, + ], + 'booking_physical_mixed' => [ + 'order_items' => [ + $this->create_mock_order_item_product( true, true, 'booking' ), // booking. + $this->create_mock_order_item_product( false, true, 'simple' ), // physical. + ], + 'expected_product_type' => 'multiple', + 'evidence_types_flag_enabled' => true, + ], ]; } /** * Create a mock order item product for testing. * - * @param bool $is_virtual Whether the product is virtual. - * @param bool $is_valid Whether the product is valid (can be retrieved). + * @param bool $is_virtual Whether the product is virtual. + * @param bool $is_valid Whether the product is valid (can be retrieved). + * @param string $product_type The product type (e.g., 'simple', 'booking', 'variable'). * @return MockObject */ - private function create_mock_order_item_product( $is_virtual = false, $is_valid = true ) { + private function create_mock_order_item_product( $is_virtual = false, $is_valid = true, $product_type = 'simple' ) { $mock_product = $this->getMockBuilder( 'WC_Product' ) ->disableOriginalConstructor() - ->setMethods( [ 'is_virtual' ] ) + ->setMethods( [ 'is_virtual', 'get_type' ] ) ->getMock(); $mock_product->method( 'is_virtual' )->willReturn( $is_virtual ); + $mock_product->method( 'get_type' )->willReturn( $product_type ); $mock_order_item = $this->getMockBuilder( 'WC_Order_Item_Product' ) ->disableOriginalConstructor() From c558ce934cec56882c1259f1b6fe28ba812d2fea Mon Sep 17 00:00:00 2001 From: Valery Sukhomlinov <683297+dmvrtx@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:40:53 +0100 Subject: [PATCH 18/32] Visa Compliance disputes: special notice and attention to a higher fee (#11121) --- ...spute-requires-attention-to-a-specific-fee | 4 + client/disputes/new-evidence/index.tsx | 38 +- client/disputes/strings.ts | 13 + ...te-awaiting-response-details.test.tsx.snap | 573 ++++++++++++++++++ .../dispute-notice.test.tsx.snap | 369 +++++++++++ .../__snapshots__/dispute-steps.test.tsx.snap | 250 ++++++++ ...dispute-awaiting-response-details.test.tsx | 463 ++++++++++++++ .../__tests__/dispute-notice.test.tsx | 243 ++++++++ .../__tests__/dispute-steps.test.tsx | 205 +++++++ .../dispute-awaiting-response-details.tsx | 57 +- .../dispute-details/dispute-notice.tsx | 20 + .../dispute-details/dispute-steps.tsx | 110 ++++ .../dispute-details/style.scss | 13 +- .../class-wc-payments-api-client.php | 18 + .../test-class-wc-payments-api-client.php | 147 +++++ 15 files changed, 2509 insertions(+), 14 deletions(-) create mode 100644 changelog/woopmnt-5113-visa-compliance-dispute-requires-attention-to-a-specific-fee create mode 100644 client/payment-details/dispute-details/__tests__/__snapshots__/dispute-awaiting-response-details.test.tsx.snap create mode 100644 client/payment-details/dispute-details/__tests__/__snapshots__/dispute-notice.test.tsx.snap create mode 100644 client/payment-details/dispute-details/__tests__/__snapshots__/dispute-steps.test.tsx.snap create mode 100644 client/payment-details/dispute-details/__tests__/dispute-awaiting-response-details.test.tsx create mode 100644 client/payment-details/dispute-details/__tests__/dispute-notice.test.tsx create mode 100644 client/payment-details/dispute-details/__tests__/dispute-steps.test.tsx diff --git a/changelog/woopmnt-5113-visa-compliance-dispute-requires-attention-to-a-specific-fee b/changelog/woopmnt-5113-visa-compliance-dispute-requires-attention-to-a-specific-fee new file mode 100644 index 00000000000..59736df02aa --- /dev/null +++ b/changelog/woopmnt-5113-visa-compliance-dispute-requires-attention-to-a-specific-fee @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Handling of the Visa Compliance disputes with attention to a specific dispute fee. diff --git a/client/disputes/new-evidence/index.tsx b/client/disputes/new-evidence/index.tsx index b8172dcd3bf..f273af3ee85 100644 --- a/client/disputes/new-evidence/index.tsx +++ b/client/disputes/new-evidence/index.tsx @@ -576,6 +576,13 @@ export default ( { query }: { query: { id: string } } ) => { dispute.status !== 'needs_response' && dispute.status !== 'warning_needs_response'; + const isVisaComplianceDispute = + dispute && + ( dispute.reason === 'noncompliant' || + ( dispute?.enhanced_eligibility_types || [] ).includes( + 'visa_compliance' + ) ); + // --- Accordion summary content (must be before any early returns) --- const summaryItems = useMemo( () => { if ( ! dispute ) return []; @@ -933,6 +940,25 @@ export default ( { query }: { query: { id: string } } ) => {
); + const inlineNoticeVisaCompliance = () => ( + + { createInterpolateElement( + __( + 'The outcome of this dispute will be determined by Visa. WooPayments has no influence over the decision and is not liable for any chargebacks.', + 'woocommerce-payments' + ), + { + strong: , + } + ) } + + ); + // --- Step content --- const renderStepContent = () => { // if ( ! fields.length ) return null; @@ -981,7 +1007,9 @@ export default ( { query }: { query: { id: string } } ) => { fields={ recommendedDocumentsFields } readOnly={ readOnly } /> - { inlineNotice( bankName ) } + { isVisaComplianceDispute + ? inlineNoticeVisaCompliance() + : inlineNotice( bankName ) } ); } @@ -1015,7 +1043,9 @@ export default ( { query }: { query: { id: string } } ) => { fields={ recommendedShippingDocumentsFields } readOnly={ readOnly } /> - { inlineNotice( bankName ) } + { isVisaComplianceDispute + ? inlineNoticeVisaCompliance() + : inlineNotice( bankName ) } ); } @@ -1118,7 +1148,9 @@ export default ( { query }: { query: { id: string } } ) => { } } readOnly={ readOnly } /> - { inlineNotice( bankName ) } + { isVisaComplianceDispute + ? inlineNoticeVisaCompliance() + : inlineNotice( bankName ) } ); } diff --git a/client/disputes/strings.ts b/client/disputes/strings.ts index ae730aa763a..491712a734f 100644 --- a/client/disputes/strings.ts +++ b/client/disputes/strings.ts @@ -349,6 +349,19 @@ export const reasons: Record< ), ], }, + noncompliant: { + display: __( 'Non-compliant', 'woocommerce-payments' ), + claim: __( + 'Your customer’s bank claims this payment violates Visa’s rules.', + 'woocommerce-payments' + ), + summary: [ + __( + 'The customer’s bank claims this transaction doesn’t conform to Visa’s network rules.', + 'woocommerce-payments' + ), + ], + }, }; // Mapping of disputes status to display string. diff --git a/client/payment-details/dispute-details/__tests__/__snapshots__/dispute-awaiting-response-details.test.tsx.snap b/client/payment-details/dispute-details/__tests__/__snapshots__/dispute-awaiting-response-details.test.tsx.snap new file mode 100644 index 00000000000..e7a1cc2f58e --- /dev/null +++ b/client/payment-details/dispute-details/__tests__/__snapshots__/dispute-awaiting-response-details.test.tsx.snap @@ -0,0 +1,573 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DisputeAwaitingResponseDetails - Visa Compliance renders Visa compliance dispute with NonCompliantDisputeSteps 1`] = ` +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + gap: calc(4px * 2); + -webkit-box-pack: start; + -ms-flex-pack: start; + -webkit-justify-content: flex-start; + justify-content: flex-start; + width: 100%; +} + +.emotion-2>* { + min-width: 0; +} + +.emotion-4 { + display: block; + max-height: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; +} + +.emotion-16 { + font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',sans-serif; + font-size: 13px; + box-sizing: border-box; +} + +.emotion-16 *, +.emotion-16 *::before, +.emotion-16 *::after { + box-sizing: inherit; +} + +.components-panel__row .emotion-18 { + margin-bottom: inherit; +} + +.emotion-20 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: flex-start; + -webkit-box-align: flex-start; + -ms-flex-align: flex-start; + align-items: flex-start; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + gap: 0; + -webkit-box-pack: start; + -ms-flex-pack: start; + -webkit-justify-content: start; + justify-content: start; + width: 100%; +} + +.emotion-20>* { + min-width: 0; +} + +
+
+
+

+ Dispute details +

+
+
+
+ Error notice +
+
+
+
+ + + + + +
+
+ Your customer’s bank, Chase Bank, claims this payment violates Visa’s rules. + + You can challenge the dispute by 7:59 PM on September 9, 2023, or accept it. + + If you accept the dispute, you will forfeit the funds and pay the dispute fee. Challenging adds an additional $500 USD dispute fee that is only returned to you if you win. +
+
+
+
+
+
+ +
+
+
+
+`; diff --git a/client/payment-details/dispute-details/__tests__/__snapshots__/dispute-notice.test.tsx.snap b/client/payment-details/dispute-details/__tests__/__snapshots__/dispute-notice.test.tsx.snap new file mode 100644 index 00000000000..4f53f61bab0 --- /dev/null +++ b/client/payment-details/dispute-details/__tests__/__snapshots__/dispute-notice.test.tsx.snap @@ -0,0 +1,369 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DisputeNotice - Visa Compliance renders Visa compliance notice with bank name 1`] = ` +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + gap: calc(4px * 2); + -webkit-box-pack: start; + -ms-flex-pack: start; + -webkit-justify-content: flex-start; + justify-content: flex-start; + width: 100%; +} + +.emotion-2>* { + min-width: 0; +} + +.emotion-4 { + display: block; + max-height: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; +} + +
+
+
+ Error notice +
+
+
+
+ + + + + +
+
+ Your customer’s bank, Chase Bank, claims this payment violates Visa’s rules. + + You can challenge the dispute by 11:59 PM on September 9, 2023, or accept it. + + If you accept the dispute, you will forfeit the funds and pay the dispute fee. Challenging adds an additional $500 USD dispute fee that is only returned to you if you win. +
+
+
+
+
+
+`; + +exports[`DisputeNotice - Visa Compliance renders Visa compliance notice without bank name 1`] = ` +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + gap: calc(4px * 2); + -webkit-box-pack: start; + -ms-flex-pack: start; + -webkit-justify-content: flex-start; + justify-content: flex-start; + width: 100%; +} + +.emotion-2>* { + min-width: 0; +} + +.emotion-4 { + display: block; + max-height: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; +} + +
+
+
+ Error notice +
+
+
+
+ + + + + +
+
+ Your customer’s bank claims this payment violates Visa’s rules. + + You can challenge the dispute by 11:59 PM on September 9, 2023, or accept it. + + If you accept the dispute, you will forfeit the funds and pay the dispute fee. Challenging adds an additional $500 USD dispute fee that is only returned to you if you win. +
+
+
+
+
+
+`; + +exports[`DisputeNotice - Visa Compliance renders urgent error status for Visa compliance disputes 1`] = ` +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + gap: calc(4px * 2); + -webkit-box-pack: start; + -ms-flex-pack: start; + -webkit-justify-content: flex-start; + justify-content: flex-start; + width: 100%; +} + +.emotion-2>* { + min-width: 0; +} + +.emotion-4 { + display: block; + max-height: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; +} + +
+
+
+ Error notice +
+
+
+
+ + + + + +
+
+ Your customer’s bank, Chase Bank, claims this payment violates Visa’s rules. + + You can challenge the dispute by 11:59 PM on September 9, 2023, or accept it. + + If you accept the dispute, you will forfeit the funds and pay the dispute fee. Challenging adds an additional $500 USD dispute fee that is only returned to you if you win. +
+
+
+
+
+
+`; + +exports[`DisputeNotice - Visa Compliance renders with warning status when not urgent 1`] = ` +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + gap: calc(4px * 2); + -webkit-box-pack: start; + -ms-flex-pack: start; + -webkit-justify-content: flex-start; + justify-content: flex-start; + width: 100%; +} + +.emotion-2>* { + min-width: 0; +} + +.emotion-4 { + display: block; + max-height: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; +} + +
+
+
+ Warning notice +
+
+
+
+ + + + + +
+
+ Your customer’s bank, Chase Bank, claims this payment violates Visa’s rules. + + You can challenge the dispute by 11:59 PM on September 9, 2023, or accept it. + + If you accept the dispute, you will forfeit the funds and pay the dispute fee. Challenging adds an additional $500 USD dispute fee that is only returned to you if you win. +
+
+
+
+
+
+`; diff --git a/client/payment-details/dispute-details/__tests__/__snapshots__/dispute-steps.test.tsx.snap b/client/payment-details/dispute-details/__tests__/__snapshots__/dispute-steps.test.tsx.snap new file mode 100644 index 00000000000..b566a8707e9 --- /dev/null +++ b/client/payment-details/dispute-details/__tests__/__snapshots__/dispute-steps.test.tsx.snap @@ -0,0 +1,250 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NonCompliantDisputeSteps renders the Visa compliance dispute steps 1`] = ` +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + gap: calc(4px * 2); + -webkit-box-pack: start; + -ms-flex-pack: start; + -webkit-justify-content: flex-start; + justify-content: flex-start; + width: 100%; +} + +.emotion-2>* { + min-width: 0; +} + +.emotion-4 { + display: block; + max-height: 100%; + max-width: 100%; + min-height: 0; + min-width: 0; +} + +
+
+
+
+

+ +

+
+
+
+
+
+ +
+
+
+ Accepting the dispute +
+
+ Accepting the dispute means you’ll forfeit the funds, pay the standard dispute fee, and avoid the $500 USD Visa fee. +
+
+ +
+
+
+ +
+
+
+ Challenge the dispute +
+
+ Challenging the dispute will incur a $500 USD network fee, charged by our partner Stripe when you submit evidence. This fee will be refunded if you win the dispute. +
+
+ +
+
+
+
+
+ Information notice +
+
+
+
+ + + + + +
+
+ + The outcome of this dispute will be determined by Visa. + + WooPayments has no influence over the decision and is not liable for any chargebacks. +
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/client/payment-details/dispute-details/__tests__/dispute-awaiting-response-details.test.tsx b/client/payment-details/dispute-details/__tests__/dispute-awaiting-response-details.test.tsx new file mode 100644 index 00000000000..7f35ef030ea --- /dev/null +++ b/client/payment-details/dispute-details/__tests__/dispute-awaiting-response-details.test.tsx @@ -0,0 +1,463 @@ +/** @format */ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +/** + * Internal dependencies + */ +import DisputeAwaitingResponseDetails from '../dispute-awaiting-response-details'; +import { useDisputeAccept } from 'wcpay/data'; +import type { Dispute } from 'wcpay/types/disputes'; +import type { ChargeBillingDetails } from 'wcpay/types/charges'; +import WCPaySettingsContext from 'wcpay/settings/wcpay-settings-context'; + +const mockDisputeDoAccept = jest.fn(); + +jest.mock( 'wcpay/data', () => ( { + useDisputeAccept: jest.fn( () => ( { + doAccept: mockDisputeDoAccept, + isLoading: false, + } ) ), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + createRegistryControl: jest.fn(), + dispatch: jest.fn( () => ( { + setIsMatching: jest.fn(), + onLoad: jest.fn(), + } ) ), + registerStore: jest.fn(), + select: jest.fn(), + useDispatch: jest.fn( () => ( { + createErrorNotice: jest.fn(), + } ) ), + useSelect: jest.fn( () => ( { getNotices: jest.fn() } ) ), + withDispatch: jest.fn( () => jest.fn() ), + withSelect: jest.fn( () => jest.fn() ), +} ) ); + +jest.mock( 'tracks', () => ( { + recordEvent: jest.fn(), +} ) ); + +declare const global: { + wcpaySettings: { + zeroDecimalCurrencies: string[]; + connect: { + country: string; + }; + }; +}; + +const mockUseDisputeAccept = useDisputeAccept as jest.MockedFunction< + typeof useDisputeAccept +>; + +const getBaseDispute = (): Dispute => ( { + id: 'dp_visa_compliance_1', + amount: 5000, + charge: 'ch_mock', + order: null, + balance_transactions: [ + { + amount: -5000, + currency: 'usd', + fee: 1500, + reporting_category: 'dispute', + }, + ], + created: 1693453017, + currency: 'usd', + evidence: {}, + evidence_details: { + due_by: 1694303999, + has_evidence: false, + past_due: false, + submission_count: 0, + }, + issuer_evidence: null, + metadata: {}, + payment_intent: 'pi_1', + reason: 'noncompliant', + status: 'needs_response', + enhanced_eligibility_types: [ 'visa_compliance' ], +} ); + +const getBaseBillingDetails = (): ChargeBillingDetails => ( { + name: 'Test Customer', + email: 'test@example.com', + phone: null, + address: { + city: 'Test City', + country: 'US', + line1: '123 Test St', + line2: '', + postal_code: '12345', + state: 'CA', + }, +} ); + +const renderWithContext = ( component: React.ReactElement ) => { + const settingsContext = { + accountStatus: { + country: 'US', + }, + storeName: 'Test Store', + featureFlags: { + isDisputeIssuerEvidenceEnabled: false, + }, + }; + return render( + // @ts-expect-error: Only need a part of the settings for the test + + { component } + + ); +}; + +describe( 'DisputeAwaitingResponseDetails - Visa Compliance', () => { + // eslint-disable-next-line + const originalWarn = console.warn; + + beforeEach( () => { + jest.clearAllMocks(); + + global.wcpaySettings = { + zeroDecimalCurrencies: [], + connect: { + country: 'US', + }, + }; + + // Suppress the List component deprecation warning + // eslint-disable-next-line + console.warn = ( ...args ) => { + const warningMessage = args[ 0 ]; + if ( + typeof warningMessage === 'string' && + warningMessage.includes( 'List with items prop is deprecated' ) + ) { + return; // Suppress this specific warning + } + originalWarn( ...args ); // Pass through other warnings + }; + } ); + + afterEach( () => { + // Restore original console.warn after each test + // eslint-disable-next-line + console.warn = originalWarn; + } ); + + test( 'renders Visa compliance dispute with NonCompliantDisputeSteps', () => { + const dispute = getBaseDispute(); + const customer = getBaseBillingDetails(); + + const { container } = renderWithContext( + + ); + + // Check for Visa compliance specific content + expect( screen.getByText( /Steps you can take/i ) ).toBeInTheDocument(); + + // Check for the two steps: Accept and Challenge + expect( + screen.getByText( /Accepting the dispute/i, { + selector: '.dispute-steps__item-name', + } ) + ).toBeInTheDocument(); + expect( + screen.getByText( /Challenge the dispute/i, { + selector: '.dispute-steps__item-name', + } ) + ).toBeInTheDocument(); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'renders Visa compliance checkbox', () => { + const dispute = getBaseDispute(); + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + // Check for the checkbox with the $500 fee acknowledgment + const checkbox = screen.getByRole( 'checkbox', { + name: /By checking this box, you acknowledge that challenging this Visa compliance dispute incurs a \$500 USD fee/i, + } ); + + expect( checkbox ).toBeInTheDocument(); + expect( checkbox ).not.toBeChecked(); + } ); + + test( 'Challenge button is disabled when checkbox is not checked and no staged evidence', () => { + const dispute = getBaseDispute(); + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + const challengeButton = screen.getByRole( 'button', { + name: /Challenge dispute/i, + } ); + + expect( challengeButton ).toBeDisabled(); + } ); + + test( 'Challenge button is enabled when checkbox is checked', async () => { + const dispute = getBaseDispute(); + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + const checkbox = screen.getByRole( 'checkbox', { + name: /By checking this box, you acknowledge that challenging this Visa compliance dispute incurs a \$500 USD fee/i, + } ); + + // Check the checkbox + await userEvent.click( checkbox ); + + expect( checkbox ).toBeChecked(); + + const challengeButton = screen.getByRole( 'button', { + name: /Challenge dispute/i, + } ); + + expect( challengeButton ).not.toBeDisabled(); + } ); + + test( 'Challenge button is enabled when there is staged evidence (regardless of checkbox)', () => { + const dispute: Dispute = { + ...getBaseDispute(), + evidence_details: { + due_by: 1694303999, + has_evidence: true, // Has staged evidence + past_due: false, + submission_count: 0, + }, + }; + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + const challengeButton = screen.getByRole( 'button', { + name: /Continue with challenge/i, + } ); + + // Button should be enabled even without checking the checkbox + expect( challengeButton ).not.toBeDisabled(); + } ); + + test( 'renders correct help link for Visa compliance disputes', () => { + const dispute = getBaseDispute(); + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + const helpLink = screen.getByRole( 'link', { + name: /Learn more about Visa compliance disputes/i, + } ); + + expect( helpLink ).toBeInTheDocument(); + expect( helpLink ).toHaveAttribute( + 'href', + 'https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/#visa-compliance-disputes' + ); + } ); + + test( 'Accept dispute button works correctly for Visa compliance disputes', async () => { + const dispute = getBaseDispute(); + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + const acceptButton = screen.getByRole( 'button', { + name: /Accept dispute/i, + } ); + + // Accept button should be enabled + expect( acceptButton ).not.toBeDisabled(); + + // Click to open modal + await userEvent.click( acceptButton ); + + // Check modal is displayed + expect( + screen.getByRole( 'heading', { name: /Accept the dispute\?/i } ) + ).toBeInTheDocument(); + } ); + + test( 'does not render checkbox for non-Visa compliance disputes', () => { + const dispute: Dispute = { + ...getBaseDispute(), + reason: 'fraudulent', // Different reason + enhanced_eligibility_types: [], + }; + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + // Checkbox should not be present + expect( + screen.queryByRole( 'checkbox', { + name: /By checking this box, you acknowledge that challenging this Visa compliance dispute/i, + } ) + ).not.toBeInTheDocument(); + } ); + + test( 'render checkbox when reason is noncompliant but missing visa_compliance eligibility type', () => { + const dispute: Dispute = { + ...getBaseDispute(), + enhanced_eligibility_types: [], // Missing visa_compliance + }; + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + // Checkbox should not be present + expect( + screen.queryByRole( 'checkbox', { + name: /By checking this box, you acknowledge that challenging this Visa compliance dispute/i, + } ) + ).toBeInTheDocument(); + } ); + + test( 'Challenge button remains disabled during accept request', () => { + mockUseDisputeAccept.mockReturnValueOnce( { + doAccept: mockDisputeDoAccept, + isLoading: true, // Request in progress + } ); + + const dispute = getBaseDispute(); + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + const challengeButton = screen.getByRole( 'button', { + name: /Challenge dispute/i, + } ); + + expect( challengeButton ).toBeDisabled(); + } ); + + test( 'renders Visa-specific notice text in dispute notice', () => { + const dispute = getBaseDispute(); + const customer = getBaseBillingDetails(); + + renderWithContext( + + ); + + // Check for Visa-specific text in the notice + expect( + screen.getByText( + /Your customer’s bank, Chase Bank, claims this payment violates Visa’s rules/i, + { exact: false } + ) + ).toBeInTheDocument(); + + expect( + screen.getByText( /Challenging adds an additional \$500 USD/i, { + exact: false, + } ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/client/payment-details/dispute-details/__tests__/dispute-notice.test.tsx b/client/payment-details/dispute-details/__tests__/dispute-notice.test.tsx new file mode 100644 index 00000000000..2e3287393f5 --- /dev/null +++ b/client/payment-details/dispute-details/__tests__/dispute-notice.test.tsx @@ -0,0 +1,243 @@ +/** @format */ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import React from 'react'; + +/** + * Internal dependencies + */ +import DisputeNotice from '../dispute-notice'; +import type { Dispute } from 'wcpay/types/disputes'; + +// Mock date formatting utility +jest.mock( 'wcpay/utils/date-time', () => ( { + formatDateTimeFromTimestamp: jest.fn( + () => '11:59 PM on September 9, 2023' + ), +} ) ); + +const getBaseDispute = (): Dispute => ( { + id: 'dp_1', + amount: 5000, + charge: 'ch_mock', + order: null, + balance_transactions: [ + { + amount: -5000, + currency: 'usd', + fee: 1500, + reporting_category: 'dispute', + }, + ], + created: 1693453017, + currency: 'usd', + evidence: {}, + evidence_details: { + due_by: 1694303999, + has_evidence: false, + past_due: false, + submission_count: 0, + }, + issuer_evidence: null, + metadata: {}, + payment_intent: 'pi_1', + reason: 'fraudulent', + status: 'needs_response', +} ); + +describe( 'DisputeNotice - Visa Compliance', () => { + test( 'renders Visa compliance notice with bank name', () => { + const dispute: Dispute = { + ...getBaseDispute(), + reason: 'noncompliant', + }; + + const { container } = render( + + ); + + const notice = container.querySelector( '.dispute-notice' ); + expect( notice ).toBeInTheDocument(); + + // Check for Visa-specific text + expect( notice?.textContent ).toMatch( + /Your customer.s bank, Chase Bank, claims this payment violates Visa.s rules/i + ); + + // Check for the $500 fee mention + expect( notice?.textContent ).toMatch( + /Challenging adds an additional \$500 USD/i + ); + + // Check for the refund condition + expect( notice?.textContent ).toMatch( + /that is only returned to you if you win/i + ); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'renders Visa compliance notice without bank name', () => { + const dispute: Dispute = { + ...getBaseDispute(), + reason: 'noncompliant', + }; + + const { container } = render( + + ); + + const notice = container.querySelector( '.dispute-notice' ); + expect( notice ).toBeInTheDocument(); + + // Should show generic "Your customer's bank" text + expect( notice?.textContent ).toMatch( + /Your customer.s bank claims this payment violates Visa.s rules/i + ); + + // Should not mention specific bank name + expect( notice?.textContent ).not.toMatch( /Chase Bank/i ); + + // Check for the $500 fee mention + expect( notice?.textContent ).toMatch( + /Challenging adds an additional \$500 USD/i + ); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'renders urgent error status for Visa compliance disputes', () => { + const dispute: Dispute = { + ...getBaseDispute(), + reason: 'noncompliant', + }; + + const { container } = render( + + ); + + // Check that the notice has error status (urgent) + const notice = container.querySelector( '.dispute-notice' ); + expect( notice ).toBeInTheDocument(); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'renders deadline information correctly', () => { + const dispute: Dispute = { + ...getBaseDispute(), + reason: 'noncompliant', + }; + + const { container } = render( + + ); + + const notice = container.querySelector( '.dispute-notice' ); + expect( notice ).toBeInTheDocument(); + + // Check that the deadline is mentioned + expect( notice?.textContent ).toMatch( + /11:59 PM on September 9, 2023/i + ); + } ); + + test( 'does not render Visa compliance text for other dispute reasons', () => { + const dispute: Dispute = { + ...getBaseDispute(), + reason: 'fraudulent', // Different reason + }; + + const { container } = render( + + ); + + const notice = container.querySelector( '.dispute-notice' ); + expect( notice ).toBeInTheDocument(); + + // Should not show Visa-specific text + expect( notice?.textContent ).not.toMatch( /violates Visa.s rules/i ); + + expect( notice?.textContent ).not.toMatch( /additional \$500 USD/i ); + + // Should show generic dispute text instead + expect( notice?.textContent ).toMatch( + /The cardholder claims this is an unauthorized transaction/i + ); + } ); + + test( 'includes information about accepting the dispute', () => { + const dispute: Dispute = { + ...getBaseDispute(), + reason: 'noncompliant', + }; + + const { container } = render( + + ); + + const notice = container.querySelector( '.dispute-notice' ); + expect( notice ).toBeInTheDocument(); + + // Check for text about accepting + expect( notice?.textContent ).toMatch( /or accept it/i ); + + expect( notice?.textContent ).toMatch( + /you will forfeit the funds and pay the dispute fee/i + ); + } ); + + test( 'renders with warning status when not urgent', () => { + const dispute: Dispute = { + ...getBaseDispute(), + reason: 'noncompliant', + }; + + const { container } = render( + + ); + + // Notice should still render with warning status + const notice = container.querySelector( '.dispute-notice' ); + expect( notice ).toBeInTheDocument(); + + expect( container ).toMatchSnapshot(); + } ); +} ); diff --git a/client/payment-details/dispute-details/__tests__/dispute-steps.test.tsx b/client/payment-details/dispute-details/__tests__/dispute-steps.test.tsx new file mode 100644 index 00000000000..0a711cffaf2 --- /dev/null +++ b/client/payment-details/dispute-details/__tests__/dispute-steps.test.tsx @@ -0,0 +1,205 @@ +/** @format */ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +/** + * Internal dependencies + */ +import { NonCompliantDisputeSteps } from '../dispute-steps'; + +describe( 'NonCompliantDisputeSteps', () => { + test( 'renders the Visa compliance dispute steps', () => { + const { container } = render( ); + + // Check for accordion title + expect( + screen.getByText( /Steps you can take/i, { + selector: '.wcpay-accordion__title-content', + } ) + ).toBeInTheDocument(); + + // Check for subtitle + expect( + screen.getByText( + /We recommend reviewing your options before responding by the deadline/i + ) + ).toBeInTheDocument(); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'renders "Accepting the dispute" step', () => { + const { container } = render( ); + + // Check step title + expect( + screen.getByText( /Accepting the dispute/i, { + selector: '.dispute-steps__item-name', + } ) + ).toBeInTheDocument(); + + // Check step description - use container query to avoid text matching issues + const descriptions = container.querySelectorAll( + '.dispute-steps__item-description' + ); + const acceptDescription = Array.from( descriptions ).find( ( el ) => + el.textContent?.includes( 'forfeit the funds' ) + ); + expect( acceptDescription?.textContent ).toMatch( + /Accepting the dispute means you’ll forfeit the funds, pay the standard dispute fee, and avoid the \$500 USD Visa fee./i + ); + + // Check for Learn more link + const learnMoreLinks = screen.getAllByRole( 'link', { + name: /Learn more/i, + } ); + expect( learnMoreLinks[ 0 ] ).toHaveAttribute( + 'href', + 'https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/#visa-compliance-disputes' + ); + } ); + + test( 'renders "Challenge the dispute" step', () => { + render( ); + + // Check step title + expect( + screen.getByText( /Challenge the dispute/i, { + selector: '.dispute-steps__item-name', + } ) + ).toBeInTheDocument(); + + // Check step description mentions $500 fee + expect( + screen.getByText( + /Challenging the dispute will incur a \$500 USD network fee/i, + { exact: false } + ) + ).toBeInTheDocument(); + + // Check that it mentions the fee is charged by Stripe + expect( + screen.getByText( /charged by our partner Stripe/i, { + exact: false, + } ) + ).toBeInTheDocument(); + + // Check that it mentions the refund condition + expect( + screen.getByText( + /This fee will be refunded if you win the dispute/i, + { exact: false } + ) + ).toBeInTheDocument(); + } ); + + test( 'renders Learn more links for both steps', () => { + render( ); + + const learnMoreLinks = screen.getAllByRole( 'link', { + name: /Learn more/i, + } ); + + // Should have 2 Learn more links + expect( learnMoreLinks ).toHaveLength( 2 ); + + // Both should point to the same documentation + learnMoreLinks.forEach( ( link ) => { + expect( link ).toHaveAttribute( + 'href', + 'https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/#visa-compliance-disputes' + ); + expect( link ).toHaveAttribute( 'target', '_blank' ); + expect( link ).toHaveAttribute( 'rel', 'noopener noreferrer' ); + } ); + } ); + + test( 'renders Visa-specific notice at the bottom', () => { + const { container } = render( ); + + // Check for the notice container + const notice = container.querySelector( + '.dispute-steps__notice-content' + ); + expect( notice ).toBeInTheDocument(); + + // Check for the notice text - use container.textContent to avoid multiple element issues + expect( notice?.textContent ).toMatch( + /The outcome of this dispute will be determined by Visa/i + ); + + // Check for disclaimer + expect( notice?.textContent ).toMatch( + /WooPayments has no influence over the decision and is not liable for any chargebacks/i + ); + } ); + + test( 'renders correct icons for each step', () => { + const { container } = render( ); + + // Check that icons are rendered + const icons = container.querySelectorAll( '.dispute-steps__item-icon' ); + expect( icons ).toHaveLength( 2 ); + } ); + + test( 'renders two dispute steps', () => { + const { container } = render( ); + + const steps = container.querySelectorAll( '.dispute-steps__item' ); + expect( steps ).toHaveLength( 2 ); + } ); + + test( 'renders info notice with correct status', () => { + const { container } = render( ); + + const notice = container.querySelector( + '.dispute-steps__notice-content' + ); + expect( notice ).toBeInTheDocument(); + } ); + + test( 'does not render email customer action', () => { + render( ); + + // Should not have "Email customer" button (unlike regular DisputeSteps) + expect( + screen.queryByRole( 'button', { name: /Email customer/i } ) + ).not.toBeInTheDocument(); + } ); + + test( 'does not render withdrawal step', () => { + render( ); + + // Should not have "Ask for the dispute to be withdrawn" step (unlike regular DisputeSteps) + expect( + screen.queryByText( /Ask for the dispute to be withdrawn/i ) + ).not.toBeInTheDocument(); + } ); + + test( 'renders with correct structure', () => { + const { container } = render( ); + + // Check for main container + expect( + container.querySelector( '.dispute-steps' ) + ).toBeInTheDocument(); + + // Check for accordion + expect( + container.querySelector( '.wcpay-accordion' ) + ).toBeInTheDocument(); + + // Check for steps container + expect( + container.querySelector( '.dispute-steps__items' ) + ).toBeInTheDocument(); + + // Check for notice container + expect( + container.querySelector( '.dispute-steps__notice' ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx b/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx index cb5f7fe5c56..17eac5f27e6 100644 --- a/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx +++ b/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx @@ -15,12 +15,13 @@ import { Link } from '@woocommerce/components'; */ import { Button, + CheckboxControl, ExternalLink, Flex, FlexItem, + HorizontalRule, Icon, Modal, - HorizontalRule, } from '@wordpress/components'; import type { Dispute } from 'wcpay/types/disputes'; import type { ChargeBillingDetails } from 'wcpay/types/charges'; @@ -35,6 +36,7 @@ import { DisputeSteps, InquirySteps, NotDefendableInquirySteps, + NonCompliantDisputeSteps, } from './dispute-steps'; import InlineNotice from 'components/inline-notice'; import WCPaySettingsContext from 'wcpay/settings/wcpay-settings-context'; @@ -173,6 +175,10 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { isLoading: isDisputeAcceptRequestPending, } = useDisputeAccept( dispute ); const [ isModalOpen, setModalOpen ] = useState( false ); + const [ + isVisaComplianceConditionAccepted, + setVisaComplianceConditionAccepted, + ] = useState( false ); const hasStagedEvidence = dispute.evidence_details?.has_evidence; const { createErrorNotice } = useDispatch( 'core/notices' ); @@ -181,6 +187,12 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { featureFlags: { isDisputeIssuerEvidenceEnabled }, } = useContext( WCPaySettingsContext ); + const isVisaComplianceDispute = + dispute.reason === 'noncompliant' || + ( dispute?.enhanced_eligibility_types || [] ).includes( + 'visa_compliance' + ); + // Get the appropriate documentation URL based on dispute type const getLearnMoreDocsUrl = () => { if ( isInquiry( dispute.status ) ) { @@ -189,6 +201,9 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { } return 'https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/#inquiries'; } + if ( isVisaComplianceDispute ) { + return 'https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/#visa-compliance-disputes'; + } return 'https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/#responding'; }; @@ -206,6 +221,12 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { 'woocommerce-payments' ); } + if ( isVisaComplianceDispute ) { + return __( + 'Learn more about Visa compliance disputes', + 'woocommerce-payments' + ); + } return __( 'Learn more about responding to disputes', 'woocommerce-payments' @@ -245,10 +266,7 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { * - Visa Compliance disputes (require confirmation of a specific fee) */ const isDefendable = ! ( - ( paymentMethod === 'klarna' && isInquiry( dispute.status ) ) || - ( dispute?.enhanced_eligibility_types || [] ).includes( - 'visa_compliance' - ) + paymentMethod === 'klarna' && isInquiry( dispute.status ) ); const challengeButtonDefaultText = isInquiry( dispute.status ) @@ -271,9 +289,8 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { /> ); - // we cannot nest ternary operators, so let's build the steps in a variable - const steps = isInquiry( dispute.status ) ? ( - inquirySteps + const disputeSteps = isVisaComplianceDispute ? ( + ) : ( = ( { /> ); + // we cannot nest ternary operators, so let's build the steps in a variable + const steps = isInquiry( dispute.status ) ? inquirySteps : disputeSteps; + return (
@@ -330,7 +350,19 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { { getHelpLinkText() }
- + { /* Checkbox for the Visa Compliance dispute */ } + { isVisaComplianceDispute && ( +
+ +
+ ) } { /* Dispute Actions */ } {
@@ -351,7 +383,12 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { +
+
+ { /* Step 2: Challenge or accept the dispute */ } +
+
+ +
+
+
+ { __( + 'Challenge the dispute', + 'woocommerce-payments' + ) } +
+
+ { __( + 'Challenging the dispute will incur a $500 USD network fee, charged by our partner Stripe when you submit evidence. This fee will be refunded if you win the dispute.', + 'woocommerce-payments' + ) } +
+
+
+ +
+
+
+ + { /* Dispute notice */ } +
+ + { createInterpolateElement( + __( + 'The outcome of this dispute will be determined by Visa. WooPayments has no influence over the decision and is not liable for any chargebacks.', + 'woocommerce-payments' + ), + { + strong: , + } + ) } + +
+
+ + + +
+ ); +}; + export const InquirySteps: React.FC< Props > = ( { dispute, customer, diff --git a/client/payment-details/dispute-details/style.scss b/client/payment-details/dispute-details/style.scss index 04cb6339774..e294197a6c7 100644 --- a/client/payment-details/dispute-details/style.scss +++ b/client/payment-details/dispute-details/style.scss @@ -48,6 +48,10 @@ } } + &__visa-compliance-checkbox { + margin: 0; + } + &__actions { display: flex; justify-content: start; @@ -179,7 +183,14 @@ font-weight: var( --Regular, 400 ); line-height: var( --s, 20px ); /* 153.846% */ - width: 80%; + @media screen and ( max-width: $break-small ) { + margin-bottom: 8px; + width: 100%; + } + } + + &-content:last-child &-description { + width: 90%; @media screen and ( max-width: $break-small ) { margin-bottom: 8px; diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index abc6da0aa45..7656961f39a 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -677,12 +677,30 @@ public function update_dispute( $dispute_id, $evidence, $submit, $metadata ) { ); } + // Fetch the dispute to check if it's a Visa compliance (noncompliant) dispute. + $dispute_details = $this->get_dispute( $dispute_id ); + if ( is_wp_error( $dispute_details ) ) { + return $dispute_details; + } + $request = [ 'evidence' => $evidence, 'submit' => $submit, 'metadata' => $metadata, ]; + // Add Visa compliance flag for noncompliant disputes. + if ( isset( $dispute_details['reason'] ) && 'noncompliant' === $dispute_details['reason'] ) { + $request['evidence']['enhanced_evidence'] = array_merge( + $request['evidence']['enhanced_evidence'] ?? [], + [ + 'visa_compliance' => [ + 'fee_acknowledged' => 'true', + ], + ] + ); + } + $dispute = $this->request( $request, self::DISPUTES_API . '/' . $dispute_id, self::POST ); // Invalidate the dispute caches. \WC_Payments::get_database_cache()->delete_dispute_caches(); diff --git a/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php b/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php index 2cc818a7b28..85ae043030f 100644 --- a/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php +++ b/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php @@ -1326,6 +1326,153 @@ public function test_determine_suggested_product_type( $order_items, $expected_p $this->assertEquals( $expected_product_type, $result ); } + /** + * Test updating a dispute with or without Visa compliance flag based on dispute reason. + * + * @dataProvider data_update_dispute_visa_compliance + * @throws API_Exception + */ + public function test_update_dispute_visa_compliance_flag( $dispute_reason, $should_have_flag ) { + $dispute_id = 'dp_test123'; + $evidence = [ + 'product_description' => 'Product description', + 'customer_name' => 'Customer Name', + 'uncategorized_text' => 'Additional details', + 'customer_email_address' => 'customer@example.com', + 'customer_purchase_ip' => '1.2.3.4', + 'billing_address' => '123 Main St', + 'receipt' => 'file_123', + 'customer_signature' => 'file_456', + 'shipping_documentation' => 'file_789', + ]; + $submit = true; + $metadata = [ 'order_id' => '123' ]; + + // Mock the dispute cache to avoid errors. + $mock_cache = $this->createMock( \WCPay\Database_Cache::class ); + $mock_cache->method( 'delete_dispute_caches' ) + ->willReturn( null ); + + // Replace the database cache in the container. + wcpay_get_test_container()->replace( \WCPay\Database_Cache::class, $mock_cache ); + + // Mock the HTTP client to first return dispute details, then accept the update. + $this->mock_http_client + ->expects( $this->exactly( 2 ) ) + ->method( 'remote_request' ) + ->willReturnCallback( + function ( $data, $body ) use ( $dispute_id, $evidence, $metadata, $dispute_reason, $should_have_flag ) { + // First call: GET dispute to check the reason. + if ( strpos( $data['url'], '/disputes/' . $dispute_id ) !== false && 'GET' === $data['method'] ) { + return [ + 'body' => wp_json_encode( + [ + 'id' => $dispute_id, + 'charge' => 'ch_test123', + 'reason' => $dispute_reason, + 'status' => 'needs_response', + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + } + + // Second call: POST to update the dispute. + if ( strpos( $data['url'], '/disputes/' . $dispute_id ) !== false && 'POST' === $data['method'] ) { + // Validate the request parameters. + $this->validate_default_remote_request_params( + $data, + 'https://public-api.wordpress.com/wpcom/v2/sites/%s/wcpay/disputes/' . $dispute_id, + 'POST' + ); + + // Validate the body contains or doesn't contain the Visa compliance flag. + $decoded = json_decode( $body, true ); + + // Verify the standard evidence is present. + $this->assertArrayHasKey( 'evidence', $decoded ); + // Verify the Visa compliance flag presence based on dispute reason. + if ( $should_have_flag ) { + $this->assertArrayHasKey( 'enhanced_evidence', $decoded['evidence'] ); + $this->assertArrayHasKey( 'visa_compliance', $decoded['evidence']['enhanced_evidence'] ); + $this->assertArrayHasKey( 'fee_acknowledged', $decoded['evidence']['enhanced_evidence']['visa_compliance'] ); + $this->assertEquals( 'true', $decoded['evidence']['enhanced_evidence']['visa_compliance']['fee_acknowledged'] ); + $evidence_without_flag = $decoded['evidence']; + unset( $evidence_without_flag['enhanced_evidence'] ); + $this->assertEquals( $evidence, $evidence_without_flag ); + } else { + // Evidence shouldn't be modified. + $this->assertEquals( $evidence, $decoded['evidence'] ); + } + + // Verify the submit flag is set. + $this->assertArrayHasKey( 'submit', $decoded ); + $this->assertTrue( $decoded['submit'] ); + + // Verify the metadata is present. + $this->assertArrayHasKey( 'metadata', $decoded ); + $this->assertEquals( $metadata, $decoded['metadata'] ); + + return [ + 'body' => wp_json_encode( + [ + 'id' => $dispute_id, + 'charge' => 'ch_test123', + 'reason' => $dispute_reason, + 'status' => 'needs_response', + 'evidence' => $evidence, + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + } + + return [ + 'body' => wp_json_encode( [] ), + 'response' => [ + 'code' => 404, + 'message' => 'Not Found', + ], + ]; + } + ); + + // Call the method under test. + $result = $this->payments_api_client->update_dispute( $dispute_id, $evidence, $submit, $metadata ); + + // Assert the response is correct. + $this->assertEquals( $dispute_id, $result['id'] ); + + // Clean up. + wcpay_get_test_container()->reset_all_replacements(); + } + + /** + * Data provider for test_update_dispute_visa_compliance_flag. + */ + public function data_update_dispute_visa_compliance() { + return [ + 'noncompliant_dispute_should_have_flag' => [ + 'dispute_reason' => 'noncompliant', + 'should_have_flag' => true, + ], + 'fraudulent_dispute_should_not_have_flag' => [ + 'dispute_reason' => 'fraudulent', + 'should_have_flag' => false, + ], + 'product_unacceptable_dispute_should_not_have_flag' => [ + 'dispute_reason' => 'product_unacceptable', + 'should_have_flag' => false, + ], + ]; + } + /** * Data provider for test_determine_suggested_product_type. */ From 3058f59adbe52396663bc5ec54dbc1f271ad7b47 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Thu, 13 Nov 2025 15:23:34 +0100 Subject: [PATCH 19/32] Do not create refunds when the authorization isn't captured (#11130) --- changelog/fix-refund-on-cancel | 4 ++ includes/class-wc-payments-order-service.php | 12 ++++- ...wc-payments-webhook-processing-service.php | 8 +++ .../test-class-wc-payments-order-service.php | 31 ++++++++--- ...wc-payments-webhook-processing-service.php | 51 +++++++++++++++++++ 5 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 changelog/fix-refund-on-cancel diff --git a/changelog/fix-refund-on-cancel b/changelog/fix-refund-on-cancel new file mode 100644 index 00000000000..8c3c7877e85 --- /dev/null +++ b/changelog/fix-refund-on-cancel @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Refunds and fees should not be tracked for canceled authorizations diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php index 83f99220351..e1be0cc5b9e 100644 --- a/includes/class-wc-payments-order-service.php +++ b/includes/class-wc-payments-order-service.php @@ -1142,6 +1142,10 @@ private function mark_payment_capture_cancelled( $order, $intent_data ) { $this->set_fraud_meta_box_type_for_order( $order, Fraud_Meta_Box_Type::REVIEW_BLOCKED ); } + // Remove transaction fee since the authorization was canceled and no payment was processed. + $order->delete_meta_data( self::WCPAY_TRANSACTION_FEE_META_KEY ); + $order->delete_meta_data( '_wcpay_net' ); + $this->update_order_status( $order, Order_Status::CANCELLED ); $this->complete_order_processing( $order, $intent_data['intent_status'] ); } @@ -1318,7 +1322,9 @@ private function mark_order_held_for_review_for_fraud( $order, $intent_data ) { */ public function attach_transaction_fee_to_order( $order, $charge ) { try { - if ( $charge && null !== $charge->get_application_fee_amount() ) { + // Only set transaction fee if the charge was actually captured. + // Canceled authorizations should not have fees since no payment was processed. + if ( $charge && null !== $charge->get_application_fee_amount() && $charge->is_captured() ) { $order->update_meta_data( self::WCPAY_TRANSACTION_FEE_META_KEY, WC_Payments_Utils::interpret_stripe_amount( $charge->get_application_fee_amount(), $charge->get_currency() ) @@ -1361,6 +1367,10 @@ public function cancel_authorizations_on_order_status_change( $order_id ) { $intent = $request->send(); $this->post_unique_capture_cancelled_note( $order, $intent_id, $charge->get_id() ); + + // Remove transaction fee since the authorization was canceled and no payment was processed. + $order->delete_meta_data( self::WCPAY_TRANSACTION_FEE_META_KEY ); + $order->delete_meta_data( '_wcpay_net' ); } $this->set_intention_status_for_order( $order, $intent->get_status() ); diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php index 37218744de6..5d38d15ad9d 100644 --- a/includes/class-wc-payments-webhook-processing-service.php +++ b/includes/class-wc-payments-webhook-processing-service.php @@ -857,6 +857,14 @@ private function process_webhook_refund_triggered_externally( array $event_body return; } + // Check if the charge was actually captured before processing the refund. + // Stripe sends charge.refunded webhooks for cancelled authorizations even though no payment was captured. + // We should not create WooCommerce refund objects for these cases as they cause negative values in analytics. + $captured = $event_object['captured'] ?? false; + if ( ! $captured ) { + return; + } + // Fetch the details of the refund so that we can find the associated order and write a note. $charge_id = $this->read_webhook_property( $event_object, 'id' ); $refund = $this->read_webhook_property( $event_object, 'refunds' )['data'][0]; // Most recent refund. diff --git a/tests/unit/test-class-wc-payments-order-service.php b/tests/unit/test-class-wc-payments-order-service.php index b31fa1b9b44..6479dd930d9 100644 --- a/tests/unit/test-class-wc-payments-order-service.php +++ b/tests/unit/test-class-wc-payments-order-service.php @@ -1363,20 +1363,26 @@ public function test_get_order_throws_exception() { } public function test_attach_transaction_fee_to_order() { - $order = WC_Helper_Order::create_order(); - $this->order_service->attach_transaction_fee_to_order( $order, new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, 113, [], [], 'usd' ) ); + $order = WC_Helper_Order::create_order(); + $charge = new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, 113, [], [], 'usd' ); + $charge->set_captured( true ); + $this->order_service->attach_transaction_fee_to_order( $order, $charge ); $this->assertEquals( 1.13, $order->get_meta( '_wcpay_transaction_fee', true ) ); } public function test_attach_transaction_fee_to_order_zero_fee() { - $order = WC_Helper_Order::create_order(); - $this->order_service->attach_transaction_fee_to_order( $order, new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, 0, [], [], 'eur' ) ); + $order = WC_Helper_Order::create_order(); + $charge = new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, 0, [], [], 'eur' ); + $charge->set_captured( true ); + $this->order_service->attach_transaction_fee_to_order( $order, $charge ); $this->assertEquals( 0, $order->get_meta( '_wcpay_transaction_fee', true ) ); } public function test_attach_transaction_fee_to_order_zero_decimal_fee() { - $order = WC_Helper_Order::create_order(); - $this->order_service->attach_transaction_fee_to_order( $order, new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, 30000, [], [], 'jpy' ) ); + $order = WC_Helper_Order::create_order(); + $charge = new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, 30000, [], [], 'jpy' ); + $charge->set_captured( true ); + $this->order_service->attach_transaction_fee_to_order( $order, $charge ); $this->assertEquals( 30000, $order->get_meta( '_wcpay_transaction_fee', true ) ); } @@ -1388,6 +1394,19 @@ public function test_attach_transaction_fee_to_order_null_fee() { $this->order_service->attach_transaction_fee_to_order( $mock_order, new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, null, [], [], 'eur' ) ); } + public function test_attach_transaction_fee_to_order_uncaptured_charge() { + $mock_order = $this->createMock( 'WC_Order' ); + $mock_order + ->expects( $this->never() ) + ->method( 'update_meta_data' ); + + $charge = new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, 113, [], [], 'usd' ); + $charge->set_captured( false ); + + // Fee should not be set for uncaptured charges. + $this->order_service->attach_transaction_fee_to_order( $mock_order, $charge ); + } + public function test_add_note_and_metadata_for_created_refund_successful_fully_refunded(): void { $order = WC_Helper_Order::create_order(); $order->save(); diff --git a/tests/unit/test-class-wc-payments-webhook-processing-service.php b/tests/unit/test-class-wc-payments-webhook-processing-service.php index b2df16065c9..104804df968 100644 --- a/tests/unit/test-class-wc-payments-webhook-processing-service.php +++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php @@ -817,6 +817,7 @@ public function test_payment_intent_successful_adds_relevant_metadata() { 'type' => 'card', ], 'application_fee_amount' => 100, + 'captured' => true, ], ], ], @@ -1651,6 +1652,7 @@ public function test_process_full_refund_succeeded(): void { 'status' => 'succeeded', 'amount' => 1800, 'currency' => 'usd', + 'captured' => true, ]; $this->mock_order @@ -1708,6 +1710,7 @@ public function test_process_partial_refund_succeeded(): void { 'status' => 'succeeded', 'amount' => 1800, 'currency' => 'usd', + 'captured' => true, ]; $this->mock_order @@ -1755,6 +1758,7 @@ public function test_process_refund_ignores_processed_event(): void { 'status' => 'succeeded', 'amount' => 1800, 'currency' => 'usd', + 'captured' => true, ]; $this->mock_order @@ -1857,6 +1861,7 @@ public function test_process_refund_throws_when_order_not_found(): void { 'status' => 'succeeded', 'amount' => 1800, 'currency' => 'usd', + 'captured' => true, ]; $this->mock_db_wrapper @@ -1887,6 +1892,7 @@ public function test_process_refund_throws_with_negative_amount(): void { 'status' => 'succeeded', 'amount' => -1800, 'currency' => 'usd', + 'captured' => true, ]; $this->mock_order @@ -1921,6 +1927,7 @@ public function test_process_refund_throws_with_invalid_refunded_amount(): void 'status' => 'succeeded', 'amount' => 1800, 'currency' => 'usd', + 'captured' => true, ]; $this->mock_order @@ -1938,6 +1945,50 @@ public function test_process_refund_throws_with_invalid_refunded_amount(): void $this->webhook_processing_service->process( $this->event_body ); } + public function test_process_refund_ignores_uncaptured_charge(): void { + $this->event_body['type'] = 'charge.refunded'; + $this->event_body['livemode'] = true; + $this->event_body['data']['object'] = [ + 'id' => 'test_charge_id', + 'refunds' => [ + 'data' => [ + [ + 'id' => 'test_refund_id', + 'status' => Refund_Status::SUCCEEDED, + 'amount' => 1800, + 'currency' => 'usd', + 'reason' => 'requested_by_customer', + 'balance_transaction' => 'txn_123', + ], + ], + ], + 'status' => 'succeeded', + 'amount' => 1800, + 'currency' => 'usd', + 'captured' => false, // Not captured - this is a canceled authorization. + ]; + + // The webhook should return early before fetching the order since captured = false. + $this->mock_db_wrapper + ->expects( $this->never() ) + ->method( 'order_from_charge_id' ); + + // Refund processing should be skipped for uncaptured charges. + $this->order_service + ->expects( $this->never() ) + ->method( 'get_wcpay_refund_id_for_order' ); + + $this->order_service + ->expects( $this->never() ) + ->method( 'create_refund_for_order' ); + + $this->order_service + ->expects( $this->never() ) + ->method( 'add_note_and_metadata_for_created_refund' ); + + $this->webhook_processing_service->process( $this->event_body ); + } + /** * @dataProvider provider_mode_mismatch_detection */ From 1a6502e214a5e829632605878f4a5244a92111fe Mon Sep 17 00:00:00 2001 From: Valery Sukhomlinov <683297+dmvrtx@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:16:10 +0100 Subject: [PATCH 20/32] Fix PHP8 deprecation warning about callables (#11135) --- .../woopmnt-5500-php-deprecations-in-the-request-classes | 4 ++++ includes/admin/tasks/class-wc-payments-task-disputes.php | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog/woopmnt-5500-php-deprecations-in-the-request-classes diff --git a/changelog/woopmnt-5500-php-deprecations-in-the-request-classes b/changelog/woopmnt-5500-php-deprecations-in-the-request-classes new file mode 100644 index 00000000000..e7cc35881fc --- /dev/null +++ b/changelog/woopmnt-5500-php-deprecations-in-the-request-classes @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix deprecation warning about usage of `parent` in callables. diff --git a/includes/admin/tasks/class-wc-payments-task-disputes.php b/includes/admin/tasks/class-wc-payments-task-disputes.php index 7d5ac82faf4..cb314f0fcd2 100644 --- a/includes/admin/tasks/class-wc-payments-task-disputes.php +++ b/includes/admin/tasks/class-wc-payments-task-disputes.php @@ -157,7 +157,7 @@ function ( $total, $dispute ) { */ public function get_parent_id() { // WC 6.4.0 compatibility. - if ( is_callable( 'parent::get_parent_id' ) ) { + if ( is_callable( [ parent::class, 'get_parent_id' ] ) ) { return parent::get_parent_id(); } From 679f80401f4d9d93bd9707820bf5c3f41c3e9427 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 17 Nov 2025 08:45:10 +0100 Subject: [PATCH 21/32] feat: Amazon Pay settings UI (#11132) --- assets/images/payment-methods/amazon-pay.svg | 1 + changelog/feat-amazon-pay-settings-ui | 5 + client/globals.d.ts | 1 + client/payment-methods-icons.tsx | 5 + .../amazon-pay-settings.js | 187 ++++++++++++++++++ .../express-checkout-settings/index.js | 38 ++++ .../express-checkout/__tests__/index.test.js | 40 ++++ .../express-checkout/amazon-pay-item.tsx | 93 +++++++++ client/settings/express-checkout/index.js | 8 + includes/class-wc-payments-features.php | 1 + 10 files changed, 379 insertions(+) create mode 100644 assets/images/payment-methods/amazon-pay.svg create mode 100644 changelog/feat-amazon-pay-settings-ui create mode 100644 client/settings/express-checkout-settings/amazon-pay-settings.js create mode 100644 client/settings/express-checkout/amazon-pay-item.tsx diff --git a/assets/images/payment-methods/amazon-pay.svg b/assets/images/payment-methods/amazon-pay.svg new file mode 100644 index 00000000000..0f1d959c0c6 --- /dev/null +++ b/assets/images/payment-methods/amazon-pay.svg @@ -0,0 +1 @@ + diff --git a/changelog/feat-amazon-pay-settings-ui b/changelog/feat-amazon-pay-settings-ui new file mode 100644 index 00000000000..ecff0c0d1fb --- /dev/null +++ b/changelog/feat-amazon-pay-settings-ui @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: feat: add Amazon Pay settings UI behind feature flag + + diff --git a/client/globals.d.ts b/client/globals.d.ts index 0b87712c085..436866a55c7 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -35,6 +35,7 @@ declare global { isFRTReviewFeatureActive: boolean; isDynamicCheckoutPlaceOrderButtonEnabled: boolean; isAccountDetailsEnabled: boolean; + amazonPay: boolean; }; accountFees: Record< string, any >; fraudServices: unknown[]; diff --git a/client/payment-methods-icons.tsx b/client/payment-methods-icons.tsx index e5fdd9235bc..8d505dbf9e2 100644 --- a/client/payment-methods-icons.tsx +++ b/client/payment-methods-icons.tsx @@ -21,6 +21,7 @@ import DiscoverAsset from 'assets/images/cards/discover.svg?asset'; import CBAsset from 'assets/images/cards/cb.svg?asset'; import UnionPayAsset from 'assets/images/cards/unionpay.svg?asset'; import LinkAsset from 'assets/images/payment-methods/link.svg?asset'; +import AmazonPayAsset from 'assets/images/payment-methods/amazon-pay.svg?asset'; import './style.scss'; const iconComponent = ( @@ -40,6 +41,10 @@ const iconComponent = ( /> ); +export const AmazonPayIcon = iconComponent( + AmazonPayAsset, + __( 'Amazon Pay', 'woocommerce-payments' ) +); export const AmericanExpressIcon = iconComponent( AmexAsset, __( 'American Express', 'woocommerce-payments' ) diff --git a/client/settings/express-checkout-settings/amazon-pay-settings.js b/client/settings/express-checkout-settings/amazon-pay-settings.js new file mode 100644 index 00000000000..43ea2bb0bf3 --- /dev/null +++ b/client/settings/express-checkout-settings/amazon-pay-settings.js @@ -0,0 +1,187 @@ +/** + * External dependencies + */ +import React, { useState } from 'react'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import CardBody from '../card-body'; +import { + Card, + CheckboxControl, + BaseControl, + RadioControl, +} from '@wordpress/components'; +import { usePaymentRequestButtonSize } from 'wcpay/data'; +import interpolateComponents from '@automattic/interpolate-components'; + +const makeButtonSizeText = ( string ) => + interpolateComponents( { + mixedString: string, + components: { + helpText: ( + + ), + }, + } ); + +const buttonSizeOptions = [ + { + label: makeButtonSizeText( + __( + 'Small {{helpText}}(40 px){{/helpText}}', + 'woocommerce-payments' + ) + ), + value: 'small', + }, + { + label: makeButtonSizeText( + __( + 'Medium {{helpText}}(48 px){{/helpText}}', + 'woocommerce-payments' + ) + ), + value: 'medium', + }, + { + label: makeButtonSizeText( + __( + 'Large {{helpText}}(55 px){{/helpText}}', + 'woocommerce-payments' + ) + ), + value: 'large', + }, +]; + +const GeneralSettings = () => { + const [ size, setSize ] = usePaymentRequestButtonSize(); + + return ( + + + + ); +}; + +const AmazonPaySettings = ( { section } ) => { + const [ isAmazonPayEnabled, setIsAmazonPayEnabled ] = useState( false ); + const [ amazonPayLocations, setAmazonPayLocations ] = useState( [ + 'product', + 'cart', + 'checkout', + ] ); + + const makeLocationChangeHandler = ( location ) => ( isChecked ) => { + if ( isChecked ) { + setAmazonPayLocations( [ ...amazonPayLocations, location ] ); + } else { + setAmazonPayLocations( + amazonPayLocations.filter( ( name ) => name !== location ) + ); + } + }; + + return ( + + { section === 'enable' && ( + +
+ + { /* eslint-disable-next-line @wordpress/no-base-control-with-label-without-id */ } + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ ) } + + { section === 'general' && } +
+ ); +}; + +export default AmazonPaySettings; diff --git a/client/settings/express-checkout-settings/index.js b/client/settings/express-checkout-settings/index.js index 53f9e46e673..ff7889e5751 100644 --- a/client/settings/express-checkout-settings/index.js +++ b/client/settings/express-checkout-settings/index.js @@ -12,11 +12,13 @@ import './index.scss'; import SettingsSection from '../settings-section'; import PaymentRequestSettings from './payment-request-settings'; import WooPaySettings from './woopay-settings'; +import AmazonPaySettings from './amazon-pay-settings'; import SettingsLayout from '../settings-layout'; import LoadableSettingsSection from '../loadable-settings-section'; import SaveSettingsSection from '../save-settings-section'; import ErrorBoundary from '../../components/error-boundary'; import { + AmazonPayIcon, ApplePayIcon, GooglePayIcon, WooIcon, @@ -110,6 +112,42 @@ const methods = { ], controls: ( props ) => , }, + amazon_pay: { + title: 'Amazon Pay', + sections: [ + { + section: 'enable', + description: () => ( + <> +
+ +
+

+ { __( + 'Allow your customers to collect payments via Amazon Pay.', + 'woocommerce-payments' + ) } +

+ + ), + }, + { + section: 'general', + description: () => ( + <> +

{ __( 'Settings', 'woocommerce-payments' ) }

+

+ { __( + 'Configure the display of Amazon Pay buttons on your store.', + 'woocommerce-payments' + ) } +

+ + ), + }, + ], + controls: ( props ) => , + }, }; const ExpressCheckoutSettings = ( { methodId } ) => { diff --git a/client/settings/express-checkout/__tests__/index.test.js b/client/settings/express-checkout/__tests__/index.test.js index b207605e7c1..960f8a3968e 100644 --- a/client/settings/express-checkout/__tests__/index.test.js +++ b/client/settings/express-checkout/__tests__/index.test.js @@ -225,4 +225,44 @@ describe( 'ExpressCheckout', () => { ) ).toBeInTheDocument(); } ); + + it( 'should render Amazon Pay when the feature flag is enabled', () => { + const context = { + accountStatus: {}, + featureFlags: { woopay: true, amazonPay: true }, + }; + useGetAvailablePaymentMethodIds.mockReturnValue( [ 'link', 'card' ] ); + useEnabledPaymentMethodIds.mockReturnValue( [ [ 'card' ] ] ); + + render( + + + + ); + + expect( screen.getByLabelText( 'Amazon Pay' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'WooPay' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'Link by Stripe' ) ).toBeInTheDocument(); + } ); + + it( 'should not render Amazon Pay by default', () => { + const context = { + accountStatus: {}, + featureFlags: { woopay: true }, + }; + useGetAvailablePaymentMethodIds.mockReturnValue( [ 'link', 'card' ] ); + useEnabledPaymentMethodIds.mockReturnValue( [ [ 'card' ] ] ); + + render( + + + + ); + + expect( + screen.queryByLabelText( 'Amazon Pay' ) + ).not.toBeInTheDocument(); + expect( screen.getByLabelText( 'WooPay' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'Link by Stripe' ) ).toBeInTheDocument(); + } ); } ); diff --git a/client/settings/express-checkout/amazon-pay-item.tsx b/client/settings/express-checkout/amazon-pay-item.tsx new file mode 100644 index 00000000000..044ee39b9ee --- /dev/null +++ b/client/settings/express-checkout/amazon-pay-item.tsx @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import React, { useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { getPaymentMethodSettingsUrl } from '../../utils'; + +/** + * Internal dependencies + */ +import { Button, CheckboxControl } from '@wordpress/components'; +import { AmazonPayIcon } from 'wcpay/payment-methods-icons'; +import interpolateComponents from '@automattic/interpolate-components'; + +const AmazonPayExpressCheckoutItem = (): React.ReactElement => { + const [ isAmazonPayEnabled, setIsAmazonPayEnabled ] = useState( false ); + + return ( +
  • +
    +
    + +
    +
    +
    +
    +
    + +
    +
    + { __( 'Amazon Pay', 'woocommerce-payments' ) } +
    +
    +
    + { __( + 'Amazon Pay', + 'woocommerce-payments' + ) } +
    +
    + { interpolateComponents( { + mixedString: __( + /* eslint-disable-next-line max-len */ + 'Enhance sales by providing a quick, straightforward, and secure checkout experience. ' + + 'By activating this feature, you accept ' + + '{{stripeLink}}Stripe{{/stripeLink}} and ' + + "{{amazonLink}}Amazon{{/amazonLink}}'s terms of use. ", + 'woocommerce-payments' + ), + /* eslint-disable jsx-a11y/anchor-has-content */ + components: { + stripeLink: ( + + ), + amazonLink: ( + + ), + }, + /* eslint-enable jsx-a11y/anchor-has-content */ + } ) } +
    +
    +
    +
    +
    + +
    +
    +
    +
  • + ); +}; + +export default AmazonPayExpressCheckoutItem; diff --git a/client/settings/express-checkout/index.js b/client/settings/express-checkout/index.js index 797ae2a0241..0689932ad7b 100644 --- a/client/settings/express-checkout/index.js +++ b/client/settings/express-checkout/index.js @@ -4,6 +4,7 @@ * External dependencies */ import { Card } from '@wordpress/components'; +import { useContext } from '@wordpress/element'; /** * Internal dependencies @@ -13,8 +14,14 @@ import './style.scss'; import WooPayExpressCheckoutItem from './woopay-item'; import AppleGooglePayExpressCheckoutItem from './apple-google-pay-item'; import LinkExpressCheckoutItem from './link-item'; +import AmazonPayExpressCheckoutItem from './amazon-pay-item'; +import WCPaySettingsContext from '../wcpay-settings-context'; const ExpressCheckout = () => { + const { + featureFlags: { amazonPay: isAmazonPayEligible }, + } = useContext( WCPaySettingsContext ); + return ( @@ -22,6 +29,7 @@ const ExpressCheckout = () => { + { isAmazonPayEligible && } diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index f8ab8eeddc1..ef950f16e3c 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -389,6 +389,7 @@ public static function to_array() { 'isFRTReviewFeatureActive' => self::is_frt_review_feature_active(), 'isDynamicCheckoutPlaceOrderButtonEnabled' => self::is_dynamic_checkout_place_order_button_enabled(), 'isAccountDetailsEnabled' => self::is_account_details_enabled(), + 'amazonPay' => self::is_amazon_pay_enabled(), ] ); } From 0820a2f071caf7d5711c7768375fb81a68c2dc32 Mon Sep 17 00:00:00 2001 From: Valery Sukhomlinov <683297+dmvrtx@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:13:31 +0100 Subject: [PATCH 22/32] Enable non-reusable payment methods for manual subscriptions (#11111) Co-authored-by: Radoslav Georgiev Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ...nt-method-not-available-for-manual-renewal | 4 + includes/class-wc-payment-gateway-wcpay.php | 6 +- ...wc-payment-gateway-wcpay-subscriptions.php | 108 ++++++- .../class-upe-payment-method.php | 35 ++- .../class-wc-payments-invoice-service.php | 15 +- ...class-wc-payments-subscription-service.php | 9 +- .../class-wc-payments-subscriptions.php | 2 +- .../helpers/class-wc-helper-subscription.php | 15 + .../helpers/class-wc-helper-subscriptions.php | 18 ++ .../multi-currency/test-class-analytics.php | 5 - .../test-class-upe-payment-method.php | 59 ++++ .../test-class-upe-split-payment-gateway.php | 7 + ...pay-subscriptions-non-reusable-methods.php | 184 +++++++++++ ...test-class-wc-payments-invoice-service.php | 27 +- ...class-wc-payments-subscription-service.php | 1 + ...ay-wcpay-subscriptions-process-payment.php | 21 +- .../test-class-wc-payment-gateway-wcpay.php | 7 + ...n-reusable-payment-methods-integration.php | 162 ++++++++++ ...ts-subscription-service-creation-logic.php | 291 ++++++++++++++++++ ...lass-wc-payments-woopay-button-handler.php | 6 + 20 files changed, 933 insertions(+), 49 deletions(-) create mode 100644 changelog/woopmnt-5245-ideal-payment-method-not-available-for-manual-renewal create mode 100644 tests/unit/subscriptions/test-class-wc-payment-gateway-wcpay-subscriptions-non-reusable-methods.php create mode 100644 tests/unit/test-class-wc-payments-non-reusable-payment-methods-integration.php create mode 100644 tests/unit/test-class-wc-payments-subscription-service-creation-logic.php diff --git a/changelog/woopmnt-5245-ideal-payment-method-not-available-for-manual-renewal b/changelog/woopmnt-5245-ideal-payment-method-not-available-for-manual-renewal new file mode 100644 index 00000000000..3314f598c28 --- /dev/null +++ b/changelog/woopmnt-5245-ideal-payment-method-not-available-for-manual-renewal @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Allow non-reusable payment methods to be used for the manually renewed subscriptions. diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 78780e977c0..23d9c3ef388 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1600,7 +1600,11 @@ public function process_payment_for_order( $cart, $payment_information, $schedul // Make sure that setting fingerprint is performed after setting metadata because metadata will override any values you set before for metadata param. $request->set_fingerprint( $payment_information->get_fingerprint() ); if ( $save_payment_method_to_store ) { - $request->setup_future_usage(); + // Only set setup_future_usage for reusable payment methods. + // Non-reusable payment methods (e.g., iDEAL) will be used for manual renewals and don't support setup_future_usage. + if ( $this->payment_method->is_reusable() ) { + $request->setup_future_usage(); + } } if ( $scheduled_subscription_payment ) { $mandate = $this->get_mandate_param_for_renewal_order( $order ); diff --git a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php index ca9449c071c..08ae6a11de3 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -183,6 +183,7 @@ public function maybe_init_subscriptions_hooks() { add_filter( 'woocommerce_email_classes', [ $this, 'add_emails' ], 20 ); add_filter( 'woocommerce_available_payment_gateways', [ $this, 'prepare_order_pay_page' ] ); + add_action( 'woocommerce_checkout_subscription_created', [ $this, 'maybe_force_subscription_to_manual' ], 10, 1 ); add_action( 'woocommerce_scheduled_subscription_payment_' . $this->id, [ $this, 'scheduled_subscription_payment' ], 10, 2 ); add_action( 'woocommerce_subscription_failing_payment_method_updated_' . $this->id, [ $this, 'update_failing_payment_method' ], 10, 2 ); add_filter( 'wc_payments_display_save_payment_method_checkbox', [ $this, 'display_save_payment_method_checkbox' ], 10 ); @@ -190,6 +191,12 @@ public function maybe_init_subscriptions_hooks() { // Display the credit card used for a subscription in the "My Subscriptions" table. add_filter( 'woocommerce_my_subscriptions_payment_method', [ $this, 'maybe_render_subscription_payment_method' ], 10, 2 ); + // Hide "Change payment" button for manual subscriptions with non-reusable payment methods. + add_filter( 'wcs_view_subscription_actions', [ $this, 'maybe_hide_change_payment_for_manual_subscriptions' ], 10, 2 ); + + // Hide "Auto-renew" toggle for manual subscriptions with non-reusable payment methods. + add_filter( 'user_has_cap', [ $this, 'maybe_hide_auto_renew_toggle_for_manual_subscriptions' ], 100, 3 ); + // Used to filter out unwanted metadata on new renewal orders. if ( ! class_exists( 'WC_Subscriptions_Data_Copier' ) ) { add_filter( 'wcs_renewal_order_meta_query', [ $this, 'update_renewal_meta_data' ], 10, 3 ); @@ -616,15 +623,69 @@ public function maybe_render_subscription_payment_method( $payment_method_to_dis if ( is_null( $token ) ) { Logger::info( 'There is no saved payment token for subscription #' . $subscription->get_id() ); - return $payment_method_to_display; + } else { + $payment_method_to_display = $token->get_display_name(); } - return $token->get_display_name(); + + return $payment_method_to_display; } catch ( \Exception $e ) { Logger::error( 'Failed to get payment method for subscription #' . $subscription->get_id() . ' ' . $e ); return $payment_method_to_display; } } + /** + * Hide "Change payment" button for manual subscriptions with non-reusable payment methods. + * These subscriptions use the "Renew now" flow where customers choose a payment method at renewal time. + * + * @param array $actions The subscription actions. + * @param WC_Subscription $subscription The subscription object. + * @return array The modified actions array. + */ + public function maybe_hide_change_payment_for_manual_subscriptions( $actions, $subscription ) { + // Only process manual subscriptions with non-reusable payment methods. + $original_payment_method_id = $subscription->get_meta( '_wcpay_original_payment_method_id', true ); + + if ( $subscription->is_manual() && ! empty( $original_payment_method_id ) ) { + // Remove the "Change payment" action since they'll choose payment method during renewal. + unset( $actions['change_payment_method'] ); + } + + return $actions; + } + + /** + * Hide "Auto renew" toggle for manual subscriptions with non-reusable payment methods. + * + * @param array $allcaps List of user capabilities. + * @param array $caps Which capabilities are being checked. + * @param array $args Arguments, in our case user ID and subscription ID. + * @return array + */ + public function maybe_hide_auto_renew_toggle_for_manual_subscriptions( $allcaps, $caps, $args ) { + if ( ! isset( $caps[0] ) || 'toggle_shop_subscription_auto_renewal' !== $caps[0] ) { + // Do not interfere with other capabilities. + return $allcaps; + } + + if ( ! isset( $args[2] ) ) { + return $allcaps; + } + $subscription = wcs_get_subscription( $args[2] ); + if ( ! $subscription ) { + return $allcaps; + } + // Only process manual subscriptions with non-reusable payment methods. + $original_payment_method_id = $subscription->get_meta( '_wcpay_original_payment_method_id', true ); + + if ( $subscription->is_manual() && ! empty( $original_payment_method_id ) ) { + // Remove the capability as this subscription won't work with automatic renewals. + unset( $allcaps['toggle_shop_subscription_auto_renewal'] ); + } + + return $allcaps; + } + /** * Outputs a select element to be used for the Subscriptions payment meta token selection. * @@ -1051,4 +1112,47 @@ public function get_mandate_param_for_renewal_order( WC_Order $renewal_order ): return $mandate; } + + /** + * Force subscription to manual renewal if non-reusable payment method was used. + * This should be hooked into 'woocommerce_checkout_subscription_created' action. + * + * @param WC_Subscription $subscription The subscription being created. + */ + public function maybe_force_subscription_to_manual( $subscription ) { + // Only process WCPay subscriptions (including split UPE gateways like woocommerce_payments_ideal). + $payment_method_id = $subscription->get_payment_method(); + if ( 0 !== strpos( $payment_method_id, WC_Payment_Gateway_WCPay::GATEWAY_ID ) ) { + return; + } + + // Check if this is a split UPE gateway (e.g., woocommerce_payments_ideal). + // Split UPE gateways are used for non-reusable payment methods like iDEAL, Bancontact, etc. + // The base gateway (woocommerce_payments) is used for cards, which are reusable. + if ( WC_Payment_Gateway_WCPay::GATEWAY_ID === $payment_method_id ) { + // This is the base gateway (card), which is reusable - no action needed. + return; + } + + // This is a split UPE gateway (non-reusable payment method). + // Extract the payment method type from the gateway ID (e.g., "ideal" from "woocommerce_payments_ideal"). + $payment_method_type = str_replace( WC_Payment_Gateway_WCPay::GATEWAY_ID . '_', '', $payment_method_id ); + + // Store the original payment method ID for reference. + $subscription->update_meta_data( '_wcpay_original_payment_method_id', $payment_method_id ); + + // Set to manual renewal (keep the original split payment method ID). + $subscription->set_requires_manual_renewal( true ); + + $subscription->save(); + + // Add order note confirming the subscription was set to manual. + $subscription->add_order_note( + sprintf( + /* translators: %s: payment method type */ + __( 'Subscription set to manual renewal because %s is a non-reusable payment method.', 'woocommerce-payments' ), + $payment_method_type + ) + ); + } } diff --git a/includes/payment-methods/class-upe-payment-method.php b/includes/payment-methods/class-upe-payment-method.php index c2c663085a6..10ecebf33d6 100644 --- a/includes/payment-methods/class-upe-payment-method.php +++ b/includes/payment-methods/class-upe-payment-method.php @@ -183,8 +183,11 @@ public function has_domestic_transactions_restrictions() { } /** - * Returns boolean dependent on whether payment method - * can be used at checkout + * Returns boolean dependent on whether payment method can be used at checkout. + * + * Payment method can be used at checkout if: + * - If there are payment amount limits, order total is within limits. + * - If it is a subscription order, payment method is either reusable, or subscription is manual. * * @param string $account_country Country of merchants account. * @param bool $skip_limits_per_currency_check Whether to skip limits per currency check. @@ -192,10 +195,22 @@ public function has_domestic_transactions_restrictions() { * @return bool */ public function is_enabled_at_checkout( string $account_country, bool $skip_limits_per_currency_check = false ) { - if ( $this->is_subscription_item_in_cart() || $this->is_changing_payment_method_for_subscription() ) { - return $this->is_reusable(); + // Check if we're in a subscription context (cart checkout, changing payment method, or renewal). + $is_subscription_context = $this->is_subscription_item_in_cart() || $this->is_changing_payment_method_for_subscription(); + + // Also check if we're on the order-pay page for a renewal order. + if ( ! $is_subscription_context && is_wc_endpoint_url( 'order-pay' ) && function_exists( 'wcs_order_contains_renewal' ) ) { + $order = wc_get_order( absint( get_query_var( 'order-pay' ) ) ); + if ( $order && wcs_order_contains_renewal( $order ) ) { + $is_subscription_context = true; + } } + // Reusable methods are always available for subscriptions. Other methods are available if manual renewal is allowed. + $are_manual_renewals_accepted = function_exists( 'wcs_is_manual_renewal_enabled' ) && wcs_is_manual_renewal_enabled(); + $is_available_for_subscription = $are_manual_renewals_accepted || $this->is_reusable(); + + $order_is_within_currency_limits = true; // This part ensures that when payment limits for the currency declared, those will be respected (e.g. BNPLs). if ( [] !== $this->limits_per_currency && ! $skip_limits_per_currency_check ) { $order = null; @@ -229,16 +244,18 @@ public function is_enabled_at_checkout( string $account_country, bool $skip_limi } // If there is no range specified for the currency-country pair we don't support it and return false. if ( null === $range ) { - return false; + $order_is_within_currency_limits = false; + } else { + $is_valid_minimum = null === $range['min'] || $amount >= $range['min']; + $is_valid_maximum = null === $range['max'] || $amount <= $range['max']; + $order_is_within_currency_limits = $is_valid_minimum && $is_valid_maximum; } - $is_valid_minimum = null === $range['min'] || $amount >= $range['min']; - $is_valid_maximum = null === $range['max'] || $amount <= $range['max']; - return $is_valid_minimum && $is_valid_maximum; } } } - return true; + return $order_is_within_currency_limits + && ( ( ! $is_subscription_context ) || $is_available_for_subscription ); } /** diff --git a/includes/subscriptions/class-wc-payments-invoice-service.php b/includes/subscriptions/class-wc-payments-invoice-service.php index bf4d01d7de1..cd7fda20251 100644 --- a/includes/subscriptions/class-wc-payments-invoice-service.php +++ b/includes/subscriptions/class-wc-payments-invoice-service.php @@ -39,14 +39,6 @@ class WC_Payments_Invoice_Service { */ private $payments_api_client; - /** - * Product Service - * - * @var WC_Payments_Product_Service - */ - private $product_service; - - /** * Order Service * @@ -57,17 +49,14 @@ class WC_Payments_Invoice_Service { /** * Constructor. * - * @param WC_Payments_API_Client $payments_api_client WooCommerce Payments API client. - * @param WC_Payments_Product_Service $product_service Product Service. - * @param WC_Payments_Order_Service $order_service WC payments Order Service. + * @param WC_Payments_API_Client $payments_api_client WooCommerce Payments API client. + * @param WC_Payments_Order_Service $order_service WC payments Order Service. */ public function __construct( WC_Payments_API_Client $payments_api_client, - WC_Payments_Product_Service $product_service, WC_Payments_Order_Service $order_service ) { $this->payments_api_client = $payments_api_client; - $this->product_service = $product_service; $this->order_service = $order_service; /** diff --git a/includes/subscriptions/class-wc-payments-subscription-service.php b/includes/subscriptions/class-wc-payments-subscription-service.php index b1ff51a4a47..57ed54f8a28 100644 --- a/includes/subscriptions/class-wc-payments-subscription-service.php +++ b/includes/subscriptions/class-wc-payments-subscription-service.php @@ -769,9 +769,14 @@ public function create_subscription_for_manual_renewal( int $order_id ) { $subscriptions = wcs_get_subscriptions_for_renewal_order( $order_id ); - foreach ( $subscriptions as $subscription_id => $subscription ) { + foreach ( $subscriptions as $subscription ) { if ( ! self::get_wcpay_subscription_id( $subscription ) && $subscription->is_manual() ) { - $this->create_subscription( $subscription ); + // Only create WCPay subscription if subscription has payment tokens (reusable payment methods). + $payment_tokens = $subscription->get_payment_tokens(); + if ( ! empty( $payment_tokens ) ) { + $this->create_subscription( $subscription ); + } + // If no payment tokens, don't create WCPay subscription (non-reusable payment methods). } } } diff --git a/includes/subscriptions/class-wc-payments-subscriptions.php b/includes/subscriptions/class-wc-payments-subscriptions.php index 0007051ff3d..37f63ab73aa 100644 --- a/includes/subscriptions/class-wc-payments-subscriptions.php +++ b/includes/subscriptions/class-wc-payments-subscriptions.php @@ -83,7 +83,7 @@ public static function init( WC_Payments_API_Client $api_client, WC_Payments_Cus // Instantiate additional classes. self::$product_service = new WC_Payments_Product_Service( $api_client, $account ); - self::$invoice_service = new WC_Payments_Invoice_Service( $api_client, self::$product_service, self::$order_service ); + self::$invoice_service = new WC_Payments_Invoice_Service( $api_client, self::$order_service ); self::$subscription_service = new WC_Payments_Subscription_Service( $api_client, $customer_service, self::$product_service, self::$invoice_service ); self::$event_handler = new WC_Payments_Subscriptions_Event_Handler( self::$invoice_service, self::$subscription_service ); diff --git a/tests/unit/helpers/class-wc-helper-subscription.php b/tests/unit/helpers/class-wc-helper-subscription.php index e6f0aa7f70f..3d8414037a6 100644 --- a/tests/unit/helpers/class-wc-helper-subscription.php +++ b/tests/unit/helpers/class-wc-helper-subscription.php @@ -127,6 +127,13 @@ class WC_Subscription extends WC_Mock_WC_Data { */ public $customer_id = null; + /** + * Payment tokens. + * + * @var array + */ + public $payment_tokens = []; + /** * A helper function for handling function calls not yet implimented on this helper. * @@ -276,4 +283,12 @@ public function get_customer_id() { public function set_customer_id( $customer_id = null ) { $this->customer_id = $customer_id ?? get_current_user_id(); } + + public function get_payment_tokens() { + return $this->payment_tokens; + } + + public function set_payment_tokens( $tokens ) { + $this->payment_tokens = $tokens; + } } diff --git a/tests/unit/helpers/class-wc-helper-subscriptions.php b/tests/unit/helpers/class-wc-helper-subscriptions.php index a2c99a77071..7343189d847 100644 --- a/tests/unit/helpers/class-wc-helper-subscriptions.php +++ b/tests/unit/helpers/class-wc-helper-subscriptions.php @@ -97,6 +97,13 @@ function wcs_is_manual_renewal_required() { return ( WC_Subscriptions::$wcs_is_manual_renewal_required )(); } +function wcs_is_manual_renewal_enabled() { + if ( ! WC_Subscriptions::$wcs_is_manual_renewal_enabled ) { + return; + } + return ( WC_Subscriptions::$wcs_is_manual_renewal_enabled )(); +} + /** * Class WC_Subscriptions. * @@ -201,6 +208,13 @@ class WC_Subscriptions { */ public static $wcs_is_manual_renewal_required = null; + /** + * wcs_is_manual_renewal_enabled mock. + * + * @var function + */ + public static $wcs_is_manual_renewal_enabled = null; + public static function set_wcs_order_contains_subscription( $function ) { self::$wcs_order_contains_subscription = $function; } @@ -252,4 +266,8 @@ public static function is_duplicate_site() { public static function set_wcs_is_manual_renewal_required( $function ) { self::$wcs_is_manual_renewal_required = $function; } + + public static function set_wcs_is_manual_renewal_enabled( $function ) { + self::$wcs_is_manual_renewal_enabled = $function; + } } diff --git a/tests/unit/multi-currency/test-class-analytics.php b/tests/unit/multi-currency/test-class-analytics.php index 178db790140..5a7cd97e098 100644 --- a/tests/unit/multi-currency/test-class-analytics.php +++ b/tests/unit/multi-currency/test-class-analytics.php @@ -71,7 +71,6 @@ public function set_up() { $this->add_mock_order_with_meta(); $this->set_is_admin( true ); - $this->set_is_rest_request( true ); add_filter( 'woocommerce_is_rest_api_request', '__return_true' ); // Add manage_woocommerce capability to user. $cb = $this->create_can_manage_woocommerce_cap_override( true ); @@ -585,10 +584,6 @@ private function set_is_admin( bool $is_admin ) { $current_screen->method( 'in_admin' )->willReturn( $is_admin ); } - private function set_is_rest_request() { - $_SERVER['REQUEST_URI'] = '/ajax'; - } - /** * @param bool $can_manage_woocommerce * diff --git a/tests/unit/payment-methods/test-class-upe-payment-method.php b/tests/unit/payment-methods/test-class-upe-payment-method.php index bb986dd2c97..cb4858af6db 100644 --- a/tests/unit/payment-methods/test-class-upe-payment-method.php +++ b/tests/unit/payment-methods/test-class-upe-payment-method.php @@ -13,6 +13,7 @@ use WC_Payments_Account; use WC_Payments_Token_Service; use WC_Payments; +use WC_Subscriptions; /** * UPE_Payment_Method unit tests @@ -202,4 +203,62 @@ public function provider_test_get_countries() { ], ]; } + + /** + * Test that non-reusable payment methods are enabled when manual renewals are accepted. + */ + public function test_is_enabled_at_checkout_allows_non_reusable_when_manual_renewals_accepted() { + // Arrange: Mock a non-reusable payment method (iDEAL). + $ideal_method = $this->getMockBuilder( UPE_Payment_Method::class ) + ->onlyMethods( [ 'is_reusable', 'is_subscription_item_in_cart' ] ) + ->disableOriginalConstructor() + ->getMock(); + $ideal_method->method( 'is_reusable' )->willReturn( false ); + $ideal_method->method( 'is_subscription_item_in_cart' )->willReturn( true ); + + // Enable manual renewals. + WC_Subscriptions::set_wcs_is_manual_renewal_enabled( + function () { + return true; + } + ); + + // Act. + $result = $ideal_method->is_enabled_at_checkout( Country_Code::NETHERLANDS, true ); + + // Assert. + $this->assertTrue( $result, 'Non-reusable payment methods should be enabled when manual renewals are accepted' ); + + // Cleanup. + WC_Subscriptions::set_wcs_is_manual_renewal_enabled( null ); + } + + /** + * Test that non-reusable payment methods are disabled for subscription checkout when manual renewals are not accepted. + */ + public function test_is_enabled_at_checkout_disables_non_reusable_without_manual_renewals() { + // Arrange: Mock a non-reusable payment method. + $ideal_method = $this->getMockBuilder( UPE_Payment_Method::class ) + ->onlyMethods( [ 'is_reusable', 'is_subscription_item_in_cart' ] ) + ->disableOriginalConstructor() + ->getMock(); + $ideal_method->method( 'is_reusable' )->willReturn( false ); + $ideal_method->method( 'is_subscription_item_in_cart' )->willReturn( true ); + + // Disable manual renewals. + WC_Subscriptions::set_wcs_is_manual_renewal_enabled( + function () { + return false; + } + ); + + // Act. + $result = $ideal_method->is_enabled_at_checkout( Country_Code::NETHERLANDS, true ); + + // Assert. + $this->assertFalse( $result, 'Non-reusable payment methods should be disabled for subscriptions when manual renewals are not accepted' ); + + // Cleanup. + WC_Subscriptions::set_wcs_is_manual_renewal_enabled( null ); + } } diff --git a/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php b/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php index 0b53a392576..b77f3954d70 100644 --- a/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php +++ b/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php @@ -36,6 +36,7 @@ use WCPay\Duplicates_Detection_Service; use WCPay\Internal\Service\Level3Service; use WCPay\Internal\Service\OrderService; +use WC_Subscriptions; /** * WC_Payment_Gateway_WCPay unit tests */ @@ -1071,6 +1072,12 @@ public function test_only_reusabled_payment_methods_enabled_with_subscription_it // Setup $this->mock_payment_methods. $this->set_cart_contains_subscription_items( true ); + // Disable manual renewals to test only reusable methods are enabled. + WC_Subscriptions::set_wcs_is_manual_renewal_enabled( + function () { + return false; + } + ); $card_method = $this->mock_payment_methods['card']; $giropay_method = $this->mock_payment_methods['giropay']; diff --git a/tests/unit/subscriptions/test-class-wc-payment-gateway-wcpay-subscriptions-non-reusable-methods.php b/tests/unit/subscriptions/test-class-wc-payment-gateway-wcpay-subscriptions-non-reusable-methods.php new file mode 100644 index 00000000000..66be64301b9 --- /dev/null +++ b/tests/unit/subscriptions/test-class-wc-payment-gateway-wcpay-subscriptions-non-reusable-methods.php @@ -0,0 +1,184 @@ +mock_gateway = $this->getMockForTrait( + WC_Payment_Gateway_WCPay_Subscriptions_Trait::class, + [], + '', + true, + true, + true, + [] + ); + } + + /** + * Test that subscriptions created with split UPE gateways (non-reusable) are forced to manual. + */ + public function test_maybe_force_subscription_to_manual_with_split_upe_gateway() { + // Arrange: Create a subscription with a split UPE gateway (iDEAL). + $subscription = new WC_Subscription(); + $subscription->set_payment_method( 'woocommerce_payments_ideal' ); + $subscription->set_requires_manual_renewal( false ); // Start as automatic. + $subscription->save(); + + // Act: Call the method. + $this->mock_gateway->maybe_force_subscription_to_manual( $subscription ); + + // Assert: Subscription should be forced to manual. + $this->assertTrue( $subscription->is_manual(), 'Subscription should be manual for non-reusable payment method' ); + $this->assertEquals( 'woocommerce_payments_ideal', $subscription->get_meta( '_wcpay_original_payment_method_id', true ), 'Original payment method ID should be stored' ); + $this->assertEquals( 'woocommerce_payments_ideal', $subscription->get_payment_method(), 'Payment method should remain as split UPE gateway' ); + } + + /** + * Test that subscriptions created with base gateway (card) are NOT forced to manual. + */ + public function test_maybe_force_subscription_to_manual_with_base_gateway() { + // Arrange: Create a subscription with the base gateway (card). + $subscription = new WC_Subscription(); + $subscription->set_payment_method( 'woocommerce_payments' ); + $subscription->set_requires_manual_renewal( false ); // Automatic. + $subscription->save(); + + // Act: Call the method. + $this->mock_gateway->maybe_force_subscription_to_manual( $subscription ); + + // Assert: Subscription should remain automatic (not forced to manual). + $this->assertFalse( $subscription->is_manual(), 'Subscription should remain automatic for reusable payment method (card)' ); + $this->assertEmpty( $subscription->get_meta( '_wcpay_original_payment_method_id', true ), 'No original payment method ID should be stored for card' ); + } + + /** + * Test that non-WCPay subscriptions are not affected. + */ + public function test_maybe_force_subscription_to_manual_ignores_non_wcpay_gateway() { + // Arrange: Create a subscription with a different payment gateway. + $subscription = new WC_Subscription(); + $subscription->set_payment_method( 'stripe' ); + $subscription->set_requires_manual_renewal( false ); + $subscription->save(); + + // Act: Call the method. + $this->mock_gateway->maybe_force_subscription_to_manual( $subscription ); + + // Assert: Subscription should not be affected. + $this->assertFalse( $subscription->is_manual(), 'Non-WCPay subscription should not be affected' ); + $this->assertEmpty( $subscription->get_meta( '_wcpay_original_payment_method_id', true ) ); + } + + /** + * Test that subscription is properly configured when forcing to manual (verifies side effects). + */ + public function test_maybe_force_subscription_to_manual_configures_subscription() { + // Arrange: Create a subscription with a split UPE gateway. + $subscription = new WC_Subscription(); + $subscription->set_payment_method( 'woocommerce_payments_bancontact' ); + $subscription->set_requires_manual_renewal( false ); // Start as automatic. + $subscription->save(); + + // Act: Call the method. + $this->mock_gateway->maybe_force_subscription_to_manual( $subscription ); + + // Assert: Verify the subscription was properly configured. + $this->assertTrue( $subscription->is_manual(), 'Subscription should be set to manual' ); + $this->assertEquals( 'woocommerce_payments_bancontact', $subscription->get_meta( '_wcpay_original_payment_method_id', true ), 'Original payment method ID should be stored' ); + $this->assertEquals( 'woocommerce_payments_bancontact', $subscription->get_payment_method(), 'Payment method should remain unchanged' ); + } + + /** + * Test hiding "Change payment" button for manual subscriptions with non-reusable payment methods. + */ + public function test_maybe_hide_change_payment_for_manual_subscriptions_hides_button() { + // Arrange: Create a manual subscription with non-reusable payment method. + $subscription = new WC_Subscription(); + $subscription->set_requires_manual_renewal( true ); + $subscription->update_meta_data( '_wcpay_original_payment_method_id', 'woocommerce_payments_ideal' ); + $subscription->save(); + + $actions = [ + 'change_payment_method' => [ + 'url' => 'https://example.com/change-payment', + 'name' => 'Change payment', + ], + 'view' => [ + 'url' => 'https://example.com/view', + 'name' => 'View', + ], + ]; + + // Act: Call the method. + $result = $this->mock_gateway->maybe_hide_change_payment_for_manual_subscriptions( $actions, $subscription ); + + // Assert: "Change payment" button should be removed. + $this->assertArrayNotHasKey( 'change_payment_method', $result, '"Change payment" action should be removed' ); + $this->assertArrayHasKey( 'view', $result, 'Other actions should remain' ); + } + + /** + * Test that "Change payment" button is shown for manual subscriptions without non-reusable payment method meta. + */ + public function test_maybe_hide_change_payment_for_manual_subscriptions_shows_button_without_meta() { + // Arrange: Create a manual subscription WITHOUT non-reusable payment method meta. + $subscription = new WC_Subscription(); + $subscription->set_requires_manual_renewal( true ); + $subscription->save(); + + $actions = [ + 'change_payment_method' => [ + 'url' => 'https://example.com/change-payment', + 'name' => 'Change payment', + ], + ]; + + // Act: Call the method. + $result = $this->mock_gateway->maybe_hide_change_payment_for_manual_subscriptions( $actions, $subscription ); + + // Assert: "Change payment" button should remain. + $this->assertArrayHasKey( 'change_payment_method', $result, '"Change payment" action should remain without non-reusable payment method meta' ); + } + + /** + * Test that "Change payment" button is shown for automatic subscriptions. + */ + public function test_maybe_hide_change_payment_for_automatic_subscriptions() { + // Arrange: Create an automatic subscription with non-reusable payment method meta. + $subscription = new WC_Subscription(); + $subscription->set_requires_manual_renewal( false ); + $subscription->update_meta_data( '_wcpay_original_payment_method_id', 'woocommerce_payments_ideal' ); + $subscription->save(); + + $actions = [ + 'change_payment_method' => [ + 'url' => 'https://example.com/change-payment', + 'name' => 'Change payment', + ], + ]; + + // Act: Call the method. + $result = $this->mock_gateway->maybe_hide_change_payment_for_manual_subscriptions( $actions, $subscription ); + + // Assert: "Change payment" button should remain for automatic subscriptions. + $this->assertArrayHasKey( 'change_payment_method', $result, '"Change payment" action should remain for automatic subscriptions' ); + } +} diff --git a/tests/unit/subscriptions/test-class-wc-payments-invoice-service.php b/tests/unit/subscriptions/test-class-wc-payments-invoice-service.php index b7e33c2a4e2..825cc5aa092 100644 --- a/tests/unit/subscriptions/test-class-wc-payments-invoice-service.php +++ b/tests/unit/subscriptions/test-class-wc-payments-invoice-service.php @@ -14,7 +14,6 @@ */ class WC_Payments_Invoice_Service_Test extends WCPAY_UnitTestCase { - const PRICE_ID_KEY = '_wcpay_product_price_id'; const PENDING_INVOICE_ID_KEY = '_wcpay_pending_invoice_id'; const ORDER_INVOICE_ID_KEY = '_wcpay_billing_invoice_id'; const SUBSCRIPTION_ID_META_KEY = '_wcpay_subscription_id'; @@ -24,16 +23,22 @@ class WC_Payments_Invoice_Service_Test extends WCPAY_UnitTestCase { /** * Mock WC_Payments_API_Client. * - * @var WC_Payments_API_Client|MockObject + * @var WC_Payments_API_Client&MockObject */ private $mock_api_client; /** - * Mock WC_Payments_Product_Service. + * Mock WC_Payments_Order_Service. * - * @var WC_Payments_Product_Service|MockObject + * @var WC_Payments_Order_Service&MockObject */ - private $mock_product_service; + private $mock_order_service; + + /** + * Invoice Service under test. + * @var WC_Payments_Invoice_Service + */ + private $invoice_service; /** * Pre-test setup @@ -41,10 +46,9 @@ class WC_Payments_Invoice_Service_Test extends WCPAY_UnitTestCase { public function set_up() { parent::set_up(); - $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); - $this->mock_product_service = $this->createMock( WC_Payments_Product_Service::class ); - $this->mock_order_service = $this->createMock( WC_Payments_Order_Service::class ); - $this->invoice_service = new WC_Payments_Invoice_Service( $this->mock_api_client, $this->mock_product_service, $this->mock_order_service ); + $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); + $this->mock_order_service = $this->createMock( WC_Payments_Order_Service::class ); + $this->invoice_service = new WC_Payments_Invoice_Service( $this->mock_api_client, $this->mock_order_service ); } /** @@ -137,6 +141,7 @@ public function test_maybe_record_invoice_payment() { $mock_order = WC_Helper_Order::create_order(); $mock_subscription = new WC_Subscription(); + $mock_subscription->payment_tokens = [ uniqid( 'pm_' ) ]; $mock_subscription->payment_method = 'woocommerce_payments'; $mock_subscription->update_meta_data( self::SUBSCRIPTION_ID_META_KEY, 'sub_123abc' ); $mock_subscription->save(); @@ -226,7 +231,7 @@ public function test_validate_invoice_with_valid_data() { ->method( 'update_subscription' ); $invoice_service = $this->getMockBuilder( WC_Payments_Invoice_Service::class ) - ->setConstructorArgs( [ $this->mock_api_client, $this->mock_product_service, $this->mock_order_service ] ) + ->setConstructorArgs( [ $this->mock_api_client, $this->mock_order_service ] ) ->onlyMethods( [ 'get_recurring_items', 'get_wcpay_item_id' ] ) ->getMock(); @@ -346,7 +351,7 @@ public function test_validate_invoice_with_invalid_data() { // Create a partial mock of the invoice service. $invoice_service = $this->getMockBuilder( WC_Payments_Invoice_Service::class ) - ->setConstructorArgs( [ $this->mock_api_client, $this->mock_product_service, $this->mock_order_service ] ) + ->setConstructorArgs( [ $this->mock_api_client, $this->mock_order_service ] ) ->onlyMethods( [ 'get_recurring_items', 'get_wcpay_item_id' ] ) ->getMock(); diff --git a/tests/unit/subscriptions/test-class-wc-payments-subscription-service.php b/tests/unit/subscriptions/test-class-wc-payments-subscription-service.php index d9abecb8f9a..976a8480cff 100644 --- a/tests/unit/subscriptions/test-class-wc-payments-subscription-service.php +++ b/tests/unit/subscriptions/test-class-wc-payments-subscription-service.php @@ -252,6 +252,7 @@ public function test_create_subscription_for_manual_renewal() { $mock_subscription->set_requires_manual_renewal( true ); $mock_subscription->set_parent( $mock_order ); $mock_subscription->set_props( [ 'payment_method' => WC_Payment_Gateway_WCPay::GATEWAY_ID ] ); + $mock_subscription->payment_tokens = [ uniqid( 'pm_' ) ]; WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( function ( $id ) use ( $mock_subscription ) { diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php index 8be72c70d74..a757dfdd813 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php @@ -145,6 +145,7 @@ public function set_up() { $mock_dpps = $this->createMock( Duplicate_Payment_Prevention_Service::class ); $mock_payment_method = $this->createMock( CC_Payment_Method::class ); + $mock_payment_method->method( 'is_reusable' )->willReturn( true ); $this->mock_wcpay_gateway = $this->getMockBuilder( '\WC_Payment_Gateway_WCPay' ) ->setConstructorArgs( @@ -278,7 +279,9 @@ public function test_new_card_subscription() { $orders = array_merge( [ $order ], $subscriptions ); foreach ( $orders as $order ) { $payment_tokens = $order->get_payment_tokens(); - $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + if ( [] !== $payment_tokens ) { + $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + } } } @@ -322,7 +325,9 @@ public function test_new_card_zero_dollar_subscription() { $orders = array_merge( [ $order ], $subscriptions ); foreach ( $orders as $order ) { $payment_tokens = $order->get_payment_tokens(); - $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + if ( [] !== $payment_tokens ) { + $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + } } } @@ -351,7 +356,9 @@ public function test_new_card_is_added_before_status_update() { $orders = array_merge( [ $order ], $subscriptions ); foreach ( $orders as $order ) { $payment_tokens = $order->get_payment_tokens(); - $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + if ( [] !== $payment_tokens ) { + $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + } } } @@ -432,7 +439,9 @@ public function test_saved_card_subscription() { $orders = array_merge( [ $order ], $subscriptions ); foreach ( $orders as $order ) { $payment_tokens = $order->get_payment_tokens(); - $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + if ( [] !== $payment_tokens ) { + $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + } } } @@ -471,7 +480,9 @@ public function test_saved_card_zero_dollar_subscription() { $orders = array_merge( [ $order ], $subscriptions ); foreach ( $orders as $order ) { $payment_tokens = $order->get_payment_tokens(); - $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + if ( [] !== $payment_tokens ) { + $this->assertEquals( $this->token->get_id(), end( $payment_tokens ) ); + } } } diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 95a27f48e4c..2b5f43d6824 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -44,6 +44,7 @@ use WCPay\Payment_Methods\WC_Helper_Site_Currency; use WCPay\WooPay\WooPay_Utilities; use WCPay\Session_Rate_Limiter; +use WC_Subscriptions; // Need to use WC_Mock_Data_Store. require_once __DIR__ . '/helpers/class-wc-mock-wc-data-store.php'; @@ -709,6 +710,12 @@ function ( $order ) { return true; } ); + // Disable manual renewals to test only reusable methods are enabled. + WC_Subscriptions::set_wcs_is_manual_renewal_enabled( + function () { + return false; + } + ); $card_method = $this->payment_methods['card']; $giropay_method = $this->payment_methods['giropay']; diff --git a/tests/unit/test-class-wc-payments-non-reusable-payment-methods-integration.php b/tests/unit/test-class-wc-payments-non-reusable-payment-methods-integration.php new file mode 100644 index 00000000000..a5cef23dd14 --- /dev/null +++ b/tests/unit/test-class-wc-payments-non-reusable-payment-methods-integration.php @@ -0,0 +1,162 @@ +createMock( WC_Payments_API_Client::class ); + $mock_order_service = $this->createMock( WC_Payments_Order_Service::class ); + $mock_product_service = $this->createMock( WC_Payments_Product_Service::class ); + $mock_customer_service = $this->createMock( WC_Payments_Customer_Service::class ); + + $this->mock_subscription_product = new WC_Subscriptions_Product(); + $this->mock_subscription_product->set_props( + [ + 'regular_price' => 10, + 'price' => 10, + ] + ); + $this->mock_subscription_product->save(); + + $mock_customer_service + ->method( 'get_customer_id_for_order' ) + ->willReturn( uniqid( 'wcpay_cus_' ) ); + + $mock_product_service + ->method( 'get_wcpay_product_id_for_item' ) + ->willReturn( uniqid( 'wcpay_prod_' ) ); + + $mock_product_service + ->method( 'get_or_create_wcpay_product_id' ) + ->willReturn( uniqid( 'wcpay_prod_' ) ); + + $mock_product_service + ->method( 'is_valid_billing_cycle' ) + ->willReturn( true ); + + $mock_api_client + ->method( 'create_subscription' ) + ->willReturn( + [ + 'id' => uniqid( 'sub_' ), + 'items' => [ + 'data' => [ + [ + 'id' => uniqid( 'sub_item_' ), + ], + ], + ], + ] + ); + + // Mock charge_invoice for maybe_record_invoice_payment. + $mock_api_client + ->method( 'charge_invoice' ) + ->willReturn( [ 'status' => 'paid' ] ); + + $this->invoice_service = new WC_Payments_Invoice_Service( $mock_api_client, $mock_order_service ); + $this->subscription_service = new WC_Payments_Subscription_Service( $mock_api_client, $mock_customer_service, $mock_product_service, $this->invoice_service ); + $this->payment_gateway_trait = $this->getMockForTrait( WC_Payment_Gateway_WCPay_Subscriptions_Trait::class ); + } + + /** + * Test complete flow: Reusable payment method -> Manual subscription -> Converts to automatic if reusable payment method is used. + */ + public function test_reusable_payment_method_with_manual_subscription_converts_to_automatic() { + // Arrange: Create order and subscription with reusable payment method. + $order = WC_Helper_Order::create_order( 1, 50, $this->mock_subscription_product ); + $subscription = new WC_Subscription(); + $subscription->set_requires_manual_renewal( true ); + $subscription->set_parent( $order ); + $subscription->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID ); + $subscription->set_payment_tokens( [ uniqid( 'pm_' ) ] ); + // Mock subscription meta for WCPay checks. + $subscription->update_meta_data( WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY, uniqid( 'sub_test_' ) ); + $subscription->update_meta_data( WC_Payments_Invoice_Service::ORDER_INVOICE_ID_KEY, 'inv_test123' ); + $subscription->save(); + + // Mock required functions. + WC_Subscriptions::set_wcs_get_subscriptions_for_order( + function ( $order_id ) use ( $subscription ) { + return [ $subscription ]; + } + ); + WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( + function ( $order_id ) use ( $subscription ) { + return [ $subscription ]; + } + ); + + // Mock the abstract get_payment_token method to return a payment token for subscriptions with tokens. + $this->payment_gateway_trait + ->method( 'get_payment_token' ) + ->willReturnCallback( + function ( $subscription_or_order ) { + if ( is_a( $subscription_or_order, 'WC_Subscription' ) ) { + $tokens = $subscription_or_order->get_payment_tokens(); + return ! empty( $tokens ) ? $tokens[0] : null; + } + return null; + } + ); + + // Act 1: Test that subscription stays manual during renewal (real method). + $initial_manual_state = $subscription->is_manual(); + $this->invoice_service->maybe_record_invoice_payment( $order->get_id() ); + $stayed_manual = $subscription->is_manual(); + + // Act 2: Test WCPay subscription creation during renewal (real method). + $this->subscription_service->create_subscription_for_manual_renewal( $order->get_id() ); + + // Assert: Complete flow behavior. + $this->assertTrue( $initial_manual_state, 'Subscription should start as manual' ); + $this->assertNotTrue( $stayed_manual, 'Subscription should become automatic when payment tokens are present' ); + // Note: We can't easily assert create_subscription was called without complex mocking, + // but the real method will call it when payment tokens exist. + } + + public function tear_down() { + parent::tear_down(); + WC_Subscriptions::set_wcs_get_subscriptions_for_order( null ); + WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( null ); + } +} diff --git a/tests/unit/test-class-wc-payments-subscription-service-creation-logic.php b/tests/unit/test-class-wc-payments-subscription-service-creation-logic.php new file mode 100644 index 00000000000..f549d5e672f --- /dev/null +++ b/tests/unit/test-class-wc-payments-subscription-service-creation-logic.php @@ -0,0 +1,291 @@ +mock_api_client = $this->createMock( WC_Payments_API_Client::class ); + $this->mock_product_service = $this->createMock( WC_Payments_Product_Service::class ); + $this->mock_customer_service = $this->createMock( WC_Payments_Customer_Service::class ); + $this->mock_invoice_service = $this->createMock( WC_Payments_Invoice_Service::class ); + $this->subscription_service = new WC_Payments_Subscription_Service( $this->mock_api_client, $this->mock_customer_service, $this->mock_product_service, $this->mock_invoice_service ); + + $this->mock_subscription_product = new WC_Subscriptions_Product(); + WC_Subscriptions_Product::set_period( 'month' ); + WC_Subscriptions_Product::set_interval( 1 ); + $this->mock_subscription_product->set_props( + [ + 'regular_price' => 10, + 'price' => 10, + ] + ); + $this->mock_subscription_product->save(); + + $this->mock_get_product_from_item_callback = function () { + return $this->mock_subscription_product; + }; + + add_filter( 'woocommerce_get_product_from_item', $this->mock_get_product_from_item_callback ); + } + + /** + * Test that WCPay subscription IS created for manual renewal with payment tokens. + */ + public function test_should_create_wcpay_subscription_for_manual_renewal_with_payment_tokens() { + // Arrange. + $order = WC_Helper_Order::create_order( 1, 50, $this->mock_subscription_product ); + $subscription = new WC_Subscription(); + $subscription->set_requires_manual_renewal( true ); + $subscription->set_parent( $order ); + $subscription->set_props( + [ + 'payment_method' => WC_Payment_Gateway_WCPay::GATEWAY_ID, + // Payment tokens saved (characteristic of reusable payment methods like cards). + 'payment_tokens' => [ uniqid( 'pm_' ) ], + ] + ); + $subscription->update_meta_data( self::SUBSCRIPTION_ID_META_KEY, '' ); + $subscription->update_meta_data( self::ORDER_INVOICE_ID_KEY, uniqid( 'order_' ) ); + $subscription->save(); + + $mock_line_item = array_values( $order->get_items() )[0]; + $mock_shipping_item = array_values( $order->get_items( 'shipping' ) )[0]; + $mock_wcpay_product_id = uniqid( 'wcpay_prod_' ); + $mock_wcpay_subscription_id = uniqid( 'wcpay_subscription_' ); + $mock_wcpay_subscription_item_id = uniqid( 'wcpay_subscription_item_' ); + $mock_subscription_data = [ + 'customer' => uniqid( 'cus_' ), + 'items' => [ + [ + 'quantity' => 4, + 'metadata' => [ + 'wc_item_id' => $mock_line_item->get_id(), + ], + 'price_data' => [ + 'currency' => 'USD', + 'product' => $mock_wcpay_product_id, + 'unit_amount_decimal' => 1000.0, + 'recurring' => [ + 'interval' => 'month', + 'interval_count' => 1, + ], + ], + ], + [ + 'price_data' => [ + 'product' => $mock_wcpay_product_id, + 'currency' => 'USD', + 'unit_amount_decimal' => 1000.0, + 'recurring' => [ + 'interval' => 'month', + 'interval_count' => 1, + ], + ], + 'metadata' => [ + 'wc_item_id' => $mock_shipping_item->get_id(), + 'method' => $mock_shipping_item->get_name(), + ], + ], + ], + 'metadata' => [ + 'subscription_source' => 'woo_subscriptions', + ], + ]; + + $this->mock_customer_service + ->method( 'get_customer_id_for_order' ) + ->willReturn( $mock_subscription_data['customer'] ); + + $this->mock_product_service + ->expects( $this->once() ) + ->method( 'get_wcpay_product_id_for_item' ) + ->willReturn( $mock_wcpay_product_id ); + + $this->mock_product_service + ->expects( $this->once() ) + ->method( 'get_or_create_wcpay_product_id' ) + ->willReturn( $mock_wcpay_product_id ); + + $this->mock_product_service + ->method( 'is_valid_billing_cycle' ) + ->willReturn( true ); + + // Mock subscription creation API call. + $this->mock_api_client->expects( $this->once() ) + ->method( 'create_subscription' ) + ->with( $mock_subscription_data ) + ->willReturn( + [ + 'id' => $mock_wcpay_subscription_id, + 'latest_invoice' => 'mock_wcpay_invoice_id', + 'items' => [ + 'data' => [ + [ + 'id' => $mock_wcpay_subscription_item_id, + 'metadata' => [ + 'wc_item_id' => $mock_line_item->get_id(), + ], + ], + [ + 'id' => $mock_wcpay_subscription_item_id, + 'metadata' => [ + 'wc_item_id' => $mock_shipping_item->get_id(), + ], + ], + ], + ], + ] + ); + + // Mock wcs_get_subscriptions_for_renewal_order function. + WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( + function ( $order_id ) use ( $subscription ) { + return [ $subscription ]; + } + ); + + // Act - Call the actual subscription service method. + $this->subscription_service->create_subscription_for_manual_renewal( $order->get_id() ); + + // Assert - Verify that create_subscription was called (indicating WCPay subscription was created). + // The mock expectation for create_subscription will verify this. + } + + /** + * Test that WCPay subscription is NOT created for manual renewal without payment tokens. + */ + public function test_should_not_create_wcpay_subscription_for_manual_renewal_without_payment_tokens() { + // Arrange. + $order = WC_Helper_Order::create_order(); + $subscription = new WC_Subscription(); + $subscription->set_requires_manual_renewal( true ); + $subscription->set_parent( $order ); + + // Mock empty payment tokens to simulate non-reusable payment method. + $subscription->payment_tokens = []; + $subscription->payment_method = 'woocommerce_payments'; + // Mock that subscription doesn't have WCPay subscription ID yet. + $subscription->update_meta_data( self::SUBSCRIPTION_ID_META_KEY, '' ); + $subscription->save(); + + // Mock subscription creation API call should NOT be called. + $this->mock_api_client->expects( $this->never() ) + ->method( 'create_subscription' ); + + // Mock wcs_get_subscriptions_for_renewal_order function. + WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( + function ( $order_id ) use ( $subscription ) { + return [ $subscription ]; + } + ); + + // Act - Call the actual subscription service method. + $this->subscription_service->create_subscription_for_manual_renewal( $order->get_id() ); + + // Assert - Verify that create_subscription was NOT called (indicating WCPay subscription was NOT created). + // The mock expectation for never() will verify this. + } + + /** + * Test that WCPay subscription is NOT created for automatic subscriptions. + */ + public function test_should_not_create_wcpay_subscription_for_automatic_subscription() { + // Arrange. + $order = WC_Helper_Order::create_order(); + $subscription = new WC_Subscription(); + $subscription->set_requires_manual_renewal( false ); // Automatic subscription. + $subscription->set_parent( $order ); + // Even with payment tokens, automatic subscriptions use different logic. + $subscription->payment_tokens = [ 'pm_test123' ]; + $subscription->payment_method = 'woocommerce_payments'; + $subscription->update_meta_data( self::SUBSCRIPTION_ID_META_KEY, uniqid( 'sub_' ) ); + $subscription->update_meta_data( self::ORDER_INVOICE_ID_KEY, uniqid( 'order_' ) ); + $subscription->save(); + + // Mock subscription creation API call should NOT be called. + $this->mock_api_client->expects( $this->never() ) + ->method( 'create_subscription' ); + + // Mock wcs_get_subscriptions_for_renewal_order function. + WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( + function ( $order_id ) use ( $subscription ) { + return [ $subscription ]; + } + ); + + // Act - Call the actual subscription service method. + $this->subscription_service->create_subscription_for_manual_renewal( $order->get_id() ); + + // Assert - Verify that create_subscription was NOT called since subscription is automatic. + // The mock expectation for never() will verify this. + } + + public function tear_down() { + parent::tear_down(); + WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( null ); + remove_filter( 'woocommerce_get_product_from_item', $this->mock_get_product_from_item_callback ); + } +} diff --git a/tests/unit/test-class-wc-payments-woopay-button-handler.php b/tests/unit/test-class-wc-payments-woopay-button-handler.php index cfeed01922c..325ac1094ec 100644 --- a/tests/unit/test-class-wc-payments-woopay-button-handler.php +++ b/tests/unit/test-class-wc-payments-woopay-button-handler.php @@ -63,6 +63,12 @@ class WC_Payments_WooPay_Button_Handler_Test extends WCPAY_UnitTestCase { public function set_up() { parent::set_up(); + // Clean up orphaned product meta lookup entries from previous tests. + // WooCommerce's product deletion doesn't always clean up the lookup table properly in test environments, + // which can cause duplicate primary key errors when product IDs get reused. + global $wpdb; + $wpdb->query( "DELETE FROM {$wpdb->prefix}wc_product_meta_lookup WHERE product_id NOT IN (SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product')" ); + $this->mock_api_client = $this->getMockBuilder( 'WC_Payments_API_Client' ) ->disableOriginalConstructor() ->setMethods( From 0f483d14484556b2a245c9bc897be48f1824403c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 19:13:11 +0000 Subject: [PATCH 23/32] Update version and add changelog entries for release 10.3.0 --- changelog.txt | 13 +++++++++++++ changelog/chore-add-amazon-pay-feature-flag | 4 ---- changelog/dev-qit-e2e-basic-checkout | 3 --- changelog/dev-qit-e2e-foundation | 5 ----- changelog/dev-test-mode-text-fix | 4 ---- changelog/feat-amazon-pay-settings-ui | 5 ----- changelog/fix-jcb-logo | 4 ---- changelog/fix-missing-badge | 5 ----- .../fix-payment-methods-logos-overflow-text-color | 4 ---- changelog/fix-refund-on-cancel | 4 ---- changelog/fix-subscription-urls | 5 ----- changelog/fix-woopay-blocks-field | 4 ---- ...subscriptions-toggle-not-saving-when-unchecked | 4 ---- ...ssion-pm-logos-overflow-in-short-code-checkout | 4 ---- changelog/update-remove-payout-status-icon | 5 ----- ...e-verify-user-access-when-deleting-test-orders | 5 ----- ...e-dispute-requires-attention-to-a-specific-fee | 4 ---- ...ayment-method-not-available-for-manual-renewal | 4 ---- ...y-pill-shaped-button-is-cutoff-on-checkout-alt | 4 ---- ...tton-spinner-and-label-is-not-visible-on-click | 4 ---- ...backend-update-backend-for-new-challenge-forms | 4 ---- ...pmnt-5449-update-payout-banner-in-the-settings | 4 ---- ...t-5500-php-deprecations-in-the-request-classes | 4 ---- package-lock.json | 4 ++-- package.json | 2 +- readme.txt | 15 ++++++++++++++- woocommerce-payments.php | 2 +- 27 files changed, 31 insertions(+), 98 deletions(-) delete mode 100644 changelog/chore-add-amazon-pay-feature-flag delete mode 100644 changelog/dev-qit-e2e-basic-checkout delete mode 100644 changelog/dev-qit-e2e-foundation delete mode 100644 changelog/dev-test-mode-text-fix delete mode 100644 changelog/feat-amazon-pay-settings-ui delete mode 100644 changelog/fix-jcb-logo delete mode 100644 changelog/fix-missing-badge delete mode 100644 changelog/fix-payment-methods-logos-overflow-text-color delete mode 100644 changelog/fix-refund-on-cancel delete mode 100644 changelog/fix-subscription-urls delete mode 100644 changelog/fix-woopay-blocks-field delete mode 100644 changelog/fix-woopmnt-5394-fix-wcpay-subscriptions-toggle-not-saving-when-unchecked delete mode 100644 changelog/fix-woopmnt-5397-regression-pm-logos-overflow-in-short-code-checkout delete mode 100644 changelog/update-remove-payout-status-icon delete mode 100644 changelog/update-verify-user-access-when-deleting-test-orders delete mode 100644 changelog/woopmnt-5113-visa-compliance-dispute-requires-attention-to-a-specific-fee delete mode 100644 changelog/woopmnt-5245-ideal-payment-method-not-available-for-manual-renewal delete mode 100644 changelog/woopmnt-5396-woopay-pill-shaped-button-is-cutoff-on-checkout-alt delete mode 100644 changelog/woopmnt-5398-woopay-button-spinner-and-label-is-not-visible-on-click delete mode 100644 changelog/woopmnt-5447-backend-update-backend-for-new-challenge-forms delete mode 100644 changelog/woopmnt-5449-update-payout-banner-in-the-settings delete mode 100644 changelog/woopmnt-5500-php-deprecations-in-the-request-classes diff --git a/changelog.txt b/changelog.txt index ae10a9615a3..7cf06224239 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,18 @@ *** WooPayments Changelog *** += 10.3.0 - 2025-11-26 = +* Add - Add backend support for additional dispute evidence types (event, booking, other) behind feature flag. +* Add - Allow non-reusable payment methods to be used for the manually renewed subscriptions. +* Add - chore: add amazon pay feature flag. +* Add - Handling of the Visa Compliance disputes with attention to a specific dispute fee. +* Fix - fix: text color of payment method icons on checkout page when a dark background is used +* Fix - Fix deprecation warning about usage of `parent` in callables. +* Fix - Fix styling of the WooPay button to make sure that the spinner is visible when loading. +* Fix - Fix WooPay express button text clipping +* Fix - Refunds and fees should not be tracked for canceled authorizations +* Fix - WooPay component spacing issues on blocks and classic checkout. +* Update - Change payout texts for New Account Waiting Period to be consistent with new Account Details + = 10.2.0 - 2025-11-06 = * Add - Add WooCommerce Tool to delete test orders. * Add - Sync store setup details with the Transact Platform. diff --git a/changelog/chore-add-amazon-pay-feature-flag b/changelog/chore-add-amazon-pay-feature-flag deleted file mode 100644 index bb656d14bba..00000000000 --- a/changelog/chore-add-amazon-pay-feature-flag +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: add - -chore: add amazon pay feature flag. diff --git a/changelog/dev-qit-e2e-basic-checkout b/changelog/dev-qit-e2e-basic-checkout deleted file mode 100644 index a1d72601ed5..00000000000 --- a/changelog/dev-qit-e2e-basic-checkout +++ /dev/null @@ -1,3 +0,0 @@ -Significance: patch -Type: dev -Comment: Dev only changes to run E2E tests with QIT. diff --git a/changelog/dev-qit-e2e-foundation b/changelog/dev-qit-e2e-foundation deleted file mode 100644 index 3db34d37454..00000000000 --- a/changelog/dev-qit-e2e-foundation +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: dev -Comment: Add foundation to run E2E tests with QIT and basic tests. - - diff --git a/changelog/dev-test-mode-text-fix b/changelog/dev-test-mode-text-fix deleted file mode 100644 index 25036cf8d87..00000000000 --- a/changelog/dev-test-mode-text-fix +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: update - -Minor copy update to the delete test orders tool. diff --git a/changelog/feat-amazon-pay-settings-ui b/changelog/feat-amazon-pay-settings-ui deleted file mode 100644 index ecff0c0d1fb..00000000000 --- a/changelog/feat-amazon-pay-settings-ui +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: dev -Comment: feat: add Amazon Pay settings UI behind feature flag - - diff --git a/changelog/fix-jcb-logo b/changelog/fix-jcb-logo deleted file mode 100644 index 123b2e115f0..00000000000 --- a/changelog/fix-jcb-logo +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Comment: fix JCB logo. diff --git a/changelog/fix-missing-badge b/changelog/fix-missing-badge deleted file mode 100644 index 41a346b57e4..00000000000 --- a/changelog/fix-missing-badge +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: fix -Comment: Fix regression caused by PR #11085 - - diff --git a/changelog/fix-payment-methods-logos-overflow-text-color b/changelog/fix-payment-methods-logos-overflow-text-color deleted file mode 100644 index e2268dc3f91..00000000000 --- a/changelog/fix-payment-methods-logos-overflow-text-color +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -fix: text color of payment method icons on checkout page when a dark background is used diff --git a/changelog/fix-refund-on-cancel b/changelog/fix-refund-on-cancel deleted file mode 100644 index 8c3c7877e85..00000000000 --- a/changelog/fix-refund-on-cancel +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Refunds and fees should not be tracked for canceled authorizations diff --git a/changelog/fix-subscription-urls b/changelog/fix-subscription-urls deleted file mode 100644 index 1f79e52e375..00000000000 --- a/changelog/fix-subscription-urls +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: update -Comment: Updated old docs URLs to new ones. - - diff --git a/changelog/fix-woopay-blocks-field b/changelog/fix-woopay-blocks-field deleted file mode 100644 index 3ddc4e36e48..00000000000 --- a/changelog/fix-woopay-blocks-field +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -WooPay component spacing issues on blocks and classic checkout. diff --git a/changelog/fix-woopmnt-5394-fix-wcpay-subscriptions-toggle-not-saving-when-unchecked b/changelog/fix-woopmnt-5394-fix-wcpay-subscriptions-toggle-not-saving-when-unchecked deleted file mode 100644 index 63e709651b7..00000000000 --- a/changelog/fix-woopmnt-5394-fix-wcpay-subscriptions-toggle-not-saving-when-unchecked +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Fix - WCPay Subscriptions setting not persisting when unchecked diff --git a/changelog/fix-woopmnt-5397-regression-pm-logos-overflow-in-short-code-checkout b/changelog/fix-woopmnt-5397-regression-pm-logos-overflow-in-short-code-checkout deleted file mode 100644 index 44ebe78a541..00000000000 --- a/changelog/fix-woopmnt-5397-regression-pm-logos-overflow-in-short-code-checkout +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Fix payment method logos overflow in shortcode checkout after adding JCB and UnionPay logos. diff --git a/changelog/update-remove-payout-status-icon b/changelog/update-remove-payout-status-icon deleted file mode 100644 index aa0f1309af1..00000000000 --- a/changelog/update-remove-payout-status-icon +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: update -Comment: New Account Details: Remove the payout icon status to be consistent with account status (without icon) - - diff --git a/changelog/update-verify-user-access-when-deleting-test-orders b/changelog/update-verify-user-access-when-deleting-test-orders deleted file mode 100644 index 8c89f6880eb..00000000000 --- a/changelog/update-verify-user-access-when-deleting-test-orders +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: update -Comment: Enforce strict capability checks when deleting test orders. - - diff --git a/changelog/woopmnt-5113-visa-compliance-dispute-requires-attention-to-a-specific-fee b/changelog/woopmnt-5113-visa-compliance-dispute-requires-attention-to-a-specific-fee deleted file mode 100644 index 59736df02aa..00000000000 --- a/changelog/woopmnt-5113-visa-compliance-dispute-requires-attention-to-a-specific-fee +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: add - -Handling of the Visa Compliance disputes with attention to a specific dispute fee. diff --git a/changelog/woopmnt-5245-ideal-payment-method-not-available-for-manual-renewal b/changelog/woopmnt-5245-ideal-payment-method-not-available-for-manual-renewal deleted file mode 100644 index 3314f598c28..00000000000 --- a/changelog/woopmnt-5245-ideal-payment-method-not-available-for-manual-renewal +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: add - -Allow non-reusable payment methods to be used for the manually renewed subscriptions. diff --git a/changelog/woopmnt-5396-woopay-pill-shaped-button-is-cutoff-on-checkout-alt b/changelog/woopmnt-5396-woopay-pill-shaped-button-is-cutoff-on-checkout-alt deleted file mode 100644 index 47f0d0c2bc2..00000000000 --- a/changelog/woopmnt-5396-woopay-pill-shaped-button-is-cutoff-on-checkout-alt +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Fix WooPay express button text clipping diff --git a/changelog/woopmnt-5398-woopay-button-spinner-and-label-is-not-visible-on-click b/changelog/woopmnt-5398-woopay-button-spinner-and-label-is-not-visible-on-click deleted file mode 100644 index bff68f24a29..00000000000 --- a/changelog/woopmnt-5398-woopay-button-spinner-and-label-is-not-visible-on-click +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: fix - -Fix styling of the WooPay button to make sure that the spinner is visible when loading. diff --git a/changelog/woopmnt-5447-backend-update-backend-for-new-challenge-forms b/changelog/woopmnt-5447-backend-update-backend-for-new-challenge-forms deleted file mode 100644 index 5d71a29b10c..00000000000 --- a/changelog/woopmnt-5447-backend-update-backend-for-new-challenge-forms +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: add - -Add backend support for additional dispute evidence types (event, booking, other) behind feature flag. diff --git a/changelog/woopmnt-5449-update-payout-banner-in-the-settings b/changelog/woopmnt-5449-update-payout-banner-in-the-settings deleted file mode 100644 index 2b8228b2969..00000000000 --- a/changelog/woopmnt-5449-update-payout-banner-in-the-settings +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: update - -Change payout texts for New Account Waiting Period to be consistent with new Account Details diff --git a/changelog/woopmnt-5500-php-deprecations-in-the-request-classes b/changelog/woopmnt-5500-php-deprecations-in-the-request-classes deleted file mode 100644 index e7cc35881fc..00000000000 --- a/changelog/woopmnt-5500-php-deprecations-in-the-request-classes +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Fix deprecation warning about usage of `parent` in callables. diff --git a/package-lock.json b/package-lock.json index 766e99861dd..6f0c0ba7aae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "woocommerce-payments", - "version": "10.2.0", + "version": "10.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "woocommerce-payments", - "version": "10.2.0", + "version": "10.3.0", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 7cd84dbd036..70571a81188 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-payments", - "version": "10.2.0", + "version": "10.3.0", "main": "webpack.config.js", "author": "Automattic", "license": "GPL-3.0-or-later", diff --git a/readme.txt b/readme.txt index 629c0698a72..5474ca8d68e 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: woocommerce payments, apple pay, credit card, google pay, payment, payment Requires at least: 6.0 Tested up to: 6.8 Requires PHP: 7.3 -Stable tag: 10.2.0 +Stable tag: 10.3.0 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -87,6 +87,19 @@ You can read our Terms of Service and other policies [here](https://woocommerce. == Changelog == += 10.3.0 - 2025-11-26 = +* Add - Add backend support for additional dispute evidence types (event, booking, other) behind feature flag. +* Add - Allow non-reusable payment methods to be used for the manually renewed subscriptions. +* Add - chore: add amazon pay feature flag. +* Add - Handling of the Visa Compliance disputes with attention to a specific dispute fee. +* Fix - fix: text color of payment method icons on checkout page when a dark background is used +* Fix - Fix deprecation warning about usage of `parent` in callables. +* Fix - Fix styling of the WooPay button to make sure that the spinner is visible when loading. +* Fix - Fix WooPay express button text clipping +* Fix - Refunds and fees should not be tracked for canceled authorizations +* Fix - WooPay component spacing issues on blocks and classic checkout. +* Update - Change payout texts for New Account Waiting Period to be consistent with new Account Details + = 10.2.0 - 2025-11-06 = * Add - Add WooCommerce Tool to delete test orders. * Add - Sync store setup details with the Transact Platform. diff --git a/woocommerce-payments.php b/woocommerce-payments.php index d21d0e8b266..5b43fd8f727 100644 --- a/woocommerce-payments.php +++ b/woocommerce-payments.php @@ -11,7 +11,7 @@ * WC tested up to: 10.3.0 * Requires at least: 6.0 * Requires PHP: 7.3 - * Version: 10.2.0 + * Version: 10.3.0 * Requires Plugins: woocommerce * * @package WooCommerce\Payments From 6c95a2ce995d5ef2203a8b2ba2480ef91ebba185 Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 20 Nov 2025 10:24:21 +0100 Subject: [PATCH 24/32] fix: ECE w/ zero decimals currencies on blocks cart/checkout (#11134) --- changelog/fix-ece-wc-blocks-amounts | 4 + .../components/express-checkout-container.js | 8 +- .../__tests__/use-express-checkout.test.js | 255 ++++++++++++++++++ .../blocks/hooks/use-express-checkout.js | 35 ++- 4 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 changelog/fix-ece-wc-blocks-amounts diff --git a/changelog/fix-ece-wc-blocks-amounts b/changelog/fix-ece-wc-blocks-amounts new file mode 100644 index 00000000000..db5e6f8d922 --- /dev/null +++ b/changelog/fix-ece-wc-blocks-amounts @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +fix: ensuring that the Express Checkout Buttons show the correct amounts for currencies formatted in unusual ways (e.g.: USD with zero decimals) diff --git a/client/express-checkout/blocks/components/express-checkout-container.js b/client/express-checkout/blocks/components/express-checkout-container.js index 400440059f0..f9abeebd65c 100644 --- a/client/express-checkout/blocks/components/express-checkout-container.js +++ b/client/express-checkout/blocks/components/express-checkout-container.js @@ -12,6 +12,7 @@ import { getExpressCheckoutButtonAppearance, getExpressCheckoutData, } from '../../utils'; +import { transformPrice } from '../../transformers/wc-to-stripe'; import '../express-checkout-element.scss'; const ExpressCheckoutContainer = ( props ) => { @@ -24,7 +25,12 @@ const ExpressCheckoutContainer = ( props ) => { const options = { mode: 'payment', paymentMethodCreation: 'manual', - amount: ! isPreview ? billing.cartTotal.value : 10, + // ensuring that the total amount is transformed to the correct format. + amount: ! isPreview + ? transformPrice( billing.cartTotal.value, { + currency_minor_unit: billing.currency.minorUnit ?? 0, + } ) + : 10, currency: ! isPreview ? billing.currency.code.toLowerCase() : 'usd', appearance: getExpressCheckoutButtonAppearance( buttonAttributes ), locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en', diff --git a/client/express-checkout/blocks/hooks/__tests__/use-express-checkout.test.js b/client/express-checkout/blocks/hooks/__tests__/use-express-checkout.test.js index b8f923fd8a0..d96fa8bee7f 100644 --- a/client/express-checkout/blocks/hooks/__tests__/use-express-checkout.test.js +++ b/client/express-checkout/blocks/hooks/__tests__/use-express-checkout.test.js @@ -37,6 +37,9 @@ describe( 'useExpressCheckout', () => { beforeEach( () => { global.$ = jQueryMock; global.jQuery = jQueryMock; + window.wcpayExpressCheckoutParams.checkout = { + currency_decimals: 2, + }; } ); it( 'should provide the line items', () => { @@ -81,6 +84,9 @@ describe( 'useExpressCheckout', () => { label: 'Total', value: 4330, }, + currency: { + minorUnit: 2, + }, }, shippingData: { needsShipping: false, @@ -154,6 +160,9 @@ describe( 'useExpressCheckout', () => { // this scenario happens with the Gift Cards plugin. value: 400, }, + currency: { + minorUnit: 2, + }, }, shippingData: { needsShipping: false, @@ -188,6 +197,9 @@ describe( 'useExpressCheckout', () => { label: 'Total', value: 448, }, + currency: { + minorUnit: 2, + }, }, shippingData: { needsShipping: false, @@ -222,6 +234,9 @@ describe( 'useExpressCheckout', () => { label: 'Total', value: 448, }, + currency: { + minorUnit: 2, + }, }, shippingData: { needsShipping: true, @@ -269,6 +284,9 @@ describe( 'useExpressCheckout', () => { label: 'Total', value: 448, }, + currency: { + minorUnit: 2, + }, }, shippingData: { needsShipping: true, @@ -295,4 +313,241 @@ describe( 'useExpressCheckout', () => { } ) ); } ); + + it( 'should transform amounts correctly with standard 2-decimal currency (USD, EUR)', () => { + const event = { resolve: jest.fn() }; + window.wcpayExpressCheckoutParams.checkout.currency_decimals = 2; + + const { result } = renderHook( () => + useExpressCheckout( { + billing: { + cartTotalItems: [ + { + key: 'total_items', + label: 'Subtotal:', + value: 1000, + valueWithTax: 1000, + }, + { + key: 'total_tax', + label: 'Tax:', + value: 100, + valueWithTax: 100, + }, + ], + cartTotal: { + label: 'Total', + value: 1600, + }, + currency: { + minorUnit: 2, + }, + }, + shippingData: { + needsShipping: true, + shippingRates: [ + { + shipping_rates: [ + { + rate_id: 'flat_rate', + price: '500', + name: 'Flat Rate', + }, + ], + }, + ], + }, + onClick: jest.fn(), + onClose: {}, + setExpressPaymentError: {}, + } ) + ); + + result.current.onButtonClick( event ); + + expect( event.resolve ).toHaveBeenCalledWith( + expect.objectContaining( { + lineItems: [ + { amount: 1000, name: 'Subtotal:' }, + { amount: 100, name: 'Tax:' }, + ], + shippingRates: [ + { + id: 'flat_rate', + displayName: 'Flat Rate', + amount: 500, + }, + ], + } ) + ); + } ); + + it( 'should transform amounts correctly with zero-decimal currency (JPY, KRW)', () => { + const event = { resolve: jest.fn() }; + window.wcpayExpressCheckoutParams.checkout.currency_decimals = 0; + + const { result } = renderHook( () => + useExpressCheckout( { + billing: { + cartTotalItems: [ + { + key: 'total_items', + label: 'Subtotal:', + value: 10, + valueWithTax: 10, + }, + ], + cartTotal: { + label: 'Total', + value: 15, + }, + currency: { + code: 'KRW', + minorUnit: 0, + }, + }, + shippingData: { + needsShipping: true, + shippingRates: [ + { + shipping_rates: [ + { + rate_id: 'flat_rate', + price: '5', + name: 'Flat Rate', + }, + ], + }, + ], + }, + onClick: jest.fn(), + onClose: {}, + setExpressPaymentError: {}, + } ) + ); + + result.current.onButtonClick( event ); + + expect( event.resolve ).toHaveBeenCalledWith( + expect.objectContaining( { + lineItems: [ { amount: 10, name: 'Subtotal:' } ], + shippingRates: [ + { + id: 'flat_rate', + displayName: 'Flat Rate', + amount: 5, + }, + ], + } ) + ); + } ); + + it( 'should transform amounts correctly with USD configured to display zero decimals', () => { + const event = { resolve: jest.fn() }; + // mocking a configured to display USD with 0 decimals - ensuring that Stripe still receives decimals + window.wcpayExpressCheckoutParams.checkout.currency_decimals = 2; + + const { result } = renderHook( () => + useExpressCheckout( { + billing: { + cartTotalItems: [ + { + key: 'total_items', + label: 'Subtotal:', + value: 10, + valueWithTax: 10, + }, + ], + cartTotal: { + label: 'Total', + value: 15, + }, + currency: { + minorUnit: 0, + }, + }, + shippingData: { + needsShipping: true, + shippingRates: [ + { + shipping_rates: [ + { + rate_id: 'flat_rate', + price: '5', + name: 'Flat Rate', + }, + ], + }, + ], + }, + onClick: jest.fn(), + onClose: {}, + setExpressPaymentError: {}, + } ) + ); + + result.current.onButtonClick( event ); + + expect( event.resolve ).toHaveBeenCalledWith( + expect.objectContaining( { + lineItems: [ { amount: 1000, name: 'Subtotal:' } ], + shippingRates: [ + { + id: 'flat_rate', + displayName: 'Flat Rate', + amount: 500, + }, + ], + } ) + ); + } ); + + it( 'should exclude line items when transformed cart total is less than transformed line items total', () => { + const event = { resolve: jest.fn() }; + window.wcpayExpressCheckoutParams.checkout.currency_decimals = 2; + + const { result } = renderHook( () => + useExpressCheckout( { + billing: { + cartTotalItems: [ + { + key: 'total_items', + label: 'Subtotal:', + value: 1000, + valueWithTax: 1000, + }, + { + key: 'total_tax', + label: 'Tax:', + value: 100, + valueWithTax: 100, + }, + ], + cartTotal: { + label: 'Total', + // Cart total is less than sum of line items (rounding error scenario) + value: 1050, + }, + currency: { + minorUnit: 2, + }, + }, + shippingData: { + needsShipping: false, + shippingRates: [], + }, + onClick: jest.fn(), + onClose: {}, + setExpressPaymentError: {}, + } ) + ); + + result.current.onButtonClick( event ); + + expect( event.resolve ).toHaveBeenCalledWith( + expect.objectContaining( { + lineItems: [], + } ) + ); + } ); } ); diff --git a/client/express-checkout/blocks/hooks/use-express-checkout.js b/client/express-checkout/blocks/hooks/use-express-checkout.js index a3f28340342..2e1fdeb0bac 100644 --- a/client/express-checkout/blocks/hooks/use-express-checkout.js +++ b/client/express-checkout/blocks/hooks/use-express-checkout.js @@ -22,6 +22,7 @@ import { onConfirmHandler, onReadyHandler, } from '../../event-handlers'; +import { transformPrice } from '../../transformers/wc-to-stripe'; import { SHIPPING_RATES_UPPER_LIMIT_COUNT } from 'wcpay/express-checkout/constants'; export const useExpressCheckout = ( { @@ -73,7 +74,13 @@ export const useExpressCheckout = ( { .map( ( rate ) => { return { id: rate.rate_id, - amount: parseInt( rate.price, 10 ), + amount: transformPrice( + parseInt( rate.price, 10 ), + { + currency_minor_unit: + billing.currency.minorUnit ?? 0, + } + ), displayName: rate.name, }; } ) @@ -92,25 +99,34 @@ export const useExpressCheckout = ( { } } - const lineItems = normalizeLineItems( billing.cartTotalItems ); - const totalAmountOfLineItems = lineItems.reduce( + const lineItems = normalizeLineItems( billing.cartTotalItems ).map( + ( item ) => ( { + ...item, + // ensuring that the amount is transformed to the correct format expected by Stripe. + amount: transformPrice( item.amount, { + currency_minor_unit: billing.currency.minorUnit ?? 0, + } ), + } ) + ); + const lineItemsTotals = lineItems.reduce( ( acc, lineItem ) => acc + lineItem.amount, 0 ); + const cartTotals = transformPrice( billing.cartTotal.value, { + currency_minor_unit: billing.currency.minorUnit ?? 0, + } ); + const options = { business: { name: getExpressCheckoutData( 'store_name' ), }, - // if the `billing.cartTotal.value` is less than the total of `lineItems`, Stripe throws an error + // if the transformed cart total is less than the total of `lineItems`, Stripe throws an error // it can sometimes happen that the total is _slightly_ less, due to rounding errors on individual items/taxes/shipping // (or with the `woocommerce_tax_round_at_subtotal` setting). // if that happens, let's just not return any of the line items. // This way, just the total amount will be displayed to the customer. - lineItems: - billing.cartTotal.value < totalAmountOfLineItems - ? [] - : lineItems, + lineItems: cartTotals < lineItemsTotals ? [] : lineItems, emailRequired: true, shippingAddressRequired, phoneNumberRequired: @@ -121,6 +137,8 @@ export const useExpressCheckout = ( { .allowed_shipping_countries, }; + // console.log( '### options', options ); + // Click event from WC Blocks. onClick(); // Global click event handler from WooPayments to ECE. @@ -133,6 +151,7 @@ export const useExpressCheckout = ( { billing.cartTotal.value, shippingData.needsShipping, shippingData.shippingRates, + billing.currency.minorUnit, ] ); From fab1428c2bf754efb7885c45537741a14e096832 Mon Sep 17 00:00:00 2001 From: Adam Heckler <5512652+aheckler@users.noreply.github.com> Date: Tue, 25 Nov 2025 08:15:29 -0500 Subject: [PATCH 25/32] Update fees doc links (#11151) Co-authored-by: Miguel Gasca --- changelog/fix-update-fees-doc-links | 4 ++++ client/components/account-balances/__tests__/index.test.tsx | 4 ++-- client/components/account-balances/strings.ts | 2 +- client/components/deposits-overview/deposit-notices.tsx | 2 +- .../utils/__tests__/__snapshots__/account-fees.test.tsx.snap | 4 ++-- client/utils/account-fees.tsx | 4 ++-- includes/class-wc-payments-order-service.php | 2 +- readme.txt | 2 +- 8 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 changelog/fix-update-fees-doc-links diff --git a/changelog/fix-update-fees-doc-links b/changelog/fix-update-fees-doc-links new file mode 100644 index 00000000000..eea33e3d1dd --- /dev/null +++ b/changelog/fix-update-fees-doc-links @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Comment: Update links to the WooPayments fees documentation. diff --git a/client/components/account-balances/__tests__/index.test.tsx b/client/components/account-balances/__tests__/index.test.tsx index 60f8f357006..85a0b254a26 100644 --- a/client/components/account-balances/__tests__/index.test.tsx +++ b/client/components/account-balances/__tests__/index.test.tsx @@ -254,7 +254,7 @@ describe( 'AccountBalances', () => { } ); expect( within( tooltip ).getAllByRole( 'link' )[ 1 ] ).toHaveAttribute( 'href', - 'https://woocommerce.com/document/woopayments/fees-and-debits/account-showing-negative-balance/' + 'https://woocommerce.com/document/woopayments/fees/account-showing-negative-balance/' ); } ); @@ -273,7 +273,7 @@ describe( 'AccountBalances', () => { } ); expect( within( tooltip ).getAllByRole( 'link' )[ 1 ] ).toHaveAttribute( 'href', - 'https://woocommerce.com/document/woopayments/fees-and-debits/account-showing-negative-balance/' + 'https://woocommerce.com/document/woopayments/fees/account-showing-negative-balance/' ); } ); diff --git a/client/components/account-balances/strings.ts b/client/components/account-balances/strings.ts index 0193e4b3cef..a42ec3213a4 100644 --- a/client/components/account-balances/strings.ts +++ b/client/components/account-balances/strings.ts @@ -12,5 +12,5 @@ export const documentationUrls = { depositSchedule: 'https://woocommerce.com/document/woopayments/payouts/payout-schedule/', negativeBalance: - 'https://woocommerce.com/document/woopayments/fees-and-debits/account-showing-negative-balance/', + 'https://woocommerce.com/document/woopayments/fees/account-showing-negative-balance/', }; diff --git a/client/components/deposits-overview/deposit-notices.tsx b/client/components/deposits-overview/deposit-notices.tsx index 6c89e06df5a..5afaf200665 100644 --- a/client/components/deposits-overview/deposit-notices.tsx +++ b/client/components/deposits-overview/deposit-notices.tsx @@ -102,7 +102,7 @@ export const NegativeBalanceDepositsPausedNotice: React.FC = () => ( ), }, diff --git a/client/utils/__tests__/__snapshots__/account-fees.test.tsx.snap b/client/utils/__tests__/__snapshots__/account-fees.test.tsx.snap index 9c8f8ef5c0b..7569b515936 100644 --- a/client/utils/__tests__/__snapshots__/account-fees.test.tsx.snap +++ b/client/utils/__tests__/__snapshots__/account-fees.test.tsx.snap @@ -45,7 +45,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays base @@ -113,7 +113,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays base diff --git a/client/utils/account-fees.tsx b/client/utils/account-fees.tsx index 44e1bdc4d9a..3aedad75c03 100644 --- a/client/utils/account-fees.tsx +++ b/client/utils/account-fees.tsx @@ -19,9 +19,9 @@ import { createInterpolateElement } from '@wordpress/element'; import PAYMENT_METHOD_IDS from 'constants/payment-method'; const countryFeeStripeDocsBaseLink = - 'https://woocommerce.com/document/woopayments/fees-and-debits/fees/#'; + 'https://woocommerce.com/document/woopayments/fees/#'; const countryFeeStripeDocsBaseLinkNoCountry = - 'https://woocommerce.com/document/woopayments/fees-and-debits/fees/'; + 'https://woocommerce.com/document/woopayments/fees/'; const countryFeeStripeDocsSectionNumbers: Record< string, string > = { AE: 'united-arab-emirates', AU: 'australia', diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php index e1be0cc5b9e..e16608a7710 100644 --- a/includes/class-wc-payments-order-service.php +++ b/includes/class-wc-payments-order-service.php @@ -2409,7 +2409,7 @@ private function is_frod_supported( $country_code ) { * @return string */ private function get_frod_support_note( $formatted_amount ) { - $learn_more_url = 'https://woocommerce.com/document/woopayments/fees-and-debits/preventing-negative-balances/#adding-funds'; + $learn_more_url = 'https://woocommerce.com/document/woopayments/fees/preventing-negative-balances/#adding-funds'; return sprintf( WC_Payments_Utils::esc_interpolated_html( /* translators: %s: Formatted refund amount */ diff --git a/readme.txt b/readme.txt index 5474ca8d68e..8fbafdd2085 100644 --- a/readme.txt +++ b/readme.txt @@ -28,7 +28,7 @@ Features previously only available on your payment provider’s website are now **Pay as you go** -WooPayments is **free to install**, with **no setup fees or monthly fees**. Our pay-as-you-go pricing model means we're incentivized to help you succeed! [Read more about transaction fees](https://woocommerce.com/document/woopayments/fees-and-debits/fees/). +WooPayments is **free to install**, with **no setup fees or monthly fees**. Our pay-as-you-go pricing model means we're incentivized to help you succeed! [Read more about transaction fees](https://woocommerce.com/document/woopayments/fees/). **Supported by the WooCommerce team** From ba0364c8596adf201b240375ead7cd3c11cc8828 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 25 Nov 2025 18:42:25 +0100 Subject: [PATCH 26/32] fix: blocks checkout logos with WC beta (#11158) --- changelog/fix-blocks-checkout-logos-in-wc-beta | 4 ++++ client/checkout/blocks/style.scss | 1 + 2 files changed, 5 insertions(+) create mode 100644 changelog/fix-blocks-checkout-logos-in-wc-beta diff --git a/changelog/fix-blocks-checkout-logos-in-wc-beta b/changelog/fix-blocks-checkout-logos-in-wc-beta new file mode 100644 index 00000000000..642f8561d55 --- /dev/null +++ b/changelog/fix-blocks-checkout-logos-in-wc-beta @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +fix: payment method logos compatibility with WooCommerce Blocks in WC>=10.4 diff --git a/client/checkout/blocks/style.scss b/client/checkout/blocks/style.scss index dfb310833c9..3d42bc82da5 100644 --- a/client/checkout/blocks/style.scss +++ b/client/checkout/blocks/style.scss @@ -72,6 +72,7 @@ button.wcpay-stripelink-modal-trigger:hover { > .payment-methods--logos { grid-area: logos; + height: 24px; justify-self: end; } From e3dde0b4196544a7b7784f20484392e0abbdef77 Mon Sep 17 00:00:00 2001 From: Cvetan Cvetanov Date: Tue, 25 Nov 2025 20:10:56 +0200 Subject: [PATCH 27/32] =?UTF-8?q?Fix=20Incorrect=20Reference=20to=20?= =?UTF-8?q?=E2=80=9CWooPayments=20Mobile=20Application=E2=80=9D=20on=20Car?= =?UTF-8?q?d=20Readers=20Page=20(#11157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- changelog/fix-WOOPMNT-5517-mobile-app-reference | 4 ++++ client/card-readers/list/index.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog/fix-WOOPMNT-5517-mobile-app-reference diff --git a/changelog/fix-WOOPMNT-5517-mobile-app-reference b/changelog/fix-WOOPMNT-5517-mobile-app-reference new file mode 100644 index 00000000000..163871c27e5 --- /dev/null +++ b/changelog/fix-WOOPMNT-5517-mobile-app-reference @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Update “WooPayments” to “WooCommerce” mobile application in the Card Readers page. diff --git a/client/card-readers/list/index.tsx b/client/card-readers/list/index.tsx index 7cec0a34dd0..c14726bc2e8 100644 --- a/client/card-readers/list/index.tsx +++ b/client/card-readers/list/index.tsx @@ -26,7 +26,7 @@ const ReadersListDescription = () => ( 'To connect or disconnect card readers, use the %s mobile application.', 'woocommerce-payments' ), - 'WooPayments' + 'WooCommerce' ) }

    From 55513636478dfbe90e7af42813707d716c595e31 Mon Sep 17 00:00:00 2001 From: Francesco Date: Wed, 26 Nov 2025 11:23:35 +0100 Subject: [PATCH 28/32] chore: update fees docs URL (#11156) --- changelog/chore-update-account-fees-docs-url | 4 +++ client/utils/account-fees.tsx | 26 +++++--------------- 2 files changed, 10 insertions(+), 20 deletions(-) create mode 100644 changelog/chore-update-account-fees-docs-url diff --git a/changelog/chore-update-account-fees-docs-url b/changelog/chore-update-account-fees-docs-url new file mode 100644 index 00000000000..dd6176611c2 --- /dev/null +++ b/changelog/chore-update-account-fees-docs-url @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +chore: update account fees docs URL diff --git a/client/utils/account-fees.tsx b/client/utils/account-fees.tsx index 3aedad75c03..44b6c2c2609 100644 --- a/client/utils/account-fees.tsx +++ b/client/utils/account-fees.tsx @@ -19,8 +19,6 @@ import { createInterpolateElement } from '@wordpress/element'; import PAYMENT_METHOD_IDS from 'constants/payment-method'; const countryFeeStripeDocsBaseLink = - 'https://woocommerce.com/document/woopayments/fees/#'; -const countryFeeStripeDocsBaseLinkNoCountry = 'https://woocommerce.com/document/woopayments/fees/'; const countryFeeStripeDocsSectionNumbers: Record< string, string > = { AE: 'united-arab-emirates', @@ -63,16 +61,8 @@ const countryFeeStripeDocsSectionNumbers: Record< string, string > = { RO: 'romania', }; -const stripeFeeSectionExistsForCountry = ( country: string ): boolean => { - return countryFeeStripeDocsSectionNumbers.hasOwnProperty( country ); -}; - const getStripeFeeSectionUrl = ( country: string ): string => { - return sprintf( - '%s%s', - countryFeeStripeDocsBaseLink, - countryFeeStripeDocsSectionNumbers[ country ] - ); + return `${ countryFeeStripeDocsBaseLink }#${ countryFeeStripeDocsSectionNumbers[ country ] }`; }; const getFeeDescriptionString = ( @@ -195,31 +185,27 @@ export const formatMethodFeesTooltip = ( wcpaySettings.connect.country ? (
    - { stripeFeeSectionExistsForCountry( + { countryFeeStripeDocsSectionNumbers.hasOwnProperty( wcpaySettings.connect.country ) ? interpolateComponents( { mixedString: sprintf( /* translators: %s: WooPayments */ __( - '{{linkToStripePage /}} about %s Fees in your country', + '{{linkToStripePage}}Learn more{{/linkToStripePage}} about %s Fees in your country', 'woocommerce-payments' ), 'WooPayments' ), components: { linkToStripePage: ( + // @ts-expect-error: children is provided when interpolating the component - { __( - 'Learn more', - 'woocommerce-payments' - ) } - + /> ), }, } ) @@ -236,7 +222,7 @@ export const formatMethodFeesTooltip = ( linkToStripePage: ( { __( From 28f671c765bd7c62c1308a64cdcb6207f6cf88d3 Mon Sep 17 00:00:00 2001 From: Miguel Gasca Date: Fri, 5 Dec 2025 13:15:04 +0100 Subject: [PATCH 29/32] Cherry-pick: Fix Woo 10.4.0-beta compatibility (#11159) (#11185) --- changelog/fix-wc-10-4-compatibility | 5 ++++ .../test-class-wc-payments-order-service.php | 30 +++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 changelog/fix-wc-10-4-compatibility diff --git a/changelog/fix-wc-10-4-compatibility b/changelog/fix-wc-10-4-compatibility new file mode 100644 index 00000000000..84d57dbaf6f --- /dev/null +++ b/changelog/fix-wc-10-4-compatibility @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: This change is only applicable to unit tests, and those are not distributed with the plugin, hence there are no actual changes. + + diff --git a/tests/unit/test-class-wc-payments-order-service.php b/tests/unit/test-class-wc-payments-order-service.php index 6479dd930d9..cbe4e457375 100644 --- a/tests/unit/test-class-wc-payments-order-service.php +++ b/tests/unit/test-class-wc-payments-order-service.php @@ -40,6 +40,10 @@ public function set_up() { $this->order_service = new WC_Payments_Order_Service( $this->createMock( WC_Payments_API_Client::class ) ); $this->order = WC_Helper_Order::create_order(); + + $gateways = WC()->payment_gateways->payment_gateways(); + $this->order->set_payment_method( $gateways[ WC_Payment_Gateway_WCPay::GATEWAY_ID ] ); + $this->order->save(); } /** @@ -111,7 +115,7 @@ public function test_order_status_not_updated_if_order_locked() { * * @dataProvider mark_payment_completed_provider */ - public function test_mark_payment_completed( $order_status, $intent_args, $expected_note_1, $expected_fraud_outcome, $expected_fraud_meta_box ) { + public function test_mark_payment_completed( $order_status, $intent_args, $expected_note_old, $expected_note_new, $expected_fraud_outcome, $expected_fraud_meta_box ) { // Arrange: Create intention with proper outcome status, update order status if needed. $intent = WC_Helper_Intention::create_intention( $intent_args ); if ( $order_status ) { @@ -134,8 +138,9 @@ public function test_mark_payment_completed( $order_status, $intent_args, $expec $this->assertTrue( $this->order->has_status( wc_get_is_paid_statuses() ) ); // Assert: Check that the notes were updated. - $notes = wc_get_order_notes( [ 'order_id' => $this->order->get_id() ] ); - $this->assertStringContainsString( $expected_note_1, $notes[1]->content ); + $notes = wc_get_order_notes( [ 'order_id' => $this->order->get_id() ] ); + $expected_note = version_compare( constant( 'WC_VERSION' ), '10.4', '>=' ) ? $expected_note_new : $expected_note_old; + $this->assertStringContainsString( $expected_note, $notes[1]->content ); $this->assertStringContainsString( 'successfully charged using WooPayments', $notes[0]->content ); $this->assertStringContainsString( '/payments/transactions/details&id=pi_mock" target="_blank" rel="noopener noreferrer">pi_mock', $notes[0]->content ); @@ -153,7 +158,8 @@ public function mark_payment_completed_provider() { 'mark_complete_no_fraud_outcome_no_pmtype' => [ 'order_status' => false, 'intent_args' => [], - 'expected_note_1' => 'Pending payment to Processing', + 'expected_note_old' => 'Pending payment to Processing', + 'expected_note_new' => 'Payment via Card (pi_mock)', 'expected_fraud_outcome' => false, 'expected_fraud_meta_box' => Fraud_Meta_Box_Type::NOT_CARD, ], @@ -162,7 +168,8 @@ public function mark_payment_completed_provider() { 'intent_args' => [ 'payment_method_options' => [ 'card' => [ 'request_three_d_secure' => 'automatic' ] ], ], - 'expected_note_1' => 'Pending payment to Processing', + 'expected_note_old' => 'Pending payment to Processing', + 'expected_note_new' => 'Payment via Card (pi_mock)', 'expected_fraud_outcome' => false, 'expected_fraud_meta_box' => false, ], @@ -174,7 +181,8 @@ public function mark_payment_completed_provider() { ], 'payment_method_options' => [ 'card' => [ 'request_three_d_secure' => 'automatic' ] ], ], - 'expected_note_1' => 'Pending payment to Processing', + 'expected_note_old' => 'Pending payment to Processing', + 'expected_note_new' => 'Payment via Card (pi_mock)', 'expected_fraud_outcome' => Rule::FRAUD_OUTCOME_ALLOW, 'expected_fraud_meta_box' => Fraud_Meta_Box_Type::ALLOW, ], @@ -186,7 +194,8 @@ public function mark_payment_completed_provider() { ], 'payment_method_options' => [ 'card' => [ 'request_three_d_secure' => 'automatic' ] ], ], - 'expected_note_1' => 'On hold to Processing', + 'expected_note_old' => 'On hold to Processing', + 'expected_note_new' => 'Payment via Card (pi_mock)', 'expected_fraud_outcome' => Rule::FRAUD_OUTCOME_ALLOW, 'expected_fraud_meta_box' => Fraud_Meta_Box_Type::REVIEW_ALLOWED, ], @@ -224,8 +233,11 @@ public function test_mark_payment_capture_completed( $intent_args, $order_fraud_ $this->assertTrue( $this->order->has_status( wc_get_is_paid_statuses() ) ); // Assert: Check that the notes were updated. - $notes = wc_get_order_notes( [ 'order_id' => $this->order->get_id() ] ); - $this->assertStringContainsString( 'On hold to Processing', $notes[1]->content ); + $notes = wc_get_order_notes( [ 'order_id' => $this->order->get_id() ] ); + $expected_note = version_compare( constant( 'WC_VERSION' ), '10.4', '>=' ) + ? 'Payment via Card (pi_mock).' + : 'On hold to Processing'; + $this->assertStringContainsString( $expected_note, $notes[1]->content ); $this->assertStringContainsString( 'successfully captured using WooPayments', $notes[0]->content ); $this->assertStringContainsString( '/payments/transactions/details&id=pi_mock" target="_blank" rel="noopener noreferrer">pi_mock', $notes[0]->content ); From 1bca2ffcfef756919f9345c7004e8cc4589cc0c0 Mon Sep 17 00:00:00 2001 From: Daniel Mallory Date: Fri, 5 Dec 2025 14:14:23 +0000 Subject: [PATCH 30/32] Cherry-pick: Add payment method promotions (#11163) (#11183) Co-authored-by: Vlad Olaru Co-authored-by: Claude Co-authored-by: Cvetan Cvetanov Co-authored-by: Miguel Gasca --- .claude/CLAUDE.md | 2 + .claude/PM_PROMOTIONS.md | 536 ++++++ .gitignore | 1 + .../klarna-promotion-spotlight.svg | 29 + changelog/add-payment-method-promotions | 4 + .../__snapshots__/index.test.tsx.snap | 77 + .../__tests__/index.test.tsx | 282 ++++ client/components/promotional-badge/index.tsx | 81 + .../components/promotional-badge/style.scss | 21 + .../spotlight/__tests__/index.test.tsx | 489 ++++++ client/components/spotlight/index.tsx | 368 +++++ client/components/spotlight/style.scss | 187 +++ client/components/spotlight/types.ts | 85 + client/data/index.ts | 1 + client/data/pm-promotions/action-types.ts | 6 + client/data/pm-promotions/actions.ts | 150 ++ client/data/pm-promotions/hooks.ts | 46 + client/data/pm-promotions/index.ts | 12 + client/data/pm-promotions/reducer.ts | 36 + client/data/pm-promotions/resolvers.ts | 95 ++ client/data/pm-promotions/selectors.ts | 34 + client/data/pm-promotions/types.d.ts | 82 + client/data/store.js | 5 + client/deposits/index.tsx | 5 + client/disputes/__tests__/index.test.tsx | 7 + client/disputes/index.tsx | 5 + client/documents/index.tsx | 5 + client/overview/__tests__/index.test.js | 7 + client/overview/index.js | 4 + .../spotlight/__tests__/index.test.tsx | 330 ++++ client/promotions/spotlight/index.tsx | 156 ++ .../buy-now-pay-later-section.test.js | 3 + .../payment-request-button-preview.test.js | 1 + .../__tests__/payment-method.test.js | 481 ++++++ .../payment-methods-list/payment-method.tsx | 70 +- .../__tests__/payment-methods-section.test.js | 3 + client/settings/settings-manager/index.js | 4 + client/stylesheets/abstracts/_variables.scss | 5 + client/tracks/event.d.ts | 5 + client/transactions/__tests__/index.test.tsx | 7 + client/transactions/index.tsx | 5 + client/utils/__tests__/account-fees.test.tsx | 203 +++ client/utils/account-fees.tsx | 75 + client/wc-payments-settings-spotlight.js | 36 + composer.json | 3 +- composer.lock | 21 +- includes/admin/class-wc-payments-admin.php | 125 +- ...rest-payments-pm-promotions-controller.php | 147 ++ ...s-wc-rest-payments-settings-controller.php | 30 +- .../class-wc-payments-incentives-service.php | 2 +- ...lass-wc-payments-pm-promotions-service.php | 1050 ++++++++++++ includes/class-wc-payments.php | 19 +- includes/constants/class-track-events.php | 7 +- includes/core/server/class-request.php | 1 + .../request/class-activate-pm-promotion.md | 34 + .../request/class-activate-pm-promotion.php | 54 + .../server/request/class-get-pm-promotions.md | 57 + .../request/class-get-pm-promotions.php | 75 + .../class-wc-payments-api-client.php | 1 + tests/js/jest.config.js | 6 + .../admin/test-class-wc-payments-admin.php | 105 +- ...s-pm-promotions-controller-integration.php | 587 +++++++ ...rest-payments-pm-promotions-controller.php | 116 ++ ...s-wc-rest-payments-settings-controller.php | 58 +- tests/unit/bootstrap.php | 1 + ...lass-wc-payments-pm-promotions-service.php | 1440 +++++++++++++++++ webpack/shared.js | 2 + 67 files changed, 7943 insertions(+), 44 deletions(-) create mode 100644 .claude/PM_PROMOTIONS.md create mode 100644 assets/images/illustrations/klarna-promotion-spotlight.svg create mode 100644 changelog/add-payment-method-promotions create mode 100644 client/components/promotional-badge/__tests__/__snapshots__/index.test.tsx.snap create mode 100644 client/components/promotional-badge/__tests__/index.test.tsx create mode 100644 client/components/promotional-badge/index.tsx create mode 100644 client/components/promotional-badge/style.scss create mode 100644 client/components/spotlight/__tests__/index.test.tsx create mode 100644 client/components/spotlight/index.tsx create mode 100644 client/components/spotlight/style.scss create mode 100644 client/components/spotlight/types.ts create mode 100644 client/data/pm-promotions/action-types.ts create mode 100644 client/data/pm-promotions/actions.ts create mode 100644 client/data/pm-promotions/hooks.ts create mode 100644 client/data/pm-promotions/index.ts create mode 100644 client/data/pm-promotions/reducer.ts create mode 100644 client/data/pm-promotions/resolvers.ts create mode 100644 client/data/pm-promotions/selectors.ts create mode 100644 client/data/pm-promotions/types.d.ts create mode 100644 client/promotions/spotlight/__tests__/index.test.tsx create mode 100644 client/promotions/spotlight/index.tsx create mode 100644 client/wc-payments-settings-spotlight.js create mode 100644 includes/admin/class-wc-rest-payments-pm-promotions-controller.php create mode 100644 includes/class-wc-payments-pm-promotions-service.php create mode 100644 includes/core/server/request/class-activate-pm-promotion.md create mode 100644 includes/core/server/request/class-activate-pm-promotion.php create mode 100644 includes/core/server/request/class-get-pm-promotions.md create mode 100644 includes/core/server/request/class-get-pm-promotions.php create mode 100644 tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller-integration.php create mode 100644 tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller.php create mode 100644 tests/unit/test-class-wc-payments-pm-promotions-service.php diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index f908184eeed..0d68a71e753 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -186,3 +186,5 @@ npm run i18n:pot # Generate translations - Test files mirror source structure - PHP tests require Docker - ensure it's running before executing tests - Use `npm run test:php` to run all tests or edit the command to pass PHPUnit filters +- When pushing, always push only the current branch: `git push origin HEAD` (not `git push` which tries to push all configured branches) +- When pulling, always pull only the current branch: `git pull origin $(git branch --show-current)` or `git pull --rebase origin HEAD` diff --git a/.claude/PM_PROMOTIONS.md b/.claude/PM_PROMOTIONS.md new file mode 100644 index 00000000000..f1c6ab80aa7 --- /dev/null +++ b/.claude/PM_PROMOTIONS.md @@ -0,0 +1,536 @@ +# Payment Method (PM) Promotions + +## Overview + +PM Promotions display promotional offers for payment methods that merchants haven't yet enabled. The system uses a **flat data structure** where each promotion is a standalone object with a `type` field indicating its display context (spotlight, badge). + +## Data Flow + +``` +Transact Platform API → WC_Payments_PM_Promotions_Service → REST API → Redux Store → Components + ↓ + validate → filter → normalize +``` + +**Server-side (backend) responsibilities:** +- Fetch promotions from WooPayments API (with store context) +- Validate promotion structure +- Filter by: dismissals, PM validity, enabled status, **active discounts** +- Normalize data (apply fallbacks, derive titles) +- Cache results with context-aware invalidation + +**Client-side (frontend) responsibilities:** +- Validate promotion structure (type guards, defense in depth) +- Filter dismissed promotions (defense in depth) +- Render appropriate UI based on `type` +- Track analytics events + +## Data Structures + +### Promotion (TypeScript) + +```typescript +// client/data/pm-promotions/types.d.ts + +type PmPromotionType = 'spotlight' | 'badge'; + +interface PmPromotion { + id: string; // Globally unique promotion variation ID (e.g., "campaign-name-promo__spotlight__blabla"). A campaign can have multiple variations. + promo_id: string; // Campaign identifier e.g., "campaign-name-promo" + payment_method: string; // PM ID from Payment_Method constants e.g., "klarna" + payment_method_title: string; // Human-readable payment method title e.g., "Klarna" + type: PmPromotionType; // Display context: 'spotlight' | 'badge' + title: string; // Promotion headline + badge_text?: string; // Optional badge text (for spotlight type) + badge_type?: ChipType; // Optional badge visual style + description: string; // Promotion body text + cta_label: string; // Primary button text (fallback: "Enable {payment_method_title}") + tc_url: string; // Terms & conditions URL (required) + tc_label: string; // Terms link text (fallback: "See terms") + footnote?: string; // Optional footnote text to be displayed below main content + image?: string; // Optional image URL (mostly for spotlight type) +} +``` + +### Redux State + +```typescript +interface PmPromotionsState { + pmPromotions?: PmPromotion[]; + pmPromotionsError?: ApiError; +} +``` + +### Dismissals (Server-side storage) + +```php +// Flat structure: [id => timestamp] +[ + 'klarna-2026-promo__spotlight' => 1733123456, + 'klarna-2026-promo__badge' => 1733123789, +] +``` + +## Key Files + +### Client (Frontend) + +| File | Purpose | +|------|---------| +| `client/data/pm-promotions/types.d.ts` | TypeScript interfaces | +| `client/data/pm-promotions/hooks.ts` | `usePmPromotions`, `usePmPromotionActions` hooks | +| `client/data/pm-promotions/selectors.ts` | Redux selectors (`getPmPromotions`, `getPmPromotionsError`) | +| `client/data/pm-promotions/actions.ts` | `activatePmPromotion`, `dismissPmPromotion` | +| `client/data/pm-promotions/resolvers.ts` | API fetch with type guards | +| `client/promotions/spotlight/index.tsx` | Spotlight promotion component | +| `client/components/promotional-badge/index.tsx` | Badge promotion component with T&C tooltip | +| `client/settings/payment-methods-list/payment-method.tsx` | PM settings item (uses PromotionalBadge) | + +### Server (Backend) + +| File | Purpose | +|------|---------| +| `includes/class-wc-payments-pm-promotions-service.php` | Main service: fetch, filter, normalize promotions | +| `includes/admin/class-wc-rest-payments-pm-promotions-controller.php` | REST API controller | +| `tests/unit/admin/test-class-wc-payments-pm-promotions-service.php` | Service unit tests | + +## Hooks API + +### usePmPromotions + +```typescript +const { pmPromotions, isLoading, pmPromotionsError } = usePmPromotions(); +// Returns: { pmPromotions: PmPromotion[], isLoading: boolean, pmPromotionsError?: ApiError } +``` + +### usePmPromotionActions + +```typescript +const { activatePmPromotion, dismissPmPromotion } = usePmPromotionActions(); + +// Activate a promotion (enables the payment method) +activatePmPromotion(id: string); // e.g., "klarna-2026-promo__spotlight" + +// Dismiss a promotion +dismissPmPromotion(id: string); // e.g., "klarna-2026-promo__spotlight" +``` + +## Selectors + +```typescript +// Get all promotions +getPmPromotions(state): PmPromotion[] + +// Get promotions error +getPmPromotionsError(state): ApiError | undefined +``` + +## Component Implementation Pattern + +### SpotlightPromotion Example + +```tsx +// client/promotions/spotlight/index.tsx + +const SpotlightPromotion: React.FC = () => { + const { pmPromotions, isLoading } = usePmPromotions(); + const { activatePmPromotion, dismissPmPromotion } = usePmPromotionActions(); + + // Don't render if data is still loading + if (isLoading) return null; + + // Don't render if no promotions available + if (!pmPromotions || pmPromotions.length === 0) return null; + + // Find spotlight promotion + const spotlightPromotion = pmPromotions.find(p => p.type === 'spotlight'); + if (!spotlightPromotion) return null; + + // Common event properties for tracking + const getEventProperties = () => ({ + promo_id: spotlightPromotion.promo_id, + payment_method: spotlightPromotion.payment_method, + display_context: 'spotlight', + source: getPageSource(), // Helper that returns page identifier + path: window.location.pathname + window.location.search, + }); + + // Track when promotion becomes visible + const handleView = () => { + recordEvent('wcpay_payment_method_promotion_view', getEventProperties()); + }; + + // Activate promotion and enable payment method + const handlePrimaryClick = () => { + recordEvent('wcpay_payment_method_promotion_activate_click', getEventProperties()); + activatePmPromotion(spotlightPromotion.id); + }; + + // Open terms and conditions link + const handleSecondaryClick = () => { + recordEvent('wcpay_payment_method_promotion_link_click', { + ...getEventProperties(), + link_type: 'terms', + }); + if (spotlightPromotion.tc_url) { + window.open(spotlightPromotion.tc_url, '_blank', 'noopener,noreferrer'); + } + }; + + // Dismiss the promotion + const handleDismiss = () => { + recordEvent('wcpay_payment_method_promotion_dismiss_click', getEventProperties()); + dismissPmPromotion(spotlightPromotion.id); + }; + + return ( + + ); +}; +``` + +## Analytics Events + +All events include base properties: + +```typescript +{ + promo_id: string, // promo_id + payment_method: string, // payment_method + display_context: string, // 'spotlight' | 'badge' + source: string, // page identifier + path: string, // window.location.pathname + search +} +``` + +| Event | Trigger | +|-------|---------| +| `wcpay_payment_method_promotion_view` | Promotion becomes visible | +| `wcpay_payment_method_promotion_activate_click` | Primary CTA clicked | +| `wcpay_payment_method_promotion_link_click` | Terms link clicked (+ `link_type: 'terms'`) | +| `wcpay_payment_method_promotion_dismiss_click` | Close/dismiss clicked | + +## REST API Endpoints + +### GET /wc/v3/payments/pm-promotions + +Returns array of visible promotions (already filtered server-side). + +### POST /wc/v3/payments/pm-promotions/{id}/activate + +Activates a promotion (enables the payment method). + +**URL Parameter:** +- `id`: The promotion unique identifier (e.g., `klarna-2026-promo__spotlight`) + +### POST /wc/v3/payments/pm-promotions/{id}/dismiss + +Dismisses a promotion. + +**URL Parameter:** +- `id`: The promotion unique identifier (e.g., `klarna-2026-promo__spotlight`) + +## Type Guards (Validation) + +The resolver validates API responses: + +```typescript +function isPmPromotion(value: unknown): value is PmPromotion { + if (typeof value !== 'object' || value === null) return false; + const obj = value as Record; + return ( + typeof obj.id === 'string' && + typeof obj.promo_id === 'string' && + typeof obj.payment_method === 'string' && + typeof obj.payment_method_title === 'string' && + typeof obj.type === 'string' && + (obj.type === 'spotlight' || obj.type === 'badge') && + typeof obj.title === 'string' && + typeof obj.description === 'string' && + typeof obj.cta_label === 'string' && + typeof obj.tc_url === 'string' && + typeof obj.tc_label === 'string' + ); +} +``` + +## Important Implementation Notes + +1. **ID vs promo_id**: The `id` is the unique identifier across the system for a certain promotion instance intended for display (spotlight, badge, modal, banner, etc.) - aka promotion variation.`promo_id` is the unique promotion campaign identifier (can have multiple variations) - it is mostly used for attaching to tracking events. +2. **Type filtering**: Each component filters for its own `type` ('spotlight', 'badge') +3. **No variations**: The client receives flat promotions - no nested structures +4. **Server derives titles**: `payment_method_title` comes from server, not client lookup +5. **Fallbacks applied server-side**: `cta_label` and `tc_label` have server-side defaults +6. **Image is optional**: Don't display image section if `image` is empty/undefined +7. **Badge fields**: `badge_text` and `badge_type` are optional and primarily used for spotlight type + +## Testing + +### Mock Promotion Data + +```typescript +const mockPromotion: PmPromotion = { + id: 'klarna-promo__spotlight', + promo_id: 'klarna-promo', + payment_method: 'klarna', + payment_method_title: 'Klarna', + type: 'spotlight', + title: 'Zero Processing Fees for 90 Days', + badge_text: 'Limited Time', + badge_type: 'success', + description: 'Save on every Klarna transaction.', + cta_label: 'Enable Klarna', + tc_url: 'https://example.com/terms', + tc_label: 'See terms', + footnote: '*Offer valid for new activations only.', + image: 'https://example.com/image.png', +}; +``` + +### Test Files + +- `client/promotions/spotlight/__tests__/index.test.tsx` +- `client/components/promotional-badge/__tests__/index.test.tsx` +- `client/data/pm-promotions/__tests__/*.test.ts` +- `tests/unit/test-class-wc-payments-pm-promotions-service.php` +- `tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller.php` +- `tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller-integration.php` + +### Test Mock Setup + +When testing components that use PM promotions, add the following mock to your test file: + +```typescript +jest.mock('wcpay/data', () => ({ + // ... other mocks + usePmPromotions: jest.fn().mockReturnValue({ + pmPromotions: [], + isLoading: false, + }), + usePmPromotionActions: jest.fn().mockReturnValue({ + activatePmPromotion: jest.fn(), + dismissPmPromotion: jest.fn(), + }), +})); +``` + +--- + +## Server-Side Implementation + +### WC_Payments_PM_Promotions_Service + +The main service class handles fetching, filtering, and normalizing promotions. + +#### Dependencies + +```php +// Constructor accepts optional dependencies for testing +public function __construct( $gateway = null, $account = null ) { + $this->gateway = $gateway; // WC_Payment_Gateway_WCPay + $this->account = $account; // WC_Payments_Account +} +``` + +If not provided, dependencies are lazily resolved via `WC_Payments::get_gateway()` and `WC_Payments::get_account_service()`. + +#### Key Methods + +| Method | Purpose | +|--------|---------| +| `get_visible_promotions()` | Main entry point - returns filtered, normalized promotions | +| `activate_promotion($identifier)` | Activate a promotion (enable PM) | +| `dismiss_promotion($id)` | Dismiss a promotion | +| `clear_cache()` | Clear the promotions transient cache | + +#### Filtering Logic (`filter_promotions`) + +Promotions are filtered out if: +1. **Invalid PM**: Payment method not in `get_upe_available_payment_methods()` +2. **Already enabled**: Payment method already in `get_upe_enabled_payment_method_ids()` +3. **Has active discount**: Payment method has an existing discount (see below) +4. **Different promo_id**: Only the first `promo_id` per payment method is kept + +```php +private function filter_promotions( array $promotions ): array { + foreach ( $promotions as $promotion ) { + // Skip invalid PMs + if ( ! $this->is_valid_payment_method( $pm_id ) ) continue; + + // Skip already enabled PMs + if ( in_array( $pm_id, $enabled_pms, true ) ) continue; + + // Skip PMs with active discounts + if ( $this->payment_method_has_discount( $pm_id ) ) continue; + + // Keep only first promo_id per PM + // ... + } +} +``` + +#### Caching + +- Cache key: `wcpay_pm_promotions` (transient) +- Cache invalidation: Context hash based on dismissals + locale +- Error caching: API errors cached for 6 hours to prevent hammering + +--- + +## Account Fees & Discount Detection + +### Fee Structure + +Account fees are retrieved via `WC_Payments_Account::get_fees()` and indexed by payment method ID: + +```php +$fees = [ + 'klarna' => [ + 'base' => [ + 'percentage_rate' => 0.029, + 'fixed_rate' => 30, + 'currency' => 'usd', + ], + 'discount' => [ + [ + 'discount' => 50, // Percentage off (50 = 50% off) + 'end_time' => 1735689600, // Unix timestamp + 'volume_currency' => 'usd', + 'volume_allowance' => 100000, // Optional: volume limit in cents + ], + ], + ], + 'card' => [ + 'base' => [ /* ... */ ], + // No 'discount' key = no active discount + ], +]; +``` + +### Discount Detection + +A payment method has an active discount if: +1. `fees[pm_id]['discount']` exists and is an array +2. `fees[pm_id]['discount'][0]['discount']` is non-empty (truthy) + +```php +private function payment_method_has_discount( string $payment_method_id ): bool { + $fees = $this->get_account_fees(); + + if ( empty( $fees[ $payment_method_id ] ) ) { + return false; + } + + $pm_fees = $fees[ $payment_method_id ]; + + if ( ! empty( $pm_fees['discount'] ) && is_array( $pm_fees['discount'] ) ) { + $first_discount = $pm_fees['discount'][0] ?? []; + return ! empty( $first_discount['discount'] ); + } + + return false; +} +``` + +**Important**: Promotions are filtered out for payment methods with active discounts to prevent showing promotional offers when the merchant already has a discount applied. + +--- + +## PromotionalBadge Component + +The `PromotionalBadge` component displays badge-type promotions in the payment methods settings list. + +### Props + +```typescript +interface PromotionalBadgeProps { + message: string; // Badge text (e.g., "Zero fees for 90 days") + tooltip: string; // Tooltip content + type?: ChipType; // Visual style (default: 'success') + tooltipLabel?: string; // Accessible label for tooltip button + tcUrl?: string; // Optional T&C URL - appends link to tooltip + tcLabel?: string; // Optional T&C link text (fallback: "See terms") +} +``` + +### T&C Link Behavior + +When `tcUrl` is provided: +1. A link is appended to the tooltip content +2. If `tcLabel` is provided and non-empty, it's used as the link text +3. Otherwise, falls back to "See terms" +4. Link opens in new tab with `rel="noopener noreferrer"` + +```tsx +// Use backend-provided tc_label when available, otherwise fall back to default. +const tcLinkLabel = tcLabel || __( 'See terms', 'woocommerce-payments' ); + +// Build tooltip content with optional T&C link. +const tooltipContent = tcUrl ? ( + <> + { tooltip }{ ' ' } + + { tcLinkLabel } + + +) : ( + tooltip +); +``` + +### Usage in PaymentMethod Component + +The `PaymentMethod` component (`payment-method.tsx`) shows badges for: +1. **Active discounts** from account fees (via `accountFees[id].discount`) +2. **Badge-type promotions** from the promotions API + +```tsx +// Get badge-type promotion for this payment method. +const { pmPromotions = [] } = usePmPromotions(); +const badgePromotion = pmPromotions?.find( + ( promo ) => promo.payment_method === id && promo.type === 'badge' +); + +// Priority: active discount > badge promotion +const showPromotionalBadge = hasDiscount || badgePromotion; + +// Get badge content from appropriate source +if ( hasDiscount ) { + return { + message: getDiscountBadgeText( discountFee ), + tooltip: getDiscountTooltipText( discountFee ), + tooltipLabel: __( 'Discount details', 'woocommerce-payments' ), + }; +} +if ( badgePromotion ) { + return { + message: badgePromotion.title, + tooltip: badgePromotion.description, + tooltipLabel: __( 'Promotion details', 'woocommerce-payments' ), + tcUrl: badgePromotion.tc_url, + tcLabel: badgePromotion.tc_label, + }; +} +``` + +--- + +## Storage Keys + +| Option/Transient | Purpose | +|------------------|---------| +| `wcpay_pm_promotions` | Transient cache for promotions | +| `_wcpay_pm_promotion_dismissals` | Option: [id => timestamp] | diff --git a/.gitignore b/.gitignore index b6405d4e0ac..57580860fa5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ project.properties .sublimelinterrc .cursor/ **/.claude/**/*.local.* +.claude/local/ .zed/ .phpactor.json diff --git a/assets/images/illustrations/klarna-promotion-spotlight.svg b/assets/images/illustrations/klarna-promotion-spotlight.svg new file mode 100644 index 00000000000..aee406c965c --- /dev/null +++ b/assets/images/illustrations/klarna-promotion-spotlight.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/changelog/add-payment-method-promotions b/changelog/add-payment-method-promotions new file mode 100644 index 00000000000..ea3c9dbccd9 --- /dev/null +++ b/changelog/add-payment-method-promotions @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding ability to support payment method promotional campaigns (spotlight and badge treatments). diff --git a/client/components/promotional-badge/__tests__/__snapshots__/index.test.tsx.snap b/client/components/promotional-badge/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..66e89e81e4d --- /dev/null +++ b/client/components/promotional-badge/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,77 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PromotionalBadge renders correctly with all props 1`] = ` +
    + + 30% off fees through Dec 31, 2026 + + +
    +`; + +exports[`PromotionalBadge renders correctly with all props including T&C 1`] = ` +
    + + 30% off fees through Dec 31, 2026 + + +
    +`; diff --git a/client/components/promotional-badge/__tests__/index.test.tsx b/client/components/promotional-badge/__tests__/index.test.tsx new file mode 100644 index 00000000000..14b7869804d --- /dev/null +++ b/client/components/promotional-badge/__tests__/index.test.tsx @@ -0,0 +1,282 @@ +/** @format */ + +/** + * External dependencies + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import PromotionalBadge from '../'; + +describe( 'PromotionalBadge', () => { + test( 'renders the badge with message', () => { + render( + + ); + + expect( screen.getByText( '50% off fees' ) ).toBeInTheDocument(); + } ); + + test( 'renders with success type by default', () => { + const { container } = render( + + ); + + const badge = container.querySelector( '.wcpay-promotional-badge' ); + expect( badge ).toHaveClass( 'chip-success' ); + } ); + + test( 'renders with custom chip type', () => { + const { container } = render( + + ); + + const badge = container.querySelector( '.wcpay-promotional-badge' ); + expect( badge ).toHaveClass( 'chip-warning' ); + } ); + + test( 'renders with alert chip type', () => { + const { container } = render( + + ); + + const badge = container.querySelector( '.wcpay-promotional-badge' ); + expect( badge ).toHaveClass( 'chip-alert' ); + } ); + + test( 'renders with light chip type', () => { + const { container } = render( + + ); + + const badge = container.querySelector( '.wcpay-promotional-badge' ); + expect( badge ).toHaveClass( 'chip-light' ); + } ); + + test( 'renders with primary chip type', () => { + const { container } = render( + + ); + + const badge = container.querySelector( '.wcpay-promotional-badge' ); + expect( badge ).toHaveClass( 'chip-primary' ); + } ); + + test( 'renders the info icon button', () => { + render( + + ); + + const tooltipButton = screen.getByRole( 'button', { + name: /discount details/i, + } ); + expect( tooltipButton ).toBeInTheDocument(); + } ); + + test( 'shows tooltip content when clicking the info icon', () => { + render( + + ); + + const tooltipButton = screen.getByRole( 'button', { + name: /discount details/i, + } ); + fireEvent.click( tooltipButton ); + + expect( + screen.getByText( 'You are getting 50% off on processing fees.' ) + ).toBeInTheDocument(); + } ); + + test( 'uses default tooltip label when not provided', () => { + render( + + ); + + const tooltipButton = screen.getByRole( 'button', { + name: /more information/i, + } ); + expect( tooltipButton ).toBeInTheDocument(); + } ); + + test( 'applies chip base class', () => { + const { container } = render( + + ); + + const badge = container.querySelector( '.wcpay-promotional-badge' ); + expect( badge ).toHaveClass( 'chip' ); + } ); + + test( 'renders correctly with all props', () => { + const { container } = render( + + ); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'shows tooltip with T&C link when tcUrl is provided', () => { + render( + + ); + + const tooltipButton = screen.getByRole( 'button', { + name: /discount details/i, + } ); + fireEvent.click( tooltipButton ); + + expect( + screen.getByText( 'You are getting 50% off on processing fees.' ) + ).toBeInTheDocument(); + + const tcLink = screen.getByRole( 'link', { name: /see terms/i } ); + expect( tcLink ).toBeInTheDocument(); + expect( tcLink ).toHaveAttribute( 'href', 'https://example.com/terms' ); + expect( tcLink ).toHaveAttribute( 'target', '_blank' ); + expect( tcLink ).toHaveAttribute( 'rel', 'noopener noreferrer' ); + } ); + + test( 'uses backend-provided tcLabel when available', () => { + render( + + ); + + const tooltipButton = screen.getByRole( 'button', { + name: /more information/i, + } ); + fireEvent.click( tooltipButton ); + + const tcLink = screen.getByRole( 'link', { + name: /view full terms and conditions/i, + } ); + expect( tcLink ).toBeInTheDocument(); + // Ensure the default is NOT used. + expect( + screen.queryByRole( 'link', { + name: /^see terms$/i, + } ) + ).not.toBeInTheDocument(); + } ); + + test( 'does not show T&C link when tcUrl is provided but tcLabel is not', () => { + render( + + ); + + const tooltipButton = screen.getByRole( 'button', { + name: /more information/i, + } ); + fireEvent.click( tooltipButton ); + + // No link should be shown when tcLabel is not provided. + expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument(); + } ); + + test( 'does not show T&C link when tcLabel is empty string', () => { + render( + + ); + + const tooltipButton = screen.getByRole( 'button', { + name: /more information/i, + } ); + fireEvent.click( tooltipButton ); + + // Empty tcLabel signals that the link is already in the description. + expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument(); + } ); + + test( 'does not show T&C link when tcUrl is not provided', () => { + render( + + ); + + const tooltipButton = screen.getByRole( 'button', { + name: /discount details/i, + } ); + fireEvent.click( tooltipButton ); + + expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument(); + } ); + + test( 'renders correctly with all props including T&C', () => { + const { container } = render( + + ); + + expect( container ).toMatchSnapshot(); + } ); +} ); diff --git a/client/components/promotional-badge/index.tsx b/client/components/promotional-badge/index.tsx new file mode 100644 index 00000000000..5e6dcb336f0 --- /dev/null +++ b/client/components/promotional-badge/index.tsx @@ -0,0 +1,81 @@ +/** @format */ + +/** + * External dependencies + */ +import React from 'react'; +import clsx from 'clsx'; +import InfoOutlineIcon from 'gridicons/dist/info-outline'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { ChipType } from 'wcpay/components/chip'; +import { ClickTooltip } from 'wcpay/components/tooltip'; +import { sanitizeHTML } from 'utils/sanitize'; +import './style.scss'; + +interface PromotionalBadgeProps { + /** The badge text displayed in the chip */ + message: string; + /** The tooltip content shown when clicking the info icon */ + tooltip: string; + /** The chip type/color (defaults to "success") */ + type?: ChipType; + /** Accessible label for the tooltip button */ + tooltipLabel?: string; + /** Optional terms & conditions URL - when provided, a link is appended to the tooltip */ + tcUrl?: string; + /** Optional terms & conditions link label */ + tcLabel?: string; +} + +const PromotionalBadge: React.FC< PromotionalBadgeProps > = ( { + message, + tooltip, + type = 'success', + tooltipLabel = __( 'More information', 'woocommerce-payments' ), + tcUrl, + tcLabel, +} ) => { + const classNames = clsx( + 'chip', + `chip-${ type }`, + 'wcpay-promotional-badge' + ); + + // Build tooltip content with optional T&C link. + // Only show the link if both tcUrl and tcLabel are provided. + // An empty tcLabel signals that the link is already in the description. + const tooltipContent = + tcUrl && tcLabel ? ( + <> + { ' ' } + + { tcLabel } + + + ) : ( + + ); + + return ( + + { message } + } + buttonLabel={ tooltipLabel } + content={ tooltipContent } + /> + + ); +}; + +export default PromotionalBadge; diff --git a/client/components/promotional-badge/style.scss b/client/components/promotional-badge/style.scss new file mode 100644 index 00000000000..20e993ee411 --- /dev/null +++ b/client/components/promotional-badge/style.scss @@ -0,0 +1,21 @@ +/** @format */ + +.wcpay-promotional-badge { + display: inline-flex; + align-items: center; + gap: 4px; + + .wcpay-tooltip__content-wrapper { + display: inline-flex; + align-items: center; + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + + > div { + margin-right: 0; + } + } +} diff --git a/client/components/spotlight/__tests__/index.test.tsx b/client/components/spotlight/__tests__/index.test.tsx new file mode 100644 index 00000000000..46df4d460f8 --- /dev/null +++ b/client/components/spotlight/__tests__/index.test.tsx @@ -0,0 +1,489 @@ +/** + * External dependencies + */ +import React from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import Spotlight from '../index'; +import { SpotlightProps } from '../types'; + +// Mock the style import +jest.mock( '../style.scss', () => ( {} ) ); + +describe( 'Spotlight Component', () => { + const defaultProps: SpotlightProps = { + badge: 'Limited time offer', + heading: 'Test Heading', + description: 'Test description text', + primaryButtonLabel: 'Activate', + onPrimaryClick: jest.fn(), + onDismiss: jest.fn(), + showImmediately: true, // Show immediately for tests + }; + + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'renders the spotlight with all basic elements', () => { + render( ); + + expect( screen.getByText( 'Limited time offer' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Test Heading' ) ).toBeInTheDocument(); + expect( + screen.getByText( 'Test description text' ) + ).toBeInTheDocument(); + expect( screen.getByText( 'Activate' ) ).toBeInTheDocument(); + expect( screen.getByLabelText( 'Close' ) ).toBeInTheDocument(); + } ); + + it( 'renders without badge when not provided', () => { + const propsWithoutBadge = { ...defaultProps, badge: undefined }; + render( ); + + expect( + screen.queryByText( 'Limited time offer' ) + ).not.toBeInTheDocument(); + expect( screen.getByText( 'Test Heading' ) ).toBeInTheDocument(); + } ); + + it( 'renders secondary button when provided', () => { + const propsWithSecondary = { + ...defaultProps, + secondaryButtonLabel: 'Learn more', + onSecondaryClick: jest.fn(), + }; + render( ); + + expect( screen.getByText( 'Learn more' ) ).toBeInTheDocument(); + } ); + + it( 'renders footnote when provided', () => { + const propsWithFootnote = { + ...defaultProps, + footnote: '*Terms and conditions apply', + }; + render( ); + + expect( + screen.getByText( '*Terms and conditions apply' ) + ).toBeInTheDocument(); + } ); + + it( 'renders footnote with React component content', () => { + const propsWithReactFootnote = { + ...defaultProps, + footnote: ( + <> + *Terms and conditions apply + + ), + }; + render( ); + + expect( screen.getByText( /Terms and/i ) ).toBeInTheDocument(); + expect( screen.getByText( 'conditions' ) ).toBeInTheDocument(); + } ); + + it( 'renders image when provided as string', () => { + const propsWithImage = { + ...defaultProps, + image: 'https://example.com/image.png', + }; + const { container } = render( ); + + // Image is decorative (role="presentation", aria-hidden="true"), so query by tag directly + const image = container.querySelector( 'img' ); + expect( image ).toBeInTheDocument(); + expect( image ).toHaveAttribute( + 'src', + 'https://example.com/image.png' + ); + expect( image ).toHaveAttribute( 'alt', '' ); + expect( image ).toHaveAttribute( 'role', 'presentation' ); + expect( image ).toHaveAttribute( 'aria-hidden', 'true' ); + } ); + + it( 'renders image when provided as React element', () => { + const propsWithImage = { + ...defaultProps, + image:
    Custom Image
    , + }; + render( ); + + expect( screen.getByTestId( 'custom-image' ) ).toBeInTheDocument(); + } ); + + it( 'calls onPrimaryClick but not onDismiss when primary button is clicked', async () => { + jest.useFakeTimers(); + const onPrimaryClick = jest.fn(); + const onDismiss = jest.fn(); + + render( + + ); + + const primaryButton = screen.getByText( 'Activate' ); + await userEvent.click( primaryButton ); + + expect( onPrimaryClick ).toHaveBeenCalledTimes( 1 ); + + // Fast forward past the animation timeout + act( () => { + jest.advanceTimersByTime( 500 ); + } ); + + // onDismiss should NOT be called - backend handles dismissal on activation + expect( onDismiss ).not.toHaveBeenCalled(); + + jest.useRealTimers(); + } ); + + it( 'calls onSecondaryClick when secondary button is clicked', async () => { + const onSecondaryClick = jest.fn(); + + render( + + ); + + const secondaryButton = screen.getByText( 'Learn more' ); + await userEvent.click( secondaryButton ); + + expect( onSecondaryClick ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'calls onDismiss when close button is clicked', async () => { + const onDismiss = jest.fn(); + + render( ); + + const closeButton = screen.getByLabelText( 'Close' ); + await userEvent.click( closeButton ); + + // onDismiss is called after animation timeout + await waitFor( + () => { + expect( onDismiss ).toHaveBeenCalledTimes( 1 ); + }, + { timeout: 500 } + ); + } ); + + it( 'does not render when showImmediately is false initially', () => { + const propsWithDelay = { + ...defaultProps, + showImmediately: false, + }; + render( ); + + // Component should not be visible initially + expect( screen.queryByText( 'Test Heading' ) ).not.toBeInTheDocument(); + } ); + + it( 'renders after delay when showImmediately is false', async () => { + jest.useFakeTimers(); + const propsWithDelay = { + ...defaultProps, + showImmediately: false, + }; + render( ); + + // Component should not be visible initially + expect( screen.queryByText( 'Test Heading' ) ).not.toBeInTheDocument(); + + // Fast forward time by 4 seconds + act( () => { + jest.advanceTimersByTime( 4000 ); + } ); + + // Component should now be visible + await waitFor( () => { + expect( screen.getByText( 'Test Heading' ) ).toBeInTheDocument(); + } ); + + jest.useRealTimers(); + } ); + + it( 'renders description with React component content', () => { + const propsWithReactContent = { + ...defaultProps, + description: ( + <> + Test with bold text + + ), + }; + render( ); + + expect( screen.getByText( /Test with/i ) ).toBeInTheDocument(); + expect( screen.getByText( 'bold' ) ).toBeInTheDocument(); + } ); + + it( 'applies correct CSS classes', () => { + const { container } = render( ); + + expect( + container.querySelector( '.wcpay-spotlight' ) + ).toBeInTheDocument(); + expect( + container.querySelector( '.wcpay-spotlight--visible' ) + ).toBeInTheDocument(); + expect( + container.querySelector( '.wcpay-spotlight__card' ) + ).toBeInTheDocument(); + } ); + + it( 'calls onView when spotlight becomes visible with showImmediately', () => { + const onView = jest.fn(); + + render( ); + + expect( onView ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'calls onView after delay when showImmediately is false', async () => { + jest.useFakeTimers(); + const onView = jest.fn(); + + render( + + ); + + // onView should not be called initially + expect( onView ).not.toHaveBeenCalled(); + + // Fast forward time by 4 seconds + act( () => { + jest.advanceTimersByTime( 4000 ); + } ); + + // Flush requestAnimationFrame calls + await act( async () => { + await Promise.resolve(); + } ); + + // onView should now be called + await waitFor( () => { + expect( onView ).toHaveBeenCalledTimes( 1 ); + } ); + + jest.useRealTimers(); + } ); + + describe( 'Accessibility', () => { + it( 'has correct dialog ARIA attributes', () => { + render( ); + + const dialog = screen.getByRole( 'dialog' ); + expect( dialog ).toHaveAttribute( 'aria-modal', 'true' ); + expect( dialog ).toHaveAttribute( + 'aria-labelledby', + 'spotlight-heading' + ); + } ); + + it( 'heading has correct id for aria-labelledby', () => { + render( ); + + const heading = screen.getByRole( 'heading', { + name: 'Test Heading', + } ); + expect( heading ).toHaveAttribute( 'id', 'spotlight-heading' ); + } ); + + it( 'closes spotlight when Escape key is pressed', async () => { + const onDismiss = jest.fn(); + + render( ); + + // Press Escape key + await userEvent.keyboard( '{Escape}' ); + + // onDismiss is called after animation timeout + await waitFor( + () => { + expect( onDismiss ).toHaveBeenCalledTimes( 1 ); + }, + { timeout: 500 } + ); + } ); + + it( 'traps focus within the dialog on Tab', async () => { + const propsWithSecondary = { + ...defaultProps, + secondaryButtonLabel: 'Learn more', + onSecondaryClick: jest.fn(), + }; + + render( ); + + const closeButton = screen.getByLabelText( 'Close' ); + const primaryButton = screen.getByText( 'Activate' ); + + // Focus the last element (primary button) + primaryButton.focus(); + expect( primaryButton.ownerDocument.activeElement ).toBe( + primaryButton + ); + + // Tab should wrap to the first focusable element (close button) + await userEvent.tab(); + expect( primaryButton.ownerDocument.activeElement ).toBe( + closeButton + ); + } ); + + it( 'traps focus within the dialog on Shift+Tab', async () => { + const propsWithSecondary = { + ...defaultProps, + secondaryButtonLabel: 'Learn more', + onSecondaryClick: jest.fn(), + }; + + render( ); + + const closeButton = screen.getByLabelText( 'Close' ); + const primaryButton = screen.getByText( 'Activate' ); + + // Focus the first element (close button) + closeButton.focus(); + expect( closeButton.ownerDocument.activeElement ).toBe( + closeButton + ); + + // Shift+Tab should wrap to the last focusable element (primary button) + await userEvent.tab( { shift: true } ); + expect( closeButton.ownerDocument.activeElement ).toBe( + primaryButton + ); + } ); + + it( 'focuses the dialog when it becomes visible', () => { + render( ); + + const dialog = screen.getByRole( 'dialog' ); + expect( dialog.ownerDocument.activeElement ).toBe( dialog ); + } ); + } ); + + describe( 'Badge type variations', () => { + it( 'renders badge with default success type when badgeType is not provided', () => { + const { container } = render( ); + + const badge = container.querySelector( '.chip-success' ); + expect( badge ).toBeInTheDocument(); + } ); + + it( 'renders badge with specified badgeType', () => { + const propsWithBadgeType = { + ...defaultProps, + badgeType: 'warning' as const, + }; + const { container } = render( + + ); + + const badge = container.querySelector( '.chip-warning' ); + expect( badge ).toBeInTheDocument(); + } ); + + it( 'renders badge with alert type', () => { + const propsWithAlertType = { + ...defaultProps, + badgeType: 'alert' as const, + }; + const { container } = render( + + ); + + const badge = container.querySelector( '.chip-alert' ); + expect( badge ).toBeInTheDocument(); + } ); + + it( 'defaults to success type when invalid badgeType is provided', () => { + const propsWithInvalidType = { + ...defaultProps, + badgeType: 'invalid-type' as any, + }; + const { container } = render( + + ); + + // Should fall back to success type + const badge = container.querySelector( '.chip-success' ); + expect( badge ).toBeInTheDocument(); + } ); + + it( 'defaults to success type when badgeType is undefined', () => { + const propsWithUndefinedType = { + ...defaultProps, + badgeType: undefined, + }; + const { container } = render( + + ); + + const badge = container.querySelector( '.chip-success' ); + expect( badge ).toBeInTheDocument(); + } ); + } ); + + describe( 'CSS class variations', () => { + it( 'applies has-image class when image is provided', () => { + const propsWithImage = { + ...defaultProps, + image: 'https://example.com/image.png', + }; + const { container } = render( ); + + expect( + container.querySelector( '.wcpay-spotlight__card.has-image' ) + ).toBeInTheDocument(); + } ); + + it( 'does not apply has-image class when no image provided', () => { + const { container } = render( ); + + const card = container.querySelector( '.wcpay-spotlight__card' ); + expect( card ).toBeInTheDocument(); + expect( card ).not.toHaveClass( 'has-image' ); + } ); + + it( 'removes visible class during close animation', async () => { + const onDismiss = jest.fn(); + const { container } = render( + + ); + + // Initially visible + expect( + container.querySelector( '.wcpay-spotlight--visible' ) + ).toBeInTheDocument(); + + // Click close + const closeButton = screen.getByLabelText( 'Close' ); + await userEvent.click( closeButton ); + + // Class should be removed immediately (before timeout completes) + expect( + container.querySelector( '.wcpay-spotlight--visible' ) + ).not.toBeInTheDocument(); + } ); + } ); +} ); diff --git a/client/components/spotlight/index.tsx b/client/components/spotlight/index.tsx new file mode 100644 index 00000000000..91b808e45ba --- /dev/null +++ b/client/components/spotlight/index.tsx @@ -0,0 +1,368 @@ +/** + * External dependencies + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Card, + CardBody, + CardHeader, + CardMedia, + CardFooter, + Button, + Flex, + Icon, +} from '@wordpress/components'; +import { closeSmall } from '@wordpress/icons'; +import { __, sprintf } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; + +/** + * Internal dependencies + */ +import { SpotlightProps } from './types'; +import Chip, { ChipType } from 'components/chip'; +import { sanitizeHTML } from 'utils/sanitize'; +import './style.scss'; + +const showDelayMs = 4000; // 4 seconds + +/** + * Valid chip types for the badge. + */ +const validBadgeTypes: ChipType[] = [ + 'primary', + 'success', + 'light', + 'warning', + 'alert', +]; + +/** + * Get a valid badge type, defaulting to 'success' if invalid or not provided. + * + * @param type - The badge type to validate. + * @return A valid ChipType. + */ +const getValidBadgeType = ( type?: ChipType ): ChipType => { + if ( type && validBadgeTypes.includes( type ) ) { + return type; + } + return 'success'; +}; + +const Spotlight: React.FC< SpotlightProps > = ( { + badge, + badgeType, + heading, + description, + footnote, + image, + primaryButtonLabel, + onPrimaryClick, + secondaryButtonLabel, + onSecondaryClick, + onDismiss, + onView, + showImmediately = false, +} ) => { + const validBadgeType = getValidBadgeType( badgeType ); + const [ isVisible, setIsVisible ] = useState( false ); + const [ isAnimatingIn, setIsAnimatingIn ] = useState( false ); + const closeTimeoutRef = useRef< ReturnType< typeof setTimeout > | null >( + null + ); + const dialogRef = useRef< HTMLDivElement >( null ); + const previouslyFocusedElementRef = useRef< HTMLElement | null >( null ); + + useEffect( () => { + if ( showImmediately ) { + setIsVisible( true ); + setIsAnimatingIn( true ); + return; + } + + // Show the spotlight after a delay + const timer = setTimeout( () => { + setIsVisible( true ); + // Double RAF to ensure browser paints initial state before animating + requestAnimationFrame( () => { + requestAnimationFrame( () => { + setIsAnimatingIn( true ); + } ); + } ); + }, showDelayMs ); + + return () => clearTimeout( timer ); + }, [ showImmediately ] ); + + // Cleanup close timeout on unmount + useEffect( () => { + return () => { + if ( closeTimeoutRef.current ) { + clearTimeout( closeTimeoutRef.current ); + } + }; + }, [] ); + + // Call onView and announce to screen readers when spotlight becomes visible + useEffect( () => { + if ( isAnimatingIn ) { + // Announce to screen readers that a dialog has appeared + speak( + sprintf( + /* translators: %s: heading text of the spotlight dialog */ + __( 'Dialog opened: %s', 'woocommerce-payments' ), + heading + ), + 'polite' + ); + + if ( onView ) { + onView(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ isAnimatingIn ] ); + + const handleClose = useCallback( + ( shouldDismiss = true ) => { + setIsAnimatingIn( false ); + // Wait for animation to complete before hiding + closeTimeoutRef.current = setTimeout( () => { + setIsVisible( false ); + if ( shouldDismiss ) { + onDismiss(); + } + }, 300 ); + }, + [ onDismiss ] + ); + + // Focus management: save previous focus, focus dialog, handle Escape, trap focus, restore on close + useEffect( () => { + if ( ! isVisible || ! dialogRef.current ) { + return; + } + + const dialog = dialogRef.current; + const ownerDocument = dialog.ownerDocument; + + // Save the currently focused element to restore later + previouslyFocusedElementRef.current = ownerDocument.activeElement as HTMLElement; + + // Focus the dialog + dialog.focus(); + + const handleKeyDown = ( event: KeyboardEvent ) => { + if ( event.key === 'Escape' ) { + event.preventDefault(); + handleClose(); + return; + } + + // Focus trapping + if ( event.key === 'Tab' ) { + const focusableElements = dialog.querySelectorAll< + HTMLElement + >( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const firstElement = focusableElements[ 0 ]; + const lastElement = + focusableElements[ focusableElements.length - 1 ]; + const activeElement = ownerDocument.activeElement; + + if ( event.shiftKey && activeElement === firstElement ) { + // Shift + Tab: if on first element, wrap to last + event.preventDefault(); + lastElement?.focus(); + } else if ( + ! event.shiftKey && + activeElement === lastElement + ) { + // Tab: if on last element, wrap to first + event.preventDefault(); + firstElement?.focus(); + } + } + }; + + ownerDocument.addEventListener( 'keydown', handleKeyDown ); + + return () => { + ownerDocument.removeEventListener( 'keydown', handleKeyDown ); + // Restore focus to previously focused element + previouslyFocusedElementRef.current?.focus(); + }; + }, [ isVisible, handleClose ] ); + + const handlePrimaryClick = () => { + onPrimaryClick(); + // Close without calling onDismiss - the backend handles dismissal on activation. + handleClose( false ); + }; + + if ( ! isVisible ) { + return null; + } + + return ( +
    +
    + + { image && ( + + { typeof image === 'string' ? ( + + ) : ( + image + ) } + + ) } + + + + { /* When no image: show badge if available, otherwise show heading */ } + { ! image && badge && ( +
    + +
    + ) } + { ! image && ! badge && ( +

    + { heading } +

    + ) } + { /* Spacer when image is present (header is overlaid) */ } + { image && } + + ) } + +
    + +
    +
    +
    + ); +}; + +export default Spotlight; diff --git a/client/components/spotlight/style.scss b/client/components/spotlight/style.scss new file mode 100644 index 00000000000..48b721f2e98 --- /dev/null +++ b/client/components/spotlight/style.scss @@ -0,0 +1,187 @@ +/** @format */ + +.wcpay-spotlight { + position: fixed; + bottom: $gap-large; + right: $gap-large; + z-index: 999; + width: 300px; + opacity: 0; + transform: translateX( 100% ); + transition: opacity 0.3s ease-out, transform 0.3s ease-out; + font-family: 'SF Pro Display', $default-font; + + &--visible { + opacity: 1; + transform: translateX( 0 ); + } + + @media screen and ( max-width: 782px ) { + right: 0; + left: 0; + bottom: 0; + width: 100%; + + .wcpay-spotlight__card { + margin: 0; + + &.components-card { + border-radius: 0; + } + } + } + + @media ( prefers-reduced-motion: reduce ) { + transition: none; + } +} + +.wcpay-spotlight__container { + width: 100%; +} + +.wcpay-spotlight__card { + margin: 0; + position: relative; + overflow: hidden; + + // Override WordPress Card component border-radius + &.components-card { + border-radius: $radius-medium; + box-shadow: $elevation-medium; + } + + &.has-image { + .wcpay-spotlight__header { + position: absolute; + top: 0; + right: 0; + width: 100%; + z-index: 1; + background: transparent; + } + + .wcpay-spotlight__image { + padding: 0; + } + } +} + +.wcpay-spotlight__header { + padding: $gap-small $gap-small 0; + + &.components-card__header { + border-radius: $radius-medium $radius-medium 0 0; + } + + // Reset margins for badge/heading when in header + .wcpay-spotlight__badge, + .wcpay-spotlight__heading { + margin: 0; + } +} + +.wcpay-spotlight__controls { + width: 100%; +} + +.wcpay-spotlight__close-btn { + min-width: auto; + width: 32px; + height: 32px; + padding: $gap-smallest; + + &:hover { + background: transparent; + } + + &:focus-visible { + outline: 2px solid $gutenberg-blue; + outline-offset: 2px; + box-shadow: none; + } + + .components-icon { + color: $gray-900; + } +} + +.wcpay-spotlight__image { + padding: 0; + margin: 0; + + img, + svg { + width: 100%; + height: auto; + display: block; + } +} + +.wcpay-spotlight__body { + padding: $gap; + + // Remove top padding when there's no image (header is directly above) + .wcpay-spotlight__card:not( .has-image ) & { + padding-top: 0; + } +} + +.wcpay-spotlight__badge { + margin-bottom: $gap-small; +} + +.wcpay-spotlight__heading { + @include heading-lg; + + margin: 0 0 $gap-small; + color: $gray-900; +} + +.wcpay-spotlight__description { + @include body-md; + + color: $gray-900; + margin: 0; + + a { + color: $wp-blue-50; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.wcpay-spotlight__footnote { + @include body-sm; + + color: $gray-900; + margin: $gap-small 0 0; +} + +.wcpay-spotlight__footer { + padding: 0 $gap $gap-large; + + &.components-card__footer { + border-radius: 0 0 $radius-medium $radius-medium; + } +} + +.wcpay-spotlight__primary-btn, +.wcpay-spotlight__secondary-btn { + &:focus-visible { + outline: 2px solid $gutenberg-blue; + outline-offset: 2px; + box-shadow: none; + } +} + +// Override WC settings page styles that affect cards +body.woocommerce_page_wc-settings + .wcpay-spotlight + .wcpay-spotlight__card.components-surface.components-card { + border-radius: $radius-medium; + box-shadow: $elevation-medium; +} diff --git a/client/components/spotlight/types.ts b/client/components/spotlight/types.ts new file mode 100644 index 00000000000..800b2059508 --- /dev/null +++ b/client/components/spotlight/types.ts @@ -0,0 +1,85 @@ +/** @format */ + +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +/** + * Internal dependencies + */ +import { ChipType } from 'wcpay/components/chip'; + +/** + * Props for the Spotlight component. + */ +export interface SpotlightProps { + /** + * Badge text to display at the top (e.g., "Limited time offer"). + */ + badge?: string; + + /** + * Badge type/color for the Chip component. + * Defaults to "success" if not provided or invalid. + */ + badgeType?: ChipType; + + /** + * Main heading text. + */ + heading: string; + + /** + * Description content (can be a string or React component). + */ + description: ReactNode; + + /** + * Optional footnote content shown at the bottom (can be a string or React component). + */ + footnote?: ReactNode; + + /** + * Image element or URL to display in the spotlight. + */ + image?: ReactNode | string; + + /** + * Primary button label. + */ + primaryButtonLabel: string; + + /** + * Callback when the primary button is clicked. + */ + onPrimaryClick: () => void; + + /** + * Secondary button/link label (e.g., "Learn more"). + */ + secondaryButtonLabel?: string; + + /** + * Callback when the secondary button is clicked. + */ + onSecondaryClick?: () => void; + + /** + * Callback when the spotlight is dismissed via the close button. + */ + onDismiss: () => void; + + /** + * Callback when the spotlight becomes visible (after delay and animation starts). + * Useful for tracking view events. + */ + onView?: () => void; + + /** + * Whether to show the spotlight immediately without delay (for testing). + * + * @default false + */ + showImmediately?: boolean; +} diff --git a/client/data/index.ts b/client/data/index.ts index 7509c789361..fc53bd0e32d 100644 --- a/client/data/index.ts +++ b/client/data/index.ts @@ -24,6 +24,7 @@ export * from './documents/hooks'; export * from './payment-intents/hooks'; export * from './authorizations/hooks'; export * from './files/hooks'; +export * from './pm-promotions/hooks'; import { TimelineItem } from './timeline/types'; import { ApiError } from '../types/errors'; diff --git a/client/data/pm-promotions/action-types.ts b/client/data/pm-promotions/action-types.ts new file mode 100644 index 00000000000..1c09b8c7853 --- /dev/null +++ b/client/data/pm-promotions/action-types.ts @@ -0,0 +1,6 @@ +/** @format */ + +export enum ACTION_TYPES { + SET_PM_PROMOTIONS = 'SET_PM_PROMOTIONS', + SET_ERROR_FOR_PM_PROMOTIONS = 'SET_ERROR_FOR_PM_PROMOTIONS', +} diff --git a/client/data/pm-promotions/actions.ts b/client/data/pm-promotions/actions.ts new file mode 100644 index 00000000000..d85f983a9d2 --- /dev/null +++ b/client/data/pm-promotions/actions.ts @@ -0,0 +1,150 @@ +/** @format */ + +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { ACTION_TYPES } from './action-types'; +import { + PmPromotionsData, + UpdatePmPromotionsAction, + ErrorPmPromotionsAction, +} from './types'; +import { ApiError } from '../../types/errors'; +import { NAMESPACE } from '../constants'; + +/** + * Type guard to check if an error is an ApiError. + */ +function isApiError( error: unknown ): error is ApiError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof ( error as ApiError ).code === 'string' + ); +} + +/** + * Normalizes an unknown error to an ApiError. + */ +function normalizeError( error: unknown ): ApiError { + if ( isApiError( error ) ) { + return error; + } + return { + code: 'unknown_error', + }; +} + +export function updatePmPromotions( + data: PmPromotionsData +): UpdatePmPromotionsAction { + return { + type: ACTION_TYPES.SET_PM_PROMOTIONS, + data, + }; +} + +export function updateErrorForPmPromotions( + error: ApiError +): ErrorPmPromotionsAction { + return { + type: ACTION_TYPES.SET_ERROR_FOR_PM_PROMOTIONS, + error, + }; +} + +/** + * Activate a PM promotion. + * + * @param {string} identifier The promotion identifier. + */ +export function* activatePmPromotion( identifier: string ): unknown { + const path = `${ NAMESPACE }/pm-promotions/${ identifier }/activate`; + + try { + yield apiFetch( { + path, + method: 'POST', + } ); + + yield controls.dispatch( + 'core/notices', + 'createSuccessNotice', + __( 'Promotion activated successfully!', 'woocommerce-payments' ) + ); + + // Refetch promotions to update the list. + yield controls.dispatch( + 'wc/payments', + 'invalidateResolution', + 'getPmPromotions', + [] + ); + } catch ( e ) { + yield controls.dispatch( + 'core/notices', + 'createErrorNotice', + __( + 'Error activating promotion. Please try again.', + 'woocommerce-payments' + ) + ); + yield controls.dispatch( + 'wc/payments', + 'updateErrorForPmPromotions', + normalizeError( e ) + ); + } +} + +/** + * Dismiss a PM promotion. + * + * @param {string} id The promotion unique identifier. + */ +export function* dismissPmPromotion( id: string ): unknown { + const path = `${ NAMESPACE }/pm-promotions/${ id }/dismiss`; + + try { + yield apiFetch( { + path, + method: 'POST', + } ); + + yield controls.dispatch( + 'core/notices', + 'createSuccessNotice', + __( 'Promotion dismissed.', 'woocommerce-payments' ) + ); + + // Refetch promotions to update the list. + yield controls.dispatch( + 'wc/payments', + 'invalidateResolution', + 'getPmPromotions', + [] + ); + } catch ( e ) { + yield controls.dispatch( + 'core/notices', + 'createErrorNotice', + __( + 'Error dismissing promotion. Please try again.', + 'woocommerce-payments' + ) + ); + yield controls.dispatch( + 'wc/payments', + 'updateErrorForPmPromotions', + normalizeError( e ) + ); + } +} diff --git a/client/data/pm-promotions/hooks.ts b/client/data/pm-promotions/hooks.ts new file mode 100644 index 00000000000..0cdd70c520f --- /dev/null +++ b/client/data/pm-promotions/hooks.ts @@ -0,0 +1,46 @@ +/** @format */ + +/** + * External dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from '../constants'; +import { PmPromotionsResponse, PmPromotionActions } from './types'; + +/** + * Hook to retrieve PM promotions data. + * + * @return {PmPromotionsResponse} The promotions data, error, and loading state. + */ +export const usePmPromotions = (): PmPromotionsResponse => + useSelect( ( select ) => { + const { getPmPromotions, getPmPromotionsError, isResolving } = select( + STORE_NAME + ); + + return { + pmPromotions: getPmPromotions(), + pmPromotionsError: getPmPromotionsError(), + isLoading: isResolving( 'getPmPromotions' ), + }; + } ); + +/** + * Hook to get PM promotion actions (activate and dismiss). + * + * @return {PmPromotionActions} Object with activatePmPromotion and dismissPmPromotion functions. + */ +export const usePmPromotionActions = (): PmPromotionActions => { + const { activatePmPromotion, dismissPmPromotion } = useDispatch( + STORE_NAME + ); + + return { + activatePmPromotion, + dismissPmPromotion, + }; +}; diff --git a/client/data/pm-promotions/index.ts b/client/data/pm-promotions/index.ts new file mode 100644 index 00000000000..c524cca8b05 --- /dev/null +++ b/client/data/pm-promotions/index.ts @@ -0,0 +1,12 @@ +/** @format */ + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; + +export { reducer, selectors, actions, resolvers }; +export * from './hooks'; diff --git a/client/data/pm-promotions/reducer.ts b/client/data/pm-promotions/reducer.ts new file mode 100644 index 00000000000..eadcbecfd38 --- /dev/null +++ b/client/data/pm-promotions/reducer.ts @@ -0,0 +1,36 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { ACTION_TYPES } from './action-types'; +import { PmPromotionsState, PmPromotionsActions } from './types'; + +const defaultState: PmPromotionsState = { + pmPromotions: undefined, + pmPromotionsError: undefined, +}; + +export const receivePmPromotions = ( + state: PmPromotionsState = defaultState, + action: PmPromotionsActions +): PmPromotionsState => { + switch ( action.type ) { + case ACTION_TYPES.SET_PM_PROMOTIONS: + return { + ...state, + pmPromotions: action.data, + pmPromotionsError: undefined, + }; + case ACTION_TYPES.SET_ERROR_FOR_PM_PROMOTIONS: + return { + ...state, + pmPromotions: undefined, + pmPromotionsError: action.error, + }; + } + + return state; +}; + +export default receivePmPromotions; diff --git a/client/data/pm-promotions/resolvers.ts b/client/data/pm-promotions/resolvers.ts new file mode 100644 index 00000000000..dfc73cabad4 --- /dev/null +++ b/client/data/pm-promotions/resolvers.ts @@ -0,0 +1,95 @@ +/** @format */ + +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from '../constants'; +import { PmPromotion, PmPromotionsData } from './types'; +import { ApiError } from '../../types/errors'; + +/** + * Type guard to check if an object is a valid PmPromotion. + */ +function isPmPromotion( value: unknown ): value is PmPromotion { + if ( typeof value !== 'object' || value === null ) { + return false; + } + const obj = value as Record< string, unknown >; + return ( + typeof obj.id === 'string' && + typeof obj.promo_id === 'string' && + typeof obj.payment_method === 'string' && + typeof obj.payment_method_title === 'string' && + typeof obj.type === 'string' && + ( obj.type === 'spotlight' || obj.type === 'badge' ) && + typeof obj.title === 'string' && + typeof obj.description === 'string' && + typeof obj.cta_label === 'string' && + typeof obj.tc_url === 'string' && + typeof obj.tc_label === 'string' + ); +} + +/** + * Type guard to check if a value is valid PmPromotionsData. + */ +function isPmPromotionsData( value: unknown ): value is PmPromotionsData { + return Array.isArray( value ) && value.every( isPmPromotion ); +} + +/** + * Type guard to check if an error is an ApiError. + */ +function isApiError( error: unknown ): error is ApiError { + return typeof error === 'object' && error !== null && 'code' in error; +} + +/** + * Normalizes an unknown error to an ApiError. + */ +function normalizeError( error: unknown ): ApiError { + if ( isApiError( error ) ) { + return error; + } + return { + code: 'unknown_error', + }; +} + +/** + * Retrieve PM promotions data. + */ +export function* getPmPromotions(): unknown { + const path = `${ NAMESPACE }/pm-promotions`; + + try { + const result = yield apiFetch( { path } ); + + if ( ! isPmPromotionsData( result ) ) { + throw new Error( 'Invalid promotions data received from API' ); + } + + yield controls.dispatch( 'wc/payments', 'updatePmPromotions', result ); + } catch ( e ) { + yield controls.dispatch( + 'core/notices', + 'createErrorNotice', + __( + 'Error retrieving promotions. Please try again later.', + 'woocommerce-payments' + ) + ); + yield controls.dispatch( + 'wc/payments', + 'updateErrorForPmPromotions', + normalizeError( e ) + ); + } +} diff --git a/client/data/pm-promotions/selectors.ts b/client/data/pm-promotions/selectors.ts new file mode 100644 index 00000000000..cc1cbdead5b --- /dev/null +++ b/client/data/pm-promotions/selectors.ts @@ -0,0 +1,34 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { PmPromotionsState, PmPromotionsData } from './types'; +import { ApiError } from '../../types/errors'; + +// Type for the full Redux state with pmPromotions slice. +interface State { + pmPromotions: PmPromotionsState; +} + +/** + * Retrieves the PM promotions array from the state. + * + * @param {State} state The full Redux state. + * + * @return {PmPromotionsData} Array of promotions, or empty array if not loaded. + */ +export const getPmPromotions = ( state: State ): PmPromotionsData => { + return state.pmPromotions?.pmPromotions ?? ( [] as PmPromotionsData ); +}; + +/** + * Retrieves any error that occurred while fetching PM promotions. + * + * @param {State} state The full Redux state. + * + * @return {ApiError | undefined} The error or undefined. + */ +export const getPmPromotionsError = ( state: State ): ApiError | undefined => { + return state.pmPromotions?.pmPromotionsError; +}; diff --git a/client/data/pm-promotions/types.d.ts b/client/data/pm-promotions/types.d.ts new file mode 100644 index 00000000000..ee81f4d1ed3 --- /dev/null +++ b/client/data/pm-promotions/types.d.ts @@ -0,0 +1,82 @@ +/** @format */ + +/** + * Internal Dependencies + */ +import { ApiError } from '../../types/errors'; +import { ACTION_TYPES } from './action-types'; + +export type PmPromotionType = 'spotlight' | 'badge'; + +/** + * Badge appearance type for promotion badges. + * Self-contained to avoid data layer dependency on presentation components. + */ +export type BadgeType = 'primary' | 'success' | 'light' | 'warning' | 'alert'; + +export interface PmPromotion { + id: string; + promo_id: string; + payment_method: string; + payment_method_title: string; + type: PmPromotionType; + title: string; + badge_text?: string; + badge_type?: BadgeType; + description: string; + cta_label: string; + tc_url: string; + tc_label: string; + footnote?: string; + image?: string; +} + +/** + * The API returns an array of promotions directly. + */ +export type PmPromotionsData = PmPromotion[]; + +export interface PmPromotionsState { + pmPromotions?: PmPromotionsData; + pmPromotionsError?: ApiError; +} + +export interface PmPromotionsResponse { + isLoading: boolean; + pmPromotions?: PmPromotionsData; + pmPromotionsError?: ApiError; +} + +export interface UpdatePmPromotionsAction { + type: ACTION_TYPES.SET_PM_PROMOTIONS; + data: PmPromotionsData; +} + +export interface ErrorPmPromotionsAction { + type: ACTION_TYPES.SET_ERROR_FOR_PM_PROMOTIONS; + error: ApiError; +} + +export type PmPromotionsActions = + | UpdatePmPromotionsAction + | ErrorPmPromotionsAction; + +/** + * Hook return type for usePmPromotionActions. + * These are the dispatched action creators wrapped by @wordpress/data. + */ +export interface PmPromotionActions { + /** + * Activate a PM promotion by its identifier. + * + * @param identifier - The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight'). + */ + activatePmPromotion: ( identifier: string ) => void; + + /** + * Dismiss a PM promotion by its identifier. + * + * @param id - The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight'). + */ + dismissPmPromotion: ( id: string ) => void; +} diff --git a/client/data/store.js b/client/data/store.js index 899b2d58e5b..f554ee6a71b 100644 --- a/client/data/store.js +++ b/client/data/store.js @@ -20,6 +20,7 @@ import * as documents from './documents'; import * as paymentIntents from './payment-intents'; import * as authorizations from './authorizations'; import * as files from './files'; +import * as pmPromotions from './pm-promotions'; // Extracted into wrapper function to facilitate testing. export const initStore = () => @@ -37,6 +38,7 @@ export const initStore = () => paymentIntents: paymentIntents.reducer, authorizations: authorizations.reducer, files: files.reducer, + pmPromotions: pmPromotions.reducer, } ), actions: { ...deposits.actions, @@ -51,6 +53,7 @@ export const initStore = () => ...paymentIntents.actions, ...authorizations.actions, ...files.actions, + ...pmPromotions.actions, }, controls, selectors: { @@ -66,6 +69,7 @@ export const initStore = () => ...paymentIntents.selectors, ...authorizations.selectors, ...files.selectors, + ...pmPromotions.selectors, }, resolvers: { ...deposits.resolvers, @@ -80,5 +84,6 @@ export const initStore = () => ...paymentIntents.resolvers, ...authorizations.resolvers, ...files.resolvers, + ...pmPromotions.resolvers, }, } ); diff --git a/client/deposits/index.tsx b/client/deposits/index.tsx index d8b4e261224..8aeef930e42 100644 --- a/client/deposits/index.tsx +++ b/client/deposits/index.tsx @@ -23,6 +23,8 @@ import { hasAutomaticScheduledDeposits } from 'wcpay/deposits/utils'; import { recordEvent } from 'wcpay/tracks'; import { MaybeShowMerchantFeedbackPrompt } from 'wcpay/merchant-feedback-prompt'; import { saveOption } from 'wcpay/data/settings/actions'; +import ErrorBoundary from 'components/error-boundary'; +import SpotlightPromotion from 'promotions/spotlight'; const useNextDepositNoticeState = () => { const [ isDismissed, setIsDismissed ] = useState( @@ -156,6 +158,9 @@ const DepositsPage: React.FC = () => { + + + ); }; diff --git a/client/disputes/__tests__/index.test.tsx b/client/disputes/__tests__/index.test.tsx index 91013a75037..95b8206d8de 100644 --- a/client/disputes/__tests__/index.test.tsx +++ b/client/disputes/__tests__/index.test.tsx @@ -41,6 +41,13 @@ jest.mock( 'data/index', () => ( { useDisputes: jest.fn(), useDisputesSummary: jest.fn(), useSettings: jest.fn(), + usePmPromotions: jest + .fn() + .mockReturnValue( { pmPromotions: [], isLoading: false } ), + usePmPromotionActions: jest.fn().mockReturnValue( { + activatePmPromotion: jest.fn(), + dismissPmPromotion: jest.fn(), + } ), } ) ); jest.mock( '@woocommerce/data', () => { diff --git a/client/disputes/index.tsx b/client/disputes/index.tsx index 8be4dc10798..1c6993f051b 100644 --- a/client/disputes/index.tsx +++ b/client/disputes/index.tsx @@ -45,6 +45,8 @@ import { usePersistedColumnVisibility } from 'wcpay/hooks/use-persisted-table-co import { useReportExport } from 'wcpay/hooks/use-report-export'; import { useDispatch } from '@wordpress/data'; import { MaybeShowMerchantFeedbackPrompt } from 'wcpay/merchant-feedback-prompt'; +import ErrorBoundary from 'components/error-boundary'; +import SpotlightPromotion from 'promotions/spotlight'; const getHeaders = ( sortColumn?: string ): DisputesTableHeader[] => [ { @@ -470,6 +472,9 @@ export const DisputesList = (): JSX.Element => { ), ] } /> + + + ); }; diff --git a/client/documents/index.tsx b/client/documents/index.tsx index a322fc17961..55fdc5eebbf 100644 --- a/client/documents/index.tsx +++ b/client/documents/index.tsx @@ -10,12 +10,17 @@ import Page from 'components/page'; import DocumentsList from './list'; import { TestModeNotice } from 'components/test-mode-notice'; import { MaybeShowMerchantFeedbackPrompt } from 'wcpay/merchant-feedback-prompt'; +import ErrorBoundary from 'components/error-boundary'; +import SpotlightPromotion from 'promotions/spotlight'; export const DocumentsPage = (): JSX.Element => { return ( + + + ); }; diff --git a/client/overview/__tests__/index.test.js b/client/overview/__tests__/index.test.js index 6b117612536..45d2ce4dc53 100644 --- a/client/overview/__tests__/index.test.js +++ b/client/overview/__tests__/index.test.js @@ -72,6 +72,13 @@ jest.mock( 'wcpay/data', () => ( { .fn() .mockReturnValue( { overviews: { currencies: [] } } ), useActiveLoanSummary: jest.fn().mockReturnValue( { isLoading: true } ), + usePmPromotions: jest + .fn() + .mockReturnValue( { pmPromotions: [], isLoading: false } ), + usePmPromotionActions: jest.fn().mockReturnValue( { + activatePmPromotion: jest.fn(), + dismissPmPromotion: jest.fn(), + } ), } ) ); select.mockReturnValue( { diff --git a/client/overview/index.js b/client/overview/index.js index 9672b7a287c..b97da958cb4 100644 --- a/client/overview/index.js +++ b/client/overview/index.js @@ -36,6 +36,7 @@ import { recordEvent } from 'wcpay/tracks'; import StripeSpinner from 'wcpay/components/stripe-spinner'; import { getAdminUrl, isInTestModeOnboarding } from 'wcpay/utils'; import { EmbeddedConnectNotificationBanner } from 'wcpay/embedded-components'; +import SpotlightPromotion from 'promotions/spotlight'; const OverviewPageError = () => { const queryParams = getQuery(); @@ -404,6 +405,9 @@ const OverviewPage = () => { ) } + + + ); }; diff --git a/client/promotions/spotlight/__tests__/index.test.tsx b/client/promotions/spotlight/__tests__/index.test.tsx new file mode 100644 index 00000000000..2130bf08525 --- /dev/null +++ b/client/promotions/spotlight/__tests__/index.test.tsx @@ -0,0 +1,330 @@ +/** @format */ + +/** + * External dependencies + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import SpotlightPromotion from '../index'; +import { usePmPromotions, usePmPromotionActions } from 'wcpay/data'; +import { recordEvent } from 'tracks'; + +interface MockSpotlightProps { + heading?: React.ReactNode; + description?: React.ReactNode; + footnote?: React.ReactNode; + image?: string; + primaryButtonLabel?: string; + secondaryButtonLabel?: string; + onPrimaryClick?: () => void; + onSecondaryClick?: () => void; + onDismiss?: () => void; + onView?: () => void; +} + +// Mock the dependencies +jest.mock( 'wcpay/data', () => ( { + usePmPromotions: jest.fn(), + usePmPromotionActions: jest.fn(), +} ) ); + +jest.mock( 'tracks', () => ( { + recordEvent: jest.fn(), +} ) ); + +jest.mock( 'components/spotlight', () => ( { + __esModule: true, + default: ( props: MockSpotlightProps ) => ( +
    +
    { props.heading }
    +
    { props.description }
    + { props.footnote && ( +
    { props.footnote }
    + ) } + { props.image && ( +
    { props.image }
    + ) } + + + + +
    + ), +} ) ); + +describe( 'SpotlightPromotion', () => { + const mockActivatePmPromotion = jest.fn(); + const mockDismissPmPromotion = jest.fn(); + + // New flat promotion structure (no nested variations). + const mockPromotionData = [ + { + id: 'klarna-promo__spotlight', + promo_id: 'klarna-promo', + payment_method: 'klarna', + payment_method_title: 'Klarna', + type: 'spotlight', + title: 'Activate Klarna', + description: 'Offer your customers flexible payments', + cta_label: 'Activate now', + tc_url: 'https://example.com/terms', + tc_label: 'See terms', + footnote: '*Terms apply', + image: 'https://example.com/image.png', + }, + ]; + + beforeEach( () => { + jest.clearAllMocks(); + + ( usePmPromotionActions as jest.Mock ).mockReturnValue( { + activatePmPromotion: mockActivatePmPromotion, + dismissPmPromotion: mockDismissPmPromotion, + } ); + } ); + + it( 'renders spotlight when promotion available', () => { + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: mockPromotionData, + isLoading: false, + } ); + + render( ); + + expect( screen.getByTestId( 'spotlight-mock' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'spotlight-heading' ) ).toHaveTextContent( + 'Activate Klarna' + ); + expect( + screen.getByTestId( 'spotlight-description' ) + ).toHaveTextContent( 'Offer your customers flexible payments' ); + } ); + + it( 'does not render when promotions are loading', () => { + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: mockPromotionData, + isLoading: true, + } ); + + const { container } = render( ); + + expect( container.firstChild ).toBeNull(); + } ); + + it( 'does not render when no spotlight type promotion available', () => { + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: [ + { + id: 'klarna-promo__badge', + promo_id: 'klarna-promo', + payment_method: 'klarna', + payment_method_title: 'Klarna', + type: 'badge', // Not a spotlight type + title: 'Different promotion', + description: 'Badge description', + cta_label: 'Click', + tc_url: 'https://example.com/terms', + tc_label: 'See terms', + }, + ], + isLoading: false, + } ); + + const { container } = render( ); + + expect( container.firstChild ).toBeNull(); + } ); + + it( 'does not render when no promotions available', () => { + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: [], + isLoading: false, + } ); + + const { container } = render( ); + + expect( container.firstChild ).toBeNull(); + } ); + + it( 'calls activatePmPromotion when primary button is clicked', () => { + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: mockPromotionData, + isLoading: false, + } ); + + render( ); + + const activateButton = screen.getByText( 'Activate now' ); + activateButton.click(); + + expect( mockActivatePmPromotion ).toHaveBeenCalledWith( + 'klarna-promo__spotlight' + ); + } ); + + it( 'calls dismissPmPromotion with single id when close button is clicked', () => { + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: mockPromotionData, + isLoading: false, + } ); + + render( ); + + const closeButton = screen.getByText( 'Close' ); + closeButton.click(); + + // Now dismissPmPromotion is called with just the id (flat structure). + expect( mockDismissPmPromotion ).toHaveBeenCalledWith( + 'klarna-promo__spotlight' + ); + } ); + + it( 'opens tc_url when secondary button is clicked', () => { + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: mockPromotionData, + isLoading: false, + } ); + + const windowOpenSpy = jest + .spyOn( window, 'open' ) + .mockImplementation( () => null ); + + render( ); + + // Secondary button uses tc_label from promotion data. + const termsButton = screen.getByText( 'See terms' ); + termsButton.click(); + + expect( windowOpenSpy ).toHaveBeenCalledWith( + 'https://example.com/terms', + '_blank', + 'noopener,noreferrer' + ); + + windowOpenSpy.mockRestore(); + } ); + + it( 'renders footnote text', () => { + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: mockPromotionData, + isLoading: false, + } ); + + render( ); + + expect( screen.getByText( /Terms apply/i ) ).toBeInTheDocument(); + } ); + + it( 'does not render footnote when not provided', () => { + const dataWithoutFootnote = [ + { + id: 'klarna-promo__spotlight', + promo_id: 'klarna-promo', + payment_method: 'klarna', + payment_method_title: 'Klarna', + type: 'spotlight', + title: 'Activate Klarna', + description: 'Offer your customers flexible payments', + cta_label: 'Activate now', + tc_url: 'https://example.com/terms', + tc_label: 'See terms', + // No footnote. + }, + ]; + + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: dataWithoutFootnote, + isLoading: false, + } ); + + render( ); + + expect( screen.getByTestId( 'spotlight-mock' ) ).toBeInTheDocument(); + expect( + screen.queryByTestId( 'spotlight-footnote' ) + ).not.toBeInTheDocument(); + } ); + + describe( 'tracks events', () => { + const expectedBaseProperties = { + promo_id: 'klarna-promo', + payment_method: 'klarna', + display_context: 'spotlight', + source: 'unknown', + path: '/', + }; + + beforeEach( () => { + ( usePmPromotions as jest.Mock ).mockReturnValue( { + pmPromotions: mockPromotionData, + isLoading: false, + } ); + } ); + + it( 'records view event when spotlight becomes visible', () => { + render( ); + + const viewButton = screen.getByText( 'View' ); + viewButton.click(); + + expect( recordEvent ).toHaveBeenCalledWith( + 'wcpay_payment_method_promotion_view', + expectedBaseProperties + ); + } ); + + it( 'records activate_click event when primary button is clicked', () => { + render( ); + + const activateButton = screen.getByText( 'Activate now' ); + activateButton.click(); + + expect( recordEvent ).toHaveBeenCalledWith( + 'wcpay_payment_method_promotion_activate_click', + expectedBaseProperties + ); + } ); + + it( 'records link_click event when secondary button is clicked', () => { + const windowOpenSpy = jest + .spyOn( window, 'open' ) + .mockImplementation( () => null ); + + render( ); + + // Secondary button now uses tc_label. + const termsButton = screen.getByText( 'See terms' ); + termsButton.click(); + + expect( recordEvent ).toHaveBeenCalledWith( + 'wcpay_payment_method_promotion_link_click', + { + ...expectedBaseProperties, + link_type: 'terms', + } + ); + + windowOpenSpy.mockRestore(); + } ); + + it( 'records dismiss event when close button is clicked', () => { + render( ); + + const closeButton = screen.getByText( 'Close' ); + closeButton.click(); + + expect( recordEvent ).toHaveBeenCalledWith( + 'wcpay_payment_method_promotion_dismiss_click', + expectedBaseProperties + ); + } ); + } ); +} ); diff --git a/client/promotions/spotlight/index.tsx b/client/promotions/spotlight/index.tsx new file mode 100644 index 00000000000..ed253f2ef9e --- /dev/null +++ b/client/promotions/spotlight/index.tsx @@ -0,0 +1,156 @@ +/** @format */ + +/** + * External dependencies + */ +import React, { useCallback, useMemo } from 'react'; + +/** + * Internal dependencies + */ +import Spotlight from 'components/spotlight'; +import { usePmPromotions, usePmPromotionActions } from 'wcpay/data'; +import { PmPromotion } from 'data/pm-promotions/types'; +import { recordEvent } from 'tracks'; + +/** + * Determine a human-readable source identifier based on the current page. + * + * @return {string} Source identifier for tracking. + */ +const getPageSource = (): string => { + const path = window.location.pathname + window.location.search; + + if ( path.includes( 'path=%2Fpayments%2Foverview' ) ) { + return 'wcpay-overview'; + } + if ( path.includes( 'path=%2Fpayments%2Fsettings' ) ) { + return 'wcpay-settings'; + } + if ( + path.includes( 'page=wc-settings' ) && + path.includes( 'tab=checkout' ) + ) { + return 'wc-settings-payments'; + } + + return 'unknown'; +}; + +/** + * Container component that fetches promotions and renders the Spotlight component. + * + * This component: + * - Fetches promotions from the API + * - Filters for 'spotlight' type promotions + * - Handles activation and dismissal of promotions + */ +const SpotlightPromotion: React.FC = () => { + const { pmPromotions, isLoading } = usePmPromotions(); + const { activatePmPromotion, dismissPmPromotion } = usePmPromotionActions(); + + // Memoize the spotlight promotion lookup to prevent recalculation on every render. + const spotlightPromotion: PmPromotion | undefined = useMemo( + () => pmPromotions?.find( ( promo ) => promo.type === 'spotlight' ), + [ pmPromotions ] + ); + + /** + * Get common event properties for tracking. + * Memoized to maintain reference equality. + */ + const getEventProperties = useCallback( + () => ( { + promo_id: spotlightPromotion?.promo_id, + payment_method: spotlightPromotion?.payment_method, + display_context: 'spotlight', + source: getPageSource(), + path: window.location.pathname + window.location.search, + } ), + [ spotlightPromotion?.promo_id, spotlightPromotion?.payment_method ] + ); + + const handleView = useCallback( () => { + recordEvent( + 'wcpay_payment_method_promotion_view', + getEventProperties() + ); + }, [ getEventProperties ] ); + + const handlePrimaryClick = useCallback( () => { + if ( ! spotlightPromotion ) return; + recordEvent( + 'wcpay_payment_method_promotion_activate_click', + getEventProperties() + ); + activatePmPromotion( spotlightPromotion.id ); + }, [ getEventProperties, activatePmPromotion, spotlightPromotion ] ); + + const handleSecondaryClick = useCallback( () => { + if ( ! spotlightPromotion ) return; + recordEvent( 'wcpay_payment_method_promotion_link_click', { + ...getEventProperties(), + link_type: 'terms', + } ); + if ( spotlightPromotion.tc_url ) { + try { + const parsedUrl = new URL( spotlightPromotion.tc_url ); + if ( + parsedUrl.protocol === 'https:' || + parsedUrl.protocol === 'http:' + ) { + window.open( + spotlightPromotion.tc_url, + '_blank', + 'noopener,noreferrer' + ); + } + } catch { + // Invalid URL, don't open + } + } + }, [ getEventProperties, spotlightPromotion ] ); + + const handleDismiss = useCallback( () => { + if ( ! spotlightPromotion ) return; + recordEvent( + 'wcpay_payment_method_promotion_dismiss_click', + getEventProperties() + ); + dismissPmPromotion( spotlightPromotion.id ); + }, [ getEventProperties, dismissPmPromotion, spotlightPromotion ] ); + + // Don't render if data is still loading. + if ( isLoading ) { + return null; + } + + // Don't render if no promotions available. + if ( ! pmPromotions || pmPromotions.length === 0 ) { + return null; + } + + // No spotlight promotion available. + if ( ! spotlightPromotion ) { + return null; + } + + return ( + + ); +}; + +export default SpotlightPromotion; diff --git a/client/settings/buy-now-pay-later-section/__tests__/buy-now-pay-later-section.test.js b/client/settings/buy-now-pay-later-section/__tests__/buy-now-pay-later-section.test.js index d5cf714fbdf..8133d569f1c 100644 --- a/client/settings/buy-now-pay-later-section/__tests__/buy-now-pay-later-section.test.js +++ b/client/settings/buy-now-pay-later-section/__tests__/buy-now-pay-later-section.test.js @@ -42,6 +42,9 @@ jest.mock( 'wcpay/data', () => ( { useUnselectedPaymentMethod: jest.fn(), useGetDuplicatedPaymentMethodIds: jest.fn(), useSettings: jest.fn().mockReturnValue( { isLoading: false } ), + usePmPromotions: jest + .fn() + .mockReturnValue( { pmPromotions: [], isLoading: false } ), } ) ); jest.mock( '@wordpress/data', () => ( { diff --git a/client/settings/express-checkout-settings/__tests__/payment-request-button-preview.test.js b/client/settings/express-checkout-settings/__tests__/payment-request-button-preview.test.js index b4e92134313..b802f331150 100644 --- a/client/settings/express-checkout-settings/__tests__/payment-request-button-preview.test.js +++ b/client/settings/express-checkout-settings/__tests__/payment-request-button-preview.test.js @@ -36,6 +36,7 @@ jest.mock( 'wcpay/data', () => { ...actual, useWooPayEnabledSettings: () => [ false, jest.fn() ], usePaymentRequestEnabledSettings: () => [ true, jest.fn() ], + usePmPromotions: () => ( { pmPromotions: [], isLoading: false } ), }; } ); diff --git a/client/settings/payment-methods-list/__tests__/payment-method.test.js b/client/settings/payment-methods-list/__tests__/payment-method.test.js index 00d14d42e17..1d619c3b949 100644 --- a/client/settings/payment-methods-list/__tests__/payment-method.test.js +++ b/client/settings/payment-methods-list/__tests__/payment-method.test.js @@ -12,16 +12,19 @@ import user from '@testing-library/user-event'; */ import PaymentMethod from '../payment-method'; import DuplicatedPaymentMethodsContext from '../../settings-manager/duplicated-payment-methods-context'; +import WCPaySettingsContext from '../../wcpay-settings-context'; import { useEnabledPaymentMethodIds, useGetPaymentMethodStatuses, useManualCapture, + usePmPromotions, } from 'wcpay/data'; jest.mock( 'wcpay/data', () => ( { useEnabledPaymentMethodIds: jest.fn(), useGetPaymentMethodStatuses: jest.fn(), useManualCapture: jest.fn(), + usePmPromotions: jest.fn(), } ) ); describe( 'PaymentMethod', () => { @@ -31,6 +34,27 @@ describe( 'PaymentMethod', () => { ] ); useGetPaymentMethodStatuses.mockReturnValue( {} ); useManualCapture.mockReturnValue( [ false ] ); + usePmPromotions.mockReturnValue( { + pmPromotions: [], + isLoading: false, + } ); + + // Set up wcpaySettings with required properties for discount badge tests. + global.wcpaySettings = { + ...global.wcpaySettings, + zeroDecimalCurrencies: [ 'jpy', 'krw', 'vnd' ], + currencyData: { + US: { + code: 'USD', + symbol: '$', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + }, + connect: { country: 'US' }, + }; } ); // Clear the mocks (including the mock call count) after each test. @@ -179,4 +203,461 @@ describe( 'PaymentMethod', () => { ) ).toBeInTheDocument(); } ); + + describe( 'Promotional discount badge', () => { + // Helper to create a complete FeeStructure for tests. + const createFeeStructure = ( overrides = {} ) => ( { + base: { + currency: 'USD', + percentage_rate: 0.029, + fixed_rate: 30, + }, + additional: { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }, + fx: { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }, + discount: [], + ...overrides, + } ); + + it( 'renders promotional badge when payment method has active discount', () => { + const accountFeesWithDiscount = { + klarna: createFeeStructure( { + discount: [ + { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + discount: 0.5, // 50% - formatFee multiplies by 100. + end_time: '2026-12-31', + volume_allowance: null, + volume_currency: null, + current_volume: null, + }, + ], + } ), + }; + + render( + + + + ); + + // The badge should show the discount text (desktop + mobile labels). + expect( + screen.getAllByText( /50% off fees through/i ) + ).toHaveLength( 2 ); + } ); + + it( 'does not render promotional badge when payment method has no discount', () => { + const accountFeesNoDiscount = { + klarna: createFeeStructure( { + discount: [], + } ), + }; + + render( + + + + ); + + // No discount badge should be present. + expect( screen.queryByText( /off fees/i ) ).not.toBeInTheDocument(); + } ); + + it( 'does not render promotional badge when first discount entry has no discount value', () => { + // Tests edge case where discount array has multiple entries but + // the first entry (which is checked) has no discount value. + const accountFeesMultipleDiscounts = { + klarna: createFeeStructure( { + discount: [ + { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + // First entry has no discount value. + end_time: '2026-12-31', + volume_allowance: null, + volume_currency: null, + current_volume: null, + }, + { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + discount: 0.5, // Second entry has discount but is not checked. + end_time: '2026-12-31', + volume_allowance: null, + volume_currency: null, + current_volume: null, + }, + ], + } ), + }; + + render( + + + + ); + + // No discount badge should be present since only first entry is checked. + expect( screen.queryByText( /off fees/i ) ).not.toBeInTheDocument(); + } ); + + it( 'does not render promotional badge when discount has no discount value', () => { + const accountFeesNoDiscountValue = { + klarna: createFeeStructure( { + discount: [ + { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + // No discount value. + end_time: null, + volume_allowance: null, + volume_currency: null, + current_volume: null, + }, + ], + } ), + }; + + render( + + + + ); + + // No discount badge should be present. + expect( screen.queryByText( /off fees/i ) ).not.toBeInTheDocument(); + } ); + + it( 'does not render promotional badge when accountFees is not provided', () => { + render( + + + + ); + + // No discount badge should be present. + expect( screen.queryByText( /off fees/i ) ).not.toBeInTheDocument(); + } ); + + it( 'renders promotional badge with volume-based discount', () => { + const accountFeesWithVolumeDiscount = { + affirm: createFeeStructure( { + discount: [ + { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + discount: 1, // 100% - formatFee multiplies by 100. + end_time: '2026-06-30', + volume_allowance: 50000, + volume_currency: 'USD', + current_volume: 0, + }, + ], + } ), + }; + + render( + + + + ); + + // The badge should show the discount text (100% = full discount, desktop + mobile labels). + expect( + screen.getAllByText( /100% off fees through/i ) + ).toHaveLength( 2 ); + } ); + + it( 'renders promotional badge without end date when end_time is not provided', () => { + const accountFeesNoEndTime = { + klarna: createFeeStructure( { + discount: [ + { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + discount: 0.25, // 25% - formatFee multiplies by 100. + end_time: null, + volume_allowance: null, + volume_currency: null, + current_volume: null, + }, + ], + } ), + }; + + render( + + + + ); + + // The badge should show the discount text without date (desktop + mobile labels). + expect( screen.getAllByText( /25% off fees/i ) ).toHaveLength( 2 ); + } ); + } ); + + describe( 'PM Promotion badge', () => { + // Helper to create a mock badge promotion. + const createBadgePromotion = ( overrides = {} ) => ( { + id: 'klarna-promo__badge', + promo_id: 'klarna-promo', + payment_method: 'klarna', + payment_method_title: 'Klarna', + type: 'badge', + title: 'Zero fees for 90 days', + description: + 'Enable Klarna and pay zero processing fees for 90 days.', + cta_label: 'Enable Klarna', + tc_url: 'https://example.com/terms', + tc_label: 'See terms', + ...overrides, + } ); + + it( 'renders promotional badge when payment method has badge promotion', () => { + const badgePromotion = createBadgePromotion(); + usePmPromotions.mockReturnValue( { + pmPromotions: [ badgePromotion ], + isLoading: false, + } ); + + render( + + ); + + // The badge should show the promotion title (desktop + mobile labels). + expect( + screen.getAllByText( 'Zero fees for 90 days' ) + ).toHaveLength( 2 ); + } ); + + it( 'does not render promotional badge for non-matching payment method', () => { + const badgePromotion = createBadgePromotion( { + payment_method: 'affirm', + } ); + usePmPromotions.mockReturnValue( { + pmPromotions: [ badgePromotion ], + isLoading: false, + } ); + + render( + + ); + + // No badge should be present since promotion is for affirm. + expect( + screen.queryByText( 'Zero fees for 90 days' ) + ).not.toBeInTheDocument(); + } ); + + it( 'does not render promotional badge for spotlight-type promotion', () => { + const spotlightPromotion = createBadgePromotion( { + id: 'klarna-promo__spotlight', + type: 'spotlight', + } ); + usePmPromotions.mockReturnValue( { + pmPromotions: [ spotlightPromotion ], + isLoading: false, + } ); + + render( + + ); + + // No badge should be present since promotion is spotlight type. + expect( + screen.queryByText( 'Zero fees for 90 days' ) + ).not.toBeInTheDocument(); + } ); + + it( 'prefers discount fee over badge promotion when both exist', () => { + // Create accountFees with discount. + const accountFeesWithDiscount = { + klarna: { + base: { + currency: 'USD', + percentage_rate: 0.029, + fixed_rate: 30, + }, + additional: { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }, + fx: { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }, + discount: [ + { + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + discount: 0.5, // 50%. + end_time: '2026-12-31', + volume_allowance: null, + volume_currency: null, + current_volume: null, + }, + ], + }, + }; + + // Also have a badge promotion. + const badgePromotion = createBadgePromotion(); + usePmPromotions.mockReturnValue( { + pmPromotions: [ badgePromotion ], + isLoading: false, + } ); + + render( + + + + ); + + // Should show discount fee text, not promotion title. + expect( + screen.getAllByText( /50% off fees through/i ) + ).toHaveLength( 2 ); + expect( + screen.queryByText( 'Zero fees for 90 days' ) + ).not.toBeInTheDocument(); + } ); + + it( 'renders badge promotion with correct tooltip label', () => { + const badgePromotion = createBadgePromotion(); + usePmPromotions.mockReturnValue( { + pmPromotions: [ badgePromotion ], + isLoading: false, + } ); + + render( + + ); + + // The tooltip button should have the correct accessible label. + const tooltipButtons = screen.getAllByRole( 'button', { + name: /promotion details/i, + } ); + expect( tooltipButtons ).toHaveLength( 2 ); // Desktop + mobile. + } ); + + it( 'does not render promotional badge when promotions array is empty', () => { + usePmPromotions.mockReturnValue( { + pmPromotions: [], + isLoading: false, + } ); + + render( + + ); + + // No badge should be present. + expect( + screen.queryByText( 'Zero fees for 90 days' ) + ).not.toBeInTheDocument(); + } ); + + it( 'does not render promotional badge while promotions are loading', () => { + usePmPromotions.mockReturnValue( { + pmPromotions: [], + isLoading: true, + } ); + + render( + + ); + + // No badge should be present while loading. + expect( + screen.queryByText( 'Zero fees for 90 days' ) + ).not.toBeInTheDocument(); + } ); + } ); } ); diff --git a/client/settings/payment-methods-list/payment-method.tsx b/client/settings/payment-methods-list/payment-method.tsx index d4d815a1824..d1fac97eb41 100644 --- a/client/settings/payment-methods-list/payment-method.tsx +++ b/client/settings/payment-methods-list/payment-method.tsx @@ -15,9 +15,13 @@ import { FeeStructure } from 'wcpay/types/fees'; import { formatMethodFeesDescription, formatMethodFeesTooltip, + getDiscountBadgeText, + getDiscountTooltipText, } from 'wcpay/utils/account-fees'; import WCPaySettingsContext from '../wcpay-settings-context'; +import { PmPromotion } from 'wcpay/data/pm-promotions/types'; import Chip from 'wcpay/components/chip'; +import PromotionalBadge from 'wcpay/components/promotional-badge'; import Pill from 'wcpay/components/pill'; import './payment-method.scss'; import DuplicateNotice from 'wcpay/components/duplicate-notice'; @@ -34,7 +38,7 @@ import UnionPay from 'assets/images/cards/unionpay.svg?asset'; import PAYMENT_METHOD_IDS from 'wcpay/constants/payment-method'; import usePaymentMethodAvailability from './use-payment-method-availability'; import InlineNotice from 'wcpay/components/inline-notice'; -import { useEnabledPaymentMethodIds } from 'wcpay/data'; +import { useEnabledPaymentMethodIds, usePmPromotions } from 'wcpay/data'; interface PaymentMethodProps { id: string; @@ -51,12 +55,46 @@ interface PaymentMethodProps { const PaymentMethodLabel = ( { id, label, + accountFees, + badgePromotion, }: { id: string; label: string; + accountFees?: Record< string, FeeStructure >; + badgePromotion?: PmPromotion; } ): React.ReactElement => { const { chip, chipType = 'warning' } = usePaymentMethodAvailability( id ); + const discountFee = accountFees?.[ id ]?.discount?.[ 0 ]; + const hasDiscount = discountFee?.discount; + + // Show badge for either: active discount fee OR badge-type promotion. + const showPromotionalBadge = hasDiscount || badgePromotion; + + // Determine badge content based on source. + const getBadgeContent = () => { + if ( hasDiscount ) { + return { + message: getDiscountBadgeText( discountFee ), + tooltip: getDiscountTooltipText( discountFee ), + tooltipLabel: __( 'Discount details', 'woocommerce-payments' ), + }; + } + if ( badgePromotion ) { + return { + message: badgePromotion.title, + tooltip: badgePromotion.description, + tooltipLabel: __( 'Promotion details', 'woocommerce-payments' ), + tcUrl: badgePromotion.tc_url, + tcLabel: badgePromotion.tc_label, + type: badgePromotion.badge_type, + }; + } + return null; + }; + + const badgeContent = getBadgeContent(); + return ( <> { label } @@ -66,6 +104,16 @@ const PaymentMethodLabel = ( {
    ) } { chip && } + { showPromotionalBadge && badgeContent && ( + + ) } ); }; @@ -106,6 +154,12 @@ const PaymentMethod = ( { WCPaySettingsContext ); + // Get badge-type promotion for this payment method. + const { pmPromotions = [] } = usePmPromotions(); + const badgePromotion = pmPromotions?.find( + ( promo ) => promo.payment_method === id && promo.type === 'badge' + ); + const { duplicates, dismissedDuplicateNotices, @@ -142,12 +196,22 @@ const PaymentMethod = ( {
    - +
    - +
    { description } diff --git a/client/settings/payment-methods-section/__tests__/payment-methods-section.test.js b/client/settings/payment-methods-section/__tests__/payment-methods-section.test.js index ae9d815b28f..ffc907da31e 100644 --- a/client/settings/payment-methods-section/__tests__/payment-methods-section.test.js +++ b/client/settings/payment-methods-section/__tests__/payment-methods-section.test.js @@ -41,6 +41,9 @@ jest.mock( 'wcpay/data', () => ( { useUnselectedPaymentMethod: jest.fn(), useGetDuplicatedPaymentMethodIds: jest.fn(), useSettings: jest.fn().mockReturnValue( { isLoading: false } ), + usePmPromotions: jest + .fn() + .mockReturnValue( { pmPromotions: [], isLoading: false } ), } ) ); jest.mock( 'multi-currency/interface/data', () => ( { diff --git a/client/settings/settings-manager/index.js b/client/settings/settings-manager/index.js index 6c05a8b7ceb..8c3ff3d637c 100644 --- a/client/settings/settings-manager/index.js +++ b/client/settings/settings-manager/index.js @@ -31,6 +31,7 @@ import { import FraudProtection from '../fraud-protection'; import DuplicatedPaymentMethodsContext from './duplicated-payment-methods-context'; import VatFormModal from '../../vat/form-modal'; +import SpotlightPromotion from 'promotions/spotlight'; import './style.scss'; const ExpressCheckoutDescription = () => ( @@ -306,6 +307,9 @@ const SettingsManager = () => { setModalOpen={ handleVatFormModalClose } onCompleted={ handleVatFormModalCompleted } /> + + + ); }; diff --git a/client/stylesheets/abstracts/_variables.scss b/client/stylesheets/abstracts/_variables.scss index 37a1c9d3c21..b0027f3d2e8 100644 --- a/client/stylesheets/abstracts/_variables.scss +++ b/client/stylesheets/abstracts/_variables.scss @@ -13,5 +13,10 @@ $gap-smallest: 4px; $modal-max-width: 500px; $radius-small: 2px; +$radius-medium: 4px; + $elevation-small: 0 1px 2px rgba( $black, 0.05 ), 0 2px 3px rgba( $black, 0.04 ), 0 6px 6px rgba( $black, 0.03 ), 0 8px 8px rgba( $black, 0.02 ); +$elevation-medium: 0 2px 3px rgba( $black, 0.05 ), + 0 4px 5px rgba( $black, 0.04 ), 0 4px 5px rgba( $black, 0.03 ), + 0 16px 16px rgba( $black, 0.02 ); diff --git a/client/tracks/event.d.ts b/client/tracks/event.d.ts index d5a02d46a58..e85c75464b6 100644 --- a/client/tracks/event.d.ts +++ b/client/tracks/event.d.ts @@ -127,4 +127,9 @@ export type MerchantEvent = | 'payments_transactions_details_refund_full' | 'payments_transactions_risk_review_list_review_button_click' | 'payments_transactions_uncaptured_list_capture_charge_button_click' + | 'wcpay_payment_method_promotion_view' + | 'wcpay_payment_method_promotion_dismiss' + | 'wcpay_payment_method_promotion_activate_click' + | 'wcpay_payment_method_promotion_secondary_click' + | 'wcpay_payment_method_promotion_link_click' | string; diff --git a/client/transactions/__tests__/index.test.tsx b/client/transactions/__tests__/index.test.tsx index bc382e25073..849896d3f00 100644 --- a/client/transactions/__tests__/index.test.tsx +++ b/client/transactions/__tests__/index.test.tsx @@ -46,6 +46,13 @@ jest.mock( 'data/index', () => ( { useManualCapture: jest.fn(), useSettings: jest.fn(), useAuthorizationsSummary: jest.fn(), + usePmPromotions: jest + .fn() + .mockReturnValue( { pmPromotions: [], isLoading: false } ), + usePmPromotionActions: jest.fn().mockReturnValue( { + activatePmPromotion: jest.fn(), + dismissPmPromotion: jest.fn(), + } ), } ) ); jest.mock( '@woocommerce/data', () => { diff --git a/client/transactions/index.tsx b/client/transactions/index.tsx index d354d6adb80..17e94b56eba 100644 --- a/client/transactions/index.tsx +++ b/client/transactions/index.tsx @@ -24,6 +24,8 @@ import { import WCPaySettingsContext from '../settings/wcpay-settings-context'; import BlockedList from './blocked'; import { MaybeShowMerchantFeedbackPrompt } from 'wcpay/merchant-feedback-prompt'; +import ErrorBoundary from 'components/error-boundary'; +import SpotlightPromotion from 'promotions/spotlight'; declare const window: any; @@ -120,6 +122,9 @@ export const TransactionsPage: React.FC = () => { ); } } + + + ); }; diff --git a/client/utils/__tests__/account-fees.test.tsx b/client/utils/__tests__/account-fees.test.tsx index 3184e42d644..88cef351c47 100644 --- a/client/utils/__tests__/account-fees.test.tsx +++ b/client/utils/__tests__/account-fees.test.tsx @@ -13,6 +13,8 @@ import { formatMethodFeesDescription, formatMethodFeesTooltip, getCurrentBaseFee, + getDiscountBadgeText, + getDiscountTooltipText, } from '../account-fees'; import { formatCurrency } from 'multi-currency/interface/functions'; import { BaseFee, DiscountFee, FeeStructure } from 'wcpay/types/fees'; @@ -28,6 +30,8 @@ declare const global: { connect: { country: string; }; + dateFormat?: string; + timeFormat?: string; }; }; @@ -339,4 +343,203 @@ describe( 'Account fees utility functions', () => { expect( container ).toMatchSnapshot(); } ); } ); + + describe( 'getDiscountBadgeText()', () => { + beforeAll( () => { + global.wcpaySettings = { + ...global.wcpaySettings, + dateFormat: 'M j, Y', + timeFormat: 'g:i a', + }; + } ); + + it( 'returns discount percentage with end date when end_time is provided', () => { + const discountFee: DiscountFee = { + discount: 0.5, + end_time: '2026-02-27 04:20:49', + volume_allowance: null, + volume_currency: null, + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + const result = getDiscountBadgeText( discountFee ); + + expect( result ).toContain( '50%' ); + expect( result ).toContain( 'off fees through' ); + } ); + + it( 'returns discount percentage without date when end_time is null', () => { + const discountFee: DiscountFee = { + discount: 0.25, + end_time: null, + volume_allowance: null, + volume_currency: null, + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + const result = getDiscountBadgeText( discountFee ); + + expect( result ).toBe( '25% off fees' ); + } ); + + it( 'returns empty string for zero discount', () => { + const discountFee: DiscountFee = { + discount: 0, + end_time: null, + volume_allowance: null, + volume_currency: null, + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + const result = getDiscountBadgeText( discountFee ); + + expect( result ).toBe( '' ); + } ); + + it( 'returns empty string for undefined discount', () => { + const discountFee: DiscountFee = { + end_time: null, + volume_allowance: null, + volume_currency: null, + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + const result = getDiscountBadgeText( discountFee ); + + expect( result ).toBe( '' ); + } ); + } ); + + describe( 'getDiscountTooltipText()', () => { + beforeAll( () => { + global.wcpaySettings = { + ...global.wcpaySettings, + dateFormat: 'M j, Y', + timeFormat: 'g:i a', + }; + } ); + + it( 'returns text with volume allowance and end time', () => { + const discountFee: DiscountFee = { + discount: 0.5, + end_time: '2026-02-27 04:20:49', + volume_allowance: 100000, + volume_currency: 'usd', + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + const result = getDiscountTooltipText( discountFee ); + + expect( result ).toContain( '50%' ); + expect( result ).toContain( 'first' ); + expect( result ).toContain( 'total payment volume' ); + expect( result ).toContain( 'or through' ); + } ); + + it( 'returns text with only volume allowance when end_time is null', () => { + const discountFee: DiscountFee = { + discount: 0.3, + end_time: null, + volume_allowance: 50000, + volume_currency: 'usd', + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + const result = getDiscountTooltipText( discountFee ); + + expect( result ).toContain( '30%' ); + expect( result ).toContain( 'first' ); + expect( result ).toContain( 'total payment volume' ); + expect( result ).not.toContain( 'through' ); + } ); + + it( 'returns text with only end time when volume_allowance is null', () => { + const discountFee: DiscountFee = { + discount: 0.2, + end_time: '2026-02-27 04:20:49', + volume_allowance: null, + volume_currency: null, + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + const result = getDiscountTooltipText( discountFee ); + + expect( result ).toContain( '20%' ); + expect( result ).toContain( 'through' ); + expect( result ).not.toContain( 'first' ); + expect( result ).not.toContain( 'total payment volume' ); + } ); + + it( 'returns basic text when neither volume_allowance nor end_time are provided', () => { + const discountFee: DiscountFee = { + discount: 0.15, + end_time: null, + volume_allowance: null, + volume_currency: null, + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + const result = getDiscountTooltipText( discountFee ); + + expect( result ).toBe( + 'You are getting 15% off on processing fees.' + ); + } ); + + it( 'uses volume_currency when available, falls back to currency', () => { + const discountFeeWithVolumeCurrency: DiscountFee = { + discount: 0.1, + end_time: null, + volume_allowance: 10000, + volume_currency: 'eur', + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + getDiscountTooltipText( discountFeeWithVolumeCurrency ); + + expect( formatCurrency ).toHaveBeenCalledWith( 10000, 'eur' ); + + const discountFeeWithoutVolumeCurrency: DiscountFee = { + discount: 0.1, + end_time: null, + volume_allowance: 10000, + volume_currency: null, + current_volume: null, + currency: 'USD', + percentage_rate: 0, + fixed_rate: 0, + }; + + getDiscountTooltipText( discountFeeWithoutVolumeCurrency ); + + expect( formatCurrency ).toHaveBeenCalledWith( 10000, 'USD' ); + } ); + } ); } ); diff --git a/client/utils/account-fees.tsx b/client/utils/account-fees.tsx index 44b6c2c2609..050959de9f3 100644 --- a/client/utils/account-fees.tsx +++ b/client/utils/account-fees.tsx @@ -13,6 +13,7 @@ import './account-fees.scss'; */ import { formatCurrency } from 'multi-currency/interface/functions'; import { formatFee } from 'utils/fees'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; import React from 'react'; import { BaseFee, DiscountFee, FeeStructure } from 'wcpay/types/fees'; import { createInterpolateElement } from '@wordpress/element'; @@ -360,3 +361,77 @@ export const getTransactionsPaymentMethodName = ( // Fallback for unknown payment methods return __( 'Unknown transactions', 'woocommerce-payments' ); }; + +export const getDiscountBadgeText = ( discountFee: DiscountFee ): string => { + if ( ! discountFee.discount ) { + return ''; + } + + const discountPercentage = formatFee( discountFee.discount ); + + if ( discountFee.end_time ) { + return sprintf( + /* translators: %1$s: discount percentage, %2$s: expiration date */ + __( '%1$s%% off fees through %2$s', 'woocommerce-payments' ), + discountPercentage, + formatDateTimeFromString( discountFee.end_time ) + ); + } + + return sprintf( + /* translators: %s: discount percentage */ + __( '%s%% off fees', 'woocommerce-payments' ), + discountPercentage + ); +}; + +export const getDiscountTooltipText = ( discountFee: DiscountFee ): string => { + if ( ! discountFee.discount ) { + return ''; + } + + const discountPercentage = formatFee( discountFee.discount ); + const currencyCode = discountFee.volume_currency ?? discountFee.currency; + + if ( discountFee.volume_allowance && discountFee.end_time ) { + return sprintf( + /* translators: %1$s: discount percentage, %2$s: total payment volume until this promotion expires, %3$s: End date of the promotion */ + __( + 'You are getting %1$s%% off on processing fees for the first %2$s of total payment volume or through %3$s.', + 'woocommerce-payments' + ), + discountPercentage, + formatCurrency( discountFee.volume_allowance, currencyCode ), + formatDateTimeFromString( discountFee.end_time ) + ); + } else if ( discountFee.volume_allowance ) { + return sprintf( + /* translators: %1$s: discount percentage, %2$s: total payment volume until this promotion expires */ + __( + 'You are getting %1$s%% off on processing fees for the first %2$s of total payment volume.', + 'woocommerce-payments' + ), + discountPercentage, + formatCurrency( discountFee.volume_allowance, currencyCode ) + ); + } else if ( discountFee.end_time ) { + return sprintf( + /* translators: %1$s: discount percentage, %2$s: End date of the promotion */ + __( + 'You are getting %1$s%% off on processing fees through %2$s.', + 'woocommerce-payments' + ), + discountPercentage, + formatDateTimeFromString( discountFee.end_time ) + ); + } + + return sprintf( + /* translators: %s: discount percentage */ + __( + 'You are getting %s%% off on processing fees.', + 'woocommerce-payments' + ), + discountPercentage + ); +}; diff --git a/client/wc-payments-settings-spotlight.js b/client/wc-payments-settings-spotlight.js new file mode 100644 index 00000000000..7bb75812172 --- /dev/null +++ b/client/wc-payments-settings-spotlight.js @@ -0,0 +1,36 @@ +/** @format */ + +/** + * External dependencies + */ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +/** + * Internal dependencies + */ +import SpotlightPromotion from 'promotions/spotlight'; + +/** + * Mounts the SpotlightPromotion component to the DOM. + */ +const mountSpotlightPromotion = () => { + const container = document.getElementById( + 'wcpay-payments-settings-spotlight' + ); + + if ( container ) { + const root = createRoot( container ); + root.render( ); + } +}; + +// Mount immediately if DOM is already ready, otherwise wait for DOMContentLoaded +if ( + document.readyState === 'interactive' || + document.readyState === 'complete' +) { + mountSpotlightPromotion(); +} else { + window.addEventListener( 'DOMContentLoaded', mountSpotlightPromotion ); +} diff --git a/composer.json b/composer.json index 7eb4e8b4567..0d9c820c0d6 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "automattic/jetpack-config": "3.0.1", "automattic/jetpack-autoloader": "5.0.2", "automattic/jetpack-sync": "4.8.3", - "woocommerce/subscriptions-core": "6.7.1" + "woocommerce/subscriptions-core": "6.7.1", + "automattic/jetpack-constants": "3.0.8" }, "require-dev": { "composer/installers": "1.10.0", diff --git a/composer.lock b/composer.lock index 435991b7ba4..ca9f45e4c85 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dc2e50629f4ea7ed368df386842eac1b", + "content-hash": "52d66753a7442fbfca11ae0c2605af2c", "packages": [ { "name": "automattic/jetpack-a8c-mc-stats", @@ -390,25 +390,26 @@ }, { "name": "automattic/jetpack-constants", - "version": "v3.0.3", + "version": "v3.0.8", "source": { "type": "git", "url": "https://github.com/Automattic/jetpack-constants.git", - "reference": "4eac57a30282d67589fdad81034d11ac7b7c4941" + "reference": "f9bf00ab48956b8326209e7c0baf247a0ed721c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/jetpack-constants/zipball/4eac57a30282d67589fdad81034d11ac7b7c4941", - "reference": "4eac57a30282d67589fdad81034d11ac7b7c4941", + "url": "https://api.github.com/repos/Automattic/jetpack-constants/zipball/f9bf00ab48956b8326209e7c0baf247a0ed721c4", + "reference": "f9bf00ab48956b8326209e7c0baf247a0ed721c4", "shasum": "" }, "require": { "php": ">=7.2" }, "require-dev": { - "automattic/jetpack-changelogger": "^6.0.0", + "automattic/jetpack-changelogger": "^6.0.5", + "automattic/phpunit-select-config": "^1.0.3", "brain/monkey": "^2.6.2", - "yoast/phpunit-polyfills": "^1.1.1" + "yoast/phpunit-polyfills": "^4.0.0" }, "suggest": { "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." @@ -435,9 +436,9 @@ ], "description": "A wrapper for defining constants in a more testable way.", "support": { - "source": "https://github.com/Automattic/jetpack-constants/tree/v3.0.3" + "source": "https://github.com/Automattic/jetpack-constants/tree/v3.0.8" }, - "time": "2025-03-05T12:24:54+00:00" + "time": "2025-04-28T15:12:45+00:00" }, { "name": "automattic/jetpack-ip", @@ -7019,7 +7020,7 @@ "php": ">=7.3", "ext-json": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "7.3" }, diff --git a/includes/admin/class-wc-payments-admin.php b/includes/admin/class-wc-payments-admin.php index 6c47eb093f2..9ef893f6354 100644 --- a/includes/admin/class-wc-payments-admin.php +++ b/includes/admin/class-wc-payments-admin.php @@ -6,6 +6,7 @@ */ use Automattic\Jetpack\Identity_Crisis as Jetpack_Identity_Crisis; +use Automattic\Jetpack\Constants; use Automattic\WooCommerce\Admin\Features\Features; use WCPay\Constants\Intent_Status; use WCPay\Core\Server\Request; @@ -86,6 +87,13 @@ class WC_Payments_Admin { */ private $incentives_service; + /** + * WC_Payments_PM_Promotions_Service instance to get information for payment method promotions. + * + * @var WC_Payments_PM_Promotions_Service + */ + private $pm_promotions_service; + /** * WC_Payments_Fraud_Service instance to get information about fraud services. * @@ -121,14 +129,15 @@ class WC_Payments_Admin { /** * Hook in admin menu items. * - * @param WC_Payments_API_Client $payments_api_client WooCommerce Payments API client. - * @param WC_Payment_Gateway_WCPay $gateway WCPay Gateway instance to get information regarding WooCommerce Payments setup. - * @param WC_Payments_Account $account Account instance. - * @param WC_Payments_Onboarding_Service $onboarding_service Onboarding service instance. - * @param WC_Payments_Order_Service $order_service Order service instance. - * @param WC_Payments_Incentives_Service $incentives_service Incentives service instance. - * @param WC_Payments_Fraud_Service $fraud_service Fraud service instance. - * @param Database_Cache $database_cache Database Cache instance. + * @param WC_Payments_API_Client $payments_api_client WooCommerce Payments API client. + * @param WC_Payment_Gateway_WCPay $gateway WCPay Gateway instance to get information regarding WooCommerce Payments setup. + * @param WC_Payments_Account $account Account instance. + * @param WC_Payments_Onboarding_Service $onboarding_service Onboarding service instance. + * @param WC_Payments_Order_Service $order_service Order service instance. + * @param WC_Payments_Incentives_Service $incentives_service Incentives service instance. + * @param WC_Payments_PM_Promotions_Service $pm_promotions_service PM Promotions service instance. + * @param WC_Payments_Fraud_Service $fraud_service Fraud service instance. + * @param Database_Cache $database_cache Database Cache instance. */ public function __construct( WC_Payments_API_Client $payments_api_client, @@ -137,17 +146,19 @@ public function __construct( WC_Payments_Onboarding_Service $onboarding_service, WC_Payments_Order_Service $order_service, WC_Payments_Incentives_Service $incentives_service, + WC_Payments_PM_Promotions_Service $pm_promotions_service, WC_Payments_Fraud_Service $fraud_service, Database_Cache $database_cache ) { - $this->payments_api_client = $payments_api_client; - $this->wcpay_gateway = $gateway; - $this->account = $account; - $this->onboarding_service = $onboarding_service; - $this->order_service = $order_service; - $this->incentives_service = $incentives_service; - $this->fraud_service = $fraud_service; - $this->database_cache = $database_cache; + $this->payments_api_client = $payments_api_client; + $this->wcpay_gateway = $gateway; + $this->account = $account; + $this->onboarding_service = $onboarding_service; + $this->order_service = $order_service; + $this->incentives_service = $incentives_service; + $this->pm_promotions_service = $pm_promotions_service; + $this->fraud_service = $fraud_service; + $this->database_cache = $database_cache; } /** @@ -172,6 +183,8 @@ public function init_hooks() { add_action( 'admin_init', [ $this, 'redirect_deposits_to_payouts' ] ); add_action( 'woocommerce_update_options_site-visibility', [ $this, 'inform_stripe_when_store_goes_live' ] ); add_action( 'admin_init', [ $this, 'add_css_classes' ] ); + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_wc_payment_settings_spotlight' ] ); + add_action( 'admin_footer', [ $this, 'inject_payment_settings_spotlight_container' ] ); } /** @@ -664,6 +677,17 @@ public function register_payments_scripts() { WC_Payments::get_file_version( 'dist/plugins-page.css' ), 'all' ); + + WC_Payments::register_script_with_dependencies( 'WCPAY_WC_PAYMENTS_SETTINGS_SPOTLIGHT', 'dist/wc-payments-settings-spotlight' ); + wp_set_script_translations( 'WCPAY_WC_PAYMENTS_SETTINGS_SPOTLIGHT', 'woocommerce-payments' ); + + WC_Payments_Utils::register_style( + 'WCPAY_WC_PAYMENTS_SETTINGS_SPOTLIGHT', + plugins_url( 'dist/wc-payments-settings-spotlight.css', WCPAY_PLUGIN_FILE ), + [], + WC_Payments::get_file_version( 'dist/wc-payments-settings-spotlight.css' ), + 'all' + ); } /** @@ -1394,4 +1418,73 @@ private function get_uncaptured_transactions_count() { return $authorization_summary['count']; } + + /** + * Enqueue the spotlight promotion script on WooCommerce Payments Settings page. + * Only runs on WooCommerce 9.9.2+ (when the new WooCommerce Payments Settings UI was enabled for all stores). + */ + public function enqueue_wc_payment_settings_spotlight() { + // Check for minimum WooCommerce version 9.9.2. + if ( ! Constants::is_defined( 'WC_VERSION' ) || version_compare( Constants::get_constant( 'WC_VERSION' ), '9.9.2', '<' ) ) { + return; + } + + // Only enqueue on the WooCommerce Payments Settings page. + if ( ! $this->is_wc_admin_payments_settings_page() ) { + return; + } + + // Only enqueue if there are visible spotlight promotions. + $promotions = $this->pm_promotions_service->get_visible_promotions(); + if ( empty( $promotions ) ) { + return; + } + + $has_spotlight = false; + foreach ( $promotions as $promotion ) { + if ( isset( $promotion['type'] ) && 'spotlight' === $promotion['type'] ) { + $has_spotlight = true; + break; + } + } + + if ( ! $has_spotlight ) { + return; + } + + wp_enqueue_script( 'WCPAY_WC_PAYMENTS_SETTINGS_SPOTLIGHT' ); + wp_enqueue_style( 'WCPAY_WC_PAYMENTS_SETTINGS_SPOTLIGHT' ); + } + + /** + * Inject the container div for the spotlight promotion on WooCommerce payment settings page. + * Only runs on WooCommerce 9.9.2+ (when the new WooCommerce Payments Settings UI was enabled for all stores). + */ + public function inject_payment_settings_spotlight_container() { + // Check for minimum WooCommerce version 9.9.2. + if ( ! Constants::is_defined( 'WC_VERSION' ) || version_compare( Constants::get_constant( 'WC_VERSION' ), '9.9.2', '<' ) ) { + return; + } + + // Only inject on the WooCommerce Payments settings page. + if ( ! $this->is_wc_admin_payments_settings_page() ) { + return; + } + + echo '
    '; + } + + /** + * Check if we're on the WooCommerce Payments Settings page. + * + * @return bool True if on the WC payment settings page. + */ + private function is_wc_admin_payments_settings_page(): bool { + // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + return isset( $_REQUEST['page'] ) && 'wc-settings' === wp_unslash( $_REQUEST['page'] ) && + isset( $_REQUEST['tab'] ) && 'checkout' === wp_unslash( $_REQUEST['tab'] ) && + ! isset( $_REQUEST['section'] ) + && is_admin(); + // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + } } diff --git a/includes/admin/class-wc-rest-payments-pm-promotions-controller.php b/includes/admin/class-wc-rest-payments-pm-promotions-controller.php new file mode 100644 index 00000000000..208224f67ce --- /dev/null +++ b/includes/admin/class-wc-rest-payments-pm-promotions-controller.php @@ -0,0 +1,147 @@ +promotions_service = $promotions_service; + } + + /** + * Configure REST API routes. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_promotions' ], + 'permission_callback' => [ $this, 'check_permission' ], + ] + ); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[a-zA-Z0-9_-]+)/activate', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'activate_promotion' ], + 'permission_callback' => [ $this, 'check_permission' ], + 'args' => [ + 'id' => [ + 'required' => true, + 'type' => 'string', + 'description' => __( 'The promotion unique identifier.', 'woocommerce-payments' ), + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [ $this, 'validate_promotion_id' ], + ], + ], + ] + ); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[a-zA-Z0-9_-]+)/dismiss', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'dismiss_promotion' ], + 'permission_callback' => [ $this, 'check_permission' ], + 'args' => [ + 'id' => [ + 'required' => true, + 'type' => 'string', + 'description' => __( 'The promotion unique identifier.', 'woocommerce-payments' ), + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => [ $this, 'validate_promotion_id' ], + ], + ], + ] + ); + } + + /** + * Retrieve the active promotions list. + * + * @return WP_REST_Response|WP_Error + */ + public function get_promotions() { + $promotions = $this->promotions_service->get_visible_promotions(); + return rest_ensure_response( $promotions ?? [] ); + } + + /** + * Activate a promotion. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return WP_REST_Response|WP_Error + */ + public function activate_promotion( WP_REST_Request $request ) { + $result = $this->promotions_service->activate_promotion( $request->get_param( 'id' ) ); + + return rest_ensure_response( [ 'success' => $result ] ); + } + + /** + * Dismiss a promotion. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return WP_REST_Response|WP_Error + */ + public function dismiss_promotion( WP_REST_Request $request ) { + $result = $this->promotions_service->dismiss_promotion( $request->get_param( 'id' ) ); + + return rest_ensure_response( [ 'success' => $result ] ); + } + + /** + * Validate the promotion ID parameter. + * + * @param mixed $value The parameter value. + * @param WP_REST_Request $request The request object. + * @param string $param The parameter name. + * + * @return bool True if valid, false otherwise. + */ + public function validate_promotion_id( $value, WP_REST_Request $request, string $param ): bool { + if ( ! is_string( $value ) || empty( $value ) ) { + return false; + } + + // Match alphanumeric characters, underscores, and hyphens only. + return (bool) preg_match( '/^[a-zA-Z0-9_-]+$/', $value ); + } +} diff --git a/includes/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php index d70c08f29d5..0a1b2812121 100644 --- a/includes/admin/class-wc-rest-payments-settings-controller.php +++ b/includes/admin/class-wc-rest-payments-settings-controller.php @@ -39,22 +39,32 @@ class WC_REST_Payments_Settings_Controller extends WC_Payments_REST_Controller { */ protected $account; + /** + * WC_Payments_PM_Promotions_Service instance for payment method promotions. + * + * @var WC_Payments_PM_Promotions_Service + */ + private $pm_promotions_service; + /** * WC_REST_Payments_Settings_Controller constructor. * - * @param WC_Payments_API_Client $api_client WC_Payments_API_Client instance. - * @param WC_Payment_Gateway_WCPay $wcpay_gateway WC_Payment_Gateway_WCPay instance. - * @param WC_Payments_Account $account Account class instance. + * @param WC_Payments_API_Client $api_client WC_Payments_API_Client instance. + * @param WC_Payment_Gateway_WCPay $wcpay_gateway WC_Payment_Gateway_WCPay instance. + * @param WC_Payments_Account $account Account class instance. + * @param WC_Payments_PM_Promotions_Service $pm_promotions_service PM Promotions Service instance. */ public function __construct( WC_Payments_API_Client $api_client, WC_Payment_Gateway_WCPay $wcpay_gateway, - WC_Payments_Account $account + WC_Payments_Account $account, + WC_Payments_PM_Promotions_Service $pm_promotions_service ) { parent::__construct( $api_client ); - $this->wcpay_gateway = $wcpay_gateway; - $this->account = $account; + $this->wcpay_gateway = $wcpay_gateway; + $this->account = $account; + $this->pm_promotions_service = $pm_promotions_service; } /** @@ -573,7 +583,7 @@ public function update_settings( WP_REST_Request $request ) { /** * Schedule a migration of Stripe Billing subscriptions. * - * @param WP_REST_Request $request The request object. Optional. If passed, the function will return a REST response. + * @param WP_REST_Request|null $request The request object. Optional. If passed, the function will return a REST response. * * @return WP_REST_Response|null The response object, if this is a REST request. */ @@ -679,6 +689,12 @@ function ( $payment_method ) use ( $available_payment_methods ) { } continue; } + + // Try to activate any promotions for this payment method BEFORE enabling it. + // This is done first because visible promotions are filtered out for already-enabled PMs. + // The service method handles its own exception catching, logging, and tracking internally. + $this->pm_promotions_service->maybe_activate_promotion_for_payment_method( $payment_method_id ); + $gateway->enable(); } diff --git a/includes/class-wc-payments-incentives-service.php b/includes/class-wc-payments-incentives-service.php index eb999407d8b..de61cb75e72 100644 --- a/includes/class-wc-payments-incentives-service.php +++ b/includes/class-wc-payments-incentives-service.php @@ -334,7 +334,7 @@ private function get_incentives( string $country_code ): array { return $this->incentives_memo; } - // Store incentive in transient cache (together with the context hash) for the given number of seconds + // Store incentive in the transient cache (together with the context hash) for the given number of seconds // or 1 day in seconds. Also attach a timestamp to the transient data so we know when we last fetched. set_transient( $this->cache_transient_name, diff --git a/includes/class-wc-payments-pm-promotions-service.php b/includes/class-wc-payments-pm-promotions-service.php new file mode 100644 index 00000000000..328c1db1021 --- /dev/null +++ b/includes/class-wc-payments-pm-promotions-service.php @@ -0,0 +1,1050 @@ + timestamp]. + * + * @var string + */ + const PROMOTION_DISMISSALS_OPTION = '_wcpay_pm_promotion_dismissals'; + + /** + * The memoized raw promotions to avoid fetching multiple times during a request. + * + * @var array|null + */ + private $promotions_memo = null; + + /** + * The memoized visible promotions (filtered and normalized) for the current request. + * False means not yet computed, null means computed with no results, array means has results. + * + * @var array|null|false + */ + private $visible_promotions_memo = false; + + /** + * WC_Payment_Gateway_WCPay instance. + * + * @var WC_Payment_Gateway_WCPay|null + */ + private $gateway; + + /** + * WC_Payments_Account instance. + * + * @var WC_Payments_Account|null + */ + private $account; + + /** + * Class constructor. + * + * @param WC_Payment_Gateway_WCPay|null $gateway Optional gateway instance. + * @param WC_Payments_Account|null $account Optional account instance. + */ + public function __construct( ?WC_Payment_Gateway_WCPay $gateway = null, ?WC_Payments_Account $account = null ) { + $this->gateway = $gateway; + $this->account = $account; + } + + /** + * Initialise class hooks. + * + * @return void + */ + public function init_hooks() { + // Hooks can be added here if needed in the future. + } + + /** + * Clear the promotions cache. + * + * @return void + */ + public function clear_cache(): void { + delete_transient( self::PROMOTIONS_CACHE_KEY ); + $this->reset_memo(); + } + + /** + * Reset the memoized promotions. + * + * This is useful for testing purposes. + * + * @return void + */ + public function reset_memo(): void { + $this->promotions_memo = null; + $this->visible_promotions_memo = false; + } + + /** + * Get promotions that should be visible to the user. + * + * @return array|null The promotions or null if there is no eligible promotion. + */ + public function get_visible_promotions(): ?array { + // Promotions are only visible to users who can manage WooCommerce (aka act on the promotions). + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return null; + } + + // Return memoized result if available (false means not yet computed). + if ( false !== $this->visible_promotions_memo ) { + return $this->visible_promotions_memo; + } + + $promotions = $this->get_promotions(); + + // Validate each promotion's structure. + $promotions = array_filter( + $promotions, + function ( $promotion ) { + return $this->validate_promotion( $promotion ); + } + ); + + // Filter promotions by dismissal status, PM validity, enabled status, and promo_id uniqueness. + $promotions = $this->filter_promotions( $promotions ); + + // Normalize the promotions (apply fallbacks, derive fields). + $promotions = $this->normalize_promotions( $promotions ); + + // Return early if there are no promotions left. + if ( empty( $promotions ) ) { + $this->visible_promotions_memo = null; + return null; + } + + $this->visible_promotions_memo = array_values( $promotions ); + return $this->visible_promotions_memo; + } + + /** + * Fetches and caches eligible promotions from the WooPayments API. + * + * @return array List of eligible promotions. + */ + private function get_promotions(): array { + // Check memoized data first. + if ( null !== $this->promotions_memo ) { + return $this->promotions_memo; + } + + // Try to use the cached data. + $cache = get_transient( self::PROMOTIONS_CACHE_KEY ); + + // If the cached data is not expired, and it's a WP_Error, + // it means there was an API error previously, and we should not retry just yet. + if ( is_wp_error( $cache ) ) { + // Initialize the in-memory cache and return it. + $this->promotions_memo = []; + + return $this->promotions_memo; + } + + // Gather the store context data. + $store_context = [ + // All the PM promotions dismissals. + 'dismissals' => $this->get_promotion_dismissals(), + // Store locale, e.g. `en_US`. + 'locale' => get_locale(), + ]; + + // Fingerprint the store context through a hash of certain entries. + $store_context_hash = $this->generate_context_hash( $store_context ); + + // Use the transient cached data if it exists, it is not expired, + // and the store context hasn't changed since we last requested from the WooPayments API (based on context hash). + if ( false !== $cache + && ! empty( $cache['context_hash'] ) && is_string( $cache['context_hash'] ) + && hash_equals( $store_context_hash, $cache['context_hash'] ) ) { + + // We have a store context hash, and it matches with the current context one. + // We can use the cached data. + $this->promotions_memo = $cache['promotions'] ?? []; + + return $this->promotions_memo; + } + + // By this point, we have an expired transient or the store context has changed. + // Query for promotions by calling the WooPayments API. + $wcpay_request = Request\Get_PM_Promotions::create(); + $wcpay_request->set_store_context_params( $store_context ); + $response = $wcpay_request->handle_rest_request(); + + // Return early if there is an error, waiting 6 hours before the next attempt. + if ( is_wp_error( $response ) ) { + // Store a trimmed down, lightweight error. + /** + * Type hint for static analysis. + * + * @var WP_Error $response + */ + $error = new \WP_Error( + $response->get_error_code(), + $response->get_error_message(), + wp_remote_retrieve_response_code( $response ) + ); + // Store the error in the transient so we know this is due to an API error. + set_transient( self::PROMOTIONS_CACHE_KEY, $error, HOUR_IN_SECONDS * 6 ); + + // Initialize the in-memory cache and return it. + $this->promotions_memo = []; + + return $this->promotions_memo; + } + + $cache_for = wp_remote_retrieve_header( $response, 'cache-for' ); + // Initialize the in-memory cache. + $this->promotions_memo = []; + + if ( 200 === wp_remote_retrieve_response_code( $response ) ) { + // Decode the results, falling back to an empty array. + $results = json_decode( wp_remote_retrieve_body( $response ), true ) ?? []; + + $this->promotions_memo = $results; + } + + // Skip transient cache if `cache-for` header equals zero. + if ( '0' === $cache_for ) { + // If we have a transient cache that is not expired, delete it so there are no leftovers. + if ( false !== $cache ) { + delete_transient( self::PROMOTIONS_CACHE_KEY ); + } + + return $this->promotions_memo; + } + + // Store promotions in the transient cache (together with the context hash) for the given number of seconds + // or 1 day in seconds. Also attach a timestamp to the transient data so we know when we last fetched. + set_transient( + self::PROMOTIONS_CACHE_KEY, + [ + 'promotions' => $this->promotions_memo, + 'context_hash' => $store_context_hash, + 'timestamp' => time(), + ], + ! empty( $cache_for ) ? (int) $cache_for : DAY_IN_SECONDS + ); + + return $this->promotions_memo; + } + + /** + * Activate a promotion. + * + * This will: + * 1. Send a request to the server to apply the promotion discount. + * 2. Enable the payment method for checkout. + * Activating a promotion implies acceptance of terms and conditions for the promotion. + * + * @param string $id The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight'). + * + * @return bool True on success, false on failure. + */ + public function activate_promotion( string $id ): bool { + // Find the promotion to get the payment method. + $promotion = $this->find_promotion_by_id( $id ); + if ( null === $promotion ) { + return false; + } + + $payment_method_id = $promotion['payment_method'] ?? ''; + if ( empty( $payment_method_id ) ) { + return false; + } + + // Send request to server to apply the promotion discount. + // The server should also handle capability requesting if it is not already requested. + // This way we can keep things in sync and avoid applying discounts without having the capability requested. + $wcpay_request = Request\Activate_PM_Promotion::create( $id ); + $wcpay_request->assign_hook( 'wcpay_activate_pm_promotion_request' ); + $response = $wcpay_request->handle_rest_request(); + if ( is_wp_error( $response ) ) { + $error_message = sprintf( + 'Server activation request failed [%s]: %s', + $response->get_error_code(), + $response->get_error_message() + ); + return $this->handle_promotion_activation_failure( $payment_method_id, $promotion, $error_message ); + } + + // Mark the promotion as dismissed so it won't be shown again. + // Do it before the payment method gateway enabling in case that fails. + $this->mark_promotion_dismissed( $id ); + + // Enable the payment method for checkout. + if ( ! $this->enable_payment_method_gateway( $payment_method_id, $promotion ) ) { + return false; + } + + // Clear the promotions cache to ensure fresh data on next fetch. + $this->clear_cache(); + // Clear the account cache. + if ( null !== $this->account ) { + $this->account->clear_cache(); + } + + // Track successful activation. + $this->tracks_event( + Track_Events::PAYMENT_METHOD_PROMOTION_ACTIVATED, + [ + 'payment_method_id' => $payment_method_id, + 'promo_id' => $promotion['promo_id'] ?? null, + 'promo_instance_id' => $id, + ] + ); + + return true; + } + + /** + * Find a promotion by its ID. + * + * @param string $id The promotion ID (e.g., 'klarna-2026-promo__spotlight'). + * + * @return array|null The promotion data or null if not found. + * + * @psalm-suppress InvalidReturnType, InvalidReturnStatement - Returns full promotion array from get_visible_promotions(). + */ + private function find_promotion_by_id( string $id ): ?array { + $promotions = $this->get_visible_promotions(); + + if ( null === $promotions ) { + return null; + } + + foreach ( $promotions as $promotion ) { + if ( isset( $promotion['id'] ) && $promotion['id'] === $id ) { + return $promotion; + } + } + + return null; + } + + /** + * Find a promotion for a payment method. + * + * We will return the first promotion found for the payment method. + * + * @param string $payment_method_id The payment method ID (e.g., 'klarna'). + * + * @return array|null The promotion data or null if not found. + * + * @psalm-suppress InvalidReturnType, InvalidReturnStatement - Returns full promotion array from get_visible_promotions(). + */ + private function find_promotion_by_payment_method( string $payment_method_id ): ?array { + $promotions = $this->get_visible_promotions(); + + if ( null === $promotions ) { + return null; + } + + foreach ( $promotions as $promotion ) { + if ( isset( $promotion['payment_method'] ) && $promotion['payment_method'] === $payment_method_id ) { + return $promotion; + } + } + + return null; + } + + /** + * Enable a payment method gateway. + * + * @param string $payment_method_id The payment method ID (e.g., 'klarna'). + * @param array $promotion The promotion data associated with the payment method. + * + * @return bool True on success, false on failure. + */ + private function enable_payment_method_gateway( string $payment_method_id, array $promotion ): bool { + $gateway = WC_Payments::get_payment_gateway_by_id( $payment_method_id ); + if ( ! $gateway ) { + $this->log_gateway_error( $payment_method_id, 'payment gateway instance not available' ); + return false; + } + + // Attempt to enable the gateway with exception handling. + try { + $gateway->enable(); + } catch ( \Exception $e ) { + $this->log_gateway_error( $payment_method_id, $e->getMessage() ); + return false; + } + + // Verify the gateway was actually enabled. + if ( 'yes' !== $gateway->get_option( 'enabled' ) ) { + $this->log_gateway_error( $payment_method_id, 'gateway enable() did not persist enabled state' ); + return false; + } + + $pm_to_capability_key_map = $gateway->get_payment_method_capability_key_map(); + $this->tracks_event( + Track_Events::PAYMENT_METHOD_ENABLED, + [ + 'payment_method_id' => $payment_method_id, + 'capability_id' => $pm_to_capability_key_map[ $payment_method_id ] ?? null, + 'promo_id' => $promotion['promo_id'] ?? null, + ] + ); + + // Synchronize enabled payment method IDs across all gateways. + $this->sync_enabled_payment_method_across_gateways( $payment_method_id ); + + return true; + } + + /** + * Log a gateway error. + * + * @param string $payment_method_id The payment method ID. + * @param string $error_message The error message. + */ + private function log_gateway_error( string $payment_method_id, string $error_message ): void { + if ( function_exists( 'wc_get_logger' ) ) { + $logger = wc_get_logger(); + $logger->warning( + sprintf( + /* translators: 1: Payment method ID, 2: Error message */ + 'Failed to enable payment method %1$s: %2$s', + $payment_method_id, + $error_message + ), + [ 'source' => 'woopayments' ] + ); + } + } + + /** + * Synchronize enabled payment method ID across all gateways. + * + * @param string $payment_method_id The payment method ID to sync. + */ + private function sync_enabled_payment_method_across_gateways( string $payment_method_id ): void { + $gateway_map = WC_Payments::get_payment_gateway_map(); + if ( empty( $gateway_map ) ) { + return; + } + + foreach ( $gateway_map as $payment_gateway ) { + $enabled_pm_ids = $payment_gateway->get_upe_enabled_payment_method_ids(); + + // Skip if already present or not a valid array. + if ( ! is_array( $enabled_pm_ids ) || in_array( $payment_method_id, $enabled_pm_ids, true ) ) { + continue; + } + + $enabled_pm_ids[] = $payment_method_id; + $result = $payment_gateway->update_option( 'upe_enabled_payment_method_ids', $enabled_pm_ids ); + + if ( false === $result && function_exists( 'wc_get_logger' ) ) { + $logger = wc_get_logger(); + $logger->warning( + sprintf( + 'Failed to sync payment method %s to gateway %s', + $payment_method_id, + get_class( $payment_gateway ) + ), + [ 'source' => 'woopayments' ] + ); + } + } + } + + /** + * Activate any visible promotions for a payment method being enabled via settings. + * + * This method should be called BEFORE the payment method is enabled for checkout, + * as visible promotions are filtered out for already-enabled payment methods. + * + * Handles its own exception catching, logging, and tracking internally. + * + * @param string $payment_method_id The payment method ID (e.g., 'klarna'). + * @param bool $should_enable Whether to enable the payment method for checkout. + * + * @return bool True if a promotion was activated, false otherwise. + */ + public function maybe_activate_promotion_for_payment_method( string $payment_method_id, bool $should_enable = false ): bool { + $promotion = $this->find_promotion_by_payment_method( $payment_method_id ); + if ( null === $promotion ) { + return false; + } + + // Send request to server to apply the promotion discount. + // The server should also handle capability requesting if it is not already requested. + $wcpay_request = Request\Activate_PM_Promotion::create( $promotion['id'] ); + $wcpay_request->assign_hook( 'wcpay_activate_pm_promotion_request' ); + $response = $wcpay_request->handle_rest_request(); + if ( is_wp_error( $response ) ) { + $error_message = sprintf( + 'Server activation request failed [%s]: %s', + $response->get_error_code(), + $response->get_error_message() + ); + return $this->handle_promotion_activation_failure( $payment_method_id, $promotion, $error_message ); + } + + // Enable the payment method for checkout if requested. + if ( $should_enable && ! $this->enable_payment_method_gateway( $payment_method_id, $promotion ) ) { + return $this->handle_promotion_activation_failure( $payment_method_id, $promotion, 'Failed to enable payment method gateway' ); + } + + // Clear the promotions cache to ensure fresh data on next fetch. + $this->clear_cache(); + // Clear the account cache. + if ( null !== $this->account ) { + $this->account->clear_cache(); + } + + // Track successful activation. + $this->tracks_event( + Track_Events::PAYMENT_METHOD_PROMOTION_ACTIVATED, + [ + 'payment_method_id' => $payment_method_id, + 'promo_id' => $promotion['promo_id'] ?? null, + // The `unique_promo_id` is excluded intentionally as it's not a reliable without a specific promo type. + ] + ); + + return true; + } + + /** + * Handle promotion activation failure by logging and tracking. + * + * @param string $payment_method_id The payment method ID. + * @param array $promotion The promotion data. + * @param string $error_message The error message. + * + * @return bool Always returns false. + */ + private function handle_promotion_activation_failure( string $payment_method_id, array $promotion, string $error_message ): bool { + // Log the error. + if ( function_exists( 'wc_get_logger' ) ) { + $logger = wc_get_logger(); + /* translators: 1: Payment method ID, 2: Error message */ + $logger->error( sprintf( 'Failed to activate promotion for payment method %1$s: %2$s', $payment_method_id, $error_message ), [ 'source' => 'woopayments' ] ); + } + + // Track the failure. + $this->tracks_event( + Track_Events::PAYMENT_METHOD_PROMOTION_ACTIVATION_FAILED, + [ + 'payment_method_id' => $payment_method_id, + 'promo_id' => $promotion['promo_id'] ?? null, + ] + ); + + return false; + } + + /** + * Dismiss a promotion. + * + * @param string $id The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight'). + * + * @return bool True if dismissed, false if already dismissed or error. + */ + public function dismiss_promotion( string $id ): bool { + // Cannot dismiss a non-existing promotion. + $promotion = $this->find_promotion_by_id( $id ); + if ( null === $promotion ) { + return false; + } + + if ( ! $this->mark_promotion_dismissed( $id ) ) { + return false; + } + + // Track dismissal event. + $this->tracks_event( + Track_Events::PAYMENT_METHOD_PROMOTION_DISMISSED, + [ + 'payment_method_id' => $promotion['payment_method'] ?? null, + 'promo_id' => $promotion['promo_id'] ?? null, + 'promo_instance_id' => $id, + ] + ); + + // Reset memo to ensure fresh data on next access. + // The context hash change will also invalidate the transient cache. + $this->reset_memo(); + + return true; + } + + /** + * Mark a promotion as dismissed in local state. + * + * @param string $id The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight'). + * + * @return bool True if dismissed, false if already dismissed. + */ + private function mark_promotion_dismissed( string $id ): bool { + // Don't dismiss if already dismissed. + if ( $this->is_promotion_dismissed( $id ) ) { + return false; + } + + $dismissals = $this->get_promotion_dismissals(); + $dismissals[ $id ] = time(); + + return update_option( self::PROMOTION_DISMISSALS_OPTION, $dismissals, false ); + } + + /** + * Get all promotion dismissals. + * + * @return array Associative array of [id => timestamp]. + */ + public function get_promotion_dismissals(): array { + return get_option( self::PROMOTION_DISMISSALS_OPTION, [] ); + } + + /** + * Check if a promotion has been dismissed. + * + * Being dismissed means having an entry in the dismissals option with a timestamp into the past. + * + * @param string $id The promotion unique identifier. + * + * @return bool True if dismissed, false otherwise. + */ + public function is_promotion_dismissed( string $id ): bool { + $dismissals = $this->get_promotion_dismissals(); + + return isset( $dismissals[ $id ] ) && is_int( $dismissals[ $id ] ) && $dismissals[ $id ] > 0 && $dismissals[ $id ] <= time(); + } + + /** + * Check whether the promotion data is valid. + * Validates required fields based on promotion type. + * + * @param mixed $promotion_data The promotion data. + * + * @return bool Whether the promotion data is valid. + */ + private function validate_promotion( $promotion_data ): bool { + if ( ! is_array( $promotion_data ) || empty( $promotion_data ) ) { + return false; + } + + // Required fields for all promotions. + $required_fields = [ 'id', 'promo_id', 'payment_method', 'type', 'title', 'description', 'tc_url' ]; + + foreach ( $required_fields as $field ) { + if ( ! isset( $promotion_data[ $field ] ) || ! is_string( $promotion_data[ $field ] ) ) { + return false; + } + } + + // Validate type is supported. + $valid_types = [ 'spotlight', 'badge' ]; + if ( ! in_array( $promotion_data['type'], $valid_types, true ) ) { + return false; + } + + return true; + } + + /** + * Generate a hash from the store context data. + * + * @param array $context The store context data. + * + * @return string The context hash. + */ + private function generate_context_hash( array $context ): string { + // Include only certain entries in the context hash. + // We need only discrete, user-interaction dependent data. + // Do not include information that changes automatically (e.g., time since activation, etc.). + return md5( + wp_json_encode( + [ + 'dismissals' => $context['dismissals'] ?? [], + 'locale' => $context['locale'] ?? '', + ] + ) + ); + } + + /** + * Get list of valid payment method IDs from the gateway. + * + * @return array List of valid payment method IDs. + */ + private function get_valid_payment_method_ids(): array { + if ( null === $this->gateway ) { + $this->gateway = WC_Payments::get_gateway(); + } + + if ( null === $this->gateway || ! is_callable( [ $this->gateway, 'get_upe_available_payment_methods' ] ) ) { + return []; + } + + $result = $this->gateway->get_upe_available_payment_methods(); + + return is_array( $result ) ? $result : []; + } + + /** + * Get list of enabled payment method IDs. + * + * @return array List of enabled payment method IDs. + */ + private function get_enabled_payment_method_ids(): array { + if ( null === $this->gateway ) { + $this->gateway = WC_Payments::get_gateway(); + } + + if ( null === $this->gateway || ! is_callable( [ $this->gateway, 'get_upe_enabled_payment_method_ids' ] ) ) { + return []; + } + + $result = $this->gateway->get_upe_enabled_payment_method_ids(); + + return is_array( $result ) ? $result : []; + } + + /** + * Get the account fees. + * + * @return array Account fees indexed by payment method ID. + */ + private function get_account_fees(): array { + if ( null === $this->account ) { + $this->account = WC_Payments::get_account_service(); + } + + if ( null === $this->account || ! is_callable( [ $this->account, 'get_fees' ] ) ) { + return []; + } + + $result = $this->account->get_fees(); + + return is_array( $result ) ? $result : []; + } + + /** + * Check if a payment method has an active discount. + * + * @param string $payment_method_id The payment method ID. + * @param array|null $account_fees Optional. Pre-fetched account fees. If null, will be fetched. + * + * @return bool True if the payment method has an active discount. + */ + private function payment_method_has_active_discount( string $payment_method_id, ?array $account_fees = null ): bool { + if ( null === $account_fees ) { + $account_fees = $this->get_account_fees(); + } + + if ( empty( $account_fees[ $payment_method_id ] ) ) { + return false; + } + + $pm_fees = $account_fees[ $payment_method_id ]; + + // Verify discount is a non-empty array. + if ( ! isset( $pm_fees['discount'] ) || ! is_array( $pm_fees['discount'] ) || empty( $pm_fees['discount'] ) ) { + return false; + } + + // Get first discount entry regardless of array key structure. + $first_discount = reset( $pm_fees['discount'] ); + if ( is_array( $first_discount ) && array_key_exists( 'discount', $first_discount ) && ! empty( $first_discount['discount'] ) ) { + return true; + } + + return false; + } + + /** + * Filter promotions by dismissal status, payment method validity, enabled status, discount status, + * and promo_id uniqueness per payment method. + * + * @param array $promotions Array of promotions. + * + * @return array Filtered promotions. + */ + private function filter_promotions( array $promotions ): array { + // Pre-fetch all data needed for filtering to avoid N+1 queries. + $enabled_pms = $this->get_enabled_payment_method_ids(); + $valid_pms = $this->get_valid_payment_method_ids(); + $account_fees = $this->get_account_fees(); + $seen_promo_ids = []; // Track first promo_id per PM. + $filtered = []; + + foreach ( $promotions as $promotion ) { + $id = $promotion['id'] ?? ''; + $pm_id = $promotion['payment_method'] ?? ''; + $promo_id = $promotion['promo_id'] ?? ''; + + // Filters ordered by performance cost (cheapest first, all use pre-fetched data). + + // 1. Skip promotions for already enabled payment methods. + if ( in_array( $pm_id, $enabled_pms, true ) ) { + continue; + } + + // 2. Skip invalid payment methods. + if ( ! in_array( $pm_id, $valid_pms, true ) ) { + continue; + } + + // 3. Skip dismissed promotions (WP cached option). + if ( $this->is_promotion_dismissed( $id ) ) { + continue; + } + + // 4. Skip promotions for payment methods that already have an active discount. + if ( $this->payment_method_has_active_discount( $pm_id, $account_fees ) ) { + continue; + } + + // 5. Track first promo_id per PM - keep all surfaces for that promo_id. + // Must be last as it has side effects (tracks seen promo_ids). + if ( ! isset( $seen_promo_ids[ $pm_id ] ) ) { + $seen_promo_ids[ $pm_id ] = $promo_id; + } + + // Skip if this is a different promo_id for an already-seen PM. + if ( $seen_promo_ids[ $pm_id ] !== $promo_id ) { + continue; + } + + $filtered[] = $promotion; + } + + return $filtered; + } + + /** + * Normalize promotions by applying fallbacks and deriving fields. + * + * @param array $promotions Array of promotions. + * + * @return array Normalized promotions. + */ + private function normalize_promotions( array $promotions ): array { + $normalized = []; + + foreach ( $promotions as $promotion ) { + // These fields are validated as required before normalization. + $pm_id = $promotion['payment_method']; + $tc_url = $promotion['tc_url']; + + // Add derived payment_method_title if not provided. + if ( empty( $promotion['payment_method_title'] ) ) { + $promotion['payment_method_title'] = $this->get_payment_method_title( $pm_id ); + } + + // Apply fallback for cta_label using the final payment_method_title. + if ( empty( $promotion['cta_label'] ) ) { + /* translators: %s is the payment method title, e.g., "Klarna" */ + $promotion['cta_label'] = sprintf( __( 'Enable %s', 'woocommerce-payments' ), $promotion['payment_method_title'] ); + } + + // Apply type-specific sanitization BEFORE tc_label fallback. + // This ensures we check against the sanitized description (which might lose the link). + $promotion = $this->sanitize_promotion( $promotion ); + + // Apply fallback for tc_label only if tc_url is not already in the sanitized description. + // If tc_url is in the description, leaving tc_label empty signals frontend to not add a link. + if ( empty( $promotion['tc_label'] ) ) { + if ( strpos( $promotion['description'], $tc_url ) === false ) { + $promotion['tc_label'] = __( 'See terms', 'woocommerce-payments' ); + } else { + // Explicitly set to empty string when skipping fallback. + $promotion['tc_label'] = ''; + } + } + + $normalized[] = $promotion; + } + + return $normalized; + } + + /** + * Sanitize a promotion's fields based on its type. + * + * @param array $promotion The promotion data. + * + * @return array Sanitized promotion. + */ + private function sanitize_promotion( array $promotion ): array { + $type = $promotion['type'] ?? ''; + + // Sanitize identifier fields strictly with sanitize_key. + $key_fields = [ 'id', 'promo_id', 'payment_method', 'type' ]; + foreach ( $key_fields as $field ) { + if ( isset( $promotion[ $field ] ) ) { + $promotion[ $field ] = sanitize_key( $promotion[ $field ] ); + } + } + + // Sanitize text fields (no HTML allowed). + $text_fields = [ 'payment_method_title', 'title', 'cta_label', 'tc_label', 'badge_text' ]; + foreach ( $text_fields as $field ) { + if ( isset( $promotion[ $field ] ) ) { + $promotion[ $field ] = sanitize_text_field( $promotion[ $field ] ); + } + } + + // Normalize badge_type: ensure it's a valid type, defaulting to 'success'. + $valid_badge_types = [ 'primary', 'success', 'light', 'warning', 'alert' ]; + $promotion['badge_type'] = isset( $promotion['badge_type'] ) && in_array( $promotion['badge_type'], $valid_badge_types, true ) + ? $promotion['badge_type'] + : 'success'; + + // Sanitize URL fields. + if ( isset( $promotion['tc_url'] ) ) { + $promotion['tc_url'] = esc_url_raw( $promotion['tc_url'] ); + } + if ( isset( $promotion['image'] ) ) { + $promotion['image'] = esc_url_raw( $promotion['image'] ); + } + + // Sanitize description based on type. + if ( isset( $promotion['description'] ) ) { + $promotion['description'] = $this->sanitize_description( $promotion['description'], $type ); + } + + // Sanitize footnote (same as spotlight description - allows light HTML). + if ( isset( $promotion['footnote'] ) ) { + $promotion['footnote'] = $this->sanitize_description( $promotion['footnote'], 'spotlight' ); + } + + return $promotion; + } + + /** + * Sanitize description field based on promotion type. + * + * Spotlight type allows light HTML: paragraphs, bold, italic, links, breaks. + * Badge type only allows links. + * + * @param string $description The description to sanitize. + * @param string $type The promotion type. + * + * @return string Sanitized description. + */ + private function sanitize_description( string $description, string $type ): string { + if ( 'spotlight' === $type ) { + // Allow light HTML for spotlight: paragraphs, bold, italic, links, breaks. + $allowed_html = [ + 'p' => [], + 'strong' => [], + 'b' => [], + 'em' => [], + 'i' => [], + 'a' => [ + 'href' => [], + 'target' => [], + 'rel' => [], + ], + 'br' => [], + ]; + return wp_kses( $description, $allowed_html ); + } + + if ( 'badge' === $type ) { + // Badge type: only allow links. + $allowed_html = [ + 'a' => [ + 'href' => [], + 'target' => [], + 'rel' => [], + ], + ]; + return wp_kses( $description, $allowed_html ); + } + + // Default: strip all HTML. + return sanitize_text_field( $description ); + } + + /** + * Get the human-readable title for a payment method. + * + * @param string $payment_method_id The payment method ID. + * + * @return string The payment method title or a fallback. + */ + private function get_payment_method_title( string $payment_method_id ): string { + $payment_method = WC_Payments::get_payment_method_by_id( $payment_method_id ); + + if ( false !== $payment_method && method_exists( $payment_method, 'get_title' ) ) { + return $payment_method->get_title(); + } + + // Fallback to formatted ID (e.g., 'klarna' -> 'Klarna'). + return ucfirst( str_replace( '_', ' ', $payment_method_id ) ); + } + + /** + * Send a Tracks event. + * + * By default Woo adds `url`, `blog_lang`, `blog_id`, `store_id`, `products_count`, and `wc_version` + * properties to every event. + * + * @todo This is a duplicate of the one in the WC_Payments_Account, WC_REST_Payments_Settings_Controller, and WC_Payments_Onboarding_Service classes. + * + * @param string $name The event name. + * @param array $properties Optional. The event custom properties. + * + * @return void + */ + private function tracks_event( string $name, array $properties = [] ) { + if ( ! function_exists( 'wc_admin_record_tracks_event' ) ) { + return; + } + + // Add default properties to every event. + $account_service = WC_Payments::get_account_service(); + $tracking_info = $account_service ? $account_service->get_tracking_info() : []; + + $properties = array_merge( + $properties, + [ + 'is_test_mode' => WC_Payments::mode()->is_test(), + 'jetpack_connected' => true, // Any PM promotions require a Jetpack connection. + 'wcpay_version' => WCPAY_VERSION_NUMBER, + 'woo_country_code' => WC()->countries->get_base_country(), + ], + $tracking_info ?? [] + ); + + wc_admin_record_tracks_event( $name, $properties ); + + Logger::info( 'Tracks event: ' . $name . ' with data: ' . wp_json_encode( WC_Payments_Utils::redact_array( $properties, [ 'woo_country_code' ] ) ) ); + } +} diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 99f53d1f7f9..2b87a8451ff 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -289,6 +289,13 @@ class WC_Payments { */ private static $incentives_service; + /** + * Instance of WC_Payments_PM_Promotions_Service, created in init function. + * + * @var WC_Payments_PM_Promotions_Service + */ + private static $pm_promotions_service; + /** * Instance of WC_Payments_Express_Checkout_Button_Helper, created in init function. * @@ -415,6 +422,8 @@ public static function init() { include_once __DIR__ . '/core/server/request/class-list-charge-refunds.php'; include_once __DIR__ . '/core/server/request/class-get-request.php'; include_once __DIR__ . '/core/server/request/class-request-utils.php'; + include_once __DIR__ . '/core/server/request/class-get-pm-promotions.php'; + include_once __DIR__ . '/core/server/request/class-activate-pm-promotion.php'; include_once __DIR__ . '/woopay/services/class-checkout-service.php'; @@ -520,6 +529,7 @@ public static function init() { include_once __DIR__ . '/core/service/class-wc-payments-customer-service-api.php'; include_once __DIR__ . '/class-duplicate-payment-prevention-service.php'; include_once __DIR__ . '/class-wc-payments-incentives-service.php'; + include_once __DIR__ . '/class-wc-payments-pm-promotions-service.php'; include_once __DIR__ . '/class-compatibility-service.php'; include_once __DIR__ . '/compat/multi-currency/wc-payments-multi-currency.php'; include_once __DIR__ . '/compat/multi-currency/class-wc-payments-currency-manager.php'; @@ -562,6 +572,7 @@ public static function init() { self::$woopay_util = new WooPay_Utilities(); self::$woopay_tracker = new WooPay_Tracker( self::get_wc_payments_http() ); self::$incentives_service = new WC_Payments_Incentives_Service( self::$database_cache ); + self::$pm_promotions_service = new WC_Payments_PM_Promotions_Service( null, self::$account ); self::$duplicate_payment_prevention_service = new Duplicate_Payment_Prevention_Service(); self::$duplicates_detection_service = new Duplicates_Detection_Service(); @@ -573,6 +584,7 @@ public static function init() { self::$fraud_service->init_hooks(); self::$onboarding_service->init_hooks(); self::$incentives_service->init_hooks(); + self::$pm_promotions_service->init_hooks(); self::$compatibility_service->init_hooks(); self::$customer_service->init_hooks(); self::$token_service->init_hooks(); @@ -746,6 +758,7 @@ function () { self::$onboarding_service, self::$order_service, self::$incentives_service, + self::$pm_promotions_service, self::$fraud_service, self::$database_cache ); @@ -1138,7 +1151,7 @@ public static function init_rest_api() { $accounts_controller->register_routes(); include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-settings-controller.php'; - $settings_controller = new WC_REST_Payments_Settings_Controller( self::$api_client, self::get_gateway(), self::$account ); + $settings_controller = new WC_REST_Payments_Settings_Controller( self::$api_client, self::get_gateway(), self::$account, self::$pm_promotions_service ); $settings_controller->register_routes(); include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-settings-option-controller.php'; @@ -1157,6 +1170,10 @@ public static function init_rest_api() { $capital_controller = new WC_REST_Payments_Capital_Controller( self::$api_client ); $capital_controller->register_routes(); + include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-pm-promotions-controller.php'; + $promotions_controller = new WC_REST_Payments_PM_Promotions_Controller( self::$api_client, self::$pm_promotions_service ); + $promotions_controller->register_routes(); + include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-onboarding-controller.php'; $onboarding_controller = new WC_REST_Payments_Onboarding_Controller( self::$api_client, self::$onboarding_service ); $onboarding_controller->register_routes(); diff --git a/includes/constants/class-track-events.php b/includes/constants/class-track-events.php index 758ff7bb6f6..16931d51f07 100644 --- a/includes/constants/class-track-events.php +++ b/includes/constants/class-track-events.php @@ -18,6 +18,9 @@ */ class Track_Events extends Base_Constant { // Payment method events. - public const PAYMENT_METHOD_ENABLED = 'wcpay_payment_method_enabled'; - public const PAYMENT_METHOD_DISABLED = 'wcpay_payment_method_disabled'; + public const PAYMENT_METHOD_ENABLED = 'wcpay_payment_method_enabled'; + public const PAYMENT_METHOD_DISABLED = 'wcpay_payment_method_disabled'; + public const PAYMENT_METHOD_PROMOTION_DISMISSED = 'wcpay_payment_method_promotion_dismissed'; + public const PAYMENT_METHOD_PROMOTION_ACTIVATED = 'wcpay_payment_method_promotion_activated'; + public const PAYMENT_METHOD_PROMOTION_ACTIVATION_FAILED = 'wcpay_payment_method_promotion_activation_failed'; } diff --git a/includes/core/server/class-request.php b/includes/core/server/class-request.php index 1dee5001a55..b1bfc136020 100644 --- a/includes/core/server/class-request.php +++ b/includes/core/server/class-request.php @@ -154,6 +154,7 @@ abstract class Request { WC_Payments_API_Client::TERMINAL_READERS_API => 'terminal/readers', WC_Payments_API_Client::MINIMUM_RECURRING_AMOUNT_API => 'subscriptions/minimum_amount', WC_Payments_API_Client::CAPITAL_API => 'capital', + WC_Payments_API_Client::PROMOTIONS_API => 'payment_method_promotions', WC_Payments_API_Client::WEBHOOK_FETCH_API => 'webhook/failed_events', WC_Payments_API_Client::DOCUMENTS_API => 'documents', WC_Payments_API_Client::VAT_API => 'vat', diff --git a/includes/core/server/request/class-activate-pm-promotion.md b/includes/core/server/request/class-activate-pm-promotion.md new file mode 100644 index 00000000000..cb8dd472f86 --- /dev/null +++ b/includes/core/server/request/class-activate-pm-promotion.md @@ -0,0 +1,34 @@ +# `Activate_PM_Promotion` request class + +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) + +## Description + +The `WCPay\Core\Server\Request\Activate_PM_Promotion` class is used to construct the request for activating a payment method (PM) promotion. When a promotion is activated, the associated payment method discount is applied to the merchant's account. + +## Parameters + +When creating `Activate_PM_Promotion` requests, the promotion ID must be provided to the `::create()` method. The identifier should be in the format used by the promotions system (e.g., `klarna-2026-promo__spotlight`). + +There are no additional parameters for this request. + +## Filter + +When using this request, provide the following filter and arguments: + +- Name: `wcpay_activate_pm_promotion_request` +- Arguments: None required (the promotion ID is available via `$request->get_id()`) + +## Example + +```php +use WCPay\Core\Server\Request\Activate_PM_Promotion; + +$request = Activate_PM_Promotion::create( $promotion_id ); +$request->assign_hook( 'wcpay_activate_pm_promotion_request' ); +$response = $request->send(); +``` + +## Related + +- [`Get_PM_Promotions`](class-get-pm-promotions.md) - Retrieves available PM promotions from the server \ No newline at end of file diff --git a/includes/core/server/request/class-activate-pm-promotion.php b/includes/core/server/request/class-activate-pm-promotion.php new file mode 100644 index 00000000000..617fd22f742 --- /dev/null +++ b/includes/core/server/request/class-activate-pm-promotion.php @@ -0,0 +1,54 @@ +id ) ) { + throw new \InvalidArgumentException( 'Promotion ID is required for activation' ); + } + return WC_Payments_API_Client::PROMOTIONS_API . '/' . $this->id . '/activate'; + } + + /** + * Returns the request's HTTP method. + */ + public function get_method(): string { + return 'POST'; + } + + /** + * Sets the promotion instance ID, which will be used in the request URL. + * + * @param string $id Sets the promotion instance ID, which will be used in the request URL. + */ + protected function set_id( string $id ) { + $this->id = $id; + } +} diff --git a/includes/core/server/request/class-get-pm-promotions.md b/includes/core/server/request/class-get-pm-promotions.md new file mode 100644 index 00000000000..2acca66c55f --- /dev/null +++ b/includes/core/server/request/class-get-pm-promotions.md @@ -0,0 +1,57 @@ +# `Get_PM_Promotions` request class + +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) + +## Description + +The `WCPay\Core\Server\Request\Get_PM_Promotions` class is used to construct the request for retrieving available payment method (PM) promotions from the WooCommerce Payments server. These promotions offer merchants discounts on processing fees for specific payment methods. + +## Parameters + +No ID is required for creation. Store context can be provided via the `set_store_context_params()` method. + +### Available Methods + +| Method | Description | +|--------|-------------| +| `set_store_context_params( array $context )` | Attaches store context (dismissals, locale, etc.) to the request | + +### Store Context Parameters + +The `set_store_context_params()` method accepts an array with the following keys: + +| Key | Type | Description | +|-----|------|-------------| +| `dismissals` | `array` | Map of dismissed promotion IDs to timestamps | +| `locale` | `string` | Store locale (e.g., `en_US`) | + +## Filter + +When using this request, provide the following filter and arguments: + +- Name: `wcpay_get_pm_promotions_request` +- Arguments: None required + +## Response + +This request returns the raw response (including headers) to allow access to caching directives like `cache-for`. + +## Example + +```php +use WCPay\Core\Server\Request\Get_PM_Promotions; + +$store_context = [ + 'dismissals' => [ 'promo1__spotlight' => 1234567890 ], + 'locale' => get_locale(), +]; + +$request = Get_PM_Promotions::create(); +$request->set_store_context_params( $store_context ); +$request->assign_hook( 'wcpay_get_pm_promotions_request' ); +$response = $request->handle_rest_request(); +``` + +## Related + +- [`Activate_PM_Promotion`](class-activate-pm-promotion.md) - Activates a PM promotion \ No newline at end of file diff --git a/includes/core/server/request/class-get-pm-promotions.php b/includes/core/server/request/class-get-pm-promotions.php new file mode 100644 index 00000000000..386e0984792 --- /dev/null +++ b/includes/core/server/request/class-get-pm-promotions.php @@ -0,0 +1,75 @@ + $value ) { + // If the key is not a string, skip it. + if ( ! is_string( $key ) ) { + continue; + } + // If the value is null or empty, skip it. + if ( is_null( $value ) || '' === $value ) { + continue; + } + + // JSON encode arrays (like dismissals) as the server expects strings. + if ( is_array( $value ) ) { + $encoded = wp_json_encode( $value ); + // Skip this parameter if encoding fails. + if ( false === $encoded ) { + continue; + } + $value = $encoded; + } + + $this->set_param( $key, $value ); + } + } +} diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index 7656961f39a..e93d4af2c7f 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -85,6 +85,7 @@ class WC_Payments_API_Client implements MultiCurrencyApiClientInterface { const RECOMMENDED_PAYMENT_METHODS = 'payment_methods/recommended'; const ADDRESS_AUTOCOMPLETE_TOKEN = 'address-autocomplete-token'; const STORE_SETUP_API = 'accounts/store_setup'; + const PROMOTIONS_API = 'payment_method_promotions'; /** * Common keys in API requests/responses that we might want to redact. diff --git a/tests/js/jest.config.js b/tests/js/jest.config.js index 0918377defd..3882f02ee5e 100644 --- a/tests/js/jest.config.js +++ b/tests/js/jest.config.js @@ -48,6 +48,12 @@ module.exports = { '/tests/e2e', '/tests/qit/e2e', ], + modulePathIgnorePatterns: [ + '/docker/', + '/vendor/', + '/.*/build/', + '/.*/build-module/', + ], watchPathIgnorePatterns: [ '/node_modules/', '/vendor/', diff --git a/tests/unit/admin/test-class-wc-payments-admin.php b/tests/unit/admin/test-class-wc-payments-admin.php index ebefb5aa799..d14264fb218 100644 --- a/tests/unit/admin/test-class-wc-payments-admin.php +++ b/tests/unit/admin/test-class-wc-payments-admin.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\MockObject\MockObject; use WCPay\Database_Cache; +use Automattic\Jetpack\Constants; /** * WC_Payments_Admin unit tests. @@ -58,6 +59,13 @@ class WC_Payments_Admin_Test extends WCPAY_UnitTestCase { */ private $mock_fraud_service; + /** + * Mock PM Promotions Service. + * + * @var WC_Payments_PM_Promotions_Service|MockObject; + */ + private $mock_pm_promotions_service; + /** * Mock database cache. * @@ -65,6 +73,13 @@ class WC_Payments_Admin_Test extends WCPAY_UnitTestCase { */ private $mock_database_cache; + /** + * Backup object of $GLOBALS['current_screen']. + * + * @var object + */ + private $current_screen_backup; + /** * @var WC_Payments_Admin */ @@ -76,6 +91,13 @@ public function set_up() { $menu = null; // phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited $submenu = null; // phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited + // Mock screen. + $this->current_screen_backup = $GLOBALS['current_screen'] ?? null; + $GLOBALS['current_screen'] = $this->get_screen_mock(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + if ( ! did_action( 'current_screen' ) ) { + do_action( 'current_screen', $GLOBALS['current_screen'] ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + } + $this->mock_api_client = $this->getMockBuilder( WC_Payments_API_Client::class ) ->disableOriginalConstructor() ->getMock(); @@ -104,6 +126,10 @@ public function set_up() { ->disableOriginalConstructor() ->getMock(); + $this->mock_pm_promotions_service = $this->getMockBuilder( WC_Payments_PM_Promotions_Service::class ) + ->disableOriginalConstructor() + ->getMock(); + $this->mock_database_cache = $this->getMockBuilder( Database_Cache::class ) ->disableOriginalConstructor() ->getMock(); @@ -123,14 +149,18 @@ public function set_up() { $this->mock_onboarding_service, $this->mock_order_service, $this->mock_incentives_service, + $this->mock_pm_promotions_service, $this->mock_fraud_service, $this->mock_database_cache ); } public function tear_down() { - unset( $_GET ); - set_current_screen( 'front' ); + // Restore screen backup. + if ( $this->current_screen_backup ) { + $GLOBALS['current_screen'] = $this->current_screen_backup; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + } + parent::tear_down(); } @@ -456,4 +486,75 @@ public function test_transactions_notification_badge_no_display() { $this->assertSame( 'Transactions', $transactions_menu_item ); } + + public function test_enqueue_wc_payment_settings_spotlight_does_not_enqueue_on_wrong_page() { + global $wp_scripts, $wp_styles; + + // Arrange. + $wp_scripts = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_styles = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + $_GET['page'] = 'wc-payments'; + $_GET['tab'] = 'products'; // Wrong WC settings tab. + + // Mock the current screen. + $GLOBALS['current_screen']->id = 'woocommerce_page_wc-settings'; + + // Mock the WooCommerce version to be at the minimum required version. + Constants::set_constant( 'WC_VERSION', '9.9.2' ); + + // Act. + $this->payments_admin->enqueue_wc_payment_settings_spotlight(); + + // Assert. + $this->assertFalse( wp_script_is( 'WCPAY_WC_PAYMENTS_SETTINGS_SPOTLIGHT', 'enqueued' ) ); + $this->assertFalse( wp_style_is( 'WCPAY_WC_PAYMENTS_SETTINGS_SPOTLIGHT', 'enqueued' ) ); + + // Clean up. + unset( $_GET['page'], $_GET['tab'] ); + Constants::clear_constants(); + } + + public function test_enqueue_wc_payment_settings_spotlight_does_not_enqueue_on_old_wc_version() { + global $wp_scripts, $wp_styles; + + // Arrange. + $wp_scripts = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wp_styles = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + $_GET['page'] = 'wc-payments'; + $_GET['tab'] = 'checkout'; + + // Mock the current screen. + $GLOBALS['current_screen']->id = 'woocommerce_page_wc-settings'; + + // Mock the WooCommerce version to NOT be at the minimum required version. + Constants::set_constant( 'WC_VERSION', '9.9.1' ); + + // Act. + $this->payments_admin->enqueue_wc_payment_settings_spotlight(); + + // Assert. + $this->assertFalse( wp_script_is( 'WCPAY_WC_PAYMENTS_SETTINGS_SPOTLIGHT', 'enqueued' ) ); + $this->assertFalse( wp_style_is( 'WCPAY_WC_PAYMENTS_SETTINGS_SPOTLIGHT', 'enqueued' ) ); + + // Clean up. + unset( $_GET['page'], $_GET['tab'] ); + Constants::clear_constants(); + } + + /** + * Returns an object mocking what we need from \WP_Screen. + * + * @return object + */ + private function get_screen_mock(): object { + $screen_mock = $this->getMockBuilder( \stdClass::class )->setMethods( [ 'in_admin', 'add_option' ] )->getMock(); + $screen_mock->method( 'in_admin' )->willReturn( true ); + foreach ( [ 'id', 'base', 'action', 'post_type' ] as $key ) { + $screen_mock->{$key} = ''; + } + + return $screen_mock; + } } diff --git a/tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller-integration.php b/tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller-integration.php new file mode 100644 index 00000000000..11eccfffaa5 --- /dev/null +++ b/tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller-integration.php @@ -0,0 +1,587 @@ +setExpectedIncorrectUsage( 'register_rest_route' ); + + // Store original gateway map to restore in tear_down. + $reflection = new ReflectionClass( WC_Payments::class ); + $property = $reflection->getProperty( 'payment_gateway_map' ); + $property->setAccessible( true ); + $this->original_gateway_map = $property->getValue(); + + // Set the user so that we can pass the authentication. + wp_set_current_user( 1 ); + + $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); + $this->mock_promotions_service = $this->createMock( WC_Payments_PM_Promotions_Service::class ); + + // Create mock gateway with available payment methods for integration tests. + $this->mock_gateway = $this->createMock( WC_Payment_Gateway_WCPay::class ); + $this->mock_gateway->method( 'get_upe_available_payment_methods' ) + ->willReturn( [ 'card', 'klarna', 'affirm', 'afterpay_clearpay' ] ); + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [] ); // No PMs enabled, so promotions will show. + + // Real service with mock gateway for integration tests. + $this->promotions_service = new WC_Payments_PM_Promotions_Service( $this->mock_gateway ); + + // Controller with real service for integration tests. + $this->controller = new WC_REST_Payments_PM_Promotions_Controller( + $this->mock_api_client, + $this->promotions_service + ); + + // Controller with mocked service for isolated endpoint tests. + $this->controller_with_mock = new WC_REST_Payments_PM_Promotions_Controller( + $this->mock_api_client, + $this->mock_promotions_service + ); + + // Register routes for test controllers so rest_do_request() works. + // We register both controllers - the mocked one for isolated tests. + $this->controller_with_mock->register_routes(); + } + + public function tear_down() { + parent::tear_down(); + + // Restore original gateway map to prevent test pollution. + $reflection = new ReflectionClass( WC_Payments::class ); + $property = $reflection->getProperty( 'payment_gateway_map' ); + $property->setAccessible( true ); + $property->setValue( null, $this->original_gateway_map ); + + delete_transient( WC_Payments_PM_Promotions_Service::PROMOTIONS_CACHE_KEY ); + delete_option( WC_Payments_PM_Promotions_Service::PROMOTION_DISMISSALS_OPTION ); + $this->promotions_service->reset_memo(); + } + + /** + * Helper to create a valid promotion array. + * + * @param array $overrides Optional overrides. + * + * @return array Promotion data. + */ + private function create_valid_promotion( array $overrides = [] ): array { + return array_merge( + [ + 'id' => 'test-promo__spotlight', + 'promo_id' => 'test-promo', + 'payment_method' => 'klarna', + 'payment_method_title' => 'Klarna', + 'type' => 'spotlight', + 'title' => 'Test Promotion', + 'description' => 'Test description', + 'cta_label' => 'Enable Now', + 'tc_url' => 'https://example.com/terms', + 'tc_label' => 'See terms', + ], + $overrides + ); + } + + /** + * Helper to set up the promotions cache with given promotions. + * + * @param array $promotions Array of promotions to cache. + */ + private function set_promotions_cache( array $promotions ): void { + // Generate the context hash to match what the service will generate. + $store_context = [ + 'dismissals' => $this->promotions_service->get_promotion_dismissals(), + 'locale' => get_locale(), + ]; + $context_hash = md5( + wp_json_encode( + [ + 'dismissals' => $store_context['dismissals'], + 'locale' => $store_context['locale'], + ] + ) + ); + + set_transient( + WC_Payments_PM_Promotions_Service::PROMOTIONS_CACHE_KEY, + [ + 'promotions' => $promotions, + 'context_hash' => $context_hash, + 'timestamp' => time(), + ], + DAY_IN_SECONDS + ); + } + + /** + * Helper to set up a mock payment gateway in WC_Payments for testing. + * + * @param string $payment_method_id The payment method ID (e.g., 'klarna'). + * @param MockObject|null $gateway_mock Optional gateway mock. Creates one if not provided. + * @param bool $enabled Whether the gateway should be mocked as enabled. Default true. + */ + private function set_payment_gateway_for_testing( string $payment_method_id, $gateway_mock = null, bool $enabled = true ): void { + if ( null === $gateway_mock ) { + $gateway_mock = $this->createMock( WC_Payment_Gateway_WCPay::class ); + $gateway_mock->method( 'enable' )->willReturn( true ); + $gateway_mock->method( 'get_option' ) + ->willReturnCallback( + function ( $key ) use ( $enabled ) { + return 'enabled' === $key ? ( $enabled ? 'yes' : 'no' ) : ''; + } + ); + $gateway_mock->method( 'get_option_key' )->willReturn( 'woocommerce_woocommerce_payments_' . $payment_method_id . '_settings' ); + $gateway_mock->method( 'get_payment_method_capability_key_map' )->willReturn( [] ); + $gateway_mock->method( 'get_upe_enabled_payment_method_ids' )->willReturn( [] ); + $gateway_mock->method( 'update_option' )->willReturn( true ); + } + + // Use reflection to access the private static property. + $reflection = new ReflectionClass( WC_Payments::class ); + $property = $reflection->getProperty( 'payment_gateway_map' ); + $property->setAccessible( true ); + + $gateway_map = $property->getValue(); + $gateway_map[ $payment_method_id ] = $gateway_mock; + $property->setValue( null, $gateway_map ); + } + + /** + * Helper to clean up the WC_Payments gateway map after testing. + * + * @param string $payment_method_id The payment method ID to remove. + */ + private function clear_payment_gateway_for_testing( string $payment_method_id ): void { + $reflection = new ReflectionClass( WC_Payments::class ); + $property = $reflection->getProperty( 'payment_gateway_map' ); + $property->setAccessible( true ); + + $gateway_map = $property->getValue(); + unset( $gateway_map[ $payment_method_id ] ); + $property->setValue( null, $gateway_map ); + } + + /* + * ========================================================================= + * GET PROMOTIONS ENDPOINT TESTS + * + * Unit tests use direct controller calls to verify logic with mocked service. + * Integration tests use rest_do_request() to verify REST routing and permissions. + * ========================================================================= + */ + + public function test_get_promotions_returns_200_response() { + $this->mock_promotions_service->method( 'get_visible_promotions' ) + ->willReturn( [ $this->create_valid_promotion() ] ); + + $request = new WP_REST_Request( 'GET', $this->rest_base ); + $response = $this->controller_with_mock->get_promotions( $request ); + + $this->assertSame( 200, $response->status ); + } + + public function test_get_promotions_returns_array_of_promotions() { + $promotions = [ + $this->create_valid_promotion( [ 'id' => 'promo1__spotlight' ] ), + $this->create_valid_promotion( + [ + 'id' => 'promo2__badge', + 'type' => 'badge', + ] + ), + ]; + + $this->mock_promotions_service->method( 'get_visible_promotions' ) + ->willReturn( $promotions ); + + $request = new WP_REST_Request( 'GET', $this->rest_base ); + $response = $this->controller_with_mock->get_promotions( $request ); + + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertCount( 2, $data ); + } + + public function test_get_promotions_returns_empty_array_when_no_promotions() { + $this->mock_promotions_service->method( 'get_visible_promotions' ) + ->willReturn( null ); + + $request = new WP_REST_Request( 'GET', $this->rest_base ); + $response = $this->controller_with_mock->get_promotions( $request ); + + // Controller converts null to empty array for consistent REST response. + $this->assertSame( [], $response->get_data() ); + } + + public function test_get_promotions_returns_promotion_with_all_fields() { + $promotion = $this->create_valid_promotion( + [ + 'footnote' => 'Test footnote', + 'image' => 'https://example.com/image.png', + ] + ); + + $this->mock_promotions_service->method( 'get_visible_promotions' ) + ->willReturn( [ $promotion ] ); + + $request = new WP_REST_Request( 'GET', $this->rest_base ); + $response = $this->controller_with_mock->get_promotions( $request ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'id', $data[0] ); + $this->assertArrayHasKey( 'promo_id', $data[0] ); + $this->assertArrayHasKey( 'payment_method', $data[0] ); + $this->assertArrayHasKey( 'payment_method_title', $data[0] ); + $this->assertArrayHasKey( 'type', $data[0] ); + $this->assertArrayHasKey( 'title', $data[0] ); + $this->assertArrayHasKey( 'description', $data[0] ); + $this->assertArrayHasKey( 'cta_label', $data[0] ); + $this->assertArrayHasKey( 'tc_url', $data[0] ); + $this->assertArrayHasKey( 'tc_label', $data[0] ); + $this->assertArrayHasKey( 'footnote', $data[0] ); + $this->assertArrayHasKey( 'image', $data[0] ); + } + + public function test_get_promotions_returns_401_for_unauthenticated_user() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', $this->rest_base ); + $response = rest_do_request( $request ); + + $this->assertSame( 401, $response->get_status() ); + } + + /* + * ========================================================================= + * ACTIVATE PROMOTION ENDPOINT TESTS + * ========================================================================= + */ + + public function test_activate_promotion_calls_service_with_id() { + $id = 'test-promo'; + + $this->mock_promotions_service->expects( $this->once() ) + ->method( 'activate_promotion' ) + ->with( $id ) + ->willReturn( true ); + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/activate' ); + $request->set_param( 'id', $id ); + + $response = $this->controller_with_mock->activate_promotion( $request ); + + $this->assertSame( 200, $response->status ); + } + + public function test_activate_promotion_returns_success_response() { + $id = 'test-promo'; + + $this->mock_promotions_service->method( 'activate_promotion' ) + ->willReturn( true ); + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/activate' ); + $request->set_param( 'id', $id ); + + $response = $this->controller_with_mock->activate_promotion( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + } + + public function test_activate_promotion_returns_401_for_unauthenticated_user() { + wp_set_current_user( 0 ); + $id = 'test-promo'; + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/activate' ); + $response = rest_do_request( $request ); + + $this->assertSame( 401, $response->get_status() ); + } + + /* + * ========================================================================= + * DISMISS PROMOTION ENDPOINT TESTS + * ========================================================================= + */ + + public function test_dismiss_promotion_calls_service_with_id() { + $id = 'test-promo__spotlight'; + + $this->mock_promotions_service->expects( $this->once() ) + ->method( 'dismiss_promotion' ) + ->with( $id ) + ->willReturn( true ); + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/dismiss' ); + $request->set_param( 'id', $id ); + + $response = $this->controller_with_mock->dismiss_promotion( $request ); + + $this->assertSame( 200, $response->status ); + } + + public function test_dismiss_promotion_returns_success_true_when_dismissed() { + $id = 'test-promo__spotlight'; + + $this->mock_promotions_service->method( 'dismiss_promotion' ) + ->willReturn( true ); + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/dismiss' ); + $request->set_param( 'id', $id ); + + $response = $this->controller_with_mock->dismiss_promotion( $request ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + } + + public function test_dismiss_promotion_returns_success_false_when_already_dismissed() { + $id = 'test-promo__spotlight'; + + $this->mock_promotions_service->method( 'dismiss_promotion' ) + ->willReturn( false ); + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/dismiss' ); + $request->set_param( 'id', $id ); + + $response = $this->controller_with_mock->dismiss_promotion( $request ); + $data = $response->get_data(); + + $this->assertFalse( $data['success'] ); + } + + public function test_dismiss_promotion_returns_401_for_unauthenticated_user() { + wp_set_current_user( 0 ); + $id = 'test-promo__spotlight'; + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/dismiss' ); + $response = rest_do_request( $request ); + + $this->assertSame( 401, $response->get_status() ); + } + + public function test_dismiss_promotion_integration_stores_dismissal() { + $id = 'test-promo__spotlight'; + + // Set up cache with a test promotion so dismiss_promotion can find it. + $this->set_promotions_cache( [ $this->create_valid_promotion() ] ); + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/dismiss' ); + $request->set_param( 'id', $id ); + + $this->controller->dismiss_promotion( $request ); + + $this->assertTrue( $this->promotions_service->is_promotion_dismissed( $id ) ); + } + + /* + * ========================================================================= + * ROUTE REGISTRATION TESTS + * ========================================================================= + */ + + public function test_register_routes_creates_get_endpoint() { + // Expect the "incorrect usage" notice since we're calling outside rest_api_init. + $this->setExpectedIncorrectUsage( 'register_rest_route' ); + + $this->controller->register_routes(); + + $routes = rest_get_server()->get_routes(); + $route = '/wc/v3/payments/pm-promotions'; + + $this->assertArrayHasKey( $route, $routes ); + $this->assertContains( 'GET', array_keys( $routes[ $route ][0]['methods'] ) ); + } + + public function test_register_routes_creates_activate_endpoint() { + // Expect the "incorrect usage" notice since we're calling outside rest_api_init. + $this->setExpectedIncorrectUsage( 'register_rest_route' ); + + $this->controller->register_routes(); + + $routes = rest_get_server()->get_routes(); + $route = '/wc/v3/payments/pm-promotions/(?P[a-zA-Z0-9_-]+)/activate'; + + $this->assertArrayHasKey( $route, $routes ); + $this->assertContains( 'POST', array_keys( $routes[ $route ][0]['methods'] ) ); + } + + public function test_register_routes_creates_dismiss_endpoint() { + // Expect the "incorrect usage" notice since we're calling outside rest_api_init. + $this->setExpectedIncorrectUsage( 'register_rest_route' ); + + $this->controller->register_routes(); + + $routes = rest_get_server()->get_routes(); + $route = '/wc/v3/payments/pm-promotions/(?P[a-zA-Z0-9_-]+)/dismiss'; + + $this->assertArrayHasKey( $route, $routes ); + $this->assertContains( 'POST', array_keys( $routes[ $route ][0]['methods'] ) ); + } + + /* + * ========================================================================= + * PERMISSION TESTS + * ========================================================================= + */ + + public function test_check_permission_returns_true_for_admin() { + // User 1 is an admin. + wp_set_current_user( 1 ); + + $result = $this->controller->check_permission(); + + $this->assertTrue( $result ); + } + + public function test_check_permission_returns_false_for_non_admin() { + // Create a subscriber user. + $subscriber_id = self::factory()->user->create( [ 'role' => 'subscriber' ] ); + wp_set_current_user( $subscriber_id ); + + $result = $this->controller->check_permission(); + + $this->assertFalse( $result ); + } + + public function test_check_permission_returns_false_for_guest() { + wp_set_current_user( 0 ); + + $result = $this->controller->check_permission(); + + $this->assertFalse( $result ); + } + + /* + * ========================================================================= + * FULL INTEGRATION TESTS + * ========================================================================= + */ + + public function test_full_workflow_get_dismiss_verify() { + // Set up cache with a test promotion. + $this->set_promotions_cache( [ $this->create_valid_promotion() ] ); + + // Step 1: Get promotions. + $get_response = $this->controller->get_promotions(); + + $promotions = $get_response->get_data(); + $this->assertNotNull( $promotions ); + $this->assertNotEmpty( $promotions ); + + // Step 2: Dismiss a promotion using the full id. + $first_promo_id = $promotions[0]['id']; + $dismiss_request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $first_promo_id . '/dismiss' ); + $dismiss_request->set_param( 'id', $first_promo_id ); + + $dismiss_response = $this->controller->dismiss_promotion( $dismiss_request ); + + $this->assertTrue( $dismiss_response->get_data()['success'] ); + + // Step 3: Verify dismissal was recorded. + $this->assertTrue( $this->promotions_service->is_promotion_dismissed( $first_promo_id ) ); + } + + public function test_full_workflow_activate_returns_success() { + // Set up cache with a test promotion. + $this->set_promotions_cache( [ $this->create_valid_promotion() ] ); + + $id = 'test-promo__spotlight'; + + // Mock the API request to return success. + $this->mock_wcpay_request( Activate_PM_Promotion::class, 1, $id, [] ); + + // Set up the payment gateway in WC_Payments so enable_payment_method_gateway can find it. + $this->set_payment_gateway_for_testing( 'klarna' ); + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/activate' ); + $request->set_param( 'id', $id ); + + $response = $this->controller->activate_promotion( $request ); + + // Clean up the gateway. + $this->clear_payment_gateway_for_testing( 'klarna' ); + + $this->assertTrue( $response->get_data()['success'] ); + } + + public function test_full_workflow_activate_returns_false_for_invalid_id() { + // Set up cache with a test promotion. + $this->set_promotions_cache( [ $this->create_valid_promotion() ] ); + + $id = 'non-existent-promo'; + + $request = new WP_REST_Request( 'POST', $this->rest_base . '/' . $id . '/activate' ); + $request->set_param( 'id', $id ); + + $response = $this->controller->activate_promotion( $request ); + + $this->assertFalse( $response->get_data()['success'] ); + } +} diff --git a/tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller.php b/tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller.php new file mode 100644 index 00000000000..42ffdecdb76 --- /dev/null +++ b/tests/unit/admin/test-class-wc-rest-payments-pm-promotions-controller.php @@ -0,0 +1,116 @@ +user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_user ); + + $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); + + // Create mock gateway with available payment methods. + $this->mock_gateway = $this->createMock( WC_Payment_Gateway_WCPay::class ); + $this->mock_gateway->method( 'get_upe_available_payment_methods' ) + ->willReturn( [ 'card', 'klarna', 'affirm', 'afterpay_clearpay' ] ); + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [] ); + + $this->promotions_service = new WC_Payments_PM_Promotions_Service( $this->mock_gateway ); + + $this->controller = new WC_REST_Payments_PM_Promotions_Controller( $this->mock_api_client, $this->promotions_service ); + } + + public function tear_down() { + parent::tear_down(); + delete_transient( WC_Payments_PM_Promotions_Service::PROMOTIONS_CACHE_KEY ); + delete_option( WC_Payments_PM_Promotions_Service::PROMOTION_DISMISSALS_OPTION ); + $this->promotions_service->reset_memo(); + } + + public function test_get_promotions_returns_promotions_from_service() { + // Mock promotions in the flat structure. + $mock_promotions = [ + [ + 'id' => 'test_promo__spotlight', + 'promo_id' => 'test_promo', + 'payment_method' => 'klarna', + 'type' => 'spotlight', + 'title' => 'Test Promotion', + 'description' => 'Test description', + 'cta_label' => 'Enable Klarna', + 'tc_url' => 'https://example.com/terms', + 'tc_label' => 'See terms', + ], + ]; + + // Create a mock service that returns the promotions directly. + $mock_service = $this->createMock( WC_Payments_PM_Promotions_Service::class ); + $mock_service->method( 'get_visible_promotions' ) + ->willReturn( $mock_promotions ); + + // Create controller with mock service. + $controller = new WC_REST_Payments_PM_Promotions_Controller( $this->mock_api_client, $mock_service ); + + $request = new WP_REST_Request( 'GET' ); + $response = $controller->get_promotions( $request ); + + $this->assertSame( 200, $response->status ); + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertCount( 1, $data ); + $this->assertSame( 'test_promo__spotlight', $data[0]['id'] ); + $this->assertSame( 'klarna', $data[0]['payment_method'] ); + $this->assertSame( 'spotlight', $data[0]['type'] ); + } + + public function test_get_promotions_returns_empty_array_when_no_promotions() { + // Create a mock service that returns null (no promotions). + $mock_service = $this->createMock( WC_Payments_PM_Promotions_Service::class ); + $mock_service->method( 'get_visible_promotions' ) + ->willReturn( null ); + + // Create controller with mock service. + $controller = new WC_REST_Payments_PM_Promotions_Controller( $this->mock_api_client, $mock_service ); + + $request = new WP_REST_Request( 'GET' ); + $response = $controller->get_promotions( $request ); + + $this->assertSame( 200, $response->status ); + $this->assertIsArray( $response->get_data() ); + $this->assertEmpty( $response->get_data() ); + } +} diff --git a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php index a24e1c62d98..3efce7c4313 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php @@ -96,6 +96,13 @@ class WC_REST_Payments_Settings_Controller_Test extends WCPAY_UnitTestCase { */ private $mock_session_service; + /** + * Mock PM Promotions Service. + * + * @var WC_Payments_PM_Promotions_Service|MockObject + */ + private $mock_pm_promotions_service; + /** * Domestic currency. * @@ -138,6 +145,7 @@ public function set_up() { $this->mock_localization_service = $this->createMock( WC_Payments_Localization_Service::class ); $this->mock_fraud_service = $this->createMock( WC_Payments_Fraud_Service::class ); $this->mock_duplicates_detection_service = $this->createMock( Duplicates_Detection_Service::class ); + $this->mock_pm_promotions_service = $this->createMock( WC_Payments_PM_Promotions_Service::class ); $mock_payment_methods = []; $payment_method_classes = [ @@ -193,7 +201,7 @@ public function set_up() { $this->mock_duplicates_detection_service, $mock_rate_limiter ); - $this->controller = new WC_REST_Payments_Settings_Controller( $this->mock_api_client, $this->gateway, $this->mock_wcpay_account ); + $this->controller = new WC_REST_Payments_Settings_Controller( $this->mock_api_client, $this->gateway, $this->mock_wcpay_account, $this->mock_pm_promotions_service ); $this->mock_api_client ->method( 'is_server_connected' ) @@ -399,6 +407,54 @@ public function test_update_settings_saves_enabled_payment_methods() { $this->assertEquals( [ Payment_Method::CARD, Payment_Method::IDEAL ], WC_Payments::get_gateway()->get_option( 'upe_enabled_payment_method_ids' ) ); } + public function test_update_settings_calls_promotion_activation_for_newly_enabled_payment_methods() { + // Set up initial state: CARD is already enabled. + WC_Payments::get_gateway()->update_option( 'upe_enabled_payment_method_ids', [ Payment_Method::CARD ] ); + + // Create a mock that expects maybe_activate_promotion_for_payment_method to be called only for IDEAL (the newly enabled method). + $mock_pm_promotions_service = $this->createMock( WC_Payments_PM_Promotions_Service::class ); + $mock_pm_promotions_service->expects( $this->once() ) + ->method( 'maybe_activate_promotion_for_payment_method' ) + ->with( Payment_Method::IDEAL ); + + // Create controller with the specific mock. + $controller = new WC_REST_Payments_Settings_Controller( + $this->mock_api_client, + $this->gateway, + $this->mock_wcpay_account, + $mock_pm_promotions_service + ); + + $request = new WP_REST_Request(); + $request->set_param( 'enabled_payment_method_ids', [ Payment_Method::CARD, Payment_Method::IDEAL ] ); + + $controller->update_settings( $request ); + } + + public function test_update_settings_does_not_call_promotion_activation_when_no_new_payment_methods() { + // Set up initial state: CARD is already enabled. + WC_Payments::get_gateway()->update_option( 'upe_enabled_payment_method_ids', [ Payment_Method::CARD ] ); + + // Create a mock that expects maybe_activate_promotion_for_payment_method to never be called. + $mock_pm_promotions_service = $this->createMock( WC_Payments_PM_Promotions_Service::class ); + $mock_pm_promotions_service->expects( $this->never() ) + ->method( 'maybe_activate_promotion_for_payment_method' ); + + // Create controller with the specific mock. + $controller = new WC_REST_Payments_Settings_Controller( + $this->mock_api_client, + $this->gateway, + $this->mock_wcpay_account, + $mock_pm_promotions_service + ); + + $request = new WP_REST_Request(); + // Request with the same enabled payment methods (no change). + $request->set_param( 'enabled_payment_method_ids', [ Payment_Method::CARD ] ); + + $controller->update_settings( $request ); + } + public function test_update_settings_fails_if_user_cannot_manage_woocommerce() { $cb = $this->create_can_manage_woocommerce_cap_override( false ); add_filter( 'user_has_cap', $cb ); diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php index aa490afd901..b9b54773fc9 100755 --- a/tests/unit/bootstrap.php +++ b/tests/unit/bootstrap.php @@ -92,6 +92,7 @@ function ( $default ) { require_once $_plugin_dir . 'includes/admin/tracks/class-tracker.php'; require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-reader-controller.php'; require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-files-controller.php'; + require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-pm-promotions-controller.php'; require_once $_plugin_dir . 'includes/reports/class-wc-rest-payments-reports-transactions-controller.php'; require_once $_plugin_dir . 'includes/reports/class-wc-rest-payments-reports-authorizations-controller.php'; require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-payment-intents-controller.php'; diff --git a/tests/unit/test-class-wc-payments-pm-promotions-service.php b/tests/unit/test-class-wc-payments-pm-promotions-service.php new file mode 100644 index 00000000000..5c2550a1681 --- /dev/null +++ b/tests/unit/test-class-wc-payments-pm-promotions-service.php @@ -0,0 +1,1440 @@ +getProperty( 'payment_gateway_map' ); + $property->setAccessible( true ); + $this->original_gateway_map = $property->getValue(); + + // Create and set an admin user (required for get_visible_promotions capability check). + $admin_user = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_user ); + + $this->mock_gateway = $this->createMock( WC_Payment_Gateway_WCPay::class ); + + // Default available payment methods for validation tests. + $this->mock_gateway->method( 'get_upe_available_payment_methods' ) + ->willReturn( [ Payment_Method::CARD, Payment_Method::KLARNA, Payment_Method::AFFIRM, Payment_Method::AFTERPAY, Payment_Method::LINK, Payment_Method::SEPA ] ); + + // Note: get_upe_enabled_payment_method_ids is NOT mocked here by default. + // Tests that need it must configure it explicitly via $this->mock_gateway->method(). + + // Default capability key map for tracks events. + $this->mock_gateway->method( 'get_payment_method_capability_key_map' ) + ->willReturn( [] ); + + $this->service = new WC_Payments_PM_Promotions_Service( $this->mock_gateway ); + } + + public function tear_down() { + parent::tear_down(); + + // Restore original gateway map to prevent test pollution. + $reflection = new ReflectionClass( WC_Payments::class ); + $property = $reflection->getProperty( 'payment_gateway_map' ); + $property->setAccessible( true ); + $property->setValue( null, $this->original_gateway_map ); + + delete_transient( WC_Payments_PM_Promotions_Service::PROMOTIONS_CACHE_KEY ); + delete_option( WC_Payments_PM_Promotions_Service::PROMOTION_DISMISSALS_OPTION ); + $this->service->reset_memo(); + } + + /** + * Helper method to invoke private methods for testing. + * + * @param string $method_name The method name. + * @param array $args The arguments to pass. + * + * @return mixed The method result. + */ + private function invoke_private_method( string $method_name, array $args = [] ) { + $reflection = new ReflectionClass( $this->service ); + $method = $reflection->getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( $this->service, $args ); + } + + /** + * Helper to create a valid promotion array. + * + * @param array $overrides Optional overrides. + * + * @return array Promotion data. + */ + private function create_valid_promotion( array $overrides = [] ): array { + return array_merge( + [ + 'id' => 'test-promo__spotlight', + 'promo_id' => 'test-promo', + 'payment_method' => Payment_Method::KLARNA, + 'type' => 'spotlight', + 'title' => 'Test Promotion', + 'description' => 'Test description', + 'cta_label' => 'Enable Now', + 'tc_url' => 'https://example.com/terms', + 'tc_label' => 'See terms', + ], + $overrides + ); + } + + /** + * Helper to set up the promotions cache with given promotions. + * + * @param array $promotions Array of promotions to cache. + */ + private function set_promotions_cache( array $promotions ): void { + // Generate the context hash to match what the service will generate. + $store_context = [ + 'dismissals' => $this->service->get_promotion_dismissals(), + 'locale' => get_locale(), + ]; + $context_hash = md5( + wp_json_encode( + [ + 'dismissals' => $store_context['dismissals'], + 'locale' => $store_context['locale'], + ] + ) + ); + + set_transient( + WC_Payments_PM_Promotions_Service::PROMOTIONS_CACHE_KEY, + [ + 'promotions' => $promotions, + 'context_hash' => $context_hash, + 'timestamp' => time(), + ], + DAY_IN_SECONDS + ); + } + + /** + * Helper to set up a mock payment gateway in WC_Payments for testing. + * + * @param string $payment_method_id The payment method ID (e.g., 'klarna'). + * @param MockObject|null $gateway_mock Optional gateway mock. Creates one if not provided. + * @param bool $enabled Whether the gateway should be mocked as enabled. Default true. + */ + private function set_payment_gateway_for_testing( string $payment_method_id, $gateway_mock = null, bool $enabled = true ): void { + if ( null === $gateway_mock ) { + $gateway_mock = $this->createMock( WC_Payment_Gateway_WCPay::class ); + $gateway_mock->method( 'enable' )->willReturn( true ); + $gateway_mock->method( 'get_option' ) + ->willReturnCallback( + function ( $key ) use ( $enabled ) { + return 'enabled' === $key ? ( $enabled ? 'yes' : 'no' ) : ''; + } + ); + $gateway_mock->method( 'get_option_key' )->willReturn( 'woocommerce_woocommerce_payments_' . $payment_method_id . '_settings' ); + $gateway_mock->method( 'get_payment_method_capability_key_map' )->willReturn( [] ); + $gateway_mock->method( 'get_upe_enabled_payment_method_ids' )->willReturn( [] ); + $gateway_mock->method( 'update_option' )->willReturn( true ); + } + + // Use reflection to access the private static property. + $reflection = new ReflectionClass( WC_Payments::class ); + $property = $reflection->getProperty( 'payment_gateway_map' ); + $property->setAccessible( true ); + + $gateway_map = $property->getValue(); + $gateway_map[ $payment_method_id ] = $gateway_mock; + $property->setValue( null, $gateway_map ); + } + + /** + * Helper to clean up the WC_Payments gateway map after testing. + * + * @param string $payment_method_id The payment method ID to remove. + */ + private function clear_payment_gateway_for_testing( string $payment_method_id ): void { + $reflection = new ReflectionClass( WC_Payments::class ); + $property = $reflection->getProperty( 'payment_gateway_map' ); + $property->setAccessible( true ); + + $gateway_map = $property->getValue(); + unset( $gateway_map[ $payment_method_id ] ); + $property->setValue( null, $gateway_map ); + } + + /* + * ========================================================================= + * VALIDATION TESTS + * ========================================================================= + */ + + public function test_validate_promotion_accepts_valid_spotlight() { + $promotion = $this->create_valid_promotion( [ 'type' => 'spotlight' ] ); + + $result = $this->invoke_private_method( 'validate_promotion', [ $promotion ] ); + + $this->assertTrue( $result ); + } + + public function test_validate_promotion_accepts_valid_badge() { + $promotion = $this->create_valid_promotion( [ 'type' => 'badge' ] ); + + $result = $this->invoke_private_method( 'validate_promotion', [ $promotion ] ); + + $this->assertTrue( $result ); + } + + public function test_validate_promotion_rejects_null() { + $result = $this->invoke_private_method( 'validate_promotion', [ null ] ); + + $this->assertFalse( $result ); + } + + public function test_validate_promotion_rejects_empty_array() { + $result = $this->invoke_private_method( 'validate_promotion', [ [] ] ); + + $this->assertFalse( $result ); + } + + public function test_validate_promotion_rejects_non_array() { + $result = $this->invoke_private_method( 'validate_promotion', [ 'string' ] ); + + $this->assertFalse( $result ); + } + + /** + * @dataProvider provider_required_fields + */ + public function test_validate_promotion_rejects_missing_required_field( string $field ) { + $promotion = $this->create_valid_promotion(); + unset( $promotion[ $field ] ); + + $result = $this->invoke_private_method( 'validate_promotion', [ $promotion ] ); + + $this->assertFalse( $result, "Should reject promotion missing required field: $field" ); + } + + public function provider_required_fields(): array { + return [ + 'id' => [ 'id' ], + 'promo_id' => [ 'promo_id' ], + 'payment_method' => [ 'payment_method' ], + 'type' => [ 'type' ], + 'title' => [ 'title' ], + 'description' => [ 'description' ], + 'tc_url' => [ 'tc_url' ], + ]; + } + + /** + * @dataProvider provider_required_fields + */ + public function test_validate_promotion_rejects_non_string_required_field( string $field ) { + $promotion = $this->create_valid_promotion(); + $promotion[ $field ] = 123; // Non-string value. + + $result = $this->invoke_private_method( 'validate_promotion', [ $promotion ] ); + + $this->assertFalse( $result, "Should reject promotion with non-string $field" ); + } + + public function test_validate_promotion_rejects_invalid_type() { + $promotion = $this->create_valid_promotion( [ 'type' => 'invalid_type' ] ); + + $result = $this->invoke_private_method( 'validate_promotion', [ $promotion ] ); + + $this->assertFalse( $result ); + } + + public function test_validate_promotion_accepts_missing_optional_fields() { + $promotion = [ + 'id' => 'test-promo__spotlight', + 'promo_id' => 'test-promo', + 'payment_method' => Payment_Method::KLARNA, + 'type' => 'spotlight', + 'title' => 'Test Promotion', + 'description' => 'Test description', + 'tc_url' => 'https://example.com/terms', + // cta_label, tc_label, footnote, image are optional. + ]; + + $result = $this->invoke_private_method( 'validate_promotion', [ $promotion ] ); + + $this->assertTrue( $result ); + } + + /* + * ========================================================================= + * FILTERING TESTS + * ========================================================================= + */ + + public function test_filter_promotions_removes_invalid_payment_method() { + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [] ); + + $promotions = [ + $this->create_valid_promotion( [ 'payment_method' => 'invalid_pm' ] ), + ]; + + $result = $this->invoke_private_method( 'filter_promotions', [ $promotions ] ); + + $this->assertEmpty( $result ); + } + + public function test_filter_promotions_removes_already_enabled_pm() { + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [ Payment_Method::KLARNA ] ); // Klarna is enabled. + + $promotions = [ + $this->create_valid_promotion( [ 'payment_method' => Payment_Method::KLARNA ] ), + ]; + + $result = $this->invoke_private_method( 'filter_promotions', [ $promotions ] ); + + $this->assertEmpty( $result ); + } + + public function test_filter_promotions_keeps_not_enabled_pm() { + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [ Payment_Method::CARD ] ); // Only card is enabled. + + $promotions = [ + $this->create_valid_promotion( [ 'payment_method' => Payment_Method::KLARNA ] ), + ]; + + $result = $this->invoke_private_method( 'filter_promotions', [ $promotions ] ); + + $this->assertCount( 1, $result ); + $this->assertSame( Payment_Method::KLARNA, $result[0]['payment_method'] ); + } + + public function test_filter_promotions_keeps_first_promo_id_per_pm() { + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [] ); + + $promotions = [ + $this->create_valid_promotion( + [ + 'id' => 'first-promo__spotlight', + 'promo_id' => 'first-promo', + 'payment_method' => Payment_Method::KLARNA, + ] + ), + $this->create_valid_promotion( + [ + 'id' => 'second-promo__spotlight', + 'promo_id' => 'second-promo', + 'payment_method' => Payment_Method::KLARNA, + ] + ), + ]; + + $result = $this->invoke_private_method( 'filter_promotions', [ $promotions ] ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'first-promo', $result[0]['promo_id'] ); + } + + public function test_filter_promotions_keeps_all_surfaces_for_same_promo_id() { + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [] ); + + $promotions = [ + $this->create_valid_promotion( + [ + 'id' => 'promo__spotlight', + 'promo_id' => 'promo', + 'payment_method' => Payment_Method::KLARNA, + 'type' => 'spotlight', + ] + ), + $this->create_valid_promotion( + [ + 'id' => 'promo__badge', + 'promo_id' => 'promo', + 'payment_method' => Payment_Method::KLARNA, + 'type' => 'badge', + ] + ), + ]; + + $result = $this->invoke_private_method( 'filter_promotions', [ $promotions ] ); + + $this->assertCount( 2, $result ); + $this->assertSame( 'spotlight', $result[0]['type'] ); + $this->assertSame( 'badge', $result[1]['type'] ); + } + + public function test_filter_promotions_allows_different_pm_same_promo_id() { + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [] ); + + $promotions = [ + $this->create_valid_promotion( + [ + 'id' => 'promo__klarna', + 'promo_id' => 'promo', + 'payment_method' => Payment_Method::KLARNA, + ] + ), + $this->create_valid_promotion( + [ + 'id' => 'promo__affirm', + 'promo_id' => 'promo', + 'payment_method' => Payment_Method::AFFIRM, + ] + ), + ]; + + $result = $this->invoke_private_method( 'filter_promotions', [ $promotions ] ); + + $this->assertCount( 2, $result ); + } + + public function test_filter_promotions_removes_pm_with_active_discount() { + // Create a mock account that returns fees with a discount for klarna. + $mock_account = $this->createMock( WC_Payments_Account::class ); + $mock_account->method( 'get_fees' ) + ->willReturn( + [ + 'klarna' => [ + 'base' => [ + 'percentage_rate' => 0.029, + 'fixed_rate' => 30, + ], + 'discount' => [ + [ + 'discount' => 50, + 'end_time' => strtotime( '+30 days' ), + 'volume_currency' => 'usd', + ], + ], + ], + ] + ); + + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [] ); + + // Create service with mock account. + $service = new WC_Payments_PM_Promotions_Service( $this->mock_gateway, $mock_account ); + + $promotions = [ + $this->create_valid_promotion( + [ + 'id' => 'promo__klarna', + 'promo_id' => 'promo', + 'payment_method' => Payment_Method::KLARNA, + ] + ), + $this->create_valid_promotion( + [ + 'id' => 'promo__affirm', + 'promo_id' => 'promo', + 'payment_method' => Payment_Method::AFFIRM, + ] + ), + ]; + + $reflection = new ReflectionClass( $service ); + $method = $reflection->getMethod( 'filter_promotions' ); + $method->setAccessible( true ); + + $result = $method->invokeArgs( $service, [ $promotions ] ); + + // Only affirm promotion should remain (klarna has active discount). + $this->assertCount( 1, $result ); + $this->assertSame( Payment_Method::AFFIRM, $result[0]['payment_method'] ); + } + + public function test_filter_promotions_keeps_pm_without_discount() { + // Create a mock account that returns fees without discounts. + $mock_account = $this->createMock( WC_Payments_Account::class ); + $mock_account->method( 'get_fees' ) + ->willReturn( + [ + Payment_Method::KLARNA => [ + 'base' => [ + 'percentage_rate' => 0.029, + 'fixed_rate' => 30, + ], + 'discount' => [], // Empty discount array. + ], + Payment_Method::AFFIRM => [ + 'base' => [ + 'percentage_rate' => 0.029, + 'fixed_rate' => 30, + ], + // No discount key at all. + ], + ] + ); + + $this->mock_gateway->method( 'get_upe_enabled_payment_method_ids' ) + ->willReturn( [] ); + + // Create service with mock account. + $service = new WC_Payments_PM_Promotions_Service( $this->mock_gateway, $mock_account ); + + $promotions = [ + $this->create_valid_promotion( + [ + 'id' => 'promo__klarna', + 'promo_id' => 'promo', + 'payment_method' => Payment_Method::KLARNA, + ] + ), + $this->create_valid_promotion( + [ + 'id' => 'promo__affirm', + 'promo_id' => 'promo', + 'payment_method' => Payment_Method::AFFIRM, + ] + ), + ]; + + $reflection = new ReflectionClass( $service ); + $method = $reflection->getMethod( 'filter_promotions' ); + $method->setAccessible( true ); + + $result = $method->invokeArgs( $service, [ $promotions ] ); + + // Both promotions should remain (neither has active discount). + $this->assertCount( 2, $result ); + } + + /* + * ========================================================================= + * NORMALIZATION TESTS + * ========================================================================= + */ + + public function test_normalize_promotions_adds_payment_method_title() { + $promotions = [ + $this->create_valid_promotion( [ 'payment_method' => Payment_Method::KLARNA ] ), + ]; + + $result = $this->invoke_private_method( 'normalize_promotions', [ $promotions ] ); + + $this->assertArrayHasKey( 'payment_method_title', $result[0] ); + // Fallback should capitalize the PM ID if no payment method object found. + $this->assertNotEmpty( $result[0]['payment_method_title'] ); + } + + public function test_normalize_promotions_keeps_existing_payment_method_title() { + $promotions = [ + $this->create_valid_promotion( + [ + 'payment_method' => Payment_Method::KLARNA, + 'payment_method_title' => 'Custom Klarna Title', + ] + ), + ]; + + $result = $this->invoke_private_method( 'normalize_promotions', [ $promotions ] ); + + $this->assertSame( 'Custom Klarna Title', $result[0]['payment_method_title'] ); + } + + public function test_normalize_promotions_applies_cta_label_fallback() { + $promotion = $this->create_valid_promotion( [ 'payment_method' => Payment_Method::KLARNA ] ); + unset( $promotion['cta_label'] ); + + $result = $this->invoke_private_method( 'normalize_promotions', [ [ $promotion ] ] ); + + $this->assertStringContainsString( 'Enable', $result[0]['cta_label'] ); + } + + public function test_normalize_promotions_keeps_existing_cta_label() { + $promotions = [ + $this->create_valid_promotion( [ 'cta_label' => 'Custom CTA' ] ), + ]; + + $result = $this->invoke_private_method( 'normalize_promotions', [ $promotions ] ); + + $this->assertSame( 'Custom CTA', $result[0]['cta_label'] ); + } + + public function test_normalize_promotions_applies_tc_label_fallback() { + $promotion = $this->create_valid_promotion(); + unset( $promotion['tc_label'] ); + + $result = $this->invoke_private_method( 'normalize_promotions', [ [ $promotion ] ] ); + + $this->assertArrayHasKey( 'tc_label', $result[0] ); + $this->assertNotEmpty( $result[0]['tc_label'] ); + } + + public function test_normalize_promotions_keeps_existing_tc_label() { + $promotions = [ + $this->create_valid_promotion( [ 'tc_label' => 'Custom Terms' ] ), + ]; + + $result = $this->invoke_private_method( 'normalize_promotions', [ $promotions ] ); + + $this->assertSame( 'Custom Terms', $result[0]['tc_label'] ); + } + + public function test_normalize_promotions_skips_tc_label_fallback_when_tc_url_in_description() { + $tc_url = 'https://example.com/terms'; + $promotions = [ + $this->create_valid_promotion( + [ + 'tc_url' => $tc_url, + 'description' => 'Get 50% off! See terms.', + ] + ), + ]; + // Remove tc_label to test fallback behavior. + unset( $promotions[0]['tc_label'] ); + + $result = $this->invoke_private_method( 'normalize_promotions', [ $promotions ] ); + + // tc_label should remain empty when tc_url is already in description. + $this->assertArrayHasKey( 'tc_label', $result[0] ); + $this->assertEmpty( $result[0]['tc_label'] ); + } + + public function test_normalize_promotions_applies_tc_label_fallback_when_tc_url_not_in_description() { + $promotions = [ + $this->create_valid_promotion( + [ + 'tc_url' => 'https://example.com/terms', + 'description' => 'Get 50% off on processing fees.', + ] + ), + ]; + // Remove tc_label to test fallback behavior. + unset( $promotions[0]['tc_label'] ); + + $result = $this->invoke_private_method( 'normalize_promotions', [ $promotions ] ); + + // tc_label should get fallback when tc_url is not in description. + $this->assertArrayHasKey( 'tc_label', $result[0] ); + $this->assertNotEmpty( $result[0]['tc_label'] ); + $this->assertSame( 'See terms', $result[0]['tc_label'] ); + } + + public function test_normalize_promotions_sanitizes_title_strips_all_html() { + $promotions = [ + $this->create_valid_promotion( + [ + 'title' => 'Test Title', + ] + ), + ]; + + $result = $this->invoke_private_method( 'normalize_promotions', [ $promotions ] ); + + // Title should strip all HTML. + $this->assertStringNotContainsString( '
    Content
    Text', + ] + ), + ]; + + $result = $this->invoke_private_method( 'normalize_promotions', [ $promotions ] ); + + // Script, div, span should be stripped. + $this->assertStringNotContainsString( '