diff --git a/packages/account/build/constants.js b/packages/account/build/constants.js index 13739e5c4c1e..c0a569d80482 100644 --- a/packages/account/build/constants.js +++ b/packages/account/build/constants.js @@ -30,6 +30,7 @@ const ALIASES = { Stores: path.resolve(__dirname, '../src/Stores'), Styles: path.resolve(__dirname, '../src/Styles'), Types: path.resolve(__dirname, '../src/Types'), + 'react/jsx-runtime': 'react/jsx-runtime.js', }; const rules = (is_test_env = false) => [ diff --git a/packages/account/build/webpack.config.js b/packages/account/build/webpack.config.js index d2f271ec2718..03e1de3f9d5f 100644 --- a/packages/account/build/webpack.config.js +++ b/packages/account/build/webpack.config.js @@ -22,6 +22,7 @@ module.exports = function (env) { 'terms-of-use-config': 'Configs/terms-of-use-config', 'trading-assessment-config': 'Configs/trading-assessment-config', 'test-warning-modal': 'Components/trading-assessment/test-warning-modal', + 'employment-tax-info-config':'Configs/employment-tax-info-config', }, mode: IS_RELEASE ? 'production' : 'development', module: { diff --git a/packages/account/src/Components/additional-kyc-info-modal/__test__/additional-kyc-info-form.spec.tsx b/packages/account/src/Components/additional-kyc-info-modal/__test__/additional-kyc-info-form.spec.tsx deleted file mode 100644 index f6827953f478..000000000000 --- a/packages/account/src/Components/additional-kyc-info-modal/__test__/additional-kyc-info-form.spec.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import { StoreProvider, mockStore } from '@deriv/stores'; -import { AdditionalKycInfoForm } from '../additional-kyc-info-form'; -import userEvent from '@testing-library/user-event'; -import { useSettings } from '@deriv/api'; -import { TSocketError } from '@deriv/api/types'; - -jest.mock('@deriv/api', () => ({ - ...jest.requireActual('@deriv/api'), - useSettings: jest.fn(), -})); - -const mockedUseSettings = useSettings as jest.Mock; - -type TMutation = Partial['mutation']>; - -type TMockConfig = Omit, 'mutation'> & { - mutation: TMutation; -}; - -const mock_settings: Partial = { - update: jest.fn(), - mutation: { isLoading: false, isSuccess: false, error: null, isError: false }, - data: { - tax_identification_number: '', - tax_residence: '', - place_of_birth: '', - account_opening_reason: '', - has_submitted_personal_details: false, - }, -}; - -jest.mock('@deriv/shared', () => ({ - ...jest.requireActual('@deriv/shared'), - generateValidationFunction: jest.fn(), -})); - -describe('AdditionalKycInfoForm', () => { - const setError = jest.fn(); - const mock_store = mockStore({}); - - it('should render the form fields', () => { - mockedUseSettings.mockReturnValue(mock_settings); - render( - - - - ); - - expect(screen.getByTestId('dt_place_of_birth')).toBeInTheDocument(); - expect(screen.getByTestId('dt_tax_residence')).toBeInTheDocument(); - expect(screen.getByTestId('dt_tax_identification_number')).toBeInTheDocument(); - expect(screen.getByTestId('dt_account_opening_reason')).toBeInTheDocument(); - }); - - it('should render loading state upon fetching data', () => { - mockedUseSettings.mockReturnValue({ ...mock_settings, isLoading: true }); - render( - - - - ); - - expect(screen.getByTestId('dt_initial_loader')).toBeInTheDocument(); - }); - - it('should submit the form when all fields are valid', async () => { - mockedUseSettings.mockReturnValue(mock_settings); - render( - - - - ); - - const submit_btn = screen.getByRole('button', { name: 'Submit' }); - - userEvent.type(screen.getByTestId('dt_place_of_birth'), 'Ghana'); - userEvent.type(screen.getByTestId('dt_tax_residence'), 'Ghana'); - userEvent.type(screen.getByTestId('dt_tax_identification_number'), 'GHA-000000000-0'); - userEvent.type(screen.getByTestId('dt_account_opening_reason'), 'Speculative'); - - await waitFor(() => { - expect(submit_btn).toBeEnabled(); - }); - userEvent.click(screen.getByRole('button', { name: 'Submit' })); - - expect(mockedUseSettings).toHaveBeenCalled(); - }); - - it('should be able to submit the form without filling optional fields', async () => { - mockedUseSettings.mockReturnValue(mock_settings); - render( - - - - ); - - const submit_btn = screen.getByRole('button', { name: 'Submit' }); - - userEvent.type(screen.getByTestId('dt_place_of_birth'), 'Ghana'); - userEvent.type(screen.getByTestId('dt_account_opening_reason'), 'Speculative'); - - await waitFor(() => { - expect(submit_btn).toBeEnabled(); - }); - userEvent.click(screen.getByRole('button', { name: 'Submit' })); - - expect(mockedUseSettings).toHaveBeenCalled(); - }); - - it('should show an error message if form validation fails', async () => { - mockedUseSettings.mockReturnValue({ - ...mock_settings, - mutation: { - ...mock_settings.mutation, - isError: true, - status: 'error', - error: { - message: 'Invalid TIN format', - } as unknown as TSocketError<'set_settings'>, - }, - }); - render( - - - - ); - - const submit_btn = screen.getByRole('button', { name: 'Submit' }); - - userEvent.type(screen.getByTestId('dt_place_of_birth'), 'Ghana'); - userEvent.type(screen.getByTestId('dt_tax_residence'), 'Ghana'); - userEvent.type(screen.getByTestId('dt_tax_identification_number'), 'GHA-00000000'); - userEvent.type(screen.getByTestId('dt_account_opening_reason'), 'Speculative'); - - userEvent.click(submit_btn); - - expect(mockedUseSettings).toHaveBeenCalled(); - expect(setError).toHaveBeenCalled(); - }); -}); diff --git a/packages/account/src/Components/additional-kyc-info-modal/__test__/additional-kyc-info-modal.spec.tsx b/packages/account/src/Components/additional-kyc-info-modal/__test__/additional-kyc-info-modal.spec.tsx deleted file mode 100644 index 13decd303a3c..000000000000 --- a/packages/account/src/Components/additional-kyc-info-modal/__test__/additional-kyc-info-modal.spec.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { StoreProvider, mockStore } from '@deriv/stores'; -import { AdditionalKycInfoModal } from '../additional-kyc-info-modal'; - -jest.mock('../additional-kyc-info-form.tsx', () => jest.fn(() =>
AdditionalKycInfoForm
)); - -describe('AdditionalKycInfoModal', () => { - let modal_root_el: HTMLElement; - const mock_store = mockStore({ - ui: { - is_additional_kyc_info_modal_open: true, - toggleAdditionalKycInfoModal: jest.fn(), - }, - }); - - beforeAll(() => { - modal_root_el = document.createElement('div'); - modal_root_el.setAttribute('id', 'modal_root'); - document.body.appendChild(modal_root_el); - }); - - afterAll(() => { - document.body.removeChild(modal_root_el); - }); - - it('should render the modal when is_additional_kyc_info_modal_open is true', () => { - render( - - - - ); - expect(screen.getByText(/additional information required/i)).toBeInTheDocument(); - expect(screen.getByText(/AdditionalKycInfoForm/i)).toBeInTheDocument(); - }); -}); diff --git a/packages/account/src/Components/additional-kyc-info-modal/__test__/form-config.spec.tsx b/packages/account/src/Components/additional-kyc-info-modal/__test__/form-config.spec.tsx deleted file mode 100644 index 015cb169b86d..000000000000 --- a/packages/account/src/Components/additional-kyc-info-modal/__test__/form-config.spec.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { GetSettings, ResidenceList } from '@deriv/api-types'; -import { getFormFieldsConfig, getFormConfig, TFields } from '../form-config'; - -const mockAccountSettings: GetSettings = { - immutable_fields: ['place_of_birth'], - place_of_birth: 'UK', - tax_residence: 'UK', - tax_identification_number: '12345', - account_opening_reason: 'Hedging', -}; - -const mockResidenceList: ResidenceList = [ - { value: 'UK', text: 'United Kingdom' }, - { value: 'US', text: 'United States' }, -]; - -describe('getFormFieldsConfig', () => { - it('should return the correct form fields configuration', () => { - const requiredFields: TFields[] = ['place_of_birth', 'tax_residence']; - const config = getFormFieldsConfig(mockAccountSettings, mockResidenceList, requiredFields); - - expect(config.place_of_birth.type).toBe('select'); - expect(config.place_of_birth.initial_value).toBe('United Kingdom'); - expect(config.place_of_birth.disabled).toBe(true); - expect(config.place_of_birth.required).toBe(true); - - expect(config.tax_residence.type).toBe('select'); - expect(config.tax_residence.initial_value).toBe('United Kingdom'); - expect(config.tax_residence.disabled).toBe(false); - expect(config.tax_residence.required).toBe(true); - }); -}); - -describe('getFormConfig', () => { - it('should return the correct form configuration', () => { - const requiredFields: TFields[] = ['place_of_birth', 'tax_residence']; - const formConfig = getFormConfig({ - account_settings: mockAccountSettings, - residence_list: mockResidenceList, - required_fields: requiredFields, - }); - - expect(formConfig.fields.place_of_birth.disabled).toBe(true); - expect(formConfig.fields.place_of_birth.required).toBe(true); - - expect(formConfig.fields.tax_residence.disabled).toBe(false); - expect(formConfig.fields.tax_residence.required).toBe(true); - }); - - it('should return the correct form configuration with input types', () => { - const requiredFields: TFields[] = ['place_of_birth', 'tax_residence']; - const formConfig = getFormConfig({ - account_settings: { ...mockAccountSettings, immutable_fields: ['place_of_birth', 'tax_residence'] }, - residence_list: mockResidenceList, - required_fields: requiredFields, - with_input_types: true, - }); - - expect(formConfig.fields.place_of_birth.type).toBe('select'); - expect(formConfig.fields.place_of_birth.disabled).toBe(true); - expect(formConfig.fields.place_of_birth.required).toBe(true); - - expect(formConfig.fields.tax_residence.type).toBe('select'); - expect(formConfig.fields.tax_residence.disabled).toBe(true); - expect(formConfig.fields.tax_residence.required).toBe(true); - }); -}); diff --git a/packages/account/src/Components/additional-kyc-info-modal/additional-kyc-info-form.tsx b/packages/account/src/Components/additional-kyc-info-modal/additional-kyc-info-form.tsx deleted file mode 100644 index 3691c2f0ffff..000000000000 --- a/packages/account/src/Components/additional-kyc-info-modal/additional-kyc-info-form.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { Button, Loading, Modal, Text } from '@deriv/components'; -import { observer, useStore } from '@deriv/stores'; -import { Localize } from '@deriv/translations'; -import clsx from 'clsx'; -import { Form, Formik } from 'formik'; -import React from 'react'; -import { useSettings } from '@deriv/api'; -import { OECD_TIN_FORMAT_URL } from '../../Constants/external-urls'; -import FormFieldInfo from '../form-field-info'; -import { FormInputField } from '../forms/form-fields'; -import FormSelectField from '../forms/form-select-field'; -import { getFormConfig } from './form-config'; -import { TListItem } from 'Types'; -import { useDevice } from '@deriv-com/ui'; - -const FormTitle = () => { - const { isDesktop } = useDevice(); - return ( - - - - ); -}; - -type TAdditionalKycInfoFormProps = { - setError?: React.Dispatch>; -}; - -export const AdditionalKycInfoForm = observer(({ setError }: TAdditionalKycInfoFormProps) => { - const { client, ui, notifications } = useStore(); - const { residence_list, updateAccountStatus } = client; - const { - update, - mutation: { isLoading, error, status }, - data: account_settings, - isLoading: isAccountSettingsLoading, - } = useSettings(); - - const { fields, initialValues, validate } = getFormConfig({ - account_settings, - residence_list, - required_fields: ['place_of_birth', 'account_opening_reason'], - }); - - const onSubmit = (values: typeof initialValues) => { - const place_of_birth = residence_list?.find(item => item.text === values.place_of_birth)?.value; - - const payload: Record = { - place_of_birth, - account_opening_reason: values.account_opening_reason, - }; - - if (values.tax_identification_number) { - payload.tax_identification_number = values.tax_identification_number; - } - - if (values.tax_residence) { - const tax_residence = residence_list?.find(item => item.text === values.tax_residence)?.value; - payload.tax_residence = tax_residence; - } - - update(payload); - }; - - React.useEffect(() => { - if (status === 'success') { - updateAccountStatus(); - notifications.refreshNotifications(); - ui.toggleAdditionalKycInfoModal(); - ui.toggleKycInformationSubmittedModal(); - } else if (status === 'error') { - setError?.(error); - } - }, [error, notifications, setError, status, ui, updateAccountStatus]); - - if (isAccountSettingsLoading) { - return ; - } - - return ( - - {({ isValid, setFieldValue }) => ( -
- {isLoading ? ( - - ) : ( -
- -
- -
-
- - - } - /> -
-
- {/* @ts-expect-error Label type of Input field is string instead of ReactNode */} - - , -
, - ]} - /> - } - /> -
-
- { - setFieldValue('account_opening_reason', value, true); - }} - list_height='6rem' - {...fields.account_opening_reason} - /> -
-
- )} - - - - - )} -
- ); -}); - -AdditionalKycInfoForm.displayName = 'AdditionalKycInfoForm'; - -export default AdditionalKycInfoForm; diff --git a/packages/account/src/Components/additional-kyc-info-modal/additional-kyc-info-modal.scss b/packages/account/src/Components/additional-kyc-info-modal/additional-kyc-info-modal.scss deleted file mode 100644 index bb9682d085d5..000000000000 --- a/packages/account/src/Components/additional-kyc-info-modal/additional-kyc-info-modal.scss +++ /dev/null @@ -1,91 +0,0 @@ -.dc-modal { - &__container { - &_additional-kyc-info { - &-header { - border-bottom: 2px solid var(--general-section-1); - } - - &-footer { - padding: 1.6rem 0 0; - margin: unset; - } - - .inline-message { - margin: 0.8rem 1.6rem; - width: calc(100% - 3.2rem); - justify-content: flex-start; - } - } - } -} - -.additional-kyc-info-modal { - &__portal-header { - padding: 2.5rem 2rem; - } - - &__form { - padding: unset; - height: 100%; - position: relative; - - @include mobile-or-tablet-screen { - width: 100%; - } - - &--header { - margin: 1.6rem 0 2.4rem; - - @include mobile-or-tablet-screen { - padding: 0; - } - } - - .dc-dropdown-list { - margin: 1rem 0; - } - - &-layout { - display: flex; - flex-direction: column; - height: 100%; - - &--fields { - display: flex; - flex-direction: column; - padding: 0 24.4rem; - - @include mobile-or-tablet-screen { - padding: 0 1.6rem; - } - } - } - - &-field { - margin-bottom: 3.2rem; - - &--info { - display: flex; - align-items: baseline; - gap: 0.8rem; - } - } - - &-action { - padding: 1.6rem 2.4rem; - - @include mobile-or-tablet-screen { - position: absolute; - bottom: 0; - width: 100%; - padding: 1.6rem; - } - - .dc-btn { - @include mobile-or-tablet-screen { - width: 100%; - } - } - } - } -} diff --git a/packages/account/src/Components/additional-kyc-info-modal/additional-kyc-info-modal.tsx b/packages/account/src/Components/additional-kyc-info-modal/additional-kyc-info-modal.tsx deleted file mode 100644 index 6a243dbbe01d..000000000000 --- a/packages/account/src/Components/additional-kyc-info-modal/additional-kyc-info-modal.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { Div100vhContainer, InlineMessage, Modal, PageOverlay, Text, UILoader } from '@deriv/components'; -import { getPlatformSettings } from '@deriv/shared'; -import { observer, useStore } from '@deriv/stores'; -import { Localize } from '@deriv/translations'; -import { useDevice } from '@deriv-com/ui'; -import React from 'react'; -import AdditionalKycInfoForm from './additional-kyc-info-form'; - -type TAdditionalKycInfoFormWithHintBox = { - error?: unknown; - setError?: React.Dispatch>; -}; - -const AdditionalKycInfoFormWithHintBox = ({ error, setError }: TAdditionalKycInfoFormWithHintBox) => { - return ( - - {!!error && ( - - )} - - - ); -}; - -export const AdditionalKycInfoModal = observer(() => { - const { - ui: { is_additional_kyc_info_modal_open: is_open, toggleAdditionalKycInfoModal }, - } = useStore(); - const { isDesktop } = useDevice(); - const [error, setError] = React.useState(''); - - const toggleModal = (e?: React.MouseEvent | React.KeyboardEvent) => { - // if e.target is anchor tag, don't close modal for link click within modal - const target = e?.target as HTMLElement; - if (target.tagName === 'A') e?.stopPropagation(); - toggleAdditionalKycInfoModal(); - }; - - const mt5_platform_settings = getPlatformSettings('mt5'); - - const ModalTitle = () => ( - - ); - - return ( - }> -
- {isDesktop ? ( - } - toggleModal={toggleModal} - className='additional-kyc-info' - width='90.4rem' - height={error ? '54.4rem' : '49.6rem'} - > - - - - - ) : ( - - - - } - onClickClose={toggleAdditionalKycInfoModal} - header_classname='additional-kyc-info-modal__portal-header' - > - - - - - )} -
-
- ); -}); - -AdditionalKycInfoModal.displayName = 'AdditionalKycInfoModal'; diff --git a/packages/account/src/Components/additional-kyc-info-modal/form-config.tsx b/packages/account/src/Components/additional-kyc-info-modal/form-config.tsx deleted file mode 100644 index 05179815ad4b..000000000000 --- a/packages/account/src/Components/additional-kyc-info-modal/form-config.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import React from 'react'; -import { GetSettings, ResidenceList } from '@deriv/api-types'; -import { Localize, localize } from '@deriv/translations'; -import { generateValidationFunction } from '@deriv/shared'; -import { TListItem } from 'Types'; - -export type TFields = 'place_of_birth' | 'tax_residence' | 'tax_identification_number' | 'account_opening_reason'; - -type ReqRule = ['req', React.ReactNode]; - -type LengthRule = ['length', React.ReactNode, { min: number; max: number }]; - -type RegularRule = ['regular', React.ReactNode, { regex: RegExp }]; - -type CustomValidator = ( - value: string, - /** - * The options passed to the validation function - */ - options: Record, - /** - * The values of all fields in the form - */ - values: Record -) => React.ReactNode; - -type CustomRule = [CustomValidator, React.ReactNode]; - -type Rule = ReqRule | LengthRule | RegularRule | CustomRule; - -type TInputConfig = { - label: React.ReactNode; - /** - * The type of the input field (e.g. 'text', 'password', 'select', etc.) - */ - type?: string; - initial_value: string; - required?: boolean; - disabled?: boolean; - placeholder?: string; - /** - * The list of items for the dropdown or select - */ - list_items?: TListItem[]; - /** - * The validation rules for the input field (e.g. 'req', 'length', 'regular', etc.) - */ - rules?: Array; -}; - -export type TGetField = Omit & { name: string }; - -export type TFormFieldsConfig = { - [key in TFields]: TInputConfig; -}; - -/** - * The base config for form fields with validation rules - * every field should have label, type, initial_value, disabled, required, placeholder, list_items, rules - * - * `list_items` is used for dropdowns and select - * @returns TFormFieldsConfig - */ -export const getFormFieldsConfig = ( - account_settings: GetSettings, - residence_list: ResidenceList, - required_fields: TFields[] -) => { - /** - * Check if the field is disabled based on the immutable_fields from API - */ - const isFieldDisabled = (field: string) => account_settings?.immutable_fields?.includes(field); - - /** - * Check if the field is required based on the required_fields array passed - */ - const isFieldRequired = (field: TFields) => required_fields.includes(field); - - const config: TFormFieldsConfig = { - place_of_birth: { - label: ( - - ), - type: 'select', - initial_value: - (account_settings.place_of_birth && - residence_list.find(item => item.value === account_settings.place_of_birth)?.text) ?? - '', - disabled: isFieldDisabled('place_of_birth'), - required: isFieldRequired('place_of_birth'), - list_items: residence_list as TListItem[], - rules: [['req', ]], - }, - tax_residence: { - label: ( - - ), - type: 'select', - initial_value: - (account_settings.tax_residence && - residence_list.find(item => item.value === account_settings.tax_residence)?.text) ?? - '', - disabled: isFieldDisabled('tax_residence'), - required: isFieldRequired('tax_residence'), - list_items: residence_list as TListItem[], - rules: [], - }, - tax_identification_number: { - label: ( - - ), - type: 'text', - initial_value: account_settings.tax_identification_number ?? '', - disabled: isFieldDisabled('tax_identification_number'), - required: isFieldRequired('tax_identification_number'), - rules: [ - [ - 'length', - , - { min: 0, max: 25 }, - ], - [ - // check if the TIN value is available, then perform the regex test - // else return true (to pass the test) - // this is to allow empty string to pass the test in case of optioal TIN field - (value: string) => (value ? RegExp(/^(?!^$|\s+)[A-Za-z0-9./\s-]{0,25}$/).test(value) : true), - localize('Letters, numbers, spaces, periods, hyphens and forward slashes only.'), - ], - [ - (value, options, { tax_residence }) => { - return value ? !!tax_residence : true; - }, - , - ], - [ - (value: string, options, { tax_residence }) => { - const tin_format = residence_list.find( - res => res.text === tax_residence && res.tin_format - )?.tin_format; - return value && tin_format - ? tin_format.some(tax_regex => new RegExp(tax_regex).test(value)) - : true; - }, - , - ], - ], - }, - account_opening_reason: { - label: ( - - ), - type: 'select', - initial_value: account_settings.account_opening_reason ?? '', - disabled: isFieldDisabled('account_opening_reason'), - required: isFieldRequired('account_opening_reason'), - list_items: [ - { - text: localize('Hedging'), - value: 'Hedging', - }, - { - text: localize('Income Earning'), - value: 'Income Earning', - }, - { - text: localize('Speculative'), - value: 'Speculative', - }, - ], - rules: [ - [ - 'req', - , - ], - ], - }, - }; - return config; -}; - -/** - * Generate initial values for form fields - */ -const generateInitialValues = (fields: ReturnType) => { - const initial_values: Record = {} as Record; - (Object.keys(fields) as TFields[]).forEach(field => { - initial_values[field] = fields[field].initial_value; - }); - return initial_values; -}; - -/** - * This function is used to transform form fields config to the format that is used in Formik or Formik Field - */ -const getField = (fields: TFormFieldsConfig, name: TFields, with_input_types: boolean): TGetField => { - const { label, placeholder, required, disabled, type, list_items } = fields[name]; - - return { - name, - label, - required, - disabled, - ...(with_input_types ? { type } : {}), - ...(placeholder ? { placeholder } : {}), - ...(list_items ? { list_items } : {}), - }; -}; - -/** - * Function to transform and return form config that can be used within the component that renders the form - */ -export const getFormConfig = (options: { - account_settings: GetSettings; - residence_list: ResidenceList; - required_fields: TFields[]; - with_input_types?: boolean; -}) => { - const { account_settings, residence_list, required_fields, with_input_types = false } = options; - const fields_config = getFormFieldsConfig(account_settings, residence_list, required_fields); - const inputs: Record = {} as Record; - Object.keys(fields_config).forEach(field_key => { - // @ts-expect-error `field_key` is always a key of `fields_config`, Hence can ignore the TS error. - inputs[field_key] = getField(fields_config, field_key, with_input_types); - }); - return { - fields: inputs, - /** typing fields_config as any as this current config has different structure - * and generateValidationFunction should have generic types - * */ - validate: generateValidationFunction('', fields_config as any), - initialValues: generateInitialValues(fields_config), - }; -}; diff --git a/packages/account/src/Components/additional-kyc-info-modal/index.ts b/packages/account/src/Components/additional-kyc-info-modal/index.ts deleted file mode 100644 index 8d987c7f7f09..000000000000 --- a/packages/account/src/Components/additional-kyc-info-modal/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { AdditionalKycInfoModal } from './additional-kyc-info-modal'; -import './additional-kyc-info-modal.scss'; - -export default AdditionalKycInfoModal; diff --git a/packages/account/src/Components/forms/__tests__/personal-details-form.spec.tsx b/packages/account/src/Components/forms/__tests__/personal-details-form.spec.tsx index 7722a6cba755..32c2a1008774 100644 --- a/packages/account/src/Components/forms/__tests__/personal-details-form.spec.tsx +++ b/packages/account/src/Components/forms/__tests__/personal-details-form.spec.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { Formik } from 'formik'; - -import { fireEvent, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import PersonalDetailsForm from '../personal-details-form'; +import { APIProvider } from '@deriv/api'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -22,9 +22,11 @@ describe('PersonalDetailsForm', () => { const renderComponent = () => { render( - - - + + + + + ); }; @@ -52,22 +54,4 @@ describe('PersonalDetailsForm', () => { expect(mr_radio_input).not.toBeChecked(); expect(ms_radio_input).toBeChecked(); }); - - it('should display crs confirmation checkbox if tax residence & tin fields are filled', () => { - render( - - - - ); - - fireEvent.change(screen.getByTestId('tax_residence'), { target: { value: 'Afghanistan' } }); - fireEvent.change(screen.getByTestId('tax_identification_number'), { target: { value: '1234567890' } }); - - expect( - screen.queryByLabelText(/i confirm that my tax information is accurate and complete/i) - ).toBeInTheDocument(); - }); }); diff --git a/packages/account/src/Components/forms/form-fields/__tests__/tax-identification-number.spec.tsx b/packages/account/src/Components/forms/form-fields/__tests__/tax-identification-number.spec.tsx new file mode 100644 index 000000000000..abd90cec4990 --- /dev/null +++ b/packages/account/src/Components/forms/form-fields/__tests__/tax-identification-number.spec.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Formik } from 'formik'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TaxIdentificationNumber from '../tax-indentification-number'; + +describe('Testing component', () => { + const renderFunction = (props: React.ComponentProps) => + render( + + + + ); + + it('should render TIN Field component', () => { + const props: React.ComponentProps = { + required: true, + disabled: false, + is_tin_popover_open: true, + setIsTinPopoverOpen: jest.fn(), + setIsTaxResidencePopoverOpen: jest.fn(), + }; + + renderFunction(props); + + expect(screen.getByText(/Tax identification number*/)).toBeInTheDocument; + }); + + it('should render TIN Field component without required', () => { + const props: React.ComponentProps = { + required: false, + disabled: false, + is_tin_popover_open: true, + setIsTinPopoverOpen: jest.fn(), + setIsTaxResidencePopoverOpen: jest.fn(), + }; + + renderFunction(props); + + expect(screen.getByText(/Tax identification number/)).toBeInTheDocument; + }); + + it('should open popover dialog when hovered', () => { + const props: React.ComponentProps = { + required: false, + disabled: false, + is_tin_popover_open: true, + setIsTinPopoverOpen: jest.fn(), + setIsTaxResidencePopoverOpen: jest.fn(), + }; + + renderFunction(props); + + const popover = screen.getByTestId('tax_identification_number_pop_over'); + userEvent.click(popover); + expect(props.setIsTaxResidencePopoverOpen).toBeCalledWith(false); + expect(props.setIsTinPopoverOpen).toBeCalledWith(true); + }); +}); diff --git a/packages/account/src/Components/forms/form-fields/account-opening-reason.tsx b/packages/account/src/Components/forms/form-fields/account-opening-reason.tsx new file mode 100644 index 000000000000..90f28c2733d1 --- /dev/null +++ b/packages/account/src/Components/forms/form-fields/account-opening-reason.tsx @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +//@ts-nocheck [TODO] - Need to fix typescript errors in Autocomplete & SelectNative components +import React from 'react'; +import { useDevice } from '@deriv-com/ui'; +import { SelectNative, Dropdown } from '@deriv/components'; +import { useTranslations } from '@deriv-com/translations'; +import { Field, FieldProps } from 'formik'; +import clsx from 'clsx'; + +type TAccountOpeningReasonFieldProps = { + required: boolean; + account_opening_reason_list: { text: string; value: string }[]; + setFieldValue: (field: string, value: string, should_validate?: boolean) => void; + disabled: boolean; + fieldFocused?: boolean; + is_modal?: boolean; +}; + +const AccountOpeningReasonField = ({ + required, + account_opening_reason_list, + setFieldValue, + disabled, + fieldFocused, + is_modal, +}: TAccountOpeningReasonFieldProps) => { + const { isDesktop } = useDevice(); + const { localize } = useTranslations(); + + return ( + + + {({ field, meta }: FieldProps) => ( +
+ {isDesktop ? ( + + ) : ( + { + field.onChange(e); + setFieldValue('account_opening_reason', e.target.value, true); + }} + required + data_testid='account_opening_reason_mobile' + disabled={disabled} + className={clsx({ 'focus-field': fieldFocused })} + /> + )} +
+ )} +
+
+ ); +}; + +export default AccountOpeningReasonField; diff --git a/packages/account/src/Components/forms/form-fields/employment-status.tsx b/packages/account/src/Components/forms/form-fields/employment-status.tsx new file mode 100644 index 000000000000..319089e852fb --- /dev/null +++ b/packages/account/src/Components/forms/form-fields/employment-status.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Field, FieldProps } from 'formik'; +import { Dropdown, SelectNative } from '@deriv/components'; +import { useTranslations } from '@deriv-com/translations'; +import { getEmploymentStatusList } from '../../../Constants/financial-information-list'; +import { useDevice } from '@deriv-com/ui'; +import clsx from 'clsx'; + +type TEmploymentStatusFieldProps = { + required: boolean; + is_disabled: boolean; + fieldFocused?: boolean; +}; + +const EmploymentStatusField = ({ required, is_disabled, fieldFocused }: TEmploymentStatusFieldProps) => { + const { isDesktop } = useDevice(); + const { localize } = useTranslations(); + + return ( + + {({ field, form: { setFieldValue, setFieldTouched, handleBlur, handleChange }, meta }: FieldProps) => ( +
+ {isDesktop ? ( + { + setFieldValue('tin_skipped', 0, true); + setFieldValue(field.name, e.target?.value, true); + handleChange(e); + }} + handleBlur={handleBlur} + error={meta.touched ? meta.error : undefined} + disabled={is_disabled} + className={clsx('dropdown-field', { 'focus-field': fieldFocused })} + /> + ) : ( + ) => { + setFieldValue('tin_skipped', 0, true); + setFieldTouched('employment_status', true); + handleChange(e); + }} + disabled={is_disabled} + className={clsx({ 'focus-field': fieldFocused })} + /> + )} +
+ )} +
+ ); +}; + +export default EmploymentStatusField; diff --git a/packages/account/src/Components/forms/form-fields/index.ts b/packages/account/src/Components/forms/form-fields/index.ts index 8ecb3a2bce22..335408a2e547 100644 --- a/packages/account/src/Components/forms/form-fields/index.ts +++ b/packages/account/src/Components/forms/form-fields/index.ts @@ -1,4 +1,7 @@ import FormInputField from './form-input-field'; import DateOfBirthField from './date-of-birth-field'; +import EmploymentStatusField from './employment-status'; +import TaxResidenceField from './tax-residence'; +import TaxIdentificationNumberField from './tax-indentification-number'; -export { FormInputField, DateOfBirthField }; +export { FormInputField, DateOfBirthField, EmploymentStatusField, TaxResidenceField, TaxIdentificationNumberField }; diff --git a/packages/account/src/Components/forms/form-fields/tax-indentification-number.tsx b/packages/account/src/Components/forms/form-fields/tax-indentification-number.tsx new file mode 100644 index 000000000000..6f2366b1786a --- /dev/null +++ b/packages/account/src/Components/forms/form-fields/tax-indentification-number.tsx @@ -0,0 +1,76 @@ +import { Localize, useTranslations } from '@deriv-com/translations'; +import FormInputField from './form-input-field'; +import { Popover } from '@deriv/components'; +import { OECD_TIN_FORMAT_URL } from '../../../Constants/external-urls'; +import { useDevice } from '@deriv-com/ui'; +import clsx from 'clsx'; + +type TTaxIdentificationNumberFieldProps = { + required?: boolean; + disabled: boolean; + is_tin_popover_open: boolean; + setIsTinPopoverOpen: (is_open: boolean) => void; + setIsTaxResidencePopoverOpen: (is_open: boolean) => void; + fieldFocused?: boolean; +}; + +const TaxIdentificationNumberField = ({ + required = false, + is_tin_popover_open, + setIsTinPopoverOpen, + setIsTaxResidencePopoverOpen, + disabled, + fieldFocused, +}: TTaxIdentificationNumberFieldProps) => { + const { localize } = useTranslations(); + + const { isDesktop } = useDevice(); + + return ( +
+ +
{ + setIsTaxResidencePopoverOpen(false); + setIsTinPopoverOpen(true); + if ((e.target as HTMLElement).tagName !== 'A') e.stopPropagation(); + }} + > + here to learn more." + } + components={[ + , + ]} + /> + } + zIndex='9998' + disable_message_icon + /> +
+
+ ); +}; + +export default TaxIdentificationNumberField; diff --git a/packages/account/src/Components/forms/form-fields/tax-residence.tsx b/packages/account/src/Components/forms/form-fields/tax-residence.tsx new file mode 100644 index 000000000000..5f20f4e03c84 --- /dev/null +++ b/packages/account/src/Components/forms/form-fields/tax-residence.tsx @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +//@ts-nocheck [TODO] - Need to fix typescript errors in Autocomplete & SelectNative components + +import { ChangeEvent } from 'react'; +import { Field, FieldProps } from 'formik'; +import { ResidenceList } from '@deriv/api-types'; +import { Autocomplete, SelectNative, Popover } from '@deriv/components'; +import { useResidenceList } from '@deriv/hooks'; +import { TItem } from '@deriv/components/src/components/dropdown-list'; +import { useTranslations } from '@deriv-com/translations'; +import { useDevice } from '@deriv-com/ui'; +import clsx from 'clsx'; + +type TTaxResidenceFieldProps = { + required?: boolean; + setIsTaxResidencePopoverOpen: (is_open: boolean) => void; + setIsTinPopoverOpen: (is_open: boolean) => void; + is_tax_residence_popover_open: boolean; + disabled: boolean; + fieldFocused?: boolean; +}; + +const TaxResidenceField = ({ + required = false, + setIsTaxResidencePopoverOpen, + setIsTinPopoverOpen, + is_tax_residence_popover_open, + disabled, + fieldFocused, +}: TTaxResidenceFieldProps) => { + const { data: residence_list } = useResidenceList(); + const { isDesktop } = useDevice(); + const { localize } = useTranslations(); + + return ( + + {({ field, form: { setFieldValue }, meta }: FieldProps) => ( +
+ {isDesktop ? ( + { + setFieldValue( + 'tax_residence', + (item as ResidenceList[0]).value ? (item as ResidenceList[0]).text : '', + true + ); + }} + data-testid='tax_residence' + disabled={disabled} + required={required} + className={clsx({ 'focus-field': fieldFocused })} + /> + ) : ( + ) => { + field.onChange(e); + setFieldValue('tax_residence', e.target.value, true); + }} + required={required} + data_testid='tax_residence_mobile' + disabled={disabled} + className={clsx({ 'focus-field': fieldFocused })} + /> + )} +
{ + setIsTaxResidencePopoverOpen(true); + setIsTinPopoverOpen(false); + e.stopPropagation(); + }} + > + +
+
+ )} +
+ ); +}; + +export default TaxResidenceField; diff --git a/packages/account/src/Components/forms/form-select-field.tsx b/packages/account/src/Components/forms/form-select-field.tsx index 5d12b7b02a02..486d83f809be 100644 --- a/packages/account/src/Components/forms/form-select-field.tsx +++ b/packages/account/src/Components/forms/form-select-field.tsx @@ -1,8 +1,7 @@ import { FC, Fragment } from 'react'; import { Autocomplete, SelectNative } from '@deriv/components'; import { Field, FieldProps, FormikErrors } from 'formik'; -import { TGetField } from '../additional-kyc-info-modal/form-config'; -import { TListItem } from 'Types'; +import { TListItem, TGetField } from '../../Types'; import { useDevice } from '@deriv-com/ui'; type TFormSelectField = TGetField & { @@ -40,9 +39,9 @@ const FormSelectField: FC = ({ {!isDesktop ? ( = ({ // @ts-expect-error This needs to fixed in AutoComplete component onItemSelection={onItemSelection ?? onSelect(field.name, setFieldValue)} data-testid={`dt_${field.name}`} - // @ts-expect-error This needs to fixed in AutoComplete component - list_height={list_height} + list_height={list_height as string} /> )} diff --git a/packages/account/src/Components/forms/personal-details-form.jsx b/packages/account/src/Components/forms/personal-details-form.jsx index 81c659067656..a0172b31607b 100644 --- a/packages/account/src/Components/forms/personal-details-form.jsx +++ b/packages/account/src/Components/forms/personal-details-form.jsx @@ -2,26 +2,17 @@ import React from 'react'; import { Link } from 'react-router-dom'; import clsx from 'clsx'; import { Field, useFormikContext } from 'formik'; -import { - Autocomplete, - Checkbox, - Dropdown, - InlineMessage, - Popover, - RadioGroup, - SelectNative, - Text, -} from '@deriv/components'; -import { getLegalEntityName, routes, validPhone } from '@deriv/shared'; +import { Autocomplete, Checkbox, InlineMessage, RadioGroup, SelectNative, Text } from '@deriv/components'; +import { routes, validPhone } from '@deriv/shared'; import { Localize, localize } from '@deriv/translations'; import { isFieldImmutable, verifyFields } from '../../Helpers/utils'; -import { getEmploymentStatusList } from '../../Sections/Assessment/FinancialAssessment/financial-information-list'; import FormBodySection from '../form-body-section'; import { DateOfBirthField, FormInputField } from './form-fields'; import FormSubHeader from '../form-sub-header'; import InlineNoteWithIcon from '../inline-note-with-icon'; +import { useResidenceList } from '@deriv/hooks'; import { useDevice } from '@deriv-com/ui'; -import { OECD_TIN_FORMAT_URL } from '../../Constants/external-urls'; +import AccountOpeningReasonField from './form-fields/account-opening-reason'; const PersonalDetailsForm = props => { const { isDesktop } = useDevice(); @@ -33,15 +24,12 @@ const PersonalDetailsForm = props => { is_rendered_for_idv, editable_fields = [], has_real_account, - residence_list, is_fully_authenticated, account_opening_reason_list, closeRealAccountSignup, salutation_list, is_rendered_for_onfido, is_qualified_for_poa, - should_close_tooltip, - setShouldCloseTooltip, class_name, states_list, side_note, @@ -52,26 +40,9 @@ const PersonalDetailsForm = props => { // need to put this check related to DIEL clients const is_svg_only = is_svg && !is_eu_user; - const [is_tax_residence_popover_open, setIsTaxResidencePopoverOpen] = React.useState(false); - const [is_tin_popover_open, setIsTinPopoverOpen] = React.useState(false); + const { errors, touched, values, setFieldValue, handleChange, handleBlur } = useFormikContext(); - const { errors, touched, values, setFieldValue, handleChange, handleBlur, setFieldTouched } = useFormikContext(); - - const handleToolTipStatus = React.useCallback(() => { - if (is_tax_residence_popover_open) { - setIsTaxResidencePopoverOpen(false); - } - if (is_tin_popover_open) { - setIsTinPopoverOpen(false); - } - }, [is_tax_residence_popover_open, is_tin_popover_open]); - - React.useEffect(() => { - if (should_close_tooltip) { - handleToolTipStatus(); - setShouldCloseTooltip(false); - } - }, [should_close_tooltip, handleToolTipStatus, setShouldCloseTooltip]); + const { data: residence_list } = useResidenceList(); const getNameAndDobLabels = () => { const is_asterisk_needed = is_svg || is_eu_user || is_rendered_for_onfido || is_rendered_for_idv; @@ -416,100 +387,6 @@ const PersonalDetailsForm = props => { required /> )} - {!is_svg_only && ('tax_residence' in values || 'tax_identification_number' in values) && ( - - - {'tax_residence' in values && ( - - )} - {'tax_identification_number' in values && ( - - )} - {'employment_status' in values && ( -
- {isDesktop ? ( - { - setFieldValue('occupation', '', true); - handleChange(e); - }} - handleBlur={handleBlur} - error={touched.employment_status && errors.employment_status} - disabled={isFieldImmutable('employment_status', editable_fields)} - /> - ) : ( - { - setFieldTouched('employment_status', true); - setFieldValue('occupation', '', true); - handleChange(e); - }} - disabled={isFieldImmutable('employment_status', editable_fields)} - /> - )} -
- )} - {'tax_identification_confirm' in values && ( - - setFieldValue( - 'tax_identification_confirm', - !values.tax_identification_confirm, - true - ) - } - value={values.tax_identification_confirm} - label={localize( - 'I hereby confirm that the tax information I provided is true and complete. I will also inform {{legal_entity_name}} about any changes to this information.', - { - legal_entity_name: getLegalEntityName('maltainvest'), - } - )} - withTabIndex={0} - data-testid='tax_identification_confirm' - has_error={ - !!(touched.tax_identification_confirm && errors.tax_identification_confirm) - } - /> - )} -
- )} {!is_svg_only && 'account_opening_reason' in values && ( { required /> )} - {'tax_residence' in values && ( - - )} - {'tax_identification_number' in values && ( - - )} {'account_opening_reason' in values && ( { (values?.account_opening_reason && has_real_account) } required - /> - )} - {values?.tax_residence && values?.tax_identification_number && ( - - } - label_font_size={isDesktop ? 'xs' : 'xxs'} - onChange={e => { - setFieldValue('crs_confirmation', e.target.checked, true); - setFieldTouched('crs_confirmation', true); - }} - has_error={!!(touched?.crs_confirmation && errors?.crs_confirmation)} + is_modal /> )} @@ -676,180 +520,3 @@ const PlaceOfBirthField = ({ handleChange, setFieldValue, disabled, residence_li ); }; - -const TaxResidenceField = ({ - setFieldValue, - residence_list, - required = false, - setIsTaxResidencePopoverOpen, - setIsTinPopoverOpen, - is_tax_residence_popover_open, - disabled, -}) => { - const { isDesktop } = useDevice(); - return ( - - {({ field, meta }) => ( -
- {isDesktop ? ( - - setFieldValue('tax_residence', value ? text : '', true) - } - list_portal_id='modal_root' - data-testid='tax_residence' - disabled={disabled} - required={required} - /> - ) : ( - { - field.onChange(e); - setFieldValue('tax_residence', e.target.value, true); - }} - {...field} - required={required} - data_testid='tax_residence_mobile' - disabled={disabled} - /> - )} -
{ - setIsTaxResidencePopoverOpen(true); - setIsTinPopoverOpen(false); - e.stopPropagation(); - }} - > - -
-
- )} -
- ); -}; - -const TaxIdentificationNumberField = ({ - is_tin_popover_open, - setIsTinPopoverOpen, - setIsTaxResidencePopoverOpen, - disabled, - required = false, -}) => { - const { isDesktop } = useDevice(); - return ( -
- ); -}; - -const AccountOpeningReasonField = ({ no_header, required, account_opening_reason_list, setFieldValue, disabled }) => { - const { isDesktop } = useDevice(); - return ( - - {!no_header && } - - {({ field, meta }) => ( - - {isDesktop ? ( - - ) : ( - { - field.onChange(e); - setFieldValue('account_opening_reason', e.target.value, true); - }} - {...field} - required - data_testid='account_opening_reason_mobile' - disabled={disabled} - /> - )} - - )} - - - ); -}; diff --git a/packages/account/src/Components/personal-details/__tests__/personal-details.spec.tsx b/packages/account/src/Components/personal-details/__tests__/personal-details.spec.tsx index a5453cec53c5..23d4f9a0d8be 100644 --- a/packages/account/src/Components/personal-details/__tests__/personal-details.spec.tsx +++ b/packages/account/src/Components/personal-details/__tests__/personal-details.spec.tsx @@ -1,14 +1,15 @@ import React, { ComponentProps, ReactNode } from 'react'; import { BrowserRouter } from 'react-router-dom'; -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { splitValidationResultTypes } from '../../real-account-signup/helpers/utils'; import PersonalDetails from '../personal-details'; -import { shouldShowIdentityInformation, isDocumentTypeValid, isAdditionalDocumentValid } from '../../../Helpers/utils'; +import { shouldShowIdentityInformation } from '../../../Helpers/utils'; import { StoreProvider, mockStore } from '@deriv/stores'; import { Analytics } from '@deriv-com/analytics'; import { FormikErrors } from 'formik'; +import { getIDVFormValidationSchema } from '../../../Configs/kyc-validation-config'; import { useDevice } from '@deriv-com/ui'; +import { APIProvider } from '@deriv/api'; jest.mock('@deriv-com/ui', () => ({ ...jest.requireActual('@deriv-com/ui'), @@ -25,13 +26,6 @@ jest.mock('@deriv/components', () => ({ Popover: jest.fn(props => props.is_open && {props.message}), })); -jest.mock('../../real-account-signup/helpers/utils.ts', () => ({ - splitValidationResultTypes: jest.fn(() => ({ - warnings: mock_warnings, - errors: mock_errors, - })), -})); - jest.mock('react-dom', () => ({ ...jest.requireActual('react-dom'), createPortal: (node: ReactNode) => node, @@ -76,8 +70,6 @@ const runCommonFormfieldsTests = (is_svg: boolean) => { expect(screen.queryByTestId('citizenship')).toBeInTheDocument(); expect(screen.queryByTestId('citizenship_mobile')).not.toBeInTheDocument(); expect(screen.queryByTestId('phone')).toBeInTheDocument(); - expect(screen.queryByTestId('tax_residence')).toBeInTheDocument(); - expect(screen.queryByTestId('tax_residence_mobile')).not.toBeInTheDocument(); if (is_svg) { expect(screen.getByText(/your first name as in your identity document/i)).toBeInTheDocument(); @@ -99,24 +91,6 @@ const runCommonFormfieldsTests = (is_svg: boolean) => { ).toBeInTheDocument(); } - const tax_residence_pop_over = screen.queryByTestId('tax_residence_pop_over'); - if (tax_residence_pop_over) { - fireEvent.click(tax_residence_pop_over); - } - - expect(screen.getByText(tax_residence_pop_over_text)).toBeInTheDocument(); - - expect(screen.getByLabelText(/tax identification number/i)).toBeInTheDocument(); - const tax_identification_number_pop_over = screen.queryByTestId('tax_identification_number_pop_over'); - expect(tax_identification_number_pop_over).toBeInTheDocument(); - - if (tax_identification_number_pop_over) { - fireEvent.click(tax_identification_number_pop_over); - } - - expect(screen.getByText(tin_pop_over_text)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'here' })).toBeInTheDocument(); - if (is_svg) expect( screen.getByRole('heading', { @@ -270,9 +244,6 @@ describe('', () => { place_of_birth: '', citizen: '', phone: '+34', - tax_residence: '', - tax_identification_number: '', - tax_identification_confirm: false, }, onSubmit: jest.fn(), getCurrentStep: jest.fn(() => 1), @@ -297,11 +268,13 @@ describe('', () => { const renderwithRouter = ({ props = mock_props, store = mock_store }) => { render( - - - - - + + + + + + + ); }; @@ -311,9 +284,10 @@ describe('', () => { }); it('should have validation errors on form fields', async () => { - const new_props = { ...mock_props, is_svg: false }; + const new_props = { ...mock_props, is_svg: false, real_account_signup_target: 'maltainvest' }; + const store_config = mockStore({ ui: { is_desktop: true } }); - renderwithRouter({ props: new_props }); + renderwithRouter({ props: new_props, store: store_config }); const first_name = screen.getByTestId('first_name'); const last_name = screen.getByTestId('last_name'); @@ -321,79 +295,29 @@ describe('', () => { const place_of_birth = screen.getByTestId('place_of_birth'); const citizenship = screen.getByTestId('citizenship'); const phone = screen.getByTestId('phone'); - const tax_residence = screen.getByTestId('tax_residence'); - const tax_identification_number = screen.getByTestId('tax_identification_number'); - fireEvent.blur(first_name); - fireEvent.blur(last_name); + userEvent.clear(first_name); fireEvent.blur(date_of_birth); + userEvent.clear(last_name); fireEvent.blur(place_of_birth); fireEvent.blur(citizenship); fireEvent.blur(phone); - fireEvent.blur(tax_residence); - fireEvent.blur(tax_identification_number); expect(await screen.findByText(/first name is required\./i)).toBeInTheDocument(); - expect(await screen.findByText(/last name is required\./i)).toBeInTheDocument(); expect(await screen.findByText(/date of birth is required\./i)).toBeInTheDocument(); expect(await screen.findByText(/place of birth is required\./i)).toBeInTheDocument(); expect(await screen.findByText(/citizenship is required/i)).toBeInTheDocument(); - expect(await screen.findByText(/phone is required\./i)).toBeInTheDocument(); - expect(await screen.findByText(/tax residence is required\./i)).toBeInTheDocument(); - expect(await screen.findByText(/tax identification number is required\./i)).toBeInTheDocument(); - (splitValidationResultTypes as jest.Mock).mockReturnValue({ - ...mock_warnings, - errors: { - ...mock_errors, - first_name: 'letters, spaces, periods, hyphens, apostrophes only', - last_name: 'last name should be between 2 and 50 characters.', - date_of_birth: 'You must be 18 years old and above.', - tax_identification_number: "Tax Identification Number can't be longer than 25 characters.", - }, - }); + expect(await screen.findByText(/You should enter 9-20 numbers./i)).toBeInTheDocument(); + fireEvent.change(first_name, { target: { value: '123' } }); - fireEvent.change(last_name, { target: { value: 'a' } }); + fireEvent.change(last_name, { target: { value: 'abcd' } }); fireEvent.change(date_of_birth, { target: { value: '2021-04-13' } }); - fireEvent.change(tax_identification_number, { target: { value: '123456789012345678901234567890' } }); expect(await screen.findByText(/letters, spaces, periods, hyphens, apostrophes only/i)).toBeInTheDocument(); - expect(await screen.findByText(/last name should be between 2 and 50 characters/i)).toBeInTheDocument(); expect(await screen.findByText(/you must be 18 years old and above\./i)).toBeInTheDocument(); - expect( - await screen.findByText(/tax Identification Number can't be longer than 25 characters\./i) - ).toBeInTheDocument(); - }); - - it('submit button should be enabled if TIN or tax_residence is optional in case of CR accounts', () => { - const new_props = { - ...mock_props, - is_svg: true, - value: { - first_name: '', - last_name: '', - date_of_birth: '', - place_of_birth: '', - phone: '+34', - tax_residence: '', - tax_identification_number: '', - }, - }; - renderwithRouter({ props: new_props }); - - const first_name = screen.getByTestId('first_name'); - const last_name = screen.getByTestId('last_name'); - const date_of_birth = screen.getByTestId('date_of_birth'); - const phone = screen.getByTestId('phone'); - - userEvent.type(first_name, 'test firstname'); - userEvent.type(last_name, 'test lastname'); - userEvent.type(date_of_birth, '2000-12-12'); - userEvent.type(phone, '+49123456789012'); - expect(screen.getByRole('button', { name: /next/i })).toBeEnabled(); }); it('should not display confirmation checkbox if opt-out of IDV', async () => { - (splitValidationResultTypes as jest.Mock).mockReturnValue({ warnings: {}, errors: {} }); const new_props = { ...mock_props, value: { @@ -401,8 +325,13 @@ describe('', () => { last_name: '', date_of_birth: '', phone: '+93', - account_opening_reason: '', - place_of_birth: '', + account_opening_reason: 'Hedging', + place_of_birth: 'Aland Islands', + document_type: { + id: 'none', + text: 'I want to do this later', + value: 'none', + }, }, }; @@ -415,8 +344,8 @@ describe('', () => { userEvent.type(first_name, 'test firstname'); userEvent.type(last_name, 'test lastname'); - userEvent.type(date_of_birth, '2000-12-12'); - userEvent.type(phone, '+49123456789012'); + fireEvent.change(date_of_birth, { target: { value: '2000-12-12' } }); + fireEvent.change(phone, { target: { value: '+931234567890' } }); const previous_btn = screen.getByRole('button', { name: /previous/i }); const next_btn = screen.getByRole('button', { name: /next/i }); @@ -435,23 +364,6 @@ describe('', () => { }); }); - it('should autopopulate tax_residence for MF clients', () => { - const new_props = { - ...mock_props, - is_svg: false, - value: { - ...mock_props.value, - tax_residence: 'Malta', - }, - }; - renderwithRouter({ props: new_props }); - expect( - screen.getByRole('textbox', { - name: /tax residence\*/i, - }) - ).toHaveValue('Malta'); - }); - it('should render PersonalDetails component', () => { renderwithRouter({}); expect(screen.getByTestId('personal_details_form')).toBeInTheDocument(); @@ -483,20 +395,6 @@ describe('', () => { expect(mock_props.closeRealAccountSignup).toHaveBeenCalledTimes(1); }); - it('should disable tax_residence field if it is immutable from BE', () => { - const new_props = { - ...mock_props, - value: { - ...mock_props.value, - ...idv_document_data, - tax_residence: 'France', - }, - disabled_items: ['salutation', 'first_name', 'last_name', 'date_of_birth', 'tax_residence'], - }; - renderwithRouter({ props: new_props }); - expect(screen.getByTestId('tax_residence')).toBeDisabled(); - }); - it('should show title and Name label when salutation is passed', () => { const mock_store = mockStore({ traders_hub: { @@ -590,7 +488,7 @@ describe('', () => { expect(screen.getByTestId('last_name')).toBeDisabled(); expect(screen.getByTestId('date_of_birth')).toBeDisabled(); expect(screen.getByTestId('place_of_birth')).toBeEnabled(); - expect(screen.getByTestId('citizenship')).toBeEnabled(); // citizenship value is not disabled by BE, so enable the field + expect(screen.getByTestId('citizenship')).toBeEnabled(); }); it('should disable citizen field if the client is_fully_authenticated', () => { @@ -608,8 +506,6 @@ describe('', () => { }); it('should display proper data in mobile mode', () => { - // [TODO] - Remove this when PersonalDetailsForm is migrated to TSX - (useDevice as jest.Mock).mockReturnValue({ isDesktop: false }); const new_props = { ...mock_props, is_svg: false }; @@ -625,54 +521,13 @@ describe('', () => { expect(screen.queryByTestId('citizenship_mobile')).toBeInTheDocument(); expect(screen.queryByTestId('citizenship')).not.toBeInTheDocument(); expect(screen.queryByTestId('phone')).toBeInTheDocument(); - expect(screen.queryByTestId('tax_residence_mobile')).toBeInTheDocument(); - expect(screen.queryByTestId('tax_residence')).not.toBeInTheDocument(); - expect(screen.getByText(/tax identification number/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/tax identification number/i)).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: /account opening reason/i })).toBeInTheDocument(); expect(screen.queryByTestId('dt_dropdown_display')).not.toBeInTheDocument(); expect(screen.queryByTestId('account_opening_reason_mobile')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument(); }); - it('should select correct dropdown options in mobile mode', () => { - (useDevice as jest.Mock).mockReturnValueOnce({ isDesktop: false }); - - const new_props = { ...mock_props, is_svg: false }; - - renderwithRouter({ props: new_props }); - const place_of_birth_mobile = screen.queryByTestId('place_of_birth_mobile'); - - expect(place_of_birth_mobile).toBeInTheDocument(); - - if (place_of_birth_mobile) { - fireEvent.change(place_of_birth_mobile, { target: { value: 'Afghanistan' } }); - } - - const { getByText } = within(screen.getAllByTestId('selected_value')[0]); - expect(getByText('Afghanistan')).toBeInTheDocument(); - }); - - it('should show error for invalid TIN', async () => { - const newvalidate = { - errors: { - ...mock_errors, - tax_identification_number: 'Tax Identification Number is not properly formatted.', - }, - }; - (splitValidationResultTypes as jest.Mock).mockReturnValue(newvalidate); - renderwithRouter({}); - const tax_identification_number = screen.getByTestId('tax_identification_number'); - - fireEvent.blur(tax_identification_number); - fireEvent.change(tax_identification_number, { target: { value: '123456789012345678901234567890' } }); - - expect(await screen.findByText(/tax identification number is not properly formatted/i)).toBeInTheDocument(); - }); - it('should submit the form if there is no validation error on desktop', async () => { - (splitValidationResultTypes as jest.Mock).mockReturnValue({ warnings: {}, errors: {} }); const new_props = { ...mock_props, value: { @@ -680,6 +535,8 @@ describe('', () => { last_name: '', date_of_birth: '', phone: '+93', + account_opening_reason: 'Hedging', + place_of_birth: 'Aland Islands', }, }; @@ -713,25 +570,20 @@ describe('', () => { }); it('should submit the form if there is no validation error on mobile', async () => { - // [TODO] - Remove this when PersonalDetailsForm is migrated to TSX (useDevice as jest.Mock).mockReturnValueOnce({ isDesktop: false }); - (splitValidationResultTypes as jest.Mock).mockReturnValue({ warnings: {}, errors: {} }); const new_props = { ...mock_props, is_svg: false, value: { - account_opening_reason: '', - citizen: '', + account_opening_reason: 'Income Earning', + citizen: 'Albania', date_of_birth: '', first_name: '', last_name: '', phone: '+49', - place_of_birth: '', + place_of_birth: 'Albania', salutation: '', - tax_identification_confirm: false, - tax_identification_number: '', - tax_residence: '', }, }; @@ -741,13 +593,7 @@ describe('', () => { const first_name = screen.getByTestId('first_name'); const last_name = screen.getByTestId('last_name'); const date_of_birth = screen.getByTestId('date_of_birth'); - const place_of_birth_mobile = screen.getByTestId('place_of_birth_mobile'); - const citizenship = screen.getByTestId('citizenship_mobile'); const phone = screen.getByTestId('phone'); - const tax_residence_mobile = screen.getByTestId('tax_residence_mobile'); - const tax_identification_number = screen.getByTestId('tax_identification_number'); - const tax_identification_confirm = screen.getByTestId('tax_identification_confirm'); - const account_opening_reason_mobile = screen.getByTestId('account_opening_reason_mobile'); const checkbox = screen.queryByLabelText( /i confirm that the name and date of birth above match my chosen identity document/i @@ -758,13 +604,7 @@ describe('', () => { fireEvent.change(first_name, { target: { value: 'test firstname' } }); fireEvent.change(last_name, { target: { value: 'test lastname' } }); fireEvent.change(date_of_birth, { target: { value: '2000-12-12' } }); - fireEvent.change(place_of_birth_mobile, { target: { value: 'Albania' } }); - fireEvent.change(citizenship, { target: { value: 'Albania' } }); fireEvent.change(phone, { target: { value: '+49123456789012' } }); - fireEvent.change(tax_residence_mobile, { target: { value: 'Afghanistan' } }); - fireEvent.change(tax_identification_number, { target: { value: '123123123123' } }); - fireEvent.change(tax_identification_confirm, { target: { value: true } }); - fireEvent.change(account_opening_reason_mobile, { target: { value: 'Income Earning' } }); expect(mr_radio_btn.checked).toEqual(true); const next_btn = screen.getByRole('button', { name: /next/i }); @@ -778,7 +618,6 @@ describe('', () => { }); it('should save filled date when cancel button is clicked ', async () => { - (splitValidationResultTypes as jest.Mock).mockReturnValue({ warnings: {}, errors: {} }); const new_props = { ...mock_props, value: { @@ -804,71 +643,6 @@ describe('', () => { }); }); - it('should close tax_residence pop-over when clicked outside', () => { - const new_props = { ...mock_props, is_svg: false }; - renderwithRouter({ props: new_props }); - - const tax_residence_pop_over = screen.getByTestId('tax_residence_pop_over'); - expect(tax_residence_pop_over).toBeInTheDocument(); - - fireEvent.click(tax_residence_pop_over); - expect(screen.getByText(tax_residence_pop_over_text)).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('heading', { name: /account opening reason/i })); - - expect(screen.queryByText(tax_residence_pop_over_text)).not.toBeInTheDocument(); - }); - - it('should close tax_identification_number_pop_over when clicked outside', () => { - const new_props = { ...mock_props, is_svg: false }; - renderwithRouter({ props: new_props }); - - const tin_pop_over = screen.getByTestId('tax_identification_number_pop_over'); - expect(tin_pop_over).toBeInTheDocument(); - fireEvent.click(tin_pop_over); - - expect(screen.getByText(tin_pop_over_text)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'here' })).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('heading', { name: /account opening reason/i })); - - expect(screen.queryByText(tin_pop_over_text)).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'here' })).not.toBeInTheDocument(); - }); - - it('should close tax_residence pop-over when scrolled', () => { - renderwithRouter({}); - - const tax_residence_pop_over = screen.getByTestId('tax_residence_pop_over'); - expect(tax_residence_pop_over).toBeInTheDocument(); - fireEvent.click(tax_residence_pop_over); - - expect(screen.getByText(tax_residence_pop_over_text)).toBeInTheDocument(); - - fireEvent.scroll(screen.getByTestId('dt_personal_details_container'), { - target: { scrollY: 100 }, - }); - - expect(screen.queryByText(tax_residence_pop_over_text)).not.toBeInTheDocument(); - }); - - it('should close tax_identification_number_pop_over when scrolled', () => { - renderwithRouter({}); - - const tax_identification_number_pop_over = screen.getByTestId('tax_identification_number_pop_over'); - expect(tax_identification_number_pop_over).toBeInTheDocument(); - fireEvent.click(tax_identification_number_pop_over); - expect(screen.getByText(tin_pop_over_text)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'here' })).toBeInTheDocument(); - - fireEvent.scroll(screen.getByTestId('dt_personal_details_container'), { - target: { scrollY: 100 }, - }); - - expect(screen.queryByText(tax_residence_pop_over_text)).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'here' })).not.toBeInTheDocument(); - }); - it('should validate idv values when a document type is selected', async () => { (shouldShowIdentityInformation as jest.Mock).mockReturnValue(true); const new_props = { @@ -879,11 +653,17 @@ describe('', () => { }, residence_list: default_residence_details as any, }; + + const idvSchema = getIDVFormValidationSchema(); + renderwithRouter({ props: new_props }); await waitFor(() => { - expect(isDocumentTypeValid).toHaveBeenCalled(); - expect(isAdditionalDocumentValid).not.toHaveBeenCalled(); + try { + idvSchema.validateSync(idv_document_data); + } catch (e) { + expect((e as any).errors[0]).toMatch(/Please enter the correct format./i); + } }); }); @@ -891,12 +671,19 @@ describe('', () => { (shouldShowIdentityInformation as jest.Mock).mockReturnValue(true); const new_document_data = { - ...idv_document_data, + document_number: 'A1234562', + + document_additional: 'AB1', document_type: { - ...idv_document_data.document_type, + id: 'passport', + text: 'Passport', additional: { - display_name: '12345', + display_name: 'File Number', + format: '^.{15}$', + example_format: 'AB1234567890123', }, + value: '^.{8}$', + example_format: 'A1234567', }, }; @@ -908,10 +695,16 @@ describe('', () => { }, residence_list: default_residence_details, }; + + const idvSchema = getIDVFormValidationSchema(); renderwithRouter({ props: new_props }); await waitFor(() => { - expect(isAdditionalDocumentValid).toHaveBeenCalled(); + try { + idvSchema.validateSync(new_document_data); + } catch (e) { + expect((e as any).errors[0]).toEqual('Please enter the correct format. Example: AB1234567890123'); + } }); }); }); diff --git a/packages/account/src/Components/personal-details/personal-details.tsx b/packages/account/src/Components/personal-details/personal-details.tsx index af6bbc6e3b56..93a02aa4d8c3 100644 --- a/packages/account/src/Components/personal-details/personal-details.tsx +++ b/packages/account/src/Components/personal-details/personal-details.tsx @@ -1,26 +1,22 @@ -import { useState, Fragment, useCallback, useMemo, useEffect } from 'react'; +import { Fragment, useCallback, useMemo, useEffect } from 'react'; import clsx from 'clsx'; -import { Form, Formik, FormikErrors } from 'formik'; +import { Form, Formik } from 'formik'; import { Analytics, TEvents } from '@deriv-com/analytics'; import { AutoHeightWrapper, Div100vhContainer, FormSubmitButton, Modal, ThemedScrollbars } from '@deriv/components'; +import { getIDVNotApplicableOption } from '@deriv/shared'; import { useDevice } from '@deriv-com/ui'; -import { getIDVNotApplicableOption, removeEmptyPropertiesFromObject } from '@deriv/shared'; import { Localize, localize } from '@deriv/translations'; import { useStore, observer } from '@deriv/stores'; -import { - isAdditionalDocumentValid, - isDocumentNumberValid, - isDocumentTypeValid, - shouldShowIdentityInformation, -} from '../../Helpers/utils'; +import { shouldShowIdentityInformation } from '../../Helpers/utils'; import { DerivLightNameDobPoiIcon } from '@deriv/quill-icons'; import FormSubHeader from '../form-sub-header'; import IDVForm from '../forms/idv-form'; import PersonalDetailsForm from '../forms/personal-details-form'; -import { splitValidationResultTypes } from '../real-account-signup/helpers/utils'; import ScrollToFieldWithError from '../forms/scroll-to-field-with-error'; import { TIDVFormValues, TListItem, TPersonalDetailsBaseForm } from '../../Types'; import { GetAccountStatus, GetSettings, ResidenceList } from '@deriv/api-types'; +import { getPersonalDetailsBaseValidationSchema } from '../../Configs/user-profile-validation-config'; +import { getIDVFormValidationSchema } from '../../Configs/kyc-validation-config'; type TPersonalDetailsSectionForm = Partial & { confirmation_checkbox?: boolean; @@ -82,8 +78,6 @@ const PersonalDetails = observer( traders_hub: { is_eu_user }, } = useStore(); const { account_status, account_settings, residence, real_account_signup_target } = props; - const [should_close_tooltip, setShouldCloseTooltip] = useState(false); - const [no_confirmation_needed, setNoConfirmationNeeded] = useState(false); const { isDesktop } = useDevice(); const handleCancel = (values: TPersonalDetailsSectionForm) => { @@ -127,43 +121,15 @@ const PersonalDetails = observer( const IDV_NOT_APPLICABLE_OPTION = useMemo(() => getIDVNotApplicableOption(), []); - const validateIDV = (values: TPersonalDetailsSectionForm) => { - const errors: FormikErrors = {}; - const { document_type, document_number, document_additional } = values; - if (document_type?.id === IDV_NOT_APPLICABLE_OPTION.id) return errors; - /* eslint-disable @typescript-eslint/ban-ts-comment */ - // @ts-expect-error Error is tring but Formik value was an object - errors.document_type = isDocumentTypeValid(document_type); - - const needs_additional_document = !!document_type?.additional; - - if (needs_additional_document) { - errors.document_additional = isAdditionalDocumentValid(document_type, document_additional); - } - - errors.document_number = isDocumentNumberValid(document_number, document_type); - - if (document_type?.id !== IDV_NOT_APPLICABLE_OPTION.id && !values?.confirmation_checkbox) { - errors.confirmation_checkbox = 'error'; - } - return removeEmptyPropertiesFromObject(errors); - }; - - const handleValidate = (values: TPersonalDetailsSectionForm) => { - const current_step = getCurrentStep() - 1; - onSave(current_step, values); - - setNoConfirmationNeeded(values?.document_type?.id === IDV_NOT_APPLICABLE_OPTION.id); - let idv_error = {}; - if (is_rendered_for_idv) { - idv_error = validateIDV(values); - } - const { errors } = splitValidationResultTypes(validate(values)); - const error_data = { ...idv_error, ...errors }; - return error_data; - }; - - const closeToolTip = () => setShouldCloseTooltip(true); + const schema = useMemo( + () => + is_rendered_for_idv + ? getPersonalDetailsBaseValidationSchema(real_account_signup_target).concat( + getIDVFormValidationSchema() + ) + : getPersonalDetailsBaseValidationSchema(real_account_signup_target), + [is_rendered_for_idv, real_account_signup_target] + ); /* In most modern browsers, setting autocomplete to "off" will not prevent a password manager from asking the user if they would like to save username and password information, or from automatically filling in those values in a site's login form. @@ -188,14 +154,16 @@ const PersonalDetails = observer( return ( { trackEvent({ action: 'save', user_choice: JSON.stringify(values), }); - onSubmit(getCurrentStep() - 1, values, actions.setSubmitting, goToNextStep); + const current_step = getCurrentStep() - 1; + onSave(current_step, values); + onSubmit(current_step, values, actions.setSubmitting, goToNextStep); }} > {({ handleSubmit, isSubmitting, values }) => ( @@ -206,7 +174,6 @@ const PersonalDetails = observer( ref={setRef} onSubmit={handleSubmit} autoComplete='off' - onClick={closeToolTip} data-testid='personal_details_form' > - +
{is_rendered_for_idv && ( @@ -246,7 +209,7 @@ const PersonalDetails = observer( is_virtual={is_virtual} is_svg={is_svg} is_eu_user={is_eu_user} - side_note={} + side_note={} is_rendered_for_idv={is_rendered_for_idv} editable_fields={getEditableFields( values?.confirmation_checkbox, @@ -258,15 +221,15 @@ const PersonalDetails = observer( closeRealAccountSignup={closeRealAccountSignup} salutation_list={salutation_list} account_opening_reason_list={account_opening_reason_list} - should_close_tooltip={should_close_tooltip} - setShouldCloseTooltip={setShouldCloseTooltip} inline_note_text={ ]} /> } - no_confirmation_needed={no_confirmation_needed} + no_confirmation_needed={ + values?.document_type?.id === IDV_NOT_APPLICABLE_OPTION.id + } />
diff --git a/packages/account/src/Components/poi/idv-document-submit/__tests__/idv-document-submit.spec.tsx b/packages/account/src/Components/poi/idv-document-submit/__tests__/idv-document-submit.spec.tsx index 6d853a7155f5..a85d4cde1d12 100644 --- a/packages/account/src/Components/poi/idv-document-submit/__tests__/idv-document-submit.spec.tsx +++ b/packages/account/src/Components/poi/idv-document-submit/__tests__/idv-document-submit.spec.tsx @@ -5,6 +5,7 @@ import { StoreProvider, mockStore } from '@deriv/stores'; import { isDocumentNumberValid } from 'Helpers/utils'; import IdvDocumentSubmit from '../idv-document-submit'; import { useDevice } from '@deriv-com/ui'; +import { APIProvider } from '@deriv/api'; const mock_store = mockStore({ client: { @@ -93,9 +94,11 @@ describe('', () => { const renderComponent = () => { render( - - - + + + + + ); }; diff --git a/packages/account/src/Components/poi/poi-confirm-with-example-form-container/__tests__/poi-confirm-with-example-form-container.spec.tsx b/packages/account/src/Components/poi/poi-confirm-with-example-form-container/__tests__/poi-confirm-with-example-form-container.spec.tsx index cabd6f440b1f..0b50d02ebc77 100644 --- a/packages/account/src/Components/poi/poi-confirm-with-example-form-container/__tests__/poi-confirm-with-example-form-container.spec.tsx +++ b/packages/account/src/Components/poi/poi-confirm-with-example-form-container/__tests__/poi-confirm-with-example-form-container.spec.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import PoiConfirmWithExampleFormContainer from '../poi-confirm-with-example-form-container'; +import { APIProvider } from '@deriv/api'; jest.mock('@deriv/quill-icons', () => ({ ...jest.requireActual('@deriv/quill-icons'), @@ -55,9 +56,17 @@ describe('', () => { getChangeableFields: jest.fn(() => ['first_name', 'last_name', 'date_of_birth']), onFormConfirm: jest.fn(), }; + + const renderComponent = ({ props = mock_props }) => + render( + + + + ); + const clarification_message = /To avoid delays, enter your/; it('should render PersonalDetailsForm with image and checkbox', async () => { - render(); + renderComponent({}); expect(await screen.findByText('DerivLightNameDobPoiIcon')).toBeInTheDocument(); expect(screen.getByText(clarification_message)).toBeInTheDocument(); @@ -72,7 +81,7 @@ describe('', () => { }); it('should change fields and trigger submit', async () => { jest.useFakeTimers(); - render(); + renderComponent({}); const checkbox_el: HTMLInputElement = await screen.findByRole('checkbox'); expect(checkbox_el.checked).toBeFalsy(); diff --git a/packages/account/src/Configs/__test__/kyc-validation-config.spec.ts b/packages/account/src/Configs/__test__/kyc-validation-config.spec.ts new file mode 100644 index 000000000000..366ceaf59e80 --- /dev/null +++ b/packages/account/src/Configs/__test__/kyc-validation-config.spec.ts @@ -0,0 +1,41 @@ +import { getIDVFormValidationSchema } from '../kyc-validation-config'; + +describe('getIDVFormValidationSchema', () => { + it('should return return true when data matches schema', async () => { + const schema = getIDVFormValidationSchema(); + + const result = await schema.isValid({ + document_additional: 'hompl7358z', + document_number: '123456789011', + document_type: { + additional: { + displayName: 'PAN Card', + exampleFormat: 'ABCDE1234F', + format: '^[a-zA-Z]{5}\\d{4}[a-zA-Z]{1}$', + }, + exampleFormat: '123456789012', + id: 'aadhaar', + text: 'Aadhaar Card', + value: '^[0-9]{12}$', + }, + }); + + expect(result).toBeTruthy(); + }); + + it('should return false when data fails to match schema', async () => { + const schema = getIDVFormValidationSchema(); + + const result = await schema.isValid({ + document_number: 'Abc123456', + document_type: { + exampleFormat: 'ABC1234567', + id: 'epic', + text: 'Voter ID', + value: '^[a-zA-Z]{3}[0-9]{7}$', + }, + }); + + expect(result).toBeFalsy(); + }); +}); diff --git a/packages/account/src/Configs/__test__/personal-details-config.spec.ts b/packages/account/src/Configs/__test__/personal-details-config.spec.ts deleted file mode 100644 index bb0a2a74cfbe..000000000000 --- a/packages/account/src/Configs/__test__/personal-details-config.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { personal_details_config } from '../personal-details-config'; - -jest.mock('@deriv/shared', () => ({ - ...jest.requireActual('@deriv/shared'), - getErrorMessages: jest.fn().mockReturnValue({ - name: jest.fn(), - password: jest.fn(), - }), - generateValidationFunction: jest.fn(), - getDefaultFields: jest.fn(), - toMoment: jest.fn(), - validLength: jest.fn(), -})); - -describe('personal-details-config', () => { - const mock_props: Parameters[0] = { - residence_list: [ - { - phone_idd: '62', - text: 'Indonesia', - value: 'is', - tin_format: [], - disabled: '1', - identity: { - services: { - idv: { - documents_supported: {}, - has_visual_sample: 0, - is_country_supported: 0, - }, - onfido: { - documents_supported: { - driving_licence: { - display_name: 'Driving Licence', - }, - national_identity_card: { - display_name: 'National Identity Card', - }, - passport: { - display_name: 'Passport', - }, - residence_permit: { - display_name: 'Residence Permit', - }, - }, - is_country_supported: 1, - }, - }, - }, - }, - ], - account_settings: { - tax_residence: 'id', - residence: 'Indonesia', - document_type: '', - document_number: '', - }, - real_account_signup_target: 'maltainvest', - account_status: { - p2p_poa_required: 0, - cashier_validation: ['system_maintenance'], - currency_config: { - USD: { - is_deposit_suspended: 0, - is_withdrawal_suspended: 0, - }, - }, - p2p_status: 'active', - prompt_client_to_authenticate: 0, - risk_classification: '', - status: [''], - }, - residence: 'af', - }; - - it('should return account tax residence as default value if it is already set', () => { - const personal_details = personal_details_config(mock_props); - expect(personal_details.tax_residence.default_value).toEqual('Indonesia'); - }); - - it('should return residence as the default value for MF clients, If the account tax residence is not set', () => { - const new_props = { - ...mock_props, - account_settings: { - ...mock_props.account_settings, - tax_residence: '', - }, - }; - const personal_details = personal_details_config(new_props); - expect(personal_details.tax_residence.default_value).toEqual(new_props.account_settings.residence); - }); - - it('should not set default value for CR clients, If the account tax residence is not set', () => { - const new_props = { - ...mock_props, - real_account_signup_target: 'svg', - account_settings: { - ...mock_props.account_settings, - tax_residence: '', - }, - }; - const personal_details = personal_details_config(new_props); - expect(personal_details.tax_residence.default_value).toEqual(''); - }); - - it('should include svg in additional fields if client is not high risk for mt5', () => { - const new_props = { - ...mock_props, - real_account_signup_target: 'svg', - }; - const personal_details = personal_details_config(new_props); - const additional_fields = [ - 'place_of_birth', - 'tax_residence', - 'tax_identification_number', - 'account_opening_reason', - ]; - additional_fields.forEach(field => { - expect(personal_details[field].supported_in).toContain('svg'); - }); - }); -}); diff --git a/packages/account/src/Configs/__test__/user-profile-validation.spec.ts b/packages/account/src/Configs/__test__/user-profile-validation.spec.ts new file mode 100644 index 000000000000..4f115088eb77 --- /dev/null +++ b/packages/account/src/Configs/__test__/user-profile-validation.spec.ts @@ -0,0 +1,330 @@ +import * as Yup from 'yup'; +import { + getAddressDetailValidationSchema, + getEmploymentAndTaxValidationSchema, + getPersonalDetailsBaseValidationSchema, +} from '../user-profile-validation-config'; +import { TinValidations } from '@deriv/api/types'; +import { ValidationConstants } from '@deriv-com/utils'; + +describe('getPersonalDetailsBaseValidationSchema', () => { + let validation_schema: ReturnType; + + it('should validate a valid set of personal details for non-eu clients', () => { + validation_schema = getPersonalDetailsBaseValidationSchema('svg'); + const valid_values = { + first_name: 'John', + last_name: 'Doe', + phone: '+1234567890', + account_opening_reason: 'Investment', + date_of_birth: '1990-01-01', + place_of_birth: 'Germany', + }; + + expect(validation_schema.isValidSync(valid_values)).toBeTruthy(); + }); + + it('should validate a valid set of personal details for eu clients', () => { + validation_schema = getPersonalDetailsBaseValidationSchema('maltainvest'); + const valid_values = { + salutation: 'Mr', + first_name: 'John', + last_name: 'Doe', + phone: '+1234567890', + account_opening_reason: 'Investment', + date_of_birth: '1990-01-01', + place_of_birth: 'Germany', + citizen: 'Germany', + }; + + expect(validation_schema.isValidSync(valid_values)).toBeTruthy(); + }); + + it('should throw an error if salutation is not provided for maltainvest clients', () => { + validation_schema = getPersonalDetailsBaseValidationSchema('maltainvest'); + const invalid_values = { + first_name: 'John', + last_name: 'Doe', + phone: '+1234567890', + account_opening_reason: 'Investment', + date_of_birth: '1990-01-01', + place_of_birth: 'Germany', + citizen: 'Germany', + }; + + try { + validation_schema.validateSync(invalid_values); + } catch (error: unknown) { + expect((error as Yup.ValidationError).message).toBe('Salutation is required.'); + } + }); + + it('should throw an error if first name is invalid', async () => { + const { first_name } = getPersonalDetailsBaseValidationSchema('svg').fields; + + await expect(first_name.validate('John')).resolves.toBe('John'); + await expect(first_name.validate('')).rejects.toThrow('First name is required.'); + await expect(first_name.validate('A1')).rejects.toThrow('Letters, spaces, periods, hyphens, apostrophes only.'); + await expect( + first_name.validate('ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKL') + ).rejects.toThrow('Enter no more than 50 characters.'); + }); + + it('should throw an error if last name is invalid', async () => { + const { last_name } = getPersonalDetailsBaseValidationSchema('maltainvest').fields; + + await expect(last_name.validate('Doe')).resolves.toBe('Doe'); + await expect(last_name.validate('')).rejects.toThrow('Last name is required.'); + await expect(last_name.validate('A2')).rejects.toThrow('Letters, spaces, periods, hyphens, apostrophes only.'); + await expect( + last_name.validate('ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKL') + ).rejects.toThrow('Enter no more than 50 characters.'); + }); + + it('should throw an error if date of birth is invalid', async () => { + const { date_of_birth } = getPersonalDetailsBaseValidationSchema('svg').fields; + + await expect(date_of_birth.validate('1990-01-01')).resolves.toBe('1990-01-01'); + await expect(date_of_birth.validate('')).rejects.toThrow('Date of birth is required.'); + await expect(date_of_birth.validate('2021-01-01')).rejects.toThrow('You must be 18 years old and above.'); + }); + + it('should throw an error if phone number is invalid', async () => { + const { phone } = getPersonalDetailsBaseValidationSchema('svg').fields; + + await expect(phone.validate('+1234567890')).resolves.toBe('+1234567890'); + await expect(phone.validate('')).rejects.toThrow('Phone is required.'); + await expect(phone.validate('1234567890')).rejects.toThrow( + 'Please enter a valid phone number (e.g. +15417541234).' + ); + await expect(phone.validate('1234567890'.repeat(4))).rejects.toThrow('You should enter 9-20 numbers.'); + }); + + it('should throw an error if place of birth is invalid', async () => { + const { place_of_birth } = getPersonalDetailsBaseValidationSchema('svg').fields; + + await expect(place_of_birth.validate('Germany')).resolves.toBe('Germany'); + await expect(place_of_birth.validate('')).rejects.toThrow('Place of birth is required.'); + }); + + it('should throw an error if account opening reason is invalid', async () => { + const { account_opening_reason } = getPersonalDetailsBaseValidationSchema('svg').fields; + + await expect(account_opening_reason.validate('Investment')).resolves.toBe('Investment'); + await expect(account_opening_reason.validate('')).rejects.toThrow('Account opening reason is required.'); + }); + + it('should throw an error if citizen is invalid', async () => { + const { citizen } = getPersonalDetailsBaseValidationSchema('maltainvest').fields; + + await expect(citizen.validate('Germany')).resolves.toBe('Germany'); + await expect(citizen.validate('')).rejects.toThrow('Citizenship is required.'); + }); +}); + +describe('getEmploymentAndTaxValidationSchema', () => { + let validation_schema: ReturnType; + + const tin_config: TinValidations = { + tin_employment_status_bypass: ['Student'], + tin_format: ['^\\d{5}$'], + is_tin_mandatory: false, + }; + + beforeAll(() => { + validation_schema = getEmploymentAndTaxValidationSchema({ tin_config }); + }); + + it('should validate employment and tax details when valid values are provided', () => { + const valid_values = { + employment_status: 'Employed', + tax_residence: 'Germany', + tin_skipped: 0, + tax_identification_confirm: true, + tax_identification_number: '12345', + }; + + expect(validation_schema.isValidSync(valid_values)).toBeTruthy(); + }); + + it('should throw employment status required error when employment status is not provided', () => { + const invalid_values = { + tax_residence: 'Germany', + tin_skipped: 0, + tax_identification_confirm: true, + tax_identification_number: '12345', + }; + + try { + validation_schema.validateSync(invalid_values); + } catch (error: unknown) { + expect(error).toBeInstanceOf(Yup.ValidationError); + expect((error as Yup.ValidationError).message).toBe('Employment status is required.'); + } + }); + + it('should ensure tax details are confirmed when tax identification number and tax residence is provided', () => { + const invalid_values = { + employment_status: 'Employed', + tax_residence: 'Germany', + tin_skipped: 0, + tax_identification_confirm: false, + tax_identification_number: '12345', + }; + + try { + validation_schema.validateSync(invalid_values); + } catch (error: unknown) { + expect(error).toBeInstanceOf(Yup.ValidationError); + expect((error as Yup.ValidationError).message).toBe( + 'tax_identification_confirm must be one of the following values: true' + ); + } + }); + + it('should ensure tax identification number is not longer than 25 characters', () => { + const invalid_values = { + employment_status: 'Employed', + tax_residence: 'Germany', + tin_skipped: 0, + tax_identification_confirm: true, + tax_identification_number: '12345678901234567890123456', + }; + + try { + validation_schema.validateSync(invalid_values); + } catch (error: unknown) { + expect((error as Yup.ValidationError).message).toBe( + "Tax identification number can't be longer than 25 characters." + ); + } + }); + + it('should ensure tax identification number is properly formatted', () => { + const invalid_values = { + employment_status: 'Employed', + tax_residence: 'Germany', + tin_skipped: 0, + tax_identification_confirm: true, + tax_identification_number: '1234', + }; + + try { + validation_schema.validateSync(invalid_values); + } catch (error: unknown) { + expect((error as Yup.ValidationError).message).toBe('Tax identification number is not properly formatted.'); + } + }); + + it('should ensure tax identification number starts with a letter or number', () => { + const invalid_values = { + employment_status: 'Employed', + tax_residence: 'Germany', + tin_skipped: 0, + tax_identification_confirm: true, + tax_identification_number: '-1234', + }; + + try { + validation_schema.validateSync(invalid_values); + } catch (error: unknown) { + expect((error as Yup.ValidationError).message).toBe('Tax identification number is not properly formatted.'); + } + }); + + it('should ensure tax residence is provided when tax identification number is provided', () => { + const invalid_values = { + employment_status: 'Employed', + tax_residence: '', + tin_skipped: 0, + tax_identification_confirm: true, + tax_identification_number: '1234', + }; + + try { + validation_schema.validateSync(invalid_values); + } catch (error: unknown) { + expect((error as Yup.ValidationError).message).toBe('Please fill in tax residence.'); + } + }); + + it('should not validate tax details when tin_skipped is true', () => { + const valid_values = { + employment_status: 'Student', + tin_skipped: 1, + tax_residence: 'Germany', + }; + expect(validation_schema.isValidSync(valid_values)).toBeTruthy(); + }); +}); + +describe('getAddressDetailValidationSchema', () => { + const maxCharsMessage = 'Only 70 characters, please.'; + const { addressPermittedSpecialCharacters } = ValidationConstants.messagesHints; + + it('validates address_line_1 correctly without svg flag', async () => { + const { address_line_1 } = getAddressDetailValidationSchema(false).fields; + + await expect(address_line_1.validate('123 Main St')).resolves.toBe('123 Main St'); + await expect(address_line_1.validate('')).rejects.toThrow('First line of address is required'); + await expect(address_line_1.validate('a$^&')).rejects.toThrow( + `Use only the following special characters: ${addressPermittedSpecialCharacters}` + ); + await expect(address_line_1.validate('a'.repeat(71))).rejects.toThrow(maxCharsMessage); + await expect(address_line_1.validate('P.O. Box 121')).resolves.toBe('P.O. Box 121'); + }); + + it("PO box shouldn't exist for SVG ", async () => { + const { address_line_1 } = getAddressDetailValidationSchema(true).fields; + await expect(address_line_1.validate('P.O. Box 123')).rejects.toThrow('P.O. Box is not accepted in address'); + }); + + it('validates address_line_2 correctly', async () => { + const { address_line_2 } = getAddressDetailValidationSchema(false).fields; + + await expect(address_line_2.validate('Apt 4B')).resolves.toBe('Apt 4B'); + await expect(address_line_2.validate('a$^&')).rejects.toThrow( + `Use only the following special characters: ${addressPermittedSpecialCharacters}` + ); + await expect(address_line_2.validate('a'.repeat(71))).rejects.toThrow(maxCharsMessage); + await expect(address_line_2.validate('P.O. Box 120')).resolves.toBe('P.O. Box 120'); + }); + + it('validates address_line_2 correctly with svg flag', async () => { + const { address_line_2 } = getAddressDetailValidationSchema(true).fields; + + await expect(address_line_2.validate('P.O. Box 122')).rejects.toThrow('P.O. Box is not accepted in address'); + }); + + it('validates address_postcode correctly with country id', async () => { + const { address_postcode } = getAddressDetailValidationSchema(false).fields; + await expect(address_postcode.validate('12345')).resolves.toBe('12345'); + await expect(address_postcode.validate('$%&')).rejects.toThrow( + 'Only letters, numbers, space and hyphen are allowed.' + ); + await expect(address_postcode.validate('a'.repeat(21))).rejects.toThrow( + 'Please enter a postal/ZIP code under 20 characters.' + ); + await expect(address_postcode.validate('JE1 1AA')).resolves.toBe('JE1 1AA'); + }); + + it('validates address_state correctly', async () => { + const { address_state } = getAddressDetailValidationSchema(false).fields; + + await expect(address_state.validate('NY')).resolves.toBe('NY'); + await expect(address_state.validate('a'.repeat(102))).rejects.toThrow('State is not in a proper format'); + + await expect(address_state.validate('%_ASD')).rejects.toThrow('State is not in a proper format'); + }); + + it('validates address_city correctly', async () => { + const { address_city } = getAddressDetailValidationSchema(false).fields; + + await expect(address_city.validate('New York')).resolves.toBe('New York'); + await expect(address_city.validate('')).rejects.toThrow('City is required'); + await expect(address_city.validate('a'.repeat(102))).rejects.toThrow('Only 99 characters, please.'); + await expect(address_city.validate('%_ASD')).rejects.toThrow( + 'Only letters, periods, hyphens, apostrophes, and spaces, please.' + ); + }); +}); diff --git a/packages/account/src/Configs/employment-tax-info-config.ts b/packages/account/src/Configs/employment-tax-info-config.ts new file mode 100644 index 000000000000..71b025fb3dad --- /dev/null +++ b/packages/account/src/Configs/employment-tax-info-config.ts @@ -0,0 +1,63 @@ +import { localize } from '@deriv-com/translations'; +import { GetSettings, ResidenceList } from '@deriv/api-types'; +import { TSchema, getDefaultFields } from '@deriv/shared'; + +type TEmploymentTaxInfoConfigProps = { + account_settings: GetSettings; + residence_list: ResidenceList; +}; + +const generateEmploymentTaxInfoFormValues = ({ + account_settings, + residence_list, +}: TEmploymentTaxInfoConfigProps): TSchema => ({ + employment_status: { + supported_in: ['svg', 'maltainvest'], + default_value: account_settings.employment_status ?? '', + }, + tax_residence: { + supported_in: ['svg', 'maltainvest'], + default_value: + (account_settings?.tax_residence + ? residence_list.find(item => item.value === account_settings?.tax_residence)?.text + : account_settings?.residence) || '', + }, + tax_identification_number: { + supported_in: ['svg', 'maltainvest'], + default_value: account_settings.tax_identification_number ?? '', + }, + tax_identification_confirm: { + default_value: false, + supported_in: ['svg', 'maltainvest'], + }, + tin_skipped: { + default_value: 0, + supported_in: ['svg', 'maltainvest'], + }, +}); + +const getEmploymentTaxIfoConfig = ( + { + account_settings, + residence_list, + real_account_signup_target, + }: TEmploymentTaxInfoConfigProps & { real_account_signup_target: string }, + component: React.Component +) => { + const config = generateEmploymentTaxInfoFormValues({ account_settings, residence_list }); + return { + header: { + active_title: localize('Complete your employment and tax information details'), + title: localize('Employment and tax information'), + }, + body: component, + form_value: getDefaultFields(real_account_signup_target, config), + props: { + disabled_items: account_settings?.immutable_fields, + real_account_signup_target, + }, + passthrough: ['residence_list'], + }; +}; + +export default getEmploymentTaxIfoConfig; diff --git a/packages/account/src/Configs/kyc-validation-config.ts b/packages/account/src/Configs/kyc-validation-config.ts new file mode 100644 index 000000000000..dd190a2b7b22 --- /dev/null +++ b/packages/account/src/Configs/kyc-validation-config.ts @@ -0,0 +1,62 @@ +import { localize } from '@deriv-com/translations'; +import * as Yup from 'yup'; +import { AnyObject } from 'yup/lib/types'; +import { isAdditionalDocumentValid, isDocumentNumberValid } from '../Helpers/utils'; +import { TDocument } from 'Types'; +import { getIDVNotApplicableOption } from '@deriv/shared'; + +const validateDocumentNumber = (documentNumber: string, context: Yup.TestContext) => { + const result = isDocumentNumberValid(documentNumber, context.parent.document_type); + + if (result) { + return context.createError({ message: result }); + } + return true; +}; + +const validateAdditionalDocumentNumber = (additional_document_number: string, context: Yup.TestContext) => { + const result = isAdditionalDocumentValid(context.parent.document_type, additional_document_number); + if (result) { + return context.createError({ message: result }); + } + return true; +}; + +export const getIDVFormValidationSchema = () => { + const IDV_NOT_APPLICABLE_OPTION = getIDVNotApplicableOption(); + return Yup.object({ + document_additional: Yup.string() + .test({ + name: 'testAdditionalDocumentNumber', + test: (value, context) => { + if (context.parent.document_type.id === IDV_NOT_APPLICABLE_OPTION.id) { + return true; + } + return validateAdditionalDocumentNumber(value as string, context); + }, + }) + .default(''), + document_number: Yup.string() + .test({ + name: 'testDocumentNumber', + test: (value, context) => { + if (context.parent.document_type.id === IDV_NOT_APPLICABLE_OPTION.id) { + return true; + } + return validateDocumentNumber(value as string, context); + }, + }) + .default(''), + document_type: Yup.mixed().test({ + name: 'validate-document-type', + test: (input, context) => { + if (input?.value) { + return true; + } + return context.createError({ + message: localize('Please select a document type.'), + }); + }, + }), + }); +}; diff --git a/packages/account/src/Configs/personal-details-config.ts b/packages/account/src/Configs/personal-details-config.ts index b6889e8df665..80d3f3cfe20e 100644 --- a/packages/account/src/Configs/personal-details-config.ts +++ b/packages/account/src/Configs/personal-details-config.ts @@ -1,16 +1,7 @@ import { GetAccountStatus, GetSettings, ResidenceList } from '@deriv/api-types'; -import { - TSchema, - generateValidationFunction, - getDefaultFields, - getErrorMessages, - toMoment, - validLength, -} from '@deriv/shared'; +import { TSchema, getDefaultFields, toMoment } from '@deriv/shared'; import { localize } from '@deriv/translations'; -import { shouldShowIdentityInformation } from 'Helpers/utils'; import { TUpgradeInfo } from 'Types'; -import { PHONE_NUMBER_LENGTH } from 'Constants/personal-details'; type TPersonalDetailsConfig = { upgrade_info?: TUpgradeInfo; @@ -33,49 +24,28 @@ export const personal_details_config = ({ return {}; } - const default_residence = (real_account_signup_target === 'maltainvest' && account_settings?.residence) || ''; - const config = { account_opening_reason: { supported_in: ['maltainvest'], default_value: account_settings.account_opening_reason ?? '', - rules: [['req', localize('Account opening reason is required.')]], }, salutation: { supported_in: ['maltainvest'], default_value: account_settings.salutation ?? '', - rules: [['req', localize('Salutation is required.')]], }, first_name: { supported_in: ['svg', 'maltainvest'], default_value: account_settings.first_name ?? '', - rules: [ - ['req', localize('First name is required.')], - ['length', localize('Enter no more than 50 characters.'), { min: 1, max: 50 }], - ['name', getErrorMessages().name()], - ], }, last_name: { supported_in: ['svg', 'maltainvest'], default_value: account_settings.last_name ?? '', - rules: [ - ['req', localize('Last name is required.')], - ['length', localize('Enter no more than 50 characters.'), { min: 1, max: 50 }], - ['name', getErrorMessages().name()], - ], }, date_of_birth: { supported_in: ['svg', 'maltainvest'], default_value: account_settings.date_of_birth ? toMoment(account_settings.date_of_birth).format('YYYY-MM-DD') : '', - rules: [ - ['req', localize('Date of birth is required.')], - [ - (v: string) => toMoment(v).isValid() && toMoment(v).isBefore(toMoment().subtract(18, 'years')), - localize('You must be 18 years old and above.'), - ], - ], }, place_of_birth: { supported_in: ['maltainvest'], @@ -83,7 +53,6 @@ export const personal_details_config = ({ (account_settings.place_of_birth && residence_list.find(item => item.value === account_settings.place_of_birth)?.text) || '', - rules: [['req', localize('Place of birth is required.')]], }, citizen: { supported_in: ['maltainvest'], @@ -91,79 +60,10 @@ export const personal_details_config = ({ (account_settings.citizen && residence_list.find(item => item.value === account_settings.citizen)?.text) || '', - rules: [['req', localize('Citizenship is required')]], }, phone: { supported_in: ['svg', 'maltainvest'], default_value: account_settings.phone ?? '', - rules: [ - ['req', localize('Phone is required.')], - ['phone', localize('Phone is not in a proper format.')], - [ - (value: string) => { - return validLength(value, { min: PHONE_NUMBER_LENGTH.MIN, max: PHONE_NUMBER_LENGTH.MAX }); - }, - localize('You should enter {{min}}-{{max}} characters.', { - min: PHONE_NUMBER_LENGTH.MIN, - max: PHONE_NUMBER_LENGTH.MAX, - }), - ], - ], - }, - tax_residence: { - //if tax_residence is already set, we will use it as default value else for mf clients we will use residence as default value - default_value: - (account_settings?.tax_residence && - residence_list.find(item => item.value === account_settings?.tax_residence)?.text) || - default_residence, - supported_in: ['maltainvest'], - rules: [['req', localize('Tax residence is required.')]], - }, - tax_identification_number: { - default_value: account_settings.tax_identification_number ?? '', - supported_in: ['maltainvest'], - rules: [ - ['req', localize('Tax Identification Number is required.')], - [ - 'length', - localize("Tax Identification Number can't be longer than 25 characters."), - { min: 0, max: 25 }, - ], - [ - // check if the TIN value is available, then perform the regex test - // else return true (to pass the test) - // this is to allow empty string to pass the test in case of optioal TIN field - (value: string) => (value ? RegExp(/^(?!^$|\s+)[A-Za-z0-9./\s-]{0,25}$/).test(value) : true), - localize('Letters, numbers, spaces, periods, hyphens and forward slashes only.'), - ], - [ - (value: string, options: Record, { tax_residence }: { tax_residence: string }) => { - // check if TIN value is available, - // only then ask client to fill in tax residence - return value ? !!tax_residence : true; - }, - localize('Please fill in Tax residence.'), - ], - [ - (value: string, options: Record, { tax_residence }: { tax_residence: string }) => { - const tin_format = residence_list.find( - res => res.text === tax_residence && res.tin_format - )?.tin_format; - return value && tin_format ? tin_format.some(regex => new RegExp(regex).test(value)) : true; - }, - localize('Tax Identification Number is not properly formatted.'), - ], - ], - }, - employment_status: { - default_value: account_settings.employment_status ?? '', - supported_in: ['maltainvest'], - rules: [['req', localize('Employment status is required.')]], - }, - tax_identification_confirm: { - default_value: false, - supported_in: ['maltainvest'], - rules: [['confirm', localize('Please confirm your tax information.')]], }, document_type: { default_value: account_settings.document_type ?? { @@ -173,52 +73,22 @@ export const personal_details_config = ({ example_format: '', }, supported_in: ['svg'], - rules: [], }, document_number: { default_value: account_settings.document_number ?? '', supported_in: ['svg'], - rules: [], }, confirmation_checkbox: { default_value: false, supported_in: ['svg'], - rules: [], - }, - crs_confirmation: { - default_value: false, - supported_in: ['svg'], - rules: [ - [ - ( - value: string, - options: Record, - { tax_identification_number }: { tax_identification_number: string } - ) => { - // need the confirmation in case of both Tax residence and TIN are available - // only checking for TIN as we already have a rule for Tax residence to be filled if TIN field is filled - return tax_identification_number ? value : true; - }, - localize('CRS confirmation is required.'), - ], - ], }, }; if (real_account_signup_target !== 'maltainvest') { - const properties_to_update: (keyof typeof config)[] = [ - 'place_of_birth', - 'tax_residence', - 'tax_identification_number', - 'account_opening_reason', - ]; + const properties_to_update: (keyof typeof config)[] = ['place_of_birth', 'account_opening_reason']; properties_to_update.forEach(key => { config[key].supported_in.push('svg'); - // Remove required rule for TIN and Tax residence from the config to make the fields optional - if (key === 'tax_identification_number' || key === 'tax_residence') { - config[key].rules = config[key].rules.filter(rule => rule[0] !== 'req'); - } }); } @@ -252,16 +122,6 @@ const personalDetailsConfig = ( body: PersonalDetails, form_value: getDefaultFields(real_account_signup_target, config), props: { - validate: generateValidationFunction( - real_account_signup_target, - transformConfig(config, { - real_account_signup_target, - residence_list, - account_settings, - account_status, - residence, - }) - ), is_svg: upgrade_info?.can_upgrade_to === 'svg', account_opening_reason_list: [ { @@ -299,23 +159,4 @@ const personalDetailsConfig = ( }; }; -const transformConfig = ( - config: TSchema, - { real_account_signup_target, residence_list, account_status, residence }: TPersonalDetailsConfig -) => { - // Remove IDV for non supporting SVG countries - if ( - !shouldShowIdentityInformation({ - account_status, - citizen: residence, - residence_list, - real_account_signup_target, - }) - ) { - delete config.document_type; - delete config.document_number; - } - return config; -}; - export default personalDetailsConfig; diff --git a/packages/account/src/Configs/user-profile-validation-config.ts b/packages/account/src/Configs/user-profile-validation-config.ts new file mode 100644 index 000000000000..dac792b7823e --- /dev/null +++ b/packages/account/src/Configs/user-profile-validation-config.ts @@ -0,0 +1,192 @@ +import { localize } from '@deriv-com/translations'; +import * as Yup from 'yup'; +import { ValidationConstants } from '@deriv-com/utils'; +import dayjs from 'dayjs'; +import { TinValidations } from '@deriv/api/types'; +import { TEmployeeDetailsTinValidationConfig } from '../Types'; + +const { + taxIdentificationNumber, + address, + addressCity, + addressState, + postalCode, + phoneNumber, + name, + postalOfficeBoxNumber, +} = ValidationConstants.patterns; +const { addressPermittedSpecialCharacters } = ValidationConstants.messagesHints; + +type TINDepdendents = { + is_mf?: boolean; + is_real?: boolean; + tin_skipped?: boolean; + /** + * This flag indicates that tin was skipped before and was set by BE + */ + is_tin_auto_set?: boolean; +}; + +Yup.addMethod(Yup.string, 'validatePhoneNumberLength', function (message) { + return this.test('is-valid-phone-number-length', message || localize('You should enter 9-20 numbers.'), value => { + if (typeof value === 'string') { + // Remove the leading '+' symbol before validation + const phoneNumber = value.startsWith('+') ? value.slice(1) : value; + return /^[0-9]{9,20}$/.test(phoneNumber); + } + return false; + }); +}); + +const makeTinOptional = ({ is_mf, is_real, tin_skipped, is_tin_auto_set }: TINDepdendents) => { + const check_if_tin_skipped = tin_skipped && !is_tin_auto_set; + if (is_real) { + return check_if_tin_skipped; + } + // Check For Virtual account + if (is_mf) { + return check_if_tin_skipped; + } + return true; +}; + +export const getEmploymentAndTaxValidationSchema = ({ + tin_config, + is_mf = false, + is_real = false, + is_tin_auto_set = false, +}: TEmployeeDetailsTinValidationConfig) => { + return Yup.object({ + employment_status: Yup.string().required(localize('Employment status is required.')), + tax_residence: Yup.string().when('is_mf', { + is: () => is_mf, + then: Yup.string().required(localize('Tax residence is required.')), + otherwise: Yup.string().notRequired(), + }), + tin_skipped: Yup.number().oneOf([0, 1]).default(0), + tax_identification_confirm: Yup.bool().when(['tax_identification_number', 'tax_residence', 'tin_skipped'], { + is: (tax_identification_number: string, tax_residence: string, tin_skipped: boolean) => + tax_identification_number && tax_residence && !tin_skipped, + then: Yup.bool().required().oneOf([true]), + otherwise: Yup.bool().notRequired(), + }), + tax_identification_number: Yup.string() + .when(['tin_skipped'], { + is: (tin_skipped: boolean) => makeTinOptional({ is_mf, is_real, tin_skipped, is_tin_auto_set }), + then: Yup.string().notRequired(), + otherwise: Yup.string().required(localize('Tax identification number is required.')), + }) + .max(25, localize("Tax identification number can't be longer than 25 characters.")) + .matches(taxIdentificationNumber, { + excludeEmptyString: true, + message: localize('Only letters, numbers, space, hyphen, period, and forward slash are allowed.'), + }) + .test({ + name: 'validate-tin', + test: (value, context) => { + const { tax_residence } = context.parent; + if (value && !tax_residence) { + return context.createError({ message: localize('Please fill in tax residence.') }); + } + + if ( + value && + tin_config?.tin_format?.length && + !tin_config?.tin_format?.some(tax_regex => new RegExp(tax_regex).test(value as string)) + ) { + return context.createError({ + message: localize('Tax identification number is not properly formatted.'), + }); + } + + if ( + value && + tin_config?.invalid_patterns?.length && + tin_config?.invalid_patterns?.some(invalid_pattern => + new RegExp(invalid_pattern).test(value as string) + ) + ) { + return context.createError({ + message: localize('Tax identification number is not properly formatted.'), + }); + } + return true; + }, + }), + }); +}; + +export const getAddressDetailValidationSchema = (is_svg: boolean) => + Yup.object({ + address_city: Yup.string() + .required(localize('City is required')) + .max(99, localize('Only 99 characters, please.')) + .matches(addressCity, localize('Only letters, periods, hyphens, apostrophes, and spaces, please.')), + address_line_1: Yup.string() + .required(localize('First line of address is required')) + .max(70, localize('Only 70 characters, please.')) + .matches( + address, + `${localize('Use only the following special characters:')} ${addressPermittedSpecialCharacters}` + ) + .when({ + is: () => is_svg, + then: Yup.string().test( + 'po_box', + localize('P.O. Box is not accepted in address'), + value => !postalOfficeBoxNumber.test(value ?? '') + ), + }), + address_line_2: Yup.string() + .max(70, localize('Only 70 characters, please.')) + .matches( + address, + `${localize('Use only the following special characters:')} ${addressPermittedSpecialCharacters}` + ) + .when({ + is: () => is_svg, + then: Yup.string().test( + 'po_box', + localize('P.O. Box is not accepted in address'), + value => !postalOfficeBoxNumber.test(value ?? '') + ), + }), + address_postcode: Yup.string() + .max(20, localize('Please enter a postal/ZIP code under 20 characters.')) + .matches(postalCode, localize('Only letters, numbers, space and hyphen are allowed.')), + address_state: Yup.string().matches(addressState, localize('State is not in a proper format')), + }); + +export const getPersonalDetailsBaseValidationSchema = (broker_code?: string) => + Yup.object({ + salutation: Yup.string().when({ + is: () => broker_code === 'maltainvest', + then: Yup.string().required(localize('Salutation is required.')), + }), + account_opening_reason: Yup.string().required(localize('Account opening reason is required.')), + first_name: Yup.string() + .required(localize('First name is required.')) + .max(50, localize('Enter no more than 50 characters.')) + .matches(name, localize('Letters, spaces, periods, hyphens, apostrophes only.')), + last_name: Yup.string() + .required(localize('Last name is required.')) + .max(50, localize('Enter no more than 50 characters.')) + .matches(name, localize('Letters, spaces, periods, hyphens, apostrophes only.')), + date_of_birth: Yup.string() + .required('Date of birth is required.') + .test({ + name: 'validate_dob', + test: value => dayjs(value).isValid() && dayjs(value).isBefore(dayjs().subtract(18, 'years')), + message: localize('You must be 18 years old and above.'), + }), + phone: Yup.string() + .required(localize('Phone is required.')) + // @ts-expect-error yup validation giving type error + .validatePhoneNumberLength(localize('You should enter 9-20 numbers.')) + .matches(phoneNumber, localize('Please enter a valid phone number (e.g. +15417541234).')), + place_of_birth: Yup.string().required(localize('Place of birth is required.')), + citizen: Yup.string().when({ + is: () => broker_code === 'maltainvest', + then: Yup.string().required(localize('Citizenship is required.')), + }), + }); diff --git a/packages/account/src/Sections/Assessment/FinancialAssessment/financial-information-list.ts b/packages/account/src/Constants/financial-information-list.ts similarity index 100% rename from packages/account/src/Sections/Assessment/FinancialAssessment/financial-information-list.ts rename to packages/account/src/Constants/financial-information-list.ts diff --git a/packages/account/src/Constants/personal-details.ts b/packages/account/src/Constants/personal-details.ts deleted file mode 100644 index 465fdeb533cd..000000000000 --- a/packages/account/src/Constants/personal-details.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const PHONE_NUMBER_LENGTH = { - MIN: 9, - MAX: 20, -}; diff --git a/packages/account/src/Containers/employment-tax-details-container/__tests__/employment-tax-details-container.spec.tsx b/packages/account/src/Containers/employment-tax-details-container/__tests__/employment-tax-details-container.spec.tsx new file mode 100644 index 000000000000..b169dbdcb19c --- /dev/null +++ b/packages/account/src/Containers/employment-tax-details-container/__tests__/employment-tax-details-container.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Formik } from 'formik'; +import { render, screen } from '@testing-library/react'; +import { APIProvider } from '@deriv/api'; +import EmploymentTaxDetailsContainer from '../employment-tax-details-container'; +import { mockStore, StoreProvider } from '@deriv/stores'; + +describe('Testing component', () => { + const store = mockStore({}); + + it('should render EmploymentTaxDetailsContainer component', () => { + const props: React.ComponentProps = { + editable_fields: [], + parent_ref: { current: document.createElement('div') }, + handleChange: jest.fn(), + tin_validation_config: { + is_tin_mandatory: false, + tin_employment_status_bypass: [], + }, + }; + + render( + + + + + + + + ); + + expect(screen.getByText(/Employment status/)).toBeInTheDocument; + expect(screen.getByText(/Tax residence/)).toBeInTheDocument; + expect(screen.getByText(/Tax identification number/)).toBeInTheDocument; + expect(screen.getByText(/I confirm that my tax information is accurate and complete./)).toBeInTheDocument; + }); +}); diff --git a/packages/account/src/Containers/employment-tax-details-container/employment-tax-details-container.scss b/packages/account/src/Containers/employment-tax-details-container/employment-tax-details-container.scss new file mode 100644 index 000000000000..5b32c1d2e2d8 --- /dev/null +++ b/packages/account/src/Containers/employment-tax-details-container/employment-tax-details-container.scss @@ -0,0 +1,16 @@ +.employment_tax_detail_field { + &-checkbox { + align-items: flex-start; + margin: 1.6rem auto; + &__box { + align-self: baseline; + } + + &__label { + text-align: justify; + } + .dc-checkbox__box { + margin-inline-start: unset; + } + } +} diff --git a/packages/account/src/Containers/employment-tax-details-container/employment-tax-details-container.tsx b/packages/account/src/Containers/employment-tax-details-container/employment-tax-details-container.tsx new file mode 100644 index 000000000000..d9cbf5bed2f7 --- /dev/null +++ b/packages/account/src/Containers/employment-tax-details-container/employment-tax-details-container.tsx @@ -0,0 +1,234 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { FormikValues, useFormikContext } from 'formik'; +import { + EmploymentStatusField, + TaxIdentificationNumberField, + TaxResidenceField, +} from '../../Components/forms/form-fields'; +import { isFieldImmutable } from '../../Helpers/utils'; +import { Checkbox, useOnClickOutside } from '@deriv/components'; +import { Localize } from '@deriv-com/translations'; +import { getLegalEntityName } from '@deriv/shared'; +import { useDevice } from '@deriv-com/ui'; +import { useResidenceList } from '@deriv/hooks'; +import './employment-tax-details-container.scss'; +import { TinValidations } from '@deriv/api/types'; +import { observer, useStore } from '@deriv/stores'; + +type TEmploymentTaxDetailsContainerProps = { + editable_fields: string[]; + parent_ref: React.RefObject; + should_display_long_message?: boolean; + handleChange: (value: string) => void; + tin_validation_config: TinValidations; + id?: string; + should_focus_fields?: boolean; +}; + +const EmploymentTaxDetailsContainer = observer( + ({ + editable_fields, + parent_ref, + should_display_long_message, + tin_validation_config, + handleChange, + should_focus_fields, + }: TEmploymentTaxDetailsContainerProps) => { + const { values, setFieldValue, touched, errors, setValues } = useFormikContext(); + const { isDesktop } = useDevice(); + const { data: residence_list } = useResidenceList(); + const { client } = useStore(); + + const { is_virtual, account_settings } = client; + + const { tin_employment_status_bypass } = tin_validation_config; + + const is_tin_required = !is_virtual && !tin_employment_status_bypass?.includes(values.employment_status); + + const [is_tax_residence_popover_open, setIsTaxResidencePopoverOpen] = useState(false); + const [is_tin_popover_open, setIsTinPopoverOpen] = useState(false); + + const tax_residence_ref = useRef(null); + const tin_ref = useRef(null); + + const validateClickOutside = (event: MouseEvent) => { + const target = event?.target as HTMLElement; + if (target.tagName === 'A') { + event?.stopPropagation(); + return false; + } + return !tax_residence_ref.current?.contains(target) && !tin_ref.current?.contains(target); + }; + + const closeToolTips = () => { + setIsTaxResidencePopoverOpen(false); + setIsTinPopoverOpen(false); + }; + + useEffect(() => { + if (values.tax_residence) { + const tax_residence = residence_list.find(item => item.text === values.tax_residence)?.value; + if (tax_residence) { + handleChange(tax_residence); + } + } + }, [handleChange, values.tax_residence, residence_list]); + + useEffect(() => { + if (!values.tax_residence || !values.tax_identification_number) { + setFieldValue('tax_identification_confirm', false, true); + } + }, [values.tax_residence, values.tax_identification_number, setFieldValue]); + + useEffect(() => { + const parent_element = parent_ref?.current; + + if (parent_element) { + parent_element.addEventListener('scroll', closeToolTips); + } + + return () => { + if (parent_element) { + parent_element.removeEventListener('scroll', closeToolTips); + } + setIsTaxResidencePopoverOpen(false); + setIsTinPopoverOpen(false); + }; + }, [parent_ref]); + + useEffect(() => { + if (tin_validation_config) { + // This is to trigger re-validation of TIN field when the validation config changes + setFieldValue('tax_identification_number', values.tax_identification_number, true); + } + }, [tin_validation_config, setFieldValue, values.tax_identification_number]); + + useEffect(() => { + if (touched.tax_identification_number && values.tax_identification_number) { + setFieldValue('tin_skipped', 0, true); + } + }, [values.tax_identification_number, setFieldValue, touched.tax_identification_number]); + + const is_tax_details_confirm_disabled = useMemo( + () => + (isFieldImmutable('tax_identification_number', editable_fields) && + isFieldImmutable('tax_residence', editable_fields)) || + !values.tax_identification_number || + !values.tax_residence || + !!values.tin_skipped, + [editable_fields, values.tax_identification_number, values.tax_residence, values.tin_skipped] + ); + + useOnClickOutside(tax_residence_ref, () => setIsTaxResidencePopoverOpen(false), validateClickOutside); + + useOnClickOutside(tin_ref, () => setIsTinPopoverOpen(false), validateClickOutside); + + const should_show_no_tax_details_checkbox = + (tin_employment_status_bypass?.includes(values.employment_status) && !!values.tax_residence) || + Boolean(values.tin_skipped); + + const should_show_tax_confirm_checkbox = + !account_settings.tax_identification_number || touched.tax_identification_number; + + const isFieldDisabled = (field_name: string) => isFieldImmutable(field_name, editable_fields); + + return ( +
+ + + {!account_settings.tax_identification_number && should_show_no_tax_details_checkbox && ( + { + const confirm_no_tax_details = values.tin_skipped ? 0 : 1; + setValues( + { + ...values, + tin_skipped: confirm_no_tax_details, + tax_identification_number: '', + tax_identification_confirm: false, + }, + true + ); + }} + value={values.tin_skipped} + label={} + withTabIndex={0} + data-testid='tin_skipped' + label_font_size={!isDesktop ? 'xxs' : 'xs'} + label_line_height='m' + /> + )} +
+ +
+
+ +
+ {should_show_tax_confirm_checkbox && ( + + setFieldValue('tax_identification_confirm', !values.tax_identification_confirm, true) + } + value={values.tax_identification_confirm} + label={ + should_display_long_message ? ( + + ) : ( + + ) + } + withTabIndex={0} + data-testid='tax_identification_confirm' + has_error={!!(touched.tax_identification_confirm && errors.tax_identification_confirm)} + label_font_size={!isDesktop ? 'xxs' : 'xs'} + label_line_height='m' + disabled={is_tax_details_confirm_disabled} + /> + )} +
+ ); + } +); + +export default EmploymentTaxDetailsContainer; diff --git a/packages/account/src/Containers/employment-tax-details-container/index.ts b/packages/account/src/Containers/employment-tax-details-container/index.ts new file mode 100644 index 000000000000..567d84cb9d18 --- /dev/null +++ b/packages/account/src/Containers/employment-tax-details-container/index.ts @@ -0,0 +1,3 @@ +import EmploymentTaxDetailsContainer from './employment-tax-details-container'; + +export default EmploymentTaxDetailsContainer; diff --git a/packages/account/src/Containers/employment-tax-info/employment-tax-info.scss b/packages/account/src/Containers/employment-tax-info/employment-tax-info.scss new file mode 100644 index 000000000000..69659155a22d --- /dev/null +++ b/packages/account/src/Containers/employment-tax-info/employment-tax-info.scss @@ -0,0 +1,18 @@ +.employment-tax-info { + &__form { + padding: 0 16rem; + + .account-form__fieldset:first-child { + margin-top: 1rem; + } + } + + &__layout { + display: flex; + flex-direction: column; + + .details-form { + display: flex; + } + } +} diff --git a/packages/account/src/Containers/employment-tax-info/employment-tax-info.tsx b/packages/account/src/Containers/employment-tax-info/employment-tax-info.tsx new file mode 100644 index 000000000000..45e6e69930c1 --- /dev/null +++ b/packages/account/src/Containers/employment-tax-info/employment-tax-info.tsx @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +//@ts-nocheck [TODO] - Need to fix typescript errors in Autocomplete & SelectNative components + +import { useMemo, useRef } from 'react'; +import { Form, Formik, FormikValues } from 'formik'; +import clsx from 'clsx'; +import { localize } from '@deriv-com/translations'; +import { useDevice } from '@deriv-com/ui'; +import EmploymentTaxDetailsContainer from '../employment-tax-details-container'; +import { getEmploymentAndTaxValidationSchema } from '../../Configs/user-profile-validation-config'; +import ScrollToFieldWithError from '../../Components/forms/scroll-to-field-with-error'; +import { Div100vhContainer, FormSubmitButton, Modal, ThemedScrollbars } from '@deriv/components'; +import { useTinValidations } from '@deriv/hooks'; +import { observer, useStore } from '@deriv/stores'; +import './employment-tax-info.scss'; + +type TEmploymentTaxInfoProps = { + disabled_items: string[]; + getCurrentStep: () => number; + onSave: (current_step: number, values: FormikValues) => void; + onCancel: (current_step: number, goToPreviousStep: () => void) => void; + onSubmit: ( + current_step: number, + values: FormikValues, + setSubmitting: (is_submitting: boolean) => void, + goToNextStep: () => void + ) => void; + goToPreviousStep: () => void; + goToNextStep: () => void; + real_account_signup_target: string; + value: FormikValues; +}; + +const EmploymentTaxInfo = observer( + ({ + disabled_items, + value, + getCurrentStep, + onSave, + onCancel, + onSubmit, + goToPreviousStep, + goToNextStep, + real_account_signup_target, + }: TEmploymentTaxInfoProps) => { + const { isMobile, isDesktop } = useDevice(); + const scroll_div_ref = useRef(null); + const { tin_validation_config, mutate } = useTinValidations(); + const { client } = useStore(); + + const editable_fields = useMemo( + () => + ['employment_status', 'tax_residence', 'tax_identification_number'].filter( + field => !disabled_items.includes(field) + ) || [], + [disabled_items] + ); + + const is_eu = real_account_signup_target === 'maltainvest'; + + const schema = getEmploymentAndTaxValidationSchema({ + tin_config: tin_validation_config, + is_mf: is_eu, + is_real: !client.is_virtual, + }); + + const handleCancel = (values: FormikValues) => { + const current_step = (getCurrentStep?.() || 1) - 1; + onSave(current_step, values); + onCancel(current_step, goToPreviousStep); + }; + + return ( + { + const current_step = getCurrentStep() - 1; + onSave(current_step, values); + onSubmit(current_step, values, actions.setSubmitting, goToNextStep); + }} + > + {({ handleSubmit, isSubmitting, values }) => { + return ( +
+ + + + + + + + handleCancel(values)} + /> + + + ); + }} +
+ ); + } +); + +export default EmploymentTaxInfo; diff --git a/packages/account/src/Helpers/utils.tsx b/packages/account/src/Helpers/utils.tsx index 1fea91cf93e5..cd0bb3c5e9a3 100644 --- a/packages/account/src/Helpers/utils.tsx +++ b/packages/account/src/Helpers/utils.tsx @@ -158,7 +158,7 @@ export const isDocumentTypeValid = (document_type: FormikValues) => { export const isAdditionalDocumentValid = (document_type: FormikValues, additional_document_value?: string) => { const error_message = documentAdditionalError(additional_document_value, document_type?.additional); if (error_message) { - return localize(error_message) + getExampleFormat(document_type.additional?.example_format); + return error_message + getExampleFormat(document_type?.additional?.example_format); } return undefined; }; diff --git a/packages/account/src/Sections/Assessment/FinancialAssessment/NavigateToPersonalDetails.tsx b/packages/account/src/Sections/Assessment/FinancialAssessment/NavigateToPersonalDetails.tsx new file mode 100644 index 000000000000..b2322d3171d3 --- /dev/null +++ b/packages/account/src/Sections/Assessment/FinancialAssessment/NavigateToPersonalDetails.tsx @@ -0,0 +1,74 @@ +import { Button, Text, Icon } from '@deriv/components'; +import { Localize, useTranslations } from '@deriv-com/translations'; +import { useDevice } from '@deriv-com/ui'; +import { useHistory } from 'react-router'; +import { routes } from '@deriv/shared'; +import FormFooter from '../../../Components/form-footer'; +import { observer, useStore } from '@deriv/stores'; +import './navigate-to-personal-details.scss'; + +const NavigateToPersonalDetails = observer(() => { + const { localize } = useTranslations(); + const { isDesktop } = useDevice(); + const { ui, client } = useStore(); + const history = useHistory(); + + const { account_settings } = client; + + const handleOnclick = () => { + let field_to_scroll; + + if (!account_settings.account_opening_reason) { + field_to_scroll = 'account-opening-reason'; + } else { + field_to_scroll = 'employment-tax-section'; + } + + ui.setFieldRefToFocus(field_to_scroll); + history.push(routes.personal_details); + }; + + const icon_size = isDesktop ? { height: '200px', width: '200px' } : { height: '124px', width: '124px' }; + + return ( +
+
+ {/* [TODO] This will be replaced by icon from Quiull icons */} + + + + +
+ {isDesktop ? ( +
+
+ ) : ( + +
+ ); +}); + +export default NavigateToPersonalDetails; diff --git a/packages/account/src/Sections/Assessment/FinancialAssessment/__tests__/financial-assessment.spec.tsx b/packages/account/src/Sections/Assessment/FinancialAssessment/__tests__/financial-assessment.spec.tsx index 2472ef04ba55..27431471d270 100644 --- a/packages/account/src/Sections/Assessment/FinancialAssessment/__tests__/financial-assessment.spec.tsx +++ b/packages/account/src/Sections/Assessment/FinancialAssessment/__tests__/financial-assessment.spec.tsx @@ -44,20 +44,27 @@ jest.mock('@deriv/components', () => { }; }); describe('', () => { - const mock = mockStore({}); - const rendercomponent = () => { - const wrapper = ({ children }: { children: JSX.Element }) => ( - {children} - ); + const mock = mockStore({ + client: { + account_settings: { + account_opening_reason: 'Hedging', + tax_residence: 'Germany', + tax_identification_number: '123456789', + employment_status: 'Employed', + }, + }, + }); + const renderComponent = (store_config = mock) => render( - - , - { wrapper } + + + + ); - }; + it('should render FinancialAssessment component', async () => { - rendercomponent(); + renderComponent(); await waitFor(() => { expect(screen.getByText('Financial information')).toBeInTheDocument(); expect(screen.getByText('Source of income')).toBeInTheDocument(); @@ -93,7 +100,7 @@ describe('', () => { }, }) ); - rendercomponent(); + renderComponent(); await waitFor(() => { expect(screen.getByText('Employment status')).toBeInTheDocument(); expect(screen.getByText('Industry of employment')).toBeInTheDocument(); @@ -121,7 +128,7 @@ describe('', () => { }, }) ); - rendercomponent(); + renderComponent(); await waitFor(() => { expect(screen.getByText('Employment status')).toBeInTheDocument(); expect(screen.getByText('Industry of employment')).toBeInTheDocument(); diff --git a/packages/account/src/Sections/Assessment/FinancialAssessment/financial-assessment.tsx b/packages/account/src/Sections/Assessment/FinancialAssessment/financial-assessment.tsx index c598ada464ef..74c0ac36235d 100644 --- a/packages/account/src/Sections/Assessment/FinancialAssessment/financial-assessment.tsx +++ b/packages/account/src/Sections/Assessment/FinancialAssessment/financial-assessment.tsx @@ -21,7 +21,6 @@ import { getAccountTurnoverList, getEducationLevelList, getEmploymentIndustryList, - getEmploymentStatusList, getEstimatedWorthList, getIncomeSourceList, getNetIncomeList, @@ -34,12 +33,14 @@ import { getForexTradingFrequencyList, getOtherInstrumentsTradingExperienceList, getOtherInstrumentsTradingFrequencyList, -} from './financial-information-list'; +} from '../../../Constants/financial-information-list'; import type { TCoreStores } from '@deriv/stores/types'; import { GetFinancialAssessment, GetFinancialAssessmentResponse } from '@deriv/api-types'; import { getFormattedOccupationList } from 'Configs/financial-details-config'; import { TFinancialInformationForm } from 'Types'; +import { EmploymentStatusField } from 'Components/forms/form-fields'; import { useDevice } from '@deriv-com/ui'; +import NavigateToPersonalDetails from './NavigateToPersonalDetails'; type TConfirmationPage = { toggleModal: (prop: boolean) => void; @@ -194,6 +195,7 @@ const FinancialAssessment = observer(() => { updateAccountStatus, is_authentication_needed, is_financial_information_incomplete, + account_settings, } = client; const { isMobile, isTablet, isDesktop } = useDevice(); const { platform, routeBackInApp } = common; @@ -394,6 +396,15 @@ const FinancialAssessment = observer(() => { return form_data; }; + if ( + !employment_status || + !account_settings.account_opening_reason || + !account_settings.tax_residence || + !account_settings.tax_identification_number + ) { + return ; + } + return ( {({ @@ -479,45 +490,7 @@ const FinancialAssessment = observer(() => { {!is_mf && (
- {isDesktop ? ( - { - handleChange(e); - setFieldValue( - 'occupation', - '', - !shouldHideOccupationField(e.target.value) - ); - }} - handleBlur={handleBlur} - error={touched.employment_status && errors.employment_status} - /> - ) : ( - { - setFieldTouched('employment_status', true); - setFieldValue( - 'occupation', - '', - !shouldHideOccupationField(e.target.value) - ); - handleChange(e); - }} - /> - )} +
)}
diff --git a/packages/account/src/Sections/Assessment/FinancialAssessment/navigate-to-personal-details.scss b/packages/account/src/Sections/Assessment/FinancialAssessment/navigate-to-personal-details.scss new file mode 100644 index 000000000000..64ae30a15339 --- /dev/null +++ b/packages/account/src/Sections/Assessment/FinancialAssessment/navigate-to-personal-details.scss @@ -0,0 +1,53 @@ +.navigate-to-personal-details { + display: flex; + flex-direction: column; + + @include desktop-screen { + position: relative; + top: 10%; + row-gap: 2.6rem; + width: 50%; + height: 100%; + align-self: center; + } + + &__body { + display: flex; + flex-direction: column; + align-items: center; + + @include mobile-or-tablet-screen { + margin-top: 45%; + } + + &--text { + @include desktop-screen { + line-height: 3rem; + } + + @include mobile-or-tablet-screen { + line-height: 2.4rem; + padding: 0 3.2rem; + } + } + + @include desktop-screen { + row-gap: 2.6rem; + } + + @include mobile-or-tablet-screen { + row-gap: 1.6rem; + flex-grow: 1; + } + } + + &__footer { + width: 100%; + display: flex; + justify-content: center; + + &--layout { + padding: 1.6rem; + } + } +} diff --git a/packages/account/src/Sections/Profile/PersonalDetails/__tests__/personal-details-form.spec.tsx b/packages/account/src/Sections/Profile/PersonalDetails/__tests__/personal-details-form.spec.tsx index 8aa4b8541e11..8aaffc8a6cc2 100644 --- a/packages/account/src/Sections/Profile/PersonalDetails/__tests__/personal-details-form.spec.tsx +++ b/packages/account/src/Sections/Profile/PersonalDetails/__tests__/personal-details-form.spec.tsx @@ -106,21 +106,12 @@ describe('', () => { }); }); - it('should display error for up to 50 characters length validation, for last name when entered characters are more than 50', async () => { - renderComponent(); - await waitFor(async () => { - const last_name = screen.getByTestId('dt_last_name'); - await userEvent.type(last_name, 'ABCDEFGHIJKLMNOP.QRSTU VWXYZabcdefghi-jklmnopqrstuvwxyzh-shs'); - expect(screen.getByText(/Enter no more than 50 characters./)).toBeInTheDocument(); - }); - }); - it('should display error for the regex validation, for First name when unacceptable characters are entered', async () => { renderComponent(); - await waitFor(async () => { + await waitFor(() => { const first_name = screen.getByTestId('dt_first_name'); - await userEvent.type(first_name, 'test 3'); + userEvent.type(first_name, 'test 3'); expect(screen.getByText('Letters, spaces, periods, hyphens, apostrophes only.')).toBeInTheDocument(); }); }); diff --git a/packages/account/src/Sections/Profile/PersonalDetails/__tests__/validation.spec.tsx b/packages/account/src/Sections/Profile/PersonalDetails/__tests__/validation.spec.tsx index 5892e1cb2ccd..756413b210d5 100644 --- a/packages/account/src/Sections/Profile/PersonalDetails/__tests__/validation.spec.tsx +++ b/packages/account/src/Sections/Profile/PersonalDetails/__tests__/validation.spec.tsx @@ -5,15 +5,7 @@ import { } from '../validation'; describe('getPersonalDetailsValidationSchema', () => { - const non_eu_valid_data = { - first_name: 'John', - last_name: 'Doe', - phone: '+123456789', - address_line_1: 'Kuala Lumpur', - address_city: 'Kuala Lumpur', - citizen: 'Malaysian', - }; - const eu_valid_data = { + const valid_data = { first_name: 'John', last_name: 'Doe', phone: '+123456789', @@ -23,8 +15,11 @@ describe('getPersonalDetailsValidationSchema', () => { tax_identification_number: '123123123', tax_residence: 'Germany', employment_status: 'Employed', + date_of_birth: '1990-01-01', + tax_identification_confirm: true, }; - const non_eu_invalid_data = { + + const invalid_data = { first_name: 'John', last_name: 'Doe123', phone: 'wrong', @@ -32,19 +27,17 @@ describe('getPersonalDetailsValidationSchema', () => { address_city: '', citizen: '', }; - const non_eu = false; - const is_eu = true; it('should validate a valid input for non-eu users', async () => { - const validationSchema = getPersonalDetailsValidationSchema(non_eu); - const isValid = await validationSchema.isValid(non_eu_valid_data); + const validationSchema = getPersonalDetailsValidationSchema(); + const isValid = await validationSchema.isValid(valid_data); expect(isValid).toBe(true); }); it('should not validate an invalid input for non-eu users', async () => { - const validationSchema = getPersonalDetailsValidationSchema(non_eu); + const validationSchema = getPersonalDetailsValidationSchema(); try { - await validationSchema.isValid(non_eu_invalid_data); + await validationSchema.isValid(invalid_data); } catch (error) { // @ts-expect-error [TODO]: Fix type for error expect(error.errors.length).toBeGreaterThan(0); @@ -52,19 +45,13 @@ describe('getPersonalDetailsValidationSchema', () => { }); it('should validate a valid input for eu users', async () => { - const validationSchema = getPersonalDetailsValidationSchema(is_eu); - const isValid = await validationSchema.isValid(eu_valid_data); + const validationSchema = getPersonalDetailsValidationSchema(); + const isValid = await validationSchema.isValid(valid_data); expect(isValid).toBe(true); }); - it('should not validate a non-eu input for eu users', async () => { - const validationSchema = getPersonalDetailsValidationSchema(is_eu); - const isValid = await validationSchema.isValid(non_eu_valid_data); - expect(isValid).toBe(false); - }); - it('should return empty object for virtual account', () => { - const validationSchema = getPersonalDetailsValidationSchema(false, true); + const validationSchema = getPersonalDetailsValidationSchema(true); expect(validationSchema.fields).toEqual({}); }); }); diff --git a/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.scss b/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.scss new file mode 100644 index 000000000000..0086f5618c83 --- /dev/null +++ b/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.scss @@ -0,0 +1,5 @@ +.employment-tin-section { + @include desktop-screen { + margin: 2.4rem 0; + } +} diff --git a/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.tsx b/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.tsx index 27b875ad23d9..cc0ece245643 100644 --- a/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.tsx +++ b/packages/account/src/Sections/Profile/PersonalDetails/personal-details-form.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, Fragment, ChangeEvent } from 'react'; +import { useState, useRef, useEffect, Fragment, ChangeEvent, useMemo, useLayoutEffect } from 'react'; import clsx from 'clsx'; import { Formik, Form, FormikHelpers } from 'formik'; import { useHistory } from 'react-router'; @@ -6,39 +6,43 @@ import { useDevice } from '@deriv-com/ui'; import { Button, Checkbox, - Dropdown, FormSubmitErrorMessage, HintBox, Input, Loading, OpenLiveChatLink, - SelectNative, Text, } from '@deriv/components'; -import { GetSettings } from '@deriv/api-types'; import { AUTH_STATUS_CODES, WS, getBrandWebsiteName, routes } from '@deriv/shared'; import { Localize, localize } from '@deriv/translations'; import { observer, useStore } from '@deriv/stores'; -import LeaveConfirm from 'Components/leave-confirm'; -import FormFooter from 'Components/form-footer'; -import FormBody from 'Components/form-body'; -import { DateOfBirthField } from 'Components/forms/form-fields'; -import FormSubHeader from 'Components/form-sub-header'; -import LoadErrorMessage from 'Components/load-error-message'; -import POAAddressMismatchHintBox from 'Components/poa-address-mismatch-hint-box'; -import { getEmploymentStatusList } from 'Sections/Assessment/FinancialAssessment/financial-information-list'; +import LeaveConfirm from '../../../Components/leave-confirm'; +import FormFooter from '../../../Components/form-footer'; +import FormBody from '../../../Components/form-body'; +import { DateOfBirthField } from '../../../Components/forms/form-fields'; +import FormSubHeader from '../../../Components/form-sub-header'; +import LoadErrorMessage from '../../../Components/load-error-message'; +import POAAddressMismatchHintBox from '../../../Components/poa-address-mismatch-hint-box'; import InputGroup from './input-group'; import { getPersonalDetailsInitialValues, getPersonalDetailsValidationSchema, makeSettingsRequest } from './validation'; -import FormSelectField from 'Components/forms/form-select-field'; +import FormSelectField from '../../../Components/forms/form-select-field'; import { VerifyButton } from './verify-button'; import { useInvalidateQuery } from '@deriv/api'; +import EmploymentTaxDetailsContainer from '../../../Containers/employment-tax-details-container'; +import { isFieldImmutable } from '../../../Helpers/utils'; +import { PersonalDetailsValueTypes } from '../../../Types'; +import AccountOpeningReasonField from '../../../Components/forms/form-fields/account-opening-reason'; +import { account_opening_reason_list } from './constants'; +import { useScrollElementToTop } from '../../../hooks'; import { useStatesList, useResidenceList, useGrowthbookGetFeatureValue, usePhoneNumberVerificationSetTimer, useIsPhoneNumberVerified, + useTinValidations, } from '@deriv/hooks'; +import './personal-details-form.scss'; type TRestState = { show_form: boolean; @@ -57,8 +61,13 @@ const PersonalDetailsForm = observer(() => { }); const { next_email_otp_request_timer, is_email_otp_timer_loading } = usePhoneNumberVerificationSetTimer(); + const { tin_validation_config, mutate } = useTinValidations(); + + const scrollToTop = useScrollElementToTop(); + const { client, + ui, notifications, common: { is_language_changing }, } = useStore(); @@ -68,13 +77,17 @@ const PersonalDetailsForm = observer(() => { account_settings, account_status, authentication_status, - is_eu, is_virtual, current_landing_company, updateAccountStatus, + fetchAccountSettings, residence, + is_svg, + is_mf_account, } = client; + const { field_ref_to_focus, setFieldRefToFocus } = ui; + const { data: residence_list, isLoading: is_loading_residence_list } = useResidenceList(); const { data: states_list, isLoading: is_loading_state_list } = useStatesList(residence); @@ -91,6 +104,7 @@ const PersonalDetailsForm = observer(() => { }); const notification_timeout = useRef(); + const scroll_div_ref = useRef(null); const [start_on_submit_timeout, setStartOnSubmitTimeout] = useState<{ is_timeout_started: boolean; @@ -100,6 +114,12 @@ const PersonalDetailsForm = observer(() => { timeout_callback: () => null, }); + useEffect(() => { + fetchAccountSettings(); + }, [fetchAccountSettings]); + + const should_show_loader = is_loading_state_list || is_loading || is_loading_residence_list; + useEffect(() => { const init = async () => { try { @@ -138,8 +158,11 @@ const PersonalDetailsForm = observer(() => { } }; - const onSubmit = async (values: GetSettings, { setStatus, setSubmitting }: FormikHelpers) => { - setStatus({ msg: '', code: '' }); + const onSubmit = async ( + values: PersonalDetailsValueTypes, + { setStatus, setSubmitting }: FormikHelpers + ) => { + setStatus({ msg: '' }); const request = makeSettingsRequest({ ...values }, residence_list, states_list, is_virtual); setIsBtnLoading(true); const data = await WS.authorized.setSettings(request); @@ -210,11 +233,51 @@ const PersonalDetailsForm = observer(() => { return !!account_settings?.immutable_fields?.includes(name); }; + const employment_tax_editable_fields = useMemo(() => { + const fields_to_disable = ['employment_status', 'tax_identification_number'].filter(field => + isFieldImmutable(field, account_settings?.immutable_fields) + ); + /* + [TODO]: Will be removed once BE enables tax_residence in immutable_fields + If Tax_residence value is present in response, then it must not be editable + */ + if (!account_settings?.tax_residence) { + fields_to_disable.push('tax_residence'); + } + return fields_to_disable; + }, [account_settings?.immutable_fields, account_settings?.tax_residence]); + const { api_error, show_form } = rest_state; + const loadTimer = useRef(); + + // To facilitate scrolling to the field that is to be focused + useLayoutEffect(() => { + if (field_ref_to_focus && !should_show_loader && !api_error) { + loadTimer.current = setTimeout(() => { + const parentRef = isDesktop + ? document.querySelector('.account-form__personal-details .dc-themed-scrollbars') + : document.querySelector('.account__scrollbars_container--grid-layout'); + const targetRef = document.getElementById(field_ref_to_focus) as HTMLElement; + const offset = 24; // 24 is the padding of the container + scrollToTop(parentRef as HTMLElement, targetRef, offset); + }, 0); + } + return () => { + if (field_ref_to_focus) { + clearTimeout(loadTimer.current); + } + }; + }, [field_ref_to_focus, isDesktop, should_show_loader, api_error, scrollToTop, setFieldRefToFocus]); + + useEffect(() => { + return () => { + setFieldRefToFocus(null); + }; + }, [setFieldRefToFocus]); if (api_error) return ; - if (is_loading_state_list || is_loading || is_loading_residence_list) { + if (should_show_loader) { return ; } @@ -235,7 +298,15 @@ const PersonalDetailsForm = observer(() => { return undefined; }; - const displayErrorMessage = (status: any) => { + const is_tin_auto_set = Boolean(account_settings?.tin_skipped); + + const PersonalDetailSchema = getPersonalDetailsValidationSchema( + is_virtual, + is_svg, + tin_validation_config, + is_tin_auto_set + ); + const displayErrorMessage = (status: { code: string; msg: string }) => { if (status?.code === 'PhoneNumberTaken') { return ( { return ; }; - const PersonalDetailSchema = getPersonalDetailsValidationSchema(is_eu, is_virtual); - const initialValues = getPersonalDetailsInitialValues(account_settings, residence_list, states_list, is_virtual); return ( @@ -269,7 +338,6 @@ const PersonalDetailsForm = observer(() => { errors, setStatus, status, - touched, handleChange, handleBlur, handleSubmit, @@ -408,227 +476,179 @@ const PersonalDetailsForm = observer(() => { />
{!is_virtual && ( -
-
-
- ) => { - let phone_number = e.target.value.replace(/\D/g, ''); - phone_number = - phone_number.length === 0 ? '+' : `+${phone_number}`; - setFieldValue('phone', phone_number, true); - setStatus(''); - }} - onBlur={handleBlur} - required - error={errors.phone} - disabled={ - isFieldDisabled('phone') || - !!next_email_otp_request_timer || - is_email_otp_timer_loading - } - data-testid='dt_phone' - /> -
- {isPhoneNumberVerificationEnabled && ( - - )} -
-
- )} - - {'tax_residence' in values && ( - - - {'tax_residence' in values && ( -
- -
- )} - {'tax_identification_number' in values && ( -
+ +
+
+
-
- )} - {'employment_status' in values && ( -
- {isDesktop ? ( - - ) : ( - { - setFieldTouched('employment_status', true); - handleChange(e); - }} - /> - )} -
- )} -
- )} - {!is_virtual && ( - - {has_poa_address_mismatch && } - -
-
- ) => { + let phone_number = e.target.value.replace(/\D/g, ''); + phone_number = + phone_number.length === 0 ? '+' : `+${phone_number}`; + setFieldValue('phone', phone_number, true); + setStatus(''); + }} onBlur={handleBlur} - error={errors.address_line_1} required - disabled={isFieldDisabled('address_line_1')} - data-testid='dt_address_line_1' + error={errors.phone} + disabled={ + isFieldDisabled('phone') || + !!next_email_otp_request_timer || + is_email_otp_timer_loading + } + data-testid='dt_phone' /> -
-
- + {isPhoneNumberVerificationEnabled && ( + -
-
- +
+ + + )} + {!is_virtual && ( +
+ + + {has_poa_address_mismatch && } + +
+
+ +
+
+ +
+
+ +
+
+ {states_list.length ? ( + -
-
- {states_list.length ? ( - - ) : ( - - )} -
-
+ ) : ( -
-
- - )} - + )} +
+
+ +
+ + + )} {!!current_landing_company?.support_professional_client && (
diff --git a/packages/account/src/Sections/Profile/PersonalDetails/validation.ts b/packages/account/src/Sections/Profile/PersonalDetails/validation.ts index cb6c4cf78c97..630f37ae2cf0 100644 --- a/packages/account/src/Sections/Profile/PersonalDetails/validation.ts +++ b/packages/account/src/Sections/Profile/PersonalDetails/validation.ts @@ -1,82 +1,21 @@ -import { localize } from '@deriv/translations'; import * as Yup from 'yup'; -import { address_permitted_special_characters_message, getLocation, toMoment } from '@deriv/shared'; +import { formatDate, getLocation, toMoment } from '@deriv/shared'; import { GetSettings, ResidenceList, StatesList } from '@deriv/api-types'; - -Yup.addMethod(Yup.string, 'validatePhoneNumberLength', function (message) { - return this.test('is-valid-phone-number-length', message || localize('You should enter 9-20 numbers.'), value => { - if (typeof value === 'string') { - // Remove the leading '+' symbol before validation - const phoneNumber = value.startsWith('+') ? value.slice(1) : value; - return /^[0-9]{9,20}$/.test(phoneNumber); - } - return false; - }); -}); - -const getBaseSchema = () => - Yup.object().shape({ - first_name: Yup.string() - .required(localize('First name is required.')) - .min(1, localize('Enter no more than 50 characters.')) - .max(50, localize('Enter no more than 50 characters.')) - .matches( - /^(?!.*\s{2,})[\p{L}\s'.-]{1,50}$/u, - localize('Letters, spaces, periods, hyphens, apostrophes only.') - ), - last_name: Yup.string() - .required(localize('Last name is required.')) - .min(1, localize('Enter no more than 50 characters.')) - .max(50, localize('Enter no more than 50 characters.')) - .matches( - /^(?!.*\s{2,})[\p{L}\s'.-]{1,50}$/u, - localize('Letters, spaces, periods, hyphens, apostrophes only.') - ), - phone: Yup.string() - //@ts-expect-error yup validation giving type error - .validatePhoneNumberLength(localize('You should enter 9-20 numbers.')) - .required(localize('Phone is required.')) - .matches(/^\+?([0-9-]+\s)*[0-9-]+$/, localize('Enter a valid phone number (e.g. +15417541234).')), - address_line_1: Yup.string() - .trim() - .required(localize('First line of address is required.')) - .max(70, localize('Should be less than 70.')) - .matches( - /^[\p{L}\p{Nd}\s'.,:;()\u00b0@#/-]{0,70}$/u, - localize('Use only the following special characters: {{permitted_characters}}', { - permitted_characters: address_permitted_special_characters_message, - interpolation: { escapeValue: false }, - }) - ), - address_line_2: Yup.string() - .trim() - .max(70, localize('Should be less than 70.')) - .matches( - /^[\p{L}\p{Nd}\s'.,:;()\u00b0@#/-]{0,70}$/u, - localize('Use only the following special characters: {{permitted_characters}}', { - permitted_characters: address_permitted_special_characters_message, - interpolation: { escapeValue: false }, - }) - ), - address_city: Yup.string() - .required(localize('Town/City is required.')) - .max(70, localize('Should be less than 70.')) - .matches( - /^[A-Za-z]+(?:[.' -]*[A-Za-z]+){1,70}$/, - localize('Only letters, space, hyphen, period, and apostrophe are allowed.') - ), - address_postcode: Yup.string() - .max(20, localize('Please enter a Postal/ZIP code under 20 characters.')) - .matches(/^[A-Za-z0-9][A-Za-z0-9\s-]*$/, localize('Only letters, numbers, space, and hyphen are allowed.')), - }); +import { + getAddressDetailValidationSchema, + getPersonalDetailsBaseValidationSchema, + getEmploymentAndTaxValidationSchema, +} from 'Configs/user-profile-validation-config'; +import { TinValidations } from '@deriv/api/types'; +import { PersonalDetailsValueTypes } from 'Types'; export const getPersonalDetailsInitialValues = ( - account_settings: GetSettings, + account_settings: GetSettings & { tin_skipped?: 0 | 1 }, residence_list: ResidenceList, states_list: StatesList, is_virtual?: boolean -): GetSettings => { - const virtualAccountInitialValues: GetSettings = { +): PersonalDetailsValueTypes => { + const virtualAccountInitialValues: PersonalDetailsValueTypes = { email_consent: account_settings.email_consent ?? 0, residence: account_settings.residence, }; @@ -89,11 +28,16 @@ export const getPersonalDetailsInitialValues = ( address_line_2: account_settings.address_line_2 ?? '', address_postcode: account_settings.address_postcode ?? '', address_state: '', - date_of_birth: account_settings.date_of_birth, + date_of_birth: formatDate(account_settings.date_of_birth, 'YYYY-MM-DD'), first_name: account_settings.first_name, last_name: account_settings.last_name, phone: `+${account_settings.phone?.replace(/\D/g, '')}`, - tax_identification_number: account_settings.tax_identification_number ?? '', + account_opening_reason: account_settings.account_opening_reason, + employment_status: account_settings?.employment_status, + tax_residence: + (account_settings?.tax_residence + ? residence_list.find(item => item.value === account_settings?.tax_residence)?.text + : account_settings?.residence) || '', }; const isGetSettingsKey = (value: string): value is keyof GetSettings => @@ -108,25 +52,35 @@ export const getPersonalDetailsInitialValues = ( } }); + if (account_settings?.tin_skipped) { + initialValues.tin_skipped = account_settings.tin_skipped; + initialValues.tax_identification_number = ''; + } else { + initialValues.tax_identification_number = account_settings.tax_identification_number ?? ''; + } + if (account_settings.address_state) { initialValues.address_state = states_list.length ? getLocation(states_list, account_settings.address_state, 'text') : account_settings.address_state; } - if (account_settings.employment_status) { - initialValues.employment_status = account_settings.employment_status; - } - if (account_settings.request_professional_status) { initialValues.request_professional_status = account_settings.request_professional_status; } + // Setting default value of `I confirm that my tax information is accurate and complete.` checkbox + if (account_settings.tax_residence && account_settings.tax_identification_number && !account_settings.tin_skipped) { + initialValues.tax_identification_confirm = true; + } else { + initialValues.tax_identification_confirm = false; + } + return initialValues; }; export const makeSettingsRequest = ( - settings: GetSettings, + settings: PersonalDetailsValueTypes, residence_list: ResidenceList, states_list: StatesList, is_virtual: boolean @@ -143,7 +97,6 @@ export const makeSettingsRequest = ( request.last_name = request.last_name.trim(); } if (request.date_of_birth) { - // @ts-expect-error need to fix the type for date_of_birth in GetSettings because it should be string not number request.date_of_birth = toMoment(request.date_of_birth).format('YYYY-MM-DD'); } @@ -170,28 +123,35 @@ export const makeSettingsRequest = ( ? getLocation(states_list, request.address_state, 'value') : request.address_state; } + delete request.tax_identification_confirm; return request; }; -export const getPersonalDetailsValidationSchema = (is_eu: boolean, is_virtual?: boolean) => { +export const getPersonalDetailsValidationSchema = ( + is_virtual?: boolean, + is_svg?: boolean, + tin_validation_config?: TinValidations, + is_tin_auto_set?: boolean +) => { if (is_virtual) return Yup.object(); - if (!is_eu) return getBaseSchema(); - return getBaseSchema().concat( - Yup.object().shape({ - tax_identification_number: Yup.string() - .required(localize('TIN is required.')) - .max(25, localize("Tax Identification Number can't be longer than 25 characters.")) - .matches( - /^(?!^$|\s+)[A-Za-z0-9./\s-]{0,25}$/, - localize('Only letters, numbers, space, hyphen, period, and forward slash are allowed.') - ) - .matches( - /^[a-zA-Z0-9].*$/, - localize('Should start with letter or number and may contain a hyphen, period and slash.') - ), - tax_residence: Yup.string().required(localize('Tax residence is required.')), - employment_status: Yup.string().required(localize('Employment status is required.')), - }) - ); + + const personal_details_schema = getPersonalDetailsBaseValidationSchema().pick([ + 'first_name', + 'last_name', + 'phone', + 'date_of_birth', + 'citizen', + ]); + + const address_detail_schema = getAddressDetailValidationSchema(is_svg ?? false); + + const employment_tin_schema = getEmploymentAndTaxValidationSchema({ + tin_config: tin_validation_config as TinValidations, + is_mf: !is_svg, + is_real: !is_virtual, + is_tin_auto_set, + }); + + return personal_details_schema.concat(address_detail_schema).concat(employment_tin_schema); }; diff --git a/packages/account/src/Sections/Profile/PersonalDetails/verify-button.tsx b/packages/account/src/Sections/Profile/PersonalDetails/verify-button.tsx index 428b6bc1208e..46e341b7e50e 100644 --- a/packages/account/src/Sections/Profile/PersonalDetails/verify-button.tsx +++ b/packages/account/src/Sections/Profile/PersonalDetails/verify-button.tsx @@ -59,8 +59,9 @@ export const VerifyButton = observer( localStorage.setItem('routes_from_notification_to_pnv', routes.personal_details); setVerificationCode('', 'phone_number_verification'); setShouldShowPhoneNumberOTP(false); + // @ts-expect-error GetSettings types doesn't match updated set_settings payload types const request = makeSettingsRequest({ ...values }, residence_list, states_list, is_virtual); - //@ts-expect-error GetSettings types doesn't match updated set_settings payload types + // @ts-expect-error GetSettings types doesn't match updated set_settings payload types updateSettings({ payload: request }) .then(() => { sendPhoneNumberVerifyEmail(); diff --git a/packages/account/src/Sections/Verification/ProofOfIncome/proof-of-income-form.tsx b/packages/account/src/Sections/Verification/ProofOfIncome/proof-of-income-form.tsx index c603f27dd326..ae57ce6291b0 100644 --- a/packages/account/src/Sections/Verification/ProofOfIncome/proof-of-income-form.tsx +++ b/packages/account/src/Sections/Verification/ProofOfIncome/proof-of-income-form.tsx @@ -1,16 +1,9 @@ import React from 'react'; import { Field, Formik, Form, FormikErrors, FormikHelpers, FormikValues } from 'formik'; import { AccountStatusResponse, DocumentUploadRequest } from '@deriv/api-types'; -import { - Autocomplete, - Button, - DesktopWrapper, - FormSubmitErrorMessage, - MobileWrapper, - SelectNative, -} from '@deriv/components'; +import { Autocomplete, Button, FormSubmitErrorMessage, SelectNative } from '@deriv/components'; import { useFileUploader } from '@deriv/hooks'; -import { localize, Localize } from '@deriv/translations'; +import { useTranslations, Localize } from '@deriv-com/translations'; import { isEqualArray, WS } from '@deriv/shared'; import { observer, useStore } from '@deriv/stores'; import FilesDescription from 'Components/file-uploader-container/files-descriptions'; @@ -34,16 +27,16 @@ type TInitialValues = { const ProofOfIncomeForm = observer(({ onSubmit }: TProofOfIncomeForm) => { const [document_file, setDocumentFile] = React.useState([]); const [file_selection_error, setFileSelectionError] = React.useState(null); - - const poinc_documents_list = React.useMemo(() => getPoincDocumentsList(), []); - const poinc_uploader_files_descriptions = React.useMemo(() => getFileUploaderDescriptions('poinc'), []); - + const { localize } = useTranslations(); const { notifications } = useStore(); const { addNotificationMessageByKey, removeNotificationMessage, removeNotificationByKey } = notifications; const { isMobile, isDesktop } = useDevice(); const { upload } = useFileUploader(); + const poinc_documents_list = React.useMemo(() => getPoincDocumentsList(), []); + const poinc_uploader_files_descriptions = React.useMemo(() => getFileUploaderDescriptions('poinc'), []); + const initial_form_values: TInitialValues = { document_type: '', }; @@ -126,7 +119,7 @@ const ProofOfIncomeForm = observer(({ onSubmit }: TProofOfIncomeForm) => { {({ field }: FormikValues) => ( - + {isDesktop ? ( { }} required /> - - + ) : ( { required hide_top_placeholder /> - + )} )} diff --git a/packages/account/src/Sections/index.js b/packages/account/src/Sections/index.js index b21e5e13818d..2af32204659a 100644 --- a/packages/account/src/Sections/index.js +++ b/packages/account/src/Sections/index.js @@ -4,6 +4,7 @@ import ProofOfAddress from 'Sections/Verification/ProofOfAddress'; import ProofOfOwnership from 'Sections/Verification/ProofOfOwnership'; import ProofOfIncome from 'Sections/Verification/ProofOfIncome'; import Account from 'Containers/Account/account'; +import EmploymentTaxInfo from '../Containers/employment-tax-info/employment-tax-info'; import DeactivateAccount from 'Sections/Security/DeactivateAccount'; // TODO: Remove once mobile team has changed this link export { @@ -15,4 +16,5 @@ export { ProofOfIncome, Account, DeactivateAccount, + EmploymentTaxInfo, }; diff --git a/packages/account/src/Styles/account.scss b/packages/account/src/Styles/account.scss index fae2c1923dbb..6531b7d268da 100644 --- a/packages/account/src/Styles/account.scss +++ b/packages/account/src/Styles/account.scss @@ -480,6 +480,12 @@ $MIN_HEIGHT_FLOATING: calc( } } + .account-form__fieldset { + .dropdown-field { + margin-bottom: 3.2rem; + } + } + @include tablet-screen { .dc-form-submit-error-message { width: 100%; diff --git a/packages/account/src/Types/common.type.ts b/packages/account/src/Types/common.type.ts index 6609b9afc13f..20340f27f616 100644 --- a/packages/account/src/Types/common.type.ts +++ b/packages/account/src/Types/common.type.ts @@ -12,6 +12,7 @@ import { SetFinancialAssessmentRequest, IdentityVerificationAddDocumentResponse, ApiToken, + GetSettings, } from '@deriv/api-types'; import { AUTH_STATUS_CODES, @@ -20,6 +21,7 @@ import { Platforms, TRADING_PLATFORM_STATUS, } from '@deriv/shared'; +import { TinValidations } from '@deriv/api/types'; export type TToken = NonNullable[0]; @@ -326,6 +328,61 @@ export type TListItem = { value?: string; }; +export type PersonalDetailsValueTypes = Omit & { + date_of_birth?: string; + tax_identification_confirm?: boolean; + tin_skipped?: 0 | 1; +}; + +export type TEmployeeDetailsTinValidationConfig = { + tin_config: TinValidations; + is_mf?: boolean; + is_real?: boolean; + is_tin_auto_set?: boolean; +}; + +type ReqRule = ['req', React.ReactNode]; + +type LengthRule = ['length', React.ReactNode, { min: number; max: number }]; + +type RegularRule = ['regular', React.ReactNode, { regex: RegExp }]; + +type CustomValidator = ( + value: string, + /** + * The options passed to the validation function + */ + options: Record, + /** + * The values of all fields in the form + */ + values: Record +) => React.ReactNode; + +type CustomRule = [CustomValidator, React.ReactNode]; + +type Rule = ReqRule | LengthRule | RegularRule | CustomRule; + +export type TGetField = { + label: React.ReactNode; + /** + * The type of the input field (e.g. 'text', 'password', 'select', etc.) + */ + type?: string; + name: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; + /** + * The list of items for the dropdown or select + */ + list_items?: TListItem[]; + /** + * The validation rules for the input field (e.g. 'req', 'length', 'regular', etc.) + */ + rules?: Array; +}; + export type TPOAFormState = Record< 'is_btn_loading' | 'is_submit_success' | 'should_allow_submit' | 'should_show_form', boolean diff --git a/packages/account/src/hooks/index.ts b/packages/account/src/hooks/index.ts index 8db078fa131f..ff4d7dbbc5a5 100644 --- a/packages/account/src/hooks/index.ts +++ b/packages/account/src/hooks/index.ts @@ -1 +1,2 @@ export { useKycAuthStatus } from './useKycAuthStatus'; +export { useScrollElementToTop } from './useScrollToPosition'; diff --git a/packages/account/src/hooks/useScrollToPosition.ts b/packages/account/src/hooks/useScrollToPosition.ts new file mode 100644 index 000000000000..54b0106fa4bc --- /dev/null +++ b/packages/account/src/hooks/useScrollToPosition.ts @@ -0,0 +1,16 @@ +import { useCallback } from 'react'; + +type ScrollElementToTop = (rootEl: HTMLElement, targetEl: HTMLElement, offset: number) => void; + +export const useScrollElementToTop = (): ScrollElementToTop => { + const scrollElementToTop: ScrollElementToTop = useCallback((rootEl, targetEl, offset = 0) => { + if (rootEl && targetEl) { + rootEl.scrollTo({ + top: targetEl.offsetTop - offset, + behavior: 'smooth', + }); + } + }, []); + + return scrollElementToTop; +}; diff --git a/packages/api/types.ts b/packages/api/types.ts index fbf2bfa5f972..8c9cdbb1cddd 100644 --- a/packages/api/types.ts +++ b/packages/api/types.ts @@ -2406,6 +2406,73 @@ type ChangeEmailResponse = { msg_type: 'change_email'; req_id?: number; }; + +/** + * Get the validations for Tax Identification Numbers (TIN) + */ +export interface TINValidationRequest { + /** + * Must be `1` + */ + tin_validations: 1; + /** + * The tax residence selected by the client. + */ + tax_residence: string; + /** + * [Optional] Used to pass data through the websocket, which may be retrieved via the `echo_req` output field. + */ + passthrough?: { + [k: string]: unknown; + }; + /** + * [Optional] Used to map request to response. + */ + req_id?: number; +} + +/** + * A message with validations for Tax Identification Numbers (TIN) + */ +export type TINValidationResponse = { + tin_validations?: TinValidations; + /** + * Echo of the request made. + */ + echo_req: { + [k: string]: unknown; + }; + /** + * Action name of the request made. + */ + msg_type: 'tin_validations'; + /** + * Optional field sent in request to map to response, present only when request contains `req_id`. + */ + req_id?: number; + [k: string]: unknown; +}; +/** + * Validations for Tax Identification Numbers (TIN) + */ +export type TinValidations = { + /** + * List of employment statuses that bypass TIN requirements for the selected country + */ + tin_employment_status_bypass?: string[]; + /** + * Whether the TIN is mandatory for the selected country + */ + is_tin_mandatory?: boolean; + /** + * Country tax identifier formats. + */ + tin_format?: string[]; + /** + * Invalid regex patterns for tin validation + */ + invalid_patterns?: string[]; +}; /** * Get list of platform and their server status */ @@ -2871,6 +2938,10 @@ type TSocketEndpoints = { request: ServerTimeRequest; response: ServerTimeResponse; }; + tin_validations: { + request: TINValidationRequest; + response: TINValidationResponse; + }; tnc_approval: { request: TermsAndConditionsApprovalRequest; response: TermsAndConditionsApprovalResponse; diff --git a/packages/components/src/components/form-progress/form-progress.scss b/packages/components/src/components/form-progress/form-progress.scss index 87fc6cb8acb2..388f9156eff2 100644 --- a/packages/components/src/components/form-progress/form-progress.scss +++ b/packages/components/src/components/form-progress/form-progress.scss @@ -19,9 +19,9 @@ display: flex; flex-direction: column; height: 6rem; - justify-content: space-around; - width: 160px; + width: 134px; z-index: 2; + gap: 1rem; & .identifier { border-radius: 50%; @@ -29,6 +29,7 @@ width: 24px; background-color: var(--text-less-prominent); border: 1px solid var(--text-less-prominent); + margin-top: 4px; &--active { background-color: var(--brand-red-coral) !important; diff --git a/packages/components/src/components/icon/common/ic-light-orders-default.svg b/packages/components/src/components/icon/common/ic-light-orders-default.svg new file mode 100644 index 000000000000..282a6ac586c2 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-light-orders-default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/icons.js b/packages/components/src/components/icon/icons.js index be8e1356e70d..efe311500bf5 100644 --- a/packages/components/src/components/icon/icons.js +++ b/packages/components/src/components/icon/icons.js @@ -465,6 +465,7 @@ import './common/ic-jeton-dark.svg'; import './common/ic-jeton-light.svg'; import './common/ic-language.svg'; import './common/ic-less-than-eight.svg'; +import './common/ic-light-orders-default.svg'; import './common/ic-linux-logo.svg'; import './common/ic-linux.svg'; import './common/ic-live-chat.svg'; diff --git a/packages/core/src/App/Components/Elements/NotificationMessage/notification.jsx b/packages/core/src/App/Components/Elements/NotificationMessage/notification.jsx index 1e52971cf5e4..a5a8a6b2c0fd 100644 --- a/packages/core/src/App/Components/Elements/NotificationMessage/notification.jsx +++ b/packages/core/src/App/Components/Elements/NotificationMessage/notification.jsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; +import { useHistory } from 'react-router-dom'; import React from 'react'; import { Button, LinearProgress, Text } from '@deriv/components'; import { isEmptyObject } from '@deriv/shared'; @@ -13,6 +14,7 @@ import NotificationOrder from './notification-order.jsx'; const Notification = ({ data, removeNotificationMessage }) => { const linear_progress_container_ref = React.useRef(null); + const history = useHistory(); const destroy = is_closed_by_user => { removeNotificationMessage(data); @@ -117,7 +119,7 @@ const Notification = ({ data, removeNotificationMessage }) => {
{!isEmptyObject(data.action) && ( - {data.action.route ? ( + {data.action.route && !data.action.onClick ? ( {