diff --git a/assets/images/payment-method-icons/grabpay.svg b/assets/images/payment-method-icons/grabpay.svg new file mode 100644 index 00000000000..21c382f34b2 --- /dev/null +++ b/assets/images/payment-method-icons/grabpay.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/payment-methods/grabpay.svg b/assets/images/payment-methods/grabpay.svg new file mode 100644 index 00000000000..21c382f34b2 --- /dev/null +++ b/assets/images/payment-methods/grabpay.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bin/run-tests.sh b/bin/run-tests.sh index cf194d39079..7a2ac899263 100755 --- a/bin/run-tests.sh +++ b/bin/run-tests.sh @@ -28,7 +28,7 @@ if $WATCH_FLAG; then else echo "Running the tests..." - docker-compose exec -u www-data wordpress \ + docker compose exec -u www-data wordpress \ /var/www/html/wp-content/plugins/woocommerce-payments/vendor/bin/phpunit \ --configuration /var/www/html/wp-content/plugins/woocommerce-payments/phpunit.xml.dist \ $* diff --git a/changelog.txt b/changelog.txt index 62ee75bda53..840d9540e05 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,8 @@ *** WooPayments Changelog *** += 8.9.1 - 2025-02-07 = +* Fix - Ensures that the tokenised cart for ECE implementation is disabled by default. + = 8.9.0 - 2025-02-04 = * Add - Add a popover to WooPayments to present all possible payment methods * Add - Added persistent column visibility preferences for reporting tables, allowing merchants to customize and save their preferred table view layouts across sessions. diff --git a/changelog/add-grabpay-settings b/changelog/add-grabpay-settings new file mode 100644 index 00000000000..23d57644da6 --- /dev/null +++ b/changelog/add-grabpay-settings @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add GrabPay to the settings page when eligible. diff --git a/changelog/add-pass-business-name-express-checkout b/changelog/add-pass-business-name-express-checkout new file mode 100644 index 00000000000..f9fa1b9c63c --- /dev/null +++ b/changelog/add-pass-business-name-express-checkout @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Pass the business name to the express checkout handler. diff --git a/changelog/dev-e2e-pw-setup-script-permissions-and-project b/changelog/dev-e2e-pw-setup-script-permissions-and-project new file mode 100644 index 00000000000..5986562ff22 --- /dev/null +++ b/changelog/dev-e2e-pw-setup-script-permissions-and-project @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Update E2E setup scripts to avoid conflicts with other containers and permissions. diff --git a/changelog/feat-add-order-notes-to-tokenized-ece b/changelog/feat-add-order-notes-to-tokenized-ece new file mode 100644 index 00000000000..ccd4c9ddfdd --- /dev/null +++ b/changelog/feat-add-order-notes-to-tokenized-ece @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +feat: add order notes to tokenized ECE GooglePay/ApplePay on blocks checkout diff --git a/changelog/fix-10220-multi-currency-widget-markup-getter b/changelog/fix-10220-multi-currency-widget-markup-getter new file mode 100644 index 00000000000..6954f8a9c60 --- /dev/null +++ b/changelog/fix-10220-multi-currency-widget-markup-getter @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Ensure multi-currency widget markup getter don't throw errors. diff --git a/changelog/patch-disable-tokenised-carts-by-default b/changelog/patch-disable-tokenised-carts-by-default new file mode 100644 index 00000000000..0c3c7646c25 --- /dev/null +++ b/changelog/patch-disable-tokenised-carts-by-default @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Ensures that the tokenised cart for ECE implementation is disabled by default. diff --git a/changelog/update-add-handling-for-low-refund-balance b/changelog/update-add-handling-for-low-refund-balance new file mode 100644 index 00000000000..ee060808e3a --- /dev/null +++ b/changelog/update-add-handling-for-low-refund-balance @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update handling for refund processing in case of insufficient funds. diff --git a/changelog/update-remove-card-reader-receipt-details b/changelog/update-remove-card-reader-receipt-details new file mode 100644 index 00000000000..2958d7947a7 --- /dev/null +++ b/changelog/update-remove-card-reader-receipt-details @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Remove the receipt details tab in the card readers page. diff --git a/client/additional-methods-setup/constants.js b/client/additional-methods-setup/constants.js index b189fc86459..75f8e850894 100644 --- a/client/additional-methods-setup/constants.js +++ b/client/additional-methods-setup/constants.js @@ -11,6 +11,7 @@ export const upeMethods = [ 'afterpay_clearpay', 'jcb', 'klarna', + 'grabpay', ]; export const upeCapabilityStatuses = { diff --git a/client/card-readers/index.tsx b/client/card-readers/index.tsx index b0dea2257e6..4b2fe9b89cb 100644 --- a/client/card-readers/index.tsx +++ b/client/card-readers/index.tsx @@ -11,7 +11,6 @@ import { __ } from '@wordpress/i18n'; */ import Page from 'components/page'; import ReadersList from './list'; -import ReceiptSettings from './settings'; import { TabPanel } from '@wordpress/components'; import './style.scss'; @@ -31,20 +30,9 @@ export const ConnectedReaders = (): JSX.Element => { ), className: 'connected-readers-list', }, - { - name: 'receipt-details', - title: __( 'Receipt details', 'woocommerce-payments' ), - className: 'connected-readers-receipt-details', - }, ] } > - { ( tab ) => { - if ( 'receipt-details' === tab.name ) { - return ; - } - - return ; - } } + { () => } ); diff --git a/client/card-readers/settings/file-upload.tsx b/client/card-readers/settings/file-upload.tsx deleted file mode 100644 index 63fcf597e96..00000000000 --- a/client/card-readers/settings/file-upload.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** @format */ -/** - * External dependencies - */ -import React from 'react'; -import { recordEvent } from 'tracks'; -import apiFetch from '@wordpress/api-fetch'; -import { __ } from '@wordpress/i18n'; -import { useDispatch } from '@wordpress/data'; -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { FileUploadControl } from 'components/file-upload'; - -interface CardReaderFileUploadProps { - fieldKey: string; - label: string; - accept: string; - disabled?: boolean; - help?: string; - purpose: string; - fileID: string; - updateFileID: ( id: string ) => void; -} - -const BrandingFileUpload: React.FunctionComponent< CardReaderFileUploadProps > = ( - props -) => { - const { - fieldKey, - label, - accept, - disabled, - help, - purpose, - fileID, - updateFileID, - } = props; - - const [ isLoading, setLoading ] = useState( false ); - const [ uploadError, setUploadError ] = useState< boolean | string >( - false - ); - - const { createErrorNotice } = useDispatch( 'core/notices' ); - - const fileSizeExceeded = ( size: number ) => { - const fileSizeLimitInBytes = 510000; - if ( fileSizeLimitInBytes < size ) { - createErrorNotice( - __( - 'The file you have attached is exceeding the maximum limit.', - 'woocommerce-payments' - ) - ); - - return true; - } - }; - - const handleFileChange = async ( key: string, file: File ) => { - if ( ! file ) { - return; - } - - if ( fileSizeExceeded( file.size ) ) { - return; - } - - setLoading( true ); - - recordEvent( 'wcpay_merchant_settings_file_upload_started', { - type: key, - } ); - - const body = new FormData(); - body.append( 'file', file ); - body.append( 'purpose', purpose ); - // Interpreting as_account as Boolean false in the backend - body.append( 'as_account', '0' ); - - try { - const uploadedFile: unknown = await apiFetch( { - path: '/wc/v3/payments/file', - method: 'post', - body, - } ); - - if ( uploadedFile ) { - // Store uploaded file ID. - updateFileID( ( uploadedFile as any ).id ); - } - - setLoading( false ); - setUploadError( false ); - - recordEvent( 'wcpay_merchant_settings_file_upload_success', { - type: key, - } ); - } catch ( { err } ) { - recordEvent( 'wcpay_merchant_settings_file_upload_success', { - message: ( err as Error ).message, - } ); - - // Remove file ID - updateFileID( '' ); - - setLoading( false ); - setUploadError( ( err as Error ).message || '' ); - - // Show error notice - createErrorNotice( ( err as Error ).message ); - } - }; - - const handleFileRemove = () => { - updateFileID( '' ); - - setLoading( false ); - setUploadError( false ); - }; - - const isDone = ( ! isLoading && fileID && 0 < fileID.length ) as boolean; - const error = ( uploadError || '' ) as string; - - return ( -
- -
- ); -}; - -export default BrandingFileUpload; diff --git a/client/card-readers/settings/index.tsx b/client/card-readers/settings/index.tsx deleted file mode 100644 index 017db6bbf7b..00000000000 --- a/client/card-readers/settings/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** @format */ -/** - * External dependencies - */ -import React from 'react'; -import { __ } from '@wordpress/i18n'; -import { Card, CardBody } from '@wordpress/components'; -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SettingsSection from 'wcpay/settings/settings-section'; -import SettingsLayout from 'wcpay/settings/settings-layout'; -import LoadableSettingsSection from 'wcpay/settings/loadable-settings-section'; -import SaveSettingsSection from 'wcpay/settings/save-settings-section'; -import BusinessDetailsSection from './sections/business-details'; -import ContactsDetailsSection from './sections/contacts-details'; -import AddressDetailsSection from './sections/address-details'; -import BrandingDetailsSection from './sections/branding-details'; - -const ReadersSettingsDescription = (): JSX.Element => ( - <> -

{ __( 'Card reader receipts', 'woocommerce-payments' ) }

-

- { __( - 'These details will appear on emailed receipts for customers that pay in person using card readers. ' + - 'Updating the details here will not affect any other stores settings.', - 'woocommerce-payments' - ) } -

- -); - -const ReceiptSettings = (): JSX.Element => { - const [ isBusinessInputsValid, setBusinessInputsValid ] = useState( true ); - const [ isContactsInputsValid, setContactsInputsValid ] = useState( true ); - const areInputsValid = isBusinessInputsValid && isContactsInputsValid; - - return ( - - - - - - - - - - - - - - - - ); -}; - -export default ReceiptSettings; diff --git a/client/card-readers/settings/sections/address-details.js b/client/card-readers/settings/sections/address-details.js deleted file mode 100644 index 3646d6bbbd6..00000000000 --- a/client/card-readers/settings/sections/address-details.js +++ /dev/null @@ -1,125 +0,0 @@ -/** @format */ -/** - * External dependencies - */ -import React from 'react'; -import { __ } from '@wordpress/i18n'; -import { TextControl, SelectControl, Notice } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import { - useAccountBusinessSupportAddress, - useGetSavingError, -} from '../../../data'; - -const AddressDetailsSection = () => { - const [ - accountBusinessSupportAddress, - accountBusinessSupportAddressCountry, - accountBusinessSupportAddressLine1, - accountBusinessSupportAddressLine2, - accountBusinessSupportAddressCity, - accountBusinessSupportAddressState, - accountBusinessSupportAddressPostalCode, - setAccountBusinessSupportAddress, - ] = useAccountBusinessSupportAddress(); - - const businessSuppotAddressErrorMessage = useGetSavingError()?.data?.details - ?.account_business_support_address?.message; - - const handleAddressPropertyChange = ( property, value ) => { - setAccountBusinessSupportAddress( { - ...accountBusinessSupportAddress, - [ property ]: value, - } ); - }; - - const unescapeHtmlEntities = ( string ) => { - const doc = new DOMParser().parseFromString( string, 'text/html' ); - return doc.documentElement.textContent; - }; - - const countriesOptions = Object.entries( wcSettings.countries ).map( - ( [ value, label ] ) => ( { - label: unescapeHtmlEntities( label ), - value: value, - } ) - ); - - const countryStates = - wcpaySettings.connect.availableStates[ - accountBusinessSupportAddressCountry - ] || []; - const countryStatesOptions = Object.entries( countryStates ).map( - ( [ value, label ] ) => ( { - label: unescapeHtmlEntities( label ), - value: unescapeHtmlEntities( label ), - country: value, - } ) - ); - - return ( - <> -

{ __( 'Business address', 'woocommerce-payments' ) }

- { businessSuppotAddressErrorMessage && ( - - { businessSuppotAddressErrorMessage } - - ) } - - handleAddressPropertyChange( 'country', value ) - } - options={ countriesOptions } - /> - - handleAddressPropertyChange( 'line1', value ) - } - /> - - handleAddressPropertyChange( 'line2', value ) - } - /> - - handleAddressPropertyChange( 'city', value ) - } - /> - { countryStatesOptions.length > 0 && ( - - handleAddressPropertyChange( 'state', value ) - } - options={ countryStatesOptions } - /> - ) } - - handleAddressPropertyChange( 'postal_code', value ) - } - /> - - ); -}; - -export default AddressDetailsSection; diff --git a/client/card-readers/settings/sections/branding-details.js b/client/card-readers/settings/sections/branding-details.js deleted file mode 100644 index ec18653c501..00000000000 --- a/client/card-readers/settings/sections/branding-details.js +++ /dev/null @@ -1,59 +0,0 @@ -/** @format */ -/** - * External dependencies - */ -import React from 'react'; -import { __ } from '@wordpress/i18n'; -import { useEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import BrandingFileUpload from '../file-upload'; - -import { useAccountBrandingLogo } from '../../../data'; - -const BrandingDetailsSection = () => { - const [ - getAccountBrandingLogo, - setAccountBrandingLogo, - ] = useAccountBrandingLogo(); - - useEffect( () => { - document - .querySelectorAll( - '.components-circular-option-picker__dropdown-link-action button' - ) - .forEach( function ( el ) { - el.innerHTML = __( 'Edit', 'woocommerce-payments' ); - } ); - }, [] ); - - return ( - <> -

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

-

- { __( - 'Your business’s logo will be used on printed receipts.', - 'woocommerce-payments' - ) } -

- - - - ); -}; - -export default BrandingDetailsSection; diff --git a/client/card-readers/settings/sections/business-details.js b/client/card-readers/settings/sections/business-details.js deleted file mode 100644 index e1e201c25fb..00000000000 --- a/client/card-readers/settings/sections/business-details.js +++ /dev/null @@ -1,77 +0,0 @@ -/** @format */ -/** - * External dependencies - */ -import { React, useLayoutEffect } from 'react'; -import { __ } from '@wordpress/i18n'; -import { TextControl, Notice } from '@wordpress/components'; -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { useAccountBusinessName, useAccountBusinessURL } from '../../../data'; - -const BusinessDetailsSection = ( { setInputsValid } ) => { - const [ hasError, setHasError ] = useState( false ); - - const [ - accountBusinessName, - setAccountBusinessName, - ] = useAccountBusinessName(); - - const [ - accountBusinessURL, - setAccountBusinessURL, - ] = useAccountBusinessURL(); - - useLayoutEffect( () => { - const businessUrl = document.querySelector( - '.card-readers-business-url-input input' - ); - businessUrl.focus(); - businessUrl.blur(); - }, [] ); - - const validateBusinessURL = ( event ) => { - if ( event.target.checkValidity() ) { - setHasError( false ); - setInputsValid( true ); - } else { - setHasError( true ); - setInputsValid( false ); - } - }; - - return ( - <> -

{ __( 'Business details', 'woocommerce-payments' ) }

- - { hasError && ( - - - { __( - 'Error: Invalid business URL, should start with http:// or https:// prefix.', - 'woocommerce-payments' - ) } - - - ) } - - - ); -}; - -export default BusinessDetailsSection; diff --git a/client/card-readers/settings/sections/contacts-details.js b/client/card-readers/settings/sections/contacts-details.js deleted file mode 100644 index b54a3d62576..00000000000 --- a/client/card-readers/settings/sections/contacts-details.js +++ /dev/null @@ -1,33 +0,0 @@ -/** @format */ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import SupportPhoneInput from 'wcpay/settings/support-phone-input'; -import SupportEmailInput from 'wcpay/settings/support-email-input'; -import React, { useEffect, useState } from 'react'; - -const ContactDetailsSection = ( { setInputsValid } ) => { - const [ isEmailInputValid, setEmailInputValid ] = useState( true ); - const [ isPhoneInputValid, setPhoneInputValid ] = useState( true ); - - useEffect( () => { - setInputsValid( isEmailInputValid && isPhoneInputValid ); - }, [ isEmailInputValid, isPhoneInputValid, setInputsValid ] ); - - return ( - <> -

- { __( 'Customer support contacts', 'woocommerce-payments' ) } -

- - - - ); -}; - -export default ContactDetailsSection; diff --git a/client/card-readers/settings/sections/test/index.test.js b/client/card-readers/settings/sections/test/index.test.js deleted file mode 100644 index 68244127851..00000000000 --- a/client/card-readers/settings/sections/test/index.test.js +++ /dev/null @@ -1,149 +0,0 @@ -/** - * External dependencies - */ -import { render, screen } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import BusinessDetailsSection from '../business-details'; -import ContactsDetailsSection from '../contacts-details'; -import AddressDetailsSection from '../address-details'; -import BrandingDetailsSection from '../branding-details'; -import BrandingFileUpload from '../../file-upload'; - -jest.mock( '../../file-upload', () => jest.fn() ); - -const setInputsValidMock = jest.fn(); - -describe( 'Card Reader Business Details section', () => { - beforeEach( () => { - global.wcpaySettings = { - connect: { - country: 'US', - availableCountries: { US: 'United States (US)' }, - availableStates: [ - { - US: [ 'Florida', 'California', 'Texas' ], - }, - ], - }, - accountStatus: { - country: 'US', - }, - }; - } ); - - test( 'Renders Business section', () => { - render( - - ); - - const heading = screen.queryByRole( 'heading', { - name: 'Business details', - } ); - expect( heading ).toBeInTheDocument(); - } ); - - test( 'Renders Business settings', () => { - render( - - ); - - const name = screen.getByLabelText( 'Business name' ); - expect( name ).toBeInTheDocument(); - - const url = screen.getByLabelText( 'Business URL' ); - expect( url ).toBeInTheDocument(); - } ); -} ); - -describe( 'Card Reader Contact Details section', () => { - test( 'Renders Contacts section', () => { - render( - - ); - - const heading = screen.queryByRole( 'heading', { - name: 'Customer support contacts', - } ); - expect( heading ).toBeInTheDocument(); - } ); - - test( 'Renders Contacts settings', () => { - render( - - ); - - const email = screen.getByLabelText( 'Support email' ); - expect( email ).toBeInTheDocument(); - - const phone = screen.getByLabelText( 'Support phone number' ); - expect( phone ).toBeInTheDocument(); - } ); -} ); - -describe( 'Card Reader Address Details section', () => { - beforeEach( () => { - jest.clearAllMocks(); - global.wcSettings = { - countries: { - US: 'United States of America', - }, - }; - } ); - - afterEach( () => { - delete global.wcSettings; - } ); - - test( 'Renders Address section', () => { - render( ); - - const heading = screen.queryByRole( 'heading', { - name: 'Business address', - } ); - expect( heading ).toBeInTheDocument(); - } ); - - test( 'Renders Address settings', () => { - render( ); - - const country = screen.getByLabelText( 'Country' ); - expect( country ).toBeInTheDocument(); - - const address1 = screen.getByLabelText( 'Address line 1' ); - expect( address1 ).toBeInTheDocument(); - - const address2 = screen.getByLabelText( 'Address line 2' ); - expect( address2 ).toBeInTheDocument(); - - const city = screen.getByLabelText( 'City' ); - expect( city ).toBeInTheDocument(); - - const zip = screen.getByLabelText( 'Postal code' ); - expect( zip ).toBeInTheDocument(); - } ); -} ); - -describe( 'Card Reader Branding Details section', () => { - beforeEach( () => { - BrandingFileUpload.mockReturnValue(
File Upload
); - } ); - - test( 'Renders Contacts section', () => { - render( ); - - const heading = screen.queryByRole( 'heading', { - name: 'Branding', - } ); - expect( heading ).toBeInTheDocument(); - } ); - - test( 'Renders Contacts settings', () => { - render( ); - - const fileUploadFields = screen.queryAllByText( 'File Upload' ); - expect( fileUploadFields.length ).toEqual( 1 ); - } ); -} ); diff --git a/client/card-readers/settings/test/__snapshots__/index.test.tsx.snap b/client/card-readers/settings/test/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 27e80536c55..00000000000 --- a/client/card-readers/settings/test/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,56 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ReceiptSettings Readers merchant settings page snapshot test 1`] = ` -
-
-
-
-

- Card reader receipts -

-

- These details will appear on emailed receipts for customers that pay in person using card readers. Updating the details here will not affect any other stores settings. -

-
-
- -

- Block placeholder -

-
-
-
-
-
-
- -
-
-
-
-`; diff --git a/client/card-readers/settings/test/index.test.tsx b/client/card-readers/settings/test/index.test.tsx deleted file mode 100644 index a27f2b7500a..00000000000 --- a/client/card-readers/settings/test/index.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * External dependencies - */ -import React from 'react'; -import { render, screen } from '@testing-library/react'; - -/** - * Internal dependencies - */ -import ReceiptSettings from '..'; - -describe( 'ReceiptSettings', () => { - test( 'Readers merchant settings page renders', () => { - render( ); - - expect( - screen.queryByText( 'Card reader receipts' ) - ).toBeInTheDocument(); - } ); - - test( 'Readers merchant settings page snapshot test', () => { - const { container } = render( ); - expect( container ).toMatchSnapshot(); - } ); -} ); diff --git a/client/card-readers/test/index.test.tsx b/client/card-readers/test/index.test.tsx index cc608c6540b..b8e97b875ee 100644 --- a/client/card-readers/test/index.test.tsx +++ b/client/card-readers/test/index.test.tsx @@ -14,7 +14,5 @@ describe( 'CardReadersSettings', () => { render( ); expect( screen.queryByText( 'Connected readers' ) ).toBeInTheDocument(); - - expect( screen.queryByText( 'Receipt details' ) ).toBeInTheDocument(); } ); } ); diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js index da139e3a27c..31c4bf32bf8 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -40,6 +40,7 @@ import { PAYMENT_METHOD_NAME_AFFIRM, PAYMENT_METHOD_NAME_AFTERPAY, PAYMENT_METHOD_NAME_KLARNA, + PAYMENT_METHOD_NAME_GRABPAY, } from '../constants.js'; import { getDeferredIntentCreationUPEFields } from './payment-elements'; import { handleWooPayEmailInput } from '../woopay/email-input-iframe'; @@ -61,6 +62,7 @@ const upeMethods = { affirm: PAYMENT_METHOD_NAME_AFFIRM, afterpay_clearpay: PAYMENT_METHOD_NAME_AFTERPAY, klarna: PAYMENT_METHOD_NAME_KLARNA, + grabpay: PAYMENT_METHOD_NAME_GRABPAY, }; const enabledPaymentMethodsConfig = getUPEConfig( 'paymentMethodsConfig' ); diff --git a/client/checkout/constants.js b/client/checkout/constants.js index 1c9b616fe58..ae5a49f5e16 100644 --- a/client/checkout/constants.js +++ b/client/checkout/constants.js @@ -11,6 +11,7 @@ export const PAYMENT_METHOD_NAME_AFFIRM = 'woocommerce_payments_affirm'; export const PAYMENT_METHOD_NAME_AFTERPAY = 'woocommerce_payments_afterpay_clearpay'; export const PAYMENT_METHOD_NAME_KLARNA = 'woocommerce_payments_klarna'; +export const PAYMENT_METHOD_NAME_GRABPAY = 'woocommerce_payments_grabpay'; export const PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT = 'woocommerce_payments_express_checkout'; export const PAYMENT_METHOD_NAME_WOOPAY_EXPRESS_CHECKOUT = @@ -32,6 +33,7 @@ export function getPaymentMethodsConstants() { PAYMENT_METHOD_NAME_AFTERPAY, PAYMENT_METHOD_NAME_CARD, PAYMENT_METHOD_NAME_KLARNA, + PAYMENT_METHOD_NAME_GRABPAY, ]; } diff --git a/client/components/payment-method-logos/index.tsx b/client/components/payment-method-logos/index.tsx index beb73857366..6ffb5e22860 100644 --- a/client/components/payment-method-logos/index.tsx +++ b/client/components/payment-method-logos/index.tsx @@ -16,6 +16,7 @@ import ApplePay from 'assets/images/payment-method-icons/applepay.svg?asset'; import AfterPay from 'assets/images/payment-method-icons/afterpay.svg?asset'; import Affirm from 'assets/images/payment-method-icons/affirm.svg?asset'; import Klarna from 'assets/images/payment-method-icons/klarna.svg?asset'; +import GrabPay from 'assets/images/payment-method-icons/grabpay.svg?asset'; import Jcb from 'assets/images/payment-method-icons/jcb.svg?asset'; import GooglePay from 'assets/images/payment-method-icons/gpay.svg?asset'; import Cartebancaire from 'assets/images/cards/cartes_bancaires.svg?asset'; @@ -110,6 +111,10 @@ const PaymentMethods = [ name: 'przelewy24', component: Przelewy24, }, + { + name: 'grabpay', + component: GrabPay, + }, ]; export const WooPaymentsMethodsLogos: React.VFC< { diff --git a/client/constants/payment-method.ts b/client/constants/payment-method.ts index 4ca2b2d7dc4..1bda071b1a6 100644 --- a/client/constants/payment-method.ts +++ b/client/constants/payment-method.ts @@ -12,6 +12,7 @@ enum PAYMENT_METHOD_IDS { CARD_PRESENT = 'card_present', EPS = 'eps', KLARNA = 'klarna', + GRABPAY = 'grabpay', GIROPAY = 'giropay', IDEAL = 'ideal', LINK = 'link', @@ -46,6 +47,7 @@ export const PAYMENT_METHOD_TITLES = { ideal: __( 'iDEAL', 'woocommerce-payments' ), jcb: __( 'JCB', 'woocommerce-payments' ), klarna: __( 'Klarna', 'woocommerce-payments' ), + grabpay: __( 'GrabPay', 'woocommerce-payments' ), link: __( 'Link', 'woocommerce-payments' ), mastercard: __( 'Mastercard', 'woocommerce-payments' ), multibanco: __( 'Multibanco', 'woocommerce-payments' ), diff --git a/client/data/transactions/hooks.ts b/client/data/transactions/hooks.ts index ce63bfb5a49..c3a1741a6c2 100644 --- a/client/data/transactions/hooks.ts +++ b/client/data/transactions/hooks.ts @@ -61,6 +61,7 @@ export interface Transaction { | 'ideal' | 'jcb' | 'klarna' + | 'grabpay' | 'link' | 'mastercard' | 'multibanco' diff --git a/client/express-checkout/blocks/hooks/use-express-checkout.js b/client/express-checkout/blocks/hooks/use-express-checkout.js index 5e2cbd1774b..0e18dc7d428 100644 --- a/client/express-checkout/blocks/hooks/use-express-checkout.js +++ b/client/express-checkout/blocks/hooks/use-express-checkout.js @@ -92,6 +92,9 @@ export const useExpressCheckout = ( { } const options = { + business: { + name: getExpressCheckoutData( 'store_name' ), + }, lineItems: normalizeLineItems( billing?.cartTotalItems ), emailRequired: true, shippingAddressRequired, diff --git a/client/express-checkout/index.js b/client/express-checkout/index.js index f1cbdc5ebb0..f37bd453a6b 100644 --- a/client/express-checkout/index.js +++ b/client/express-checkout/index.js @@ -313,6 +313,9 @@ jQuery( ( $ ) => { } const clickOptions = { + business: { + name: getExpressCheckoutData( 'store_name' ), + }, lineItems: normalizeLineItems( options.displayItems ), emailRequired: true, shippingAddressRequired: options.requestShipping, diff --git a/client/payment-methods-icons.tsx b/client/payment-methods-icons.tsx index b0a76e3f2fd..b073bfab8dc 100644 --- a/client/payment-methods-icons.tsx +++ b/client/payment-methods-icons.tsx @@ -21,6 +21,7 @@ import AfterpayAsset from 'assets/images/payment-methods/afterpay-logo.svg?asset import ClearpayAsset from 'assets/images/payment-methods/clearpay.svg?asset'; import JCBAsset from 'assets/images/payment-methods/jcb.svg?asset'; import KlarnaAsset from 'assets/images/payment-methods/klarna.svg?asset'; +import GrabPayAsset from 'assets/images/payment-methods/grabpay.svg?asset'; import VisaAsset from 'assets/images/cards/visa.svg?asset'; import MasterCardAsset from 'assets/images/cards/mastercard.svg?asset'; import AmexAsset from 'assets/images/cards/amex.svg?asset'; @@ -150,6 +151,10 @@ export const VisaIcon = iconComponent( VisaAsset, __( 'Visa', 'woocommerce-payments' ) ); +export const GrabPayIcon = iconComponent( + GrabPayAsset, + __( 'GrabPay', 'woocommerce-payments' ) +); export const WooIcon = iconComponent( WooAsset, __( 'WooPay', 'woocommerce-payments' ), diff --git a/client/payment-methods-map.tsx b/client/payment-methods-map.tsx index 69fa3e0f74a..2df498a6dcf 100644 --- a/client/payment-methods-map.tsx +++ b/client/payment-methods-map.tsx @@ -22,6 +22,7 @@ import { P24Icon, SepaIcon, SofortIcon, + GrabPayIcon, } from 'wcpay/payment-methods-icons'; const accountCountry = window.wcpaySettings?.accountStatus?.country || 'US'; @@ -233,6 +234,20 @@ const PaymentMethodInformationObject: Record< allows_pay_later: true, accepts_only_domestic_payment: true, }, + grabpay: { + id: 'grabpay', + label: __( 'GrabPay', 'woocommerce-payments' ), + description: __( + 'A popular digital wallet for cashless payments in Singapore.', + 'woocommerce-payments' + ), + icon: GrabPayIcon, + currencies: [ 'SGD' ], + stripe_key: 'grabpay_payments', + allows_manual_capture: false, + allows_pay_later: false, + accepts_only_domestic_payment: false, + }, }; export default PaymentMethodInformationObject; diff --git a/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js b/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js index f1e28fd66f9..c81670891e4 100644 --- a/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js +++ b/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js @@ -92,6 +92,9 @@ export const useExpressCheckout = ( { } const options = { + business: { + name: getExpressCheckoutData( 'store_name' ), + }, lineItems: normalizeLineItems( billing?.cartTotalItems ), emailRequired: true, shippingAddressRequired, @@ -124,7 +127,12 @@ export const useExpressCheckout = ( { elements, completePayment, abortPayment, - event + { + ...event, + order_comments: wp?.data + ?.select( 'wc/store/checkout' ) + ?.getOrderNotes(), + } ); }; diff --git a/client/tokenized-express-checkout/index.js b/client/tokenized-express-checkout/index.js index 6143c540a59..2f7330e7133 100644 --- a/client/tokenized-express-checkout/index.js +++ b/client/tokenized-express-checkout/index.js @@ -269,6 +269,9 @@ jQuery( ( $ ) => { // The "real" values will be updated once the button loads. // They are preemptively initialized because the `event.resolve({})` // needs to be called within 1 second of the `click` event. + business: { + name: getExpressCheckoutData( 'store_name' ), + }, lineItems: options.displayItems, emailRequired: true, shippingAddressRequired: options.requestShipping, diff --git a/client/types/charges.d.ts b/client/types/charges.d.ts index df26095d92c..63f49a66f38 100644 --- a/client/types/charges.d.ts +++ b/client/types/charges.d.ts @@ -40,6 +40,7 @@ export interface PaymentMethodDetails { | 'giropay' | 'ideal' | 'klarna' + | 'grabpay' | 'p24' | 'sepa_debit' | 'sofort'; diff --git a/client/types/payment-methods.d.ts b/client/types/payment-methods.d.ts index 504c2927270..064fc1a431b 100644 --- a/client/types/payment-methods.d.ts +++ b/client/types/payment-methods.d.ts @@ -15,6 +15,7 @@ export type PaymentMethod = | 'card_present' | 'eps' | 'klarna' + | 'grabpay' | 'giropay' | 'ideal' | 'p24' diff --git a/client/utils/account-fees.tsx b/client/utils/account-fees.tsx index 791b727b164..5bbe260b5f3 100644 --- a/client/utils/account-fees.tsx +++ b/client/utils/account-fees.tsx @@ -388,6 +388,8 @@ export const getTransactionsPaymentMethodName = ( return __( 'Afterpay transactions', 'woocommerce-payments' ); case 'klarna': return __( 'Klarna transactions', 'woocommerce-payments' ); + case 'grabpay': + return __( 'GrabPay transactions', 'woocommerce-payments' ); default: return __( 'Unknown transactions', 'woocommerce-payments' ); } diff --git a/includes/admin/class-wc-rest-payments-refunds-controller.php b/includes/admin/class-wc-rest-payments-refunds-controller.php index 1d0ebc59ac4..8485d5548a3 100644 --- a/includes/admin/class-wc-rest-payments-refunds-controller.php +++ b/includes/admin/class-wc-rest-payments-refunds-controller.php @@ -41,6 +41,7 @@ public function register_routes() { * * @internal Not intended for usage in integrations or outside of WooCommerce Payments. * @param WP_REST_Request $request Full data about the request. + * @return WP_REST_Response|WP_Error */ public function process_refund( $request ) { $order_id = $request->get_param( 'order_id' ); @@ -48,34 +49,67 @@ public function process_refund( $request ) { $amount = $request->get_param( 'amount' ); $reason = $request->get_param( 'reason' ); + $order = null; if ( $order_id ) { $order = wc_get_order( $order_id ); - if ( $order ) { - $result = wc_create_refund( - [ - 'amount' => WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ), - 'reason' => $reason, - 'order_id' => $order_id, - 'refund_payment' => true, - 'restock_items' => true, - ] - ); - + if ( false !== $order && $order instanceof WC_Order ) { + $result = $this->process_order_refund( $order, $amount, $reason ); + if ( is_wp_error( $result ) || false === $result ) { + return rest_ensure_response( + new WP_Error( + 'wcpay_refund_payment', + __( 'Failed to create refund', 'woocommerce-payments' ) + ) + ); + } return rest_ensure_response( $result ); } } try { - $refund_request = Refund_Charge::create( $charge_id ); - $refund_request->set_charge( $charge_id ); - $refund_request->set_amount( $amount ); - $refund_request->set_reason( $reason ); - $refund_request->set_source( 'transaction_details_no_order' ); - $response = $refund_request->send(); - - return rest_ensure_response( $response ); + return rest_ensure_response( $this->process_charge_refund( $charge_id, $amount, $reason ) ); } catch ( API_Exception $e ) { + if ( 'insufficient_balance_for_refund' === $e->get_error_code() && $order instanceof WC_Order ) { + WC_Payments::get_order_service()->handle_insufficient_balance_for_refund( $order, $amount ); + } return rest_ensure_response( new WP_Error( 'wcpay_refund_payment', $e->getMessage() ) ); } } + + /** + * Process refund for an order. + * + * @param WC_Order $order The order to refund. + * @param int $amount Refund amount. + * @param string $reason Refund reason. + * @return WC_Order_Refund|WP_Error|false + */ + private function process_order_refund( WC_Order $order, $amount, $reason ) { + return wc_create_refund( + [ + 'amount' => WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ), + 'reason' => $reason, + 'order_id' => $order->get_id(), + 'refund_payment' => true, + 'restock_items' => true, + ] + ); + } + + /** + * Process refund for a charge. + * + * @param string $charge_id The charge to refund. + * @param int $amount Refund amount. + * @param string $reason Refund reason. + * @return array + */ + private function process_charge_refund( $charge_id, $amount, $reason ) { + $refund_request = Refund_Charge::create( $charge_id ); + $refund_request->set_charge( $charge_id ); + $refund_request->set_amount( $amount ); + $refund_request->set_reason( $reason ); + $refund_request->set_source( 'transaction_details_no_order' ); + return $refund_request->send(); + } } diff --git a/includes/class-duplicates-detection-service.php b/includes/class-duplicates-detection-service.php index 119972b68b4..c5af9123c47 100644 --- a/includes/class-duplicates-detection-service.php +++ b/includes/class-duplicates-detection-service.php @@ -22,6 +22,7 @@ use WCPay\Payment_Methods\Klarna_Payment_Method; use WCPay\Payment_Methods\P24_Payment_Method; use WCPay\Payment_Methods\Sepa_Payment_Method; +use WCPay\Payment_Methods\Grabpay_Payment_Method; /** * Class handling detection of payment methods enabled by multiple plugins simultaneously. @@ -103,6 +104,7 @@ private function search_for_additional_payment_methods() { 'afterpay' => Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, 'clearpay' => Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, 'klarna' => Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + 'grabpay' => Grabpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID, ]; foreach ( $this->get_enabled_gateways() as $gateway ) { diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 2bcc683d2bb..c87f9b75ed6 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -74,6 +74,7 @@ use WCPay\Payment_Methods\P24_Payment_Method; use WCPay\Payment_Methods\Sepa_Payment_Method; use WCPay\Payment_Methods\UPE_Payment_Method; +use WCPay\Payment_Methods\Grabpay_Payment_Method; /** * Gateway class for WooPayments @@ -351,6 +352,7 @@ public function __construct( 'affirm' => 'affirm_payments', 'afterpay_clearpay' => 'afterpay_clearpay_payments', 'klarna' => 'klarna_payments', + 'grabpay' => 'grabpay_payments', 'jcb' => 'jcb_payments', ]; @@ -4142,6 +4144,7 @@ public function get_upe_available_payment_methods() { $available_methods[] = Affirm_Payment_Method::PAYMENT_METHOD_STRIPE_ID; $available_methods[] = Afterpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID; $available_methods[] = Klarna_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + $available_methods[] = Grabpay_Payment_Method::PAYMENT_METHOD_STRIPE_ID; $available_methods = array_values( apply_filters( diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index 0a80979e849..6eefb80fb9b 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -49,7 +49,7 @@ public static function are_payments_enabled() { * @return bool */ public static function is_tokenized_cart_ece_enabled(): bool { - return '1' === get_option( self::TOKENIZED_CART_ECE_FLAG_NAME, '1' ); + return '1' === get_option( self::TOKENIZED_CART_ECE_FLAG_NAME, '0' ); } /** diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php index 0db9728a724..395b430fb43 100644 --- a/includes/class-wc-payments-order-service.php +++ b/includes/class-wc-payments-order-service.php @@ -2127,4 +2127,86 @@ private function is_order_type_object( $order ): bool { private function intent_has_card_payment_type( $intent_data ): bool { return isset( $intent_data['payment_method_type'] ) && 'card' === $intent_data['payment_method_type']; } + + /** + * Countries where FROD balance is not supported. + * + * @var array + */ + const FROD_UNSUPPORTED_COUNTRIES = [ 'HK', 'SG', 'AE' ]; + + /** + * Handle insufficient balance for refund. + * + * @param WC_Order $order The order being refunded. + * @param int $amount The refund amount. + */ + public function handle_insufficient_balance_for_refund( WC_Order $order, $amount ) { + $account_country = WC_Payments::get_account_service()->get_account_country(); + + $formatted_amount = wc_price( + WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ), + [ 'currency' => $order->get_currency() ] + ); + + if ( $this->is_frod_supported( $account_country ) ) { + $order->add_order_note( $this->get_frod_support_note( $formatted_amount ) ); + } else { + $order->add_order_note( $this->get_insufficient_balance_note( $formatted_amount ) ); + } + } + + /** + * Check if FROD is supported for the given country. + * + * @param string $country_code Two-letter country code. + * @return bool + */ + private function is_frod_supported( $country_code ) { + return ! in_array( + $country_code, + self::FROD_UNSUPPORTED_COUNTRIES, + true + ); + } + + /** + * Get the order note for FROD supported countries. + * + * @param string $formatted_amount The formatted refund amount. + * @return string + */ + private function get_frod_support_note( $formatted_amount ) { + $learn_more_url = 'https://woocommerce.com/document/woopayments/fees-and-debits/preventing-negative-balances/#adding-funds'; + return sprintf( + WC_Payments_Utils::esc_interpolated_html( + /* translators: %s: Formatted refund amount */ + __( 'Refund of %s failed due to insufficient funds in your WooPayments balance. To prevent delays in refunding customers, please consider adding funds to your Future Refunds or Disputes (FROD) balance. Learn more.', 'woocommerce-payments' ), + [ + 'strong' => '', + 'a' => '', + ] + ), + $formatted_amount + ); + } + + /** + * Get the order note for countries without FROD support. + * + * @param string $formatted_amount The formatted refund amount. + * @return string + */ + private function get_insufficient_balance_note( $formatted_amount ) { + return sprintf( + WC_Payments_Utils::esc_interpolated_html( + /* translators: %1$s: Formatted refund amount */ + __( 'Refund of %1$s failed due to insufficient funds in your WooPayments balance.', 'woocommerce-payments' ), + [ + 'strong' => '', + ] + ), + $formatted_amount + ); + } } diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php index d93044e6d6b..ce9eaa5f203 100644 --- a/includes/class-wc-payments-webhook-processing-service.php +++ b/includes/class-wc-payments-webhook-processing-service.php @@ -324,6 +324,19 @@ private function process_webhook_refund_updated( $event_body ) { $order->add_order_note( $note ); $this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' ); $order->save(); + + try { + $failure_reason = $this->read_webhook_property( $event_object, 'failure_reason' ); + + if ( 'insufficient_funds' === $failure_reason ) { + $this->order_service->handle_insufficient_balance_for_refund( + $order, + $amount + ); + } + } catch ( Exception $e ) { + Logger::debug( 'Failed to handle insufficient balance for refund: ' . $e->getMessage() ); + } } /** diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 35ae2c683fb..84c5ee278ae 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -42,6 +42,7 @@ use WCPay\WooPay\WooPay_Session; use WCPay\Compatibility_Service; use WCPay\Duplicates_Detection_Service; +use WCPay\Payment_Methods\Grabpay_Payment_Method; use WCPay\WC_Payments_Currency_Manager; /** @@ -437,6 +438,7 @@ public static function init() { include_once __DIR__ . '/payment-methods/class-affirm-payment-method.php'; include_once __DIR__ . '/payment-methods/class-afterpay-payment-method.php'; include_once __DIR__ . '/payment-methods/class-klarna-payment-method.php'; + include_once __DIR__ . '/payment-methods/class-grabpay-payment-method.php'; include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-helper.php'; include_once __DIR__ . '/class-wc-payment-token-wcpay-sepa.php'; include_once __DIR__ . '/class-wc-payments-status.php'; @@ -575,6 +577,7 @@ public static function init() { Affirm_Payment_Method::class, Afterpay_Payment_Method::class, Klarna_Payment_Method::class, + Grabpay_Payment_Method::class, ]; $payment_methods = []; diff --git a/includes/constants/class-payment-method.php b/includes/constants/class-payment-method.php index ee498864ab5..711c40a6bbc 100644 --- a/includes/constants/class-payment-method.php +++ b/includes/constants/class-payment-method.php @@ -35,6 +35,7 @@ class Payment_Method extends Base_Constant { const AFFIRM = 'affirm'; const AFTERPAY = 'afterpay_clearpay'; const KLARNA = 'klarna'; + const GRABPAY = 'grabpay'; const IPP_ALLOWED_PAYMENT_METHODS = [ self::CARD_PRESENT, diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php index f580cc45565..413b51aa064 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php @@ -214,15 +214,12 @@ public function is_account_creation_possible() { } /** - * Load public scripts and styles. + * Gets the parameters needed for Express Checkout functionality. + * + * @return array Parameters for Express Checkout. */ - public function scripts() { - // Don't load scripts if page is not supported. - if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { - return; - } - - $express_checkout_params = [ + public function get_express_checkout_params() { + return [ 'ajax_url' => admin_url( 'admin-ajax.php' ), 'wc_ajax_url' => WC_AJAX::get_endpoint( '%%endpoint%%' ), 'stripe' => [ @@ -260,7 +257,20 @@ public function scripts() { 'has_block' => has_block( 'woocommerce/cart' ) || has_block( 'woocommerce/checkout' ), 'product' => $this->express_checkout_helper->get_product_data(), 'total_label' => $this->express_checkout_helper->get_total_label(), + 'store_name' => get_bloginfo( 'name' ), ]; + } + + /** + * Load public scripts and styles. + */ + public function scripts() { + // Don't load scripts if page is not supported. + if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { + return; + } + + $express_checkout_params = $this->get_express_checkout_params(); if ( WC_Payments_Features::is_tokenized_cart_ece_enabled() ) { WC_Payments::register_script_with_dependencies( diff --git a/includes/multi-currency/MultiCurrency.php b/includes/multi-currency/MultiCurrency.php index b24d53c70ab..9d394135828 100644 --- a/includes/multi-currency/MultiCurrency.php +++ b/includes/multi-currency/MultiCurrency.php @@ -522,8 +522,22 @@ public function get_switcher_widget_markup( array $instance = [], array $args = * call the_widget, you need to have the name of the widget, so we get the instance and hash to use. */ ob_start(); + + $currency_switcher_widget = $this->get_currency_switcher_widget(); + + if ( ! is_object( $currency_switcher_widget ) ) { + Logger::notice( + sprintf( + 'Invalid widget markup. Widget instance must be type object, %s given.', + gettype( $currency_switcher_widget ) + ) + ); + + return ob_get_clean(); + } + the_widget( - spl_object_hash( $this->get_currency_switcher_widget() ), + spl_object_hash( $currency_switcher_widget ), apply_filters( self::FILTER_PREFIX . 'theme_widget_instance', $instance ), apply_filters( self::FILTER_PREFIX . 'theme_widget_args', $args ) ); diff --git a/includes/payment-methods/class-grabpay-payment-method.php b/includes/payment-methods/class-grabpay-payment-method.php new file mode 100644 index 00000000000..099fa173df7 --- /dev/null +++ b/includes/payment-methods/class-grabpay-payment-method.php @@ -0,0 +1,60 @@ +currencies = [ Currency_Code::SINGAPORE_DOLLAR ]; + $this->stripe_id = self::PAYMENT_METHOD_STRIPE_ID; + $this->is_reusable = false; + $this->is_bnpl = false; + $this->icon_url = plugins_url( 'assets/images/payment-methods/grabpay.svg', WCPAY_PLUGIN_FILE ); + $this->accept_only_domestic_payment = true; + $this->countries = [ Country_Code::SINGAPORE ]; + } + + /** + * Returns payment method title + * + * @param string|null $account_country Country of merchants account. + * @param array|false $payment_details Optional payment details from charge object. + * + * @return string + */ + public function get_title( ?string $account_country = null, $payment_details = false ) { + return __( 'GrabPay', 'woocommerce-payments' ); + } + + /** + * Returns testing credentials to be printed at checkout in test mode. + * + * @param string $account_country The country of the account. + * @return string + */ + public function get_testing_instructions( string $account_country ) { + return ''; + } +} diff --git a/package-lock.json b/package-lock.json index a46357325b7..f1d8a9f2855 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "woocommerce-payments", - "version": "8.9.0", + "version": "8.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "woocommerce-payments", - "version": "8.9.0", + "version": "8.9.1", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index a8b0af00dfb..c0be9d14c13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-payments", - "version": "8.9.0", + "version": "8.9.1", "main": "webpack.config.js", "author": "Automattic", "license": "GPL-3.0-or-later", diff --git a/readme.txt b/readme.txt index 7de4c602454..d1b7d9f5d2c 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.9.0 +Stable tag: 8.9.1 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -87,6 +87,10 @@ You can read our Terms of Service and other policies [here](https://woocommerce. == Changelog == += 8.9.1 - 2025-02-07 = +* Fix - Ensures that the tokenised cart for ECE implementation is disabled by default. + + = 8.9.0 - 2025-02-04 = * Add - Add a popover to WooPayments to present all possible payment methods * Add - Added persistent column visibility preferences for reporting tables, allowing merchants to customize and save their preferred table view layouts across sessions. diff --git a/tests/e2e-pw/README.md b/tests/e2e-pw/README.md index 9bcbd373f33..e7f7f50e671 100644 --- a/tests/e2e-pw/README.md +++ b/tests/e2e-pw/README.md @@ -21,6 +21,17 @@ See [tests/e2e/README.md](/tests/e2e/README.md) for detailed e2e environment set ## FAQs +**I'm getting errors that host.docker.internal is not found.** + +This is because the `host.docker.internal` alias is not available on Linux. You can use the `localhost` alias instead. To apply it, create a file called `docker-compose.override.yml` in the `tests/e2e-pw` directory and add the following content: + +```yaml +services: + playwright: + environment: + - BASE_URL=http://localhost:8084 +``` + **How do I wait for a page or element to load?** Since [Playwright automatically waits](https://playwright.dev/docs/actionability) for elements to be present in the page before interacting with them, you probably don't need to explicitly wait for elements to load. For example, all of the following locators will automatically wait for the element to be present and stable before asserting or interacting with it: diff --git a/tests/e2e-pw/specs/__snapshots__/wcpay/merchant/merchant-admin-transactions.spec.ts/Admin-transactions-page-should-load-without-errors-1.png b/tests/e2e-pw/specs/__snapshots__/wcpay/merchant/merchant-admin-transactions.spec.ts/Admin-transactions-page-should-load-without-errors-1.png index 32b65a0a01c..1cc6beab9b7 100644 Binary files a/tests/e2e-pw/specs/__snapshots__/wcpay/merchant/merchant-admin-transactions.spec.ts/Admin-transactions-page-should-load-without-errors-1.png and b/tests/e2e-pw/specs/__snapshots__/wcpay/merchant/merchant-admin-transactions.spec.ts/Admin-transactions-page-should-load-without-errors-1.png differ diff --git a/tests/e2e/env/down.sh b/tests/e2e/env/down.sh index ba3cc6f2503..9d48ed157da 100755 --- a/tests/e2e/env/down.sh +++ b/tests/e2e/env/down.sh @@ -13,7 +13,7 @@ docker compose -f $E2E_ROOT/env/docker-compose.yml down if [[ "$E2E_USE_LOCAL_SERVER" != false ]]; then step "Stopping server containers" - docker compose -f $E2E_ROOT/deps/transact-platform-server/docker-compose.yml down + docker compose -f "$SERVER_PATH/docker-compose.yml" -f "$SERVER_PATH/docker-compose.e2e.yml" down fi # Remove auth credentials from the Playwright config. diff --git a/tests/e2e/env/setup.sh b/tests/e2e/env/setup.sh index 364d906f29e..24c89c3a04f 100755 --- a/tests/e2e/env/setup.sh +++ b/tests/e2e/env/setup.sh @@ -17,6 +17,23 @@ if [[ -f "$E2E_ROOT/config/local.env" ]]; then . "$E2E_ROOT/config/local.env" fi +# Function to handle permissions in a cross-platform way +handle_permissions() { + local path=$1 + if [[ "$(uname)" == "Darwin" ]]; then + # For MacOS environments, use less strict permissions + echo "Setting MacOS compatible permissions for $path" + chmod -R 755 "$path" + else + # For Linux/CI environments + echo "Setting Linux/CI permissions for $path" + if ! sudo chown www-data:www-data -R "$path"; then + echo "Failed to set permissions on $path" + exit 1 + fi + fi +} + # Variables BLOG_ID=${E2E_BLOG_ID-111} WC_GUEST_EMAIL=$(<"$DEFAULT_CONFIG_JSON_PATH" jq -r '.users.guest.email') @@ -75,8 +92,9 @@ if [[ "$E2E_USE_LOCAL_SERVER" != false ]]; then if [[ -n $CI ]]; then echo "Setting docker folder permissions" - redirect_output sudo chown www-data:www-data -R ./docker/wordpress - redirect_output ls -al ./docker + handle_permissions "$SERVER_PATH/docker/wordpress" + touch "$SERVER_PATH/logstash.log" + handle_permissions "$SERVER_PATH/logstash.log" fi step "Setting up SERVER containers" @@ -138,7 +156,7 @@ echo if [[ -n $CI ]]; then echo "Setting docker folder permissions" - redirect_output sudo chown www-data:www-data -R "$E2E_ROOT"/docker/wordpress/wp-content + handle_permissions "$E2E_ROOT/docker/wordpress/wp-content" redirect_output ls -al "$E2E_ROOT"/docker/wordpress fi @@ -312,7 +330,7 @@ if [[ ! ${SKIP_WC_SUBSCRIPTIONS_TESTS} ]]; then unzip -qq woocommerce-subscriptions.zip -d woocommerce-subscriptions-source - echo "Moving the unzipped plugin files. This may require your admin password" + echo "Moving the unzipped plugin files..." sudo mv woocommerce-subscriptions-source/woocommerce-subscriptions/* woocommerce-subscriptions cli wp plugin activate woocommerce-subscriptions @@ -336,6 +354,7 @@ fi echo "Creating screenshots directory" mkdir -p $WCP_ROOT/screenshots +handle_permissions $WCP_ROOT/screenshots echo "Disabling rate limiter for card declined in E2E tests" cli wp option set wcpay_session_rate_limiter_disabled_wcpay_card_declined_registry yes diff --git a/tests/e2e/env/shared.sh b/tests/e2e/env/shared.sh index 39ac4fdd760..2f9c3d3ace3 100644 --- a/tests/e2e/env/shared.sh +++ b/tests/e2e/env/shared.sh @@ -5,9 +5,9 @@ cwd=$(pwd) export WCP_ROOT=$cwd export E2E_ROOT="$cwd/tests/e2e" export WP_URL="localhost:8084" -export SERVER_PATH="$E2E_ROOT/deps/transact-platform-server" +export SERVER_PATH="$E2E_ROOT/deps/transact-platform-server-e2e" export SERVER_CONTAINER="transact_platform_server_wordpress_e2e" -export DEV_TOOLS_DIR="wcp-dev-tools" +export DEV_TOOLS_DIR="wcp-dev-tools-e2e" export DEV_TOOLS_PATH="$E2E_ROOT/deps/$DEV_TOOLS_DIR" export CLIENT_CONTAINER="wcp_e2e_wordpress" export DEFAULT_CONFIG_JSON_PATH="$E2E_ROOT/config/default.json" diff --git a/tests/e2e/env/up.sh b/tests/e2e/env/up.sh index 80696a67579..fcbe8f88b18 100755 --- a/tests/e2e/env/up.sh +++ b/tests/e2e/env/up.sh @@ -13,5 +13,5 @@ docker compose -f "$E2E_ROOT/env/docker-compose.yml" up -d if [[ "$E2E_USE_LOCAL_SERVER" != false ]]; then step "Starting server containers" - docker compose -f "$E2E_ROOT/deps/transact-platform-server/docker-compose.yml" up -d + docker compose -f "$SERVER_PATH/docker-compose.yml" -f "$SERVER_PATH/docker-compose.e2e.yml" up -d fi diff --git a/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-handler.php b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-handler.php index 0b10752c0f5..abdfc946fdc 100644 --- a/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-handler.php +++ b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-handler.php @@ -133,4 +133,20 @@ public function test_filter_cart_needs_shipping_address_subscription_products() remove_filter( 'woocommerce_shipping_method_count', '__return_zero' ); WC_Subscriptions_Cart::set_cart_contains_subscription( false ); } + + public function test_get_express_checkout_params() { + $this->mock_ece_button_helper + ->method( 'get_common_button_settings' ) + ->willReturn( + [ + 'type' => 'buy', + 'theme' => 'white', + 'height' => '30', + 'radius' => '10', + ] + ); + $params = $this->system_under_test->get_express_checkout_params(); + $this->assertArrayHasKey( 'store_name', $params ); + $this->assertEquals( get_bloginfo( 'name' ), $params['store_name'] ); + } } diff --git a/tests/unit/multi-currency/test-class-multi-currency.php b/tests/unit/multi-currency/test-class-multi-currency.php index fcedcc68e57..90e04ee1c48 100644 --- a/tests/unit/multi-currency/test-class-multi-currency.php +++ b/tests/unit/multi-currency/test-class-multi-currency.php @@ -927,6 +927,18 @@ public function test_get_switcher_widget_markup() { $this->assertEquals( $expected, $this->multi_currency->get_switcher_widget_markup() ); } + public function test_get_switcher_widget_markup_when_widget_instance_is_null() { + $mock_multi_currency = $this + ->getMockBuilder( WCPay\MultiCurrency\MultiCurrency::class ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'get_currency_switcher_widget' ] ) + ->getMock(); + + $mock_multi_currency->method( 'get_currency_switcher_widget' )->willReturn( null ); + + $this->assertEquals( '', $mock_multi_currency->get_switcher_widget_markup() ); + } + public function test_validate_currency_code_returns_existing_currency_code() { $this->assertEquals( 'CAD', $this->multi_currency->validate_currency_code( 'CAD' ) ); $this->assertEquals( 'CAD', $this->multi_currency->validate_currency_code( 'cAd' ) ); diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 082d8ca6755..651268463b4 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -41,6 +41,7 @@ use WCPay\Payment_Methods\CC_Payment_Method; use WCPay\Payment_Methods\Eps_Payment_Method; use WCPay\Payment_Methods\Giropay_Payment_Method; +use WCPay\Payment_Methods\Grabpay_Payment_Method; use WCPay\Payment_Methods\Ideal_Payment_Method; use WCPay\Payment_Methods\Klarna_Payment_Method; use WCPay\Payment_Methods\Link_Payment_Method; @@ -519,6 +520,9 @@ public function test_correct_payment_method_title_for_order() { $becs_details = [ 'type' => 'au_becs_debit', ]; + $grabpay_details = [ + 'type' => 'grabpay', + ]; $charge_payment_method_details = [ $visa_credit_details, @@ -532,6 +536,7 @@ public function test_correct_payment_method_title_for_order() { $ideal_details, $sepa_details, $becs_details, + $grabpay_details, ]; $expected_payment_method_titles = [ @@ -546,6 +551,7 @@ public function test_correct_payment_method_title_for_order() { 'iDEAL', 'SEPA Direct Debit', 'BECS Direct Debit', + 'GrabPay', ]; foreach ( $charge_payment_method_details as $i => $payment_method_details ) { @@ -609,6 +615,9 @@ public function test_payment_methods_show_correct_default_outputs() { $mock_afterpay_details = [ 'type' => 'afterpay_clearpay', ]; + $mock_grabpay_details = [ + 'type' => 'grabpay', + ]; $card_method = $this->payment_methods['card']; $giropay_method = $this->payment_methods['giropay']; @@ -621,7 +630,7 @@ public function test_payment_methods_show_correct_default_outputs() { $becs_method = $this->payment_methods['au_becs_debit']; $affirm_method = $this->payment_methods['affirm']; $afterpay_method = $this->payment_methods['afterpay_clearpay']; - + $grabpay_method = $this->payment_methods['grabpay']; $this->assertEquals( 'card', $card_method->get_id() ); $this->assertEquals( 'Cards', $card_method->get_title() ); $this->assertEquals( 'Visa debit card', $card_method->get_title( 'US', $mock_visa_details ) ); @@ -695,6 +704,12 @@ public function test_payment_methods_show_correct_default_outputs() { $this->assertSame( 'Clearpay', $afterpay_method->get_title( 'GB', $mock_afterpay_details ) ); $this->assertTrue( $afterpay_method->is_enabled_at_checkout( 'GB' ) ); $this->assertFalse( $afterpay_method->is_reusable() ); + + $this->assertEquals( 'grabpay', $grabpay_method->get_id() ); + $this->assertEquals( 'GrabPay', $grabpay_method->get_title() ); + $this->assertEquals( 'GrabPay', $grabpay_method->get_title( 'SG', $mock_grabpay_details ) ); + $this->assertTrue( $grabpay_method->is_enabled_at_checkout( 'SG' ) ); + $this->assertFalse( $grabpay_method->is_reusable() ); } public function test_only_reusabled_payment_methods_enabled_with_subscription_item_present() { @@ -717,6 +732,7 @@ function ( $order ) { $becs_method = $this->payment_methods['au_becs_debit']; $affirm_method = $this->payment_methods['affirm']; $afterpay_method = $this->payment_methods['afterpay_clearpay']; + $grabpay_method = $this->payment_methods['grabpay']; $this->assertTrue( $card_method->is_enabled_at_checkout( 'US' ) ); $this->assertFalse( $giropay_method->is_enabled_at_checkout( 'US' ) ); @@ -729,6 +745,7 @@ function ( $order ) { $this->assertFalse( $becs_method->is_enabled_at_checkout( 'US' ) ); $this->assertFalse( $affirm_method->is_enabled_at_checkout( 'US' ) ); $this->assertFalse( $afterpay_method->is_enabled_at_checkout( 'US' ) ); + $this->assertFalse( $grabpay_method->is_enabled_at_checkout( 'SG' ) ); } public function test_payment_methods_enabled_based_on_currency_limits() { @@ -814,6 +831,7 @@ public function test_only_valid_payment_methods_returned_for_currency() { $becs_method = $this->payment_methods['au_becs_debit']; $affirm_method = $this->payment_methods['affirm']; $afterpay_method = $this->payment_methods['afterpay_clearpay']; + $grabpay_method = $this->payment_methods['grabpay']; WC_Helper_Site_Currency::$mock_site_currency = 'EUR'; @@ -827,6 +845,7 @@ public function test_only_valid_payment_methods_returned_for_currency() { $this->assertTrue( $p24_method->is_currency_valid( $account_domestic_currency ) ); $this->assertTrue( $ideal_method->is_currency_valid( $account_domestic_currency ) ); $this->assertFalse( $becs_method->is_currency_valid( $account_domestic_currency ) ); + $this->assertFalse( $grabpay_method->is_currency_valid( $account_domestic_currency ) ); // BNPLs can accept only domestic payments. $this->assertFalse( $affirm_method->is_currency_valid( $account_domestic_currency ) ); $this->assertFalse( $afterpay_method->is_currency_valid( $account_domestic_currency ) ); @@ -842,12 +861,18 @@ public function test_only_valid_payment_methods_returned_for_currency() { $this->assertFalse( $p24_method->is_currency_valid( $account_domestic_currency ) ); $this->assertFalse( $ideal_method->is_currency_valid( $account_domestic_currency ) ); $this->assertFalse( $becs_method->is_currency_valid( $account_domestic_currency ) ); + $this->assertFalse( $grabpay_method->is_currency_valid( $account_domestic_currency ) ); $this->assertTrue( $affirm_method->is_currency_valid( $account_domestic_currency ) ); $this->assertTrue( $afterpay_method->is_currency_valid( $account_domestic_currency ) ); WC_Helper_Site_Currency::$mock_site_currency = 'AUD'; $this->assertTrue( $becs_method->is_currency_valid( $account_domestic_currency ) ); + WC_Helper_Site_Currency::$mock_site_currency = 'SGD'; + $this->assertTrue( $card_method->is_currency_valid( $account_domestic_currency ) ); + $this->assertFalse( $grabpay_method->is_currency_valid( $account_domestic_currency ) ); + $this->assertTrue( $grabpay_method->is_currency_valid( 'SGD' ) ); + // BNPLs can accept only domestic payments. WC_Helper_Site_Currency::$mock_site_currency = 'USD'; $account_domestic_currency = 'CAD'; @@ -4134,6 +4159,7 @@ private function init_payment_methods() { Affirm_Payment_Method::class, Afterpay_Payment_Method::class, Klarna_Payment_Method::class, + Grabpay_Payment_Method::class, ]; foreach ( $payment_method_classes as $payment_method_class ) { diff --git a/tests/unit/test-class-wc-payments-webhook-processing-service.php b/tests/unit/test-class-wc-payments-webhook-processing-service.php index 6c195574221..74398f2cdd1 100644 --- a/tests/unit/test-class-wc-payments-webhook-processing-service.php +++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php @@ -98,7 +98,7 @@ public function set_up() { $this->order_service = $this->getMockBuilder( 'WC_Payments_Order_Service' ) ->setConstructorArgs( [ $this->createMock( WC_Payments_API_Client::class ) ] ) - ->setMethods( [ 'get_wcpay_refund_id_for_order', 'add_note_and_metadata_for_refund', 'create_refund_for_order', 'mark_terminal_payment_failed' ] ) + ->setMethods( [ 'get_wcpay_refund_id_for_order', 'add_note_and_metadata_for_refund', 'create_refund_for_order', 'mark_terminal_payment_failed', 'handle_insufficient_balance_for_refund' ] ) ->getMock(); $this->mock_db_wrapper = $this->getMockBuilder( WC_Payments_DB::class ) @@ -116,7 +116,17 @@ public function set_up() { $this->mock_database_cache = $this->createMock( Database_Cache::class ); - $this->webhook_processing_service = new WC_Payments_Webhook_Processing_Service( $this->mock_api_client, $this->mock_db_wrapper, $mock_wcpay_account, $this->mock_remote_note_service, $this->order_service, $this->mock_receipt_service, $this->mock_wcpay_gateway, $this->mock_customer_service, $this->mock_database_cache ); + $this->webhook_processing_service = new WC_Payments_Webhook_Processing_Service( + $this->mock_api_client, + $this->mock_db_wrapper, + $this->createMock( WC_Payments_Account::class ), + $this->mock_remote_note_service, + $this->order_service, + $this->mock_receipt_service, + $this->mock_wcpay_gateway, + $this->mock_customer_service, + $this->mock_database_cache + ); // Build the event body data. $event_object = []; @@ -493,6 +503,37 @@ public function test_valid_failed_refund_update_webhook_with_unknown_charge_id() $this->webhook_processing_service->process( $this->event_body ); } + /** + * Test a valid failed refund update webhook with insufficient funds. + */ + public function test_valid_failed_refund_update_webhook_with_insufficient_funds() { + // Setup test request data. + $this->event_body['type'] = 'charge.refund.updated'; + $this->event_body['livemode'] = true; + $this->event_body['data']['object'] = [ + 'status' => 'failed', + 'charge' => 'charge_id', + 'id' => 'test_refund_id', + 'amount' => 999, + 'currency' => 'gbp', + 'failure_reason' => 'insufficient_funds', + ]; + + $this->mock_db_wrapper + ->expects( $this->once() ) + ->method( 'order_from_charge_id' ) + ->with( 'charge_id' ) + ->willReturn( $this->mock_order ); + + $this->order_service + ->expects( $this->once() ) + ->method( 'handle_insufficient_balance_for_refund' ) + ->with( $this->mock_order, 999 ); + + $this->webhook_processing_service->process( $this->event_body ); + } + + /** * Test a valid non-failed refund update webhook */ diff --git a/woocommerce-payments.php b/woocommerce-payments.php index fe7049bfb8a..4aefaee907a 100644 --- a/woocommerce-payments.php +++ b/woocommerce-payments.php @@ -11,7 +11,7 @@ * WC tested up to: 9.6.0 * Requires at least: 6.0 * Requires PHP: 7.3 - * Version: 8.9.0 + * Version: 8.9.1 * Requires Plugins: woocommerce * * @package WooCommerce\Payments