From 0782b6ebc43c05a7332b5a306ee93483cd57700d Mon Sep 17 00:00:00 2001 From: Yauheni Pasiukevich Date: Tue, 5 Nov 2024 16:53:26 +0100 Subject: [PATCH 01/10] feat: Add Corpay bank info step and update file upload logic --- src/CONST.ts | 1 + src/components/UploadFile.tsx | 11 +- src/languages/en.ts | 12 + src/languages/params.ts | 5 + src/libs/actions/BankAccounts.ts | 229 ++++++++++++++++++ .../NonUSD/BankInfo/BankInfo.tsx | 55 ++++- .../BankInfo/substeps/BankAccountDetails.tsx | 98 ++++++++ .../NonUSD/BankInfo/substeps/Confirmation.tsx | 50 +++- .../BankInfo/substeps/UploadStatement.tsx | 107 ++++++++ .../NonUSD/BankInfo/types.ts | 17 ++ .../ReimbursementAccount/NonUSD/WhyLink.tsx | 44 ++++ src/types/form/ReimbursementAccountForm.ts | 9 + src/types/onyx/ReimbursementAccount.ts | 9 + 13 files changed, 634 insertions(+), 13 deletions(-) create mode 100644 src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx create mode 100644 src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/UploadStatement.tsx create mode 100644 src/pages/ReimbursementAccount/NonUSD/BankInfo/types.ts create mode 100644 src/pages/ReimbursementAccount/NonUSD/WhyLink.tsx diff --git a/src/CONST.ts b/src/CONST.ts index e387964c7e24..3d5f74160a34 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -478,6 +478,7 @@ const CONST = { ALLOWED_FILE_TYPES: ['pdf', 'jpg', 'jpeg', 'png'], FILE_LIMIT: 10, TOTAL_FILES_SIZE_LIMIT: 5242880, + BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX: 'accountHolder', STEP: { COUNTRY: 'CountryStep', BANK_INFO: 'BankInfoStep', diff --git a/src/components/UploadFile.tsx b/src/components/UploadFile.tsx index 5dff344a5877..3c1c4d5f2c7a 100644 --- a/src/components/UploadFile.tsx +++ b/src/components/UploadFile.tsx @@ -69,7 +69,9 @@ function UploadFile({ const theme = useTheme(); const handleFileUpload = (files: FileObject[]) => { - const totalSize = files.reduce((sum, file) => sum + (file.size ?? 0), 0); + const resultedFiles = [...uploadedFiles, ...files]; + + const totalSize = resultedFiles.reduce((sum, file) => sum + (file.size ?? 0), 0); if (totalFilesSizeLimit) { if (totalSize > totalFilesSizeLimit) { @@ -78,7 +80,7 @@ function UploadFile({ } } - if (fileLimit && files.length > 0 && files.length > fileLimit) { + if (fileLimit && resultedFiles.length > 0 && resultedFiles.length > fileLimit) { setError(translate('attachmentPicker.tooManyFiles', {fileLimit})); return; } @@ -98,6 +100,7 @@ function UploadFile({ onInputChange(newFilesToUpload); onUpload(newFilesToUpload); + setError(''); }; return ( @@ -137,7 +140,7 @@ function UploadFile({ {file.name} onRemove(file?.uri ?? '')} + onPress={() => onRemove(file?.name ?? '')} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.remove')} > @@ -151,7 +154,7 @@ function UploadFile({ ))} {errorText !== '' && ( diff --git a/src/languages/en.ts b/src/languages/en.ts index 48595dde7f5a..33c32700c2d7 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -82,6 +82,7 @@ import type { InvalidPropertyParams, InvalidValueParams, IssueVirtualCardParams, + LastFourDigitsParams, LastSyncAccountingParams, LastSyncDateParams, LocalTimeParams, @@ -2255,6 +2256,17 @@ const translations = { findCountry: 'Find country', selectCountry: 'Select country', }, + bankInfoStep: { + whatAreYour: 'What are your business bank account details?', + letsDoubleCheck: 'Let’s double check that everything looks fine.', + thisBankAccount: 'This bank account will be used for business payments on your workspace', + accountNumber: 'Account number', + bankStatement: 'Bank statement', + chooseFile: 'Choose file', + uploadYourLatest: 'Upload your latest statement', + pleaseUpload: ({lastFourDigits}: LastFourDigitsParams) => `Please upload the most recent monthly statement for your business bank account ending in ${lastFourDigits}.`, + confirmBankInfo: 'Confirm bank info', + }, signerInfoStep: { signerInfo: 'Signer info', }, diff --git a/src/languages/params.ts b/src/languages/params.ts index e9f0c4370357..f1b2c2518f99 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -543,6 +543,10 @@ type FileLimitParams = { fileLimit: number; }; +type LastFourDigitsParams = { + lastFourDigits: string; +}; + type CompanyCardBankName = { bankName: string; }; @@ -637,6 +641,7 @@ export type { HeldRequestParams, InstantSummaryParams, IssueVirtualCardParams, + LastFourDigitsParams, LocalTimeParams, LogSizeParams, LoggedInAsParams, diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 6679a6e4b9ea..b589a29420ed 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -343,6 +343,234 @@ function validateBankAccount(bankAccountID: number, validateCode: string, policy API.write(WRITE_COMMANDS.VALIDATE_BANK_ACCOUNT_WITH_TRANSACTIONS, parameters, onyxData); } +function getCorpayBankAccountFields(country: string, currency: string) { + // TODO - Use parameters when API is ready + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const parameters: 'GetCorpayBankAccountFieldsParams' = { + countryISO: country, + currency, + isWithdrawal: true, + isBusinessBankAccount: true, + }; + + // return API.read(READ_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS, parameters); + return { + bankCountry: 'AU', + bankCurrency: 'AUD', + classification: 'Business', + destinationCountry: 'AU', + formFields: [ + { + errorMessage: 'Name is invalid. Value should be 3 to 100 characters long.', + id: 'accountHolderName', + isRequired: true, + isRequiredInValueSet: true, + label: 'Account Holder Name', + regEx: '^.{3,100}$', + validationRules: [ + { + errorMessage: 'Name is invalid. Value should be 3 to 100 characters long.', + regEx: '^.{3,100}$', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + { + defaultValue: 'AU', + id: 'accountHolderCountry', + isRequired: true, + isRequiredInValueSet: true, + label: 'Account Holder Country', + regEx: '^.{2,2}$', + validationRules: [ + { + regEx: '^.{2,2}$', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + { + errorMessage: 'PO Box address isn\u2019t allowed. Address Line 1 must be less than 1000 characters', + id: 'accountHolderAddress1', + isRequired: true, + isRequiredInValueSet: true, + label: 'Account Holder Address', + regEx: '^(?!.*\\b(P\\.?\\s?O\\.?\\s?Box|P\\.?\\s?O\\.?\\s?B\\.?|P\\.?\\s?O\\.?\\s?Bx|PO\\.?\\s?Box|PO\\.?\\s?B\\.?|PO\\.?\\s?Bx|Post\\s?Office\\s?Box|Box\\s?No\\.?|Box\\s?#|Box\\s?\\d+|Lockbox|P\\.?\\s?O\\.?\\s?B\\.?|P\\.?\\s?O\\.?\\s?Bx|P\\s?O\\s?Box\\s?No\\.?)\\b).{0,1000}$', + validationRules: [ + { + errorMessage: 'PO Box address isn\u2019t allowed. Address Line 1 must be less than 1000 characters', + regEx: '^(?!.*\\b(P\\.?\\s?O\\.?\\s?Box|P\\.?\\s?O\\.?\\s?B\\.?|P\\.?\\s?O\\.?\\s?Bx|PO\\.?\\s?Box|PO\\.?\\s?B\\.?|PO\\.?\\s?Bx|Post\\s?Office\\s?Box|Box\\s?No\\.?|Box\\s?#|Box\\s?\\d+|Lockbox|P\\.?\\s?O\\.?\\s?B\\.?|P\\.?\\s?O\\.?\\s?Bx|P\\s?O\\s?Box\\s?No\\.?)\\b).{0,1000}$', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + { + errorMessage: 'City must be less than 100 characters', + id: 'accountHolderCity', + isRequired: true, + isRequiredInValueSet: true, + label: 'Account Holder City', + regEx: '^.{0,100}$', + validationRules: [ + { + errorMessage: 'City must be less than 100 characters', + regEx: '^.{0,100}$', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + { + errorMessage: 'Swift must be less than 12 characters', + id: 'swiftBicCode', + isRequired: false, + isRequiredInValueSet: true, + label: 'Swift Code', + regEx: '^.{0,12}$', + validationRules: [ + { + errorMessage: 'Swift must be less than 12 characters', + regEx: '^.{0,12}$', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + { + errorMessage: 'Beneficiary Bank Name must be less than 250 characters', + id: 'bankName', + isRequired: true, + isRequiredInValueSet: true, + label: 'Bank Name', + regEx: '^.{0,250}$', + validationRules: [ + { + errorMessage: 'Beneficiary Bank Name must be less than 250 characters', + regEx: '^.{0,250}$', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + { + errorMessage: 'City must be less than 100 characters', + id: 'bankCity', + isRequired: true, + isRequiredInValueSet: true, + label: 'Bank City', + regEx: '^.{0,100}$', + validationRules: [ + { + errorMessage: 'City must be less than 100 characters', + regEx: '^.{0,100}$', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + { + errorMessage: 'Bank Address Line 1 must be less than 1000 characters', + id: 'bankAddressLine1', + isRequired: true, + isRequiredInValueSet: true, + label: 'Bank Address', + regEx: '^.{0,1000}$', + validationRules: [ + { + errorMessage: 'Bank Address Line 1 must be less than 1000 characters', + regEx: '^.{0,1000}$', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + { + detailedRule: [ + { + isRequired: true, + value: [ + { + errorMessage: 'Beneficiary Account Number is invalid. Value should be 1 to 50 characters long.', + regEx: '^.{1,50}$', + ruleDescription: '1 to 50 characters', + }, + ], + }, + ], + errorMessage: 'Beneficiary Account Number is invalid. Value should be 1 to 50 characters long.', + id: 'accountNumber', + isRequired: true, + isRequiredInValueSet: true, + label: 'Account Number (iACH)', + regEx: '^.{1,50}$', + validationRules: [ + { + errorMessage: 'Beneficiary Account Number is invalid. Value should be 1 to 50 characters long.', + regEx: '^.{1,50}$', + ruleDescription: '1 to 50 characters', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + { + detailedRule: [ + { + isRequired: true, + value: [ + { + errorMessage: 'BSB Number is invalid. Value should be exactly 6 digits long.', + regEx: '^[0-9]{6}$', + ruleDescription: 'Exactly 6 digits', + }, + ], + }, + ], + errorMessage: 'BSB Number is invalid. Value should be exactly 6 digits long.', + id: 'routingCode', + isRequired: true, + isRequiredInValueSet: true, + label: 'BSB Number', + regEx: '^[0-9]{6}$', + validationRules: [ + { + errorMessage: 'BSB Number is invalid. Value should be exactly 6 digits long.', + regEx: '^[0-9]{6}$', + ruleDescription: 'Exactly 6 digits', + }, + { + errorMessage: 'The following characters are not allowed: <,>, "', + regEx: '^[^<>\\x22]*$', + }, + ], + }, + ], + paymentMethods: ['E'], + preferredMethod: 'E', + }; +} + function clearReimbursementAccount() { Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, null); } @@ -572,6 +800,7 @@ export { updateAddPersonalBankAccountDraft, clearPersonalBankAccountSetupType, validatePlaidSelection, + getCorpayBankAccountFields, }; export type {BusinessAddress, PersonalAddress}; diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx index d6a9267b4f94..2088f05be19b 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx @@ -1,11 +1,17 @@ import type {ComponentType} from 'react'; -import React from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useSubStep from '@hooks/useSubStep'; -import type {SubStepProps} from '@hooks/useSubStep/types'; +import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; +import BankAccountDetails from './substeps/BankAccountDetails'; import Confirmation from './substeps/Confirmation'; +import UploadStatement from './substeps/UploadStatement'; +import type {BankInfoSubStepProps, CorpayFormField} from './types'; type BankInfoProps = { /** Handles back button press */ @@ -15,16 +21,41 @@ type BankInfoProps = { onSubmit: () => void; }; -const bodyContent: Array> = [Confirmation]; +const {COUNTRY} = INPUT_IDS.ADDITIONAL_DATA; + +const bodyContent: Array> = [BankAccountDetails, UploadStatement, Confirmation]; function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { const {translate} = useLocalize(); + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); + const [corpayFields, setCorpayFields] = useState([]); + const country = reimbursementAccountDraft?.[COUNTRY] ?? ''; + const policyID = reimbursementAccount?.achData?.policyID ?? '-1'; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const currency = policy?.outputCurrency ?? ''; + const submit = () => { onSubmit(); }; - const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit}); + const { + componentToRender: SubStep, + isEditing, + screenIndex, + nextScreen, + prevScreen, + moveTo, + goToTheLastStep, + resetScreenIndex, + } = useSubStep({bodyContent, startFrom: 0, onFinished: submit}); + + // Temporary solution to get the fields for the corpay bank account fields + useEffect(() => { + const response = BankAccounts.getCorpayBankAccountFields(country, currency); + setCorpayFields((response?.formFields as CorpayFormField[]) ?? []); + }, [country, currency]); const handleBackButtonPress = () => { if (isEditing) { @@ -34,11 +65,22 @@ function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) { if (screenIndex === 0) { onBackButtonPress(); - } else { + } else if (currency === CONST.CURRENCY.AUD) { prevScreen(); + } else { + resetScreenIndex(); } }; + const handleNextScreen = useCallback(() => { + if (currency !== CONST.CURRENCY.AUD) { + goToTheLastStep(); + return; + } + + nextScreen(); + }, [currency, goToTheLastStep, nextScreen]); + return ( ); diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx new file mode 100644 index 000000000000..a17127e9faba --- /dev/null +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/BankAccountDetails.tsx @@ -0,0 +1,98 @@ +import React, {useCallback, useMemo} from 'react'; +import {View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import useReimbursementAccountStepFormSubmit from '@hooks/useReimbursementAccountStepFormSubmit'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {BankInfoSubStepProps} from '@pages/ReimbursementAccount/NonUSD/BankInfo/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function BankAccountDetails({onNext, isEditing, corpayFields}: BankInfoSubStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const bankAccountDetailsFields = useMemo(() => { + return corpayFields.filter((field) => !field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)); + }, [corpayFields]); + + const fieldIds = bankAccountDetailsFields.map((field) => field.id); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + + bankAccountDetailsFields.forEach((field) => { + const fieldID = field.id as keyof FormOnyxValues; + + if (field.isRequired && !values[fieldID]) { + errors[fieldID] = translate('common.error.fieldRequired'); + } + + field.validationRules.forEach((rule) => { + if (rule.regEx) { + return; + } + + if (new RegExp(rule.regEx).test(values[fieldID] ? String(values[fieldID]) : '')) { + return; + } + + errors[fieldID] = rule.errorMessage; + }); + }); + + return errors; + }, + [bankAccountDetailsFields, translate], + ); + + const handleSubmit = useReimbursementAccountStepFormSubmit({ + fieldIds: fieldIds as Array>, + onNext, + shouldSaveDraft: isEditing, + }); + + const inputs = useMemo(() => { + return bankAccountDetailsFields.map((field) => { + return ( + + + + ); + }); + }, [bankAccountDetailsFields, styles.flex2, styles.mb6, isEditing]); + + return ( + + + {translate('bankInfoStep.whatAreYour')} + {inputs} + + + ); +} + +BankAccountDetails.displayName = 'BankAccountDetails'; + +export default BankAccountDetails; diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx index 9ff2b0e57de9..c336d33d2d79 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx @@ -1,16 +1,57 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import type {SubStepProps} from '@hooks/useSubStep/types'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {BankInfoSubStepProps} from '@pages/ReimbursementAccount/NonUSD/BankInfo/types'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccountForm} from '@src/types/form/ReimbursementAccountForm'; -function Confirmation({onNext}: SubStepProps) { +function Confirmation({onNext, onMove, corpayFields}: BankInfoSubStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + const [reimbursementAccountDraft] = useOnyx(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT); + const inputKeys = useMemo(() => { + const keys: Record = {}; + corpayFields.forEach((field) => { + keys[field.id] = field.id; + }); + return keys; + }, [corpayFields]); + const values = useMemo(() => getSubstepValues(inputKeys, reimbursementAccountDraft, reimbursementAccount), [inputKeys, reimbursementAccount, reimbursementAccountDraft]); + + const items = useMemo( + () => + corpayFields.map((field) => { + return ( + { + if (field.id.includes(CONST.NON_USD_BANK_ACCOUNT.BANK_INFO_STEP_ACCOUNT_HOLDER_KEY_PREFIX)) { + onMove(1); + return; + } + + onMove(0); + }} + key={field.id} + /> + ); + }), + [corpayFields, onMove, values], + ); + return ( {({safeAreaPaddingBottomStyle}) => ( @@ -18,6 +59,9 @@ function Confirmation({onNext}: SubStepProps) { style={styles.pt0} contentContainerStyle={[styles.flexGrow1, safeAreaPaddingBottomStyle]} > + {translate('bankInfoStep.letsDoubleCheck')} + {translate('bankInfoStep.thisBankAccount')} + {items}