diff --git a/changelog.txt b/changelog.txt index 969e9401b6c..cc6402cb77c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,9 @@ *** WooPayments Changelog *** += 8.6.1 - 2024-12-17 = +* Fix - Checkout: Fix error when wc_address_i18n_params does not have data for a given country +* Fix - Skip mysqlcheck SSL Requirement during E2E environment setup + = 8.6.0 - 2024-12-04 = * Add - Add Bank reference key column in Payout reports. This will help reconcile WooPayments Payouts with bank statements. * Add - Display credit card brand icons on order received page. diff --git a/changelog/add-9973-enable-pms-from-capabilities b/changelog/add-9973-enable-pms-from-capabilities new file mode 100644 index 00000000000..5ecfd2ac5cd --- /dev/null +++ b/changelog/add-9973-enable-pms-from-capabilities @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Enable Payment Methods preselected by NOX after onboarding accounts diff --git a/changelog/add-beta-badge-woopay-theme b/changelog/add-beta-badge-woopay-theme new file mode 100644 index 00000000000..8d379077613 --- /dev/null +++ b/changelog/add-beta-badge-woopay-theme @@ -0,0 +1,5 @@ +Significance: patch +Type: add +Comment: Adds a label to a gated feature. + + diff --git a/changelog/add-woopay-klaviyo-newsletter-support b/changelog/add-woopay-klaviyo-newsletter-support new file mode 100644 index 00000000000..64e94c6638e --- /dev/null +++ b/changelog/add-woopay-klaviyo-newsletter-support @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add WooPay Klaviyo newsletter integration. diff --git a/changelog/dev-7264-remove-hooks-from-customer-service-constructor b/changelog/dev-7264-remove-hooks-from-customer-service-constructor new file mode 100644 index 00000000000..d912717fc31 --- /dev/null +++ b/changelog/dev-7264-remove-hooks-from-customer-service-constructor @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Remove hooks from customer and token services to dedicated methods diff --git a/changelog/feat-9810-add-seller-message b/changelog/feat-9810-add-seller-message new file mode 100644 index 00000000000..2669c24015b --- /dev/null +++ b/changelog/feat-9810-add-seller-message @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add seller_message to failed order notes diff --git a/changelog/fix-10007-disputes-csv-export-response-type-assertion b/changelog/fix-10007-disputes-csv-export-response-type-assertion new file mode 100644 index 00000000000..b578456ece2 --- /dev/null +++ b/changelog/fix-10007-disputes-csv-export-response-type-assertion @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Add type assertion for disputes CSV export response to ensure type safety and fix TypeScript error diff --git a/changelog/fix-198-mccy-fedex-conversion b/changelog/fix-198-mccy-fedex-conversion new file mode 100644 index 00000000000..7fecbc49b87 --- /dev/null +++ b/changelog/fix-198-mccy-fedex-conversion @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Fix FedEx insurance rates with different currencies. + + diff --git a/changelog/fix-6567-user-set-date-and-time-formatting-arent-respected-in-react-components b/changelog/fix-6567-user-set-date-and-time-formatting-arent-respected-in-react-components new file mode 100644 index 00000000000..5c69920cf26 --- /dev/null +++ b/changelog/fix-6567-user-set-date-and-time-formatting-arent-respected-in-react-components @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Apply User-Defined Date Formatting Settings to WP Admin React Components diff --git a/changelog/fix-8620-fee-types-in-fees-brakedown-tooltip-are-not-internationalised b/changelog/fix-8620-fee-types-in-fees-brakedown-tooltip-are-not-internationalised new file mode 100644 index 00000000000..57528510958 --- /dev/null +++ b/changelog/fix-8620-fee-types-in-fees-brakedown-tooltip-are-not-internationalised @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Use translatable strings on the fee breakdown tooltip of the payment settings screen. diff --git a/changelog/fix-9114-level3-rounding b/changelog/fix-9114-level3-rounding new file mode 100644 index 00000000000..713c8d684cc --- /dev/null +++ b/changelog/fix-9114-level3-rounding @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Add a rounding entry to Level 3 data for rare cases where rounding errors break calculations. diff --git a/changelog/fix-9303-upgrade-woocommerce-csv-export-package b/changelog/fix-9303-upgrade-woocommerce-csv-export-package new file mode 100644 index 00000000000..db23bab80b2 --- /dev/null +++ b/changelog/fix-9303-upgrade-woocommerce-csv-export-package @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Upgrade `@woocommerce/csv-export` package to v1.10.0 – fixes unnecessary escaping of negative values in CSV exports that was preventing numerical analysis in spreadsheet applications diff --git a/changelog/fix-9418-hide-transaction-fees-when-transaction-is-not-captured b/changelog/fix-9418-hide-transaction-fees-when-transaction-is-not-captured new file mode 100644 index 00000000000..f524fd812f1 --- /dev/null +++ b/changelog/fix-9418-hide-transaction-fees-when-transaction-is-not-captured @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Hide transaction fee on admin view order screen when transaction is not captured. diff --git a/changelog/fix-9716-disputes-api-requests b/changelog/fix-9716-disputes-api-requests new file mode 100644 index 00000000000..10f5387c9b4 --- /dev/null +++ b/changelog/fix-9716-disputes-api-requests @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Performance improvements for Disputes Needing Response task shown in WooCommerce admin. diff --git a/changelog/fix-9735-render-transactions-correctly-on-capture b/changelog/fix-9735-render-transactions-correctly-on-capture new file mode 100644 index 00000000000..9ed7f628e4c --- /dev/null +++ b/changelog/fix-9735-render-transactions-correctly-on-capture @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Ensure captured transactions appear in the Transactions tab without requiring a page refresh. diff --git a/changelog/fix-9742-blank-overview-page-without-wc-features b/changelog/fix-9742-blank-overview-page-without-wc-features new file mode 100644 index 00000000000..8d473bd8ab4 --- /dev/null +++ b/changelog/fix-9742-blank-overview-page-without-wc-features @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix blank Payments > Overview page when WC onboarding is disabled. diff --git a/changelog/fix-9794-refresh-page-when-ece-dismissed b/changelog/fix-9794-refresh-page-when-ece-dismissed new file mode 100644 index 00000000000..7ec81b4760e --- /dev/null +++ b/changelog/fix-9794-refresh-page-when-ece-dismissed @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Refresh the cart and checkout pages when ECE is dismissed and the shipping options were modified in the payment sheet. diff --git a/changelog/fix-9896-postal-code-label b/changelog/fix-9896-postal-code-label new file mode 100644 index 00000000000..dad971912a8 --- /dev/null +++ b/changelog/fix-9896-postal-code-label @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Localize postal code check label based on country. diff --git a/changelog/fix-9987-filter-csv-disputes b/changelog/fix-9987-filter-csv-disputes new file mode 100644 index 00000000000..e4a87b24b1b --- /dev/null +++ b/changelog/fix-9987-filter-csv-disputes @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix filtering in async Disputes CSV export diff --git a/changelog/fix-9996-currency-conversion-fee-phrasing b/changelog/fix-9996-currency-conversion-fee-phrasing new file mode 100644 index 00000000000..bdee2cbc00f --- /dev/null +++ b/changelog/fix-9996-currency-conversion-fee-phrasing @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Use "currency conversion fee" instead "foreign exchange fee" in payment timeline and various other places. diff --git a/changelog/fix-allow-addresses-from-woo-supported-countries b/changelog/fix-allow-addresses-from-woo-supported-countries new file mode 100644 index 00000000000..626fd1ce34f --- /dev/null +++ b/changelog/fix-allow-addresses-from-woo-supported-countries @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Checkout: Fix error when wc_address_i18n_params does not have data for a given country diff --git a/changelog/fix-ece-button-for-price-including-tax b/changelog/fix-ece-button-for-price-including-tax new file mode 100644 index 00000000000..521ceb2af68 --- /dev/null +++ b/changelog/fix-ece-button-for-price-including-tax @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Show express checkout for products w/o shipping but where tax is included into price. diff --git a/changelog/fix-method-title-availability b/changelog/fix-method-title-availability new file mode 100644 index 00000000000..d9d2a0c0217 --- /dev/null +++ b/changelog/fix-method-title-availability @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Set payment method title once title is known. diff --git a/changelog/fix-tokenized-cart-multiple-variations b/changelog/fix-tokenized-cart-multiple-variations new file mode 100644 index 00000000000..5d155cd5513 --- /dev/null +++ b/changelog/fix-tokenized-cart-multiple-variations @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: fix: tokenized cart & multiple variations. + + diff --git a/changelog/fix-tokenized-cart-subscription-signup-fee b/changelog/fix-tokenized-cart-subscription-signup-fee new file mode 100644 index 00000000000..5abe9f0226b --- /dev/null +++ b/changelog/fix-tokenized-cart-subscription-signup-fee @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: fix: tokenized cart subscription signup fee price + + diff --git a/changelog/fix-tokenized-ece-pay-for-order-without-billing-email b/changelog/fix-tokenized-ece-pay-for-order-without-billing-email new file mode 100644 index 00000000000..6e34989b976 --- /dev/null +++ b/changelog/fix-tokenized-ece-pay-for-order-without-billing-email @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: fix: tokenized ECE do not show button when missing billing email + + diff --git a/changelog/fix-tokenized-ece-product-bundles-totals b/changelog/fix-tokenized-ece-product-bundles-totals new file mode 100644 index 00000000000..c003feec46a --- /dev/null +++ b/changelog/fix-tokenized-ece-product-bundles-totals @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: fix: tokenized ECE item compatibility w/ product bundles + + diff --git a/changelog/fix-unhandled-promises b/changelog/fix-unhandled-promises new file mode 100644 index 00000000000..a4d1a679405 --- /dev/null +++ b/changelog/fix-unhandled-promises @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix payment method filtering when billing country changes in Blocks checkout. diff --git a/changelog/load-checkout-scripts-on-checkout-if-not-loaded b/changelog/load-checkout-scripts-on-checkout-if-not-loaded new file mode 100644 index 00000000000..4a684203a2e --- /dev/null +++ b/changelog/load-checkout-scripts-on-checkout-if-not-loaded @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Load checkout scripts when they are not previously loaded on checkout page. diff --git a/changelog/update-5002-authorizations-improve-message-shown-to-merchants-when-there-is-an-error-capturing-authorizations b/changelog/update-5002-authorizations-improve-message-shown-to-merchants-when-there-is-an-error-capturing-authorizations new file mode 100644 index 00000000000..b76d70e1cf9 --- /dev/null +++ b/changelog/update-5002-authorizations-improve-message-shown-to-merchants-when-there-is-an-error-capturing-authorizations @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Update error messages for payment authorization actions to provide more specific and user-friendly feedback. diff --git a/changelog/update-5713-failed-orders-should-include-more-info-on-transaction-details-page b/changelog/update-5713-failed-orders-should-include-more-info-on-transaction-details-page new file mode 100644 index 00000000000..daf90a1cd39 --- /dev/null +++ b/changelog/update-5713-failed-orders-should-include-more-info-on-transaction-details-page @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Add failure reason to failed payments in the timeline. diff --git a/changelog/update-9919-embedded-components-width b/changelog/update-9919-embedded-components-width new file mode 100644 index 00000000000..ca8fe89ebb7 --- /dev/null +++ b/changelog/update-9919-embedded-components-width @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Update Embedded Components and MOX to support custom width and paddings. diff --git a/changelog/update-s6837-reduce-api-impact-for-test-drive-setup b/changelog/update-s6837-reduce-api-impact-for-test-drive-setup new file mode 100644 index 00000000000..eaa0360fe7a --- /dev/null +++ b/changelog/update-s6837-reduce-api-impact-for-test-drive-setup @@ -0,0 +1,5 @@ +Significance: patch +Type: update +Comment: Reduce the maximum number of API calls during the test-drive process and add maximum duration cut off logic. + + diff --git a/changelog/update-server-container-name b/changelog/update-server-container-name deleted file mode 100644 index cb9580f8a22..00000000000 --- a/changelog/update-server-container-name +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: dev -Comment: Updates server container name used by E2E tests - - diff --git a/client/capital/index.tsx b/client/capital/index.tsx index 81e76ad91b4..469b2d283a8 100644 --- a/client/capital/index.tsx +++ b/client/capital/index.tsx @@ -6,7 +6,6 @@ import * as React from 'react'; import { __, _n } from '@wordpress/i18n'; import { TableCard } from '@woocommerce/components'; -import { dateI18n } from '@wordpress/date'; /** * Internal dependencies. @@ -25,6 +24,8 @@ import Chip from 'components/chip'; import { useLoans } from 'wcpay/data'; import { getAdminUrl } from 'wcpay/utils'; import './style.scss'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; +import DateFormatNotice from 'wcpay/components/date-format-notice'; const columns = [ { @@ -80,7 +81,7 @@ const getLoanStatusText = ( loan: CapitalLoan ) => { return loan.fully_paid_at ? __( 'Paid off', 'woocommerce-payments' ) + ': ' + - dateI18n( 'M j, Y', loan.fully_paid_at ) + formatDateTimeFromString( loan.fully_paid_at ) : __( 'Active', 'woocommerce-payments' ); }; @@ -112,7 +113,9 @@ const getRowsData = ( loans: CapitalLoan[] ) => const data = { paid_out_at: { value: loan.paid_out_at, - display: clickable( dateI18n( 'M j, Y', loan.paid_out_at ) ), + display: clickable( + formatDateTimeFromString( loan.paid_out_at ) + ), }, status: { value: getLoanStatusText( loan ), @@ -150,7 +153,7 @@ const getRowsData = ( loans: CapitalLoan[] ) => value: loan.first_paydown_at, display: clickable( loan.first_paydown_at - ? dateI18n( 'M j, Y', loan.first_paydown_at ) + ? formatDateTimeFromString( loan.first_paydown_at ) : '-' ), }, @@ -207,6 +210,7 @@ const CapitalPage = (): JSX.Element => { return ( + { wcpaySettings.accountLoans.has_active_loan && ( diff --git a/client/capital/test/__snapshots__/index.test.tsx.snap b/client/capital/test/__snapshots__/index.test.tsx.snap index 2b146dd5714..9f0e93091cf 100644 --- a/client/capital/test/__snapshots__/index.test.tsx.snap +++ b/client/capital/test/__snapshots__/index.test.tsx.snap @@ -5,6 +5,54 @@ exports[`CapitalPage renders the TableCard component with loan data 1`] = `
+
+ +
+ The date and time formats now match your preferences. You can update them anytime in the + + settings + + . +
+ +
diff --git a/client/capital/test/index.test.tsx b/client/capital/test/index.test.tsx index f9eb43b8a49..41ea917902a 100644 --- a/client/capital/test/index.test.tsx +++ b/client/capital/test/index.test.tsx @@ -25,6 +25,7 @@ declare const global: { accountLoans: { has_active_loan: boolean; }; + dateFormat: string; }; }; @@ -37,6 +38,7 @@ describe( 'CapitalPage', () => { }, accountLoans: { has_active_loan: true }, testMode: true, + dateFormat: 'M j, Y', }; } ); diff --git a/client/checkout/utils/test/upe.test.js b/client/checkout/utils/test/upe.test.js index 7357840b51a..91ad592d5cf 100644 --- a/client/checkout/utils/test/upe.test.js +++ b/client/checkout/utils/test/upe.test.js @@ -12,6 +12,7 @@ import { isUsingSavedPaymentMethod, dispatchChangeEventFor, togglePaymentMethodForCountry, + isBillingInformationMissing, } from '../upe'; import { getPaymentMethodsConstants } from '../../constants'; @@ -22,11 +23,134 @@ jest.mock( 'wcpay/utils/checkout' ); jest.mock( '../../constants', () => { return { + ...jest.requireActual( '../../constants' ), getPaymentMethodsConstants: jest.fn(), }; } ); +function buildForm( fields ) { + const form = document.createElement( 'form' ); + fields.forEach( ( field ) => { + const input = document.createElement( 'input' ); + input.id = field.id; + input.value = field.value; + form.appendChild( input ); + } ); + return form; +} + describe( 'UPE checkout utils', () => { + describe( 'isBillingInformationMissing', () => { + beforeAll( () => { + window.wc_address_i18n_params = { + locale: { + US: {}, + HK: { + postcode: { required: false }, + }, + default: { + address_1: { required: true }, + postcode: { required: true }, + }, + }, + }; + } ); + + beforeEach( () => { + getUPEConfig.mockImplementation( ( argument ) => { + if ( argument === 'enabledBillingFields' ) { + return { + billing_first_name: { + required: true, + }, + billing_last_name: { + required: true, + }, + billing_company: { + required: false, + }, + billing_country: { + required: true, + }, + billing_address_1: { + required: true, + }, + billing_address_2: { + required: false, + }, + billing_city: { + required: true, + }, + billing_state: { + required: true, + }, + billing_postcode: { + required: true, + }, + billing_phone: { + required: true, + }, + billing_email: { + required: true, + }, + }; + } + } ); + } ); + + it( 'should return false when the billing information is not missing', () => { + const form = buildForm( [ + { id: 'billing_first_name', value: 'Test' }, + { id: 'billing_last_name', value: 'User' }, + { id: 'billing_email', value: 'test@example.com' }, + { id: 'billing_country', value: 'US' }, + { id: 'billing_address_1', value: '123 Main St' }, + { id: 'billing_city', value: 'Anytown' }, + { id: 'billing_postcode', value: '12345' }, + ] ); + expect( isBillingInformationMissing( form ) ).toBe( false ); + } ); + + it( 'should return true when the billing information is missing', () => { + const form = buildForm( [ + { id: 'billing_first_name', value: 'Test' }, + { id: 'billing_last_name', value: 'User' }, + { id: 'billing_email', value: 'test@example.com' }, + { id: 'billing_country', value: 'US' }, + { id: 'billing_address_1', value: '123 Main St' }, + { id: 'billing_city', value: 'Anytown' }, + { id: 'billing_postcode', value: '' }, + ] ); + expect( isBillingInformationMissing( form ) ).toBe( true ); + } ); + + it( 'should use the defaults when there is no specific locale data for a country', () => { + const form = buildForm( [ + { id: 'billing_first_name', value: 'Test' }, + { id: 'billing_last_name', value: 'User' }, + { id: 'billing_email', value: 'test@example.com' }, + { id: 'billing_country', value: 'MX' }, + { id: 'billing_address_1', value: '123 Main St' }, + { id: 'billing_city', value: 'Anytown' }, + { id: 'billing_postcode', value: '' }, + ] ); + expect( isBillingInformationMissing( form ) ).toBe( true ); + } ); + + it( 'should return false when the locale data for a country has no required fields', () => { + const form = buildForm( [ + { id: 'billing_first_name', value: 'Test' }, + { id: 'billing_last_name', value: 'User' }, + { id: 'billing_email', value: 'test@example.com' }, + { id: 'billing_country', value: 'HK' }, + { id: 'billing_address_1', value: '123 Main St' }, + { id: 'billing_city', value: 'Anytown' }, + { id: 'billing_postcode', value: '' }, + ] ); + expect( isBillingInformationMissing( form ) ).toBe( true ); + } ); + } ); + describe( 'getSelectedUPEGatewayPaymentMethod', () => { let container; @@ -54,7 +178,7 @@ describe( 'UPE checkout utils', () => { } ); test( 'Selected UPE Payment Method is card', () => { - container.innerHTML = ` { test( 'Selected UPE Payment Method is bancontact', () => { container.innerHTML = ` - `; diff --git a/client/checkout/utils/upe.js b/client/checkout/utils/upe.js index af8e0427c04..c8201ff1ba1 100644 --- a/client/checkout/utils/upe.js +++ b/client/checkout/utils/upe.js @@ -449,8 +449,8 @@ export const isBillingInformationMissing = ( form ) => { if ( country && locale && fieldName !== 'billing_email' ) { const key = fieldName.replace( 'billing_', '' ); isRequired = - locale[ country ][ key ]?.required ?? - locale.default[ key ]?.required; + locale[ country ]?.[ key ]?.required ?? + locale.default?.[ key ]?.required; } const hasValue = field?.value; diff --git a/client/components/account-status/account-fees/expiration-description.js b/client/components/account-status/account-fees/expiration-description.js index 6be5b58681c..497f0207ef3 100644 --- a/client/components/account-status/account-fees/expiration-description.js +++ b/client/components/account-status/account-fees/expiration-description.js @@ -4,13 +4,12 @@ * External dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { dateI18n } from '@wordpress/date'; -import moment from 'moment'; /** * Internal dependencies */ import { formatCurrency } from 'multi-currency/interface/functions'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; const ExpirationDescription = ( { feeData: { volume_allowance: volumeAllowance, end_time: endTime, ...rest }, @@ -26,7 +25,7 @@ const ExpirationDescription = ( { 'woocommerce-payments' ), formatCurrency( volumeAllowance, currencyCode ), - dateI18n( 'F j, Y', moment( endTime ).toISOString() ) + formatDateTimeFromString( endTime ) ); } else if ( volumeAllowance ) { description = sprintf( @@ -44,7 +43,7 @@ const ExpirationDescription = ( { 'Discounted base fee expires on %1$s.', 'woocommerce-payments' ), - dateI18n( 'F j, Y', moment( endTime ).toISOString() ) + formatDateTimeFromString( endTime ) ); } else { return null; diff --git a/client/components/account-status/account-fees/test/index.js b/client/components/account-status/account-fees/test/index.js index 5258af4ffdc..7405b33e371 100644 --- a/client/components/account-status/account-fees/test/index.js +++ b/client/components/account-status/account-fees/test/index.js @@ -46,6 +46,7 @@ describe( 'AccountFees', () => { precision: 2, }, }, + dateFormat: 'F j, Y', }; } ); diff --git a/client/components/active-loan-summary/index.tsx b/client/components/active-loan-summary/index.tsx index 0c5059ef87c..7ae902c590e 100755 --- a/client/components/active-loan-summary/index.tsx +++ b/client/components/active-loan-summary/index.tsx @@ -13,7 +13,6 @@ import { } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { createInterpolateElement } from '@wordpress/element'; -import { dateI18n } from '@wordpress/date'; /** * Internal dependencies. @@ -24,6 +23,7 @@ import { useActiveLoanSummary } from 'wcpay/data'; import { getAdminUrl } from 'wcpay/utils'; import './style.scss'; +import { formatDateTimeFromTimestamp } from 'wcpay/utils/date-time'; const Block = ( { title, @@ -210,12 +210,8 @@ const ActiveLoanSummary = (): JSX.Element => { 'Repaid this period (until %s)', 'woocommerce-payments' ), - dateI18n( - 'M j, Y', - new Date( - details.current_repayment_interval.due_at * - 1000 - ) + formatDateTimeFromTimestamp( + details.current_repayment_interval.due_at ) ) } > @@ -251,9 +247,8 @@ const ActiveLoanSummary = (): JSX.Element => { - { dateI18n( - 'M j, Y', - new Date( details.advance_paid_out_at * 1000 ) + { formatDateTimeFromTimestamp( + details.advance_paid_out_at ) } { - { dateI18n( - 'M j, Y', - new Date( details.repayments_begin_at * 1000 ) + { formatDateTimeFromTimestamp( + details.repayments_begin_at ) } diff --git a/client/components/active-loan-summary/test/__snapshots__/index.js.snap b/client/components/active-loan-summary/test/__snapshots__/index.js.snap index 4424415245c..4e9dd15ec13 100644 --- a/client/components/active-loan-summary/test/__snapshots__/index.js.snap +++ b/client/components/active-loan-summary/test/__snapshots__/index.js.snap @@ -74,7 +74,7 @@ exports[`Active loan summary renders correctly 1`] = `
- Repaid this period (until Feb 14, 2022) + Repaid this period (until Feb 15, 2022)
( { useActiveLoanSummary: jest.fn(), } ) ); +// Mock dateI18n +jest.mock( '@wordpress/date', () => ( { + dateI18n: jest.fn( ( format, date ) => { + return jest + .requireActual( '@wordpress/date' ) + .dateI18n( format, date, 'UTC' ); // Ensure UTC is used + } ), +} ) ); + describe( 'Active loan summary', () => { beforeEach( () => { global.wcpaySettings = { @@ -34,6 +43,7 @@ describe( 'Active loan summary', () => { precision: 2, }, }, + dateFormat: 'M j, Y', }; } ); afterEach( () => { diff --git a/client/components/date-format-notice/index.tsx b/client/components/date-format-notice/index.tsx new file mode 100644 index 00000000000..cdaba000939 --- /dev/null +++ b/client/components/date-format-notice/index.tsx @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import React, { useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import BannerNotice from 'components/banner-notice'; +import interpolateComponents from '@automattic/interpolate-components'; +import { Link } from '@woocommerce/components'; +import './style.scss'; + +const optionName = 'wcpay_date_format_notice_dismissed'; + +const DateFormatNotice: React.FC = () => { + const { updateOptions } = useDispatch( 'wc/admin/options' ); + const [ isBannerVisible, setIsBannerVisible ] = useState( + ! wcpaySettings.isDateFormatNoticeDismissed + ); + + const handleDismiss = () => { + setIsBannerVisible( false ); + wcpaySettings.isDateFormatNoticeDismissed = true; + updateOptions( { + [ optionName ]: true, + } ); + }; + + const handleSettingsClick = () => { + handleDismiss(); + }; + + if ( ! isBannerVisible ) { + return null; + } + + return ( + + { interpolateComponents( { + components: { + settingsLink: ( + + ), + }, + mixedString: __( + 'The date and time formats now match your preferences. You can update them anytime in the {{settingsLink}}settings{{/settingsLink}}.', + 'woocommerce-payments' + ), + } ) } + + ); +}; + +export default DateFormatNotice; diff --git a/client/components/date-format-notice/style.scss b/client/components/date-format-notice/style.scss new file mode 100644 index 00000000000..86ee0987728 --- /dev/null +++ b/client/components/date-format-notice/style.scss @@ -0,0 +1,5 @@ +.date-format-notice { + .wcpay-banner-notice__content { + align-self: center; // Align the content to the center of the notice and the icon. + } +} diff --git a/client/components/deposits-overview/test/index.tsx b/client/components/deposits-overview/test/index.tsx index edaa068a100..920adf540c7 100644 --- a/client/components/deposits-overview/test/index.tsx +++ b/client/components/deposits-overview/test/index.tsx @@ -68,6 +68,7 @@ declare const global: { connect: { country: string; }; + dateFormat: string; }; }; @@ -240,6 +241,7 @@ describe( 'Deposits Overview information', () => { precision: 2, }, }, + dateFormat: 'F j, Y', }; mockUseDepositIncludesLoan.mockReturnValue( { includesFinancingPayout: false, diff --git a/client/components/disputed-order-notice/index.js b/client/components/disputed-order-notice/index.js index ab51a52d16e..c7d1db9e7d1 100644 --- a/client/components/disputed-order-notice/index.js +++ b/client/components/disputed-order-notice/index.js @@ -1,7 +1,6 @@ import moment from 'moment'; import React, { useEffect } from 'react'; import { __, _n, sprintf } from '@wordpress/i18n'; -import { dateI18n } from '@wordpress/date'; import { createInterpolateElement } from '@wordpress/element'; /** @@ -20,6 +19,7 @@ import { import { useCharge } from 'wcpay/data'; import { recordEvent } from 'tracks'; import './style.scss'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; const DisputedOrderNoticeHandler = ( { chargeId, onDisableOrderRefund } ) => { const { data: charge } = useCharge( chargeId ); @@ -84,8 +84,10 @@ const DisputedOrderNoticeHandler = ( { chargeId, onDisableOrderRefund } ) => { return null; } - const now = moment(); - const dueBy = moment.unix( dispute.evidence_details?.due_by ); + // Get current time in UTC for consistent timezone-independent comparison + const now = moment().utc(); + // Parse the Unix timestamp as UTC since it's stored that way in the API + const dueBy = moment.unix( dispute.evidence_details?.due_by ).utc(); // If the dispute is due in the past, don't show notice. if ( ! now.isBefore( dueBy ) ) { @@ -131,7 +133,7 @@ const UrgentDisputeNoticeBody = ( { formatString, formattedAmount, reasons[ disputeReason ].display, - dateI18n( 'M j, Y', dueBy.local().toISOString() ) + formatDateTimeFromString( dueBy.toISOString() ) ); let suffix = sprintf( @@ -182,7 +184,7 @@ const RegularDisputeNoticeBody = ( { const suffix = sprintf( // Translators: %1$s is the dispute due date. __( 'Please respond before %1$s.', 'woocommerce-payments' ), - dateI18n( 'M j, Y', dueBy.local().toISOString() ) + formatDateTimeFromString( dueBy.toISOString() ) ); return ( diff --git a/client/components/disputed-order-notice/test/index.test.js b/client/components/disputed-order-notice/test/index.test.js index 7e44da132e0..784092295f3 100644 --- a/client/components/disputed-order-notice/test/index.test.js +++ b/client/components/disputed-order-notice/test/index.test.js @@ -36,6 +36,7 @@ describe( 'DisputedOrderNoticeHandler', () => { connect: { country: 'US', }, + dateFormat: 'M j, Y', }; useCharge.mockReturnValue( { data: mockCharge } ); } ); diff --git a/client/connect-account-page/index.tsx b/client/connect-account-page/index.tsx index 2b3f402abcb..42cb5f25f88 100644 --- a/client/connect-account-page/index.tsx +++ b/client/connect-account-page/index.tsx @@ -95,6 +95,16 @@ const ConnectAccountPage: React.FC = () => { const loaderProgressRef = useRef( testDriveLoaderProgress ); loaderProgressRef.current = testDriveLoaderProgress; + // Use a timer to track the elapsed time for the test drive mode setup. + let testDriveSetupStartTime: number; + // The test drive setup will be forced finished after 40 seconds + // (10 seconds for the initial calls plus 30 for checking the account status in a loop). + const testDriveSetupMaxDuration = 40; + + // Helper function to calculate the elapsed time in seconds. + const elapsed = ( time: number ) => + Math.round( ( Date.now() - time ) / 1000 ); + const { connectUrl, connect: { availableCountries, country }, @@ -173,19 +183,20 @@ const ConnectAccountPage: React.FC = () => { method: 'GET', } ).then( ( account ) => { // Simulate the update of the loader progress bar by 4% per check. - // Limit to a maximum of 15 checks or 30 seconds. - updateLoaderProgress( 100, 4 ); + // Limit to a maximum of 10 checks (6% progress per each request starting from 40% = max 10 checks). + updateLoaderProgress( 100, 6 ); - // If the account status is not a pending one or progress percentage is above 95, - // consider our work done and redirect the merchant. - // Otherwise, schedule another check after 2 seconds. + // If the account status is not a pending one, the progress percentage is above 95, + // or we've exceeded the timeout, consider our work done and redirect the merchant. + // Otherwise, schedule another check after a 2.5 seconds wait. if ( ( account && ( account as AccountData ).status && ! ( account as AccountData ).status.includes( 'pending' ) ) || - loaderProgressRef.current > 95 + loaderProgressRef.current > 95 || + elapsed( testDriveSetupStartTime ) > testDriveSetupMaxDuration ) { setTestDriveLoaderProgress( 100 ); const queryArgs = { @@ -203,12 +214,18 @@ const ConnectAccountPage: React.FC = () => { ...extraQueryArgs, } ); } else { - setTimeout( () => checkAccountStatus( extraQueryArgs ), 2000 ); + // Schedule another check after 2.5 seconds. + // 2.5 seconds plus 0.5 seconds for the fetch request is 3 seconds. + // With a maximum of 10 checks, we will wait for 30 seconds before ending the process normally. + setTimeout( () => checkAccountStatus( extraQueryArgs ), 2500 ); } } ); }; const handleSetupTestDriveMode = async () => { + // Record the start time of the test drive setup. + testDriveSetupStartTime = Date.now(); + // Initialize the progress bar. setTestDriveLoaderProgress( 5 ); setTestDriveModeSubmitted( true ); trackConnectAccountClicked( true ); @@ -256,6 +273,7 @@ const ConnectAccountPage: React.FC = () => { } clearInterval( updateProgress ); + // Update the progress bar to 40% since we've finished the initial account setup. setTestDriveLoaderProgress( 40 ); // Check the url for the `wcpay-connection-success` parameter, indicating a successful connection. diff --git a/client/data/authorizations/actions.ts b/client/data/authorizations/actions.ts index 0eaf50f105d..ace7f3c6fed 100644 --- a/client/data/authorizations/actions.ts +++ b/client/data/authorizations/actions.ts @@ -19,6 +19,51 @@ import { import { STORE_NAME } from '../constants'; import { ApiError } from 'wcpay/types/errors'; +const getErrorMessage = ( apiError: { + code?: string; + message?: string; +} ): string => { + // Map specific error codes to user-friendly messages + const errorMessages: Record< string, string > = { + wcpay_missing_order: __( + 'The order could not be found.', + 'woocommerce-payments' + ), + wcpay_refunded_order_uncapturable: __( + 'Payment cannot be processed for partially or fully refunded orders.', + 'woocommerce-payments' + ), + wcpay_intent_order_mismatch: __( + 'The payment cannot be processed due to a mismatch with order details.', + 'woocommerce-payments' + ), + wcpay_payment_uncapturable: __( + 'This payment cannot be processed in its current state.', + 'woocommerce-payments' + ), + wcpay_capture_error: __( + 'The payment capture failed to complete.', + 'woocommerce-payments' + ), + wcpay_cancel_error: __( + 'The payment cancellation failed to complete.', + 'woocommerce-payments' + ), + wcpay_server_error: __( + 'An unexpected error occurred. Please try again later.', + 'woocommerce-payments' + ), + }; + + return ( + errorMessages[ apiError.code ?? '' ] ?? + __( + 'Unable to process the payment. Please try again later.', + 'woocommerce-payments' + ) + ); +}; + export function updateAuthorizations( query: Query, data: Authorization[] @@ -151,6 +196,13 @@ export function* submitCaptureAuthorization( 'getPaymentIntent' ); + // Need to invalidate transactions tab to update newly captured transaction if needed. + yield controls.dispatch( + STORE_NAME, + 'invalidateResolutionForStoreSelector', + 'getTransactions' + ); + // Create success notice. yield controls.dispatch( 'core/notices', @@ -165,17 +217,29 @@ export function* submitCaptureAuthorization( ) ); } catch ( error ) { + const baseErrorMessage = sprintf( + // translators: %s Order id + __( + 'There has been an error capturing the payment for order #%s.', + 'woocommerce-payments' + ), + orderId + ); + + const apiError = error as { + code?: string; + message?: string; + data?: { + status?: number; + }; + }; + + const errorDetails = getErrorMessage( apiError ); + yield controls.dispatch( 'core/notices', 'createErrorNotice', - sprintf( - // translators: %s Order id - __( - 'There has been an error capturing the payment for order #%s. Please try again later.', - 'woocommerce-payments' - ), - orderId - ) + `${ baseErrorMessage } ${ errorDetails }` ); } finally { yield controls.dispatch( @@ -184,6 +248,7 @@ export function* submitCaptureAuthorization( 'getAuthorization', [ paymentIntentId ] ); + yield controls.dispatch( STORE_NAME, 'setIsRequestingAuthorization', @@ -278,17 +343,29 @@ export function* submitCancelAuthorization( ) ); } catch ( error ) { + const baseErrorMessage = sprintf( + // translators: %s Order id + __( + 'There has been an error canceling the payment for order #%s.', + 'woocommerce-payments' + ), + orderId + ); + + const apiError = error as { + code?: string; + message?: string; + data?: { + status?: number; + }; + }; + + const errorDetails = getErrorMessage( apiError ); + yield controls.dispatch( 'core/notices', 'createErrorNotice', - sprintf( - // translators: %s Order id - __( - 'There has been an error canceling the payment for order #%s. Please try again later.', - 'woocommerce-payments' - ), - orderId - ) + `${ baseErrorMessage } ${ errorDetails }` ); } finally { yield controls.dispatch( diff --git a/client/data/authorizations/test/actions.test.ts b/client/data/authorizations/test/actions.test.ts index 1c73ab5d7a2..36527d1836a 100644 --- a/client/data/authorizations/test/actions.test.ts +++ b/client/data/authorizations/test/actions.test.ts @@ -16,6 +16,7 @@ import { updateAuthorization, } from '../actions'; import authorizationsFixture from './authorizations.fixture.json'; +import { STORE_NAME } from 'wcpay/data/constants'; describe( 'Authorizations actions', () => { describe( 'submitCaptureAuthorization', () => { @@ -117,6 +118,14 @@ describe( 'Authorizations actions', () => { ) ); + expect( generator.next().value ).toEqual( + controls.dispatch( + 'wc/payments', + 'invalidateResolutionForStoreSelector', + 'getTransactions' + ) + ); + expect( generator.next().value ).toEqual( controls.dispatch( 'core/notices', @@ -153,10 +162,117 @@ describe( 'Authorizations actions', () => { controls.dispatch( 'core/notices', 'createErrorNotice', - 'There has been an error capturing the payment for order #42. Please try again later.' + 'There has been an error capturing the payment for order #42. Unable to process the payment. Please try again later.' ) ); } ); + + describe( 'error handling', () => { + it( 'should create error notice with API error message', () => { + const generator = submitCaptureAuthorization( 'pi_123', 123 ); + + // Mock the start of the capture process + expect( generator.next().value ).toEqual( + controls.dispatch( + STORE_NAME, + 'startResolution', + 'getAuthorization', + [ 'pi_123' ] + ) + ); + + expect( generator.next().value ).toEqual( + controls.dispatch( + STORE_NAME, + 'setIsRequestingAuthorization', + true + ) + ); + + // Mock API error response + const apiError = { + code: 'wcpay_refunded_order_uncapturable', + message: + 'Payment cannot be captured for partially or fully refunded orders.', + data: { status: 400 }, + }; + + // Simulate API error + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error capturing the payment for order #123. Payment cannot be processed for partially or fully refunded orders.' + ) + ); + + // Verify cleanup in finally block + expect( generator.next().value ).toEqual( + controls.dispatch( + STORE_NAME, + 'finishResolution', + 'getAuthorization', + [ 'pi_123' ] + ) + ); + + expect( generator.next().value ).toEqual( + controls.dispatch( + STORE_NAME, + 'setIsRequestingAuthorization', + false + ) + ); + } ); + + it( 'should create error notice with fallback message when API error has no message', () => { + const generator = submitCaptureAuthorization( 'pi_123', 123 ); + + // Skip initial dispatch calls + generator.next(); + generator.next(); + + // Mock API error without message + const apiError = { + code: 'unknown_error', + data: { status: 500 }, + }; + + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error capturing the payment for order #123. Unable to process the payment. Please try again later.' + ) + ); + } ); + + it( 'should show default error notice for unknown error code', () => { + const generator = submitCaptureAuthorization( + 'pi_unknown', + 999 + ); + + // Start the generator to the point where it would throw an error + generator.next(); + generator.next(); + + // Mock an API error with an unknown error code + const apiError = { + code: 'unknown_error_code', + data: { status: 500 }, + }; + + // Expect the default error message to be dispatched + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error capturing the payment for order #999. Unable to process the payment. Please try again later.' + ) + ); + } ); + } ); } ); describe( 'submitCancelAuthorization', () => { @@ -294,9 +410,56 @@ describe( 'Authorizations actions', () => { controls.dispatch( 'core/notices', 'createErrorNotice', - 'There has been an error canceling the payment for order #42. Please try again later.' + 'There has been an error canceling the payment for order #42. Unable to process the payment. Please try again later.' ) ); } ); + + describe( 'error handling', () => { + it( 'should create error notice with API error message', () => { + const generator = submitCancelAuthorization( 'pi_123', 123 ); + + // Skip initial dispatch calls + generator.next(); + generator.next(); + + // Mock API error response + const apiError = { + code: 'wcpay_payment_uncapturable', + message: 'The payment cannot be canceled at this time.', + data: { status: 400 }, + }; + + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error canceling the payment for order #123. This payment cannot be processed in its current state.' + ) + ); + } ); + + it( 'should create error notice with fallback message when API error has no message', () => { + const generator = submitCancelAuthorization( 'pi_123', 123 ); + + // Skip initial dispatch calls + generator.next(); + generator.next(); + + // Mock API error without message + const apiError = { + code: 'unknown_error', + data: { status: 500 }, + }; + + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error canceling the payment for order #123. Unable to process the payment. Please try again later.' + ) + ); + } ); + } ); } ); } ); diff --git a/client/data/disputes/hooks.ts b/client/data/disputes/hooks.ts index b8a95b1e5e6..5db1c2c3c59 100644 --- a/client/data/disputes/hooks.ts +++ b/client/data/disputes/hooks.ts @@ -17,7 +17,6 @@ import type { } from 'wcpay/types/disputes'; import type { ApiError } from 'wcpay/types/errors'; import { STORE_NAME } from '../constants'; -import { disputeAwaitingResponseStatuses } from 'wcpay/disputes/filters/config'; /** * Returns the dispute object, error object, and loading state. @@ -98,11 +97,6 @@ export const useDisputes = ( { ( select ) => { const { getDisputes, isResolving } = select( STORE_NAME ); - const search = - filter === 'awaiting_response' - ? disputeAwaitingResponseStatuses - : undefined; - const query = { paged: Number.isNaN( parseInt( paged ?? '', 10 ) ) ? '1' @@ -119,7 +113,7 @@ export const useDisputes = ( { dateBetween.sort( ( a, b ) => moment( a ).diff( moment( b ) ) ), - search, + filter, statusIs, statusIsNot, orderBy: orderBy || 'created', @@ -163,11 +157,6 @@ export const useDisputesSummary = ( { ( select ) => { const { getDisputesSummary, isResolving } = select( STORE_NAME ); - const search = - filter === 'awaiting_response' - ? disputeAwaitingResponseStatuses - : undefined; - const query = { paged: Number.isNaN( parseInt( paged ?? '', 10 ) ) ? '1' @@ -180,7 +169,7 @@ export const useDisputesSummary = ( { dateBefore, dateAfter, dateBetween, - search, + filter, statusIs, statusIsNot, }; diff --git a/client/data/disputes/resolvers.js b/client/data/disputes/resolvers.js index bf45770537c..ce748a46562 100644 --- a/client/data/disputes/resolvers.js +++ b/client/data/disputes/resolvers.js @@ -20,6 +20,7 @@ import { updateDisputesSummary, updateErrorForDispute, } from './actions'; +import { disputeAwaitingResponseStatuses } from 'wcpay/disputes/filters/config'; const formatQueryFilters = ( query ) => ( { user_email: query.userEmail, @@ -31,7 +32,10 @@ const formatQueryFilters = ( query ) => ( { formatDateValue( query.dateBetween[ 0 ] ), formatDateValue( query.dateBetween[ 1 ], true ), ], - search: query.search, + search: + query.filter === 'awaiting_response' + ? disputeAwaitingResponseStatuses + : query.search, status_is: query.statusIs, status_is_not: query.statusIsNot, locale: query.locale, @@ -42,7 +46,6 @@ export function getDisputesCSV( query ) { `${ NAMESPACE }/disputes/download`, formatQueryFilters( query ) ); - return path; } diff --git a/client/deposits/details/index.tsx b/client/deposits/details/index.tsx index 2176b079377..4e15a8653a8 100644 --- a/client/deposits/details/index.tsx +++ b/client/deposits/details/index.tsx @@ -4,9 +4,7 @@ * External dependencies */ import React from 'react'; -import { dateI18n } from '@wordpress/date'; import { __, sprintf } from '@wordpress/i18n'; -import moment from 'moment'; import { Card, CardBody, @@ -41,6 +39,8 @@ import { import { depositStatusLabels } from '../strings'; import './style.scss'; import { PayoutsRenameNotice } from '../rename-notice'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; +import DateFormatNotice from 'wcpay/components/date-format-notice'; /** * Renders the deposit status indicator UI, re-purposing the OrderStatus component from @woocommerce/components. @@ -135,11 +135,7 @@ export const DepositOverview: React.FC< DepositOverviewProps > = ( { key="depositDate" label={ `${ depositDateLabel }: ` + - dateI18n( - 'M j, Y', - moment.utc( deposit.date ).toISOString(), - true // TODO Change call to gmdateI18n and remove this deprecated param once WP 5.4 support ends. - ) + formatDateTimeFromString( deposit.date ) } value={ } detail={ deposit.bankAccount } @@ -248,6 +244,7 @@ export const DepositDetails: React.FC< DepositDetailsProps > = ( { return ( + { isLoading ? ( diff --git a/client/deposits/details/test/index.tsx b/client/deposits/details/test/index.tsx index 5f350daf4d0..fa9a4dbf042 100644 --- a/client/deposits/details/test/index.tsx +++ b/client/deposits/details/test/index.tsx @@ -45,6 +45,7 @@ declare const global: { connect: { country: string; }; + dateFormat: string; }; wcSettings: { countries: Record< string, string > }; }; @@ -67,6 +68,7 @@ describe( 'Deposit overview', () => { precision: 2, }, }, + dateFormat: 'M j, Y', }; } ); diff --git a/client/deposits/index.tsx b/client/deposits/index.tsx index d799ff3d385..229acdbbb09 100644 --- a/client/deposits/index.tsx +++ b/client/deposits/index.tsx @@ -23,6 +23,7 @@ import { useSettings } from 'wcpay/data'; import DepositsList from './list'; import { hasAutomaticScheduledDeposits } from 'wcpay/deposits/utils'; import { recordEvent } from 'wcpay/tracks'; +import DateFormatNotice from 'wcpay/components/date-format-notice'; const useNextDepositNoticeState = () => { const { updateOptions } = useDispatch( 'wc/admin/options' ); @@ -149,6 +150,7 @@ const DepositsPage: React.FC = () => { return ( + diff --git a/client/deposits/list/index.tsx b/client/deposits/list/index.tsx index 1ac643d31dc..dc3e20ac02a 100644 --- a/client/deposits/list/index.tsx +++ b/client/deposits/list/index.tsx @@ -6,9 +6,7 @@ import React, { useState } from 'react'; import { recordEvent } from 'tracks'; import { useMemo } from '@wordpress/element'; -import { dateI18n } from '@wordpress/date'; import { __, _n, sprintf } from '@wordpress/i18n'; -import moment from 'moment'; import { TableCard, Link } from '@woocommerce/components'; import { onQueryChange, getQuery } from '@woocommerce/navigation'; import { @@ -48,6 +46,7 @@ import CSVExportModal from 'components/csv-export-modal'; import { ReportingExportLanguageHook } from 'wcpay/settings/reporting-settings/interfaces'; import './style.scss'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; const getColumns = ( sortByDate?: boolean ): DepositsTableHeader[] => [ { @@ -140,11 +139,7 @@ export const DepositsList = (): JSX.Element => { href={ getDetailsURL( deposit.id, 'payouts' ) } onClick={ () => recordEvent( 'wcpay_deposits_row_click' ) } > - { dateI18n( - 'M j, Y', - moment.utc( deposit.date ).toISOString(), - true // TODO Change call to gmdateI18n and remove this deprecated param once WP 5.4 support ends. - ) } + { formatDateTimeFromString( deposit.date ) } ); @@ -335,11 +330,7 @@ export const DepositsList = (): JSX.Element => { row[ 0 ], { ...row[ 1 ], - value: dateI18n( - 'Y-m-d', - moment.utc( row[ 1 ].value ).toISOString(), - true - ), + value: formatDateTimeFromString( row[ 1 ].value as string ), }, ...row.slice( 2 ), ] ); diff --git a/client/deposits/list/test/__snapshots__/index.tsx.snap b/client/deposits/list/test/__snapshots__/index.tsx.snap index 07945cd0c64..c26a364fd04 100644 --- a/client/deposits/list/test/__snapshots__/index.tsx.snap +++ b/client/deposits/list/test/__snapshots__/index.tsx.snap @@ -361,7 +361,7 @@ exports[`Deposits list renders correctly a single deposit 1`] = ` data-link-type="wc-admin" href="admin.php?page=wc-admin&path=%2Fpayments%2Fpayouts%2Fdetails&id=po_mock1" > - Jan 2, 2020 + Jan 2 2020 - Jan 3, 2020 + Jan 3 2020 - Jan 4, 2020 + Jan 4 2020 - Jan 2, 2020 + Jan 2 2020 - Jan 3, 2020 + Jan 3 2020 - Jan 4, 2020 + Jan 4 2020 { reporting: { exportModalDismissed: true, }, + dateFormat: 'M j Y', }; } ); @@ -321,7 +323,7 @@ describe( 'Deposits list', () => { // 2. The indexOf check in amount's expect is because the amount in CSV may not contain // trailing zeros as in the display amount. // - expect( formatDate( csvFirstDeposit[ 1 ], 'M j, Y' ) ).toBe( + expect( csvFirstDeposit[ 1 ].replace( /^"|"$/g, '' ) ).toBe( displayFirstDeposit[ 0 ] ); // date expect( csvFirstDeposit[ 2 ] ).toBe( displayFirstDeposit[ 1 ] ); // type diff --git a/client/deposits/utils/index.ts b/client/deposits/utils/index.ts index 3d8fd6276e1..05f65c46bc3 100644 --- a/client/deposits/utils/index.ts +++ b/client/deposits/utils/index.ts @@ -2,21 +2,15 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { dateI18n } from '@wordpress/date'; import moment from 'moment'; - -const formatDate = ( format: string, date: number | string ) => - dateI18n( - format, - moment.utc( date ).toISOString(), - true // TODO Change call to gmdateI18n and remove this deprecated param once WP 5.4 support ends. - ); +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; interface DepositObject { date: number | string; } + export const getDepositDate = ( deposit?: DepositObject | null ): string => - deposit ? formatDate( 'F j, Y', deposit?.date ) : '—'; + deposit ? formatDateTimeFromString( deposit?.date as string ) : '—'; interface GetDepositMonthlyAnchorLabelProps { monthlyAnchor: number; diff --git a/client/deposits/utils/test/index.ts b/client/deposits/utils/test/index.ts index d0361137104..e1957ce4564 100644 --- a/client/deposits/utils/test/index.ts +++ b/client/deposits/utils/test/index.ts @@ -8,7 +8,29 @@ import momentLib from 'moment'; */ import { getDepositDate, getDepositMonthlyAnchorLabel } from '../'; +declare const global: { + wcpaySettings: { + dateFormat: string; + }; +}; + +// Mock dateI18n +jest.mock( '@wordpress/date', () => ( { + dateI18n: jest.fn( ( format, date ) => { + return jest + .requireActual( '@wordpress/date' ) + .dateI18n( format, date, 'UTC' ); // Ensure UTC is used + } ), +} ) ); + describe( 'Deposits Overview Utils / getDepositDate', () => { + beforeEach( () => { + jest.clearAllMocks(); + global.wcpaySettings = { + dateFormat: 'F j, Y', + }; + } ); + test( 'returns a display value without a deposit', () => { expect( getDepositDate() ).toEqual( '—' ); } ); diff --git a/client/disputes/evidence/test/index.js b/client/disputes/evidence/test/index.js index e9ccdb826cb..142ee738ab8 100644 --- a/client/disputes/evidence/test/index.js +++ b/client/disputes/evidence/test/index.js @@ -96,6 +96,7 @@ describe( 'Dispute evidence form', () => { global.wcpaySettings = { restUrl: 'http://example.com/wp-json/', + dateFormat: 'M j, Y', }; } ); afterEach( () => { @@ -190,6 +191,8 @@ describe( 'Dispute evidence page', () => { precision: 2, }, }, + dateFormat: 'M j, Y', + timeFormat: 'g:iA', }; } ); diff --git a/client/disputes/index.tsx b/client/disputes/index.tsx index 060afccce35..ad5e8258d7f 100644 --- a/client/disputes/index.tsx +++ b/client/disputes/index.tsx @@ -5,7 +5,6 @@ */ import React, { useState } from 'react'; import { recordEvent } from 'tracks'; -import { dateI18n } from '@wordpress/date'; import { _n, __, sprintf } from '@wordpress/i18n'; import moment from 'moment'; import { Button } from '@wordpress/components'; @@ -56,8 +55,9 @@ import { useSettings } from 'wcpay/data'; import { isAwaitingResponse } from 'wcpay/disputes/utils'; import CSVExportModal from 'components/csv-export-modal'; import { ReportingExportLanguageHook } from 'wcpay/settings/reporting-settings/interfaces'; - +import DateFormatNotice from 'wcpay/components/date-format-notice'; import './style.scss'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; const getHeaders = ( sortColumn?: string ): DisputesTableHeader[] => [ { @@ -201,10 +201,9 @@ const smartDueDate = ( dispute: CachedDispute ) => { ); } - return dateI18n( - 'M j, Y / g:iA', - moment.utc( dispute.due_by ).local().toISOString() - ); + return formatDateTimeFromString( dispute.due_by, { + includeTime: true, + } ); }; export const DisputesList = (): JSX.Element => { @@ -301,10 +300,9 @@ export const DisputesList = (): JSX.Element => { created: { value: dispute.created, display: clickable( - dateI18n( - 'M j, Y', - moment( dispute.created ).toISOString() - ) + formatDateTimeFromString( dispute.created, { + includeTime: true, + } ) ), }, dueBy: { @@ -372,6 +370,7 @@ export const DisputesList = (): JSX.Element => { date_after: dateAfter, date_between: dateBetween, match, + filter, status_is: statusIs, status_is_not: statusIsNot, } = getQuery(); @@ -398,21 +397,25 @@ export const DisputesList = (): JSX.Element => { window.confirm( confirmMessage ) ) { try { - const { exported_disputes: exportedDisputes } = await apiFetch( - { - path: getDisputesCSV( { - userEmail, - locale, - dateAfter, - dateBefore, - dateBetween, - match, - statusIs, - statusIsNot, - } ), - method: 'POST', - } - ); + const { + exported_disputes: exportedDisputes, + } = await apiFetch< { + /** The total number of disputes that will be exported in the CSV. */ + exported_disputes: number; + } >( { + path: getDisputesCSV( { + userEmail, + locale, + dateAfter, + dateBefore, + dateBetween, + match, + filter, + statusIs, + statusIsNot, + } ), + method: 'POST', + } ); createNotice( 'success', @@ -483,17 +486,18 @@ export const DisputesList = (): JSX.Element => { { // Disputed On. ...row[ 10 ], - value: dateI18n( - 'Y-m-d', - moment( row[ 10 ].value ).toISOString() + value: formatDateTimeFromString( + row[ 10 ].value as string ), }, { // Respond by. ...row[ 11 ], - value: dateI18n( - 'Y-m-d / g:iA', - moment( row[ 11 ].value ).toISOString() + value: formatDateTimeFromString( + row[ 11 ].value as string, + { + includeTime: true, + } ), }, ]; @@ -551,6 +555,7 @@ export const DisputesList = (): JSX.Element => { return ( + { precision: 2, }, }, + dateFormat: 'M j, Y', + timeFormat: 'g:iA', }; } ); diff --git a/client/disputes/test/__snapshots__/index.tsx.snap b/client/disputes/test/__snapshots__/index.tsx.snap index 06459d236e4..b3fae6b5d47 100644 --- a/client/disputes/test/__snapshots__/index.tsx.snap +++ b/client/disputes/test/__snapshots__/index.tsx.snap @@ -5,6 +5,54 @@ exports[`Disputes list renders correctly 1`] = `
+
+ +
+ The date and time formats now match your preferences. You can update them anytime in the + + settings + + . +
+ +
diff --git a/client/disputes/test/index.tsx b/client/disputes/test/index.tsx index 1409bfc852d..37bbd2e93af 100644 --- a/client/disputes/test/index.tsx +++ b/client/disputes/test/index.tsx @@ -17,13 +17,14 @@ import { useReportingExportLanguage, useSettings, } from 'data/index'; -import { formatDate, getUnformattedAmount } from 'wcpay/utils/test-utils'; +import { getUnformattedAmount } from 'wcpay/utils/test-utils'; import React from 'react'; import { CachedDispute, DisputeReason, DisputeStatus, } from 'wcpay/types/disputes'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; jest.mock( '@woocommerce/csv-export', () => { const actualModule = jest.requireActual( '@woocommerce/csv-export' ); @@ -100,6 +101,8 @@ declare const global: { reporting?: { exportModalDismissed: boolean; }; + dateFormat?: string; + timeFormat?: string; }; }; @@ -198,6 +201,8 @@ describe( 'Disputes list', () => { reporting: { exportModalDismissed: true, }, + dateFormat: 'Y-m-d', + timeFormat: 'g:iA', }; } ); @@ -363,8 +368,10 @@ describe( 'Disputes list', () => { `"${ displayFirstDispute[ 5 ] }"` ); // customer - expect( formatDate( csvFirstDispute[ 11 ], 'Y-m-d / g:iA' ) ).toBe( - formatDate( displayFirstDispute[ 6 ], 'Y-m-d / g:iA' ) + expect( csvFirstDispute[ 11 ].replace( /^"|"$/g, '' ) ).toBe( + formatDateTimeFromString( mockDisputes[ 0 ].due_by, { + includeTime: true, + } ) ); // date respond by } ); } ); diff --git a/client/documents/index.tsx b/client/documents/index.tsx index 07f75e99ddf..c95c9d3a6ba 100644 --- a/client/documents/index.tsx +++ b/client/documents/index.tsx @@ -9,10 +9,11 @@ import React from 'react'; import Page from 'components/page'; import DocumentsList from './list'; import { TestModeNotice } from 'components/test-mode-notice'; - +import DateFormatNotice from 'wcpay/components/date-format-notice'; export const DocumentsPage = (): JSX.Element => { return ( + diff --git a/client/documents/list/index.tsx b/client/documents/list/index.tsx index b69e2df54af..3223d45dcec 100644 --- a/client/documents/list/index.tsx +++ b/client/documents/list/index.tsx @@ -4,9 +4,7 @@ * External dependencies */ import React, { useCallback, useEffect, useState } from 'react'; -import { dateI18n } from '@wordpress/date'; import { __, _n, sprintf } from '@wordpress/i18n'; -import moment from 'moment'; import { TableCard, TableCardColumn } from '@woocommerce/components'; import { onQueryChange, getQuery } from '@woocommerce/navigation'; import { Button } from '@wordpress/components'; @@ -21,6 +19,7 @@ import DocumentsFilters from '../filters'; import Page from '../../components/page'; import { getDocumentUrl } from 'wcpay/utils'; import VatFormModal from 'wcpay/vat/form-modal'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; interface Column extends TableCardColumn { key: 'date' | 'type' | 'description' | 'download'; @@ -68,16 +67,8 @@ const getDocumentDescription = ( document: Document ) => { if ( document.period_from && document.period_to ) { return sprintf( __( 'VAT invoice for %s to %s', 'woocommerce-payments' ), - dateI18n( - 'M j, Y', - moment.utc( document.period_from ).toISOString(), - 'utc' - ), - dateI18n( - 'M j, Y', - moment.utc( document.period_to ).toISOString(), - 'utc' - ) + formatDateTimeFromString( document.period_from ), + formatDateTimeFromString( document.period_to ) ); } return __( @@ -180,10 +171,7 @@ export const DocumentsList = (): JSX.Element => { const data = { date: { value: document.date, - display: dateI18n( - 'M j, Y', - moment.utc( document.date ).local().toISOString() - ), + display: formatDateTimeFromString( document.date ), }, type: { value: documentType, diff --git a/client/documents/list/test/index.tsx b/client/documents/list/test/index.tsx index c54cf7a02c8..0eac7e0bf61 100644 --- a/client/documents/list/test/index.tsx +++ b/client/documents/list/test/index.tsx @@ -36,6 +36,7 @@ declare const global: { accountStatus: { hasSubmittedVatData: boolean; }; + dateFormat: string; }; }; @@ -60,6 +61,11 @@ describe( 'Documents list', () => { let container: Element; let rerender: ( ui: React.ReactElement ) => void; beforeEach( () => { + global.wcpaySettings = { + accountStatus: { hasSubmittedVatData: true }, + dateFormat: 'M j, Y', + }; + mockUseDocuments.mockReturnValue( { documents: getMockDocuments(), isLoading: false, @@ -200,6 +206,7 @@ describe( 'Document download button', () => { beforeEach( () => { global.wcpaySettings = { accountStatus: { hasSubmittedVatData: true }, + dateFormat: 'M j, Y', }; render( ); @@ -223,6 +230,7 @@ describe( 'Document download button', () => { beforeEach( () => { global.wcpaySettings = { accountStatus: { hasSubmittedVatData: false }, + dateFormat: 'M j, Y', }; render( ); @@ -293,6 +301,7 @@ describe( 'Direct document download', () => { global.wcpaySettings = { accountStatus: { hasSubmittedVatData: true }, + dateFormat: 'M j, Y', }; } ); diff --git a/client/express-checkout/blocks/index.js b/client/express-checkout/blocks/index.js index 764dc2292cd..a46ef99e82b 100644 --- a/client/express-checkout/blocks/index.js +++ b/client/express-checkout/blocks/index.js @@ -39,9 +39,7 @@ const expressCheckoutElementApplePay = ( api ) => ( { return false; } - return new Promise( ( resolve ) => { - checkPaymentMethodIsAvailable( 'applePay', cart, resolve ); - } ); + return checkPaymentMethodIsAvailable( 'applePay', cart ); }, } ); @@ -77,9 +75,7 @@ const expressCheckoutElementGooglePay = ( api ) => { return false; } - return new Promise( ( resolve ) => { - checkPaymentMethodIsAvailable( 'googlePay', cart, resolve ); - } ); + return checkPaymentMethodIsAvailable( 'googlePay', cart ); }, }; }; diff --git a/client/express-checkout/event-handlers.js b/client/express-checkout/event-handlers.js index 2d1345ff752..3c59d456251 100644 --- a/client/express-checkout/event-handlers.js +++ b/client/express-checkout/event-handlers.js @@ -13,12 +13,15 @@ import { normalizeShippingAddress, normalizeLineItems, getExpressCheckoutData, + updateShippingAddressUI, } from './utils'; import { trackExpressCheckoutButtonClick, trackExpressCheckoutButtonLoad, } from './tracking'; +let lastSelectedAddress = null; + export const shippingAddressChangeHandler = async ( api, event, elements ) => { try { const response = await api.expressCheckoutECECalculateShippingOptions( @@ -29,6 +32,9 @@ export const shippingAddressChangeHandler = async ( api, event, elements ) => { elements.update( { amount: response.total.amount, } ); + + lastSelectedAddress = event.address; + event.resolve( { shippingRates: response.shipping_options, lineItems: normalizeLineItems( response.displayItems ), @@ -171,5 +177,9 @@ export const onCompletePaymentHandler = () => { }; export const onCancelHandler = () => { + if ( lastSelectedAddress ) { + updateShippingAddressUI( lastSelectedAddress ); + } + lastSelectedAddress = null; unblockUI(); }; diff --git a/client/express-checkout/utils/checkPaymentMethodIsAvailable.js b/client/express-checkout/utils/checkPaymentMethodIsAvailable.js index b592169da22..5beb7e32942 100644 --- a/client/express-checkout/utils/checkPaymentMethodIsAvailable.js +++ b/client/express-checkout/utils/checkPaymentMethodIsAvailable.js @@ -14,71 +14,75 @@ import WCPayAPI from 'wcpay/checkout/api'; import { getUPEConfig } from 'wcpay/utils/checkout'; export const checkPaymentMethodIsAvailable = memoize( - ( paymentMethod, cart, resolve ) => { - // Create the DIV container on the fly - const containerEl = document.createElement( 'div' ); + ( paymentMethod, cart ) => { + return new Promise( ( resolve ) => { + // Create the DIV container on the fly + const containerEl = document.createElement( 'div' ); - // Ensure the element is hidden and doesn’t interfere with the page layout. - containerEl.style.display = 'none'; + // Ensure the element is hidden and doesn’t interfere with the page layout. + containerEl.style.display = 'none'; - document.querySelector( 'body' ).appendChild( containerEl ); + document.querySelector( 'body' ).appendChild( containerEl ); - const root = ReactDOM.createRoot( containerEl ); + const root = ReactDOM.createRoot( containerEl ); - const api = new WCPayAPI( - { - publishableKey: getUPEConfig( 'publishableKey' ), - accountId: getUPEConfig( 'accountId' ), - forceNetworkSavedCards: getUPEConfig( - 'forceNetworkSavedCards' - ), - locale: getUPEConfig( 'locale' ), - isStripeLinkEnabled: isLinkEnabled( - getUPEConfig( 'paymentMethodsConfig' ) - ), - }, - request - ); + const api = new WCPayAPI( + { + publishableKey: getUPEConfig( 'publishableKey' ), + accountId: getUPEConfig( 'accountId' ), + forceNetworkSavedCards: getUPEConfig( + 'forceNetworkSavedCards' + ), + locale: getUPEConfig( 'locale' ), + isStripeLinkEnabled: isLinkEnabled( + getUPEConfig( 'paymentMethodsConfig' ) + ), + }, + request + ); - root.render( - - resolve( false ) } + root.render( + { - let canMakePayment = false; - if ( event.availablePaymentMethods ) { - canMakePayment = - event.availablePaymentMethods[ paymentMethod ]; - } - resolve( canMakePayment ); - root.unmount(); - containerEl.remove(); - } } - /> - - ); + > + resolve( false ) } + options={ { + paymentMethods: { + amazonPay: 'never', + applePay: + paymentMethod === 'applePay' + ? 'always' + : 'never', + googlePay: + paymentMethod === 'googlePay' + ? 'always' + : 'never', + link: 'never', + paypal: 'never', + }, + } } + onReady={ ( event ) => { + let canMakePayment = false; + if ( event.availablePaymentMethods ) { + canMakePayment = + event.availablePaymentMethods[ + paymentMethod + ]; + } + resolve( canMakePayment ); + root.unmount(); + containerEl.remove(); + } } + /> + + ); + } ); } ); diff --git a/client/express-checkout/utils/index.ts b/client/express-checkout/utils/index.ts index 7e6c4bf2d09..3fcb6286071 100644 --- a/client/express-checkout/utils/index.ts +++ b/client/express-checkout/utils/index.ts @@ -2,6 +2,7 @@ * Internal dependencies */ export * from './normalize'; +export * from './shipping-fields'; import { getDefaultBorderRadius } from 'wcpay/utils/express-checkout'; interface MyWindow extends Window { diff --git a/client/express-checkout/utils/shipping-fields.js b/client/express-checkout/utils/shipping-fields.js new file mode 100644 index 00000000000..f097b1eca59 --- /dev/null +++ b/client/express-checkout/utils/shipping-fields.js @@ -0,0 +1,131 @@ +/* global jQuery */ +/** + * Internal dependencies + */ +import { normalizeShippingAddress, getExpressCheckoutData } from '.'; + +/** + * Checks if the intermediate address is redacted for the given country. + * CA and GB addresses are redacted and are causing errors until WooCommerce is able to + * handle redacted addresses. + * https://developers.google.com/pay/api/web/reference/response-objects#IntermediateAddress + * + * @param {string} country - The country code. + * + * @return {boolean} True if the postcode is redacted for the country, false otherwise. + */ +const isPostcodeRedactedForCountry = ( country ) => { + return [ 'CA', 'GB' ].includes( country ); +}; + +/* + * Updates a field in a form with a new value. + * + * @param {String} formSelector - The selector for the form containing the field. + * @param {Object} fieldName - The name of the field to update. + * @param {Object} value - The new value for the field. + */ +const updateShortcodeField = ( formSelector, fieldName, value ) => { + const field = document.querySelector( + `${ formSelector } [name="${ fieldName }"]` + ); + + if ( ! field ) return; + + // Check if the field is a dropdown (country/state). + if ( field.tagName === 'SELECT' && /country|state/.test( fieldName ) ) { + const options = Array.from( field.options ); + const match = options.find( + ( opt ) => + opt.value === value || + opt.textContent.trim().toLowerCase() === value.toLowerCase() + ); + + if ( match ) { + field.value = match.value; + jQuery( field ).trigger( 'change' ).trigger( 'close' ); + } + } else { + // Default behavior for text inputs. + field.value = value; + jQuery( field ).trigger( 'change' ); + } +}; + +/** + * Updates the WooCommerce Blocks shipping UI to reflect a new shipping address. + * + * @param {Object} eventAddress - The shipping address returned by the payment event. + */ +const updateBlocksShippingUI = ( eventAddress ) => { + wp?.data + ?.dispatch( 'wc/store/cart' ) + ?.setShippingAddress( normalizeShippingAddress( eventAddress ) ); +}; + +/** + * Updates the WooCommerce shortcode cart/checkout shipping UI to reflect a new shipping address. + * + * @param {Object} eventAddress - The shipping address returned by the payment event. + */ +const updateShortcodeShippingUI = ( eventAddress ) => { + const context = getExpressCheckoutData( 'button_context' ); + const address = normalizeShippingAddress( eventAddress ); + + const keys = [ 'country', 'state', 'city', 'postcode' ]; + + if ( context === 'cart' ) { + keys.forEach( ( key ) => { + if ( address[ key ] ) { + updateShortcodeField( + 'form.woocommerce-shipping-calculator', + `calc_shipping_${ key }`, + address[ key ] + ); + } + } ); + document + .querySelector( + 'form.woocommerce-shipping-calculator [name="calc_shipping"]' + ) + ?.click(); + } else if ( context === 'checkout' ) { + keys.forEach( ( key ) => { + if ( address[ key ] ) { + updateShortcodeField( + 'form.woocommerce-checkout', + `billing_${ key }`, + address[ key ] + ); + } + } ); + } +}; + +/** + * Updates the WooCommerce shipping UI to reflect a new shipping address. + * + * Determines the current context (cart or checkout) and updates either + * WooCommerce Blocks or shortcode-based shipping forms, if applicable. + * + * @param {Object} newAddress - The new shipping address object returned by the payment event. + * @param {string} newAddress.country - The country code of the shipping address. + * @param {string} [newAddress.state] - The state/province of the shipping address. + * @param {string} [newAddress.city] - The city of the shipping address. + * @param {string} [newAddress.postcode] - The postal/ZIP code of the shipping address. + */ +export const updateShippingAddressUI = ( newAddress ) => { + const context = getExpressCheckoutData( 'button_context' ); + const isBlocks = getExpressCheckoutData( 'has_block' ); + + if ( + [ 'cart', 'checkout' ].includes( context ) && + ! isPostcodeRedactedForCountry( newAddress.country ) + ) { + if ( isBlocks ) { + updateBlocksShippingUI( newAddress ); + } else { + updateShortcodeShippingUI( newAddress ); + } + } +}; diff --git a/client/globals.d.ts b/client/globals.d.ts index 0d10d7de86b..8b91ee4b05f 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -123,6 +123,7 @@ declare global { storeName: string; isNextDepositNoticeDismissed: boolean; isInstantDepositNoticeDismissed: boolean; + isDateFormatNoticeDismissed: boolean; reporting: { exportModalDismissed?: boolean; }; @@ -137,6 +138,8 @@ declare global { isOverviewSurveySubmitted: boolean; lifetimeTPV: number; defaultExpressCheckoutBorderRadius: string; + dateFormat: string; + timeFormat: string; }; const wc: { diff --git a/client/onboarding/style.scss b/client/onboarding/style.scss index 67a6351aab6..415849c1d06 100644 --- a/client/onboarding/style.scss +++ b/client/onboarding/style.scss @@ -85,10 +85,12 @@ body.wcpay-onboarding__body { } &__content { - max-width: 400px; + max-width: 615px; + width: 100%; - @media screen and ( min-width: $break-mobile ) { - width: 400px; + @media screen and ( max-width: $break-mobile ) { + width: 100%; + padding: 0 $gap; } } diff --git a/client/onboarding/utils.ts b/client/onboarding/utils.ts index 306328f64f7..809c87a9927 100644 --- a/client/onboarding/utils.ts +++ b/client/onboarding/utils.ts @@ -140,7 +140,7 @@ export const isPoEligible = async ( * @return {string | undefined} The MCC code for the selected industry. Will return undefined if no industry is selected. */ export const getMccFromIndustry = (): string | undefined => { - const industry = wcSettings.admin.onboarding.profile.industry?.[ 0 ]; + const industry = wcSettings.admin?.onboarding?.profile?.industry?.[ 0 ]; if ( ! industry ) { return undefined; } diff --git a/client/overview/index.js b/client/overview/index.js index edb215993c7..5d6d06a52c2 100644 --- a/client/overview/index.js +++ b/client/overview/index.js @@ -33,6 +33,7 @@ import SandboxModeSwitchToLiveNotice from 'wcpay/components/sandbox-mode-switch- import './style.scss'; import BannerNotice from 'wcpay/components/banner-notice'; import { PayoutsRenameNotice } from 'wcpay/deposits/rename-notice'; +import DateFormatNotice from 'wcpay/components/date-format-notice'; const OverviewPageError = () => { const queryParams = getQuery(); @@ -152,6 +153,7 @@ const OverviewPage = () => { + { showLoanOfferError && ( { __( diff --git a/client/overview/modal/update-business-details/index.tsx b/client/overview/modal/update-business-details/index.tsx index 2c654a57f9b..29a649ad561 100644 --- a/client/overview/modal/update-business-details/index.tsx +++ b/client/overview/modal/update-business-details/index.tsx @@ -3,9 +3,7 @@ */ import React, { useState } from 'react'; import { Button, Modal, Notice } from '@wordpress/components'; -import { dateI18n } from '@wordpress/date'; import { sprintf } from '@wordpress/i18n'; -import moment from 'moment'; /** * Internal dependencies @@ -13,6 +11,7 @@ import moment from 'moment'; import strings from './strings'; import './index.scss'; import { recordEvent } from 'wcpay/tracks'; +import { formatDateTimeFromTimestamp } from 'wcpay/utils/date-time'; interface Props { errorMessages: Array< string >; @@ -57,11 +56,11 @@ const UpdateBusinessDetailsModal = ( { currentDeadline ? sprintf( strings.restrictedSoonDescription, - dateI18n( - 'ga M j, Y', - moment( - currentDeadline * 1000 - ).toISOString() + formatDateTimeFromTimestamp( + currentDeadline, + { + customFormat: 'ga M j, Y', + } ) ) : strings.restrictedDescription } diff --git a/client/overview/task-list/tasks/dispute-task.tsx b/client/overview/task-list/tasks/dispute-task.tsx index 235b92696b9..333c15d6709 100644 --- a/client/overview/task-list/tasks/dispute-task.tsx +++ b/client/overview/task-list/tasks/dispute-task.tsx @@ -2,7 +2,6 @@ * External dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { dateI18n } from '@wordpress/date'; import moment from 'moment'; import { getHistory } from '@woocommerce/navigation'; @@ -15,6 +14,7 @@ import { formatCurrency } from 'multi-currency/interface/functions'; import { getAdminUrl } from 'wcpay/utils'; import { recordEvent } from 'tracks'; import { isDueWithin } from 'wcpay/disputes/utils'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; /** * Returns an array of disputes that are due within the specified number of days. @@ -142,10 +142,9 @@ export const getDisputeResolutionTask = ( ? sprintf( __( 'Respond today by %s', 'woocommerce-payments' ), // Show due_by time in local timezone: e.g. "11:59 PM". - dateI18n( - 'g:i A', - moment.utc( dispute.due_by ).local().toISOString() - ) + formatDateTimeFromString( dispute.due_by, { + customFormat: 'g:i A', + } ) ) : sprintf( __( @@ -153,11 +152,8 @@ export const getDisputeResolutionTask = ( 'woocommerce-payments' ), // Show due_by date in local timezone: e.g. "Jan 1, 2021". - dateI18n( - 'M j, Y', - moment.utc( dispute.due_by ).local().toISOString() - ), - moment( dispute.due_by ).fromNow( true ) // E.g. "2 days". + formatDateTimeFromString( dispute.due_by ), + moment.utc( dispute.due_by ).fromNow( true ) // E.g. "2 days". ); return disputeTask; diff --git a/client/overview/task-list/tasks/update-business-details-task.tsx b/client/overview/task-list/tasks/update-business-details-task.tsx index a18e06e9c09..61255a0e413 100644 --- a/client/overview/task-list/tasks/update-business-details-task.tsx +++ b/client/overview/task-list/tasks/update-business-details-task.tsx @@ -11,9 +11,8 @@ import { addQueryArgs } from '@wordpress/url'; */ import type { TaskItemProps } from '../types'; import UpdateBusinessDetailsModal from 'wcpay/overview/modal/update-business-details'; -import { dateI18n } from '@wordpress/date'; -import moment from 'moment'; import { recordEvent } from 'wcpay/tracks'; +import { formatDateTimeFromTimestamp } from 'wcpay/utils/date-time'; export const getUpdateBusinessDetailsTask = ( errorMessages: string[], @@ -46,10 +45,9 @@ export const getUpdateBusinessDetailsTask = ( 'Update by %s to avoid a disruption in payouts.', 'woocommerce-payments' ), - dateI18n( - 'ga M j, Y', - moment( currentDeadline * 1000 ).toISOString() - ) + formatDateTimeFromTimestamp( currentDeadline, { + customFormat: 'ga M j, Y', + } ) ); if ( hasSingleError ) { diff --git a/client/overview/task-list/test/tasks.js b/client/overview/task-list/test/tasks.js index 733d2208b08..9134a1c6842 100644 --- a/client/overview/task-list/test/tasks.js +++ b/client/overview/task-list/test/tasks.js @@ -139,6 +139,7 @@ describe( 'getTasks()', () => { precision: 2, }, }, + dateFormat: 'M j, Y', }; } ); afterEach( () => { diff --git a/client/payment-details/dispute-details/dispute-due-by-date.tsx b/client/payment-details/dispute-details/dispute-due-by-date.tsx index 18993ef2387..91255a4d786 100644 --- a/client/payment-details/dispute-details/dispute-due-by-date.tsx +++ b/client/payment-details/dispute-details/dispute-due-by-date.tsx @@ -2,22 +2,22 @@ * External dependencies */ import React from 'react'; -import { dateI18n } from '@wordpress/date'; import { __, _n, sprintf } from '@wordpress/i18n'; import classNames from 'classnames'; import moment from 'moment'; +import { formatDateTimeFromTimestamp } from 'wcpay/utils/date-time'; const DisputeDueByDate: React.FC< { dueBy: number; showRemainingDays?: boolean; } > = ( { dueBy, showRemainingDays = true } ) => { const daysRemaining = Math.floor( - moment.unix( dueBy ).diff( moment(), 'days', true ) - ); - const respondByDate = dateI18n( - 'M j, Y, g:ia', - moment( dueBy * 1000 ).toISOString() + moment.unix( dueBy ).utc().diff( moment().utc(), 'days', true ) ); + const respondByDate = formatDateTimeFromTimestamp( dueBy, { + separator: ', ', + includeTime: true, + } ); return ( { respondByDate } diff --git a/client/payment-details/dispute-details/dispute-resolution-footer.tsx b/client/payment-details/dispute-details/dispute-resolution-footer.tsx index 15fec759244..bc4b1e94dbd 100644 --- a/client/payment-details/dispute-details/dispute-resolution-footer.tsx +++ b/client/payment-details/dispute-details/dispute-resolution-footer.tsx @@ -2,8 +2,6 @@ * External dependencies */ import React from 'react'; -import moment from 'moment'; -import { dateI18n } from '@wordpress/date'; import { __, sprintf } from '@wordpress/i18n'; import { Link } from '@woocommerce/components'; import { createInterpolateElement } from '@wordpress/element'; @@ -17,18 +15,15 @@ import { recordEvent } from 'tracks'; import { getAdminUrl } from 'wcpay/utils'; import { getDisputeFeeFormatted } from 'wcpay/disputes/utils'; import './style.scss'; +import { formatDateTimeFromTimestamp } from 'wcpay/utils/date-time'; const DisputeUnderReviewFooter: React.FC< { dispute: Pick< Dispute, 'id' | 'metadata' | 'status' >; } > = ( { dispute } ) => { const submissionDateFormatted = dispute.metadata.__evidence_submitted_at - ? dateI18n( - 'M j, Y', - moment - .unix( - parseInt( dispute.metadata.__evidence_submitted_at, 10 ) - ) - .toISOString() + ? formatDateTimeFromTimestamp( + parseInt( dispute.metadata.__evidence_submitted_at, 10 ), + { includeTime: true } ) : '-'; @@ -93,13 +88,9 @@ const DisputeWonFooter: React.FC< { dispute: Pick< Dispute, 'id' | 'metadata' | 'status' >; } > = ( { dispute } ) => { const closedDateFormatted = dispute.metadata.__dispute_closed_at - ? dateI18n( - 'M j, Y', - moment - .unix( - parseInt( dispute.metadata.__dispute_closed_at, 10 ) - ) - .toISOString() + ? formatDateTimeFromTimestamp( + parseInt( dispute.metadata.__dispute_closed_at, 10 ), + { includeTime: true } ) : '-'; @@ -171,13 +162,8 @@ const DisputeLostFooter: React.FC< { const disputeFeeFormatted = getDisputeFeeFormatted( dispute, true ) ?? '-'; const closedDateFormatted = dispute.metadata.__dispute_closed_at - ? dateI18n( - 'M j, Y', - moment - .unix( - parseInt( dispute.metadata.__dispute_closed_at, 10 ) - ) - .toISOString() + ? formatDateTimeFromTimestamp( + parseInt( dispute.metadata.__dispute_closed_at, 10 ) ) : '-'; @@ -274,13 +260,8 @@ const InquiryUnderReviewFooter: React.FC< { dispute: Pick< Dispute, 'id' | 'metadata' | 'status' >; } > = ( { dispute } ) => { const submissionDateFormatted = dispute.metadata.__evidence_submitted_at - ? dateI18n( - 'M j, Y', - moment - .unix( - parseInt( dispute.metadata.__evidence_submitted_at, 10 ) - ) - .toISOString() + ? formatDateTimeFromTimestamp( + parseInt( dispute.metadata.__evidence_submitted_at, 10 ) ) : '-'; @@ -346,13 +327,8 @@ const InquiryClosedFooter: React.FC< { } > = ( { dispute } ) => { const isSubmitted = !! dispute.metadata.__evidence_submitted_at; const closedDateFormatted = dispute.metadata.__dispute_closed_at - ? dateI18n( - 'M j, Y', - moment - .unix( - parseInt( dispute.metadata.__dispute_closed_at, 10 ) - ) - .toISOString() + ? formatDateTimeFromTimestamp( + parseInt( dispute.metadata.__dispute_closed_at, 10 ) ) : '-'; diff --git a/client/payment-details/dispute-details/dispute-steps.tsx b/client/payment-details/dispute-details/dispute-steps.tsx index 0f90723a8f1..28707dfc6c4 100644 --- a/client/payment-details/dispute-details/dispute-steps.tsx +++ b/client/payment-details/dispute-details/dispute-steps.tsx @@ -7,8 +7,6 @@ import React from 'react'; import { __, sprintf } from '@wordpress/i18n'; import { createInterpolateElement } from '@wordpress/element'; import { ExternalLink } from '@wordpress/components'; -import { dateI18n } from '@wordpress/date'; -import moment from 'moment'; import HelpOutlineIcon from 'gridicons/dist/help-outline'; /** @@ -20,6 +18,7 @@ import { formatExplicitCurrency } from 'multi-currency/interface/functions'; import { ClickTooltip } from 'wcpay/components/tooltip'; import { getDisputeFeeFormatted } from 'wcpay/disputes/utils'; import DisputeDueByDate from './dispute-due-by-date'; +import { formatDateTimeFromTimestamp } from 'wcpay/utils/date-time'; interface Props { dispute: Dispute; @@ -35,14 +34,8 @@ export const DisputeSteps: React.FC< Props > = ( { } ) => { let emailLink; if ( customer?.email ) { - const chargeDate = dateI18n( - 'Y-m-d', - moment( chargeCreated * 1000 ).toISOString() - ); - const disputeDate = dateI18n( - 'Y-m-d', - moment( dispute.created * 1000 ).toISOString() - ); + const chargeDate = formatDateTimeFromTimestamp( chargeCreated ); + const disputeDate = formatDateTimeFromTimestamp( dispute.created ); const emailSubject = sprintf( // Translators: %1$s is the store name, %2$s is the charge date. __( @@ -175,14 +168,12 @@ export const InquirySteps: React.FC< Props > = ( { } ) => { let emailLink; if ( customer?.email ) { - const chargeDate = dateI18n( - 'Y-m-d', - moment( chargeCreated * 1000 ).toISOString() - ); - const disputeDate = dateI18n( - 'Y-m-d', - moment( dispute.created * 1000 ).toISOString() - ); + const chargeDate = formatDateTimeFromTimestamp( chargeCreated, { + includeTime: true, + } ); + const disputeDate = formatDateTimeFromTimestamp( dispute.created, { + includeTime: true, + } ); const emailSubject = sprintf( // Translators: %1$s is the store name, %2$s is the charge date. __( diff --git a/client/payment-details/dispute-details/dispute-summary-row.tsx b/client/payment-details/dispute-details/dispute-summary-row.tsx index 0a43cb223e0..95119d01f82 100644 --- a/client/payment-details/dispute-details/dispute-summary-row.tsx +++ b/client/payment-details/dispute-details/dispute-summary-row.tsx @@ -4,10 +4,8 @@ * External dependencies */ import React from 'react'; -import moment from 'moment'; import HelpOutlineIcon from 'gridicons/dist/help-outline'; import { __ } from '@wordpress/i18n'; -import { dateI18n } from '@wordpress/date'; /** * Internal dependencies @@ -20,6 +18,7 @@ import { formatStringValue } from 'wcpay/utils'; import { ClickTooltip } from 'wcpay/components/tooltip'; import Paragraphs from 'wcpay/components/paragraphs'; import DisputeDueByDate from './dispute-due-by-date'; +import { formatDateTimeFromTimestamp } from 'wcpay/utils/date-time'; interface Props { dispute: Dispute; @@ -39,10 +38,10 @@ const DisputeSummaryRow: React.FC< Props > = ( { dispute } ) => { { title: __( 'Disputed On', 'woocommerce-payments' ), content: dispute.created - ? dateI18n( - 'M j, Y, g:ia', - moment( dispute.created * 1000 ).toISOString() - ) + ? formatDateTimeFromTimestamp( dispute.created, { + separator: ', ', + includeTime: true, + } ) : '–', }, { diff --git a/client/payment-details/order-details/test/__snapshots__/index.test.tsx.snap b/client/payment-details/order-details/test/__snapshots__/index.test.tsx.snap index 7b4a7e3650f..ed6c9656178 100644 --- a/client/payment-details/order-details/test/__snapshots__/index.test.tsx.snap +++ b/client/payment-details/order-details/test/__snapshots__/index.test.tsx.snap @@ -6,6 +6,54 @@ exports[`Order details page should match the snapshot - Charge without payment i class="wcpay-payment-details woocommerce-payments-page" style="max-width: 1032px;" > +
+ +
+ The date and time formats now match your preferences. You can update them anytime in the + + settings + + . +
+ +
- Zip check + Postal code check

{ featureFlags: { paymentTimeline: true }, zeroDecimalCurrencies: [], connect: { country: 'US' }, + timeFormat: 'g:ia', + dateFormat: 'M j, Y', }; const selectMock = jest.fn( ( storeName ) => diff --git a/client/payment-details/payment-details/index.tsx b/client/payment-details/payment-details/index.tsx index cdf568520f2..a32d0c94193 100644 --- a/client/payment-details/payment-details/index.tsx +++ b/client/payment-details/payment-details/index.tsx @@ -17,7 +17,7 @@ import PaymentDetailsPaymentMethod from '../payment-method'; import { ApiError } from '../../types/errors'; import { Charge } from '../../types/charges'; import { PaymentIntent } from '../../types/payment-intents'; - +import DateFormatNotice from 'wcpay/components/date-format-notice'; interface PaymentDetailsProps { id: string; isLoading: boolean; @@ -56,6 +56,7 @@ const PaymentDetails: React.FC< PaymentDetailsProps > = ( { return ( + { diff --git a/client/payment-details/readers/index.js b/client/payment-details/readers/index.js index 193ee236288..c815f6d0dbb 100644 --- a/client/payment-details/readers/index.js +++ b/client/payment-details/readers/index.js @@ -23,7 +23,7 @@ import { formatExplicitCurrency, formatExportAmount, } from 'multi-currency/interface/functions'; - +import DateFormatNotice from 'wcpay/components/date-format-notice'; const PaymentCardReaderChargeDetails = ( props ) => { const { readers, chargeError, isLoading } = useCardReaderStats( props.chargeId, @@ -34,6 +34,7 @@ const PaymentCardReaderChargeDetails = ( props ) => { if ( ! isLoading && chargeError instanceof Error ) { return ( + diff --git a/client/payment-details/summary/index.tsx b/client/payment-details/summary/index.tsx index 1e5b6bfcac9..59ae556c785 100644 --- a/client/payment-details/summary/index.tsx +++ b/client/payment-details/summary/index.tsx @@ -4,7 +4,6 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { dateI18n } from '@wordpress/date'; import { Card, CardBody, @@ -64,6 +63,10 @@ import DisputeResolutionFooter from '../dispute-details/dispute-resolution-foote import ErrorBoundary from 'components/error-boundary'; import RefundModal from 'wcpay/payment-details/summary/refund-modal'; import CardNotice from 'wcpay/components/card-notice'; +import { + formatDateTimeFromString, + formatDateTimeFromTimestamp, +} from 'wcpay/utils/date-time'; declare const window: any; @@ -110,10 +113,10 @@ const composePaymentSummaryItems = ( { { title: __( 'Date', 'woocommerce-payments' ), content: charge.created - ? dateI18n( - 'M j, Y, g:ia', - moment( charge.created * 1000 ).toISOString() - ) + ? formatDateTimeFromTimestamp( charge.created, { + separator: ', ', + includeTime: true, + } ) : '–', }, { @@ -714,12 +717,13 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( { } ) }{ ' ' } diff --git a/client/payment-details/summary/test/__snapshots__/index.test.tsx.snap b/client/payment-details/summary/test/__snapshots__/index.test.tsx.snap index 083da902f05..6dacc01df87 100644 --- a/client/payment-details/summary/test/__snapshots__/index.test.tsx.snap +++ b/client/payment-details/summary/test/__snapshots__/index.test.tsx.snap @@ -304,7 +304,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders ca this charge within the next 7 days @@ -660,7 +660,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders th this charge within the next 7 days diff --git a/client/payment-details/summary/test/index.test.tsx b/client/payment-details/summary/test/index.test.tsx index 9055d481dda..8c47e9b9b6a 100755 --- a/client/payment-details/summary/test/index.test.tsx +++ b/client/payment-details/summary/test/index.test.tsx @@ -17,6 +17,15 @@ import PaymentDetailsSummary from '../'; import { useAuthorization } from 'wcpay/data'; import { paymentIntentMock } from 'wcpay/data/payment-intents/test/hooks'; +// Mock dateI18n +jest.mock( '@wordpress/date', () => ( { + dateI18n: jest.fn( ( format, date ) => { + return jest + .requireActual( '@wordpress/date' ) + .dateI18n( format, date, 'UTC' ); // Ensure UTC is used + } ), +} ) ); + declare const global: { wcSettings: { locale: { @@ -34,6 +43,8 @@ declare const global: { featureFlags: { isAuthAndCaptureEnabled: boolean; }; + dateFormat: string; + timeFormat: string; }; }; @@ -74,7 +85,7 @@ const getBaseCharge = (): Charge => id: 'ch_38jdHA39KKA', payment_intent: 'pi_abc', /* Stripe data comes in seconds, instead of the default Date milliseconds */ - created: Date.parse( 'Sep 19, 2019, 5:24 pm' ) / 1000, + created: 1568913840, amount: 2000, amount_refunded: 0, application_fee_amount: 70, @@ -203,6 +214,8 @@ describe( 'PaymentDetailsSummary', () => { precision: 0, }, }, + dateFormat: 'M j, Y', + timeFormat: 'g:ia', }; // mock Date.now that moment library uses to get current date for testing purposes @@ -408,7 +421,7 @@ describe( 'PaymentDetailsSummary', () => { ).toHaveTextContent( /\$20.00/ ); expect( screen.getByText( /Disputed On/i ).nextSibling - ).toHaveTextContent( /Aug 30, 2023/ ); + ).toHaveTextContent( /Aug 31, 2023/ ); expect( screen.getByText( /Reason/i ).nextSibling ).toHaveTextContent( /Transaction unauthorized/ ); diff --git a/client/payment-details/test/__snapshots__/index.test.tsx.snap b/client/payment-details/test/__snapshots__/index.test.tsx.snap index aa8a34effd0..feea948e950 100644 --- a/client/payment-details/test/__snapshots__/index.test.tsx.snap +++ b/client/payment-details/test/__snapshots__/index.test.tsx.snap @@ -6,6 +6,54 @@ exports[`Payment details page should match the snapshot - Charge query param 1`] class="wcpay-payment-details woocommerce-payments-page" style="max-width: 1032px;" > +

+ +
+ The date and time formats now match your preferences. You can update them anytime in the + + settings + + . +
+ +
- Zip check + Postal code check

+

+ +
+ The date and time formats now match your preferences. You can update them anytime in the + + settings + + . +
+ +
- Zip check + Postal code check

{ diff --git a/client/payment-details/timeline/map-events.js b/client/payment-details/timeline/map-events.js index da4e275105c..6e4f593e1f0 100644 --- a/client/payment-details/timeline/map-events.js +++ b/client/payment-details/timeline/map-events.js @@ -5,9 +5,7 @@ */ import { flatMap } from 'lodash'; import { __, sprintf } from '@wordpress/i18n'; -import { dateI18n } from '@wordpress/date'; import { addQueryArgs } from '@wordpress/url'; -import moment from 'moment'; import { createInterpolateElement } from '@wordpress/element'; import { Link } from '@woocommerce/components'; import SyncIcon from 'gridicons/dist/sync'; @@ -30,7 +28,8 @@ import { import { formatFee } from 'utils/fees'; import { getAdminUrl } from 'wcpay/utils'; import { ShieldIcon } from 'wcpay/icons'; -import { fraudOutcomeRulesetMapping } from './mappings'; +import { fraudOutcomeRulesetMapping, paymentFailureMapping } from './mappings'; +import { formatDateTimeFromTimestamp } from 'wcpay/utils/date-time'; /** * Creates a timeline item about a payment status change @@ -84,10 +83,7 @@ const getDepositTimelineItem = ( 'woocommerce-payments' ), formattedAmount, - dateI18n( - 'M j, Y', - moment( event.deposit.arrival_date * 1000 ).toISOString() - ) + formatDateTimeFromTimestamp( event.deposit.arrival_date ) ); const depositUrl = getAdminUrl( { page: 'wc-admin', @@ -143,10 +139,7 @@ const getFinancingPaydownTimelineItem = ( event, formattedAmount, body ) => { 'woocommerce-payments' ), formattedAmount, - dateI18n( - 'M j, Y', - moment( event.deposit.arrival_date * 1000 ).toISOString() - ) + formatDateTimeFromTimestamp( event.deposit.arrival_date ) ); const depositUrl = getAdminUrl( { @@ -410,12 +403,12 @@ export const feeBreakdown = ( event ) => { fixedRate !== 0 ? __( /* translators: %1$s% is the fee percentage and %2$s is the fixed rate */ - 'Foreign exchange fee: %1$s%% + %2$s', + 'Currency conversion fee: %1$s%% + %2$s', 'woocommerce-payments' ) : __( /* translators: %1$s% is the fee percentage */ - 'Foreign exchange fee: %1$s%%', + 'Currency conversion fee: %1$s%%', 'woocommerce-payments' ), 'additional-wcpay-subscription': @@ -772,6 +765,10 @@ const mapEventToTimelineItems = ( event ) => { ), ]; case 'failed': + const paymentFailureMessage = + paymentFailureMapping[ event.reason ] || + paymentFailureMapping.default; + return [ getStatusChangeTimelineItem( event, @@ -779,11 +776,14 @@ const mapEventToTimelineItems = ( event ) => { ), getMainTimelineItem( event, - stringWithAmount( - /* translators: %s is a monetary amount */ - __( 'A payment of %s failed.', 'woocommerce-payments' ), - event.amount, - true + sprintf( + /* translators: %1$s is the payment amount, %2$s is the failure reason message */ + __( + 'A payment of %1$s failed: %2$s.', + 'woocommerce-payments' + ), + formatExplicitCurrency( event.amount, event.currency ), + paymentFailureMessage ), ), diff --git a/client/payment-details/timeline/mappings.ts b/client/payment-details/timeline/mappings.ts index 80d2aceaa98..affcb62063a 100644 --- a/client/payment-details/timeline/mappings.ts +++ b/client/payment-details/timeline/mappings.ts @@ -65,3 +65,46 @@ export const fraudOutcomeRulesetMapping = { ), }, }; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const paymentFailureMapping = { + card_declined: __( + 'The card was declined by the bank', + 'woocommerce-payments' + ), + expired_card: __( 'The card has expired', 'woocommerce-payments' ), + incorrect_cvc: __( + 'The security code is incorrect', + 'woocommerce-payments' + ), + incorrect_number: __( + 'The card number is incorrect', + 'woocommerce-payments' + ), + incorrect_zip: __( 'The postal code is incorrect', 'woocommerce-payments' ), + invalid_cvc: __( 'The security code is invalid', 'woocommerce-payments' ), + invalid_expiry_month: __( + 'The expiration month is invalid', + 'woocommerce-payments' + ), + invalid_expiry_year: __( + 'The expiration year is invalid', + 'woocommerce-payments' + ), + invalid_number: __( 'The card number is invalid', 'woocommerce-payments' ), + processing_error: __( + 'An error occurred while processing the card', + 'woocommerce-payments' + ), + authentication_required: __( + 'The payment requires authentication', + 'woocommerce-payments' + ), + insufficient_funds: __( + 'The card has insufficient funds to complete the purchase', + 'woocommerce-payments' + ), + + // Default fallback + default: __( 'The payment was declined', 'woocommerce-payments' ), +}; diff --git a/client/payment-details/timeline/test/__snapshots__/index.js.snap b/client/payment-details/timeline/test/__snapshots__/index.js.snap index 95f1e8a5c9f..c7915e6aee9 100644 --- a/client/payment-details/timeline/test/__snapshots__/index.js.snap +++ b/client/payment-details/timeline/test/__snapshots__/index.js.snap @@ -933,7 +933,7 @@ exports[`PaymentDetailsTimeline renders correctly (with a mocked Timeline compon - A payment of $77.00 failed. + A payment of $77.00 failed: The card was declined by the bank.

, @@ -397,7 +397,7 @@ exports[`mapTimelineEvents single currency events formats captured events with f International card fee: 1.5%
  • - Foreign exchange fee: 2% + Currency conversion fee: 2%
  • Discount diff --git a/client/payment-details/timeline/test/index.js b/client/payment-details/timeline/test/index.js index 2529f3d673e..616c780dd69 100644 --- a/client/payment-details/timeline/test/index.js +++ b/client/payment-details/timeline/test/index.js @@ -34,6 +34,7 @@ describe( 'PaymentDetailsTimeline', () => { precision: 2, }, }, + dateFormat: 'M j, Y', }; } ); diff --git a/client/payment-details/timeline/test/map-events.js b/client/payment-details/timeline/test/map-events.js index c3e42ceae8b..beee5dc959b 100644 --- a/client/payment-details/timeline/test/map-events.js +++ b/client/payment-details/timeline/test/map-events.js @@ -47,6 +47,7 @@ describe( 'mapTimelineEvents', () => { precision: 2, }, }, + dateFormat: 'M j, Y', }; } ); @@ -662,4 +663,59 @@ describe( 'mapTimelineEvents', () => { ).toMatchSnapshot(); } ); } ); + + test( 'formats payment failure events with different error codes', () => { + const testCases = [ + { + reason: 'insufficient_funds', + expectedMessage: + 'A payment of $77.00 USD failed: The card has insufficient funds to complete the purchase.', + }, + { + reason: 'expired_card', + expectedMessage: + 'A payment of $77.00 USD failed: The card has expired.', + }, + { + reason: 'invalid_cvc', + expectedMessage: + 'A payment of $77.00 USD failed: The security code is invalid.', + }, + { + reason: 'unknown_reason', + expectedMessage: + 'A payment of $77.00 USD failed: The payment was declined.', + }, + ]; + + testCases.forEach( ( { reason, expectedMessage } ) => { + const events = mapTimelineEvents( [ + { + amount: 7700, + currency: 'USD', + datetime: 1585712113, + reason, + type: 'failed', + }, + ] ); + + expect( events[ 1 ].headline ).toBe( expectedMessage ); + } ); + } ); + + test( 'formats payment failure events with different currencies', () => { + const events = mapTimelineEvents( [ + { + amount: 7700, + currency: 'EUR', + datetime: 1585712113, + reason: 'card_declined', + type: 'failed', + }, + ] ); + + expect( events[ 1 ].headline ).toBe( + 'A payment of €77.00 EUR failed: The card was declined by the bank.' + ); + } ); } ); diff --git a/client/settings/express-checkout-settings/index.scss b/client/settings/express-checkout-settings/index.scss index f57e1e973e7..7b6bd1ca1fe 100644 --- a/client/settings/express-checkout-settings/index.scss +++ b/client/settings/express-checkout-settings/index.scss @@ -58,6 +58,18 @@ } .woopay-settings { + &__badge { + background-color: $studio-gray-5; + padding: 0 8px; + border-radius: 2px; + font-size: 12px; + line-height: 20px; + margin-left: 4px; + } + &__global-theme-label { + display: inline-flex; + align-items: center; + } &__custom-message-wrapper { position: relative; diff --git a/client/settings/express-checkout-settings/woopay-settings.js b/client/settings/express-checkout-settings/woopay-settings.js index f2a4931f9f6..6f5b16baece 100644 --- a/client/settings/express-checkout-settings/woopay-settings.js +++ b/client/settings/express-checkout-settings/woopay-settings.js @@ -226,10 +226,17 @@ const WooPaySettings = ( { section } ) => { onChange={ updateIsWooPayGlobalThemeSupportEnabled } - label={ __( - 'Enable global theme support', - 'woocommerce-payments' - ) } + label={ +
    + { __( + 'Enable global theme support', + 'woocommerce-payments' + ) } + + Beta + +
    + } help={ interpolateComponents( { mixedString: __( 'When enabled, WooPay checkout will be themed with your store’s brand colors and fonts. ' + diff --git a/client/style.scss b/client/style.scss index 2e4b8312271..24c8dfa12ed 100644 --- a/client/style.scss +++ b/client/style.scss @@ -104,6 +104,10 @@ font-weight: normal; line-height: 20px; padding: 4px 0; + + @media screen and ( max-width: 470px ) { + flex: 0 0 40%; + } } &__value { diff --git a/client/tokenized-express-checkout/compatibility/__tests__/wc-product-bundles.test.js b/client/tokenized-express-checkout/compatibility/__tests__/wc-product-bundles.test.js new file mode 100644 index 00000000000..91009ab1a25 --- /dev/null +++ b/client/tokenized-express-checkout/compatibility/__tests__/wc-product-bundles.test.js @@ -0,0 +1,253 @@ +/** + * External dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import '../wc-product-bundles'; + +describe( 'ECE product bundles compatibility', () => { + it( 'filters out cart items that are bundled by something else', () => { + const cartData = applyFilters( + 'wcpay.express-checkout.map-line-items', + { + items: [ + { + key: 'd179a6924eafc82d7864f1e0caedbe95', + id: 261, + type: 'bundle', + quantity: 1, + item_data: [ + { + key: 'Includes', + value: 'T-Shirt × 1', + }, + { + key: 'Includes', + value: 'T-Shirt with Logo × 2', + }, + { + key: 'Includes', + value: 'V-Neck T-Shirt - Medium × 1', + }, + ], + extensions: { + bundles: { + bundled_items: [ + 'abda15f782e68dc63bd615d6a05fa3d2', + '4d16fa6ebc10a1d66013b0f85640eb2b', + 'ff279cc5574ef1cf45aa76bde0d66baa', + ], + bundle_data: { + configuration: { + '1': { + product_id: 13, + quantity: 1, + discount: 20, + optional_selected: 'yes', + }, + '2': { + product_id: 30, + quantity: 2, + discount: '', + }, + '3': { + product_id: 10, + quantity: 1, + discount: '', + attributes: { + attribute_size: 'Medium', + }, + variation_id: '25', + }, + '4': { + product_id: 10, + quantity: 0, + discount: '', + optional_selected: 'no', + attributes: [], + }, + }, + is_editable: false, + is_price_hidden: false, + is_subtotal_hidden: false, + is_hidden: false, + is_meta_hidden_in_cart: true, + is_meta_hidden_in_summary: false, + }, + }, + }, + }, + { + key: 'abda15f782e68dc63bd615d6a05fa3d2', + id: 13, + type: 'simple', + quantity: 1, + extensions: { + bundles: { + bundled_by: 'd179a6924eafc82d7864f1e0caedbe95', + bundled_item_data: { + bundle_id: 261, + bundled_item_id: 1, + is_removable: true, + is_indented: true, + is_subtotal_aggregated: true, + is_parent_visible: true, + is_last: false, + is_price_hidden: false, + is_subtotal_hidden: false, + is_thumbnail_hidden: false, + is_hidden_in_cart: false, + is_hidden_in_summary: true, + }, + }, + }, + }, + { + key: '4d16fa6ebc10a1d66013b0f85640eb2b', + id: 30, + type: 'simple', + quantity: 2, + extensions: { + bundles: { + bundled_by: 'd179a6924eafc82d7864f1e0caedbe95', + bundled_item_data: { + bundle_id: 261, + bundled_item_id: 2, + is_removable: false, + is_indented: true, + is_subtotal_aggregated: true, + is_parent_visible: true, + is_last: false, + is_price_hidden: true, + is_subtotal_hidden: true, + is_thumbnail_hidden: false, + is_hidden_in_cart: false, + is_hidden_in_summary: true, + }, + }, + }, + }, + { + key: 'ff279cc5574ef1cf45aa76bde0d66baa', + id: 25, + type: 'variation', + quantity: 1, + extensions: { + bundles: { + bundled_by: 'd179a6924eafc82d7864f1e0caedbe95', + bundled_item_data: { + bundle_id: 261, + bundled_item_id: 3, + is_removable: false, + is_indented: true, + is_subtotal_aggregated: true, + is_parent_visible: true, + is_last: true, + is_price_hidden: true, + is_subtotal_hidden: true, + is_thumbnail_hidden: false, + is_hidden_in_cart: false, + is_hidden_in_summary: true, + }, + }, + }, + }, + { + key: 'c51ce410c124a10e0db5e4b97fc2af39', + id: 13, + type: 'simple', + quantity: 1, + extensions: { + bundles: [], + }, + }, + ], + items_count: 2, + } + ); + + expect( cartData ).toStrictEqual( { + items: [ + { + extensions: { + bundles: { + bundle_data: { + configuration: { + '1': { + discount: 20, + optional_selected: 'yes', + product_id: 13, + quantity: 1, + }, + '2': { + discount: '', + product_id: 30, + quantity: 2, + }, + '3': { + attributes: { + attribute_size: 'Medium', + }, + discount: '', + product_id: 10, + quantity: 1, + variation_id: '25', + }, + '4': { + attributes: [], + discount: '', + optional_selected: 'no', + product_id: 10, + quantity: 0, + }, + }, + is_editable: false, + is_hidden: false, + is_meta_hidden_in_cart: true, + is_meta_hidden_in_summary: false, + is_price_hidden: false, + is_subtotal_hidden: false, + }, + bundled_items: [ + 'abda15f782e68dc63bd615d6a05fa3d2', + '4d16fa6ebc10a1d66013b0f85640eb2b', + 'ff279cc5574ef1cf45aa76bde0d66baa', + ], + }, + }, + id: 261, + item_data: [ + { + key: 'Includes', + value: 'T-Shirt × 1', + }, + { + key: 'Includes', + value: 'T-Shirt with Logo × 2', + }, + { + key: 'Includes', + value: 'V-Neck T-Shirt - Medium × 1', + }, + ], + key: 'd179a6924eafc82d7864f1e0caedbe95', + quantity: 1, + type: 'bundle', + }, + { + extensions: { + bundles: [], + }, + id: 13, + key: 'c51ce410c124a10e0db5e4b97fc2af39', + quantity: 1, + type: 'simple', + }, + ], + items_count: 2, + } ); + } ); +} ); diff --git a/client/tokenized-express-checkout/compatibility/wc-product-bundles.js b/client/tokenized-express-checkout/compatibility/wc-product-bundles.js new file mode 100644 index 00000000000..7a3d2a4dc3c --- /dev/null +++ b/client/tokenized-express-checkout/compatibility/wc-product-bundles.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { addFilter } from '@wordpress/hooks'; + +addFilter( + 'wcpay.express-checkout.map-line-items', + 'automattic/wcpay/express-checkout', + ( cartData ) => { + return { + ...cartData, + // ensuring that the items that are bundled by another don't appear in the summary. + // otherwise they might be contributing to the wrong order total, creating errors. + items: cartData.items.filter( + ( item ) => ! item.extensions?.bundles?.bundled_by + ), + }; + } +); diff --git a/client/tokenized-express-checkout/event-handlers.js b/client/tokenized-express-checkout/event-handlers.js index 12ec2513cc7..db2bc4c2c3e 100644 --- a/client/tokenized-express-checkout/event-handlers.js +++ b/client/tokenized-express-checkout/event-handlers.js @@ -8,7 +8,11 @@ import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies */ -import { getErrorMessageFromNotice, getExpressCheckoutData } from './utils'; +import { + getErrorMessageFromNotice, + getExpressCheckoutData, + updateShippingAddressUI, +} from './utils'; import { trackExpressCheckoutButtonClick, trackExpressCheckoutButtonLoad, @@ -24,6 +28,7 @@ import { transformPrice, } from './transformers/wc-to-stripe'; +let lastSelectedAddress = null; let cartApi = new ExpressCheckoutCartApi(); export const setCartApiHandler = ( handler ) => ( cartApi = handler ); export const getCartApiHandler = () => cartApi; @@ -56,6 +61,9 @@ export const shippingAddressChangeHandler = async ( event, elements ) => { cartData.totals ), } ); + + lastSelectedAddress = event.address; + event.resolve( { shippingRates: transformCartDataForShippingRates( cartData ), lineItems: transformCartDataForDisplayItems( cartData ), @@ -216,5 +224,9 @@ export const onCompletePaymentHandler = () => { }; export const onCancelHandler = () => { + if ( lastSelectedAddress ) { + updateShippingAddressUI( lastSelectedAddress ); + } + lastSelectedAddress = null; unblockUI(); }; diff --git a/client/tokenized-express-checkout/index.js b/client/tokenized-express-checkout/index.js index eca8a2f8a36..e6c24d02e91 100644 --- a/client/tokenized-express-checkout/index.js +++ b/client/tokenized-express-checkout/index.js @@ -13,6 +13,7 @@ import '../checkout/express-checkout-buttons.scss'; import './compatibility/wc-deposits'; import './compatibility/wc-order-attribution'; import './compatibility/wc-product-page'; +import './compatibility/wc-product-bundles'; import { getExpressCheckoutButtonAppearance, getExpressCheckoutButtonStyleSettings, @@ -326,6 +327,21 @@ jQuery( ( $ ) => { 'wcpay.express-checkout.update-button-data', 'automattic/wcpay/express-checkout', async () => { + // if the product cannot be added to cart (because of missing variation selection, etc), + // don't try to add it to the cart to get new data - the call will likely fail. + if ( + getExpressCheckoutData( 'button_context' ) === 'product' + ) { + const addToCartButton = $( + '.single_add_to_cart_button' + ); + + // First check if product can be added to cart. + if ( addToCartButton.is( '.disabled' ) ) { + return; + } + } + try { expressCheckoutButtonUi.blockButton(); @@ -375,7 +391,7 @@ jQuery( ( $ ) => { expressCheckoutButtonUi.unblockButton(); } catch ( e ) { - expressCheckoutButtonUi.hide(); + expressCheckoutButtonUi.hideContainer(); } } ); diff --git a/client/tokenized-express-checkout/transformers/wc-to-stripe.js b/client/tokenized-express-checkout/transformers/wc-to-stripe.js index 867a389006b..794fc5309b1 100644 --- a/client/tokenized-express-checkout/transformers/wc-to-stripe.js +++ b/client/tokenized-express-checkout/transformers/wc-to-stripe.js @@ -8,6 +8,7 @@ import { decodeEntities } from '@wordpress/html-entities'; * Internal dependencies */ import { getExpressCheckoutData } from '../utils'; +import { applyFilters } from '@wordpress/hooks'; /** * GooglePay/ApplePay expect the prices to be formatted in cents. @@ -34,14 +35,20 @@ export const transformPrice = ( price, priceObject ) => { * - https://docs.stripe.com/js/elements_object/express_checkout_element_shippingaddresschange_event * - https://docs.stripe.com/js/elements_object/express_checkout_element_shippingratechange_event * - * @param {Object} cartData Store API Cart response object. + * @param {Object} rawCartData Store API Cart response object. * @return {{pending: boolean, name: string, amount: integer}} `displayItems` for Stripe. */ -export const transformCartDataForDisplayItems = ( cartData ) => { +export const transformCartDataForDisplayItems = ( rawCartData ) => { + // allowing extensions to manipulate the individual items returned by the backend. + const cartData = applyFilters( + 'wcpay.express-checkout.map-line-items', + rawCartData + ); + const displayItems = cartData.items.map( ( item ) => ( { amount: transformPrice( - parseInt( item.prices.price, 10 ), - item.prices + parseInt( item.totals?.line_subtotal || item.prices.price, 10 ), + item.totals || item.prices ), name: [ item.name, @@ -96,7 +103,7 @@ export const transformCartDataForDisplayItems = ( cartData ) => { * @return {{id: string, label: string, amount: integer, deliveryEstimate: string}} `shippingRates` for Stripe. */ export const transformCartDataForShippingRates = ( cartData ) => - cartData.shipping_rates?.[ 0 ].shipping_rates + cartData.shipping_rates?.[ 0 ]?.shipping_rates .sort( ( rateA, rateB ) => { if ( rateA.selected === rateB.selected ) { return 0; // Keep relative order if both have the same value for 'selected' diff --git a/client/tokenized-express-checkout/utils/index.ts b/client/tokenized-express-checkout/utils/index.ts index 98ee8b90091..bae962363b2 100644 --- a/client/tokenized-express-checkout/utils/index.ts +++ b/client/tokenized-express-checkout/utils/index.ts @@ -3,6 +3,7 @@ */ import { WCPayExpressCheckoutParams } from 'wcpay/express-checkout/utils'; export * from './normalize'; +export * from './shipping-fields'; import { getDefaultBorderRadius } from 'wcpay/utils/express-checkout'; export const getExpressCheckoutData = < diff --git a/client/tokenized-express-checkout/utils/shipping-fields.js b/client/tokenized-express-checkout/utils/shipping-fields.js new file mode 100644 index 00000000000..f097b1eca59 --- /dev/null +++ b/client/tokenized-express-checkout/utils/shipping-fields.js @@ -0,0 +1,131 @@ +/* global jQuery */ +/** + * Internal dependencies + */ +import { normalizeShippingAddress, getExpressCheckoutData } from '.'; + +/** + * Checks if the intermediate address is redacted for the given country. + * CA and GB addresses are redacted and are causing errors until WooCommerce is able to + * handle redacted addresses. + * https://developers.google.com/pay/api/web/reference/response-objects#IntermediateAddress + * + * @param {string} country - The country code. + * + * @return {boolean} True if the postcode is redacted for the country, false otherwise. + */ +const isPostcodeRedactedForCountry = ( country ) => { + return [ 'CA', 'GB' ].includes( country ); +}; + +/* + * Updates a field in a form with a new value. + * + * @param {String} formSelector - The selector for the form containing the field. + * @param {Object} fieldName - The name of the field to update. + * @param {Object} value - The new value for the field. + */ +const updateShortcodeField = ( formSelector, fieldName, value ) => { + const field = document.querySelector( + `${ formSelector } [name="${ fieldName }"]` + ); + + if ( ! field ) return; + + // Check if the field is a dropdown (country/state). + if ( field.tagName === 'SELECT' && /country|state/.test( fieldName ) ) { + const options = Array.from( field.options ); + const match = options.find( + ( opt ) => + opt.value === value || + opt.textContent.trim().toLowerCase() === value.toLowerCase() + ); + + if ( match ) { + field.value = match.value; + jQuery( field ).trigger( 'change' ).trigger( 'close' ); + } + } else { + // Default behavior for text inputs. + field.value = value; + jQuery( field ).trigger( 'change' ); + } +}; + +/** + * Updates the WooCommerce Blocks shipping UI to reflect a new shipping address. + * + * @param {Object} eventAddress - The shipping address returned by the payment event. + */ +const updateBlocksShippingUI = ( eventAddress ) => { + wp?.data + ?.dispatch( 'wc/store/cart' ) + ?.setShippingAddress( normalizeShippingAddress( eventAddress ) ); +}; + +/** + * Updates the WooCommerce shortcode cart/checkout shipping UI to reflect a new shipping address. + * + * @param {Object} eventAddress - The shipping address returned by the payment event. + */ +const updateShortcodeShippingUI = ( eventAddress ) => { + const context = getExpressCheckoutData( 'button_context' ); + const address = normalizeShippingAddress( eventAddress ); + + const keys = [ 'country', 'state', 'city', 'postcode' ]; + + if ( context === 'cart' ) { + keys.forEach( ( key ) => { + if ( address[ key ] ) { + updateShortcodeField( + 'form.woocommerce-shipping-calculator', + `calc_shipping_${ key }`, + address[ key ] + ); + } + } ); + document + .querySelector( + 'form.woocommerce-shipping-calculator [name="calc_shipping"]' + ) + ?.click(); + } else if ( context === 'checkout' ) { + keys.forEach( ( key ) => { + if ( address[ key ] ) { + updateShortcodeField( + 'form.woocommerce-checkout', + `billing_${ key }`, + address[ key ] + ); + } + } ); + } +}; + +/** + * Updates the WooCommerce shipping UI to reflect a new shipping address. + * + * Determines the current context (cart or checkout) and updates either + * WooCommerce Blocks or shortcode-based shipping forms, if applicable. + * + * @param {Object} newAddress - The new shipping address object returned by the payment event. + * @param {string} newAddress.country - The country code of the shipping address. + * @param {string} [newAddress.state] - The state/province of the shipping address. + * @param {string} [newAddress.city] - The city of the shipping address. + * @param {string} [newAddress.postcode] - The postal/ZIP code of the shipping address. + */ +export const updateShippingAddressUI = ( newAddress ) => { + const context = getExpressCheckoutData( 'button_context' ); + const isBlocks = getExpressCheckoutData( 'has_block' ); + + if ( + [ 'cart', 'checkout' ].includes( context ) && + ! isPostcodeRedactedForCountry( newAddress.country ) + ) { + if ( isBlocks ) { + updateBlocksShippingUI( newAddress ); + } else { + updateShortcodeShippingUI( newAddress ); + } + } +}; diff --git a/client/transactions/blocked/columns.tsx b/client/transactions/blocked/columns.tsx index 1d75e407cea..8e4f410ff32 100644 --- a/client/transactions/blocked/columns.tsx +++ b/client/transactions/blocked/columns.tsx @@ -2,9 +2,7 @@ * External dependencies */ import React from 'react'; -import { dateI18n } from '@wordpress/date'; import { __ } from '@wordpress/i18n'; -import moment from 'moment'; import { TableCardColumn, TableCardBodyColumn } from '@woocommerce/components'; /** @@ -15,6 +13,7 @@ import TransactionStatusPill from 'wcpay/components/transaction-status-pill'; import { FraudOutcomeTransaction } from '../../data'; import { getDetailsURL } from '../../components/details-link'; import ClickableCell from '../../components/clickable-cell'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; interface Column extends TableCardColumn { key: 'created' | 'amount' | 'customer' | 'status'; @@ -70,10 +69,9 @@ export const getBlockedListRowContent = ( data.payment_intent.id || data.order_id.toString(), 'transactions' ); - const formattedCreatedDate = dateI18n( - 'M j, Y / g:iA', - moment.utc( data.created ).local().toISOString() - ); + const formattedCreatedDate = formatDateTimeFromString( data.created, { + includeTime: true, + } ); const clickable = ( children: JSX.Element | string ) => ( { children } diff --git a/client/transactions/blocked/test/columns.test.tsx b/client/transactions/blocked/test/columns.test.tsx index 7ca2c3d4895..b65e2d10c12 100644 --- a/client/transactions/blocked/test/columns.test.tsx +++ b/client/transactions/blocked/test/columns.test.tsx @@ -15,6 +15,8 @@ declare const global: { connect: { country: string; }; + dateFormat: string; + timeFormat: string; }; }; const mockWcPaySettings = { @@ -23,6 +25,8 @@ const mockWcPaySettings = { connect: { country: 'US', }, + dateFormat: 'M j, Y', + timeFormat: 'g:iA', }; describe( 'Blocked fraud outcome transactions columns', () => { diff --git a/client/transactions/index.tsx b/client/transactions/index.tsx index 507a972cd5a..f8139f16797 100644 --- a/client/transactions/index.tsx +++ b/client/transactions/index.tsx @@ -23,6 +23,7 @@ import { } from 'wcpay/data'; import WCPaySettingsContext from '../settings/wcpay-settings-context'; import BlockedList from './blocked'; +import DateFormatNotice from 'components/date-format-notice'; declare const window: any; @@ -106,6 +107,7 @@ export const TransactionsPage: React.FC = () => { return ( + = ( { depositId, dateAvailable } ) => { id: depositId, } ); - const formattedDateAvailable = dateI18n( - 'M j, Y', - moment.utc( dateAvailable ).toISOString(), - true // TODO Change call to gmdateI18n and remove this deprecated param once WP 5.4 support ends. + const formattedDateAvailable = formatDateTimeFromString( + dateAvailable ); - return { formattedDateAvailable }; } diff --git a/client/transactions/list/index.tsx b/client/transactions/list/index.tsx index a5206ae0e5e..a4d9abc852a 100644 --- a/client/transactions/list/index.tsx +++ b/client/transactions/list/index.tsx @@ -7,9 +7,7 @@ import React, { Fragment, useState } from 'react'; import { uniq } from 'lodash'; import { useDispatch } from '@wordpress/data'; import { useMemo } from '@wordpress/element'; -import { dateI18n } from '@wordpress/date'; import { __, _n, sprintf } from '@wordpress/i18n'; -import moment from 'moment'; import { TableCard, Search, @@ -70,6 +68,7 @@ import p24BankList from '../../payment-details/payment-method/p24/bank-list'; import { HoverTooltip } from 'components/tooltip'; import { PAYMENT_METHOD_TITLES } from 'wcpay/constants/payment-method'; import { ReportingExportLanguageHook } from 'wcpay/settings/reporting-settings/interfaces'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; interface TransactionsListProps { depositId?: string; @@ -466,10 +465,9 @@ export const TransactionsList = ( date: { value: txn.date, display: clickable( - dateI18n( - 'M j, Y / g:iA', - moment.utc( txn.date ).local().toISOString() - ) + formatDateTimeFromString( txn.date, { + includeTime: true, + } ) ), }, channel: { diff --git a/client/transactions/list/test/__snapshots__/index.tsx.snap b/client/transactions/list/test/__snapshots__/index.tsx.snap index 7a4b1eb3392..c432444b3ea 100644 --- a/client/transactions/list/test/__snapshots__/index.tsx.snap +++ b/client/transactions/list/test/__snapshots__/index.tsx.snap @@ -493,7 +493,7 @@ exports[`Transactions list renders correctly when can filter by several currenci href="admin.php?page=wc-admin&path=%2Fpayments%2Ftransactions%2Fdetails&id=pi_mock&transaction_id=txn_j23jda9JJa&transaction_type=refund" tabindex="-1" > - Jan 2, 2020 / 12:46PM + Jan 2, 2020 / 5:46PM - Jan 4, 2020 / 11:22PM + Jan 5, 2020 / 4:22AM - Jan 2, 2020 / 2:55PM + Jan 2, 2020 / 7:55PM - Jan 2, 2020 / 12:46PM + Jan 2, 2020 / 5:46PM - Jan 4, 2020 / 11:22PM + Jan 5, 2020 / 4:22AM - Jan 2, 2020 / 2:55PM + Jan 2, 2020 / 7:55PM - Jan 4, 2020 / 11:22PM + Jan 5, 2020 / 4:22AM - Jan 2, 2020 / 12:46PM + Jan 2, 2020 / 5:46PM - Jan 4, 2020 / 11:22PM + Jan 5, 2020 / 4:22AM - Jan 2, 2020 / 2:55PM + Jan 2, 2020 / 7:55PM - Jan 2, 2020 / 12:46PM + Jan 2, 2020 / 5:46PM - Jan 4, 2020 / 11:22PM + Jan 5, 2020 / 4:22AM - Jan 2, 2020 / 2:55PM + Jan 2, 2020 / 7:55PM - Jan 2, 2020 / 12:46PM + Jan 2, 2020 / 5:46PM - Jan 4, 2020 / 11:22PM + Jan 5, 2020 / 4:22AM - Jan 2, 2020 / 2:55PM + Jan 2, 2020 / 7:55PM ( { + dateI18n: jest.fn( ( format, date ) => { + return jest + .requireActual( '@wordpress/date' ) + .dateI18n( format, date, 'UTC' ); // Ensure UTC is used + } ), +} ) ); + describe( 'Deposit', () => { + beforeEach( () => { + // Mock the window.wcpaySettings property + window.wcpaySettings.dateFormat = 'M j, Y'; + window.wcpaySettings.timeFormat = 'g:i a'; + } ); + + afterEach( () => { + // Reset the mock + jest.clearAllMocks(); + } ); + test( 'renders with date and payout available', () => { const { container: link } = render( diff --git a/client/transactions/list/test/index.tsx b/client/transactions/list/test/index.tsx index b233b4d5477..66db0268a34 100644 --- a/client/transactions/list/test/index.tsx +++ b/client/transactions/list/test/index.tsx @@ -58,6 +58,15 @@ jest.mock( 'data/index', () => ( { useReportingExportLanguage: jest.fn( () => [ 'en', jest.fn() ] ), } ) ); +// Mock dateI18n +jest.mock( '@wordpress/date', () => ( { + dateI18n: jest.fn( ( format, date ) => { + return jest + .requireActual( '@wordpress/date' ) + .dateI18n( format, date, 'UTC' ); // Ensure UTC is used + } ), +} ) ); + const mockDownloadCSVFile = downloadCSVFile as jest.MockedFunction< typeof downloadCSVFile >; @@ -244,6 +253,8 @@ describe( 'Transactions list', () => { exportModalDismissed: true, }, }; + window.wcpaySettings.dateFormat = 'M j, Y'; + window.wcpaySettings.timeFormat = 'g:iA'; } ); test( 'renders correctly when filtered by payout', () => { diff --git a/client/transactions/risk-review/columns.tsx b/client/transactions/risk-review/columns.tsx index d7f5de95111..c1e24c4d428 100644 --- a/client/transactions/risk-review/columns.tsx +++ b/client/transactions/risk-review/columns.tsx @@ -2,9 +2,7 @@ * External dependencies */ import React from 'react'; -import { dateI18n } from '@wordpress/date'; import { __ } from '@wordpress/i18n'; -import moment from 'moment'; import { TableCardColumn, TableCardBodyColumn } from '@woocommerce/components'; import { Button } from '@wordpress/components'; @@ -17,6 +15,7 @@ import { formatExplicitCurrency } from 'multi-currency/interface/functions'; import { recordEvent } from 'tracks'; import TransactionStatusPill from 'wcpay/components/transaction-status-pill'; import { FraudOutcomeTransaction } from '../../data'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; interface Column extends TableCardColumn { key: 'created' | 'amount' | 'customer' | 'status'; @@ -76,10 +75,9 @@ export const getRiskReviewListRowContent = ( data: FraudOutcomeTransaction ): Record< string, TableCardBodyColumn > => { const detailsURL = getDetailsURL( data.payment_intent.id, 'transactions' ); - const formattedCreatedDate = dateI18n( - 'M j, Y / g:iA', - moment.utc( data.created ).local().toISOString() - ); + const formattedCreatedDate = formatDateTimeFromString( data.created, { + includeTime: true, + } ); const clickable = ( children: JSX.Element | string ) => ( { children } diff --git a/client/transactions/risk-review/test/columns.test.tsx b/client/transactions/risk-review/test/columns.test.tsx index 033f9eb7aa2..54de107e0e4 100644 --- a/client/transactions/risk-review/test/columns.test.tsx +++ b/client/transactions/risk-review/test/columns.test.tsx @@ -15,6 +15,8 @@ declare const global: { connect: { country: string; }; + dateFormat: string; + timeFormat: string; }; }; const mockWcPaySettings = { @@ -23,6 +25,8 @@ const mockWcPaySettings = { connect: { country: 'US', }, + dateFormat: 'M j, Y', + timeFormat: 'g:iA', }; describe( 'Review fraud outcome transactions columns', () => { diff --git a/client/transactions/uncaptured/index.tsx b/client/transactions/uncaptured/index.tsx index 17058760c19..42d6d69542b 100644 --- a/client/transactions/uncaptured/index.tsx +++ b/client/transactions/uncaptured/index.tsx @@ -7,7 +7,6 @@ import React, { useEffect } from 'react'; import { __ } from '@wordpress/i18n'; import { TableCard, TableCardColumn } from '@woocommerce/components'; import { onQueryChange, getQuery } from '@woocommerce/navigation'; -import { dateI18n } from '@wordpress/date'; import moment from 'moment'; /** @@ -21,6 +20,7 @@ import { formatExplicitCurrency } from 'multi-currency/interface/functions'; import RiskLevel, { calculateRiskMapping } from 'components/risk-level'; import { recordEvent } from 'tracks'; import CaptureAuthorizationButton from 'wcpay/components/capture-authorization-button'; +import { formatDateTimeFromString } from 'wcpay/utils/date-time'; interface Column extends TableCardColumn { key: @@ -130,35 +130,25 @@ export const AuthorizationsList = (): JSX.Element => { display: auth.payment_intent_id, }, created: { - value: dateI18n( - 'M j, Y / g:iA', - moment.utc( auth.created ).local().toISOString() - ), + value: formatDateTimeFromString( auth.created, { + includeTime: true, + } ), display: clickable( - dateI18n( - 'M j, Y / g:iA', - moment.utc( auth.created ).local().toISOString() - ) + formatDateTimeFromString( auth.created, { + includeTime: true, + } ) ), }, // Payments are authorized for a maximum of 7 days capture_by: { - value: dateI18n( - 'M j, Y / g:iA', - moment - .utc( auth.created ) - .add( 7, 'd' ) - .local() - .toISOString() + value: formatDateTimeFromString( + moment.utc( auth.created ).add( 7, 'd' ).toISOString(), + { includeTime: true } ), display: clickable( - dateI18n( - 'M j, Y / g:iA', - moment - .utc( auth.created ) - .add( 7, 'd' ) - .local() - .toISOString() + formatDateTimeFromString( + moment.utc( auth.created ).add( 7, 'd' ).toISOString(), + { includeTime: true } ) ), }, diff --git a/client/transactions/uncaptured/test/index.test.tsx b/client/transactions/uncaptured/test/index.test.tsx index 52f76dcc882..fc21a532b67 100644 --- a/client/transactions/uncaptured/test/index.test.tsx +++ b/client/transactions/uncaptured/test/index.test.tsx @@ -67,6 +67,8 @@ declare const global: { precision: number; }; }; + dateFormat: string; + timeFormat: string; }; }; @@ -126,6 +128,8 @@ describe( 'Authorizations list', () => { precision: 2, }, }, + dateFormat: 'M j, Y', + timeFormat: 'g:iA', }; } ); diff --git a/client/utils/account-fees.tsx b/client/utils/account-fees.tsx index 711d3d337ed..791b727b164 100644 --- a/client/utils/account-fees.tsx +++ b/client/utils/account-fees.tsx @@ -143,7 +143,7 @@ export const formatMethodFeesTooltip = ( return (
    -
    Base fee
    +
    { __( 'Base fee', 'woocommerce-payments' ) }
    { getFeeDescriptionString( accountFees.base, @@ -153,7 +153,12 @@ export const formatMethodFeesTooltip = (
    { hasFees( accountFees.additional ) ? (
    -
    International payment method fee
    +
    + { __( + 'International payment method fee', + 'woocommerce-payments' + ) } +
    { getFeeDescriptionString( accountFees.additional, @@ -166,14 +171,21 @@ export const formatMethodFeesTooltip = ( ) } { hasFees( accountFees.fx ) ? (
    -
    Foreign exchange fee
    +
    + { __( + 'Currency conversion fee', + 'woocommerce-payments' + ) } +
    { getFeeDescriptionString( accountFees.fx ) }
    ) : ( '' ) }
    -
    Total per transaction
    +
    + { __( 'Total per transaction', 'woocommerce-payments' ) } +
    { getFeeDescriptionString( total ) }
    diff --git a/client/utils/date-time.ts b/client/utils/date-time.ts new file mode 100644 index 00000000000..83e4c2c2257 --- /dev/null +++ b/client/utils/date-time.ts @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import { dateI18n } from '@wordpress/date'; +import moment from 'moment'; + +type DateTimeFormat = string | null; + +interface FormatDateTimeOptions { + /** Whether to include time in the formatted string (defaults to true) */ + includeTime?: boolean; + /** Separator between date and time (defaults to ' / ') */ + separator?: string; + /** Custom format to use instead of WordPress settings */ + customFormat?: DateTimeFormat; + /** Timezone string (e.g., 'UTC', 'America/New_York'). If undefined, uses site default */ + timezone?: string; +} + +/** + * Formats a date/time string in YYYY-MM-DD HH:MM:SS format according to WordPress settings. + * The input date string is converted to UTC for consistent handling across timezones. + * + * @param dateTimeStr - Date time string in YYYY-MM-DD HH:MM:SS format + * @param options - Formatting options + */ +export function formatDateTimeFromString( + dateTimeStr: string, + options: FormatDateTimeOptions = {} +): string { + const { + customFormat = null, + includeTime = false, + separator = ' / ', + timezone = undefined, + } = options; + + // Convert to UTC ISO string for consistent handling + const utcDateTime = moment.utc( dateTimeStr ).toISOString(); + + const format = + customFormat || + `${ window.wcpaySettings.dateFormat }${ + includeTime + ? `${ separator }${ window.wcpaySettings.timeFormat }` + : '' + }`; + + return dateI18n( format, utcDateTime, timezone ); +} + +/** + * Formats a Unix timestamp according to WordPress settings. + * The input timestamp is converted to UTC for consistent handling across timezones. + * + * @param timestamp - Unix timestamp (seconds since epoch) + * @param options - Formatting options + */ +export function formatDateTimeFromTimestamp( + timestamp: number, + options: FormatDateTimeOptions = {} +): string { + const { + customFormat = null, + includeTime = false, + separator = ' / ', + timezone = undefined, + } = options; + + // Convert to UTC ISO string for consistent handling + const utcDateTime = moment.unix( timestamp ).utc().toISOString(); + + const format = + customFormat || + `${ window.wcpaySettings.dateFormat }${ + includeTime + ? `${ separator }${ window.wcpaySettings.timeFormat }` + : '' + }`; + + return dateI18n( format, utcDateTime, timezone ); +} diff --git a/client/utils/test/__snapshots__/account-fees.tsx.snap b/client/utils/test/__snapshots__/account-fees.tsx.snap index 89321bc7582..d92aa6ae54e 100644 --- a/client/utils/test/__snapshots__/account-fees.tsx.snap +++ b/client/utils/test/__snapshots__/account-fees.tsx.snap @@ -23,7 +23,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays base
    - Foreign exchange fee + Currency conversion fee
    1% @@ -102,7 +102,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays base
    - Foreign exchange fee + Currency conversion fee
    1% diff --git a/client/utils/test/date-time.test.ts b/client/utils/test/date-time.test.ts new file mode 100644 index 00000000000..798c95d7755 --- /dev/null +++ b/client/utils/test/date-time.test.ts @@ -0,0 +1,181 @@ +/** + * Internal dependencies + */ +import { + formatDateTimeFromString, + formatDateTimeFromTimestamp, +} from 'wcpay/utils/date-time'; + +// Mock dateI18n +jest.mock( '@wordpress/date', () => ( { + dateI18n: jest.fn( ( format, date, timezone ) => { + return jest + .requireActual( '@wordpress/date' ) + .dateI18n( format, date, timezone || 'UTC' ); // Use provided timezone or fallback to UTC + } ), +} ) ); + +describe( 'Date/Time Formatting', () => { + const originalWcpaySettings = window.wcpaySettings; + const mockWcpaySettings = { + dateFormat: 'Y-m-d', + timeFormat: 'H:i', + }; + + beforeEach( () => { + jest.clearAllMocks(); + window.wcpaySettings = mockWcpaySettings as typeof wcpaySettings; + } ); + + afterEach( () => { + window.wcpaySettings = originalWcpaySettings; + } ); + + describe( 'formatDateTimeFromString', () => { + it( 'should format using default WordPress settings', () => { + const dateTime = '2024-10-23 15:28:26'; + const formatted = formatDateTimeFromString( dateTime, { + includeTime: true, + } ); + + expect( formatted ).toBe( '2024-10-23 / 15:28' ); + } ); + + it( 'should use custom format if provided', () => { + const dateTime = '2024-10-23 15:28:26'; + const options = { customFormat: 'd-m-Y H:i:s' }; + const formatted = formatDateTimeFromString( dateTime, options ); + + expect( formatted ).toBe( '23-10-2024 15:28:26' ); + } ); + + it( 'should exclude time if includeTime is set to false', () => { + const dateTime = '2024-10-23 15:28:26'; + const formatted = formatDateTimeFromString( dateTime ); + + expect( formatted ).toBe( '2024-10-23' ); + } ); + + it( 'should use custom separator when provided', () => { + const dateTime = '2024-10-23 15:28:26'; + const options = { separator: ' - ', includeTime: true }; + const formatted = formatDateTimeFromString( dateTime, options ); + + expect( formatted ).toBe( '2024-10-23 - 15:28' ); + } ); + + it( 'should handle different timezones correctly', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dateI18n = require( '@wordpress/date' ).dateI18n; + // Temporarily modify the mock to use a different timezone: America/New_York + dateI18n.mockImplementationOnce( + ( format: string, date: string | number ) => { + return jest + .requireActual( '@wordpress/date' ) + .dateI18n( format, date, 'America/New_York' ); + } + ); + + const dateTime = '2024-10-23 15:28:26'; + const formatted = formatDateTimeFromString( dateTime, { + includeTime: true, + } ); + + expect( formatted ).toBe( '2024-10-23 / 11:28' ); + } ); + + it( 'should respect explicitly provided timezone', () => { + const dateTime = '2024-10-23 15:28:26'; + + // Test with UTC timezone + const formattedUTC = formatDateTimeFromString( dateTime, { + includeTime: true, + timezone: 'UTC', + } ); + expect( formattedUTC ).toBe( '2024-10-23 / 15:28' ); + + // Test with New York timezone + const formattedNY = formatDateTimeFromString( dateTime, { + includeTime: true, + timezone: 'America/New_York', + } ); + expect( formattedNY ).toBe( '2024-10-23 / 11:28' ); + } ); + } ); + + describe( 'formatDateTimeFromTimestamp', () => { + it( 'should format using default WordPress settings', () => { + const timestamp = 1729766906; // 2024-10-23 10:48:26 UTC + const formatted = formatDateTimeFromTimestamp( timestamp, { + includeTime: true, + } ); + + expect( formatted ).toBe( '2024-10-24 / 10:48' ); + } ); + + it( 'should use custom format if provided', () => { + const timestamp = 1729766906; // 2024-10-23 10:48:26 UTC + const options = { customFormat: 'd-m-Y H:i:s' }; + const formatted = formatDateTimeFromTimestamp( timestamp, options ); + + expect( formatted ).toBe( '24-10-2024 10:48:26' ); + } ); + + it( 'should exclude time if includeTime is set to false', () => { + const timestamp = 1729766906; // 2024-10-23 10:48:26 UTC + const formatted = formatDateTimeFromTimestamp( timestamp ); + + expect( formatted ).toBe( '2024-10-24' ); + } ); + + it( 'should use custom separator when provided', () => { + const timestamp = 1729766906; // 2024-10-23 10:48:26 UTC + const options = { + separator: ' - ', + includeTime: true, + }; + const formatted = formatDateTimeFromTimestamp( timestamp, options ); + + expect( formatted ).toBe( '2024-10-24 - 10:48' ); + } ); + + it( 'should handle different timezones correctly', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dateI18n = require( '@wordpress/date' ).dateI18n; + // Temporarily modify the mock to use a different timezone: America/New_York + dateI18n.mockImplementationOnce( + ( format: string, date: string | number ) => { + return jest + .requireActual( '@wordpress/date' ) + .dateI18n( format, date, 'America/New_York' ); + } + ); + + const timestamp = 1729766906; // 2024-10-24 10:48:26 UTC + const formatted = formatDateTimeFromTimestamp( timestamp, { + includeTime: true, + } ); + + // In New York (EDT), this should be 4 hours behind UTC + expect( formatted ).toBe( '2024-10-24 / 06:48' ); + } ); + + it( 'should respect explicitly provided timezone', () => { + const timestamp = 1729766906; // 2024-10-24 10:48:26 UTC + + // Test with UTC timezone + const formattedUTC = formatDateTimeFromTimestamp( timestamp, { + includeTime: true, + timezone: 'UTC', + } ); + expect( formattedUTC ).toBe( '2024-10-24 / 10:48' ); + + // Test with New York timezone + const formattedNY = formatDateTimeFromTimestamp( timestamp, { + includeTime: true, + timezone: 'America/New_York', + } ); + expect( formattedNY ).toBe( '2024-10-24 / 06:48' ); + } ); + } ); +} ); diff --git a/dev/phpcs/WCPay/ruleset.xml b/dev/phpcs/WCPay/ruleset.xml index 9806ccfe9e7..7c8cefbd0e3 100644 --- a/dev/phpcs/WCPay/ruleset.xml +++ b/dev/phpcs/WCPay/ruleset.xml @@ -17,10 +17,6 @@ */includes/class-wc-payments-order-success-page.php - - */includes/class-wc-payments-customer-service.php - */includes/class-wc-payments-token-service.php - */includes/class-wc-payments-webhook-reliability-service.php diff --git a/includes/admin/class-wc-payments-admin.php b/includes/admin/class-wc-payments-admin.php index 6509cc90ecb..392dec5c611 100644 --- a/includes/admin/class-wc-payments-admin.php +++ b/includes/admin/class-wc-payments-admin.php @@ -6,6 +6,7 @@ */ use Automattic\Jetpack\Identity_Crisis as Jetpack_Identity_Crisis; +use WCPay\Constants\Intent_Status; use WCPay\Core\Server\Request; use WCPay\Database_Cache; use WCPay\Logger; @@ -972,6 +973,7 @@ private function get_js_settings(): array { 'storeName' => get_bloginfo( 'name' ), 'isNextDepositNoticeDismissed' => WC_Payments_Features::is_next_deposit_notice_dismissed(), 'isInstantDepositNoticeDismissed' => get_option( 'wcpay_instant_deposit_notice_dismissed', false ), + 'isDateFormatNoticeDismissed' => get_option( 'wcpay_date_format_notice_dismissed', false ), 'reporting' => [ 'exportModalDismissed' => get_option( 'wcpay_reporting_export_modal_dismissed', false ), ], @@ -982,6 +984,8 @@ private function get_js_settings(): array { 'lifetimeTPV' => $this->account->get_lifetime_total_payment_volume(), 'defaultExpressCheckoutBorderRadius' => WC_Payments_Express_Checkout_Button_Handler::DEFAULT_BORDER_RADIUS_IN_PX, 'isWooPayGlobalThemeSupportEligible' => WC_Payments_Features::is_woopay_global_theme_support_eligible(), + 'dateFormat' => wc_date_format(), + 'timeFormat' => get_option( 'time_format' ), ]; return apply_filters( 'wcpay_js_settings', $this->wcpay_js_settings ); @@ -1253,7 +1257,7 @@ public function show_woopay_payment_method_name_admin( $order_id ) { */ public function display_wcpay_transaction_fee( $order_id ) { $order = wc_get_order( $order_id ); - if ( ! $order || ! $order->get_meta( '_wcpay_transaction_fee' ) ) { + if ( ! $order || ! $order->get_meta( '_wcpay_transaction_fee' ) || Intent_Status::REQUIRES_CAPTURE === $order->get_meta( WC_Payments_Order_Service::INTENTION_STATUS_META_KEY ) ) { return; } ?> @@ -1336,6 +1340,7 @@ public function add_transactions_notification_badge() { /** * Gets the number of disputes which need a response. ie have a 'needs_response' or 'warning_needs_response' status. + * Used to display a notification badge on the Payments > Disputes menu item. * * @return int The number of disputes which need a response. */ diff --git a/includes/admin/tasks/class-wc-payments-task-disputes.php b/includes/admin/tasks/class-wc-payments-task-disputes.php index b7212ec7623..7d5ac82faf4 100644 --- a/includes/admin/tasks/class-wc-payments-task-disputes.php +++ b/includes/admin/tasks/class-wc-payments-task-disputes.php @@ -49,6 +49,13 @@ class WC_Payments_Task_Disputes extends Task { */ private $disputes_due_within_1d; + /** + * A memory cache of all disputes needing response. + * + * @var array|null + */ + private $disputes_needing_response = null; + /** * WC_Payments_Task_Disputes constructor. */ @@ -57,13 +64,12 @@ public function __construct() { $this->api_client = \WC_Payments::get_payments_api_client(); $this->database_cache = \WC_Payments::get_database_cache(); parent::__construct(); - $this->init(); } /** * Initialize the task. */ - private function init() { + private function fetch_relevant_disputes() { $this->disputes_due_within_7d = $this->get_disputes_needing_response_within_days( 7 ); $this->disputes_due_within_1d = $this->get_disputes_needing_response_within_days( 1 ); } @@ -83,6 +89,9 @@ public function get_id() { * @return string */ public function get_title() { + if ( null === $this->disputes_needing_response ) { + $this->fetch_relevant_disputes(); + } if ( count( (array) $this->disputes_due_within_7d ) === 1 ) { $dispute = $this->disputes_due_within_7d[0]; $amount = WC_Payments_Utils::interpret_stripe_amount( $dispute['amount'], $dispute['currency'] ); @@ -275,6 +284,9 @@ public function is_complete() { * @return bool */ public function can_view() { + if ( null === $this->disputes_needing_response ) { + $this->fetch_relevant_disputes(); + } return count( (array) $this->disputes_due_within_7d ) > 0; } @@ -322,15 +334,24 @@ private function get_disputes_needing_response_within_days( $num_days ) { * @return array|null Array of disputes awaiting a response. Null on failure. */ private function get_disputes_needing_response() { - return $this->database_cache->get_or_add( + if ( null !== $this->disputes_needing_response ) { + return $this->disputes_needing_response; + } + + $this->disputes_needing_response = $this->database_cache->get_or_add( Database_Cache::ACTIVE_DISPUTES_KEY, function () { - $response = $this->api_client->get_disputes( - [ - 'pagesize' => 50, - 'search' => [ 'warning_needs_response', 'needs_response' ], - ] - ); + try { + $response = $this->api_client->get_disputes( + [ + 'pagesize' => 50, + 'search' => [ 'warning_needs_response', 'needs_response' ], + ] + ); + } catch ( \Exception $e ) { + // Ensure an array is always returned, even if the API call fails. + return []; + } $active_disputes = $response['data'] ?? []; @@ -347,8 +368,9 @@ function ( $a, $b ) { return $active_disputes; }, - // We'll consider all array values to be valid as the cache is only invalidated when it is deleted or it expires. 'is_array' ); + + return $this->disputes_needing_response; } } diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 3e02aa6acf5..d1be21241b9 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -20,7 +20,19 @@ use WCPay\Constants\Intent_Status; use WCPay\Constants\Payment_Type; use WCPay\Constants\Payment_Method; -use WCPay\Exceptions\{ Add_Payment_Method_Exception, Amount_Too_Small_Exception, Process_Payment_Exception, Intent_Authentication_Exception, API_Exception, Invalid_Address_Exception, Fraud_Prevention_Enabled_Exception, Invalid_Phone_Number_Exception, Rate_Limiter_Enabled_Exception, Order_ID_Mismatch_Exception, Order_Not_Found_Exception, New_Process_Payment_Exception }; +use WCPay\Exceptions\{Add_Payment_Method_Exception, + Amount_Too_Small_Exception, + API_Merchant_Exception, + Process_Payment_Exception, + Intent_Authentication_Exception, + API_Exception, + Invalid_Address_Exception, + Fraud_Prevention_Enabled_Exception, + Invalid_Phone_Number_Exception, + Rate_Limiter_Enabled_Exception, + Order_ID_Mismatch_Exception, + Order_Not_Found_Exception, + New_Process_Payment_Exception}; use WCPay\Core\Server\Request\Cancel_Intention; use WCPay\Core\Server\Request\Capture_Intention; use WCPay\Core\Server\Request\Create_And_Confirm_Intention; @@ -321,8 +333,7 @@ public function __construct( ]; if ( 'card' !== $this->stripe_id ) { - $this->id = self::GATEWAY_ID . '_' . $this->stripe_id; - $this->method_title = "WooPayments ($this->title)"; + $this->id = self::GATEWAY_ID . '_' . $this->stripe_id; } // Capabilities have different keys than the payment method ID's, @@ -366,7 +377,8 @@ public function __construct( * @return string */ public function get_title() { - $this->title = $this->payment_method->get_title(); + $this->title = $this->payment_method->get_title(); + $this->method_title = "WooPayments ($this->title)"; return parent::get_title(); } @@ -1270,6 +1282,9 @@ public function process_payment( $order_id ) { ); $error_details = esc_html( rtrim( $e->getMessage(), '.' ) ); + if ( $e instanceof API_Merchant_Exception ) { + $error_details = $error_details . '. ' . esc_html( rtrim( $e->get_merchant_message(), '.' ) ); + } if ( $e instanceof API_Exception && 'card_error' === $e->get_error_type() ) { // If the payment failed with a 'card_error' API exception, initialize the fraud meta box diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php index dc5430d6f0f..ebe3888afc5 100644 --- a/includes/class-wc-payments-account.php +++ b/includes/class-wc-payments-account.php @@ -2049,6 +2049,21 @@ private function init_stripe_onboarding( string $setup_mode, string $wcpay_conne $gateway->update_option( 'enabled', 'yes' ); $gateway->update_option( 'test_mode', empty( $onboarding_data['is_live'] ) ? 'yes' : 'no' ); + /** + * ================== + * Enforces the update of payment methods to 'enabled' based on the capabilities + * provided during the NOX onboarding process. + * + * @see WC_Payments_Onboarding_Service::update_enabled_payment_methods_ids + * ================== + */ + $capabilities = $this->onboarding_service->get_capabilities_from_request(); + + // Activate enabled Payment Methods IDs. + if ( ! empty( $capabilities ) ) { + $this->onboarding_service->update_enabled_payment_methods_ids( $gateway, $capabilities ); + } + // Store a state after completing KYC for tracks. This is stored temporarily in option because // user might not have agreed to TOS yet. update_option( '_wcpay_onboarding_stripe_connected', [ 'is_existing_stripe_account' => true ] ); @@ -2161,6 +2176,20 @@ private function finalize_connection( string $state, string $mode, array $additi $gateway->update_option( 'enabled', 'yes' ); $gateway->update_option( 'test_mode', 'live' !== $mode ? 'yes' : 'no' ); + /** + * ================== + * Enforces the update of payment methods to 'enabled' based on the capabilities + * provided during the NOX onboarding process. + * + * @see WC_Payments_Onboarding_Service::update_enabled_payment_methods_ids + * ================== + */ + $capabilities = $this->onboarding_service->get_capabilities_from_request(); + // Activate enabled Payment Methods IDs. + if ( ! empty( $capabilities ) ) { + $this->onboarding_service->update_enabled_payment_methods_ids( $gateway, $capabilities ); + } + // Store a state after completing KYC for tracks. This is stored temporarily in option because // user might not have agreed to TOS yet. update_option( '_wcpay_onboarding_stripe_connected', [ 'is_existing_stripe_account' => false ] ); @@ -2570,6 +2599,7 @@ function (): array { ); } + /** * Send a Tracks event. * diff --git a/includes/class-wc-payments-captured-event-note.php b/includes/class-wc-payments-captured-event-note.php index 10c48567952..07e902d8632 100644 --- a/includes/class-wc-payments-captured-event-note.php +++ b/includes/class-wc-payments-captured-event-note.php @@ -327,9 +327,9 @@ private function fee_label_mapping( int $fixed_rate, bool $is_capped ) { $res['additional-fx'] = 0 !== $fixed_rate /* translators: %1$s% is the fee percentage and %2$s is the fixed rate */ - ? __( 'Foreign exchange fee: %1$s%% + %2$s', 'woocommerce-payments' ) + ? __( 'Currency conversion fee: %1$s%% + %2$s', 'woocommerce-payments' ) /* translators: %1$s% is the fee percentage */ - : __( 'Foreign exchange fee: %1$s%%', 'woocommerce-payments' ); + : __( 'Currency conversion fee: %1$s%%', 'woocommerce-payments' ); $res['additional-wcpay-subscription'] = 0 !== $fixed_rate /* translators: %1$s% is the fee percentage and %2$s is the fixed rate */ diff --git a/includes/class-wc-payments-checkout.php b/includes/class-wc-payments-checkout.php index ee7a161f3b1..44d92d10b23 100644 --- a/includes/class-wc-payments-checkout.php +++ b/includes/class-wc-payments-checkout.php @@ -103,6 +103,7 @@ public function init_hooks() { add_action( 'wp_enqueue_scripts', [ $this, 'register_scripts' ] ); add_action( 'wp_enqueue_scripts', [ $this, 'register_scripts_for_zero_order_total' ], 11 ); + add_action( 'woocommerce_after_checkout_form', [ $this, 'maybe_load_checkout_scripts' ] ); } /** @@ -151,11 +152,18 @@ public function register_scripts_for_zero_order_total() { ! has_block( 'woocommerce/checkout' ) && ! wp_script_is( 'wcpay-upe-checkout', 'enqueued' ) ) { - WC_Payments::get_gateway()->tokenization_script(); - $script_handle = 'wcpay-upe-checkout'; - $js_object = 'wcpay_upe_config'; - wp_localize_script( $script_handle, $js_object, WC_Payments::get_wc_payments_checkout()->get_payment_fields_js_config() ); - wp_enqueue_script( $script_handle ); + $this->load_checkout_scripts(); + } + } + + /** + * Sometimes the filters can remove the payment gateway from the checkout page which results in the payment fields not being displayed. + * This could prevent loading of the payment fields (checkout) scripts. + * This function ensures that these scripts are loaded. + */ + public function maybe_load_checkout_scripts() { + if ( is_checkout() && ! wp_script_is( 'wcpay-upe-checkout', 'enqueued' ) ) { + $this->load_checkout_scripts(); } } @@ -416,7 +424,7 @@ function () use ( $prepared_customer_data ) { } ?> -
    gateway = $this->gateway->wc_payments_get_payment_gateway_by_id( $payment_method_id ); } } + + /** + * Load the checkout scripts. + */ + private function load_checkout_scripts() { + WC_Payments::get_gateway()->tokenization_script(); + $script_handle = 'wcpay-upe-checkout'; + $js_object = 'wcpay_upe_config'; + wp_localize_script( $script_handle, $js_object, WC_Payments::get_wc_payments_checkout()->get_payment_fields_js_config() ); + wp_enqueue_script( $script_handle ); + } } diff --git a/includes/class-wc-payments-customer-service.php b/includes/class-wc-payments-customer-service.php index 05f95c32d31..d0f97e061c0 100644 --- a/includes/class-wc-payments-customer-service.php +++ b/includes/class-wc-payments-customer-service.php @@ -99,7 +99,12 @@ public function __construct( $this->database_cache = $database_cache; $this->session_service = $session_service; $this->order_service = $order_service; + } + /** + * Initialize hooks + */ + public function init_hooks() { /* * Adds the WooCommerce Payments customer ID found in the user session * to the WordPress user as metadata. diff --git a/includes/class-wc-payments-onboarding-service.php b/includes/class-wc-payments-onboarding-service.php index 504ac3bd5e4..cd5fe83d348 100644 --- a/includes/class-wc-payments-onboarding-service.php +++ b/includes/class-wc-payments-onboarding-service.php @@ -277,6 +277,22 @@ public function create_embedded_kyc_session( array $self_assessment_data, bool $ ); $actioned_notes = self::get_actioned_notes(); + /** + * ================== + * Enforces the update of payment methods to 'enabled' based on the capabilities + * provided during the NOX onboarding process. + * + * @see self::update_enabled_payment_methods_ids + * ================== + */ + $capabilities = $this->get_capabilities_from_request(); + $gateway = WC_Payments::get_gateway(); + + // Activate enabled Payment Methods IDs. + if ( ! empty( $capabilities ) ) { + $this->update_enabled_payment_methods_ids( $gateway, $capabilities ); + } + try { $account_session = $this->payments_api_client->initialize_onboarding_embedded_kyc( 'live' === $setup_mode, @@ -1024,4 +1040,80 @@ public function maybe_add_test_drive_settings_to_new_account_request( array $arg return $args; } + + /** + * Update payment methods to 'enabled' based on the capabilities + * provided during the NOX onboarding process. Merchants can preselect their preferred + * payment methods as part of this flow. + * + * The capabilities are provided in the following format: + * + * [ + * 'card' => true, + * 'affirm' => true, + * ... + * ] + * + * @param WC_Payment_Gateway_WCPay $gateway Payment gateway instance. + * @param array $capabilities Provided capabilities. + */ + public function update_enabled_payment_methods_ids( $gateway, $capabilities = [] ): void { + $enabled_gateways = $gateway->get_upe_enabled_payment_method_ids(); + + $enabled_payment_methods = array_unique( + array_merge( + $enabled_gateways, + $this->exclude_placeholder_payment_methods( $capabilities ) + ) + ); + + // Update the gateway option. + $gateway->update_option( 'upe_enabled_payment_method_ids', $enabled_payment_methods ); + + /** + * Keeps the list of enabled payment method IDs synchronized between the default + * `woocommerce_woocommerce_payments_settings` and duplicates in individual gateway settings. + */ + foreach ( $enabled_payment_methods as $payment_method_id ) { + $payment_gateway = WC_Payments::get_payment_gateway_by_id( $payment_method_id ); + if ( $payment_gateway ) { + $payment_gateway->enable(); + $payment_gateway->update_option( 'upe_enabled_payment_method_ids', $enabled_payment_methods ); + } + } + + // If WooPay is enabled, update the gateway option. + if ( ! empty( $capabilities['woopay'] ) ) { + $gateway->update_is_woopay_enabled( true ); + } + + // If Apple Pay and Google Pay are disabled update the gateway option, + // otherwise they are enabled by default. + if ( empty( $capabilities['apple_google'] ) ) { + $gateway->update_option( 'payment_request', 'no' ); + } + } + + /** + * Excludes placeholder payment methods and removes duplicates. + * + * WooPay and Apple Pay & Google Pay are considered placeholder payment methods and are excluded. + * + * @param array $payment_methods Array of payment methods to process. + * + * @return array Filtered array of unique payment methods. + */ + private function exclude_placeholder_payment_methods( array $payment_methods ): array { + // Placeholder payment methods. + $excluded_methods = [ 'woopay', 'apple_google' ]; + + return array_filter( + array_unique( + array_keys( array_filter( $payment_methods ) ) + ), + function ( $payment_method ) use ( $excluded_methods ) { + return ! in_array( $payment_method, $excluded_methods, true ); + } + ); + } } diff --git a/includes/class-wc-payments-tasks.php b/includes/class-wc-payments-tasks.php index ee3feacff48..b0b01e22896 100644 --- a/includes/class-wc-payments-tasks.php +++ b/includes/class-wc-payments-tasks.php @@ -21,7 +21,11 @@ class WC_Payments_Tasks { * WC_Payments_Admin_Tasks constructor. */ public static function init() { - include_once WCPAY_ABSPATH . 'includes/admin/tasks/class-wc-payments-task-disputes.php'; + // As WooCommerce Onboarding tasks need to hook into 'init' and requires an API call. + // We only add this task for users who can manage_woocommerce / view the task. + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return; + } add_action( 'init', [ __CLASS__, 'add_task_disputes_need_response' ] ); } @@ -31,9 +35,11 @@ public static function init() { */ public static function add_task_disputes_need_response() { $account_service = WC_Payments::get_account_service(); - if ( ! $account_service || ! $account_service->is_stripe_account_valid() ) { + // The task is not required if the account is not connected, under review, or rejected. + if ( ! $account_service || ! $account_service->is_stripe_account_valid() || $account_service->is_account_under_review() || $account_service->is_account_rejected() ) { return; } + include_once WCPAY_ABSPATH . 'includes/admin/tasks/class-wc-payments-task-disputes.php'; // 'extended' = 'Things to do next' task list on WooCommerce > Home. TaskLists::add_task( 'extended', new WC_Payments_Task_Disputes() ); diff --git a/includes/class-wc-payments-token-service.php b/includes/class-wc-payments-token-service.php index 7bfdc482e18..283a0d7851a 100644 --- a/includes/class-wc-payments-token-service.php +++ b/includes/class-wc-payments-token-service.php @@ -47,7 +47,12 @@ class WC_Payments_Token_Service { public function __construct( WC_Payments_API_Client $payments_api_client, WC_Payments_Customer_Service $customer_service ) { $this->payments_api_client = $payments_api_client; $this->customer_service = $customer_service; + } + /** + * Initializes hooks. + */ + public function init_hooks() { add_action( 'woocommerce_payment_token_deleted', [ $this, 'woocommerce_payment_token_deleted' ], 10, 2 ); add_action( 'woocommerce_payment_token_set_default', [ $this, 'woocommerce_payment_token_set_default' ], 10, 2 ); add_filter( 'woocommerce_get_customer_payment_tokens', [ $this, 'woocommerce_get_customer_payment_tokens' ], 10, 3 ); diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 17300478794..7b4ae0750c2 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -354,6 +354,7 @@ public static function init() { include_once __DIR__ . '/exceptions/class-base-exception.php'; include_once __DIR__ . '/exceptions/class-api-exception.php'; + include_once __DIR__ . '/exceptions/class-api-merchant-exception.php'; include_once __DIR__ . '/exceptions/class-connection-exception.php'; include_once __DIR__ . '/core/class-mode.php'; @@ -554,6 +555,8 @@ public static function init() { self::$onboarding_service->init_hooks(); self::$incentives_service->init_hooks(); self::$compatibility_service->init_hooks(); + self::$customer_service->init_hooks(); + self::$token_service->init_hooks(); $payment_method_classes = [ CC_Payment_Method::class, @@ -1927,6 +1930,7 @@ public static function add_wcpay_options_to_woocommerce_permissions_list( $permi 'wcpay_duplicate_payment_method_notices_dismissed', 'wcpay_exit_survey_dismissed', 'wcpay_instant_deposit_notice_dismissed', + 'wcpay_date_format_notice_dismissed', ], true ); diff --git a/includes/compat/blocks/class-blocks-data-extractor.php b/includes/compat/blocks/class-blocks-data-extractor.php index 673cae7f352..becc393a5da 100644 --- a/includes/compat/blocks/class-blocks-data-extractor.php +++ b/includes/compat/blocks/class-blocks-data-extractor.php @@ -59,6 +59,15 @@ private function get_available_blocks() { $blocks[] = new \Mailchimp_Woocommerce_Newsletter_Blocks_Integration(); } + if ( class_exists( '\WCK\Blocks\CheckoutIntegration' ) ) { + // phpcs:ignore + /** + * @psalm-suppress UndefinedClass + * @phpstan-ignore-next-line + */ + $blocks[] = new \WCK\Blocks\CheckoutIntegration(); + } + return $blocks; } diff --git a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php index 31ec70bedf8..d2584f9b824 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -11,6 +11,7 @@ use WCPay\Core\Server\Request\Get_Intention; use WCPay\Exceptions\API_Exception; +use WCPay\Exceptions\API_Merchant_Exception; use WCPay\Exceptions\Invalid_Payment_Method_Exception; use WCPay\Exceptions\Add_Payment_Method_Exception; use WCPay\Exceptions\Order_Not_Found_Exception; @@ -342,6 +343,11 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) { $renewal_order->update_status( 'failed' ); if ( ! empty( $payment_information ) ) { + $error_details = esc_html( rtrim( $e->getMessage(), '.' ) ); + if ( $e instanceof API_Merchant_Exception ) { + $error_details = $error_details . '. ' . esc_html( rtrim( $e->get_merchant_message(), '.' ) ); + } + $note = sprintf( WC_Payments_Utils::esc_interpolated_html( /* translators: %1: the failed payment amount, %2: error message */ @@ -358,7 +364,7 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) { wc_price( $amount, [ 'currency' => WC_Payments_Utils::get_order_intent_currency( $renewal_order ) ] ), $renewal_order ), - esc_html( rtrim( $e->getMessage(), '.' ) ) + $error_details ); $renewal_order->add_order_note( $note ); } diff --git a/includes/exceptions/class-api-merchant-exception.php b/includes/exceptions/class-api-merchant-exception.php new file mode 100644 index 00000000000..ac10bd271bc --- /dev/null +++ b/includes/exceptions/class-api-merchant-exception.php @@ -0,0 +1,49 @@ +merchant_message = $merchant_message; + + parent::__construct( $message, $error_code, $http_code, $error_type, $decline_code, $code, $previous ); + } + + /** + * Returns the merchant message. + * + * @return string Merchant message. + */ + public function get_merchant_message(): string { + return $this->merchant_message; + } +} diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php b/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php index 672f2584c67..0613e3a4557 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php @@ -412,10 +412,10 @@ public function should_show_express_checkout_button() { // Order total doesn't matter for Pay for Order page. Thus, this page should always display payment buttons. if ( $this->is_pay_for_order_page() ) { - return true; + return $this->is_pay_for_order_supported(); } - // Non-shipping product and billing is calculated based on shopper billing addres. Excludes Pay for Order page. + // Non-shipping product and tax is calculated based on shopper billing address. Excludes Pay for Order page. if ( // If the product doesn't needs shipping. ( @@ -426,8 +426,10 @@ public function should_show_express_checkout_button() { ( ( $this->is_cart() || $this->is_checkout() ) && ! WC()->cart->needs_shipping() ) ) - // ...and billing is calculated based on billing address. - && wc_tax_enabled() && 'billing' === get_option( 'woocommerce_tax_based_on' ) + // ...and tax is calculated based on billing address. + && wc_tax_enabled() + && 'billing' === get_option( 'woocommerce_tax_based_on' ) + && 'yes' !== get_option( 'woocommerce_prices_include_tax' ) ) { return false; } @@ -747,6 +749,38 @@ public function get_product_data() { return apply_filters( 'wcpay_payment_request_product_data', $data, $product ); } + /** + * The Store API doesn't allow checkout without the billing email address present on the order data. + * https://github.com/woocommerce/woocommerce/issues/48540 + * + * @return bool + */ + private function is_pay_for_order_supported() { + if ( ! WC_Payments_Features::is_tokenized_cart_ece_enabled() ) { + return true; + } + + $order_id = absint( get_query_var( 'order-pay' ) ); + if ( 0 === $order_id ) { + return false; + } + + $order = wc_get_order( $order_id ); + if ( ! is_a( $order, 'WC_Order' ) ) { + return false; + } + + // we don't need to check its validity or value, we just need to ensure a billing email is present. + $billing_email = $order->get_billing_email(); + if ( ! empty( $billing_email ) ) { + return true; + } + + Logger::log( 'Billing email not present ( Express Checkout Element button disabled )' ); + + return false; + } + /** * Whether product page has a supported product. * diff --git a/includes/multi-currency/Compatibility/WooCommerceFedEx.php b/includes/multi-currency/Compatibility/WooCommerceFedEx.php index 8a38d058e40..15c25b4ba27 100644 --- a/includes/multi-currency/Compatibility/WooCommerceFedEx.php +++ b/includes/multi-currency/Compatibility/WooCommerceFedEx.php @@ -8,13 +8,25 @@ namespace WCPay\MultiCurrency\Compatibility; use WCPay\MultiCurrency\MultiCurrency; -use WCPay\MultiCurrency\Utils; /** * Class that controls Multi Currency Compatibility with WooCommerce FedEx Plugin. */ class WooCommerceFedEx extends BaseCompatibility { + /** + * Calls to look for in the backtrace when determining whether + * to return store currency or skip converting product prices. + */ + private const WC_SHIPPING_FEDEX_CALLS = [ + 'WC_Shipping_Fedex->set_settings', + 'WC_Shipping_Fedex->per_item_shipping', + 'WC_Shipping_Fedex->box_shipping', + 'WC_Shipping_Fedex->get_fedex_api_request', + 'WC_Shipping_Fedex->get_fedex_requests', + 'WC_Shipping_Fedex->process_result', + ]; + /** * Init the class. * @@ -23,10 +35,31 @@ class WooCommerceFedEx extends BaseCompatibility { public function init() { // Add needed actions and filters if FedEx is active. if ( class_exists( 'WC_Shipping_Fedex_Init' ) ) { + add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'should_convert_product_price' ] ); add_filter( MultiCurrency::FILTER_PREFIX . 'should_return_store_currency', [ $this, 'should_return_store_currency' ] ); } } + /** + * Checks to see if the product's price should be converted. + * + * @param bool $return Whether to convert the product's price or not. Default is true. + * + * @return bool True if it should be converted. + */ + public function should_convert_product_price( bool $return ): bool { + // If it's already false, return it. + if ( ! $return ) { + return $return; + } + + if ( $this->utils->is_call_in_backtrace( self::WC_SHIPPING_FEDEX_CALLS ) ) { + return false; + } + + return $return; + } + /** * Determine whether to return the store currency or not. * @@ -40,15 +73,7 @@ public function should_return_store_currency( bool $return ): bool { return $return; } - $calls = [ - 'WC_Shipping_Fedex->set_settings', - 'WC_Shipping_Fedex->per_item_shipping', - 'WC_Shipping_Fedex->box_shipping', - 'WC_Shipping_Fedex->get_fedex_api_request', - 'WC_Shipping_Fedex->get_fedex_requests', - 'WC_Shipping_Fedex->process_result', - ]; - if ( $this->utils->is_call_in_backtrace( $calls ) ) { + if ( $this->utils->is_call_in_backtrace( self::WC_SHIPPING_FEDEX_CALLS ) ) { return true; } diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index b3adf5bf7eb..e90094d57de 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -9,6 +9,7 @@ use WCPay\Constants\Intent_Status; use WCPay\Exceptions\API_Exception; +use WCPay\Exceptions\API_Merchant_Exception; use WCPay\Exceptions\Amount_Too_Small_Exception; use WCPay\Exceptions\Amount_Too_Large_Exception; use WCPay\Exceptions\Connection_Exception; @@ -2419,6 +2420,13 @@ protected function check_response_for_errors( $response ) { ); Logger::error( "$error_message ($error_code)" ); + + if ( 'card_declined' === $error_code && isset( $response_body['error']['payment_intent']['charges']['data'][0]['outcome']['seller_message'] ) ) { + $merchant_message = $response_body['error']['payment_intent']['charges']['data'][0]['outcome']['seller_message']; + + throw new API_Merchant_Exception( $message, $error_code, $response_code, $merchant_message, $error_type, $decline_code ); + } + throw new API_Exception( $message, $error_code, $response_code, $error_type, $decline_code ); } } diff --git a/package-lock.json b/package-lock.json index 4d8d73b8e87..1ddaf7a4ded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "woocommerce-payments", - "version": "8.6.0", + "version": "8.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "woocommerce-payments", - "version": "8.6.0", + "version": "8.6.1", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { @@ -48,7 +48,7 @@ "@typescript-eslint/parser": "4.15.2", "@woocommerce/api": "0.2.0", "@woocommerce/components": "12.3.0", - "@woocommerce/csv-export": "1.9.0", + "@woocommerce/csv-export": "1.10.0", "@woocommerce/currency": "4.3.0", "@woocommerce/date": "4.2.0", "@woocommerce/dependency-extraction-webpack-plugin": "2.2.0", @@ -10969,6 +10969,11 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@woocommerce/components/node_modules/@types/node": { + "version": "16.18.122", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.122.tgz", + "integrity": "sha512-rF6rUBS80n4oK16EW8nE75U+9fw0SSUgoPtWSvHhPXdT7itbvmS7UjB/jyM8i3AkvI6yeSM5qCwo+xN0npGDHg==" + }, "node_modules/@woocommerce/components/node_modules/@types/react": { "version": "17.0.80", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.80.tgz", @@ -10989,6 +10994,19 @@ "@types/wordpress__rich-text": "*" } }, + "node_modules/@woocommerce/components/node_modules/@woocommerce/csv-export": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@woocommerce/csv-export/-/csv-export-1.9.0.tgz", + "integrity": "sha512-fo1byPYTljic8ml5HxvxafdhrYc8pMYXqjlGZtZ9xNxWL2yffXJvskbcb7Kj6JSQhHT3BCh/5PlczJci9vQuDQ==", + "dependencies": { + "@types/node": "^16.18.68", + "browser-filesaver": "^1.1.1" + }, + "engines": { + "node": "^20.11.1", + "pnpm": "^9.1.0" + } + }, "node_modules/@woocommerce/components/node_modules/@wordpress/api-fetch": { "version": "6.55.0", "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-6.55.0.tgz", @@ -11652,23 +11670,19 @@ } }, "node_modules/@woocommerce/csv-export": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@woocommerce/csv-export/-/csv-export-1.9.0.tgz", - "integrity": "sha512-fo1byPYTljic8ml5HxvxafdhrYc8pMYXqjlGZtZ9xNxWL2yffXJvskbcb7Kj6JSQhHT3BCh/5PlczJci9vQuDQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@woocommerce/csv-export/-/csv-export-1.10.0.tgz", + "integrity": "sha512-OckWCp52fGFcyK2lwBJyiheBbvUFl0dO3Ai6tSfX+jApJa8SUlx9txhwKVSz4Xnpo+nH0B0SPHjoPtQeQeXa2w==", + "dev": true, "dependencies": { - "@types/node": "^16.18.68", + "@types/node": "20.x.x", "browser-filesaver": "^1.1.1" }, "engines": { "node": "^20.11.1", - "pnpm": "^9.1.0" + "pnpm": "9.1.3" } }, - "node_modules/@woocommerce/csv-export/node_modules/@types/node": { - "version": "16.18.102", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.102.tgz", - "integrity": "sha512-eSe2YwGCcRjqPidxfm20IAq02krERWcIIJW4FNPkU0zQLbc4L9pvhsmB0p6UJecjEf0j/E2ERHsKq7madvthKw==" - }, "node_modules/@woocommerce/currency": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@woocommerce/currency/-/currency-4.3.0.tgz", @@ -13775,6 +13789,24 @@ "pnpm": "^8.12.1" } }, + "node_modules/@woocommerce/onboarding/node_modules/@types/node": { + "version": "16.18.122", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.122.tgz", + "integrity": "sha512-rF6rUBS80n4oK16EW8nE75U+9fw0SSUgoPtWSvHhPXdT7itbvmS7UjB/jyM8i3AkvI6yeSM5qCwo+xN0npGDHg==" + }, + "node_modules/@woocommerce/onboarding/node_modules/@woocommerce/csv-export": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@woocommerce/csv-export/-/csv-export-1.9.0.tgz", + "integrity": "sha512-fo1byPYTljic8ml5HxvxafdhrYc8pMYXqjlGZtZ9xNxWL2yffXJvskbcb7Kj6JSQhHT3BCh/5PlczJci9vQuDQ==", + "dependencies": { + "@types/node": "^16.18.68", + "browser-filesaver": "^1.1.1" + }, + "engines": { + "node": "^20.11.1", + "pnpm": "^9.1.0" + } + }, "node_modules/@woocommerce/onboarding/node_modules/@woocommerce/experimental": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@woocommerce/experimental/-/experimental-3.2.0.tgz", @@ -20443,23 +20475,6 @@ "node": ">= 10.13.0" } }, - "node_modules/@wordpress/scripts/node_modules/rechoir/node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@wordpress/scripts/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -20655,18 +20670,6 @@ "node": ">=8" } }, - "node_modules/@wordpress/scripts/node_modules/supports-hyperlinks/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@wordpress/scripts/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", diff --git a/package.json b/package.json index d69725ac2da..cf6c7f7b843 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-payments", - "version": "8.6.0", + "version": "8.6.1", "main": "webpack.config.js", "author": "Automattic", "license": "GPL-3.0-or-later", @@ -115,7 +115,7 @@ "@typescript-eslint/parser": "4.15.2", "@woocommerce/api": "0.2.0", "@woocommerce/components": "12.3.0", - "@woocommerce/csv-export": "1.9.0", + "@woocommerce/csv-export": "1.10.0", "@woocommerce/currency": "4.3.0", "@woocommerce/date": "4.2.0", "@woocommerce/dependency-extraction-webpack-plugin": "2.2.0", @@ -215,7 +215,7 @@ "overrides": { "@automattic/puppeteer-utils": "github:Automattic/puppeteer-utils#update/babel-deps", "@woocommerce/components": { - "@woocommerce/csv-export": "1.9.0", + "@woocommerce/csv-export": "1.10.0", "@woocommerce/currency": "4.3.0" }, "@wordpress/scripts": { diff --git a/readme.txt b/readme.txt index d67f01c3951..0122e5021ae 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: woocommerce payments, apple pay, credit card, google pay, payment, payment Requires at least: 6.0 Tested up to: 6.7 Requires PHP: 7.3 -Stable tag: 8.6.0 +Stable tag: 8.6.1 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -94,6 +94,11 @@ Please note that our support for the checkout block is still experimental and th == Changelog == += 8.6.1 - 2024-12-17 = +* Fix - Checkout: Fix error when wc_address_i18n_params does not have data for a given country +* Fix - Skip mysqlcheck SSL Requirement during E2E environment setup + + = 8.6.0 - 2024-12-04 = * Add - Add Bank reference key column in Payout reports. This will help reconcile WooPayments Payouts with bank statements. * Add - Display credit card brand icons on order received page. diff --git a/src/Internal/Service/Level3Service.php b/src/Internal/Service/Level3Service.php index 67db748debe..b75f3dd1271 100644 --- a/src/Internal/Service/Level3Service.php +++ b/src/Internal/Service/Level3Service.php @@ -80,10 +80,10 @@ public function get_data_from_order( int $order_id ): array { $order_items = array_values( $order->get_items( [ 'line_item', 'fee' ] ) ); $currency = $order->get_currency(); - $process_item = function ( $item ) use ( $currency ) { - return $this->process_item( $item, $currency ); - }; - $items_to_send = array_map( $process_item, $order_items ); + $items_to_send = []; + foreach ( $order_items as $item ) { + $items_to_send = array_merge( $items_to_send, $this->process_item( $item, $currency ) ); + } $level3_data = [ 'merchant_reference' => (string) $order->get_id(), // An alphanumeric string of up to characters in length. This unique value is assigned by the merchant to identify the order. Also known as an “Order ID”. @@ -137,9 +137,9 @@ public function get_data_from_order( int $order_id ): array { * * @param WC_Order_Item_Product|WC_Order_Item_Fee $item Item to process. * @param string $currency Currency to use. - * @return \stdClass + * @return \stdClass[] */ - private function process_item( WC_Order_Item $item, string $currency ): stdClass { + private function process_item( WC_Order_Item $item, string $currency ): array { // Check to see if it is a WC_Order_Item_Product or a WC_Order_Item_Fee. if ( $item instanceof WC_Order_Item_Product ) { $subtotal = $item->get_subtotal(); @@ -164,7 +164,7 @@ private function process_item( WC_Order_Item $item, string $currency ): stdClass $unit_cost = 0; } - return (object) [ + $line_item = (object) [ 'product_code' => (string) $product_code, // Up to 12 characters that uniquely identify the product. 'product_description' => $description, // Up to 26 characters long describing the product. 'unit_cost' => $unit_cost, // Cost of the product, in cents, as a non-negative integer. @@ -172,6 +172,29 @@ private function process_item( WC_Order_Item $item, string $currency ): stdClass 'tax_amount' => $tax_amount, // The amount of tax this item had added to it, in cents, as a non-negative integer. 'discount_amount' => $discount_amount, // The amount an item was discounted—if there was a sale,for example, as a non-negative integer. ]; + $line_items = [ $line_item ]; + + /** + * In edge cases, rounding after division might lead to a slight inconsistency. + * + * For example: 10/3 with 2 decimal places = 3.33, but 3.33*3 = 9.99. + */ + if ( $subtotal > 0 ) { + $prepared_subtotal = $this->prepare_amount( $subtotal, $currency ); + $difference = $prepared_subtotal - ( $unit_cost * $quantity ); + if ( $difference > 0 ) { + $line_items[] = (object) [ + 'product_code' => 'rounding-fix', + 'product_description' => __( 'Rounding fix', 'woocommerce-payments' ), + 'unit_cost' => $difference, + 'quantity' => 1, + 'tax_amount' => 0, + 'discount_amount' => 0, + ]; + } + } + + return $line_items; } /** diff --git a/tests/fixtures/captured-payments/discount.json b/tests/fixtures/captured-payments/discount.json index 2fa6a911d74..5bf6f936c45 100644 --- a/tests/fixtures/captured-payments/discount.json +++ b/tests/fixtures/captured-payments/discount.json @@ -60,7 +60,7 @@ "feeBreakdown": { "base": "Base fee: 2.9% + $0.30", "additional-international": "International card fee: 1%", - "additional-fx": "Foreign exchange fee: 1%", + "additional-fx": "Currency conversion fee: 1%", "discount": { "label": "Discount", "variable": "Variable fee: -4.9%", diff --git a/tests/fixtures/captured-payments/foreign-card.json b/tests/fixtures/captured-payments/foreign-card.json index 234878b2372..df45c326d62 100644 --- a/tests/fixtures/captured-payments/foreign-card.json +++ b/tests/fixtures/captured-payments/foreign-card.json @@ -53,7 +53,7 @@ "feeBreakdown": { "base": "Base fee: 2.9% + $0.30", "additional-international": "International card fee: 1%", - "additional-fx": "Foreign exchange fee: 1%" + "additional-fx": "Currency conversion fee: 1%" }, "netString": "Net payout: $95.47 USD" } diff --git a/tests/fixtures/captured-payments/fx-decimal.json b/tests/fixtures/captured-payments/fx-decimal.json index b95e9318c84..2f065036122 100644 --- a/tests/fixtures/captured-payments/fx-decimal.json +++ b/tests/fixtures/captured-payments/fx-decimal.json @@ -45,7 +45,7 @@ "feeString": "Fee (3.9% + $0.30): -$4.39", "feeBreakdown": { "base": "Base fee: 2.9% + $0.30", - "additional-fx": "Foreign exchange fee: 1%" + "additional-fx": "Currency conversion fee: 1%" }, "netString": "Net payout: $100.65 USD" } diff --git a/tests/fixtures/captured-payments/fx-partial-capture.json b/tests/fixtures/captured-payments/fx-partial-capture.json index f10ff7aa9e9..691390d4852 100644 --- a/tests/fixtures/captured-payments/fx-partial-capture.json +++ b/tests/fixtures/captured-payments/fx-partial-capture.json @@ -57,7 +57,7 @@ "feeString": "Fee (3.51% + £0.21): -$0.88", "feeBreakdown": { "base": "Base fee: 2.9% + $0.30", - "additional-fx": "Foreign exchange fee: 1%", + "additional-fx": "Currency conversion fee: 1%", "discount": { "label": "Discount", "variable": "Variable fee: -0.39%", diff --git a/tests/fixtures/captured-payments/fx-with-capped-fee.json b/tests/fixtures/captured-payments/fx-with-capped-fee.json index 8c1b602a3eb..4c31a8435d7 100644 --- a/tests/fixtures/captured-payments/fx-with-capped-fee.json +++ b/tests/fixtures/captured-payments/fx-with-capped-fee.json @@ -55,7 +55,7 @@ "feeBreakdown": { "base": "Base fee: capped at $6.00", "additional-international": "International card fee: 1.5%", - "additional-fx": "Foreign exchange fee: 1%" + "additional-fx": "Currency conversion fee: 1%" }, "netString": "Net payout: $971.04 USD" } diff --git a/tests/fixtures/captured-payments/fx.json b/tests/fixtures/captured-payments/fx.json index 8ceee7b7438..f18ca9297ab 100644 --- a/tests/fixtures/captured-payments/fx.json +++ b/tests/fixtures/captured-payments/fx.json @@ -46,7 +46,7 @@ "feeString": "Fee (3.9% + $0.30): -$4.20", "feeBreakdown": { "base": "Base fee: 2.9% + $0.30", - "additional-fx": "Foreign exchange fee: 1%" + "additional-fx": "Currency conversion fee: 1%" }, "netString": "Net payout: $95.84 USD" } diff --git a/tests/fixtures/captured-payments/jpy-payment.json b/tests/fixtures/captured-payments/jpy-payment.json index 6c7a6b3ee05..4b4c6c152c9 100644 --- a/tests/fixtures/captured-payments/jpy-payment.json +++ b/tests/fixtures/captured-payments/jpy-payment.json @@ -57,7 +57,7 @@ "feeBreakdown": { "base": "Base fee: 3.6%", "additional-international": "International card fee: 2%", - "additional-fx": "Foreign exchange fee: 2%" + "additional-fx": "Currency conversion fee: 2%" }, "netString": "Net payout: ¥4,507 JPY" } diff --git a/tests/fixtures/captured-payments/subscription.json b/tests/fixtures/captured-payments/subscription.json index b7312ea0c02..d0e1fe705e4 100644 --- a/tests/fixtures/captured-payments/subscription.json +++ b/tests/fixtures/captured-payments/subscription.json @@ -53,7 +53,7 @@ "feeString": "Fee (4.9% + $0.30): -$3.04", "feeBreakdown": { "base": "Base fee: 2.9% + $0.30", - "additional-fx": "Foreign exchange fee: 1%", + "additional-fx": "Currency conversion fee: 1%", "additional-wcpay-subscription": "Subscription transaction fee: 1%" }, "netString": "Net payout: $52.87 USD" diff --git a/tests/unit/admin/tasks/test-class-wc-payments-task-disputes.php b/tests/unit/admin/tasks/test-class-wc-payments-task-disputes.php index 43771de278d..729be9743f0 100644 --- a/tests/unit/admin/tasks/test-class-wc-payments-task-disputes.php +++ b/tests/unit/admin/tasks/test-class-wc-payments-task-disputes.php @@ -8,6 +8,7 @@ use WCPay\Constants\Country_Code; use WooCommerce\Payments\Tasks\WC_Payments_Task_Disputes; +require_once WCPAY_ABSPATH . 'includes/admin/tasks/class-wc-payments-task-disputes.php'; /** * WC_Payments_Task_Disputes unit tests. */ 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 new file mode 100644 index 00000000000..0b10752c0f5 --- /dev/null +++ b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-handler.php @@ -0,0 +1,136 @@ +shipping()->unregister_shipping_methods(); + + $this->mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); + $this->mock_wcpay_gateway = $this->createMock( WC_Payment_Gateway_WCPay::class ); + $this->mock_ece_button_helper = $this->createMock( WC_Payments_Express_Checkout_Button_Helper::class ); + $this->mock_express_checkout_ajax_handler = $this->createMock( WC_Payments_Express_Checkout_Ajax_Handler::class ); + + $this->system_under_test = new WC_Payments_Express_Checkout_Button_Handler( + $this->mock_wcpay_account, + $this->mock_wcpay_gateway, + $this->mock_ece_button_helper, + $this->mock_express_checkout_ajax_handler + ); + + // Set up shipping zones and methods. + $this->zone = new WC_Shipping_Zone(); + $this->zone->set_zone_name( 'Worldwide' ); + $this->zone->set_zone_order( 1 ); + $this->zone->save(); + + $flat_rate = $this->zone->add_shipping_method( 'flat_rate' ); + $this->flat_rate_id = $flat_rate; + + $local_pickup = $this->zone->add_shipping_method( 'local_pickup' ); + $this->local_pickup_id = $local_pickup; + } + + public function tear_down() { + parent::tear_down(); + + // Clean up shipping zones and methods. + $this->zone->delete(); + } + + public function test_filter_cart_needs_shipping_address_regular_products() { + $this->assertEquals( + true, + $this->system_under_test->filter_cart_needs_shipping_address( true ), + 'Should not modify shipping address requirement for regular products' + ); + } + + + public function test_filter_cart_needs_shipping_address_subscription_products() { + WC_Subscriptions_Cart::set_cart_contains_subscription( true ); + $this->mock_ece_button_helper->method( 'is_checkout' )->willReturn( true ); + + $this->zone->delete_shipping_method( $this->flat_rate_id ); + $this->zone->delete_shipping_method( $this->local_pickup_id ); + + $this->assertFalse( + $this->system_under_test->filter_cart_needs_shipping_address( true ), + 'Should not require shipping address for subscription without shipping methods' + ); + + remove_filter( 'woocommerce_shipping_method_count', '__return_zero' ); + WC_Subscriptions_Cart::set_cart_contains_subscription( false ); + } +} diff --git a/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-helper.php b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-helper.php index 2432c61172c..9c7ebbef971 100644 --- a/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-helper.php +++ b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-helper.php @@ -28,13 +28,6 @@ class WC_Payments_Express_Checkout_Button_Helper_Test extends WCPAY_UnitTestCase */ private $mock_wcpay_account; - /** - * Express Checkout Helper instance. - * - * @var WC_Payments_Express_Checkout_Button_Helper - */ - private $express_checkout_helper; - /** * Test shipping zone. * @@ -61,21 +54,7 @@ class WC_Payments_Express_Checkout_Button_Helper_Test extends WCPAY_UnitTestCase * * @var WC_Payments_Express_Checkout_Button_Helper */ - private $mock_express_checkout_helper; - - /** - * Express Checkout Ajax Handler instance. - * - * @var WC_Payments_Express_Checkout_Ajax_Handler - */ - private $mock_express_checkout_ajax_handler; - - /** - * Express Checkout ECE Button Handler instance. - * - * @var WC_Payments_Express_Checkout_Button_Handler - */ - private $mock_express_checkout_ece_button_handler; + private $system_under_test; /** * Test product to add to the cart @@ -92,23 +71,7 @@ public function set_up() { $this->mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); $this->mock_wcpay_gateway = $this->make_wcpay_gateway(); - $this->mock_express_checkout_helper = new WC_Payments_Express_Checkout_Button_Helper( $this->mock_wcpay_gateway, $this->mock_wcpay_account ); - $this->mock_express_checkout_ajax_handler = $this->getMockBuilder( WC_Payments_Express_Checkout_Ajax_Handler::class ) - ->setConstructorArgs( - [ - $this->mock_express_checkout_helper, - ] - ) - ->getMock(); - - $this->mock_ece_button_helper = $this->getMockBuilder( WC_Payments_Express_Checkout_Button_Helper::class ) - ->setConstructorArgs( - [ - $this->mock_wcpay_gateway, - $this->mock_wcpay_account, - ] - ) - ->getMock(); + $this->system_under_test = new WC_Payments_Express_Checkout_Button_Helper( $this->mock_wcpay_gateway, $this->mock_wcpay_account ); WC_Helper_Shipping::delete_simple_flat_rate(); $zone = new WC_Shipping_Zone(); @@ -128,7 +91,7 @@ public function set_up() { WC()->session->init(); WC()->cart->add_to_cart( $this->simple_product->get_id(), 1 ); - $this->mock_express_checkout_helper->update_shipping_method( [ self::get_shipping_option_rate_id( $this->flat_rate_id ) ] ); + $this->system_under_test->update_shipping_method( [ self::get_shipping_option_rate_id( $this->flat_rate_id ) ] ); WC()->cart->calculate_totals(); } @@ -141,6 +104,7 @@ public function tear_down() { remove_filter( 'wc_tax_enabled', '__return_false' ); remove_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, '__return_excl' ] ); remove_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, '__return_incl' ] ); + delete_option( '_wcpay_feature_tokenized_cart_ece' ); parent::tear_down(); } @@ -195,34 +159,34 @@ public function test_common_get_button_settings() { 'height' => '48', 'radius' => '', ], - $this->mock_express_checkout_helper->get_common_button_settings() + $this->system_under_test->get_common_button_settings() ); } public function test_cart_prices_include_tax_with_tax_disabled() { add_filter( 'wc_tax_enabled', '__return_false' ); - $this->assertTrue( $this->mock_express_checkout_helper->cart_prices_include_tax() ); + $this->assertTrue( $this->system_under_test->cart_prices_include_tax() ); } public function test_cart_prices_include_tax_with_tax_enabled_and_display_incl() { add_filter( 'wc_tax_enabled', '__return_true' ); // reset in tear_down. add_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, '__return_incl' ] ); // reset in tear_down. - $this->assertTrue( $this->mock_express_checkout_helper->cart_prices_include_tax() ); + $this->assertTrue( $this->system_under_test->cart_prices_include_tax() ); } public function test_cart_prices_include_tax_with_tax_enabled_and_display_excl() { add_filter( 'wc_tax_enabled', '__return_true' ); // reset in tear_down. add_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, '__return_excl' ] ); // reset in tear_down. - $this->assertFalse( $this->mock_express_checkout_helper->cart_prices_include_tax() ); + $this->assertFalse( $this->system_under_test->cart_prices_include_tax() ); } public function test_get_total_label() { $this->mock_wcpay_account->method( 'get_statement_descriptor' ) ->willReturn( 'Google Pay' ); - $result = $this->mock_express_checkout_helper->get_total_label(); + $result = $this->system_under_test->get_total_label(); $this->assertEquals( 'Google Pay (via WooCommerce)', $result ); } @@ -238,49 +202,77 @@ function () { } ); - $result = $this->mock_express_checkout_helper->get_total_label(); + $result = $this->system_under_test->get_total_label(); $this->assertEquals( 'Google Pay (via WooPayments)', $result ); remove_all_filters( 'wcpay_payment_request_total_label_suffix' ); } - public function test_filter_cart_needs_shipping_address_returns_false() { - sleep( 1 ); - $this->zone->delete_shipping_method( $this->flat_rate_id ); - $this->zone->delete_shipping_method( $this->local_pickup_id ); + public function test_should_show_express_checkout_button_for_tokenized_ece_with_billing_email() { + global $wp; + global $wp_query; - WC_Subscriptions_Cart::set_cart_contains_subscription( true ); - - $this->mock_ece_button_helper - ->method( 'is_product' ) + $this->mock_wcpay_account + ->method( 'is_stripe_connected' ) ->willReturn( true ); + WC_Payments::mode()->dev(); + $_GET['pay_for_order'] = true; - $this->mock_express_checkout_ece_button_handler = new WC_Payments_Express_Checkout_Button_Handler( - $this->mock_wcpay_account, - $this->mock_wcpay_gateway, - $this->mock_ece_button_helper, - $this->mock_express_checkout_ajax_handler - ); + // Total is 100 USD, which is above both payment methods (Affirm and AfterPay) minimums. + $order = WC_Helper_Order::create_order( 1, 100 ); + $order_id = $order->get_id(); + $wp->query_vars = [ 'order-pay' => strval( $order_id ) ]; + $wp_query->query_vars = [ 'order-pay' => strval( $order_id ) ]; + + update_option( '_wcpay_feature_tokenized_cart_ece', '1' ); + add_filter( 'woocommerce_is_checkout', '__return_true' ); - $this->assertFalse( $this->mock_express_checkout_ece_button_handler->filter_cart_needs_shipping_address( true ) ); + $this->assertTrue( $this->system_under_test->should_show_express_checkout_button() ); + + remove_filter( 'woocommerce_is_checkout', '__return_true' ); } - public function test_filter_cart_needs_shipping_address_returns_true() { - WC_Subscriptions_Cart::set_cart_contains_subscription( true ); + public function test_should_show_express_checkout_button_for_non_shipping_but_price_includes_tax() { + $this->mock_wcpay_account + ->method( 'is_stripe_connected' ) + ->willReturn( true ); + + WC_Payments::mode()->dev(); + + add_filter( 'woocommerce_is_checkout', '__return_true' ); + add_filter( 'wc_shipping_enabled', '__return_false' ); + add_filter( 'wc_tax_enabled', '__return_true' ); + + update_option( 'woocommerce_tax_based_on', 'billing' ); + update_option( 'woocommerce_prices_include_tax', 'yes' ); - $this->mock_ece_button_helper - ->method( 'is_product' ) + $this->assertTrue( $this->system_under_test->should_show_express_checkout_button() ); + + remove_filter( 'woocommerce_is_checkout', '__return_true' ); + remove_filter( 'wc_tax_enabled', '__return_true' ); + remove_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, '__return_incl' ] ); + } + + public function test_should_not_show_express_checkout_button_for_non_shipping_but_price_does_not_include_tax() { + $this->mock_wcpay_account + ->method( 'is_stripe_connected' ) ->willReturn( true ); - $this->mock_express_checkout_ece_button_handler = new WC_Payments_Express_Checkout_Button_Handler( - $this->mock_wcpay_account, - $this->mock_wcpay_gateway, - $this->mock_ece_button_helper, - $this->mock_express_checkout_ajax_handler - ); + WC_Payments::mode()->dev(); + + add_filter( 'woocommerce_is_checkout', '__return_true' ); + add_filter( 'wc_shipping_enabled', '__return_false' ); + add_filter( 'wc_tax_enabled', '__return_true' ); - $this->assertTrue( $this->mock_express_checkout_ece_button_handler->filter_cart_needs_shipping_address( true ) ); + update_option( 'woocommerce_tax_based_on', 'billing' ); + update_option( 'woocommerce_prices_include_tax', 'no' ); + + $this->assertFalse( $this->system_under_test->should_show_express_checkout_button() ); + + remove_filter( 'woocommerce_is_checkout', '__return_true' ); + remove_filter( 'wc_tax_enabled', '__return_true' ); + remove_filter( 'pre_option_woocommerce_tax_display_cart', [ $this, '__return_incl' ] ); } /** diff --git a/tests/unit/multi-currency/compatibility/test-class-woocommerce-fedex.php b/tests/unit/multi-currency/compatibility/test-class-woocommerce-fedex.php index 60e130390fd..e52927230ca 100644 --- a/tests/unit/multi-currency/compatibility/test-class-woocommerce-fedex.php +++ b/tests/unit/multi-currency/compatibility/test-class-woocommerce-fedex.php @@ -35,6 +35,20 @@ class WCPay_Multi_Currency_WooCommerceFedEx_Tests extends WCPAY_UnitTestCase { */ private $woocommerce_fedex; + /** + * Calls to check in the backtrace. + * + * @var array + */ + private $woocommerce_fedex_calls = [ + 'WC_Shipping_Fedex->set_settings', + 'WC_Shipping_Fedex->per_item_shipping', + 'WC_Shipping_Fedex->box_shipping', + 'WC_Shipping_Fedex->get_fedex_api_request', + 'WC_Shipping_Fedex->get_fedex_requests', + 'WC_Shipping_Fedex->process_result', + ]; + /** * Pre-test setup */ @@ -54,37 +68,45 @@ public function test_should_return_store_currency_returns_true_if_true_passed() // If the calls are found, it should return true. public function test_should_return_store_currency_returns_true_if_calls_found() { - $calls = [ - 'WC_Shipping_Fedex->set_settings', - 'WC_Shipping_Fedex->per_item_shipping', - 'WC_Shipping_Fedex->box_shipping', - 'WC_Shipping_Fedex->get_fedex_api_request', - 'WC_Shipping_Fedex->get_fedex_requests', - 'WC_Shipping_Fedex->process_result', - ]; $this->mock_utils ->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) - ->with( $calls ) + ->with( $this->woocommerce_fedex_calls ) ->willReturn( true ); + $this->assertTrue( $this->woocommerce_fedex->should_return_store_currency( false ) ); } - // If the calls are found, it should return true. + // If the calls are not found, it should return false. public function test_should_return_store_currency_returns_false_if_no_calls_found() { - $calls = [ - 'WC_Shipping_Fedex->set_settings', - 'WC_Shipping_Fedex->per_item_shipping', - 'WC_Shipping_Fedex->box_shipping', - 'WC_Shipping_Fedex->get_fedex_api_request', - 'WC_Shipping_Fedex->get_fedex_requests', - 'WC_Shipping_Fedex->process_result', - ]; $this->mock_utils ->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) - ->with( $calls ) + ->with( $this->woocommerce_fedex_calls ) ->willReturn( false ); + $this->assertFalse( $this->woocommerce_fedex->should_return_store_currency( false ) ); } + + // If true is passed to should_convert_product_price and no calls are found, it should return true. + public function test_should_convert_product_price_returns_true_if_true_passed_and_no_calls_found() { + $this->mock_utils + ->expects( $this->once() ) + ->method( 'is_call_in_backtrace' ) + ->with( $this->woocommerce_fedex_calls ) + ->willReturn( false ); + + $this->assertTrue( $this->woocommerce_fedex->should_convert_product_price( true ) ); + } + + // If calls are found, should_convert_product_price should return false even if true was passed. + public function test_should_convert_product_price_returns_false_if_calls_found() { + $this->mock_utils + ->expects( $this->once() ) + ->method( 'is_call_in_backtrace' ) + ->with( $this->woocommerce_fedex_calls ) + ->willReturn( true ); + + $this->assertFalse( $this->woocommerce_fedex->should_convert_product_price( true ) ); + } } diff --git a/tests/unit/src/Internal/Service/Level3ServiceTest.php b/tests/unit/src/Internal/Service/Level3ServiceTest.php index fe3eee573c3..dba9766386d 100644 --- a/tests/unit/src/Internal/Service/Level3ServiceTest.php +++ b/tests/unit/src/Internal/Service/Level3ServiceTest.php @@ -167,6 +167,10 @@ protected function mock_level_3_order( $mock_items = array_merge( $mock_items, array_fill( 0, $basket_size - count( $mock_items ), $mock_items[0] ) ); } + $this->mock_order( $mock_items, $shipping_postcode ); + } + + protected function mock_order( array $mock_items, string $shipping_postcode ) { // Setup the order. $mock_order = $this ->getMockBuilder( WC_Order::class ) @@ -434,6 +438,25 @@ public function test_full_level3_data_with_float_quantity() { $this->assertEquals( $expected_data, $level_3_data ); } + public function test_rounding_in_edge_cases() { + $this->mock_account->method( 'get_account_country' )->willReturn( Country_Code::UNITED_STATES ); + + $mock_items = []; + $mock_items[] = $this->create_mock_item( 'Beanie with Addon', 3, 73, 0, 30 ); + $this->mock_order( $mock_items, '98012' ); + + $level_3_data = $this->sut->get_data_from_order( $this->order_id ); + + $this->assertCount( 2, $level_3_data['line_items'] ); + $this->assertEquals( 2433, $level_3_data['line_items'][0]->unit_cost ); + $this->assertEquals( 'rounding-fix', $level_3_data['line_items'][1]->product_code ); + $this->assertEquals( 'Rounding fix', $level_3_data['line_items'][1]->product_description ); + $this->assertEquals( 1, $level_3_data['line_items'][1]->unit_cost ); + $this->assertEquals( 1, $level_3_data['line_items'][1]->quantity ); + $this->assertEquals( 0, $level_3_data['line_items'][1]->tax_amount ); + $this->assertEquals( 0, $level_3_data['line_items'][1]->discount_amount ); + } + public function test_full_level3_data_with_float_quantity_zero() { $expected_data = [ 'merchant_reference' => '210', diff --git a/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php b/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php index 3fc4a56c8f6..fb95bcf1591 100644 --- a/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php +++ b/tests/unit/wc-payment-api/test-class-wc-payments-api-client.php @@ -7,7 +7,9 @@ use WCPay\Constants\Country_Code; use WCPay\Constants\Intent_Status; +use WCPay\Core\Server\Request\Create_And_Confirm_Intention; use WCPay\Exceptions\API_Exception; +use WCPay\Exceptions\API_Merchant_Exception; use WCPay\Internal\Logger; use WCPay\Exceptions\Connection_Exception; use WCPay\Fraud_Prevention\Fraud_Prevention_Service; @@ -1195,6 +1197,24 @@ public function test_get_tracking_info() { $this->assertEquals( $expect, $result ); } + public function test_throws_api_merchant_exception() { + $mock_response = []; + $mock_response['error']['code'] = 'card_declined'; + $mock_response['error']['payment_intent']['charges']['data'][0]['outcome']['seller_message'] = 'Bank declined'; + $this->set_http_mock_response( + 401, + $mock_response + ); + + try { + // This is a dummy call to trigger the response so that our test can validate the exception. + $this->payments_api_client->create_subscription(); + } catch ( API_Merchant_Exception $e ) { + $this->assertSame( 'card_declined', $e->get_error_code() ); + $this->assertSame( 'Bank declined', $e->get_merchant_message() ); + } + } + /** * Set up http mock response. * diff --git a/woocommerce-payments.php b/woocommerce-payments.php index e69ae1934ad..c606a36f8b9 100644 --- a/woocommerce-payments.php +++ b/woocommerce-payments.php @@ -11,7 +11,7 @@ * WC tested up to: 9.4.0 * Requires at least: 6.0 * Requires PHP: 7.3 - * Version: 8.6.0 + * Version: 8.6.1 * Requires Plugins: woocommerce * * @package WooCommerce\Payments