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
- You need to provide more information to enable Credit / Debit card on your checkout:
+ You need to provide more information to enable Credit / Debit Cards on your checkout:
You can add it again at any time in
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 ed9349f0e81..4fa6f05218d 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
@@ -106,7 +106,7 @@ describe( 'PaymentMethodsSection', () => {
render( );
const card = screen.getByRole( 'checkbox', {
- name: 'Credit / Debit card',
+ name: 'Credit / Debit Cards',
} );
const becs = screen.getByRole( 'checkbox', {
name: 'BECS Direct Debit',
diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php
index ffadd9cc8d9..2bcc683d2bb 100644
--- a/includes/class-wc-payment-gateway-wcpay.php
+++ b/includes/class-wc-payment-gateway-wcpay.php
@@ -1584,7 +1584,7 @@ public function process_payment_for_order( $cart, $payment_information, $schedul
do_action( 'woocommerce_payments_changed_subscription_payment_method', $order, $payment_token );
}
- $order->set_payment_method_title( __( 'Credit / Debit Card', 'woocommerce-payments' ) );
+ $order->set_payment_method_title( __( 'Credit / Debit Cards', 'woocommerce-payments' ) );
$order->save();
return [
diff --git a/includes/payment-methods/class-cc-payment-method.php b/includes/payment-methods/class-cc-payment-method.php
index 58d7d733a77..9e7f3ba6860 100644
--- a/includes/payment-methods/class-cc-payment-method.php
+++ b/includes/payment-methods/class-cc-payment-method.php
@@ -39,7 +39,7 @@ public function __construct( $token_service ) {
*/
public function get_title( ?string $account_country = null, $payment_details = false ) {
if ( ! $payment_details ) {
- return __( 'Credit card / debit card', 'woocommerce-payments' );
+ return __( 'Cards', 'woocommerce-payments' );
}
$details = $payment_details[ $this->stripe_id ];
diff --git a/tests/e2e-pw/specs/merchant/merchant-payment-gateways-confirmation.spec.ts b/tests/e2e-pw/specs/merchant/merchant-payment-gateways-confirmation.spec.ts
index 7e089ba858a..fc26cad9ccc 100644
--- a/tests/e2e-pw/specs/merchant/merchant-payment-gateways-confirmation.spec.ts
+++ b/tests/e2e-pw/specs/merchant/merchant-payment-gateways-confirmation.spec.ts
@@ -13,8 +13,7 @@ test.describe( 'payment gateways disable confirmation', () => {
const getToggle = ( page: Page ) =>
page.getByRole( 'link', {
- name:
- '"WooPayments (Credit card / debit card)" payment method is currently',
+ name: '"WooPayments (Cards)" payment method is currently',
} );
const getModalHeading = ( page: Page ) =>
diff --git a/tests/e2e-pw/specs/shopper/shopper-subscriptions-purchase-free-trial.spec.ts b/tests/e2e-pw/specs/shopper/shopper-subscriptions-purchase-free-trial.spec.ts
index 275a99752cc..1322f476e7a 100644
--- a/tests/e2e-pw/specs/shopper/shopper-subscriptions-purchase-free-trial.spec.ts
+++ b/tests/e2e-pw/specs/shopper/shopper-subscriptions-purchase-free-trial.spec.ts
@@ -151,8 +151,8 @@ describeif( shouldRunSubscriptionsTests )(
test( 'Merchant should be able to create an order with "Setup Intent"', async () => {
await goToOrder( merchantPage, orderId );
await expect(
- merchantPage.getByText( 'Payment via Credit card /' )
- ).toHaveText( /\(seti_.*\)/ );
+ merchantPage.locator( '.woocommerce-order-data__meta' )
+ ).toContainText( 'seti_' );
await goToSubscriptions( merchantPage );
const subscriptionRow = merchantPage.getByRole( 'row', {
diff --git a/tests/e2e-pw/specs/shopper/shopper-subscriptions-purchase-no-signup-fee.spec.ts b/tests/e2e-pw/specs/shopper/shopper-subscriptions-purchase-no-signup-fee.spec.ts
index 626c42771d5..9cace535df1 100644
--- a/tests/e2e-pw/specs/shopper/shopper-subscriptions-purchase-no-signup-fee.spec.ts
+++ b/tests/e2e-pw/specs/shopper/shopper-subscriptions-purchase-no-signup-fee.spec.ts
@@ -71,7 +71,7 @@ describeif( shouldRunSubscriptionsTests )(
.replace( '#', '' );
const transactionPageLink = await merchantPage
- .getByText( 'Payment via Credit card /' )
+ .getByText( 'Payment via Cards', { exact: false } )
.getByRole( 'link', { name: /pi_.+/ } )
.getAttribute( 'href' );
diff --git a/tests/e2e-pw/utils/shopper.ts b/tests/e2e-pw/utils/shopper.ts
index d3105d83a11..f9c66e49b95 100644
--- a/tests/e2e-pw/utils/shopper.ts
+++ b/tests/e2e-pw/utils/shopper.ts
@@ -295,7 +295,7 @@ export const addToCartFromShopPage = async (
export const selectPaymentMethod = async (
page: Page,
- paymentMethod = 'Credit card'
+ paymentMethod = 'Cards'
) => {
await page.getByText( paymentMethod ).click();
};
@@ -480,7 +480,7 @@ export const addSavedCard = async (
) => {
await page.getByRole( 'link', { name: 'Add payment method' } ).click();
await page.waitForLoadState( 'networkidle' );
- await page.getByText( 'Credit card / debit card' ).click();
+ await page.getByText( 'Cards' ).click();
const frameHandle = page.getByTitle( 'Secure payment input frame' );
const stripeFrame = frameHandle.contentFrame();
diff --git a/tests/unit/payment-methods/test-class-upe-payment-gateway.php b/tests/unit/payment-methods/test-class-upe-payment-gateway.php
index 000f5eade08..4df9604fa2e 100644
--- a/tests/unit/payment-methods/test-class-upe-payment-gateway.php
+++ b/tests/unit/payment-methods/test-class-upe-payment-gateway.php
@@ -698,7 +698,7 @@ public function test_payment_methods_show_correct_default_outputs() {
$afterpay_method = $this->mock_payment_methods['afterpay_clearpay'];
$this->assertEquals( 'card', $card_method->get_id() );
- $this->assertEquals( 'Credit card / debit card', $card_method->get_title( 'US' ) );
+ $this->assertEquals( 'Cards', $card_method->get_title( 'US' ) );
$this->assertEquals( 'Visa debit card', $card_method->get_title( 'US', $mock_visa_details ) );
$this->assertEquals( 'Mastercard credit card', $card_method->get_title( 'US', $mock_mastercard_details ) );
$this->assertTrue( $card_method->is_enabled_at_checkout( 'US' ) );
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 a3284a840ce..673aaf4bdd0 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
@@ -893,7 +893,7 @@ public function test_payment_methods_show_correct_default_outputs() {
$becs_method = $this->mock_payment_methods['au_becs_debit'];
$this->assertEquals( 'card', $card_method->get_id() );
- $this->assertEquals( 'Credit card / debit card', $card_method->get_title( 'US' ) );
+ $this->assertEquals( 'Cards', $card_method->get_title( 'US' ) );
$this->assertEquals( 'Visa debit card', $card_method->get_title( 'US', $mock_visa_details ) );
$this->assertEquals( 'Mastercard credit card', $card_method->get_title( 'US', $mock_mastercard_details ) );
$this->assertTrue( $card_method->is_enabled_at_checkout( 'US' ) );
diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php
index e4102d7d8c1..082d8ca6755 100644
--- a/tests/unit/test-class-wc-payment-gateway-wcpay.php
+++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php
@@ -623,7 +623,7 @@ public function test_payment_methods_show_correct_default_outputs() {
$afterpay_method = $this->payment_methods['afterpay_clearpay'];
$this->assertEquals( 'card', $card_method->get_id() );
- $this->assertEquals( 'Credit card / debit card', $card_method->get_title() );
+ $this->assertEquals( 'Cards', $card_method->get_title() );
$this->assertEquals( 'Visa debit card', $card_method->get_title( 'US', $mock_visa_details ) );
$this->assertEquals( 'Mastercard credit card', $card_method->get_title( 'US', $mock_mastercard_details ) );
$this->assertTrue( $card_method->is_enabled_at_checkout( 'US' ) );
diff --git a/tests/unit/test-class-wc-payments-checkout.php b/tests/unit/test-class-wc-payments-checkout.php
index dbb4aa9eb5e..c6ba78a24fc 100644
--- a/tests/unit/test-class-wc-payments-checkout.php
+++ b/tests/unit/test-class-wc-payments-checkout.php
@@ -373,7 +373,7 @@ public function test_link_payment_method_provided_when_card_enabled() {
[
'card' => [
'isReusable' => true,
- 'title' => 'Credit card / debit card',
+ 'title' => 'Cards',
'icon' => $icon_url,
'darkIcon' => $dark_icon_url,
'showSaveOption' => true,
From d46e2a742171cbe955b1f42baa906c334c87e455 Mon Sep 17 00:00:00 2001
From: Francesco
Date: Wed, 5 Feb 2025 22:09:21 +0100
Subject: [PATCH 07/65] feat: add order notes to tokenized ECE on blocks
checkout (#10307)
---
changelog/feat-add-order-notes-to-tokenized-ece | 4 ++++
.../blocks/hooks/use-express-checkout.js | 7 ++++++-
2 files changed, 10 insertions(+), 1 deletion(-)
create mode 100644 changelog/feat-add-order-notes-to-tokenized-ece
diff --git a/changelog/feat-add-order-notes-to-tokenized-ece b/changelog/feat-add-order-notes-to-tokenized-ece
new file mode 100644
index 00000000000..ccd4c9ddfdd
--- /dev/null
+++ b/changelog/feat-add-order-notes-to-tokenized-ece
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+feat: add order notes to tokenized ECE GooglePay/ApplePay on blocks checkout
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 f1e28fd66f9..2db200ed47c 100644
--- a/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js
+++ b/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js
@@ -124,7 +124,12 @@ export const useExpressCheckout = ( {
elements,
completePayment,
abortPayment,
- event
+ {
+ ...event,
+ order_comments: wp?.data
+ ?.select( 'wc/store/checkout' )
+ ?.getOrderNotes(),
+ }
);
};
From 29017ffb929a00cfb10c562e7353ad3018afc48f Mon Sep 17 00:00:00 2001
From: deepakpathania <68396823+deepakpathania@users.noreply.github.com>
Date: Thu, 6 Feb 2025 14:05:34 +0530
Subject: [PATCH 08/65] Remove the receipt details tab in the card readers
page. (#10294)
---
.../update-remove-card-reader-receipt-details | 4 +
client/card-readers/index.tsx | 14 +-
client/card-readers/settings/file-upload.tsx | 150 ------------------
client/card-readers/settings/index.tsx | 63 --------
.../settings/sections/address-details.js | 125 ---------------
.../settings/sections/branding-details.js | 59 -------
.../settings/sections/business-details.js | 77 ---------
.../settings/sections/contacts-details.js | 33 ----
.../settings/sections/test/index.test.js | 149 -----------------
.../test/__snapshots__/index.test.tsx.snap | 56 -------
.../card-readers/settings/test/index.test.tsx | 25 ---
client/card-readers/test/index.test.tsx | 2 -
12 files changed, 5 insertions(+), 752 deletions(-)
create mode 100644 changelog/update-remove-card-reader-receipt-details
delete mode 100644 client/card-readers/settings/file-upload.tsx
delete mode 100644 client/card-readers/settings/index.tsx
delete mode 100644 client/card-readers/settings/sections/address-details.js
delete mode 100644 client/card-readers/settings/sections/branding-details.js
delete mode 100644 client/card-readers/settings/sections/business-details.js
delete mode 100644 client/card-readers/settings/sections/contacts-details.js
delete mode 100644 client/card-readers/settings/sections/test/index.test.js
delete mode 100644 client/card-readers/settings/test/__snapshots__/index.test.tsx.snap
delete mode 100644 client/card-readers/settings/test/index.test.tsx
diff --git a/changelog/update-remove-card-reader-receipt-details b/changelog/update-remove-card-reader-receipt-details
new file mode 100644
index 00000000000..2958d7947a7
--- /dev/null
+++ b/changelog/update-remove-card-reader-receipt-details
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Remove the receipt details tab in the card readers page.
diff --git a/client/card-readers/index.tsx b/client/card-readers/index.tsx
index b0dea2257e6..4b2fe9b89cb 100644
--- a/client/card-readers/index.tsx
+++ b/client/card-readers/index.tsx
@@ -11,7 +11,6 @@ import { __ } from '@wordpress/i18n';
*/
import Page from 'components/page';
import ReadersList from './list';
-import ReceiptSettings from './settings';
import { TabPanel } from '@wordpress/components';
import './style.scss';
@@ -31,20 +30,9 @@ export const ConnectedReaders = (): JSX.Element => {
),
className: 'connected-readers-list',
},
- {
- name: 'receipt-details',
- title: __( 'Receipt details', 'woocommerce-payments' ),
- className: 'connected-readers-receipt-details',
- },
] }
>
- { ( tab ) => {
- if ( 'receipt-details' === tab.name ) {
- return ;
- }
-
- return ;
- } }
+ { () => }
);
diff --git a/client/card-readers/settings/file-upload.tsx b/client/card-readers/settings/file-upload.tsx
deleted file mode 100644
index 63fcf597e96..00000000000
--- a/client/card-readers/settings/file-upload.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-/** @format */
-/**
- * External dependencies
- */
-import React from 'react';
-import { recordEvent } from 'tracks';
-import apiFetch from '@wordpress/api-fetch';
-import { __ } from '@wordpress/i18n';
-import { useDispatch } from '@wordpress/data';
-import { useState } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import { FileUploadControl } from 'components/file-upload';
-
-interface CardReaderFileUploadProps {
- fieldKey: string;
- label: string;
- accept: string;
- disabled?: boolean;
- help?: string;
- purpose: string;
- fileID: string;
- updateFileID: ( id: string ) => void;
-}
-
-const BrandingFileUpload: React.FunctionComponent< CardReaderFileUploadProps > = (
- props
-) => {
- const {
- fieldKey,
- label,
- accept,
- disabled,
- help,
- purpose,
- fileID,
- updateFileID,
- } = props;
-
- const [ isLoading, setLoading ] = useState( false );
- const [ uploadError, setUploadError ] = useState< boolean | string >(
- false
- );
-
- const { createErrorNotice } = useDispatch( 'core/notices' );
-
- const fileSizeExceeded = ( size: number ) => {
- const fileSizeLimitInBytes = 510000;
- if ( fileSizeLimitInBytes < size ) {
- createErrorNotice(
- __(
- 'The file you have attached is exceeding the maximum limit.',
- 'woocommerce-payments'
- )
- );
-
- return true;
- }
- };
-
- const handleFileChange = async ( key: string, file: File ) => {
- if ( ! file ) {
- return;
- }
-
- if ( fileSizeExceeded( file.size ) ) {
- return;
- }
-
- setLoading( true );
-
- recordEvent( 'wcpay_merchant_settings_file_upload_started', {
- type: key,
- } );
-
- const body = new FormData();
- body.append( 'file', file );
- body.append( 'purpose', purpose );
- // Interpreting as_account as Boolean false in the backend
- body.append( 'as_account', '0' );
-
- try {
- const uploadedFile: unknown = await apiFetch( {
- path: '/wc/v3/payments/file',
- method: 'post',
- body,
- } );
-
- if ( uploadedFile ) {
- // Store uploaded file ID.
- updateFileID( ( uploadedFile as any ).id );
- }
-
- setLoading( false );
- setUploadError( false );
-
- recordEvent( 'wcpay_merchant_settings_file_upload_success', {
- type: key,
- } );
- } catch ( { err } ) {
- recordEvent( 'wcpay_merchant_settings_file_upload_success', {
- message: ( err as Error ).message,
- } );
-
- // Remove file ID
- updateFileID( '' );
-
- setLoading( false );
- setUploadError( ( err as Error ).message || '' );
-
- // Show error notice
- createErrorNotice( ( err as Error ).message );
- }
- };
-
- const handleFileRemove = () => {
- updateFileID( '' );
-
- setLoading( false );
- setUploadError( false );
- };
-
- const isDone = ( ! isLoading && fileID && 0 < fileID.length ) as boolean;
- const error = ( uploadError || '' ) as string;
-
- return (
-
-
-
- );
-};
-
-export default BrandingFileUpload;
diff --git a/client/card-readers/settings/index.tsx b/client/card-readers/settings/index.tsx
deleted file mode 100644
index 017db6bbf7b..00000000000
--- a/client/card-readers/settings/index.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-/** @format */
-/**
- * External dependencies
- */
-import React from 'react';
-import { __ } from '@wordpress/i18n';
-import { Card, CardBody } from '@wordpress/components';
-import { useState } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import SettingsSection from 'wcpay/settings/settings-section';
-import SettingsLayout from 'wcpay/settings/settings-layout';
-import LoadableSettingsSection from 'wcpay/settings/loadable-settings-section';
-import SaveSettingsSection from 'wcpay/settings/save-settings-section';
-import BusinessDetailsSection from './sections/business-details';
-import ContactsDetailsSection from './sections/contacts-details';
-import AddressDetailsSection from './sections/address-details';
-import BrandingDetailsSection from './sections/branding-details';
-
-const ReadersSettingsDescription = (): JSX.Element => (
- <>
- { __( 'Card reader receipts', 'woocommerce-payments' ) }
-
- { __(
- 'These details will appear on emailed receipts for customers that pay in person using card readers. ' +
- 'Updating the details here will not affect any other stores settings.',
- 'woocommerce-payments'
- ) }
-
- >
-);
-
-const ReceiptSettings = (): JSX.Element => {
- const [ isBusinessInputsValid, setBusinessInputsValid ] = useState( true );
- const [ isContactsInputsValid, setContactsInputsValid ] = useState( true );
- const areInputsValid = isBusinessInputsValid && isContactsInputsValid;
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default ReceiptSettings;
diff --git a/client/card-readers/settings/sections/address-details.js b/client/card-readers/settings/sections/address-details.js
deleted file mode 100644
index 3646d6bbbd6..00000000000
--- a/client/card-readers/settings/sections/address-details.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/** @format */
-/**
- * External dependencies
- */
-import React from 'react';
-import { __ } from '@wordpress/i18n';
-import { TextControl, SelectControl, Notice } from '@wordpress/components';
-
-/**
- * Internal dependencies
- */
-import {
- useAccountBusinessSupportAddress,
- useGetSavingError,
-} from '../../../data';
-
-const AddressDetailsSection = () => {
- const [
- accountBusinessSupportAddress,
- accountBusinessSupportAddressCountry,
- accountBusinessSupportAddressLine1,
- accountBusinessSupportAddressLine2,
- accountBusinessSupportAddressCity,
- accountBusinessSupportAddressState,
- accountBusinessSupportAddressPostalCode,
- setAccountBusinessSupportAddress,
- ] = useAccountBusinessSupportAddress();
-
- const businessSuppotAddressErrorMessage = useGetSavingError()?.data?.details
- ?.account_business_support_address?.message;
-
- const handleAddressPropertyChange = ( property, value ) => {
- setAccountBusinessSupportAddress( {
- ...accountBusinessSupportAddress,
- [ property ]: value,
- } );
- };
-
- const unescapeHtmlEntities = ( string ) => {
- const doc = new DOMParser().parseFromString( string, 'text/html' );
- return doc.documentElement.textContent;
- };
-
- const countriesOptions = Object.entries( wcSettings.countries ).map(
- ( [ value, label ] ) => ( {
- label: unescapeHtmlEntities( label ),
- value: value,
- } )
- );
-
- const countryStates =
- wcpaySettings.connect.availableStates[
- accountBusinessSupportAddressCountry
- ] || [];
- const countryStatesOptions = Object.entries( countryStates ).map(
- ( [ value, label ] ) => ( {
- label: unescapeHtmlEntities( label ),
- value: unescapeHtmlEntities( label ),
- country: value,
- } )
- );
-
- return (
- <>
- { __( 'Business address', 'woocommerce-payments' ) }
- { businessSuppotAddressErrorMessage && (
-
- { businessSuppotAddressErrorMessage }
-
- ) }
-
- handleAddressPropertyChange( 'country', value )
- }
- options={ countriesOptions }
- />
-
- handleAddressPropertyChange( 'line1', value )
- }
- />
-
- handleAddressPropertyChange( 'line2', value )
- }
- />
-
- handleAddressPropertyChange( 'city', value )
- }
- />
- { countryStatesOptions.length > 0 && (
-
- handleAddressPropertyChange( 'state', value )
- }
- options={ countryStatesOptions }
- />
- ) }
-
- handleAddressPropertyChange( 'postal_code', value )
- }
- />
- >
- );
-};
-
-export default AddressDetailsSection;
diff --git a/client/card-readers/settings/sections/branding-details.js b/client/card-readers/settings/sections/branding-details.js
deleted file mode 100644
index ec18653c501..00000000000
--- a/client/card-readers/settings/sections/branding-details.js
+++ /dev/null
@@ -1,59 +0,0 @@
-/** @format */
-/**
- * External dependencies
- */
-import React from 'react';
-import { __ } from '@wordpress/i18n';
-import { useEffect } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import BrandingFileUpload from '../file-upload';
-
-import { useAccountBrandingLogo } from '../../../data';
-
-const BrandingDetailsSection = () => {
- const [
- getAccountBrandingLogo,
- setAccountBrandingLogo,
- ] = useAccountBrandingLogo();
-
- useEffect( () => {
- document
- .querySelectorAll(
- '.components-circular-option-picker__dropdown-link-action button'
- )
- .forEach( function ( el ) {
- el.innerHTML = __( 'Edit', 'woocommerce-payments' );
- } );
- }, [] );
-
- return (
- <>
- { __( 'Branding', 'woocommerce-payments' ) }
-
- { __(
- 'Your business’s logo will be used on printed receipts.',
- 'woocommerce-payments'
- ) }
-
-
-
- >
- );
-};
-
-export default BrandingDetailsSection;
diff --git a/client/card-readers/settings/sections/business-details.js b/client/card-readers/settings/sections/business-details.js
deleted file mode 100644
index e1e201c25fb..00000000000
--- a/client/card-readers/settings/sections/business-details.js
+++ /dev/null
@@ -1,77 +0,0 @@
-/** @format */
-/**
- * External dependencies
- */
-import { React, useLayoutEffect } from 'react';
-import { __ } from '@wordpress/i18n';
-import { TextControl, Notice } from '@wordpress/components';
-import { useState } from '@wordpress/element';
-
-/**
- * Internal dependencies
- */
-import { useAccountBusinessName, useAccountBusinessURL } from '../../../data';
-
-const BusinessDetailsSection = ( { setInputsValid } ) => {
- const [ hasError, setHasError ] = useState( false );
-
- const [
- accountBusinessName,
- setAccountBusinessName,
- ] = useAccountBusinessName();
-
- const [
- accountBusinessURL,
- setAccountBusinessURL,
- ] = useAccountBusinessURL();
-
- useLayoutEffect( () => {
- const businessUrl = document.querySelector(
- '.card-readers-business-url-input input'
- );
- businessUrl.focus();
- businessUrl.blur();
- }, [] );
-
- const validateBusinessURL = ( event ) => {
- if ( event.target.checkValidity() ) {
- setHasError( false );
- setInputsValid( true );
- } else {
- setHasError( true );
- setInputsValid( false );
- }
- };
-
- return (
- <>
- { __( 'Business details', 'woocommerce-payments' ) }
-
- { hasError && (
-
-
- { __(
- 'Error: Invalid business URL, should start with http:// or https:// prefix.',
- 'woocommerce-payments'
- ) }
-
-
- ) }
-
- >
- );
-};
-
-export default BusinessDetailsSection;
diff --git a/client/card-readers/settings/sections/contacts-details.js b/client/card-readers/settings/sections/contacts-details.js
deleted file mode 100644
index b54a3d62576..00000000000
--- a/client/card-readers/settings/sections/contacts-details.js
+++ /dev/null
@@ -1,33 +0,0 @@
-/** @format */
-/**
- * External dependencies
- */
-import { __ } from '@wordpress/i18n';
-
-/**
- * Internal dependencies
- */
-import SupportPhoneInput from 'wcpay/settings/support-phone-input';
-import SupportEmailInput from 'wcpay/settings/support-email-input';
-import React, { useEffect, useState } from 'react';
-
-const ContactDetailsSection = ( { setInputsValid } ) => {
- const [ isEmailInputValid, setEmailInputValid ] = useState( true );
- const [ isPhoneInputValid, setPhoneInputValid ] = useState( true );
-
- useEffect( () => {
- setInputsValid( isEmailInputValid && isPhoneInputValid );
- }, [ isEmailInputValid, isPhoneInputValid, setInputsValid ] );
-
- return (
- <>
-
- { __( 'Customer support contacts', 'woocommerce-payments' ) }
-
-
-
- >
- );
-};
-
-export default ContactDetailsSection;
diff --git a/client/card-readers/settings/sections/test/index.test.js b/client/card-readers/settings/sections/test/index.test.js
deleted file mode 100644
index 68244127851..00000000000
--- a/client/card-readers/settings/sections/test/index.test.js
+++ /dev/null
@@ -1,149 +0,0 @@
-/**
- * External dependencies
- */
-import { render, screen } from '@testing-library/react';
-
-/**
- * Internal dependencies
- */
-import BusinessDetailsSection from '../business-details';
-import ContactsDetailsSection from '../contacts-details';
-import AddressDetailsSection from '../address-details';
-import BrandingDetailsSection from '../branding-details';
-import BrandingFileUpload from '../../file-upload';
-
-jest.mock( '../../file-upload', () => jest.fn() );
-
-const setInputsValidMock = jest.fn();
-
-describe( 'Card Reader Business Details section', () => {
- beforeEach( () => {
- global.wcpaySettings = {
- connect: {
- country: 'US',
- availableCountries: { US: 'United States (US)' },
- availableStates: [
- {
- US: [ 'Florida', 'California', 'Texas' ],
- },
- ],
- },
- accountStatus: {
- country: 'US',
- },
- };
- } );
-
- test( 'Renders Business section', () => {
- render(
-
- );
-
- const heading = screen.queryByRole( 'heading', {
- name: 'Business details',
- } );
- expect( heading ).toBeInTheDocument();
- } );
-
- test( 'Renders Business settings', () => {
- render(
-
- );
-
- const name = screen.getByLabelText( 'Business name' );
- expect( name ).toBeInTheDocument();
-
- const url = screen.getByLabelText( 'Business URL' );
- expect( url ).toBeInTheDocument();
- } );
-} );
-
-describe( 'Card Reader Contact Details section', () => {
- test( 'Renders Contacts section', () => {
- render(
-
- );
-
- const heading = screen.queryByRole( 'heading', {
- name: 'Customer support contacts',
- } );
- expect( heading ).toBeInTheDocument();
- } );
-
- test( 'Renders Contacts settings', () => {
- render(
-
- );
-
- const email = screen.getByLabelText( 'Support email' );
- expect( email ).toBeInTheDocument();
-
- const phone = screen.getByLabelText( 'Support phone number' );
- expect( phone ).toBeInTheDocument();
- } );
-} );
-
-describe( 'Card Reader Address Details section', () => {
- beforeEach( () => {
- jest.clearAllMocks();
- global.wcSettings = {
- countries: {
- US: 'United States of America',
- },
- };
- } );
-
- afterEach( () => {
- delete global.wcSettings;
- } );
-
- test( 'Renders Address section', () => {
- render( );
-
- const heading = screen.queryByRole( 'heading', {
- name: 'Business address',
- } );
- expect( heading ).toBeInTheDocument();
- } );
-
- test( 'Renders Address settings', () => {
- render( );
-
- const country = screen.getByLabelText( 'Country' );
- expect( country ).toBeInTheDocument();
-
- const address1 = screen.getByLabelText( 'Address line 1' );
- expect( address1 ).toBeInTheDocument();
-
- const address2 = screen.getByLabelText( 'Address line 2' );
- expect( address2 ).toBeInTheDocument();
-
- const city = screen.getByLabelText( 'City' );
- expect( city ).toBeInTheDocument();
-
- const zip = screen.getByLabelText( 'Postal code' );
- expect( zip ).toBeInTheDocument();
- } );
-} );
-
-describe( 'Card Reader Branding Details section', () => {
- beforeEach( () => {
- BrandingFileUpload.mockReturnValue( File Upload
);
- } );
-
- test( 'Renders Contacts section', () => {
- render( );
-
- const heading = screen.queryByRole( 'heading', {
- name: 'Branding',
- } );
- expect( heading ).toBeInTheDocument();
- } );
-
- test( 'Renders Contacts settings', () => {
- render( );
-
- const fileUploadFields = screen.queryAllByText( 'File Upload' );
- expect( fileUploadFields.length ).toEqual( 1 );
- } );
-} );
diff --git a/client/card-readers/settings/test/__snapshots__/index.test.tsx.snap b/client/card-readers/settings/test/__snapshots__/index.test.tsx.snap
deleted file mode 100644
index 27e80536c55..00000000000
--- a/client/card-readers/settings/test/__snapshots__/index.test.tsx.snap
+++ /dev/null
@@ -1,56 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`ReceiptSettings Readers merchant settings page snapshot test 1`] = `
-
-
-
-
-
- Card reader receipts
-
-
- These details will appear on emailed receipts for customers that pay in person using card readers. Updating the details here will not affect any other stores settings.
-
-
-
-
-
- Block placeholder
-
-
-
-
-
-
-
-
- Save changes
-
-
-
-
-
-`;
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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