From 4405c97033b6c3c9b1809bb99415a71d188d1aa7 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 3 Feb 2025 09:11:59 +0100 Subject: [PATCH 01/65] update: add payment method page to honor WC rate limiter (#10270) Co-authored-by: Guilherme Pressutto --- changelog/fix-add-payment-method-rate-limit | 4 ++++ includes/class-wc-payment-gateway-wcpay.php | 5 ++--- .../shopper-myaccount-saved-cards.spec.ts | 22 +++++++------------ 3 files changed, 14 insertions(+), 17 deletions(-) create mode 100644 changelog/fix-add-payment-method-rate-limit diff --git a/changelog/fix-add-payment-method-rate-limit b/changelog/fix-add-payment-method-rate-limit new file mode 100644 index 00000000000..0f0199d3b6d --- /dev/null +++ b/changelog/fix-add-payment-method-rate-limit @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +update: add payment method functionality to honor WC rate limit. diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index ef9c7652c39..ffadd9cc8d9 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -577,7 +577,6 @@ public function init_hooks() { add_action( 'wp_ajax_nopriv_update_order_status', [ $this, 'update_order_status' ] ); add_action( 'wp_ajax_create_setup_intent', [ $this, 'create_setup_intent_ajax' ] ); - add_action( 'wp_ajax_nopriv_create_setup_intent', [ $this, 'create_setup_intent_ajax' ] ); // Update the current request logged_in cookie after a guest user is created to avoid nonce inconsistencies. add_action( 'set_logged_in_cookie', [ $this, 'set_cookie_on_current_request' ] ); @@ -3892,8 +3891,8 @@ public function create_and_confirm_setup_intent() { */ public function create_setup_intent_ajax() { try { - $is_nonce_valid = check_ajax_referer( 'wcpay_create_setup_intent_nonce', false, false ); - if ( ! $is_nonce_valid ) { + $wc_add_payment_method_rate_limit_id = 'add_payment_method_' . get_current_user_id(); + if ( ! check_ajax_referer( 'wcpay_create_setup_intent_nonce', false, false ) || WC_Rate_Limiter::retried_too_soon( $wc_add_payment_method_rate_limit_id ) ) { throw new Add_Payment_Method_Exception( __( "We're not able to add this payment method. Please refresh the page and try again.", 'woocommerce-payments' ), 'invalid_referrer' diff --git a/tests/e2e-pw/specs/shopper/shopper-myaccount-saved-cards.spec.ts b/tests/e2e-pw/specs/shopper/shopper-myaccount-saved-cards.spec.ts index cdb4546afaf..9b66148c479 100644 --- a/tests/e2e-pw/specs/shopper/shopper-myaccount-saved-cards.spec.ts +++ b/tests/e2e-pw/specs/shopper/shopper-myaccount-saved-cards.spec.ts @@ -106,28 +106,17 @@ test.describe( 'Shopper can save and delete cards', () => { // Verify that the card was not added await expect( shopperPage.getByText( - 'You cannot add a new payment method so soon after the previous one. Please wait for 20 seconds.' + "We're not able to add this payment method. Please refresh the page and try again." ) ).toBeVisible(); - await expect( - shopperPage.getByText( - `${ config.cards.basic2.expires.month }/${ config.cards.basic2.expires.year }` - ) - ).not.toBeVisible(); - // cleanup for the next tests await goToMyAccount( shopperPage, 'payment-methods' ); await deleteSavedCard( shopperPage, config.cards.basic ); - // TODO: The following test is failing because of a bug in WooPayments, even if WC is showing an exception - // that a second card is not allowed to be saved in 20 seconds, it is saved, and the list is not empty. - /*await expect( + await expect( shopperPage.getByText( 'No saved methods found.' ) - ).toBeVisible();*/ - - // Instead, continue the cleanup for the next tests - await deleteSavedCard( shopperPage, config.cards.basic2 ); + ).toBeVisible(); } ); Object.entries( cards ).forEach( @@ -163,6 +152,11 @@ test.describe( 'Shopper can save and delete cards', () => { 'You cannot add a new payment method so soon after the previous one. Please wait for 20 seconds.' ) ).not.toBeVisible(); + await expect( + shopperPage.getByText( + "We're not able to add this payment method. Please refresh the page and try again." + ) + ).not.toBeVisible(); await expect( shopperPage.getByText( From b8b40ce64d24586e863eb4f934e8166af77ee156 Mon Sep 17 00:00:00 2001 From: Michael Pretty Date: Tue, 4 Feb 2025 03:09:14 -0500 Subject: [PATCH 02/65] Set orderby to `none` for order queries where checking if order exists. (#10051) --- changelog/fix-10046-set-orderby-none-for-exist-checks | 4 ++++ includes/class-wc-payments-incentives-service.php | 2 ++ 2 files changed, 6 insertions(+) create mode 100644 changelog/fix-10046-set-orderby-none-for-exist-checks diff --git a/changelog/fix-10046-set-orderby-none-for-exist-checks b/changelog/fix-10046-set-orderby-none-for-exist-checks new file mode 100644 index 00000000000..cec6f072c5c --- /dev/null +++ b/changelog/fix-10046-set-orderby-none-for-exist-checks @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Set orderby to `none` for order queries where checking if order exists. diff --git a/includes/class-wc-payments-incentives-service.php b/includes/class-wc-payments-incentives-service.php index 55a61e96ff7..2d082811200 100644 --- a/includes/class-wc-payments-incentives-service.php +++ b/includes/class-wc-payments-incentives-service.php @@ -275,6 +275,7 @@ private function has_wcpay(): bool { 'payment_method' => 'woocommerce_payments', 'return' => 'ids', 'limit' => 1, + 'orderby' => 'none', ] ) ) ) { @@ -319,6 +320,7 @@ private function get_store_context(): array { 'date_created' => '>=' . strtotime( '-90 days' ), 'return' => 'ids', 'limit' => 1, + 'orderby' => 'none', ] ) ), From f9c8d29201934e556786f77599e90db7cd0be220 Mon Sep 17 00:00:00 2001 From: deepakpathania <68396823+deepakpathania@users.noreply.github.com> Date: Tue, 4 Feb 2025 16:28:07 +0530 Subject: [PATCH 03/65] Update handling for 0 feeAmount to be consistent with details page. (#10293) --- changelog/update-remove-NA-for-zero-amounts | 4 ++++ client/transactions/list/index.tsx | 10 ++++------ 2 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 changelog/update-remove-NA-for-zero-amounts diff --git a/changelog/update-remove-NA-for-zero-amounts b/changelog/update-remove-NA-for-zero-amounts new file mode 100644 index 00000000000..ecde7e866c2 --- /dev/null +++ b/changelog/update-remove-NA-for-zero-amounts @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update handling for 0 feeAmount to be consistent with details page. diff --git a/client/transactions/list/index.tsx b/client/transactions/list/index.tsx index 9d0be7a2196..030291e6522 100644 --- a/client/transactions/list/index.tsx +++ b/client/transactions/list/index.tsx @@ -400,12 +400,10 @@ export const TransactionsList = ( return { value: feeAmount, display: clickable( - 0 !== feeAmount - ? formatCurrency( - isCardReader ? txn.amount : txn.fees * -1, - currency - ) - : __( 'N/A', 'woocommerce-payments' ) + formatCurrency( + isCardReader ? txn.amount : txn.fees * -1, + currency + ) ), }; }; From 6035097fa720a0c0f1ab047b28171beedbcb2577 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Tue, 4 Feb 2025 13:37:49 +0100 Subject: [PATCH 04/65] Cache get WC orders for incentives (#10231) Co-authored-by: Vlad Olaru Co-authored-by: Vlad Olaru --- changelog/add-cache-wc-orders | 4 + includes/class-database-cache.php | 5 + .../class-wc-payments-incentives-service.php | 71 +++++++-- ...t-class-wc-payments-incentives-service.php | 137 +++++++++++++++++- 4 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 changelog/add-cache-wc-orders diff --git a/changelog/add-cache-wc-orders b/changelog/add-cache-wc-orders new file mode 100644 index 00000000000..84f5680c979 --- /dev/null +++ b/changelog/add-cache-wc-orders @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Cache calls to wc_get_orders in the incentives class diff --git a/includes/class-database-cache.php b/includes/class-database-cache.php index e29bdbfc374..04cc48e1382 100644 --- a/includes/class-database-cache.php +++ b/includes/class-database-cache.php @@ -405,6 +405,11 @@ private function get_ttl( string $key, array $cache_contents ): int { case self::CONNECT_INCENTIVE_KEY: $ttl = $cache_contents['data']['ttl'] ?? HOUR_IN_SECONDS * 6; break; + case self::CONNECT_INCENTIVE_KEY . '_has_orders': + // If has orders, cache for 90 days since it won't change. + // If no orders, cache for an hour to check again soon. + $ttl = $cache_contents['data'] ? DAY_IN_SECONDS * 90 : HOUR_IN_SECONDS; + break; case self::PAYMENT_PROCESS_FACTORS_KEY: $ttl = 2 * HOUR_IN_SECONDS; break; diff --git a/includes/class-wc-payments-incentives-service.php b/includes/class-wc-payments-incentives-service.php index 2d082811200..aabad6fdfcf 100644 --- a/includes/class-wc-payments-incentives-service.php +++ b/includes/class-wc-payments-incentives-service.php @@ -305,6 +305,8 @@ private function has_wcpay_account_data(): bool { * @return array The store context. */ private function get_store_context(): array { + $has_orders = $this->get_cached_has_orders(); + return [ // Store ISO-2 country code, e.g. `US`. 'country' => WC()->countries->get_base_country(), @@ -313,23 +315,70 @@ private function get_store_context(): array { // WooCommerce active for duration in seconds. 'active_for' => time() - get_option( 'woocommerce_admin_install_timestamp', time() ), // Whether the store has paid orders in the last 90 days. - 'has_orders' => ! empty( - wc_get_orders( - [ - 'status' => [ 'wc-completed', 'wc-processing' ], - 'date_created' => '>=' . strtotime( '-90 days' ), - 'return' => 'ids', - 'limit' => 1, - 'orderby' => 'none', - ] - ) - ), + 'has_orders' => $has_orders, // Whether the store has at least one payment gateway enabled. 'has_payments' => ! empty( WC()->payment_gateways()->get_available_payment_gateways() ), 'has_wcpay' => $this->has_wcpay(), ]; } + /** + * Get cached value of whether the store has orders. + * If orders exist, cache for a week since it won't change. + * If no orders, cache for an hour to check again soon. + * + * @return bool Whether the store has orders. + */ + public function get_cached_has_orders(): bool { + $cache_key = Database_Cache::CONNECT_INCENTIVE_KEY . '_has_orders'; + $cached_value = $this->database_cache->get( $cache_key ); + + if ( null !== $cached_value ) { + return filter_var( $cached_value['data'], FILTER_VALIDATE_BOOLEAN ); + } + + // We need to determine the value. + // Start with the assumption that the store doesn't have orders in the timeframe we look at. + $has_orders = false; + // By default, we will check for new orders every 6 hours. + $expiration = 6 * HOUR_IN_SECONDS; + + // Get the latest completed, processing, or refunded order. + $latest_order = wc_get_orders( + [ + 'status' => [ 'wc-completed', 'wc-processing', 'wc-refunded' ], + 'limit' => 1, + 'orderby' => 'date', + 'order' => 'DESC', + ] + ); + if ( ! empty( $latest_order ) ) { + $latest_order = reset( $latest_order ); + // If the latest order is within the timeframe we look at, we consider the store to have orders. + // Otherwise, it clearly doesn't have orders. + if ( $latest_order instanceof WC_Abstract_Order + && strtotime( (string) $latest_order->get_date_created() ) >= strtotime( '-90 days' ) ) { + + $has_orders = true; + + // For ultimate efficiency, we will check again after 90 days from the latest order + // because in all that time we will consider the store to have orders regardless of new orders. + $expiration = strtotime( (string) $latest_order->get_date_created() ) + 90 * DAY_IN_SECONDS - time(); + } + } + + $cache_contents = [ + 'data' => $has_orders, + 'fetched' => time(), + 'errored' => false, + 'ttl' => $expiration, + ]; + + $this->database_cache->add( $cache_key, $cache_contents ); + + return $has_orders; + } + /** * Generate a hash from the store context data. * diff --git a/tests/unit/test-class-wc-payments-incentives-service.php b/tests/unit/test-class-wc-payments-incentives-service.php index db61cbea6e2..e3db40fc280 100644 --- a/tests/unit/test-class-wc-payments-incentives-service.php +++ b/tests/unit/test-class-wc-payments-incentives-service.php @@ -161,7 +161,14 @@ public function test_get_cached_connect_incentive_from_cache() { public function test_get_cached_connect_incentive_doesnt_refresh_cache_on_same_content_hash() { $this->mock_database_cache ->method( 'get' ) - ->willReturn( $this->mock_incentive_data ); + ->willReturnCallback( + function ( $key ) { + if ( Database_Cache::CONNECT_INCENTIVE_KEY . '_has_orders' === $key ) { + return $this->mock_has_orders_cache_response( false ); + } + return $this->mock_incentive_data; + } + ); $this->mock_database_cache ->expects( $this->never() ) @@ -173,10 +180,31 @@ public function test_get_cached_connect_incentive_doesnt_refresh_cache_on_same_c ); } + /** + * Helper method to create a mock has_orders cache response. + * + * @param bool $has_orders Whether there are orders or not. + * @return array The mock cache response. + */ + private function mock_has_orders_cache_response( $has_orders ) { + return [ + 'data' => $has_orders, + 'fetched' => time(), + 'errored' => false, + 'ttl' => $has_orders ? WEEK_IN_SECONDS : HOUR_IN_SECONDS, + ]; + } + public function test_get_cached_connect_incentive_refreshes_cache_on_wrong_content_hash() { $this->mock_database_cache + ->expects( $this->atLeastOnce() ) ->method( 'get' ) - ->willReturn( array_merge( $this->mock_incentive_data, [ 'context_hash' => 'wrong_hash' ] ) ); + ->willReturnMap( + [ + [ Database_Cache::CONNECT_INCENTIVE_KEY . '_has_orders', false ], + [ Database_Cache::CONNECT_INCENTIVE_KEY, array_merge( $this->mock_incentive_data, [ 'context_hash' => 'wrong_hash' ] ) ], + ] + ); $this->mock_database_cache ->expects( $this->atLeastOnce() ) @@ -190,9 +218,16 @@ public function test_get_cached_connect_incentive_refreshes_cache_on_wrong_conte } public function test_get_cached_connect_incentive_refreshes_cache_on_missing_content_hash() { - $this->mock_database_cache - ->method( 'get' ) - ->willReturn( array_merge( $this->mock_incentive_data, [ 'context_hash' => null ] ) ); + $this->mock_database_cache + ->expects( $this->atLeastOnce() ) + ->method( 'get' ) + ->willReturnMap( + [ + [ Database_Cache::CONNECT_INCENTIVE_KEY . '_has_orders', false ], + + [ Database_Cache::CONNECT_INCENTIVE_KEY, array_merge( $this->mock_incentive_data, [ 'context_hash' => null ] ) ], + ] + ); $this->mock_database_cache ->expects( $this->atLeastOnce() ) @@ -247,6 +282,84 @@ public function test_fetch_connect_incentive_with_incentive_and_cache_for() { $this->assertSame( $expected, $result ); } + public function test_get_cached_has_orders_returns_cached_value() { + $this->mock_database_cache + ->expects( $this->once() ) + ->method( 'get' ) + ->with( Database_Cache::CONNECT_INCENTIVE_KEY . '_has_orders' ) + ->willReturn( $this->mock_has_orders_cache_response( true ) ); + + $this->mock_database_cache + ->expects( $this->never() ) + ->method( 'add' ); + + $result = $this->incentives_service->get_cached_has_orders(); + $this->assertTrue( $result ); + } + + public function test_get_cached_has_orders_caches_for_longer_when_has_orders() { + $this->mock_database_cache + ->method( 'get' ) + ->willReturn( null ); + + // Mock wc_get_orders to return a mocked order. + $mock_order = $this->createMock( WC_Order::class ); + $mock_order->method( 'get_id' )->willReturn( 1 ); + $date_created = new WC_DateTime( 'now - 30 day' ); + $mock_order->method( 'get_date_created' )->willReturn( $date_created ); + $this->mock_wc_get_orders( [ $mock_order ] ); + + $this->mock_database_cache + ->expects( $this->once() ) + ->method( 'add' ) + ->with( + Database_Cache::CONNECT_INCENTIVE_KEY . '_has_orders', + $this->callback( + function ( $value ) use ( $date_created ) { + return true === $value['data'] && + is_int( $value['fetched'] ) && + false === $value['errored'] && + // 90 days - 30 days = 60 days. + 60 * DAY_IN_SECONDS === $value['ttl']; + } + ) + ); + + $result = $this->incentives_service->get_cached_has_orders(); + $this->assertTrue( $result ); + + remove_all_filters( 'woocommerce_order_query' ); + } + + public function test_get_cached_has_orders_caches_when_no_orders() { + $this->mock_database_cache + ->method( 'get' ) + ->willReturn( null ); + + // Mock wc_get_orders to return an empty array. + $this->mock_wc_get_orders( [] ); + + $this->mock_database_cache + ->expects( $this->once() ) + ->method( 'add' ) + ->with( + Database_Cache::CONNECT_INCENTIVE_KEY . '_has_orders', + $this->callback( + function ( $value ) { + return false === $value['data'] && + is_int( $value['fetched'] ) && + false === $value['errored'] && + 6 * HOUR_IN_SECONDS === $value['ttl']; + } + ) + ); + + $result = $this->incentives_service->get_cached_has_orders(); + $this->assertFalse( $result ); + + remove_all_filters( 'woocommerce_order_query' ); + } + private function mock_database_cache_with( $incentive = null ) { $this->mock_database_cache ->method( 'get_or_add' ) @@ -262,6 +375,20 @@ function () use ( $response ) { ); } + /** + * Helper method to mock wc_get_orders function + * + * @param array $orders The orders to return from the mock. + */ + private function mock_wc_get_orders( $orders ) { + add_filter( + 'woocommerce_order_query', + function () use ( $orders ) { + return $orders; + } + ); + } + /** * Mocked incentive data. * From 7f392611a9817c04b086064f383298bd645d1ca9 Mon Sep 17 00:00:00 2001 From: Samir Merchant Date: Tue, 4 Feb 2025 08:32:42 -0500 Subject: [PATCH 05/65] Fixes undefined array key warning (#10229) --- changelog/fix-10228-undefined-array-key-warning | 4 ++++ includes/class-wc-payments-checkout.php | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog/fix-10228-undefined-array-key-warning diff --git a/changelog/fix-10228-undefined-array-key-warning b/changelog/fix-10228-undefined-array-key-warning new file mode 100644 index 00000000000..fc43b14489c --- /dev/null +++ b/changelog/fix-10228-undefined-array-key-warning @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fixes thrown PHP warning in checkout config. diff --git a/includes/class-wc-payments-checkout.php b/includes/class-wc-payments-checkout.php index 44d92d10b23..d80ec898145 100644 --- a/includes/class-wc-payments-checkout.php +++ b/includes/class-wc-payments-checkout.php @@ -246,7 +246,7 @@ public function get_payment_fields_js_config() { foreach ( WC()->checkout()->get_checkout_fields( 'billing' ) as $billing_field => $billing_field_options ) { if ( ! isset( $billing_field_options['enabled'] ) || $billing_field_options['enabled'] ) { $enabled_billing_fields[ $billing_field ] = [ - 'required' => $billing_field_options['required'], + 'required' => $billing_field_options['required'] ?? false, ]; } } From 1ff074e4e188baa17914bd7e044a32d923089cae Mon Sep 17 00:00:00 2001 From: Mike Moore Date: Tue, 4 Feb 2025 13:47:45 -0500 Subject: [PATCH 06/65] Add payment method logos to card label (#10005) Co-authored-by: Guilherme Pressutto --- .../add-9826-payment-methods-logos-component | 4 + changelog/update-cards-label | 4 + .../checkout/blocks/payment-method-label.js | 55 ++++- .../blocks/payment-methods-logos/index.ts | 1 + .../payment-methods-logos/logo-popover.tsx | 133 +++++++++++ .../payment-methods-logos.tsx | 143 ++++++++++++ .../blocks/payment-methods-logos/style.scss | 46 ++++ client/checkout/blocks/style.scss | 10 - .../blocks/test/payment-method-logos.test.tsx | 115 +++++++++ client/checkout/classic/event-handlers.js | 221 ++++++++++++++++++ client/checkout/classic/style.scss | 25 ++ .../test/index.test.tsx | 2 +- client/payment-methods-map.tsx | 2 +- .../activation-modal.test.js.snap | 4 +- .../__snapshots__/delete-modal.test.js.snap | 6 +- .../__tests__/payment-methods-section.test.js | 2 +- includes/class-wc-payment-gateway-wcpay.php | 2 +- .../class-cc-payment-method.php | 2 +- ...hant-payment-gateways-confirmation.spec.ts | 3 +- ...-subscriptions-purchase-free-trial.spec.ts | 4 +- ...bscriptions-purchase-no-signup-fee.spec.ts | 2 +- tests/e2e-pw/utils/shopper.ts | 4 +- .../test-class-upe-payment-gateway.php | 2 +- .../test-class-upe-split-payment-gateway.php | 2 +- .../test-class-wc-payment-gateway-wcpay.php | 2 +- .../unit/test-class-wc-payments-checkout.php | 2 +- 26 files changed, 760 insertions(+), 38 deletions(-) create mode 100644 changelog/add-9826-payment-methods-logos-component create mode 100644 changelog/update-cards-label create mode 100644 client/checkout/blocks/payment-methods-logos/index.ts create mode 100644 client/checkout/blocks/payment-methods-logos/logo-popover.tsx create mode 100644 client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx create mode 100644 client/checkout/blocks/payment-methods-logos/style.scss create mode 100644 client/checkout/blocks/test/payment-method-logos.test.tsx diff --git a/changelog/add-9826-payment-methods-logos-component b/changelog/add-9826-payment-methods-logos-component new file mode 100644 index 00000000000..51f66c6e070 --- /dev/null +++ b/changelog/add-9826-payment-methods-logos-component @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add payment method logos to checkout block card label. diff --git a/changelog/update-cards-label b/changelog/update-cards-label new file mode 100644 index 00000000000..ce09cd5fc3a --- /dev/null +++ b/changelog/update-cards-label @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update Credit Card / Debit Card label to Cards diff --git a/client/checkout/blocks/payment-method-label.js b/client/checkout/blocks/payment-method-label.js index acbf9f1ccc0..ecc339b0a32 100644 --- a/client/checkout/blocks/payment-method-label.js +++ b/client/checkout/blocks/payment-method-label.js @@ -5,6 +5,11 @@ import { Elements, PaymentMethodMessagingElement, } from '@stripe/react-stripe-js'; +import { PaymentMethodsLogos } from './payment-methods-logos'; +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 { normalizeCurrencyToMinorUnit } from '../utils'; import { useStripeForUPE } from 'wcpay/hooks/use-stripe-async'; import { getUPEConfig } from 'wcpay/utils/checkout'; @@ -13,6 +18,31 @@ import './style.scss'; import { useEffect, useMemo, useState } from '@wordpress/element'; import { getAppearance, getFontRulesFromPage } from 'wcpay/checkout/upe-styles'; +const paymentMethods = [ + { + name: 'visa', + component: Visa, + }, + { + name: 'mastercard', + component: Mastercard, + }, + { + name: 'amex', + component: Amex, + }, + { + name: 'discover', + component: Discover, + }, + // TODO: Missing Diners Club + // TODO: What other card payment methods should be here? +]; +const breakpointConfigs = [ + { breakpoint: 550, maxElements: 2 }, + { breakpoint: 330, maxElements: 1 }, +]; + const bnplMethods = [ 'affirm', 'afterpay_clearpay', 'klarna' ]; const PaymentMethodMessageWrapper = ( { upeName, @@ -53,6 +83,7 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { const [ appearance, setAppearance ] = useState( getUPEConfig( 'wcBlocksUPEAppearance' ) ); + const [ upeAppearanceTheme, setUpeAppearanceTheme ] = useState( getUPEConfig( 'wcBlocksUPEAppearanceTheme' ) ); @@ -106,13 +137,23 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { { __( 'Test Mode', 'woocommerce-payments' ) } ) } - { + { upeName === 'card' ? ( + + ) : ( + { + ) } void; + dataTestId?: string; +} + +export const LogoPopover: React.FC< LogoPopoverProps > = ( { + id, + className, + children, + anchor, + open, + onClose, + dataTestId, +} ) => { + const popoverRef = useRef< HTMLDivElement >( null ); + const [ isPositioned, setIsPositioned ] = useState( false ); + + const updatePosition = useCallback( () => { + const popover = popoverRef.current; + if ( ! popover || ! anchor ) { + return; + } + + const label = anchor.closest( 'label' ); + if ( ! label ) return; + + const labelRect = label.getBoundingClientRect(); + const labelStyle = window.getComputedStyle( label ); + const labelPaddingRight = parseInt( labelStyle.paddingRight, 10 ); + + popover.style.position = 'fixed'; + popover.style.right = `${ + window.innerWidth - ( labelRect.right - labelPaddingRight ) + }px`; + popover.style.top = `${ labelRect.top - 30 }px`; + popover.style.left = 'auto'; + + setIsPositioned( true ); + }, [ anchor ] ); + + useLayoutEffect( () => { + if ( open && anchor ) { + // Use requestAnimationFrame to ensure the DOM has updated before positioning + requestAnimationFrame( updatePosition ); + } + }, [ open, anchor, updatePosition ] ); + + useEffect( () => { + if ( open && anchor ) { + const observer = new MutationObserver( updatePosition ); + observer.observe( anchor, { + attributes: true, + childList: true, + subtree: true, + } ); + + window.addEventListener( 'resize', updatePosition ); + window.addEventListener( 'scroll', updatePosition ); + + const handleOutsideClick = ( event: MouseEvent ) => { + if ( + popoverRef.current && + ! popoverRef.current.contains( event.target as Node ) && + ! anchor.contains( event.target as Node ) + ) { + onClose?.(); + } + }; + + const handleEscapeKey = ( event: KeyboardEvent ) => { + if ( event.key === 'Escape' ) { + onClose?.(); + } + }; + + document.addEventListener( 'mousedown', handleOutsideClick ); + document.addEventListener( 'keydown', handleEscapeKey ); + + return () => { + observer.disconnect(); + window.removeEventListener( 'resize', updatePosition ); + window.removeEventListener( 'scroll', updatePosition ); + document.removeEventListener( 'mousedown', handleOutsideClick ); + document.removeEventListener( 'keydown', handleEscapeKey ); + }; + } + }, [ open, anchor, updatePosition, onClose ] ); + + if ( ! open ) { + return null; + } + + return ( +
5 + ? 5 + : React.Children.count( children ) + }, 38px)`, + left: 'auto', + } } + role="dialog" + aria-label="Supported Credit Card Brands" + data-testid={ dataTestId } + > + { children } +
+ ); +}; diff --git a/client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx b/client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx new file mode 100644 index 00000000000..0fb54df3755 --- /dev/null +++ b/client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +/** + * Internal dependencies + */ +import { LogoPopover } from './logo-popover'; +import './style.scss'; + +interface BreakpointConfig { + breakpoint: number; + maxElements: number; +} + +interface PaymentMethodsLogosProps { + maxElements: number; + paymentMethods: { name: string; component: string }[]; + breakpointConfigs?: BreakpointConfig[]; +} + +const breakpointConfigsDefault = [ + { breakpoint: 480, maxElements: 5 }, + { breakpoint: 768, maxElements: 7 }, +]; +const paymentMethodsDefault: never[] = []; +export const PaymentMethodsLogos: React.FC< PaymentMethodsLogosProps > = ( { + maxElements = 10, + paymentMethods = paymentMethodsDefault, + breakpointConfigs = breakpointConfigsDefault, +} ) => { + const [ maxShownElements, setMaxShownElements ] = useState( maxElements ); + const [ + popoverAnchor, + setPopoverAnchor, + ] = useState< HTMLDivElement | null >( null ); + const [ popoverOpen, setPopoverOpen ] = useState( false ); + const [ shouldHavePopover, setShouldHavePopover ] = useState( false ); + + const togglePopover = () => setPopoverOpen( ! popoverOpen ); + + const anchorRef = useCallback( ( node: HTMLDivElement | null ) => { + if ( node !== null ) { + setPopoverAnchor( node ); + } + }, [] ); + + const buttonRef = useRef< HTMLDivElement | null >( null ); + + const handlePopoverClose = useCallback( () => { + setPopoverOpen( false ); + buttonRef.current?.focus(); + }, [] ); + + useEffect( () => { + const updateMaxElements = () => { + const sortedConfigs = [ ...breakpointConfigs ].sort( + ( a, b ) => a.breakpoint - b.breakpoint + ); + const config = sortedConfigs.find( + ( cfg ) => window.innerWidth <= cfg.breakpoint + ); + + setMaxShownElements( config ? config.maxElements : maxElements ); + }; + + updateMaxElements(); + window.addEventListener( 'resize', updateMaxElements ); + + return () => window.removeEventListener( 'resize', updateMaxElements ); + }, [ breakpointConfigs, maxElements ] ); + + useEffect( () => { + if ( popoverAnchor ) { + buttonRef.current = popoverAnchor; + } + }, [ popoverAnchor ] ); + + useEffect( () => { + setShouldHavePopover( paymentMethods.length > maxShownElements ); + }, [ maxShownElements, paymentMethods.length ] ); + + return ( + <> +
+
{ + if ( e.key === 'Enter' || e.key === ' ' ) { + e.preventDefault(); + togglePopover(); + } + }, + role: 'button', + tabIndex: 0, + 'aria-expanded': popoverOpen, + 'aria-controls': 'payment-methods-popover', + } ) } + data-testid="payment-methods-logos" + > + { paymentMethods + .slice( 0, maxShownElements ) + .map( ( pm ) => ( + { + ) ) } + { shouldHavePopover && ( +
+ + { paymentMethods.length - maxShownElements } +
+ ) } +
+
+ { shouldHavePopover && popoverOpen && ( + + { paymentMethods.slice( maxShownElements ).map( ( pm ) => ( + { + ) ) } + + ) } + + ); +}; diff --git a/client/checkout/blocks/payment-methods-logos/style.scss b/client/checkout/blocks/payment-methods-logos/style.scss new file mode 100644 index 00000000000..4c7f113427a --- /dev/null +++ b/client/checkout/blocks/payment-methods-logos/style.scss @@ -0,0 +1,46 @@ +.payment-methods--logos { + > div { + display: flex; + align-items: center; + + img { + width: 37px; + height: 24px; + margin-right: 4px; + border: 1px solid $gray-300; + border-radius: 3px; + } + } + + &-count { + width: 38px; + height: 24px; + background-color: rgba( $gray-700, 0.1 ); + color: $gray-900; + text-align: center; + line-height: 24px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + } +} + +.logo-popover { + background-color: #fff; + border: 1px solid $gray-300; + border-radius: 3px; + padding: 8px; + box-sizing: border-box; + box-shadow: 0 0 10px 0 rgba( 0, 0, 0, 0.1 ); + display: grid; + gap: 8px; + justify-content: center; + cursor: pointer; + width: fit-content; + + > img { + width: 38px; + height: 24px; + box-shadow: 0 0 0 1px rgba( 0, 0, 0, 0.1 ); + } +} diff --git a/client/checkout/blocks/style.scss b/client/checkout/blocks/style.scss index 3e400b6bfff..369a1141d75 100644 --- a/client/checkout/blocks/style.scss +++ b/client/checkout/blocks/style.scss @@ -95,16 +95,6 @@ button.wcpay-stripelink-modal-trigger:hover { display: none; } - @include breakpoint( '<480px' ) { - grid-template-areas: 'label logos' 'badge badge'; - grid-template-columns: 1fr auto; - align-items: start; - - .payment-methods--logos { - justify-self: end; - } - } - &__pmme-container { width: 100%; pointer-events: none; diff --git a/client/checkout/blocks/test/payment-method-logos.test.tsx b/client/checkout/blocks/test/payment-method-logos.test.tsx new file mode 100644 index 00000000000..2e946884312 --- /dev/null +++ b/client/checkout/blocks/test/payment-method-logos.test.tsx @@ -0,0 +1,115 @@ +/** + * External dependencies + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, test, expect } from '@jest/globals'; +/** + * Internal dependencies + */ +import { PaymentMethodsLogos } from '../payment-methods-logos'; + +const mockPaymentMethods = [ + { name: 'Visa', component: 'visa.png' }, + { name: 'MasterCard', component: 'mastercard.png' }, + { name: 'PayPal', component: 'paypal.png' }, + { name: 'Amex', component: 'amex.png' }, + { name: 'Discover', component: 'discover.png' }, +]; + +describe( 'PaymentMethodsLogos', () => { + test( 'renders without crashing', () => { + render( + + ); + const logoContainer = screen.getByTestId( 'payment-methods-logos' ); + expect( logoContainer ).toBeTruthy(); + } ); + + test( 'displays correct number of logos based on maxElements', () => { + render( + + ); + const logos = screen.queryAllByRole( 'img' ); + expect( logos ).toHaveLength( 3 ); + } ); + + test( 'shows popover indicator when there are more payment methods than maxElements', () => { + render( + + ); + const popoverIndicator = screen.queryByText( + `+ ${ mockPaymentMethods.length - 3 }` + ); + expect( popoverIndicator ).toBeTruthy(); + } ); + + test( 'opens popover on button click', async () => { + render( + + ); + const button = screen.getByTestId( 'payment-methods-logos' ); + + fireEvent.click( button ); + + const popover = await screen.findByTestId( 'payment-methods-popover' ); + expect( popover ).toBeTruthy(); + } ); + + test( 'handles keyboard navigation', async () => { + render( + + ); + const button = screen.getByTestId( 'payment-methods-logos' ); + + fireEvent.keyDown( button, { key: 'Enter' } ); + + const popover = await screen.findByTestId( 'payment-methods-popover' ); + expect( popover ).toBeTruthy(); + } ); + + test( 'does not show popover indicator when there are fewer payment methods than maxElements', () => { + render( + + ); + const popoverIndicator = screen.queryByText( /^\+\s*\d+$/ ); + expect( popoverIndicator ).toBeNull(); + + const logos = screen.getAllByRole( 'img' ); + expect( logos ).toHaveLength( mockPaymentMethods.length ); + } ); + + test( 'does not show popover when there are fewer payment methods than maxElements', async () => { + render( + + ); + + const button = screen.getByTestId( 'payment-methods-logos' ); + + fireEvent.click( button ); + + const popover = screen.queryByTestId( 'payment-methods-popover' ); + expect( popover ).toBeNull(); + } ); +} ); diff --git a/client/checkout/classic/event-handlers.js b/client/checkout/classic/event-handlers.js index 4ed1912250f..7eb87d79481 100644 --- a/client/checkout/classic/event-handlers.js +++ b/client/checkout/classic/event-handlers.js @@ -33,6 +33,10 @@ import { isPreviewing } from 'wcpay/checkout/preview'; import { recordUserEvent } from 'tracks'; import '../utils/copy-test-number'; import { SHORTCODE_BILLING_ADDRESS_FIELDS } from '../constants'; +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'; jQuery( function ( $ ) { enqueueFraudScripts( getUPEConfig( 'fraudServices' ) ); @@ -73,6 +77,7 @@ jQuery( function ( $ ) { $( document.body ).on( 'updated_checkout', () => { maybeMountStripePaymentElement( 'shortcode_checkout' ); injectStripePMMEContainers(); + injectPaymentMethodLogos(); } ); $checkoutForm.on( generateCheckoutEventNames(), function () { @@ -239,6 +244,222 @@ jQuery( function ( $ ) { } } + async function injectPaymentMethodLogos() { + const cardLabel = document.querySelector( + 'label[for="payment_method_woocommerce_payments"]' + ); + if ( ! cardLabel ) return; + + if ( cardLabel.querySelector( '.payment-methods--logos' ) ) return; + + const target = cardLabel.querySelector( 'img' ); + if ( ! target ) return; + + // Create container div + const logosContainer = document.createElement( 'div' ); + logosContainer.className = 'payment-methods--logos'; + + // Create inner div for flex layout + const innerContainer = document.createElement( 'div' ); + innerContainer.setAttribute( 'role', 'button' ); + innerContainer.setAttribute( 'tabindex', '0' ); + innerContainer.setAttribute( 'data-testid', 'payment-methods-logos' ); + + const paymentMethods = [ + { name: 'visa', component: Visa }, + { name: 'mastercard', component: Mastercard }, + { name: 'amex', component: Amex }, + { name: 'discover', component: Discover }, + ]; + + function getMaxElements() { + const paymentMethodElement = document.querySelector( + '.payment_method_woocommerce_payments' + ); + if ( ! paymentMethodElement ) { + return 4; // Default fallback + } + + const elementWidth = paymentMethodElement.offsetWidth; + if ( elementWidth <= 300 ) { + return 1; + } else if ( elementWidth <= 330 ) { + return 2; + } + } + + function shouldHavePopover() { + return paymentMethods.length > getMaxElements(); + } + + function createPopover( remainingMethods ) { + const popover = document.createElement( 'div' ); + popover.className = 'logo-popover'; + popover.setAttribute( 'role', 'dialog' ); + popover.setAttribute( + 'aria-label', + 'Supported Credit Card Brands' + ); + + remainingMethods.forEach( ( pm ) => { + const img = document.createElement( 'img' ); + img.src = pm.component; + img.alt = pm.name; + img.width = 38; + img.height = 24; + popover.appendChild( img ); + } ); + + // Calculate number of items per row (max 5) + const itemsPerRow = Math.min( remainingMethods.length, 5 ); + + // Set grid-template-columns based on number of items + popover.style.gridTemplateColumns = `repeat(${ itemsPerRow }, 38px)`; + + // Calculate width: (items * width) + (gaps * gap-size) + (padding * 2) + const width = itemsPerRow * 38 + ( itemsPerRow - 1 ) * 8 + 16; + popover.style.width = `${ width }px`; + + return popover; + } + + function positionPopover( popover, anchor ) { + const label = anchor.closest( 'label' ); + if ( ! label ) return; + + const labelRect = label.getBoundingClientRect(); + const labelStyle = window.getComputedStyle( label ); + const labelPaddingRight = parseInt( labelStyle.paddingRight, 10 ); + + popover.style.position = 'fixed'; + popover.style.right = `${ + window.innerWidth - ( labelRect.right - labelPaddingRight ) + }px`; + popover.style.top = `${ labelRect.top - 25 }px`; + popover.style.zIndex = '1000'; + popover.style.left = 'auto'; + } + + function updateLogos() { + innerContainer.innerHTML = ''; // Clear existing logos + const maxElements = getMaxElements(); + const visibleMethods = paymentMethods.slice( 0, maxElements ); + const remainingCount = paymentMethods.length - maxElements; + + // Add visible logos + visibleMethods.forEach( ( pm ) => { + const brandImg = document.createElement( 'img' ); + brandImg.src = pm.component; + brandImg.alt = pm.name; + brandImg.width = 38; + brandImg.height = 24; + innerContainer.appendChild( brandImg ); + } ); + + // Add count indicator if we should have a popover + if ( shouldHavePopover() ) { + const countDiv = document.createElement( 'div' ); + countDiv.className = 'payment-methods--logos-count'; + countDiv.textContent = `+ ${ remainingCount }`; + + // Add click handler directly to the count div + countDiv.addEventListener( 'click', ( e ) => { + e.stopPropagation(); + e.preventDefault(); + togglePopover(); + } ); + + innerContainer.appendChild( countDiv ); + } + + // Remove existing popover if we no longer need it + const existingPopover = cardLabel.querySelector( '.logo-popover' ); + if ( existingPopover && ! shouldHavePopover() ) { + existingPopover.remove(); + } + } + + function setupPopover() { + const popover = createPopover( + paymentMethods.slice( getMaxElements() ) + ); + cardLabel.appendChild( popover ); + positionPopover( popover, innerContainer ); + + const handleResize = () => + positionPopover( popover, innerContainer ); + window.addEventListener( 'resize', handleResize ); + window.addEventListener( 'scroll', handleResize ); + + const handlers = {}; + + const cleanup = () => { + popover.remove(); + window.removeEventListener( 'resize', handleResize ); + window.removeEventListener( 'scroll', handleResize ); + document.removeEventListener( + 'mousedown', + handlers.handleOutsideClick + ); + document.removeEventListener( + 'keydown', + handlers.handleEscapeKey + ); + }; + + handlers.handleOutsideClick = ( e ) => { + if ( + ! popover.contains( e.target ) && + ! innerContainer.contains( e.target ) + ) { + cleanup(); + } + }; + + handlers.handleEscapeKey = ( e ) => { + if ( e.key === 'Escape' ) { + cleanup(); + } + }; + + document.addEventListener( + 'mousedown', + handlers.handleOutsideClick + ); + document.addEventListener( 'keydown', handlers.handleEscapeKey ); + } + + function togglePopover() { + if ( ! shouldHavePopover() ) return; + + const existingPopover = cardLabel.querySelector( '.logo-popover' ); + if ( existingPopover ) { + existingPopover.remove(); + return; + } + + setupPopover(); + } + + // Remove the click handler from innerContainer since we're handling it on the count div + // Keep the keyboard handler for accessibility + innerContainer.addEventListener( 'keydown', ( e ) => { + if ( e.key === 'Enter' || e.key === ' ' ) { + e.preventDefault(); + e.stopPropagation(); + togglePopover(); + } + } ); + + // Initial setup + logosContainer.appendChild( innerContainer ); + target.replaceWith( logosContainer ); + updateLogos(); + + // Update on window resize + window.addEventListener( 'resize', updateLogos ); + } + function processPaymentIfNotUsingSavedMethod( $form ) { const paymentMethodType = getSelectedUPEGatewayPaymentMethod(); if ( ! isUsingSavedPaymentMethod( paymentMethodType ) ) { diff --git a/client/checkout/classic/style.scss b/client/checkout/classic/style.scss index e1fd24e3bdc..7d515babe6c 100644 --- a/client/checkout/classic/style.scss +++ b/client/checkout/classic/style.scss @@ -35,6 +35,13 @@ #payment .payment_methods { li[class*='payment_method_woocommerce_payments'] label { display: inline; + .payment-methods--logos { + float: right; + + img:last-of-type { + margin-right: 0; + } + } img { float: right; border: 0; @@ -42,6 +49,24 @@ height: 24px !important; max-height: 24px !important; } + .logo-popover { + background-color: #fff; + border: 1px solid $gray-300; + border-radius: 3px; + padding: 8px; + box-sizing: border-box; + box-shadow: 0 0 10px 0 rgba( 0, 0, 0, 0.1 ); + display: grid; + gap: 8px; + justify-content: center; + cursor: pointer; + + > img { + box-shadow: 0 0 0 1px rgba( 0, 0, 0, 0.1 ); + width: 38px; + height: 24px; + } + } } } diff --git a/client/components/payment-methods-checkboxes/test/index.test.tsx b/client/components/payment-methods-checkboxes/test/index.test.tsx index afbf32084b5..e55d7c11816 100644 --- a/client/components/payment-methods-checkboxes/test/index.test.tsx +++ b/client/components/payment-methods-checkboxes/test/index.test.tsx @@ -185,7 +185,7 @@ describe( 'PaymentMethodsCheckboxes', () => { ); const cardCheckbox = screen.getByRole( 'checkbox', { - name: 'Credit / Debit card', + name: 'Credit / Debit Cards', } ); expect( cardCheckbox ).not.toBeChecked(); userEvent.click( cardCheckbox ); diff --git a/client/payment-methods-map.tsx b/client/payment-methods-map.tsx index b22ca1f9ae2..69fa3e0f74a 100644 --- a/client/payment-methods-map.tsx +++ b/client/payment-methods-map.tsx @@ -44,7 +44,7 @@ const PaymentMethodInformationObject: Record< > = { card: { id: 'card', - label: __( 'Credit / Debit card', 'woocommerce-payments' ), + label: __( 'Credit / Debit Cards', 'woocommerce-payments' ), description: __( 'Let your customers pay with major credit and debit cards without leaving your store.', 'woocommerce-payments' diff --git a/client/settings/payment-methods-list/__tests__/__snapshots__/activation-modal.test.js.snap b/client/settings/payment-methods-list/__tests__/__snapshots__/activation-modal.test.js.snap index 5d6e4f591b2..4f6e919137e 100644 --- a/client/settings/payment-methods-list/__tests__/__snapshots__/activation-modal.test.js.snap +++ b/client/settings/payment-methods-list/__tests__/__snapshots__/activation-modal.test.js.snap @@ -55,7 +55,7 @@ exports[`Activation Modal matches the snapshot 1`] = ` class="components-modal__header-heading" id="components-modal-header-0" > - One more step to enable Credit / Debit card + One more step to enable Credit / Debit Cards - - - - -`; diff --git a/client/card-readers/settings/test/index.test.tsx b/client/card-readers/settings/test/index.test.tsx deleted file mode 100644 index a27f2b7500a..00000000000 --- a/client/card-readers/settings/test/index.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * External dependencies - */ -import React from 'react'; -import { render, screen } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import ReceiptSettings from '..'; - -describe( 'ReceiptSettings', () => { - test( 'Readers merchant settings page renders', () => { - render( ); - - expect( - screen.queryByText( 'Card reader receipts' ) - ).toBeInTheDocument(); - } ); - - test( 'Readers merchant settings page snapshot test', () => { - const { container } = render( ); - expect( container ).toMatchSnapshot(); - } ); -} ); diff --git a/client/card-readers/test/index.test.tsx b/client/card-readers/test/index.test.tsx index cc608c6540b..b8e97b875ee 100644 --- a/client/card-readers/test/index.test.tsx +++ b/client/card-readers/test/index.test.tsx @@ -14,7 +14,5 @@ describe( 'CardReadersSettings', () => { render( ); expect( screen.queryByText( 'Connected readers' ) ).toBeInTheDocument(); - - expect( screen.queryByText( 'Receipt details' ) ).toBeInTheDocument(); } ); } ); From d009cdddaaf03147cfee6cd3d7e6cbd242627ada Mon Sep 17 00:00:00 2001 From: Daniel Guerra <15204776+danielmx-dev@users.noreply.github.com> Date: Thu, 6 Feb 2025 16:55:35 +0200 Subject: [PATCH 09/65] GrabPay: Add settings page. (#10235) --- .../images/payment-method-icons/grabpay.svg | 42 +++++++++++++ assets/images/payment-methods/grabpay.svg | 42 +++++++++++++ changelog/add-grabpay-settings | 4 ++ client/additional-methods-setup/constants.js | 1 + client/checkout/blocks/index.js | 2 + client/checkout/constants.js | 2 + .../components/payment-method-logos/index.tsx | 5 ++ client/constants/payment-method.ts | 2 + client/data/transactions/hooks.ts | 1 + client/payment-methods-icons.tsx | 5 ++ client/payment-methods-map.tsx | 15 +++++ client/types/charges.d.ts | 1 + client/types/payment-methods.d.ts | 1 + client/utils/account-fees.tsx | 2 + .../class-duplicates-detection-service.php | 2 + includes/class-wc-payment-gateway-wcpay.php | 3 + includes/class-wc-payments.php | 3 + includes/constants/class-payment-method.php | 1 + .../class-grabpay-payment-method.php | 60 +++++++++++++++++++ .../test-class-wc-payment-gateway-wcpay.php | 28 ++++++++- 20 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 assets/images/payment-method-icons/grabpay.svg create mode 100644 assets/images/payment-methods/grabpay.svg create mode 100644 changelog/add-grabpay-settings create mode 100644 includes/payment-methods/class-grabpay-payment-method.php diff --git a/assets/images/payment-method-icons/grabpay.svg b/assets/images/payment-method-icons/grabpay.svg new file mode 100644 index 00000000000..21c382f34b2 --- /dev/null +++ b/assets/images/payment-method-icons/grabpay.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/payment-methods/grabpay.svg b/assets/images/payment-methods/grabpay.svg new file mode 100644 index 00000000000..21c382f34b2 --- /dev/null +++ b/assets/images/payment-methods/grabpay.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/changelog/add-grabpay-settings b/changelog/add-grabpay-settings new file mode 100644 index 00000000000..23d57644da6 --- /dev/null +++ b/changelog/add-grabpay-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add GrabPay to the settings page when eligible. diff --git a/client/additional-methods-setup/constants.js b/client/additional-methods-setup/constants.js index b189fc86459..75f8e850894 100644 --- a/client/additional-methods-setup/constants.js +++ b/client/additional-methods-setup/constants.js @@ -11,6 +11,7 @@ export const upeMethods = [ 'afterpay_clearpay', 'jcb', 'klarna', + 'grabpay', ]; export const upeCapabilityStatuses = { diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js index da139e3a27c..31c4bf32bf8 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -40,6 +40,7 @@ import { PAYMENT_METHOD_NAME_AFFIRM, PAYMENT_METHOD_NAME_AFTERPAY, PAYMENT_METHOD_NAME_KLARNA, + PAYMENT_METHOD_NAME_GRABPAY, } from '../constants.js'; import { getDeferredIntentCreationUPEFields } from './payment-elements'; import { handleWooPayEmailInput } from '../woopay/email-input-iframe'; @@ -61,6 +62,7 @@ const upeMethods = { affirm: PAYMENT_METHOD_NAME_AFFIRM, afterpay_clearpay: PAYMENT_METHOD_NAME_AFTERPAY, klarna: PAYMENT_METHOD_NAME_KLARNA, + grabpay: PAYMENT_METHOD_NAME_GRABPAY, }; const enabledPaymentMethodsConfig = getUPEConfig( 'paymentMethodsConfig' ); diff --git a/client/checkout/constants.js b/client/checkout/constants.js index 1c9b616fe58..ae5a49f5e16 100644 --- a/client/checkout/constants.js +++ b/client/checkout/constants.js @@ -11,6 +11,7 @@ export const PAYMENT_METHOD_NAME_AFFIRM = 'woocommerce_payments_affirm'; export const PAYMENT_METHOD_NAME_AFTERPAY = 'woocommerce_payments_afterpay_clearpay'; export const PAYMENT_METHOD_NAME_KLARNA = 'woocommerce_payments_klarna'; +export const PAYMENT_METHOD_NAME_GRABPAY = 'woocommerce_payments_grabpay'; export const PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT = 'woocommerce_payments_express_checkout'; export const PAYMENT_METHOD_NAME_WOOPAY_EXPRESS_CHECKOUT = @@ -32,6 +33,7 @@ export function getPaymentMethodsConstants() { PAYMENT_METHOD_NAME_AFTERPAY, PAYMENT_METHOD_NAME_CARD, PAYMENT_METHOD_NAME_KLARNA, + PAYMENT_METHOD_NAME_GRABPAY, ]; } diff --git a/client/components/payment-method-logos/index.tsx b/client/components/payment-method-logos/index.tsx index beb73857366..6ffb5e22860 100644 --- a/client/components/payment-method-logos/index.tsx +++ b/client/components/payment-method-logos/index.tsx @@ -16,6 +16,7 @@ import ApplePay from 'assets/images/payment-method-icons/applepay.svg?asset'; import AfterPay from 'assets/images/payment-method-icons/afterpay.svg?asset'; import Affirm from 'assets/images/payment-method-icons/affirm.svg?asset'; import Klarna from 'assets/images/payment-method-icons/klarna.svg?asset'; +import GrabPay from 'assets/images/payment-method-icons/grabpay.svg?asset'; import Jcb from 'assets/images/payment-method-icons/jcb.svg?asset'; import GooglePay from 'assets/images/payment-method-icons/gpay.svg?asset'; import Cartebancaire from 'assets/images/cards/cartes_bancaires.svg?asset'; @@ -110,6 +111,10 @@ const PaymentMethods = [ name: 'przelewy24', component: Przelewy24, }, + { + name: 'grabpay', + component: GrabPay, + }, ]; export const WooPaymentsMethodsLogos: React.VFC< { diff --git a/client/constants/payment-method.ts b/client/constants/payment-method.ts index 4ca2b2d7dc4..1bda071b1a6 100644 --- a/client/constants/payment-method.ts +++ b/client/constants/payment-method.ts @@ -12,6 +12,7 @@ enum PAYMENT_METHOD_IDS { CARD_PRESENT = 'card_present', EPS = 'eps', KLARNA = 'klarna', + GRABPAY = 'grabpay', GIROPAY = 'giropay', IDEAL = 'ideal', LINK = 'link', @@ -46,6 +47,7 @@ export const PAYMENT_METHOD_TITLES = { ideal: __( 'iDEAL', 'woocommerce-payments' ), jcb: __( 'JCB', 'woocommerce-payments' ), klarna: __( 'Klarna', 'woocommerce-payments' ), + grabpay: __( 'GrabPay', 'woocommerce-payments' ), link: __( 'Link', 'woocommerce-payments' ), mastercard: __( 'Mastercard', 'woocommerce-payments' ), multibanco: __( 'Multibanco', 'woocommerce-payments' ), diff --git a/client/data/transactions/hooks.ts b/client/data/transactions/hooks.ts index ce63bfb5a49..c3a1741a6c2 100644 --- a/client/data/transactions/hooks.ts +++ b/client/data/transactions/hooks.ts @@ -61,6 +61,7 @@ export interface Transaction { | 'ideal' | 'jcb' | 'klarna' + | 'grabpay' | 'link' | 'mastercard' | 'multibanco' diff --git a/client/payment-methods-icons.tsx b/client/payment-methods-icons.tsx index b0a76e3f2fd..b073bfab8dc 100644 --- a/client/payment-methods-icons.tsx +++ b/client/payment-methods-icons.tsx @@ -21,6 +21,7 @@ import AfterpayAsset from 'assets/images/payment-methods/afterpay-logo.svg?asset import ClearpayAsset from 'assets/images/payment-methods/clearpay.svg?asset'; import JCBAsset from 'assets/images/payment-methods/jcb.svg?asset'; import KlarnaAsset from 'assets/images/payment-methods/klarna.svg?asset'; +import GrabPayAsset from 'assets/images/payment-methods/grabpay.svg?asset'; import VisaAsset from 'assets/images/cards/visa.svg?asset'; import MasterCardAsset from 'assets/images/cards/mastercard.svg?asset'; import AmexAsset from 'assets/images/cards/amex.svg?asset'; @@ -150,6 +151,10 @@ export const VisaIcon = iconComponent( VisaAsset, __( 'Visa', 'woocommerce-payments' ) ); +export const GrabPayIcon = iconComponent( + GrabPayAsset, + __( 'GrabPay', 'woocommerce-payments' ) +); export const WooIcon = iconComponent( WooAsset, __( 'WooPay', 'woocommerce-payments' ), diff --git a/client/payment-methods-map.tsx b/client/payment-methods-map.tsx index 69fa3e0f74a..2df498a6dcf 100644 --- a/client/payment-methods-map.tsx +++ b/client/payment-methods-map.tsx @@ -22,6 +22,7 @@ import { P24Icon, SepaIcon, SofortIcon, + GrabPayIcon, } from 'wcpay/payment-methods-icons'; const accountCountry = window.wcpaySettings?.accountStatus?.country || 'US'; @@ -233,6 +234,20 @@ const PaymentMethodInformationObject: Record< allows_pay_later: true, accepts_only_domestic_payment: true, }, + grabpay: { + id: 'grabpay', + label: __( 'GrabPay', 'woocommerce-payments' ), + description: __( + 'A popular digital wallet for cashless payments in Singapore.', + 'woocommerce-payments' + ), + icon: GrabPayIcon, + currencies: [ 'SGD' ], + stripe_key: 'grabpay_payments', + allows_manual_capture: false, + allows_pay_later: false, + accepts_only_domestic_payment: false, + }, }; export default PaymentMethodInformationObject; diff --git a/client/types/charges.d.ts b/client/types/charges.d.ts index df26095d92c..63f49a66f38 100644 --- a/client/types/charges.d.ts +++ b/client/types/charges.d.ts @@ -40,6 +40,7 @@ export interface PaymentMethodDetails { | 'giropay' | 'ideal' | 'klarna' + | 'grabpay' | 'p24' | 'sepa_debit' | 'sofort'; diff --git a/client/types/payment-methods.d.ts b/client/types/payment-methods.d.ts index 504c2927270..064fc1a431b 100644 --- a/client/types/payment-methods.d.ts +++ b/client/types/payment-methods.d.ts @@ -15,6 +15,7 @@ export type PaymentMethod = | 'card_present' | 'eps' | 'klarna' + | 'grabpay' | 'giropay' | 'ideal' | 'p24' diff --git a/client/utils/account-fees.tsx b/client/utils/account-fees.tsx index 791b727b164..5bbe260b5f3 100644 --- a/client/utils/account-fees.tsx +++ b/client/utils/account-fees.tsx @@ -388,6 +388,8 @@ export const getTransactionsPaymentMethodName = ( return __( 'Afterpay transactions', 'woocommerce-payments' ); case 'klarna': return __( 'Klarna transactions', 'woocommerce-payments' ); + case 'grabpay': + return __( 'GrabPay transactions', 'woocommerce-payments' ); default: return __( 'Unknown transactions', 'woocommerce-payments' ); } diff --git a/includes/class-duplicates-detection-service.php b/includes/class-duplicates-detection-service.php index 119972b68b4..c5af9123c47 100644 --- a/includes/class-duplicates-detection-service.php +++ b/includes/class-duplicates-detection-service.php @@ -22,6 +22,7 @@ use WCPay\Payment_Methods\Klarna_Payment_Method; use WCPay\Payment_Methods\P24_Payment_Method; use WCPay\Payment_Methods\Sepa_Payment_Method; +use WCPay\Payment_Methods\Grabpay_Payment_Method; /** * Class handling detection of payment methods enabled by multiple plugins simultaneously. @@ -103,6 +104,7 @@ private function search_for_additional_payment_methods() { 'afterpay' => Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, 'clearpay' => Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, 'klarna' => Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'grabpay' => Grabpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, ]; foreach ( $this->get_enabled_gateways() as $gateway ) { diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 2bcc683d2bb..c87f9b75ed6 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -74,6 +74,7 @@ use WCPay\Payment_Methods\P24_Payment_Method; use WCPay\Payment_Methods\Sepa_Payment_Method; use WCPay\Payment_Methods\UPE_Payment_Method; +use WCPay\Payment_Methods\Grabpay_Payment_Method; /** * Gateway class for WooPayments @@ -351,6 +352,7 @@ public function __construct( 'affirm' => 'affirm_payments', 'afterpay_clearpay' => 'afterpay_clearpay_payments', 'klarna' => 'klarna_payments', + 'grabpay' => 'grabpay_payments', 'jcb' => 'jcb_payments', ]; @@ -4142,6 +4144,7 @@ public function get_upe_available_payment_methods() { $available_methods[] = Affirm_Payment_Method::PAYMENT_METHOD_STRIPE_ID; $available_methods[] = Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID; $available_methods[] = Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Grabpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID; $available_methods = array_values( apply_filters( diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 35ae2c683fb..84c5ee278ae 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -42,6 +42,7 @@ use WCPay\WooPay\WooPay_Session; use WCPay\Compatibility_Service; use WCPay\Duplicates_Detection_Service; +use WCPay\Payment_Methods\Grabpay_Payment_Method; use WCPay\WC_Payments_Currency_Manager; /** @@ -437,6 +438,7 @@ public static function init() { include_once __DIR__ . '/payment-methods/class-affirm-payment-method.php'; include_once __DIR__ . '/payment-methods/class-afterpay-payment-method.php'; include_once __DIR__ . '/payment-methods/class-klarna-payment-method.php'; + include_once __DIR__ . '/payment-methods/class-grabpay-payment-method.php'; include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-helper.php'; include_once __DIR__ . '/class-wc-payment-token-wcpay-sepa.php'; include_once __DIR__ . '/class-wc-payments-status.php'; @@ -575,6 +577,7 @@ public static function init() { Affirm_Payment_Method::class, Afterpay_Payment_Method::class, Klarna_Payment_Method::class, + Grabpay_Payment_Method::class, ]; $payment_methods = []; diff --git a/includes/constants/class-payment-method.php b/includes/constants/class-payment-method.php index ee498864ab5..711c40a6bbc 100644 --- a/includes/constants/class-payment-method.php +++ b/includes/constants/class-payment-method.php @@ -35,6 +35,7 @@ class Payment_Method extends Base_Constant { const AFFIRM = 'affirm'; const AFTERPAY = 'afterpay_clearpay'; const KLARNA = 'klarna'; + const GRABPAY = 'grabpay'; const IPP_ALLOWED_PAYMENT_METHODS = [ self::CARD_PRESENT, diff --git a/includes/payment-methods/class-grabpay-payment-method.php b/includes/payment-methods/class-grabpay-payment-method.php new file mode 100644 index 00000000000..099fa173df7 --- /dev/null +++ b/includes/payment-methods/class-grabpay-payment-method.php @@ -0,0 +1,60 @@ +currencies = [ Currency_Code::SINGAPORE_DOLLAR ]; + $this->stripe_id = self::PAYMENT_METHOD_STRIPE_ID; + $this->is_reusable = false; + $this->is_bnpl = false; + $this->icon_url = plugins_url( 'assets/images/payment-methods/grabpay.svg', WCPAY_PLUGIN_FILE ); + $this->accept_only_domestic_payment = true; + $this->countries = [ Country_Code::SINGAPORE ]; + } + + /** + * Returns payment method title + * + * @param string|null $account_country Country of merchants account. + * @param array|false $payment_details Optional payment details from charge object. + * + * @return string + */ + public function get_title( ?string $account_country = null, $payment_details = false ) { + return __( 'GrabPay', 'woocommerce-payments' ); + } + + /** + * Returns testing credentials to be printed at checkout in test mode. + * + * @param string $account_country The country of the account. + * @return string + */ + public function get_testing_instructions( string $account_country ) { + return ''; + } +} diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 082d8ca6755..651268463b4 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -41,6 +41,7 @@ use WCPay\Payment_Methods\CC_Payment_Method; use WCPay\Payment_Methods\Eps_Payment_Method; use WCPay\Payment_Methods\Giropay_Payment_Method; +use WCPay\Payment_Methods\Grabpay_Payment_Method; use WCPay\Payment_Methods\Ideal_Payment_Method; use WCPay\Payment_Methods\Klarna_Payment_Method; use WCPay\Payment_Methods\Link_Payment_Method; @@ -519,6 +520,9 @@ public function test_correct_payment_method_title_for_order() { $becs_details = [ 'type' => 'au_becs_debit', ]; + $grabpay_details = [ + 'type' => 'grabpay', + ]; $charge_payment_method_details = [ $visa_credit_details, @@ -532,6 +536,7 @@ public function test_correct_payment_method_title_for_order() { $ideal_details, $sepa_details, $becs_details, + $grabpay_details, ]; $expected_payment_method_titles = [ @@ -546,6 +551,7 @@ public function test_correct_payment_method_title_for_order() { 'iDEAL', 'SEPA Direct Debit', 'BECS Direct Debit', + 'GrabPay', ]; foreach ( $charge_payment_method_details as $i => $payment_method_details ) { @@ -609,6 +615,9 @@ public function test_payment_methods_show_correct_default_outputs() { $mock_afterpay_details = [ 'type' => 'afterpay_clearpay', ]; + $mock_grabpay_details = [ + 'type' => 'grabpay', + ]; $card_method = $this->payment_methods['card']; $giropay_method = $this->payment_methods['giropay']; @@ -621,7 +630,7 @@ public function test_payment_methods_show_correct_default_outputs() { $becs_method = $this->payment_methods['au_becs_debit']; $affirm_method = $this->payment_methods['affirm']; $afterpay_method = $this->payment_methods['afterpay_clearpay']; - + $grabpay_method = $this->payment_methods['grabpay']; $this->assertEquals( 'card', $card_method->get_id() ); $this->assertEquals( 'Cards', $card_method->get_title() ); $this->assertEquals( 'Visa debit card', $card_method->get_title( 'US', $mock_visa_details ) ); @@ -695,6 +704,12 @@ public function test_payment_methods_show_correct_default_outputs() { $this->assertSame( 'Clearpay', $afterpay_method->get_title( 'GB', $mock_afterpay_details ) ); $this->assertTrue( $afterpay_method->is_enabled_at_checkout( 'GB' ) ); $this->assertFalse( $afterpay_method->is_reusable() ); + + $this->assertEquals( 'grabpay', $grabpay_method->get_id() ); + $this->assertEquals( 'GrabPay', $grabpay_method->get_title() ); + $this->assertEquals( 'GrabPay', $grabpay_method->get_title( 'SG', $mock_grabpay_details ) ); + $this->assertTrue( $grabpay_method->is_enabled_at_checkout( 'SG' ) ); + $this->assertFalse( $grabpay_method->is_reusable() ); } public function test_only_reusabled_payment_methods_enabled_with_subscription_item_present() { @@ -717,6 +732,7 @@ function ( $order ) { $becs_method = $this->payment_methods['au_becs_debit']; $affirm_method = $this->payment_methods['affirm']; $afterpay_method = $this->payment_methods['afterpay_clearpay']; + $grabpay_method = $this->payment_methods['grabpay']; $this->assertTrue( $card_method->is_enabled_at_checkout( 'US' ) ); $this->assertFalse( $giropay_method->is_enabled_at_checkout( 'US' ) ); @@ -729,6 +745,7 @@ function ( $order ) { $this->assertFalse( $becs_method->is_enabled_at_checkout( 'US' ) ); $this->assertFalse( $affirm_method->is_enabled_at_checkout( 'US' ) ); $this->assertFalse( $afterpay_method->is_enabled_at_checkout( 'US' ) ); + $this->assertFalse( $grabpay_method->is_enabled_at_checkout( 'SG' ) ); } public function test_payment_methods_enabled_based_on_currency_limits() { @@ -814,6 +831,7 @@ public function test_only_valid_payment_methods_returned_for_currency() { $becs_method = $this->payment_methods['au_becs_debit']; $affirm_method = $this->payment_methods['affirm']; $afterpay_method = $this->payment_methods['afterpay_clearpay']; + $grabpay_method = $this->payment_methods['grabpay']; WC_Helper_Site_Currency::$mock_site_currency = 'EUR'; @@ -827,6 +845,7 @@ public function test_only_valid_payment_methods_returned_for_currency() { $this->assertTrue( $p24_method->is_currency_valid( $account_domestic_currency ) ); $this->assertTrue( $ideal_method->is_currency_valid( $account_domestic_currency ) ); $this->assertFalse( $becs_method->is_currency_valid( $account_domestic_currency ) ); + $this->assertFalse( $grabpay_method->is_currency_valid( $account_domestic_currency ) ); // BNPLs can accept only domestic payments. $this->assertFalse( $affirm_method->is_currency_valid( $account_domestic_currency ) ); $this->assertFalse( $afterpay_method->is_currency_valid( $account_domestic_currency ) ); @@ -842,12 +861,18 @@ public function test_only_valid_payment_methods_returned_for_currency() { $this->assertFalse( $p24_method->is_currency_valid( $account_domestic_currency ) ); $this->assertFalse( $ideal_method->is_currency_valid( $account_domestic_currency ) ); $this->assertFalse( $becs_method->is_currency_valid( $account_domestic_currency ) ); + $this->assertFalse( $grabpay_method->is_currency_valid( $account_domestic_currency ) ); $this->assertTrue( $affirm_method->is_currency_valid( $account_domestic_currency ) ); $this->assertTrue( $afterpay_method->is_currency_valid( $account_domestic_currency ) ); WC_Helper_Site_Currency::$mock_site_currency = 'AUD'; $this->assertTrue( $becs_method->is_currency_valid( $account_domestic_currency ) ); + WC_Helper_Site_Currency::$mock_site_currency = 'SGD'; + $this->assertTrue( $card_method->is_currency_valid( $account_domestic_currency ) ); + $this->assertFalse( $grabpay_method->is_currency_valid( $account_domestic_currency ) ); + $this->assertTrue( $grabpay_method->is_currency_valid( 'SGD' ) ); + // BNPLs can accept only domestic payments. WC_Helper_Site_Currency::$mock_site_currency = 'USD'; $account_domestic_currency = 'CAD'; @@ -4134,6 +4159,7 @@ private function init_payment_methods() { Affirm_Payment_Method::class, Afterpay_Payment_Method::class, Klarna_Payment_Method::class, + Grabpay_Payment_Method::class, ]; foreach ( $payment_method_classes as $payment_method_class ) { From 9442b85422f5ea6a0f0a800c9128817ad033eb9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Costa?= <10233985+cesarcosta99@users.noreply.github.com> Date: Thu, 6 Feb 2025 12:05:30 -0300 Subject: [PATCH 10/65] Prevent potential fatal in multi-currency widget markup getter (#10291) Co-authored-by: Alfredo Sumaran --- ...fix-10220-multi-currency-widget-markup-getter | 4 ++++ includes/multi-currency/MultiCurrency.php | 16 +++++++++++++++- .../multi-currency/test-class-multi-currency.php | 12 ++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 changelog/fix-10220-multi-currency-widget-markup-getter diff --git a/changelog/fix-10220-multi-currency-widget-markup-getter b/changelog/fix-10220-multi-currency-widget-markup-getter new file mode 100644 index 00000000000..6954f8a9c60 --- /dev/null +++ b/changelog/fix-10220-multi-currency-widget-markup-getter @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Ensure multi-currency widget markup getter don't throw errors. diff --git a/includes/multi-currency/MultiCurrency.php b/includes/multi-currency/MultiCurrency.php index b24d53c70ab..9d394135828 100644 --- a/includes/multi-currency/MultiCurrency.php +++ b/includes/multi-currency/MultiCurrency.php @@ -522,8 +522,22 @@ public function get_switcher_widget_markup( array $instance = [], array $args = * call the_widget, you need to have the name of the widget, so we get the instance and hash to use. */ ob_start(); + + $currency_switcher_widget = $this->get_currency_switcher_widget(); + + if ( ! is_object( $currency_switcher_widget ) ) { + Logger::notice( + sprintf( + 'Invalid widget markup. Widget instance must be type object, %s given.', + gettype( $currency_switcher_widget ) + ) + ); + + return ob_get_clean(); + } + the_widget( - spl_object_hash( $this->get_currency_switcher_widget() ), + spl_object_hash( $currency_switcher_widget ), apply_filters( self::FILTER_PREFIX . 'theme_widget_instance', $instance ), apply_filters( self::FILTER_PREFIX . 'theme_widget_args', $args ) ); diff --git a/tests/unit/multi-currency/test-class-multi-currency.php b/tests/unit/multi-currency/test-class-multi-currency.php index fcedcc68e57..90e04ee1c48 100644 --- a/tests/unit/multi-currency/test-class-multi-currency.php +++ b/tests/unit/multi-currency/test-class-multi-currency.php @@ -927,6 +927,18 @@ public function test_get_switcher_widget_markup() { $this->assertEquals( $expected, $this->multi_currency->get_switcher_widget_markup() ); } + public function test_get_switcher_widget_markup_when_widget_instance_is_null() { + $mock_multi_currency = $this + ->getMockBuilder( WCPay\MultiCurrency\MultiCurrency::class ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'get_currency_switcher_widget' ] ) + ->getMock(); + + $mock_multi_currency->method( 'get_currency_switcher_widget' )->willReturn( null ); + + $this->assertEquals( '', $mock_multi_currency->get_switcher_widget_markup() ); + } + public function test_validate_currency_code_returns_existing_currency_code() { $this->assertEquals( 'CAD', $this->multi_currency->validate_currency_code( 'CAD' ) ); $this->assertEquals( 'CAD', $this->multi_currency->validate_currency_code( 'cAd' ) ); From e51dffc8ddebdfa82a8fe49c29cd945dd6efa2d5 Mon Sep 17 00:00:00 2001 From: Daniel Guerra <15204776+danielmx-dev@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:29:54 +0200 Subject: [PATCH 11/65] Pass the business name to the express checkout options (#10299) --- .../add-pass-business-name-express-checkout | 4 +++ .../blocks/hooks/use-express-checkout.js | 3 +++ client/express-checkout/index.js | 3 +++ .../blocks/hooks/use-express-checkout.js | 3 +++ client/tokenized-express-checkout/index.js | 3 +++ ...yments-express-checkout-button-handler.php | 26 +++++++++++++------ ...yments-express-checkout-button-handler.php | 16 ++++++++++++ 7 files changed, 50 insertions(+), 8 deletions(-) create mode 100644 changelog/add-pass-business-name-express-checkout diff --git a/changelog/add-pass-business-name-express-checkout b/changelog/add-pass-business-name-express-checkout new file mode 100644 index 00000000000..f9fa1b9c63c --- /dev/null +++ b/changelog/add-pass-business-name-express-checkout @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Pass the business name to the express checkout handler. diff --git a/client/express-checkout/blocks/hooks/use-express-checkout.js b/client/express-checkout/blocks/hooks/use-express-checkout.js index 5e2cbd1774b..0e18dc7d428 100644 --- a/client/express-checkout/blocks/hooks/use-express-checkout.js +++ b/client/express-checkout/blocks/hooks/use-express-checkout.js @@ -92,6 +92,9 @@ export const useExpressCheckout = ( { } const options = { + business: { + name: getExpressCheckoutData( 'store_name' ), + }, lineItems: normalizeLineItems( billing?.cartTotalItems ), emailRequired: true, shippingAddressRequired, diff --git a/client/express-checkout/index.js b/client/express-checkout/index.js index f1cbdc5ebb0..f37bd453a6b 100644 --- a/client/express-checkout/index.js +++ b/client/express-checkout/index.js @@ -313,6 +313,9 @@ jQuery( ( $ ) => { } const clickOptions = { + business: { + name: getExpressCheckoutData( 'store_name' ), + }, lineItems: normalizeLineItems( options.displayItems ), emailRequired: true, shippingAddressRequired: options.requestShipping, diff --git a/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js b/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js index 2db200ed47c..c81670891e4 100644 --- a/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js +++ b/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js @@ -92,6 +92,9 @@ export const useExpressCheckout = ( { } const options = { + business: { + name: getExpressCheckoutData( 'store_name' ), + }, lineItems: normalizeLineItems( billing?.cartTotalItems ), emailRequired: true, shippingAddressRequired, diff --git a/client/tokenized-express-checkout/index.js b/client/tokenized-express-checkout/index.js index 6143c540a59..2f7330e7133 100644 --- a/client/tokenized-express-checkout/index.js +++ b/client/tokenized-express-checkout/index.js @@ -269,6 +269,9 @@ jQuery( ( $ ) => { // The "real" values will be updated once the button loads. // They are preemptively initialized because the `event.resolve({})` // needs to be called within 1 second of the `click` event. + business: { + name: getExpressCheckoutData( 'store_name' ), + }, lineItems: options.displayItems, emailRequired: true, shippingAddressRequired: options.requestShipping, diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php index f580cc45565..413b51aa064 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php @@ -214,15 +214,12 @@ public function is_account_creation_possible() { } /** - * Load public scripts and styles. + * Gets the parameters needed for Express Checkout functionality. + * + * @return array Parameters for Express Checkout. */ - public function scripts() { - // Don't load scripts if page is not supported. - if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { - return; - } - - $express_checkout_params = [ + public function get_express_checkout_params() { + return [ 'ajax_url' => admin_url( 'admin-ajax.php' ), 'wc_ajax_url' => WC_AJAX::get_endpoint( '%%endpoint%%' ), 'stripe' => [ @@ -260,7 +257,20 @@ public function scripts() { 'has_block' => has_block( 'woocommerce/cart' ) || has_block( 'woocommerce/checkout' ), 'product' => $this->express_checkout_helper->get_product_data(), 'total_label' => $this->express_checkout_helper->get_total_label(), + 'store_name' => get_bloginfo( 'name' ), ]; + } + + /** + * Load public scripts and styles. + */ + public function scripts() { + // Don't load scripts if page is not supported. + if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { + return; + } + + $express_checkout_params = $this->get_express_checkout_params(); if ( WC_Payments_Features::is_tokenized_cart_ece_enabled() ) { WC_Payments::register_script_with_dependencies( diff --git a/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-handler.php b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-handler.php index 0b10752c0f5..abdfc946fdc 100644 --- a/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-handler.php +++ b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-handler.php @@ -133,4 +133,20 @@ public function test_filter_cart_needs_shipping_address_subscription_products() remove_filter( 'woocommerce_shipping_method_count', '__return_zero' ); WC_Subscriptions_Cart::set_cart_contains_subscription( false ); } + + public function test_get_express_checkout_params() { + $this->mock_ece_button_helper + ->method( 'get_common_button_settings' ) + ->willReturn( + [ + 'type' => 'buy', + 'theme' => 'white', + 'height' => '30', + 'radius' => '10', + ] + ); + $params = $this->system_under_test->get_express_checkout_params(); + $this->assertArrayHasKey( 'store_name', $params ); + $this->assertEquals( get_bloginfo( 'name' ), $params['store_name'] ); + } } From 0758236b8fcdcd2e584b2acd710425e88463bcd1 Mon Sep 17 00:00:00 2001 From: Samir Merchant Date: Thu, 6 Feb 2025 20:58:53 -0500 Subject: [PATCH 12/65] Disables the tokenised cart for ECE integration by default (#10322) --- changelog/patch-disable-tokenised-carts-by-default | 4 ++++ includes/class-wc-payments-features.php | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog/patch-disable-tokenised-carts-by-default diff --git a/changelog/patch-disable-tokenised-carts-by-default b/changelog/patch-disable-tokenised-carts-by-default new file mode 100644 index 00000000000..0c3c7646c25 --- /dev/null +++ b/changelog/patch-disable-tokenised-carts-by-default @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Ensures that the tokenised cart for ECE implementation is disabled by default. diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index 0a80979e849..6eefb80fb9b 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -49,7 +49,7 @@ public static function are_payments_enabled() { * @return bool */ public static function is_tokenized_cart_ece_enabled(): bool { - return '1' === get_option( self::TOKENIZED_CART_ECE_FLAG_NAME, '1' ); + return '1' === get_option( self::TOKENIZED_CART_ECE_FLAG_NAME, '0' ); } /** From 60abecc3180e9f06a6582327ffd70b99f0123977 Mon Sep 17 00:00:00 2001 From: deepakpathania <68396823+deepakpathania@users.noreply.github.com> Date: Fri, 7 Feb 2025 15:51:48 +0530 Subject: [PATCH 13/65] Add handling for insufficient funds during refund processing (#10313) --- bin/run-tests.sh | 2 +- ...update-add-handling-for-low-refund-balance | 4 + ...ss-wc-rest-payments-refunds-controller.php | 72 +++++++++++----- includes/class-wc-payments-order-service.php | 82 +++++++++++++++++++ ...wc-payments-webhook-processing-service.php | 13 +++ ...wc-payments-webhook-processing-service.php | 45 +++++++++- 6 files changed, 196 insertions(+), 22 deletions(-) create mode 100644 changelog/update-add-handling-for-low-refund-balance diff --git a/bin/run-tests.sh b/bin/run-tests.sh index cf194d39079..7a2ac899263 100755 --- a/bin/run-tests.sh +++ b/bin/run-tests.sh @@ -28,7 +28,7 @@ if $WATCH_FLAG; then else echo "Running the tests..." - docker-compose exec -u www-data wordpress \ + docker compose exec -u www-data wordpress \ /var/www/html/wp-content/plugins/woocommerce-payments/vendor/bin/phpunit \ --configuration /var/www/html/wp-content/plugins/woocommerce-payments/phpunit.xml.dist \ $* diff --git a/changelog/update-add-handling-for-low-refund-balance b/changelog/update-add-handling-for-low-refund-balance new file mode 100644 index 00000000000..ee060808e3a --- /dev/null +++ b/changelog/update-add-handling-for-low-refund-balance @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update handling for refund processing in case of insufficient funds. diff --git a/includes/admin/class-wc-rest-payments-refunds-controller.php b/includes/admin/class-wc-rest-payments-refunds-controller.php index 1d0ebc59ac4..8485d5548a3 100644 --- a/includes/admin/class-wc-rest-payments-refunds-controller.php +++ b/includes/admin/class-wc-rest-payments-refunds-controller.php @@ -41,6 +41,7 @@ public function register_routes() { * * @internal Not intended for usage in integrations or outside of WooCommerce Payments. * @param WP_REST_Request $request Full data about the request. + * @return WP_REST_Response|WP_Error */ public function process_refund( $request ) { $order_id = $request->get_param( 'order_id' ); @@ -48,34 +49,67 @@ public function process_refund( $request ) { $amount = $request->get_param( 'amount' ); $reason = $request->get_param( 'reason' ); + $order = null; if ( $order_id ) { $order = wc_get_order( $order_id ); - if ( $order ) { - $result = wc_create_refund( - [ - 'amount' => WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ), - 'reason' => $reason, - 'order_id' => $order_id, - 'refund_payment' => true, - 'restock_items' => true, - ] - ); - + if ( false !== $order && $order instanceof WC_Order ) { + $result = $this->process_order_refund( $order, $amount, $reason ); + if ( is_wp_error( $result ) || false === $result ) { + return rest_ensure_response( + new WP_Error( + 'wcpay_refund_payment', + __( 'Failed to create refund', 'woocommerce-payments' ) + ) + ); + } return rest_ensure_response( $result ); } } try { - $refund_request = Refund_Charge::create( $charge_id ); - $refund_request->set_charge( $charge_id ); - $refund_request->set_amount( $amount ); - $refund_request->set_reason( $reason ); - $refund_request->set_source( 'transaction_details_no_order' ); - $response = $refund_request->send(); - - return rest_ensure_response( $response ); + return rest_ensure_response( $this->process_charge_refund( $charge_id, $amount, $reason ) ); } catch ( API_Exception $e ) { + if ( 'insufficient_balance_for_refund' === $e->get_error_code() && $order instanceof WC_Order ) { + WC_Payments::get_order_service()->handle_insufficient_balance_for_refund( $order, $amount ); + } return rest_ensure_response( new WP_Error( 'wcpay_refund_payment', $e->getMessage() ) ); } } + + /** + * Process refund for an order. + * + * @param WC_Order $order The order to refund. + * @param int $amount Refund amount. + * @param string $reason Refund reason. + * @return WC_Order_Refund|WP_Error|false + */ + private function process_order_refund( WC_Order $order, $amount, $reason ) { + return wc_create_refund( + [ + 'amount' => WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ), + 'reason' => $reason, + 'order_id' => $order->get_id(), + 'refund_payment' => true, + 'restock_items' => true, + ] + ); + } + + /** + * Process refund for a charge. + * + * @param string $charge_id The charge to refund. + * @param int $amount Refund amount. + * @param string $reason Refund reason. + * @return array + */ + private function process_charge_refund( $charge_id, $amount, $reason ) { + $refund_request = Refund_Charge::create( $charge_id ); + $refund_request->set_charge( $charge_id ); + $refund_request->set_amount( $amount ); + $refund_request->set_reason( $reason ); + $refund_request->set_source( 'transaction_details_no_order' ); + return $refund_request->send(); + } } diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php index 0db9728a724..395b430fb43 100644 --- a/includes/class-wc-payments-order-service.php +++ b/includes/class-wc-payments-order-service.php @@ -2127,4 +2127,86 @@ private function is_order_type_object( $order ): bool { private function intent_has_card_payment_type( $intent_data ): bool { return isset( $intent_data['payment_method_type'] ) && 'card' === $intent_data['payment_method_type']; } + + /** + * Countries where FROD balance is not supported. + * + * @var array + */ + const FROD_UNSUPPORTED_COUNTRIES = [ 'HK', 'SG', 'AE' ]; + + /** + * Handle insufficient balance for refund. + * + * @param WC_Order $order The order being refunded. + * @param int $amount The refund amount. + */ + public function handle_insufficient_balance_for_refund( WC_Order $order, $amount ) { + $account_country = WC_Payments::get_account_service()->get_account_country(); + + $formatted_amount = wc_price( + WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ), + [ 'currency' => $order->get_currency() ] + ); + + if ( $this->is_frod_supported( $account_country ) ) { + $order->add_order_note( $this->get_frod_support_note( $formatted_amount ) ); + } else { + $order->add_order_note( $this->get_insufficient_balance_note( $formatted_amount ) ); + } + } + + /** + * Check if FROD is supported for the given country. + * + * @param string $country_code Two-letter country code. + * @return bool + */ + private function is_frod_supported( $country_code ) { + return ! in_array( + $country_code, + self::FROD_UNSUPPORTED_COUNTRIES, + true + ); + } + + /** + * Get the order note for FROD supported countries. + * + * @param string $formatted_amount The formatted refund amount. + * @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'; + return sprintf( + WC_Payments_Utils::esc_interpolated_html( + /* translators: %s: Formatted refund amount */ + __( 'Refund of %s failed due to insufficient funds in your WooPayments balance. To prevent delays in refunding customers, please consider adding funds to your Future Refunds or Disputes (FROD) balance. Learn more.', 'woocommerce-payments' ), + [ + 'strong' => '', + 'a' => '', + ] + ), + $formatted_amount + ); + } + + /** + * Get the order note for countries without FROD support. + * + * @param string $formatted_amount The formatted refund amount. + * @return string + */ + private function get_insufficient_balance_note( $formatted_amount ) { + return sprintf( + WC_Payments_Utils::esc_interpolated_html( + /* translators: %1$s: Formatted refund amount */ + __( 'Refund of %1$s failed due to insufficient funds in your WooPayments balance.', 'woocommerce-payments' ), + [ + 'strong' => '', + ] + ), + $formatted_amount + ); + } } diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php index d93044e6d6b..ce9eaa5f203 100644 --- a/includes/class-wc-payments-webhook-processing-service.php +++ b/includes/class-wc-payments-webhook-processing-service.php @@ -324,6 +324,19 @@ private function process_webhook_refund_updated( $event_body ) { $order->add_order_note( $note ); $this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' ); $order->save(); + + try { + $failure_reason = $this->read_webhook_property( $event_object, 'failure_reason' ); + + if ( 'insufficient_funds' === $failure_reason ) { + $this->order_service->handle_insufficient_balance_for_refund( + $order, + $amount + ); + } + } catch ( Exception $e ) { + Logger::debug( 'Failed to handle insufficient balance for refund: ' . $e->getMessage() ); + } } /** 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 6c195574221..74398f2cdd1 100644 --- a/tests/unit/test-class-wc-payments-webhook-processing-service.php +++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php @@ -98,7 +98,7 @@ public function set_up() { $this->order_service = $this->getMockBuilder( 'WC_Payments_Order_Service' ) ->setConstructorArgs( [ $this->createMock( WC_Payments_API_Client::class ) ] ) - ->setMethods( [ 'get_wcpay_refund_id_for_order', 'add_note_and_metadata_for_refund', 'create_refund_for_order', 'mark_terminal_payment_failed' ] ) + ->setMethods( [ 'get_wcpay_refund_id_for_order', 'add_note_and_metadata_for_refund', 'create_refund_for_order', 'mark_terminal_payment_failed', 'handle_insufficient_balance_for_refund' ] ) ->getMock(); $this->mock_db_wrapper = $this->getMockBuilder( WC_Payments_DB::class ) @@ -116,7 +116,17 @@ public function set_up() { $this->mock_database_cache = $this->createMock( Database_Cache::class ); - $this->webhook_processing_service = new WC_Payments_Webhook_Processing_Service( $this->mock_api_client, $this->mock_db_wrapper, $mock_wcpay_account, $this->mock_remote_note_service, $this->order_service, $this->mock_receipt_service, $this->mock_wcpay_gateway, $this->mock_customer_service, $this->mock_database_cache ); + $this->webhook_processing_service = new WC_Payments_Webhook_Processing_Service( + $this->mock_api_client, + $this->mock_db_wrapper, + $this->createMock( WC_Payments_Account::class ), + $this->mock_remote_note_service, + $this->order_service, + $this->mock_receipt_service, + $this->mock_wcpay_gateway, + $this->mock_customer_service, + $this->mock_database_cache + ); // Build the event body data. $event_object = []; @@ -493,6 +503,37 @@ public function test_valid_failed_refund_update_webhook_with_unknown_charge_id() $this->webhook_processing_service->process( $this->event_body ); } + /** + * Test a valid failed refund update webhook with insufficient funds. + */ + public function test_valid_failed_refund_update_webhook_with_insufficient_funds() { + // Setup test request data. + $this->event_body['type'] = 'charge.refund.updated'; + $this->event_body['livemode'] = true; + $this->event_body['data']['object'] = [ + 'status' => 'failed', + 'charge' => 'charge_id', + 'id' => 'test_refund_id', + 'amount' => 999, + 'currency' => 'gbp', + 'failure_reason' => 'insufficient_funds', + ]; + + $this->mock_db_wrapper + ->expects( $this->once() ) + ->method( 'order_from_charge_id' ) + ->with( 'charge_id' ) + ->willReturn( $this->mock_order ); + + $this->order_service + ->expects( $this->once() ) + ->method( 'handle_insufficient_balance_for_refund' ) + ->with( $this->mock_order, 999 ); + + $this->webhook_processing_service->process( $this->event_body ); + } + + /** * Test a valid non-failed refund update webhook */ From 00f9d2fe3a7892e21bc1bb6601e5172d5eeb7a42 Mon Sep 17 00:00:00 2001 From: Valery Sukhomlinov <683297+dmvrtx@users.noreply.github.com> Date: Fri, 7 Feb 2025 15:51:02 +0100 Subject: [PATCH 14/65] Update permissions settings and project names for E2E test setup (#10315) Co-authored-by: Achyuth Ajoy --- ...2e-pw-setup-script-permissions-and-project | 4 +++ tests/e2e-pw/README.md | 11 +++++++ ...ions-page-should-load-without-errors-1.png | Bin 114071 -> 110692 bytes tests/e2e/env/down.sh | 2 +- tests/e2e/env/setup.sh | 27 +++++++++++++++--- tests/e2e/env/shared.sh | 4 +-- tests/e2e/env/up.sh | 2 +- 7 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 changelog/dev-e2e-pw-setup-script-permissions-and-project diff --git a/changelog/dev-e2e-pw-setup-script-permissions-and-project b/changelog/dev-e2e-pw-setup-script-permissions-and-project new file mode 100644 index 00000000000..5986562ff22 --- /dev/null +++ b/changelog/dev-e2e-pw-setup-script-permissions-and-project @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Update E2E setup scripts to avoid conflicts with other containers and permissions. diff --git a/tests/e2e-pw/README.md b/tests/e2e-pw/README.md index 9bcbd373f33..e7f7f50e671 100644 --- a/tests/e2e-pw/README.md +++ b/tests/e2e-pw/README.md @@ -21,6 +21,17 @@ See [tests/e2e/README.md](/tests/e2e/README.md) for detailed e2e environment set ## FAQs +**I'm getting errors that host.docker.internal is not found.** + +This is because the `host.docker.internal` alias is not available on Linux. You can use the `localhost` alias instead. To apply it, create a file called `docker-compose.override.yml` in the `tests/e2e-pw` directory and add the following content: + +```yaml +services: + playwright: + environment: + - BASE_URL=http://localhost:8084 +``` + **How do I wait for a page or element to load?** Since [Playwright automatically waits](https://playwright.dev/docs/actionability) for elements to be present in the page before interacting with them, you probably don't need to explicitly wait for elements to load. For example, all of the following locators will automatically wait for the element to be present and stable before asserting or interacting with it: diff --git a/tests/e2e-pw/specs/merchant/__snapshots__/merchant-admin-transactions.spec.ts/Admin-transactions-page-should-load-without-errors-1.png b/tests/e2e-pw/specs/merchant/__snapshots__/merchant-admin-transactions.spec.ts/Admin-transactions-page-should-load-without-errors-1.png index 32b65a0a01c4f799a86bc685c8c2f918c27e1a1e..1cc6beab9b74a1be2f84ce680b30e47e2739ba06 100644 GIT binary patch literal 110692 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
    aLK(OyM256 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