diff --git a/changelog/dev-specify-notifications-email b/changelog/dev-specify-notifications-email new file mode 100644 index 00000000000..43bf6849664 --- /dev/null +++ b/changelog/dev-specify-notifications-email @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add ability to specify preferred communications email. diff --git a/client/data/settings/actions.js b/client/data/settings/actions.js index 25cbc0b4fba..c36ddc37537 100644 --- a/client/data/settings/actions.js +++ b/client/data/settings/actions.js @@ -267,6 +267,10 @@ export function updateIsStripeBillingEnabled( isEnabled ) { return updateSettingsValues( { is_stripe_billing_enabled: isEnabled } ); } +export function updateAccountCommunicationsEmail( email ) { + return updateSettingsValues( { account_communications_email: email } ); +} + export function* submitStripeBillingSubscriptionMigration() { try { yield dispatch( STORE_NAME ).startResolution( diff --git a/client/data/settings/hooks.js b/client/data/settings/hooks.js index 8ff69c33da0..a43375c31c4 100644 --- a/client/data/settings/hooks.js +++ b/client/data/settings/hooks.js @@ -456,6 +456,9 @@ export const usePaymentRequestButtonBorderRadius = () => { ]; }; +/** + * @return {import('wcpay/types/wcpay-data-settings-hooks').SavingError | null} + */ export const useGetSavingError = () => { return useSelect( ( select ) => select( STORE_NAME ).getSavingError(), [] ); }; @@ -607,3 +610,16 @@ export const useStripeBillingMigration = () => { ]; }, [] ); }; + +/** + * @return {import('wcpay/types/wcpay-data-settings-hooks').GenericSettingsHook} + */ +export const useAccountCommunicationsEmail = () => { + const { updateAccountCommunicationsEmail } = useDispatch( STORE_NAME ); + + const accountCommunicationsEmail = useSelect( ( select ) => + select( STORE_NAME ).getAccountCommunicationsEmail() + ); + + return [ accountCommunicationsEmail, updateAccountCommunicationsEmail ]; +}; diff --git a/client/data/settings/selectors.js b/client/data/settings/selectors.js index 5f701b8457b..15c8e01724a 100644 --- a/client/data/settings/selectors.js +++ b/client/data/settings/selectors.js @@ -235,3 +235,7 @@ export const getStripeBillingSubscriptionCount = ( state ) => { export const getStripeBillingMigratedCount = ( state ) => { return getSettings( state ).stripe_billing_migrated_count || 0; }; + +export const getAccountCommunicationsEmail = ( state ) => { + return getSettings( state ).account_communications_email || ''; +}; diff --git a/client/settings/notification-settings/__tests__/index.test.tsx b/client/settings/notification-settings/__tests__/index.test.tsx new file mode 100644 index 00000000000..6a10532dbed --- /dev/null +++ b/client/settings/notification-settings/__tests__/index.test.tsx @@ -0,0 +1,90 @@ +/** @format */ + +/** + * External dependencies + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import NotificationSettings, { + NotificationSettingsDescription, +} from '../index'; +import { useAccountCommunicationsEmail, useGetSavingError } from 'wcpay/data'; + +jest.mock( 'wcpay/data', () => ( { + useAccountCommunicationsEmail: jest.fn(), + useGetSavingError: jest.fn(), +} ) ); + +const mockUseAccountCommunicationsEmail = useAccountCommunicationsEmail as jest.MockedFunction< + typeof useAccountCommunicationsEmail +>; +const mockUseGetSavingError = useGetSavingError as jest.MockedFunction< + typeof useGetSavingError +>; + +describe( 'NotificationSettings', () => { + beforeEach( () => { + mockUseAccountCommunicationsEmail.mockReturnValue( [ + 'test@example.com', + jest.fn(), + ] ); + mockUseGetSavingError.mockReturnValue( null ); + } ); + + it( 'renders the notification settings section', () => { + render( ); + + expect( + screen.getByLabelText( 'Communications email' ) + ).toBeInTheDocument(); + } ); + + it( 'renders with the communications email input', () => { + const testEmail = 'communications@example.com'; + mockUseAccountCommunicationsEmail.mockReturnValue( [ + testEmail, + jest.fn(), + ] ); + + render( ); + + expect( screen.getByDisplayValue( testEmail ) ).toBeInTheDocument(); + } ); +} ); + +describe( 'NotificationSettingsDescription', () => { + it( 'renders the title', () => { + render( ); + + expect( + screen.getByRole( 'heading', { name: 'Notifications' } ) + ).toBeInTheDocument(); + } ); + + it( 'renders the description text', () => { + render( ); + + expect( + screen.getByText( + 'Configure how you receive important alerts about your WooPayments account.' + ) + ).toBeInTheDocument(); + } ); + + it( 'renders the learn more link', () => { + render( ); + + const link = screen.getByRole( 'link', { + name: /Learn more/, + } ); + expect( link ).toBeInTheDocument(); + expect( link ).toHaveAttribute( + 'href', + 'https://woocommerce.com/document/woopayments/account-management/change-email-for-woopayments-alerts/' + ); + } ); +} ); diff --git a/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx b/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx new file mode 100644 index 00000000000..0da9a7547b3 --- /dev/null +++ b/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx @@ -0,0 +1,217 @@ +/** @format */ + +/** + * External dependencies + */ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import NotificationsEmailInput from '../notifications-email-input'; +import { useGetSavingError, useAccountCommunicationsEmail } from 'wcpay/data'; + +jest.mock( 'wcpay/data', () => ( { + useAccountCommunicationsEmail: jest.fn(), + useGetSavingError: jest.fn(), +} ) ); + +const mockUseAccountCommunicationsEmail = useAccountCommunicationsEmail as jest.MockedFunction< + typeof useAccountCommunicationsEmail +>; +const mockUseGetSavingError = useGetSavingError as jest.MockedFunction< + typeof useGetSavingError +>; + +describe( 'NotificationsEmailInput', () => { + beforeEach( () => { + mockUseAccountCommunicationsEmail.mockReturnValue( [ + 'communications@test.com', + jest.fn(), + ] ); + mockUseGetSavingError.mockReturnValue( null ); + } ); + + it( 'displays and updates email address', () => { + const oldEmail = 'old.communications@test.com'; + const setAccountCommunicationsEmail = jest.fn(); + mockUseAccountCommunicationsEmail.mockReturnValue( [ + oldEmail, + setAccountCommunicationsEmail, + ] ); + + render( ); + + expect( screen.getByDisplayValue( oldEmail ) ).toBeInTheDocument(); + + const newEmail = 'new.communications@test.com'; + fireEvent.change( screen.getByLabelText( 'Communications email' ), { + target: { value: newEmail }, + } ); + + expect( setAccountCommunicationsEmail ).toHaveBeenCalledWith( + newEmail + ); + } ); + + it( 'displays error message for empty email', () => { + mockUseAccountCommunicationsEmail.mockReturnValue( [ '', jest.fn() ] ); + mockUseGetSavingError.mockReturnValue( { + code: 'rest_invalid_param', + message: 'Invalid parameter(s): account_communications_email', + data: { + status: 400, + params: { + account_communications_email: + 'Error: Communications email is required.', + }, + details: { + account_communications_email: { + code: 'rest_invalid_pattern', + message: 'Error: Communications email is required.', + data: null, + }, + }, + }, + } ); + + const { container } = render( ); + expect( + container.querySelector( '.components-notice.is-error' ) + ?.textContent + ).toMatch( /Error: Communications email is required./ ); + } ); + + it( 'displays the error message for invalid email', () => { + mockUseAccountCommunicationsEmail.mockReturnValue( [ + 'invalid.email', + jest.fn(), + ] ); + mockUseGetSavingError.mockReturnValue( { + code: 'rest_invalid_param', + message: 'Invalid parameter(s): account_communications_email', + data: { + status: 400, + params: { + account_communications_email: + 'Error: Invalid email address: invalid.email', + }, + details: { + account_communications_email: { + code: 'rest_invalid_pattern', + message: 'Error: Invalid email address: invalid.email', + data: null, + }, + }, + }, + } ); + + const { container } = render( ); + expect( + container.querySelector( '.components-notice.is-error' ) + ?.textContent + ).toMatch( /Error: Invalid email address: / ); + } ); + + it( 'does not display error when saving error is null', () => { + mockUseAccountCommunicationsEmail.mockReturnValue( [ + 'valid@test.com', + jest.fn(), + ] ); + mockUseGetSavingError.mockReturnValue( null ); + + const { container } = render( ); + expect( + container.querySelector( '.components-notice.is-error' ) + ).toBeNull(); + } ); + + it( 'renders help text', () => { + render( ); + + expect( + screen.getByText( + 'Email address used for WooPayments communications.' + ) + ).toBeInTheDocument(); + } ); + + it( 'displays client-side validation error for invalid email after blur', () => { + mockUseAccountCommunicationsEmail.mockReturnValue( [ + 'invalid-email', + jest.fn(), + ] ); + mockUseGetSavingError.mockReturnValue( null ); + + const { container } = render( ); + + // Error should not be shown before blur + expect( + container.querySelector( '.components-notice.is-error' ) + ).toBeNull(); + + // Trigger blur event + fireEvent.blur( screen.getByLabelText( 'Communications email' ) ); + + // Error should be shown after blur + expect( + container.querySelector( '.components-notice.is-error' ) + ?.textContent + ).toMatch( /Please enter a valid email address./ ); + } ); + + it( 'does not display client-side validation error for valid email after blur', () => { + mockUseAccountCommunicationsEmail.mockReturnValue( [ + 'valid@test.com', + jest.fn(), + ] ); + mockUseGetSavingError.mockReturnValue( null ); + + const { container } = render( ); + + // Trigger blur event + fireEvent.blur( screen.getByLabelText( 'Communications email' ) ); + + // No error should be shown for valid email + expect( + container.querySelector( '.components-notice.is-error' ) + ).toBeNull(); + } ); + + it( 'server error takes precedence over client-side validation error', () => { + mockUseAccountCommunicationsEmail.mockReturnValue( [ + 'invalid-email', + jest.fn(), + ] ); + mockUseGetSavingError.mockReturnValue( { + code: 'rest_invalid_param', + message: 'Invalid parameter(s): account_communications_email', + data: { + status: 400, + params: { + account_communications_email: + 'Error: Invalid email address: invalid-email', + }, + details: { + account_communications_email: { + code: 'rest_invalid_pattern', + message: 'Error: Invalid email address: invalid-email', + data: null, + }, + }, + }, + } ); + + const { container } = render( ); + + // Trigger blur to enable client-side validation + fireEvent.blur( screen.getByLabelText( 'Communications email' ) ); + + // Server error should be shown instead of client-side error + expect( + container.querySelector( '.components-notice.is-error' ) + ?.textContent + ).toMatch( /Error: Invalid email address: invalid-email/ ); + } ); +} ); diff --git a/client/settings/notification-settings/index.tsx b/client/settings/notification-settings/index.tsx new file mode 100644 index 00000000000..bee47ddd4dc --- /dev/null +++ b/client/settings/notification-settings/index.tsx @@ -0,0 +1,42 @@ +/** @format **/ + +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Card, ExternalLink } from '@wordpress/components'; +import React from 'react'; + +/** + * Internal dependencies + */ +import CardBody from '../card-body'; +import NotificationsEmailInput from './notifications-email-input'; +import './style.scss'; + +export const NotificationSettingsDescription: React.FC = () => ( + <> +

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

+

+ { __( + 'Configure how you receive important alerts about your WooPayments account.', + 'woocommerce-payments' + ) } +

+ + { __( 'Learn more', 'woocommerce-payments' ) } + + +); + +const NotificationSettings: React.FC = () => { + return ( + + + + + + ); +}; + +export default NotificationSettings; diff --git a/client/settings/notification-settings/notifications-email-input.tsx b/client/settings/notification-settings/notifications-email-input.tsx new file mode 100644 index 00000000000..811b5a578ef --- /dev/null +++ b/client/settings/notification-settings/notifications-email-input.tsx @@ -0,0 +1,83 @@ +/** @format **/ + +/** + * External dependencies + */ +import { TextControl, Notice } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import React, { useState } from 'react'; + +/** + * Internal dependencies + */ +import { useAccountCommunicationsEmail, useGetSavingError } from 'wcpay/data'; + +/** + * Validates an email address format. + * + * @param email The email address to validate. + * @return Whether the email is valid. + */ +const isValidEmail = ( email: string ): boolean => { + if ( ! email ) { + return false; + } + // Basic email validation regex + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test( email ); +}; + +const NotificationsEmailInput: React.FC = () => { + const [ + accountCommunicationsEmail, + setAccountCommunicationsEmail, + ] = useAccountCommunicationsEmail(); + + const [ hasBlurred, setHasBlurred ] = useState( false ); + + const savingError = useGetSavingError(); + const serverError = + savingError?.data?.details?.account_communications_email?.message; + + // Only show client-side validation error if user has interacted with the field + const showClientValidationError = + hasBlurred && + accountCommunicationsEmail !== '' && + ! isValidEmail( accountCommunicationsEmail ); + + const clientValidationError = showClientValidationError + ? __( 'Please enter a valid email address.', 'woocommerce-payments' ) + : null; + + // Server error takes precedence over client validation error + const errorMessage = serverError || clientValidationError; + + return ( + <> + { errorMessage && ( + + { errorMessage } + + ) } + + setHasBlurred( true ) } + data-testid={ 'notifications-email-input' } + type="email" + required + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + ); +}; + +export default NotificationsEmailInput; diff --git a/client/settings/notification-settings/style.scss b/client/settings/notification-settings/style.scss new file mode 100644 index 00000000000..eb3d934594f --- /dev/null +++ b/client/settings/notification-settings/style.scss @@ -0,0 +1,11 @@ +.notification-settings { + .components-notice { + margin-left: 0; + margin-right: 0; + margin-bottom: 1em; + } + + .settings__notifications-email-input { + max-width: 500px; + } +} diff --git a/client/settings/settings-manager/index.js b/client/settings/settings-manager/index.js index 6c05a8b7ceb..f42e2ee43e6 100644 --- a/client/settings/settings-manager/index.js +++ b/client/settings/settings-manager/index.js @@ -23,6 +23,9 @@ import LoadableSettingsSection from '../loadable-settings-section'; import PaymentMethodsSection from '../payment-methods-section'; import BuyNowPayLaterSection from '../buy-now-pay-later-section'; import ErrorBoundary from '../../components/error-boundary'; +import NotificationSettings, { + NotificationSettingsDescription, +} from '../notification-settings'; import { useDepositDelayDays, useGetDuplicatedPaymentMethodIds, @@ -300,6 +303,16 @@ const SettingsManager = () => { + + + + + + + void ]; + +export interface SavingError { + code?: string; + message?: string; + data?: { + status?: number; + params?: Record< string, string >; + details?: Record< + string, + { + code?: string; + message?: string; + data?: unknown; + } + >; + }; +} diff --git a/includes/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php index d70c08f29d5..88e464ac020 100644 --- a/includes/admin/class-wc-rest-payments-settings-controller.php +++ b/includes/admin/class-wc-rest-payments-settings-controller.php @@ -191,6 +191,11 @@ public function register_routes() { 'description' => __( 'A CSS hex color value representing the secondary branding color for this account.', 'woocommerce-payments' ), 'type' => 'string', ], + 'account_communications_email' => [ + 'description' => __( 'Email address used for WooPayments communications.', 'woocommerce-payments' ), + 'type' => 'string', + 'validate_callback' => [ $this, 'validate_account_communications_email' ], + ], 'deposit_schedule_interval' => [ 'description' => __( 'An interval for deposit scheduling.', 'woocommerce-payments' ), 'type' => 'string', @@ -432,6 +437,37 @@ public function validate_business_support_address( array $value, WP_REST_Request return true; } + /** + * Validate the account communications email. + * + * @param string $value The value being validated. + * @param WP_REST_Request $request The request made. + * @param string $param The parameter name, used in error messages. + * @return true|WP_Error + */ + public function validate_account_communications_email( string $value, WP_REST_Request $request, string $param ) { + $string_validation_result = rest_validate_request_arg( $value, $request, $param ); + if ( true !== $string_validation_result ) { + return $string_validation_result; + } + + if ( '' === $value ) { + return new WP_Error( + 'rest_invalid_pattern', + __( 'Error: Communications email is required.', 'woocommerce-payments' ) + ); + } + + if ( ! is_email( $value ) ) { + return new WP_Error( + 'rest_invalid_pattern', + __( 'Error: Invalid email address: ', 'woocommerce-payments' ) . $value + ); + } + + return true; + } + /** * Retrieve settings. * @@ -499,6 +535,7 @@ public function get_settings(): WP_REST_Response { 'account_branding_primary_color' => $this->wcpay_gateway->get_option( 'account_branding_primary_color' ), 'account_branding_secondary_color' => $this->wcpay_gateway->get_option( 'account_branding_secondary_color' ), 'account_domestic_currency' => $this->wcpay_gateway->get_option( 'account_domestic_currency' ), + 'account_communications_email' => $this->wcpay_gateway->get_option( 'account_communications_email' ), 'is_payment_request_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'payment_request' ), 'is_apple_google_pay_in_payment_methods_options_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'apple_google_pay_in_payment_methods_options' ), 'is_debug_log_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'enable_logging' ), diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index a4278f234f2..5f0cb226d0b 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -101,6 +101,7 @@ class WC_Payment_Gateway_WCPay extends WC_Payment_Gateway_CC { 'account_branding_icon' => 'branding_icon', 'account_branding_primary_color' => 'branding_primary_color', 'account_branding_secondary_color' => 'branding_secondary_color', + 'account_communications_email' => 'communications_email', 'deposit_schedule_interval' => 'deposit_schedule_interval', 'deposit_schedule_weekly_anchor' => 'deposit_schedule_weekly_anchor', @@ -114,7 +115,6 @@ class WC_Payment_Gateway_WCPay extends WC_Payment_Gateway_CC { * * @type int */ - const USER_FORMATTED_TOKENS_LIMIT = 100; const PROCESS_REDIRECT_ORDER_MISMATCH_ERROR_CODE = 'upe_process_redirect_order_id_mismatched'; @@ -2477,6 +2477,8 @@ public function get_option( $key, $empty_value = null ) { return $this->get_account_branding_secondary_color(); case 'account_domestic_currency': return $this->get_account_domestic_currency(); + case 'account_communications_email': + return $this->get_account_communications_email(); case 'deposit_schedule_interval': return $this->get_deposit_schedule_interval(); case 'deposit_schedule_weekly_anchor': @@ -2845,6 +2847,25 @@ protected function get_account_branding_secondary_color( $default_value = '' ): return $default_value; } + /** + * Gets connected account communications email. + * + * @param string $default_value Value to return when not connected or failed to fetch communications email. + * + * @return string Communications email or default value. + */ + protected function get_account_communications_email( $default_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_communications_email(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account\'s communication email.' . $e ); + } + + return $default_value; + } + /** * Retrieves the domestic currency of the current account based on its country. * It will fallback to the store's currency if the account's country is not supported. diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php index af62d5e7f26..1fcd2ced56b 100644 --- a/includes/class-wc-payments-account.php +++ b/includes/class-wc-payments-account.php @@ -371,6 +371,7 @@ public function get_account_status_data(): array { 'accountLink' => empty( $account['is_test_drive'] ) ? $this->get_login_url() : false, 'hasSubmittedVatData' => $account['has_submitted_vat_data'] ?? false, 'isDocumentsEnabled' => $account['is_documents_enabled'] ?? false, + 'communicationsEmail' => $account['communications_email'] ?? '', 'requirements' => [ 'errors' => $account['requirements']['errors'] ?? [], ], @@ -484,6 +485,16 @@ public function get_business_support_phone(): string { return isset( $account['business_profile']['support_phone'] ) ? $account['business_profile']['support_phone'] : ''; } + /** + * Gets the communications email. + * + * @return string Communications email. + */ + public function get_communications_email(): string { + $account = $this->get_cached_account_data(); + return isset( $account['communications_email'] ) ? $account['communications_email'] : ''; + } + /** * Gets the branding logo. * diff --git a/includes/core/server/request/class-update-account.php b/includes/core/server/request/class-update-account.php index 24dc72ae1e5..431cbb69832 100644 --- a/includes/core/server/request/class-update-account.php +++ b/includes/core/server/request/class-update-account.php @@ -251,4 +251,15 @@ public function set_deposit_schedule_monthly_anchor( string $deposit_schedule_mo public function set_locale( string $locale ) { $this->set_param( 'locale', $locale ); } + + /** + * Sets the communications email. + * + * @param string $communications_email Communications email. + * + * @return void + */ + public function set_communications_email( string $communications_email ) { + $this->set_param( 'communications_email', $communications_email ); + } } diff --git a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php index 0dacc70c509..e2045e9fbc2 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php @@ -1056,4 +1056,56 @@ public function account_business_support_phone_validation_provider() { ], ]; } + + /** + * Tests account communications email validator + * + * @dataProvider account_communications_email_validation_provider + */ + public function test_validate_account_communications_email( $value, $request, $param, $expected ) { + $return = $this->controller->validate_account_communications_email( $value, $request, $param ); + $this->assertEquals( $return, $expected ); + } + + /** + * Provider for test_validate_account_communications_email. + * @return array[] test method params. + */ + public function account_communications_email_validation_provider() { + $request = new WP_REST_Request(); + return [ + [ + 'test@test.com', + $request, + 'account_communications_email', + true, + ], + [ + '', // Empty value should trigger error. + $request, + 'account_communications_email', + new WP_Error( 'rest_invalid_pattern', 'Error: Communications email is required.' ), + ], + [ + 'invalid-email', + $request, + 'account_communications_email', + new WP_Error( 'rest_invalid_pattern', 'Error: Invalid email address: invalid-email' ), + ], + ]; + } + + public function test_get_settings_returns_account_communications_email() { + $test_email = 'test@example.com'; + $this->mock_wcpay_account + ->method( 'is_stripe_connected' ) + ->willReturn( true ); + $this->mock_wcpay_account + ->method( 'get_communications_email' ) + ->willReturn( $test_email ); + + $response = $this->controller->get_settings(); + + $this->assertEquals( $test_email, $response->get_data()['account_communications_email'] ); + } }