From a5b186c87ef4b0c55d0292862fbcb27e617f3b5b Mon Sep 17 00:00:00 2001 From: Nagesh Pai <4162931+nagpai@users.noreply.github.com> Date: Thu, 20 Jun 2024 16:38:30 +0530 Subject: [PATCH 1/5] Instant Deposits: Show amount on a notice and button label (#8970) Co-authored-by: Nagesh Pai Co-authored-by: Rua Haszard --- assets/images/icons/send-money.svg | 3 + changelog/fix-8220-instant-deposit-messaging | 4 + client/components/account-balances/index.tsx | 115 ++++++++++++++++-- client/components/account-balances/style.scss | 9 +- .../account-balances/test/index.test.tsx | 2 +- client/deposits/instant-deposits/index.tsx | 15 ++- .../test/__snapshots__/index.tsx.snap | 14 +-- .../deposits/instant-deposits/test/index.tsx | 19 +-- client/globals.d.ts | 1 + client/utils/currency/index.js | 2 +- includes/admin/class-wc-payments-admin.php | 101 +++++++-------- includes/class-wc-payments.php | 1 + 12 files changed, 191 insertions(+), 95 deletions(-) create mode 100644 assets/images/icons/send-money.svg create mode 100644 changelog/fix-8220-instant-deposit-messaging diff --git a/assets/images/icons/send-money.svg b/assets/images/icons/send-money.svg new file mode 100644 index 00000000000..020dfa8862b --- /dev/null +++ b/assets/images/icons/send-money.svg @@ -0,0 +1,3 @@ + + + diff --git a/changelog/fix-8220-instant-deposit-messaging b/changelog/fix-8220-instant-deposit-messaging new file mode 100644 index 00000000000..15a6be38a63 --- /dev/null +++ b/changelog/fix-8220-instant-deposit-messaging @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Clearly display available instant deposit amount on notice and button label on Payment Overview page diff --git a/client/components/account-balances/index.tsx b/client/components/account-balances/index.tsx index 53010482c0f..2a1ace68487 100644 --- a/client/components/account-balances/index.tsx +++ b/client/components/account-balances/index.tsx @@ -1,24 +1,51 @@ /** * External dependencies */ -import * as React from 'react'; +import React, { useState } from 'react'; +import { useDispatch } from '@wordpress/data'; import { Flex } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import interpolateComponents from '@automattic/interpolate-components'; +import { Link } from '@woocommerce/components'; /** * Internal dependencies */ -import { useAllDepositsOverviews } from 'wcpay/data'; -import { useSelectedCurrency } from 'wcpay/overview/hooks'; +import type * as AccountOverview from 'wcpay/types/account-overview'; import BalanceBlock from './balance-block'; +import HelpOutlineIcon from 'gridicons/dist/help-outline'; +import InlineNotice from '../inline-notice'; +import InstantDepositButton from 'deposits/instant-deposits'; +import SendMoneyIcon from 'assets/images/icons/send-money.svg?asset'; import { TotalBalanceTooltip, AvailableBalanceTooltip, } from './balance-tooltip'; import { fundLabelStrings } from './strings'; -import InstantDepositButton from 'deposits/instant-deposits'; -import type * as AccountOverview from 'wcpay/types/account-overview'; +import { ClickTooltip } from '../tooltip'; +import { formatCurrency } from 'wcpay/utils/currency'; +import { useAllDepositsOverviews } from 'wcpay/data'; +import { useSelectedCurrency } from 'wcpay/overview/hooks'; import './style.scss'; +const useInstantDepositNoticeState = () => { + const { updateOptions } = useDispatch( 'wc/admin/options' ); + const [ isDismissed, setIsDismissed ] = useState( + wcpaySettings.isInstantDepositNoticeDismissed + ); + + const setInstantDepositNoticeDismissed = () => { + setIsDismissed( true ); + wcpaySettings.isInstantDepositNoticeDismissed = true; + updateOptions( { wcpay_instant_deposit_notice_dismissed: true } ); + }; + + return { + isInstantDepositNoticeDismissed: isDismissed, + handleDismissInstantDepositNotice: setInstantDepositNoticeDismissed, + }; +}; + /** * Renders account balances for the selected currency. */ @@ -26,6 +53,11 @@ const AccountBalances: React.FC = () => { const { overviews, isLoading } = useAllDepositsOverviews(); const { selectedCurrency } = useSelectedCurrency(); + const { + isInstantDepositNoticeDismissed, + handleDismissInstantDepositNotice, + } = useInstantDepositNoticeState(); + if ( ! isLoading && overviews.currencies.length === 0 ) { return null; } @@ -108,10 +140,77 @@ const AccountBalances: React.FC = () => { - + { ! isInstantDepositNoticeDismissed && ( + } + isDismissible={ true } + onRemove={ () => + handleDismissInstantDepositNotice() + } + > + { sprintf( + __( + /* translators: %$1$s: Available instant deposit amount, %2$s: Instant deposit fee percentage */ + /* 'Instantly deposit %1$s and get funds in your bank account in 30 mins for a %2$s%% fee.' */ + 'Get %1$s via instant deposit. Funds are typically in your bank account within 30 mins. Fee: %2$s%%.', + 'woocommerce-payments' + ), + formatCurrency( + selectedOverview.instantBalance.amount, + selectedOverview.instantBalance.currency + ), + selectedOverview.instantBalance + .fee_percentage + ) } + + ) } + + + + { isInstantDepositNoticeDismissed && ( // Show the tooltip only when the notice is dismissed. + } + buttonLabel={ __( + 'Learn more about instant deposit', + 'woocommerce-payments' + ) } + content={ + /* 'With instant deposit you can receive requested funds in your bank account within 30 mins for a 1.5% fee. Learn more' */ + + interpolateComponents( { + mixedString: sprintf( + __( + /* translators: %s: Instant deposit fee percentage */ + 'With {{strong}}instant deposit{{/strong}} you can receive requested funds in your bank account within 30 mins for a %s%% fee. {{learnMoreLink}}Learn more{{/learnMoreLink}}', + 'woocommerce-payments' + ), + selectedOverview.instantBalance + .fee_percentage + ), + components: { + strong: , + learnMoreLink: ( + + ), + }, + } ) + } + /> + ) } + ) } diff --git a/client/components/account-balances/style.scss b/client/components/account-balances/style.scss index 22b44cd9591..b721fe9919d 100644 --- a/client/components/account-balances/style.scss +++ b/client/components/account-balances/style.scss @@ -12,6 +12,9 @@ font-size: 12px; line-height: 16px; color: $gray-600; + display: flex; + align-items: center; + margin-top: 0; } &__amount { @@ -22,10 +25,8 @@ } } -.wcpay-account-balances__balances__item__title { - display: flex; - align-items: center; - margin-top: 0; +.wcpay-account-balances__instant-deposit-notice .components-notice__content { + margin-right: 0; } .wcpay-account-balances__instant-deposit { diff --git a/client/components/account-balances/test/index.test.tsx b/client/components/account-balances/test/index.test.tsx index 176488a605c..9875552d438 100644 --- a/client/components/account-balances/test/index.test.tsx +++ b/client/components/account-balances/test/index.test.tsx @@ -302,7 +302,7 @@ describe( 'AccountBalances', () => { render( ); screen.getByRole( 'button', { - name: 'Deposit available funds', + name: 'Get $300.00 now', } ); } ); diff --git a/client/deposits/instant-deposits/index.tsx b/client/deposits/instant-deposits/index.tsx index 9df289c2eac..683aecb03a3 100644 --- a/client/deposits/instant-deposits/index.tsx +++ b/client/deposits/instant-deposits/index.tsx @@ -5,13 +5,14 @@ */ import React from 'react'; import { Button } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { useState } from '@wordpress/element'; /** * Internal dependencies */ import './style.scss'; +import { formatCurrency } from 'wcpay/utils/currency'; import InstantDepositModal from './modal'; import { useInstantDeposit } from 'wcpay/data'; import type * as AccountOverview from 'wcpay/types/account-overview'; @@ -49,7 +50,17 @@ const InstantDepositButton: React.FC< InstantDepositButtonProps > = ( { disabled={ buttonDisabled } onClick={ () => setModalOpen( true ) } > - { __( 'Deposit available funds', 'woocommerce-payments' ) } + { sprintf( + __( + /* translators: %s: Available instant deposit amount */ + 'Get %s now', + 'woocommerce-payments' + ), + formatCurrency( + instantBalance.amount, + instantBalance.currency + ) + ) } { ( isModalOpen || inProgress ) && ( - Deposit available funds - - -`; - -exports[`Instant deposit button and modal button renders correctly with zero balance 1`] = ` -
-
`; diff --git a/client/deposits/instant-deposits/test/index.tsx b/client/deposits/instant-deposits/test/index.tsx index 4aec2f7b322..d8b2d8ec8e6 100644 --- a/client/deposits/instant-deposits/test/index.tsx +++ b/client/deposits/instant-deposits/test/index.tsx @@ -31,14 +31,6 @@ const mockInstantBalance = { currency: 'USD', } as AccountOverview.InstantBalance; -const mockZeroInstantBalance = { - amount: 0, - fee: 0, - net: 0, - fee_percentage: 1.5, - currency: 'USD', -} as AccountOverview.InstantBalance; - declare const global: { wcpaySettings: { zeroDecimalCurrencies: string[]; @@ -70,13 +62,6 @@ describe( 'Instant deposit button and modal', () => { }; } ); - test( 'button renders correctly with zero balance', () => { - const { container } = render( - - ); - expect( container ).toMatchSnapshot(); - } ); - test( 'button renders correctly with balance', () => { const { container } = render( @@ -92,7 +77,9 @@ describe( 'Instant deposit button and modal', () => { screen.queryByRole( 'dialog', { name: /instant deposit/i } ) ).not.toBeInTheDocument(); fireEvent.click( - screen.getByRole( 'button', { name: /Deposit available funds/i } ) + screen.getByRole( 'button', { + name: /Get \$123\.45 now/i, + } ) ); const modal = screen.queryByRole( 'dialog', { name: /instant deposit/i, diff --git a/client/globals.d.ts b/client/globals.d.ts index db7b9ce42a4..df4adbcfccf 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -118,6 +118,7 @@ declare global { capabilityRequestNotices: Record< string, boolean >; storeName: string; isNextDepositNoticeDismissed: boolean; + isInstantDepositNoticeDismissed: boolean; reporting: { exportModalDismissed?: boolean; }; diff --git a/client/utils/currency/index.js b/client/utils/currency/index.js index e32b423fc37..f2727cd990f 100644 --- a/client/utils/currency/index.js +++ b/client/utils/currency/index.js @@ -340,7 +340,7 @@ function composeFallbackCurrency( amount, currencyCode, isZeroDecimal ) { } } -function trimEndingZeroes( formattedCurrencyAmount = '' ) { +export function trimEndingZeroes( formattedCurrencyAmount = '' ) { return formattedCurrencyAmount .split( ' ' ) .map( ( chunk ) => diff --git a/includes/admin/class-wc-payments-admin.php b/includes/admin/class-wc-payments-admin.php index aeb97880bfa..27604515859 100644 --- a/includes/admin/class-wc-payments-admin.php +++ b/includes/admin/class-wc-payments-admin.php @@ -845,72 +845,73 @@ private function get_js_settings(): array { $site_logo_url = $site_logo_id ? ( wp_get_attachment_image_src( $site_logo_id, 'full' )[0] ?? '' ) : ''; $this->wcpay_js_settings = [ - 'version' => WCPAY_VERSION_NUMBER, - 'connectUrl' => $connect_url, - 'connect' => [ + 'version' => WCPAY_VERSION_NUMBER, + 'connectUrl' => $connect_url, + 'connect' => [ 'country' => WC()->countries->get_base_country(), 'availableCountries' => WC_Payments_Utils::supported_countries(), 'availableStates' => WC()->countries->get_states(), ], - 'connectIncentive' => $connect_incentive, - 'devMode' => $dev_mode, - 'testMode' => $test_mode, - 'onboardingTestMode' => WC_Payments_Onboarding_Service::is_test_mode_enabled(), + 'connectIncentive' => $connect_incentive, + 'devMode' => $dev_mode, + 'testMode' => $test_mode, + 'onboardingTestMode' => WC_Payments_Onboarding_Service::is_test_mode_enabled(), // Set this flag for use in the front-end to alter messages and notices if on-boarding has been disabled. - 'onBoardingDisabled' => WC_Payments_Account::is_on_boarding_disabled(), - 'onboardingFieldsData' => $this->onboarding_service->get_fields_data( get_user_locale() ), - 'errorMessage' => $error_message, - 'featureFlags' => $this->get_frontend_feature_flags(), - 'isSubscriptionsActive' => class_exists( 'WC_Subscriptions' ) && version_compare( WC_Subscriptions::$version, '2.2.0', '>=' ), + 'onBoardingDisabled' => WC_Payments_Account::is_on_boarding_disabled(), + 'onboardingFieldsData' => $this->onboarding_service->get_fields_data( get_user_locale() ), + 'errorMessage' => $error_message, + 'featureFlags' => $this->get_frontend_feature_flags(), + 'isSubscriptionsActive' => class_exists( 'WC_Subscriptions' ) && version_compare( WC_Subscriptions::$version, '2.2.0', '>=' ), // Used in the settings page by the AccountFees component. - 'zeroDecimalCurrencies' => WC_Payments_Utils::zero_decimal_currencies(), - 'fraudServices' => $this->fraud_service->get_fraud_services_config(), - 'isJetpackConnected' => $this->payments_api_client->is_server_connected(), - 'isJetpackIdcActive' => Jetpack_Identity_Crisis::has_identity_crisis(), - 'accountStatus' => $account_status_data, - 'accountFees' => $this->account->get_fees(), - 'accountLoans' => $this->account->get_capital(), - 'accountEmail' => $this->account->get_account_email(), - 'showUpdateDetailsTask' => $this->get_should_show_update_business_details_task( $account_status_data ), - 'wpcomReconnectUrl' => $this->payments_api_client->is_server_connected() && ! $this->payments_api_client->has_server_connection_owner() ? WC_Payments_Account::get_wpcom_reconnect_url() : null, - 'multiCurrencySetup' => [ + 'zeroDecimalCurrencies' => WC_Payments_Utils::zero_decimal_currencies(), + 'fraudServices' => $this->fraud_service->get_fraud_services_config(), + 'isJetpackConnected' => $this->payments_api_client->is_server_connected(), + 'isJetpackIdcActive' => Jetpack_Identity_Crisis::has_identity_crisis(), + 'accountStatus' => $account_status_data, + 'accountFees' => $this->account->get_fees(), + 'accountLoans' => $this->account->get_capital(), + 'accountEmail' => $this->account->get_account_email(), + 'showUpdateDetailsTask' => $this->get_should_show_update_business_details_task( $account_status_data ), + 'wpcomReconnectUrl' => $this->payments_api_client->is_server_connected() && ! $this->payments_api_client->has_server_connection_owner() ? WC_Payments_Account::get_wpcom_reconnect_url() : null, + 'multiCurrencySetup' => [ 'isSetupCompleted' => get_option( 'wcpay_multi_currency_setup_completed' ), ], - 'isMultiCurrencyEnabled' => WC_Payments_Features::is_customer_multi_currency_enabled(), - 'shouldUseExplicitPrice' => WC_Payments_Explicit_Price_Formatter::should_output_explicit_price(), - 'overviewTasksVisibility' => [ + 'isMultiCurrencyEnabled' => WC_Payments_Features::is_customer_multi_currency_enabled(), + 'shouldUseExplicitPrice' => WC_Payments_Explicit_Price_Formatter::should_output_explicit_price(), + 'overviewTasksVisibility' => [ 'dismissedTodoTasks' => get_option( 'woocommerce_dismissed_todo_tasks', [] ), 'deletedTodoTasks' => get_option( 'woocommerce_deleted_todo_tasks', [] ), 'remindMeLaterTodoTasks' => get_option( 'woocommerce_remind_me_later_todo_tasks', [] ), ], - 'currentUserEmail' => $current_user_email, - 'currencyData' => $currency_data, - 'restUrl' => get_rest_url( null, '' ), // rest url to concatenate when merchant use Plain permalinks. - 'siteLogoUrl' => $site_logo_url, - 'isFRTReviewFeatureActive' => WC_Payments_Features::is_frt_review_feature_active(), - 'fraudProtection' => [ + 'currentUserEmail' => $current_user_email, + 'currencyData' => $currency_data, + 'restUrl' => get_rest_url( null, '' ), // rest url to concatenate when merchant use Plain permalinks. + 'siteLogoUrl' => $site_logo_url, + 'isFRTReviewFeatureActive' => WC_Payments_Features::is_frt_review_feature_active(), + 'fraudProtection' => [ 'isWelcomeTourDismissed' => WC_Payments_Features::is_fraud_protection_welcome_tour_dismissed(), ], - 'enabledPaymentMethods' => $this->get_enabled_payment_method_ids(), - 'progressiveOnboarding' => $this->account->get_progressive_onboarding_details(), - 'accountDefaultCurrency' => $this->account->get_account_default_currency(), - 'frtDiscoverBannerSettings' => get_option( 'wcpay_frt_discover_banner_settings', '' ), - 'storeCurrency' => get_option( 'woocommerce_currency' ), - 'isWooPayStoreCountryAvailable' => WooPay_Utilities::is_store_country_available(), - 'woopayLastDisableDate' => $this->wcpay_gateway->get_option( 'platform_checkout_last_disable_date' ), - 'isStripeBillingEnabled' => WC_Payments_Features::is_stripe_billing_enabled(), - 'isStripeBillingEligible' => WC_Payments_Features::is_stripe_billing_eligible(), - 'capabilityRequestNotices' => get_option( 'wcpay_capability_request_dismissed_notices ', [] ), - 'storeName' => get_bloginfo( 'name' ), - 'isNextDepositNoticeDismissed' => WC_Payments_Features::is_next_deposit_notice_dismissed(), - 'reporting' => [ + 'enabledPaymentMethods' => $this->get_enabled_payment_method_ids(), + 'progressiveOnboarding' => $this->account->get_progressive_onboarding_details(), + 'accountDefaultCurrency' => $this->account->get_account_default_currency(), + 'frtDiscoverBannerSettings' => get_option( 'wcpay_frt_discover_banner_settings', '' ), + 'storeCurrency' => get_option( 'woocommerce_currency' ), + 'isWooPayStoreCountryAvailable' => WooPay_Utilities::is_store_country_available(), + 'woopayLastDisableDate' => $this->wcpay_gateway->get_option( 'platform_checkout_last_disable_date' ), + 'isStripeBillingEnabled' => WC_Payments_Features::is_stripe_billing_enabled(), + 'isStripeBillingEligible' => WC_Payments_Features::is_stripe_billing_eligible(), + 'capabilityRequestNotices' => get_option( 'wcpay_capability_request_dismissed_notices ', [] ), + 'storeName' => get_bloginfo( 'name' ), + 'isNextDepositNoticeDismissed' => WC_Payments_Features::is_next_deposit_notice_dismissed(), + 'isInstantDepositNoticeDismissed' => get_option( 'wcpay_instant_deposit_notice_dismissed', false ), + 'reporting' => [ 'exportModalDismissed' => get_option( 'wcpay_reporting_export_modal_dismissed', false ), ], - 'dismissedDuplicateNotices' => get_option( 'wcpay_duplicate_payment_method_notices_dismissed', [] ), - 'locale' => WC_Payments_Utils::get_language_data( get_locale() ), - 'isOverviewSurveySubmitted' => get_option( 'wcpay_survey_payment_overview_submitted', false ), - 'trackingInfo' => $this->account->get_tracking_info(), - 'lifetimeTPV' => $this->account->get_lifetime_total_payment_volume(), + 'dismissedDuplicateNotices' => get_option( 'wcpay_duplicate_payment_method_notices_dismissed', [] ), + 'locale' => WC_Payments_Utils::get_language_data( get_locale() ), + 'isOverviewSurveySubmitted' => get_option( 'wcpay_survey_payment_overview_submitted', false ), + 'trackingInfo' => $this->account->get_tracking_info(), + 'lifetimeTPV' => $this->account->get_lifetime_total_payment_volume(), ]; return apply_filters( 'wcpay_js_settings', $this->wcpay_js_settings ); diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index a7a355917a9..afa27c4f4aa 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -1849,6 +1849,7 @@ public static function add_wcpay_options_to_woocommerce_permissions_list( $permi 'wcpay_next_deposit_notice_dismissed', 'wcpay_duplicate_payment_method_notices_dismissed', 'wcpay_exit_survey_dismissed', + 'wcpay_instant_deposit_notice_dismissed', ], true ); From e56b9f771298a35a9d756431fdc40087d20dcf68 Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 20 Jun 2024 17:50:50 +0200 Subject: [PATCH 2/5] chore: copy prb blocks implementation to tokenized cart (#8989) --- ...rb-blocks-implementation-to-tokenized-cart | 5 + client/checkout/blocks/index.js | 8 +- .../blocks/apple-pay-preview.js | 3 + .../tokenized-payment-request/blocks/index.js | 60 +++++++ .../blocks/payment-request-express.js | 147 +++++++++++++++ .../blocks/use-initialization.js | 167 ++++++++++++++++++ includes/class-wc-payments-checkout.php | 1 + 7 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 changelog/feat-copy-prb-blocks-implementation-to-tokenized-cart create mode 100644 client/tokenized-payment-request/blocks/apple-pay-preview.js create mode 100644 client/tokenized-payment-request/blocks/index.js create mode 100644 client/tokenized-payment-request/blocks/payment-request-express.js create mode 100644 client/tokenized-payment-request/blocks/use-initialization.js diff --git a/changelog/feat-copy-prb-blocks-implementation-to-tokenized-cart b/changelog/feat-copy-prb-blocks-implementation-to-tokenized-cart new file mode 100644 index 00000000000..8a16761aea4 --- /dev/null +++ b/changelog/feat-copy-prb-blocks-implementation-to-tokenized-cart @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: chore: copy PRB blocks to tokenized cart + + diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js index 9f858acd87d..28291b69d3e 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -20,6 +20,8 @@ import request from '../utils/request'; import enqueueFraudScripts from 'fraud-scripts'; import paymentRequestPaymentMethod from '../../payment-request/blocks'; import expressCheckoutElementPaymentMethod from '../../express-checkout/blocks'; +import tokenizedCartPaymentRequestPaymentMethod from '../../tokenized-payment-request/blocks'; + import { PAYMENT_METHOD_NAME_CARD, PAYMENT_METHOD_NAME_BANCONTACT, @@ -154,7 +156,11 @@ if ( getUPEConfig( 'isWooPayEnabled' ) ) { } } -if ( getUPEConfig( 'isExpressCheckoutElementEnabled' ) ) { +if ( getUPEConfig( 'isTokenizedCartPrbEnabled' ) ) { + registerExpressPaymentMethod( + tokenizedCartPaymentRequestPaymentMethod( api ) + ); +} else if ( getUPEConfig( 'isExpressCheckoutElementEnabled' ) ) { registerExpressPaymentMethod( expressCheckoutElementPaymentMethod( api ) ); } else { registerExpressPaymentMethod( paymentRequestPaymentMethod( api ) ); diff --git a/client/tokenized-payment-request/blocks/apple-pay-preview.js b/client/tokenized-payment-request/blocks/apple-pay-preview.js new file mode 100644 index 00000000000..6b6f543ed05 --- /dev/null +++ b/client/tokenized-payment-request/blocks/apple-pay-preview.js @@ -0,0 +1,3 @@ +/* eslint-disable max-len */ +export const applePayImage = + "data:image/svg+xml,%3Csvg width='264' height='48' viewBox='0 0 264 48' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='264' height='48' rx='3' fill='black'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M125.114 16.6407C125.682 15.93 126.067 14.9756 125.966 14C125.135 14.0415 124.121 14.549 123.533 15.2602C123.006 15.8693 122.539 16.8641 122.661 17.7983C123.594 17.8797 124.526 17.3317 125.114 16.6407Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M125.955 17.982C124.601 17.9011 123.448 18.7518 122.801 18.7518C122.154 18.7518 121.163 18.0224 120.092 18.0421C118.696 18.0629 117.402 18.8524 116.694 20.1079C115.238 22.6196 116.31 26.3453 117.726 28.3909C118.414 29.4028 119.242 30.5174 120.334 30.4769C121.366 30.4365 121.77 29.8087 123.024 29.8087C124.277 29.8087 124.641 30.4769 125.733 30.4567C126.865 30.4365 127.573 29.4443 128.261 28.4313C129.049 27.2779 129.373 26.1639 129.393 26.1027C129.373 26.0825 127.209 25.2515 127.189 22.7606C127.169 20.6751 128.888 19.6834 128.969 19.6217C127.998 18.1847 126.481 18.0224 125.955 17.982Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M136.131 23.1804H138.834C140.886 23.1804 142.053 22.0752 142.053 20.1592C142.053 18.2432 140.886 17.1478 138.845 17.1478H136.131V23.1804ZM139.466 15.1582C142.411 15.1582 144.461 17.1903 144.461 20.1483C144.461 23.1172 142.369 25.1596 139.392 25.1596H136.131V30.3498H133.775V15.1582H139.466Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M152.198 26.224V25.3712L149.579 25.5397C148.106 25.6341 147.339 26.182 147.339 27.14C147.339 28.0664 148.138 28.6667 149.39 28.6667C150.988 28.6667 152.198 27.6449 152.198 26.224ZM145.046 27.2032C145.046 25.2551 146.529 24.1395 149.263 23.971L152.198 23.7922V22.9498C152.198 21.7181 151.388 21.0442 149.947 21.0442C148.758 21.0442 147.896 21.6548 147.717 22.5916H145.592C145.656 20.6232 147.507 19.1914 150.01 19.1914C152.703 19.1914 154.459 20.602 154.459 22.7917V30.351H152.282V28.5298H152.229C151.609 29.719 150.241 30.4666 148.758 30.4666C146.571 30.4666 145.046 29.1612 145.046 27.2032Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M156.461 34.4145V32.5934C156.608 32.6141 156.965 32.6354 157.155 32.6354C158.196 32.6354 158.785 32.1932 159.142 31.0564L159.353 30.3824L155.366 19.3281H157.827L160.604 28.298H160.657L163.434 19.3281H165.832L161.698 30.9402C160.752 33.6038 159.668 34.4778 157.376 34.4778C157.197 34.4778 156.618 34.4565 156.461 34.4145Z' fill='white'/%3E%3C/svg%3E%0A"; diff --git a/client/tokenized-payment-request/blocks/index.js b/client/tokenized-payment-request/blocks/index.js new file mode 100644 index 00000000000..714c7f9eafb --- /dev/null +++ b/client/tokenized-payment-request/blocks/index.js @@ -0,0 +1,60 @@ +/* global wcpayConfig, wcpayPaymentRequestParams */ + +/** + * Internal dependencies + */ +import { PaymentRequestExpress } from './payment-request-express'; +import { applePayImage } from './apple-pay-preview'; +import { getConfig } from '../../utils/checkout'; +import { getPaymentRequest } from '../../payment-request/utils'; + +const PAYMENT_METHOD_NAME_PAYMENT_REQUEST = + 'woocommerce_payments_tokenized_cart_payment_request'; + +const ApplePayPreview = () => ; + +const tokenizedCartPaymentRequestPaymentMethod = ( api ) => ( { + name: PAYMENT_METHOD_NAME_PAYMENT_REQUEST, + content: ( + + ), + edit: , + canMakePayment: ( cartData ) => { + // If in the editor context, always return true to display the `edit` prop preview. + // https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/4101. + if ( getConfig( 'is_admin' ) ) { + return true; + } + + if ( typeof wcpayPaymentRequestParams === 'undefined' ) { + return false; + } + + if ( + typeof wcpayConfig !== 'undefined' && + wcpayConfig.isExpressCheckoutElementEnabled + ) { + return false; + } + + return api.loadStripe( true ).then( ( stripe ) => { + // Create a payment request and check if we can make a payment to determine whether to + // show the Payment Request Button or not. This is necessary because a browser might be + // able to load the Stripe JS object, but not support Payment Requests. + const pr = getPaymentRequest( { + stripe, + total: parseInt( cartData?.cartTotals?.total_price ?? 0, 10 ), + requestShipping: cartData?.cartNeedsShipping, + displayItems: [], + } ); + + return pr.canMakePayment(); + } ); + }, + paymentMethodId: PAYMENT_METHOD_NAME_PAYMENT_REQUEST, + supports: { + features: getConfig( 'features' ), + }, +} ); + +export default tokenizedCartPaymentRequestPaymentMethod; diff --git a/client/tokenized-payment-request/blocks/payment-request-express.js b/client/tokenized-payment-request/blocks/payment-request-express.js new file mode 100644 index 00000000000..c54380b2514 --- /dev/null +++ b/client/tokenized-payment-request/blocks/payment-request-express.js @@ -0,0 +1,147 @@ +/* global wcpayPaymentRequestParams */ + +/** + * External dependencies + */ +import { Elements, PaymentRequestButtonElement } from '@stripe/react-stripe-js'; +import { recordUserEvent } from 'tracks'; +import { useEffect, useState } from 'react'; + +/** + * Internal dependencies + */ +import { useInitialization } from './use-initialization'; +import { getPaymentRequestData } from '../../payment-request/utils'; + +/** + * PaymentRequestExpressComponent + * + * @param {Object} props Incoming props. + * + * @return {ReactNode} Payment Request button component. + */ +const PaymentRequestExpressComponent = ( { + api, + billing, + shippingData, + setExpressPaymentError, + onClick, + onClose, + onPaymentRequestAvailable, +} ) => { + // TODO: Don't display custom button when result.requestType + // is `apple_pay` or `google_pay`. + const { + paymentRequest, + // paymentRequestType, + onButtonClick, + } = useInitialization( { + api, + billing, + shippingData, + setExpressPaymentError, + onClick, + onClose, + } ); + + const { type, theme, height } = getPaymentRequestData( 'button' ); + + const paymentRequestButtonStyle = { + paymentRequestButton: { + type, + theme, + height: height + 'px', + }, + }; + + if ( ! paymentRequest ) { + return null; + } + + let paymentRequestType = ''; + + // Check the availability of the Payment Request API first. + paymentRequest.canMakePayment().then( ( result ) => { + if ( ! result ) { + return; + } + + // Set the payment request type. + if ( result.applePay ) { + paymentRequestType = 'apple_pay'; + } else if ( result.googlePay ) { + paymentRequestType = 'google_pay'; + } + onPaymentRequestAvailable( paymentRequestType ); + } ); + + const onPaymentRequestButtonClick = ( event ) => { + onButtonClick( event, paymentRequest ); + + const paymentRequestTypeEvents = { + google_pay: 'gpay_button_click', + apple_pay: 'applepay_button_click', + }; + + if ( paymentRequestTypeEvents.hasOwnProperty( paymentRequestType ) ) { + const paymentRequestEvent = + paymentRequestTypeEvents[ paymentRequestType ]; + recordUserEvent( paymentRequestEvent, { + source: wcpayPaymentRequestParams?.button_context, + } ); + } + }; + + return ( + + ); +}; + +/** + * PaymentRequestExpress express payment method component. + * + * @param {Object} props PaymentMethodProps. + * + * @return {ReactNode} Stripe Elements component. + */ +export const PaymentRequestExpress = ( props ) => { + const { stripe } = props; + const [ paymentRequestType, setPaymentRequestType ] = useState( false ); + + const handlePaymentRequestAvailability = ( paymentType ) => { + setPaymentRequestType( paymentType ); + }; + + useEffect( () => { + if ( paymentRequestType ) { + const paymentRequestTypeEvents = { + google_pay: 'gpay_button_load', + apple_pay: 'applepay_button_load', + }; + + if ( + paymentRequestTypeEvents.hasOwnProperty( paymentRequestType ) + ) { + const event = paymentRequestTypeEvents[ paymentRequestType ]; + recordUserEvent( event, { + source: wcpayPaymentRequestParams?.button_context, + } ); + } + } + }, [ paymentRequestType ] ); + + return ( + + + + ); +}; diff --git a/client/tokenized-payment-request/blocks/use-initialization.js b/client/tokenized-payment-request/blocks/use-initialization.js new file mode 100644 index 00000000000..32bd20df57a --- /dev/null +++ b/client/tokenized-payment-request/blocks/use-initialization.js @@ -0,0 +1,167 @@ +/** + * External dependencies + */ +import { useEffect, useState, useCallback } from '@wordpress/element'; +import { useStripe } from '@stripe/react-stripe-js'; + +/** + * Internal dependencies + */ +import { + shippingAddressChangeHandler, + shippingOptionChangeHandler, + paymentMethodHandler, +} from '../../payment-request/event-handlers.js'; + +import { + getPaymentRequest, + getPaymentRequestData, + updatePaymentRequest, + normalizeLineItems, + displayLoginConfirmation, +} from '../../payment-request/utils'; + +export const useInitialization = ( { + api, + billing, + shippingData, + setExpressPaymentError, + onClick, + onClose, +} ) => { + const stripe = useStripe(); + + const [ paymentRequest, setPaymentRequest ] = useState( null ); + const [ isFinished, setIsFinished ] = useState( false ); + const [ paymentRequestType, setPaymentRequestType ] = useState( '' ); + + // Create the initial paymentRequest object. Note, we can't do anything if stripe isn't available yet or we have zero total. + useEffect( () => { + if ( + ! stripe || + ! billing?.cartTotal?.value || + isFinished || + paymentRequest + ) { + return; + } + + const pr = getPaymentRequest( { + stripe, + total: billing?.cartTotal?.value, + requestShipping: shippingData?.needsShipping, + displayItems: normalizeLineItems( billing?.cartTotalItems ), + } ); + + pr.canMakePayment().then( ( result ) => { + if ( result ) { + setPaymentRequest( pr ); + if ( result.applePay ) { + setPaymentRequestType( 'apple_pay' ); + } else if ( result.googlePay ) { + setPaymentRequestType( 'google_pay' ); + } else { + setPaymentRequestType( 'payment_request_api' ); + } + } + } ); + }, [ + stripe, + paymentRequest, + billing?.cartTotal?.value, + isFinished, + shippingData?.needsShipping, + billing?.cartTotalItems, + ] ); + + // It's not possible to update the `requestShipping` property in the `paymentRequest` + // object, so when `needsShipping` changes, we need to reset the `paymentRequest` object. + useEffect( () => { + setPaymentRequest( null ); + }, [ shippingData.needsShipping ] ); + + // When the payment button is clicked, update the request and show it. + const onButtonClick = useCallback( + ( evt, pr ) => { + // If login is required, display redirect confirmation dialog. + if ( getPaymentRequestData( 'login_confirmation' ) ) { + evt.preventDefault(); + displayLoginConfirmation( paymentRequestType ); + return; + } + + setIsFinished( false ); + setExpressPaymentError( '' ); + updatePaymentRequest( { + paymentRequest, + total: billing?.cartTotal?.value, + displayItems: normalizeLineItems( billing?.cartTotalItems ), + } ); + onClick(); + + // We must manually call payment request `show()` for custom buttons. + if ( pr ) { + pr.show(); + } + }, + [ + onClick, + paymentRequest, + paymentRequestType, + setExpressPaymentError, + billing.cartTotal, + billing.cartTotalItems, + ] + ); + + // Whenever paymentRequest changes, hook in event listeners. + useEffect( () => { + const cancelHandler = () => { + setIsFinished( false ); + setPaymentRequest( null ); + onClose(); + }; + + const completePayment = ( redirectUrl ) => { + setIsFinished( true ); + window.location = redirectUrl; + }; + + const abortPayment = ( paymentMethod, message ) => { + paymentMethod.complete( 'fail' ); + setIsFinished( true ); + setExpressPaymentError( message ); + }; + + paymentRequest?.on( 'shippingaddresschange', ( event ) => + shippingAddressChangeHandler( api, event ) + ); + + paymentRequest?.on( 'shippingoptionchange', ( event ) => + shippingOptionChangeHandler( api, event ) + ); + + paymentRequest?.on( 'paymentmethod', ( event ) => + paymentMethodHandler( api, completePayment, abortPayment, event ) + ); + + paymentRequest?.on( 'cancel', cancelHandler ); + + return () => { + paymentRequest?.removeAllListeners(); + }; + }, [ + setExpressPaymentError, + paymentRequest, + api, + setIsFinished, + setPaymentRequest, + onClose, + ] ); + + return { + paymentRequest, + onButtonClick, + paymentRequestType, + }; +}; diff --git a/includes/class-wc-payments-checkout.php b/includes/class-wc-payments-checkout.php index 9a649c50716..2486cec98df 100644 --- a/includes/class-wc-payments-checkout.php +++ b/includes/class-wc-payments-checkout.php @@ -193,6 +193,7 @@ public function get_payment_fields_js_config() { 'isPreview' => is_preview(), 'isSavedCardsEnabled' => $this->gateway->is_saved_cards_enabled(), 'isExpressCheckoutElementEnabled' => WC_Payments_Features::is_stripe_ece_enabled(), + 'isTokenizedCartPrbEnabled' => WC_Payments_Features::is_tokenized_cart_prb_enabled(), 'isWooPayEnabled' => $this->woopay_util->should_enable_woopay( $this->gateway ) && $this->woopay_util->should_enable_woopay_on_cart_or_checkout(), 'isWoopayExpressCheckoutEnabled' => $this->woopay_util->is_woopay_express_checkout_enabled(), 'isWoopayFirstPartyAuthEnabled' => $this->woopay_util->is_woopay_first_party_auth_enabled(), From 770cd3385253a4c6e0df0605cf0290d853eea441 Mon Sep 17 00:00:00 2001 From: Daniel Guerra <15204776+danielmx-dev@users.noreply.github.com> Date: Thu, 20 Jun 2024 19:11:29 +0300 Subject: [PATCH 3/5] Fix: Hide payment methods with domestic transactions restrictions (Klarna, Affirm, Afterpay) when conditions are not met (#8980) Co-authored-by: Brett Shumaker --- ...-restrict-klarna-non-domestic-transactions | 4 + includes/class-wc-payments-utils.php | 42 +++++ .../class-klarna-payment-method.php | 30 ++++ .../class-upe-payment-method.php | 5 +- .../test-class-klarna-payment-method.php | 155 +++++++++++++++++ .../test-class-upe-payment-method.php | 158 ++++++++++++++++++ 6 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 changelog/fix-8718-restrict-klarna-non-domestic-transactions create mode 100644 tests/unit/payment-methods/test-class-klarna-payment-method.php create mode 100644 tests/unit/payment-methods/test-class-upe-payment-method.php diff --git a/changelog/fix-8718-restrict-klarna-non-domestic-transactions b/changelog/fix-8718-restrict-klarna-non-domestic-transactions new file mode 100644 index 00000000000..60e4a65fba4 --- /dev/null +++ b/changelog/fix-8718-restrict-klarna-non-domestic-transactions @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Hide payment methods with domestic transactions restrictions (Klarna, Affirm, Afterpay) when conditions are not met. diff --git a/includes/class-wc-payments-utils.php b/includes/class-wc-payments-utils.php index f50a48b80d2..7074eb2aaf2 100644 --- a/includes/class-wc-payments-utils.php +++ b/includes/class-wc-payments-utils.php @@ -1147,4 +1147,46 @@ public static function get_active_upe_theme_transient_for_location( string $loca // Fallback to 'stripe' if no transients are set. return 'stripe'; } + + /** + * Returns the list of countries in the European Economic Area (EEA). + * + * Based on the list documented at https://www.gov.uk/eu-eea. + * + * @return string[] + */ + public static function get_european_economic_area_countries() { + return [ + Country_Code::AUSTRIA, + Country_Code::BELGIUM, + Country_Code::BULGARIA, + Country_Code::CROATIA, + Country_Code::CYPRUS, + Country_Code::CZECHIA, + Country_Code::DENMARK, + Country_Code::ESTONIA, + Country_Code::FINLAND, + Country_Code::FRANCE, + Country_Code::GERMANY, + Country_Code::GREECE, + Country_Code::HUNGARY, + Country_Code::IRELAND, + Country_Code::ICELAND, + Country_Code::ITALY, + Country_Code::LATVIA, + Country_Code::LIECHTENSTEIN, + Country_Code::LITHUANIA, + Country_Code::LUXEMBOURG, + Country_Code::MALTA, + Country_Code::NORWAY, + Country_Code::NETHERLANDS, + Country_Code::POLAND, + Country_Code::PORTUGAL, + Country_Code::ROMANIA, + Country_Code::SLOVAKIA, + Country_Code::SLOVENIA, + Country_Code::SPAIN, + Country_Code::SWEDEN, + ]; + } } diff --git a/includes/payment-methods/class-klarna-payment-method.php b/includes/payment-methods/class-klarna-payment-method.php index fa30154c226..de3ec1e4ca6 100644 --- a/includes/payment-methods/class-klarna-payment-method.php +++ b/includes/payment-methods/class-klarna-payment-method.php @@ -103,6 +103,36 @@ public function __construct( $token_service ) { ]; } + /** + * Returns payment method supported countries. + * + * For Klarna we need to include additional logic to support transactions between countries in the EEA, + * UK, and Switzerland. + * + * @return array + */ + public function get_countries() { + $account = \WC_Payments::get_account_service()->get_cached_account_data(); + $account_country = isset( $account['country'] ) ? strtoupper( $account['country'] ) : ''; + + // Countries in the EEA can transact across all other EEA countries. This includes Switzerland and the UK who aren't strictly in the EU. + $eea_countries = array_merge( + WC_Payments_Utils::get_european_economic_area_countries(), + [ Country_Code::SWITZERLAND, Country_Code::UNITED_KINGDOM ] + ); + + // If the merchant is in the EEA, UK, or Switzerland, only the countries that have the same domestic currency as the store currency will be supported. + if ( in_array( $account_country, $eea_countries, true ) ) { + $store_currency = strtoupper( get_woocommerce_currency() ); + + $countries_that_support_store_currency = array_keys( $this->limits_per_currency[ $store_currency ] ); + + return array_values( array_intersect( $eea_countries, $countries_that_support_store_currency ) ); + } + + return parent::get_countries(); + } + /** * Returns testing credentials to be printed at checkout in test mode. * diff --git a/includes/payment-methods/class-upe-payment-method.php b/includes/payment-methods/class-upe-payment-method.php index dabe8b1eeac..eaaad1bfbad 100644 --- a/includes/payment-methods/class-upe-payment-method.php +++ b/includes/payment-methods/class-upe-payment-method.php @@ -303,7 +303,10 @@ public function get_payment_method_icon_for_location( string $location = 'checko * @return array */ public function get_countries() { - return $this->countries; + $account = \WC_Payments::get_account_service()->get_cached_account_data(); + $account_country = isset( $account['country'] ) ? strtoupper( $account['country'] ) : ''; + + return $this->has_domestic_transactions_restrictions() ? [ $account_country ] : $this->countries; } /** diff --git a/tests/unit/payment-methods/test-class-klarna-payment-method.php b/tests/unit/payment-methods/test-class-klarna-payment-method.php new file mode 100644 index 00000000000..cf49b6233c6 --- /dev/null +++ b/tests/unit/payment-methods/test-class-klarna-payment-method.php @@ -0,0 +1,155 @@ +mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); + $this->original_account_service = WC_Payments::get_account_service(); + WC_Payments::set_account_service( $this->mock_wcpay_account ); + + // Arrange: Mock WC_Payments_Token_Service so its methods aren't called directly. + $this->mock_token_service = $this->getMockBuilder( 'WC_Payments_Token_Service' ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'add_payment_method_to_user' ] ) + ->getMock(); + + $this->mock_payment_method = $this->getMockBuilder( Klarna_Payment_Method::class ) + ->setConstructorArgs( [ $this->mock_token_service ] ) + ->onlyMethods( [] ) + ->getMock(); + } + + /** + * Cleanup after tests. + * + * @return void + */ + public function tear_down() { + parent::tear_down(); + wcpay_get_test_container()->reset_all_replacements(); + WC_Payments::set_account_service( $this->original_account_service ); + WC_Helper_Site_Currency::$mock_site_currency = ''; + } + + /** + * @dataProvider provider_test_get_countries + */ + public function test_get_countries( + string $account_country, + ?string $site_currency, + array $expected_result + ) { + $this->mock_wcpay_account->method( 'get_cached_account_data' )->willReturn( + [ + 'country' => $account_country, + ] + ); + + if ( $site_currency ) { + WC_Helper_Site_Currency::$mock_site_currency = $site_currency; + } + + $this->assertEqualsCanonicalizing( $expected_result, $this->mock_payment_method->get_countries() ); + } + + public function provider_test_get_countries() { + return [ + 'US account' => [ + 'account_country' => Country_Code::UNITED_STATES, + 'site_currency' => null, + 'expected_result' => [ Country_Code::UNITED_STATES ], + ], + 'UK account with GBP store currency' => [ + 'account_country' => Country_Code::UNITED_KINGDOM, + 'site_currency' => Currency_Code::POUND_STERLING, + 'expected_result' => [ Country_Code::UNITED_KINGDOM ], + ], + 'UK account with EUR store currency' => [ + 'account_country' => Country_Code::UNITED_KINGDOM, + 'site_currency' => Currency_Code::EURO, + 'expected_result' => [ + Country_Code::AUSTRIA, + Country_Code::BELGIUM, + Country_Code::FINLAND, + Country_Code::GERMANY, + Country_Code::IRELAND, + Country_Code::ITALY, + Country_Code::NETHERLANDS, + Country_Code::SPAIN, + ], + ], + 'BE account with EUR store currency' => [ + 'account_country' => Country_Code::BELGIUM, + 'site_currency' => Currency_Code::EURO, + 'expected_result' => [ + Country_Code::AUSTRIA, + Country_Code::BELGIUM, + Country_Code::FINLAND, + Country_Code::GERMANY, + Country_Code::IRELAND, + Country_Code::ITALY, + Country_Code::NETHERLANDS, + Country_Code::SPAIN, + ], + ], + ]; + } +} diff --git a/tests/unit/payment-methods/test-class-upe-payment-method.php b/tests/unit/payment-methods/test-class-upe-payment-method.php new file mode 100644 index 00000000000..96226ac9695 --- /dev/null +++ b/tests/unit/payment-methods/test-class-upe-payment-method.php @@ -0,0 +1,158 @@ + + */ + private $mock_payment_methods; + + /** + * WC_Payments_Account mocked instance. + * + * @var WC_Payments_Account|MockObject + */ + private $mock_wcpay_account; + + /** + * WC_Payments_Account original instance. + * + * @var WC_Payments_Account + */ + private $original_account_service; + + /** + * Pre-test setup + */ + public function set_up() { + parent::set_up(); + + $this->mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); + $this->original_account_service = WC_Payments::get_account_service(); + WC_Payments::set_account_service( $this->mock_wcpay_account ); + + // Arrange: Mock WC_Payments_Token_Service so its methods aren't called directly. + $this->mock_token_service = $this->getMockBuilder( 'WC_Payments_Token_Service' ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'add_payment_method_to_user' ] ) + ->getMock(); + + $payment_method_classes = [ + CC_Payment_Method::class, + Giropay_Payment_Method::class, + Sofort_Payment_Method::class, + Bancontact_Payment_Method::class, + EPS_Payment_Method::class, + P24_Payment_Method::class, + Ideal_Payment_Method::class, + Sepa_Payment_Method::class, + Becs_Payment_Method::class, + Link_Payment_Method::class, + Affirm_Payment_Method::class, + Afterpay_Payment_Method::class, + ]; + + foreach ( $payment_method_classes as $payment_method_class ) { + /** @var UPE_Payment_Method|MockObject */ + $mock_payment_method = $this->getMockBuilder( $payment_method_class ) + ->setConstructorArgs( [ $this->mock_token_service ] ) + ->onlyMethods( [] ) + ->getMock(); + $this->mock_payment_methods[ $mock_payment_method->get_id() ] = $mock_payment_method; + } + } + + /** + * Cleanup after tests. + * + * @return void + */ + public function tear_down() { + parent::tear_down(); + wcpay_get_test_container()->reset_all_replacements(); + WC_Payments::set_account_service( $this->original_account_service ); + } + + /** + * @dataProvider provider_test_get_countries + */ + public function test_get_countries( string $payment_method_id, array $expected_result, ?string $account_country = null ) { + $payment_method = $this->mock_payment_methods[ $payment_method_id ]; + + if ( $account_country ) { + $this->mock_wcpay_account->method( 'get_cached_account_data' )->willReturn( + [ + 'country' => $account_country, + ] + ); + } + + $this->assertEquals( $expected_result, $payment_method->get_countries() ); + } + + public function provider_test_get_countries() { + return [ + 'Payment method without countries' => [ + 'payment_method_id' => 'card', + 'expected_result' => [], + ], + 'Payment method supported in a single country' => [ + 'payment_method_id' => 'bancontact', + 'expected_result' => [ Country_Code::BELGIUM ], + ], + 'Payment method supported in multiple countries' => [ + 'payment_method_id' => 'sofort', + 'expected_result' => [ + Country_Code::AUSTRIA, + Country_Code::BELGIUM, + Country_Code::GERMANY, + Country_Code::NETHERLANDS, + Country_Code::SPAIN, + ], + ], + 'Payment method with domestic restrictions (US)' => [ + 'payment_method_id' => 'affirm', + 'expected_result' => [ Country_Code::UNITED_STATES ], + 'account_country' => Country_Code::UNITED_STATES, + ], + 'Payment method with domestic restrictions (CA)' => [ + 'payment_method_id' => 'affirm', + 'expected_result' => [ Country_Code::CANADA ], + 'account_country' => Country_Code::CANADA, + ], + ]; + } +} From 544f297b7e5d2fbc5cf3ebc56a4a10101512ac0c Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 20 Jun 2024 23:57:23 -0500 Subject: [PATCH 4/5] Implement payments via Express Checkout Element on the Cart and Checkout pages (#8914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rafael Zaleski Co-authored-by: Kristófer R Co-authored-by: Kristófer Reykjalín <13835680+reykjalin@users.noreply.github.com> Co-authored-by: César Costa <10233985+cesarcosta99@users.noreply.github.com> --- ...kreykjalin-8868-ece-cart-and-checkout-page | 4 + .../blocks/hooks/use-express-checkout.js | 2 +- client/express-checkout/event-handlers.js | 52 ++- client/express-checkout/index.js | 2 +- .../express-checkout/test/event-handlers.js | 437 ++++++++++++++++++ client/express-checkout/utils/index.ts | 2 +- client/express-checkout/utils/normalize.js | 9 +- client/express-checkout/utils/test/index.ts | 44 ++ .../express-checkout/utils/test/normalize.js | 416 +++++++++++++++++ 9 files changed, 939 insertions(+), 29 deletions(-) create mode 100644 changelog/kreykjalin-8868-ece-cart-and-checkout-page create mode 100644 client/express-checkout/test/event-handlers.js create mode 100644 client/express-checkout/utils/test/index.ts create mode 100644 client/express-checkout/utils/test/normalize.js diff --git a/changelog/kreykjalin-8868-ece-cart-and-checkout-page b/changelog/kreykjalin-8868-ece-cart-and-checkout-page new file mode 100644 index 00000000000..bdb4df869e3 --- /dev/null +++ b/changelog/kreykjalin-8868-ece-cart-and-checkout-page @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add support for ECE elements on the Shortcode Cart and Checkout pages diff --git a/client/express-checkout/blocks/hooks/use-express-checkout.js b/client/express-checkout/blocks/hooks/use-express-checkout.js index 090e3f82ac5..e01c5eaf5bc 100644 --- a/client/express-checkout/blocks/hooks/use-express-checkout.js +++ b/client/express-checkout/blocks/hooks/use-express-checkout.js @@ -40,7 +40,7 @@ export const useExpressCheckout = ( { }; const abortPayment = ( onConfirmEvent, message ) => { - onConfirmEvent.paymentFailed( 'fail' ); + onConfirmEvent.paymentFailed( { reason: 'fail' } ); setExpressPaymentError( message ); }; diff --git a/client/express-checkout/event-handlers.js b/client/express-checkout/event-handlers.js index 79e40789a4b..cd45606992b 100644 --- a/client/express-checkout/event-handlers.js +++ b/client/express-checkout/event-handlers.js @@ -9,34 +9,42 @@ import { import { getErrorMessageFromNotice } from './utils/index'; export const shippingAddressChangeHandler = async ( api, event, elements ) => { - const response = await api.expressCheckoutECECalculateShippingOptions( - normalizeShippingAddress( event.address ) - ); + try { + const response = await api.expressCheckoutECECalculateShippingOptions( + normalizeShippingAddress( event.address ) + ); - if ( response.result === 'success' ) { - elements.update( { - amount: response.total.amount, - } ); - event.resolve( { - shippingRates: response.shipping_options, - lineItems: normalizeLineItems( response.displayItems ), - } ); - } else { + if ( response.result === 'success' ) { + elements.update( { + amount: response.total.amount, + } ); + event.resolve( { + shippingRates: response.shipping_options, + lineItems: normalizeLineItems( response.displayItems ), + } ); + } else { + event.reject(); + } + } catch ( e ) { event.reject(); } }; export const shippingRateChangeHandler = async ( api, event, elements ) => { - const response = await api.paymentRequestUpdateShippingDetails( - event.shippingRate - ); + try { + const response = await api.paymentRequestUpdateShippingDetails( + event.shippingRate + ); - if ( response.result === 'success' ) { - elements.update( { amount: response.total.amount } ); - event.resolve( { - lineItems: normalizeLineItems( response.displayItems ), - } ); - } else { + if ( response.result === 'success' ) { + elements.update( { amount: response.total.amount } ); + event.resolve( { + lineItems: normalizeLineItems( response.displayItems ), + } ); + } else { + event.reject(); + } + } catch ( e ) { event.reject(); } }; @@ -88,6 +96,6 @@ export const onConfirmHandler = async ( completePayment( redirectUrl ); } } catch ( e ) { - return abortPayment( event, error.message ); + return abortPayment( event, e.message ); } }; diff --git a/client/express-checkout/index.js b/client/express-checkout/index.js index 6a5aa3c40ef..abcf1102b46 100644 --- a/client/express-checkout/index.js +++ b/client/express-checkout/index.js @@ -81,7 +81,7 @@ jQuery( ( $ ) => { * @param {string} message Error message to display. */ abortPayment: ( payment, message ) => { - payment.paymentFailed(); + payment.paymentFailed( { reason: 'fail' } ); wcpayECE.unblock(); $( '.woocommerce-error' ).remove(); diff --git a/client/express-checkout/test/event-handlers.js b/client/express-checkout/test/event-handlers.js new file mode 100644 index 00000000000..338ca2a3da1 --- /dev/null +++ b/client/express-checkout/test/event-handlers.js @@ -0,0 +1,437 @@ +/** + * Internal dependencies + */ +import { + shippingAddressChangeHandler, + shippingRateChangeHandler, + onConfirmHandler, +} from '../event-handlers'; +import { + normalizeLineItems, + normalizeShippingAddress, + normalizeOrderData, +} from '../utils'; + +describe( 'Express checkout event handlers', () => { + describe( 'shippingAddressChangeHandler', () => { + let api; + let event; + let elements; + + beforeEach( () => { + api = { + expressCheckoutECECalculateShippingOptions: jest.fn(), + }; + event = { + address: { + recipient: 'John Doe', + addressLine: [ '123 Main St' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }, + resolve: jest.fn(), + reject: jest.fn(), + }; + elements = { + update: jest.fn(), + }; + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'should handle successful response', async () => { + const response = { + result: 'success', + total: { amount: 1000 }, + shipping_options: [ + { id: 'option_1', label: 'Standard Shipping' }, + ], + displayItems: [ { label: 'Sample Item', amount: 500 } ], + }; + + api.expressCheckoutECECalculateShippingOptions.mockResolvedValue( + response + ); + + await shippingAddressChangeHandler( api, event, elements ); + + const expectedNormalizedAddress = normalizeShippingAddress( + event.address + ); + expect( + api.expressCheckoutECECalculateShippingOptions + ).toHaveBeenCalledWith( expectedNormalizedAddress ); + + const expectedNormalizedLineItems = normalizeLineItems( + response.displayItems + ); + expect( elements.update ).toHaveBeenCalledWith( { amount: 1000 } ); + expect( event.resolve ).toHaveBeenCalledWith( { + shippingRates: response.shipping_options, + lineItems: expectedNormalizedLineItems, + } ); + expect( event.reject ).not.toHaveBeenCalled(); + } ); + + test( 'should handle unsuccessful response', async () => { + const response = { + result: 'error', + }; + + api.expressCheckoutECECalculateShippingOptions.mockResolvedValue( + response + ); + + await shippingAddressChangeHandler( api, event, elements ); + + const expectedNormalizedAddress = normalizeShippingAddress( + event.address + ); + expect( + api.expressCheckoutECECalculateShippingOptions + ).toHaveBeenCalledWith( expectedNormalizedAddress ); + expect( elements.update ).not.toHaveBeenCalled(); + expect( event.resolve ).not.toHaveBeenCalled(); + expect( event.reject ).toHaveBeenCalled(); + } ); + + test( 'should handle API call failure', async () => { + api.expressCheckoutECECalculateShippingOptions.mockRejectedValue( + new Error( 'API error' ) + ); + + await shippingAddressChangeHandler( api, event, elements ); + + const expectedNormalizedAddress = normalizeShippingAddress( + event.address + ); + expect( + api.expressCheckoutECECalculateShippingOptions + ).toHaveBeenCalledWith( expectedNormalizedAddress ); + expect( elements.update ).not.toHaveBeenCalled(); + expect( event.resolve ).not.toHaveBeenCalled(); + expect( event.reject ).toHaveBeenCalled(); + } ); + } ); + + describe( 'shippingRateChangeHandler', () => { + let api; + let event; + let elements; + + beforeEach( () => { + api = { + paymentRequestUpdateShippingDetails: jest.fn(), + }; + event = { + shippingRate: { + id: 'rate_1', + label: 'Standard Shipping', + amount: 500, + }, + resolve: jest.fn(), + reject: jest.fn(), + }; + elements = { + update: jest.fn(), + }; + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'should handle successful response', async () => { + const response = { + result: 'success', + total: { amount: 1500 }, + displayItems: [ { label: 'Sample Item', amount: 1000 } ], + }; + + api.paymentRequestUpdateShippingDetails.mockResolvedValue( + response + ); + + await shippingRateChangeHandler( api, event, elements ); + + const expectedNormalizedLineItems = normalizeLineItems( + response.displayItems + ); + expect( + api.paymentRequestUpdateShippingDetails + ).toHaveBeenCalledWith( event.shippingRate ); + expect( elements.update ).toHaveBeenCalledWith( { amount: 1500 } ); + expect( event.resolve ).toHaveBeenCalledWith( { + lineItems: expectedNormalizedLineItems, + } ); + expect( event.reject ).not.toHaveBeenCalled(); + } ); + + test( 'should handle unsuccessful response', async () => { + const response = { + result: 'error', + }; + + api.paymentRequestUpdateShippingDetails.mockResolvedValue( + response + ); + + await shippingRateChangeHandler( api, event, elements ); + + expect( + api.paymentRequestUpdateShippingDetails + ).toHaveBeenCalledWith( event.shippingRate ); + expect( elements.update ).not.toHaveBeenCalled(); + expect( event.resolve ).not.toHaveBeenCalled(); + expect( event.reject ).toHaveBeenCalled(); + } ); + + test( 'should handle API call failure', async () => { + api.paymentRequestUpdateShippingDetails.mockRejectedValue( + new Error( 'API error' ) + ); + + await shippingRateChangeHandler( api, event, elements ); + + expect( + api.paymentRequestUpdateShippingDetails + ).toHaveBeenCalledWith( event.shippingRate ); + expect( elements.update ).not.toHaveBeenCalled(); + expect( event.resolve ).not.toHaveBeenCalled(); + expect( event.reject ).toHaveBeenCalled(); + } ); + } ); + + describe( 'onConfirmHandler', () => { + let api; + let stripe; + let elements; + let completePayment; + let abortPayment; + let event; + + beforeEach( () => { + api = { + expressCheckoutECECreateOrder: jest.fn(), + confirmIntent: jest.fn(), + }; + stripe = { + createPaymentMethod: jest.fn(), + }; + elements = { + submit: jest.fn(), + }; + completePayment = jest.fn(); + abortPayment = jest.fn(); + event = { + billingDetails: { + name: 'John Doe', + email: 'john.doe@example.com', + address: { + organization: 'Some Company', + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + phone: '(123) 456-7890', + }, + shippingAddress: { + name: 'John Doe', + organization: 'Some Company', + address: { + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + }, + shippingRate: { id: 'rate_1' }, + expressPaymentType: 'express', + }; + global.window.wcpayFraudPreventionToken = 'token123'; + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'should abort payment if elements.submit fails', async () => { + elements.submit.mockResolvedValue( { + error: { message: 'Submit error' }, + } ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( elements.submit ).toHaveBeenCalled(); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Submit error' + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + + test( 'should abort payment if stripe.createPaymentMethod fails', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + error: { message: 'Payment method error' }, + } ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( elements.submit ).toHaveBeenCalled(); + expect( stripe.createPaymentMethod ).toHaveBeenCalledWith( { + elements, + } ); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Payment method error' + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + + test( 'should abort payment if expressCheckoutECECreateOrder fails', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECECreateOrder.mockResolvedValue( { + result: 'error', + messages: 'Order creation error', + } ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + const expectedOrderData = normalizeOrderData( event, 'pm_123' ); + expect( api.expressCheckoutECECreateOrder ).toHaveBeenCalledWith( + expectedOrderData + ); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Order creation error' + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + + test( 'should complete payment if confirmationRequest is true', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECECreateOrder.mockResolvedValue( { + result: 'success', + redirect: 'https://example.com/redirect', + } ); + api.confirmIntent.mockReturnValue( true ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( api.confirmIntent ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( completePayment ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( abortPayment ).not.toHaveBeenCalled(); + } ); + + test( 'should complete payment if confirmationRequest returns a redirect URL', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECECreateOrder.mockResolvedValue( { + result: 'success', + redirect: 'https://example.com/redirect', + } ); + api.confirmIntent.mockResolvedValue( + 'https://example.com/confirmation_redirect' + ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( api.confirmIntent ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( completePayment ).toHaveBeenCalledWith( + 'https://example.com/confirmation_redirect' + ); + expect( abortPayment ).not.toHaveBeenCalled(); + } ); + + test( 'should abort payment if confirmIntent throws an error', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECECreateOrder.mockResolvedValue( { + result: 'success', + redirect: 'https://example.com/redirect', + } ); + api.confirmIntent.mockRejectedValue( + new Error( 'Intent confirmation error' ) + ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( api.confirmIntent ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Intent confirmation error' + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/client/express-checkout/utils/index.ts b/client/express-checkout/utils/index.ts index eb6f359b98b..b10d80b0960 100644 --- a/client/express-checkout/utils/index.ts +++ b/client/express-checkout/utils/index.ts @@ -3,7 +3,7 @@ export * from './normalize'; /** * An /incomplete/ representation of the data that is loaded into the frontend for the Express Checkout. */ -interface WCPayExpressCheckoutParams { +export interface WCPayExpressCheckoutParams { ajax_url: string; /** diff --git a/client/express-checkout/utils/normalize.js b/client/express-checkout/utils/normalize.js index 365e1553397..576a2109ea3 100644 --- a/client/express-checkout/utils/normalize.js +++ b/client/express-checkout/utils/normalize.js @@ -12,6 +12,7 @@ export const normalizeLineItems = ( displayItems ) => { ( { ...displayItem, name: displayItem.label, + amount: displayItem?.amount ?? displayItem?.value, } ) ); }; @@ -20,7 +21,7 @@ export const normalizeLineItems = ( displayItems ) => { * Normalize order data from Stripe's object to the expected format for WC. * * @param {Object} event Stripe's event object. - * @param {Object} paymentMethodId Stripe's payment method id. + * @param {string} paymentMethodId Stripe's payment method id. * * @return {Object} Order object in the format WooCommerce expects. */ @@ -32,14 +33,14 @@ export const normalizeOrderData = ( event, paymentMethodId ) => { const fraudPreventionTokenValue = window.wcpayFraudPreventionToken ?? ''; const phone = - event?.billingDetails?.phone ?? - event?.payerPhone?.replace( '/[() -]/g', '' ) ?? + event?.billingDetails?.phone?.replace( /[() -]/g, '' ) ?? + event?.payerPhone?.replace( /[() -]/g, '' ) ?? ''; return { billing_first_name: name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', - billing_last_name: name?.split( ' ' )?.slice( 1 )?.join( ' ' ) || '-', + billing_last_name: name?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? '-', billing_company: billing?.organization ?? '', billing_email: email ?? event?.payerEmail ?? '', billing_phone: phone, diff --git a/client/express-checkout/utils/test/index.ts b/client/express-checkout/utils/test/index.ts new file mode 100644 index 00000000000..e1e61edf988 --- /dev/null +++ b/client/express-checkout/utils/test/index.ts @@ -0,0 +1,44 @@ +/** + * Internal dependencies + */ +import { + WCPayExpressCheckoutParams, + getErrorMessageFromNotice, + getExpressCheckoutData, +} from '../index'; + +describe( 'Express checkout utils', () => { + test( 'getExpressCheckoutData returns null for missing option', () => { + expect( + getExpressCheckoutData( + // Force wrong usage, just in case this is called from JS with incorrect params. + 'does-not-exist' as keyof WCPayExpressCheckoutParams + ) + ).toBeNull(); + } ); + + test( 'getExpressCheckoutData returns correct value for present option', () => { + // We don't care that the implementation is partial for the purposes of the test, so + // the type assertion is fine. + window.wcpayExpressCheckoutParams = { + ajax_url: 'test', + } as WCPayExpressCheckoutParams; + + expect( getExpressCheckoutData( 'ajax_url' ) ).toBe( 'test' ); + } ); + + test( 'getErrorMessageFromNotice strips formatting', () => { + const notice = '

Error: Payment failed.

'; + expect( getErrorMessageFromNotice( notice ) ).toBe( + 'Error: Payment failed.' + ); + } ); + + test( 'getErrorMessageFromNotice strips scripts', () => { + const notice = + '

Error: Payment failed.

'; + expect( getErrorMessageFromNotice( notice ) ).toBe( + 'Error: Payment failed.alert("hello")' + ); + } ); +} ); diff --git a/client/express-checkout/utils/test/normalize.js b/client/express-checkout/utils/test/normalize.js new file mode 100644 index 00000000000..6bd88b47b0b --- /dev/null +++ b/client/express-checkout/utils/test/normalize.js @@ -0,0 +1,416 @@ +/** + * Internal dependencies + */ +import { + normalizeLineItems, + normalizeOrderData, + normalizeShippingAddress, +} from '../normalize'; + +describe( 'Express checkout normalization', () => { + describe( 'normalizeLineItems', () => { + test( 'normalizes blocks array properly', () => { + const displayItems = [ + { + label: 'Item 1', + value: 100, + }, + { + label: 'Item 2', + value: 200, + }, + { + label: 'Item 3', + valueWithTax: 300, + value: 200, + }, + ]; + + // Extra items in the array are expected since they're not stripped. + const expected = [ + { + name: 'Item 1', + label: 'Item 1', + amount: 100, + value: 100, + }, + { + name: 'Item 2', + label: 'Item 2', + amount: 200, + value: 200, + }, + { + name: 'Item 3', + label: 'Item 3', + amount: 200, + valueWithTax: 300, + value: 200, + }, + ]; + + expect( normalizeLineItems( displayItems ) ).toStrictEqual( + expected + ); + } ); + + test( 'normalizes shortcode array properly', () => { + const displayItems = [ + { + label: 'Item 1', + amount: 100, + }, + { + label: 'Item 2', + amount: 200, + }, + { + label: 'Item 3', + amount: 300, + }, + ]; + + const expected = [ + { + name: 'Item 1', + label: 'Item 1', + amount: 100, + }, + { + name: 'Item 2', + label: 'Item 2', + amount: 200, + }, + { + name: 'Item 3', + label: 'Item 3', + amount: 300, + }, + ]; + + expect( normalizeLineItems( displayItems ) ).toStrictEqual( + expected + ); + } ); + } ); + + describe( 'normalizeOrderData', () => { + afterEach( () => { + // Clear any changes to the fraud prevention token. + delete window.wcpayFraudPreventionToken; + } ); + + test( 'should normalize order data with complete event and paymentMethodId', () => { + window.wcpayFraudPreventionToken = 'token123'; + + const event = { + billingDetails: { + name: 'John Doe', + email: 'john.doe@example.com', + address: { + organization: 'Some Company', + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + phone: '(123) 456-7890', + }, + shippingAddress: { + name: 'John Doe', + organization: 'Some Company', + address: { + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + }, + shippingRate: { id: 'rate_1' }, + expressPaymentType: 'express', + }; + + const paymentMethodId = 'pm_123456'; + + const expectedNormalizedData = { + billing_first_name: 'John', + billing_last_name: 'Doe', + billing_company: 'Some Company', + billing_email: 'john.doe@example.com', + billing_phone: '1234567890', + billing_country: 'US', + billing_address_1: '123 Main St', + billing_address_2: 'Apt 4B', + billing_city: 'New York', + billing_state: 'NY', + billing_postcode: '10001', + shipping_first_name: 'John', + shipping_last_name: 'Doe', + shipping_company: 'Some Company', + shipping_phone: '1234567890', + shipping_country: 'US', + shipping_address_1: '123 Main St', + shipping_address_2: 'Apt 4B', + shipping_city: 'New York', + shipping_state: 'NY', + shipping_postcode: '10001', + shipping_method: [ 'rate_1' ], + order_comments: '', + payment_method: 'woocommerce_payments', + ship_to_different_address: 1, + terms: 1, + 'wcpay-payment-method': paymentMethodId, + payment_request_type: 'express', + express_payment_type: 'express', + 'wcpay-fraud-prevention-token': 'token123', + }; + + expect( normalizeOrderData( event, paymentMethodId ) ).toEqual( + expectedNormalizedData + ); + } ); + + test( 'should normalize order data with missing optional event fields', () => { + const event = {}; + const paymentMethodId = 'pm_123456'; + + const expectedNormalizedData = { + billing_first_name: '', + billing_last_name: '-', + billing_company: '', + billing_email: '', + billing_phone: '', + billing_country: '', + billing_address_1: '', + billing_address_2: '', + billing_city: '', + billing_state: '', + billing_postcode: '', + shipping_first_name: '', + shipping_last_name: '', + shipping_company: '', + shipping_phone: '', + shipping_country: '', + shipping_address_1: '', + shipping_address_2: '', + shipping_city: '', + shipping_state: '', + shipping_postcode: '', + shipping_method: [ null ], + order_comments: '', + payment_method: 'woocommerce_payments', + ship_to_different_address: 1, + terms: 1, + 'wcpay-payment-method': paymentMethodId, + payment_request_type: undefined, + express_payment_type: undefined, + 'wcpay-fraud-prevention-token': '', + }; + + expect( normalizeOrderData( event, paymentMethodId ) ).toEqual( + expectedNormalizedData + ); + } ); + + test( 'should normalize order data with minimum required fields', () => { + const event = { + billingDetails: { + name: 'John', + }, + }; + const paymentMethodId = 'pm_123456'; + + const expectedNormalizedData = { + billing_first_name: 'John', + billing_last_name: '', + billing_company: '', + billing_email: '', + billing_phone: '', + billing_country: '', + billing_address_1: '', + billing_address_2: '', + billing_city: '', + billing_state: '', + billing_postcode: '', + shipping_first_name: '', + shipping_last_name: '', + shipping_company: '', + shipping_phone: '', + shipping_country: '', + shipping_address_1: '', + shipping_address_2: '', + shipping_city: '', + shipping_state: '', + shipping_postcode: '', + shipping_method: [ null ], + order_comments: '', + payment_method: 'woocommerce_payments', + ship_to_different_address: 1, + terms: 1, + 'wcpay-payment-method': paymentMethodId, + payment_request_type: undefined, + express_payment_type: undefined, + 'wcpay-fraud-prevention-token': '', + }; + + expect( normalizeOrderData( event, paymentMethodId ) ).toEqual( + expectedNormalizedData + ); + } ); + } ); + + describe( 'normalizeShippingAddress', () => { + test( 'should normalize shipping address with all fields present', () => { + const shippingAddress = { + recipient: 'John Doe', + addressLine: [ '123 Main St', 'Apt 4B' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: 'Doe', + company: '', + address_1: '123 Main St', + address_2: 'Apt 4B', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize shipping address with only recipient name', () => { + const shippingAddress = { + recipient: 'John', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: '', + company: '', + address_1: '', + address_2: '', + city: '', + state: '', + country: '', + postcode: '', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize shipping address with missing recipient name', () => { + const shippingAddress = { + addressLine: [ '123 Main St' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: '', + last_name: '', + company: '', + address_1: '123 Main St', + address_2: '', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize shipping address with empty addressLine', () => { + const shippingAddress = { + recipient: 'John Doe', + addressLine: [], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: 'Doe', + company: '', + address_1: '', + address_2: '', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize an empty shipping address', () => { + const shippingAddress = {}; + + const expectedNormalizedAddress = { + first_name: '', + last_name: '', + company: '', + address_1: '', + address_2: '', + city: '', + state: '', + country: '', + postcode: '', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize a shipping address with a multi-word recipient name', () => { + const shippingAddress = { + recipient: 'John Doe Smith', + addressLine: [ '123 Main St', 'Apt 4B' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: 'Doe Smith', + company: '', + address_1: '123 Main St', + address_2: 'Apt 4B', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + } ); +} ); From aac2f270fa282260326e5dc0677c80fed23353b3 Mon Sep 17 00:00:00 2001 From: Jessy Pappachan <32092402+jessy-p@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:11:27 +0530 Subject: [PATCH 5/5] Save selected date preset into session (#8983) Co-authored-by: Jessy Co-authored-by: Rua Haszard --- .../update-8972-save-payment-activity-preset-session | 5 +++++ client/components/payment-activity/hooks.ts | 8 +++++--- client/components/payment-activity/index.tsx | 4 ++++ 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 changelog/update-8972-save-payment-activity-preset-session diff --git a/changelog/update-8972-save-payment-activity-preset-session b/changelog/update-8972-save-payment-activity-preset-session new file mode 100644 index 00000000000..869e0b298e5 --- /dev/null +++ b/changelog/update-8972-save-payment-activity-preset-session @@ -0,0 +1,5 @@ +Significance: patch +Type: update +Comment: Part of Payment Activity Card. Persist selected preset date in session. + + diff --git a/client/components/payment-activity/hooks.ts b/client/components/payment-activity/hooks.ts index 2f5d04da975..967ca6cf73a 100644 --- a/client/components/payment-activity/hooks.ts +++ b/client/components/payment-activity/hooks.ts @@ -108,12 +108,14 @@ export const usePaymentActivityDateRangePresets = (): { }, }; + const defaultPreset = + sessionStorage.getItem( 'selectedPresetName' ) ?? 'last_7_days'; const defaultDateRange = { - preset_name: 'last_7_days', - date_start: dateRangePresets.last_7_days.start.format( + preset_name: defaultPreset, + date_start: dateRangePresets[ defaultPreset ].start.format( 'YYYY-MM-DD\\THH:mm:ss' ), - date_end: dateRangePresets.last_7_days.end.format( + date_end: dateRangePresets[ defaultPreset ].end.format( 'YYYY-MM-DD\\THH:mm:ss' ), }; diff --git a/client/components/payment-activity/index.tsx b/client/components/payment-activity/index.tsx index 2e1f8fe2196..4649497cfdf 100644 --- a/client/components/payment-activity/index.tsx +++ b/client/components/payment-activity/index.tsx @@ -130,6 +130,10 @@ const PaymentActivity: React.FC = () => { .clone() .format( 'YYYY-MM-DD\\THH:mm:ss' ); const { key: presetName } = selectedItem; + sessionStorage.setItem( + 'selectedPresetName', + selectedItem.key + ); recordEvent( 'wcpay_overview_payment_activity_period_change', {