diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a0a38b1d89963..772316e6863ad 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2916,6 +2916,14 @@ const CONST = { VENDOR_BILL: 'VENDOR_BILL', }, + UPDATE_PERSONAL_BANK_ACCOUNT: { + PAGE_NAME: { + LEGAL_NAME: 'legal-name', + ADDRESS: 'address', + PHONE_NUMBER: 'phone-number', + }, + }, + MISSING_PERSONAL_DETAILS: { STEP_INDEX_LIST: ['1', '2', '3', '4'], STEP_INDEX_LIST_WITH_PIN: ['1', '2', '3', '4', '5'], diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 56538e3971bc7..a0febfee693fb 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -455,6 +455,15 @@ const ROUTES = { }, SETTINGS_ADD_US_BANK_ACCOUNT: 'settings/wallet/add-us-bank-account', SETTINGS_ADD_US_BANK_ACCOUNT_ENTRY_POINT: 'settings/wallet/add-us-bank-account/entry-point', + SETTINGS_UPDATE_PERSONAL_BANK_ACCOUNT: { + route: 'settings/wallet/update-personal-bank-account/:subPage?', + getRoute: (subPage?: string) => { + if (!subPage) { + return 'settings/wallet/update-personal-bank-account' as const; + } + return `settings/wallet/update-personal-bank-account/${subPage}` as const; + }, + }, SETTINGS_ADD_BANK_ACCOUNT_SELECT_COUNTRY_VERIFY_ACCOUNT: `settings/wallet/add-bank-account/select-country/${VERIFY_ACCOUNT}`, SETTINGS_BANK_ACCOUNT_PURPOSE: 'settings/wallet/bank-account-purpose', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 05816c13152dc..b3104c9fc833f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -112,6 +112,7 @@ const SCREENS = { ADD_BANK_ACCOUNT: 'Settings_Add_Bank_Account', ADD_US_BANK_ACCOUNT: 'Settings_Add_US_Bank_Account', ADD_US_BANK_ACCOUNT_ENTRY_POINT: 'Settings_Add_US_Bank_Account_Entry_Point', + UPDATE_PERSONAL_BANK_ACCOUNT: 'Settings_Update_Personal_Bank_Account', ADD_BANK_ACCOUNT_SELECT_COUNTRY_VERIFY_ACCOUNT: 'Settings_Add_Bank_Account_Select_Country_Verify_Account', BANK_ACCOUNT_PURPOSE: 'Settings_Bank_Account_Purpose', CLOSE: 'Settings_Close', diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 85b478d7b4ade..e354ecc1c4bae 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -55,6 +55,12 @@ type AddressFormProps = { /** A unique Onyx key identifying the form */ formID: typeof ONYXKEYS.FORMS.HOME_ADDRESS_FORM; + + /** Whether to hide the country selector (e.g. when country cannot be changed) */ + shouldHideCountrySelector?: boolean; + + /** Whether the form submit button should be enabled when offline */ + enabledWhenOffline?: boolean; }; function AddressForm({ @@ -69,6 +75,8 @@ function AddressForm({ street2 = '', submitButtonText = '', zip = '', + shouldHideCountrySelector = false, + enabledWhenOffline: enabledWhenOfflineProp = true, }: AddressFormProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -87,11 +95,14 @@ function AddressForm({ */ const validator = useCallback( - (values: FormOnyxValues): Errors => { + (rawValues: FormOnyxValues): Errors => { + // When hidden, the country input is unregistered so fall back to the country prop. + const values = shouldHideCountrySelector ? {...rawValues, country: rawValues.country || country} : rawValues; + const errors: Errors & { zipPostCode?: string | string[]; } = {}; - const requiredFields = ['addressLine1', 'city', 'country', 'state'] as const; + const requiredFields = shouldHideCountrySelector ? (['addressLine1', 'city', 'state'] as const) : (['addressLine1', 'city', 'country', 'state'] as const); // Check "State" dropdown is a valid state if selected Country is USA if (values.country === CONST.COUNTRY.US && !values.state) { @@ -145,7 +156,7 @@ function AddressForm({ return errors; }, - [translate], + [translate, shouldHideCountrySelector, country], ); return ( @@ -155,7 +166,7 @@ function AddressForm({ validate={validator} onSubmit={onSubmit} submitButtonText={submitButtonText} - enabledWhenOffline + enabledWhenOffline={enabledWhenOfflineProp} addBottomSafeAreaPadding > @@ -192,16 +203,20 @@ function AddressForm({ autoComplete="address-line2" /> - - - - + {!shouldHideCountrySelector && ( + <> + + + + + + )} {isUSAForm ? ( = SubStepPro /** Whether to show the Patriot Act help link (EnablePayments-only) */ shouldShowPatriotActLink?: boolean; + + /** Whether the form submit button should be enabled when offline */ + enabledWhenOffline?: boolean; }; function FullNameStep({ @@ -72,6 +75,7 @@ function FullNameStep({ customLastNameLabel, shouldShowPatriotActLink = false, forwardedFSClass, + enabledWhenOffline: enabledWhenOfflineProp = true, }: FullNameStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -125,7 +129,7 @@ function FullNameStep({ validate={customValidate ?? validate} onSubmit={onSubmit} style={[styles.mh5, styles.flexGrow1]} - enabledWhenOffline + enabledWhenOffline={enabledWhenOfflineProp} > {formTitle} diff --git a/src/hooks/useNavigationTabBarIndicatorChecks.ts b/src/hooks/useNavigationTabBarIndicatorChecks.ts index 63752960548ac..b5fc367b87a4f 100644 --- a/src/hooks/useNavigationTabBarIndicatorChecks.ts +++ b/src/hooks/useNavigationTabBarIndicatorChecks.ts @@ -2,7 +2,7 @@ import type {ValueOf} from 'type-fest'; import {isConnectionInProgress} from '@libs/actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import {hasPaymentMethodError} from '@libs/actions/PaymentMethods'; -import {hasPartiallySetupBankAccount} from '@libs/BankAccountUtils'; +import {hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; import {hasPendingExpensifyCardAction} from '@libs/CardUtils'; import {hasDomainErrors} from '@libs/DomainUtils'; import {getUberConnectionErrorDirectlyFromPolicy, shouldShowCustomUnitsError, shouldShowEmployeeListError, shouldShowPolicyError, shouldShowSyncError} from '@libs/PolicyUtils'; @@ -104,7 +104,7 @@ function useNavigationTabBarIndicatorChecks(): NavigationTabBarChecksResult { amountOwed, ownerBillingGraceEndPeriod, ), - [CONST.INDICATOR_STATUS.HAS_PARTIALLY_SETUP_BANK_ACCOUNT_INFO]: hasPartiallySetupBankAccount(bankAccountList), + [CONST.INDICATOR_STATUS.HAS_PARTIALLY_SETUP_BANK_ACCOUNT_INFO]: hasPartiallySetupBankAccount(bankAccountList) || hasPersonalBankAccountMissingInfo(bankAccountList), }; const domainChecks: Partial> = { diff --git a/src/languages/de.ts b/src/languages/de.ts index b622da7e22e02..dda8e301dea68 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3300,6 +3300,11 @@ ${ confirmationStepHeader: 'Überprüfe deine Angaben.', confirmationStepSubHeader: 'Prüfen Sie die untenstehenden Angaben sorgfältig und aktivieren Sie das Kontrollkästchen für die Bedingungen, um zu bestätigen.', toGetStarted: 'Fügen Sie ein persönliches Bankkonto hinzu, um Erstattungen zu erhalten, Rechnungen zu bezahlen oder die Expensify Wallet zu aktivieren.', + updatePersonalInfo: 'Bankkonto aktualisieren', + updatePersonalInfoFailure: 'Die Bankkontoinformationen konnten nicht aktualisiert werden. Bitte versuchen Sie es später erneut.', + updateSuccessTitle: 'Bankkonto aktualisiert!', + updateSuccessHeader: 'Bankkonto aktualisiert', + updateSuccessMessage: 'Glückwunsch, dein Bankkonto ist eingerichtet und bereit, Rückerstattungen zu empfangen.', }, addPersonalBankAccountPage: { enterPassword: 'Expensify-Passwort eingeben', diff --git a/src/languages/en.ts b/src/languages/en.ts index c75c38ebe405e..e0a24540928bb 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3344,6 +3344,11 @@ const translations = { confirmationStepHeader: 'Check your info.', confirmationStepSubHeader: 'Double check the details below, and check the terms box to confirm.', toGetStarted: 'Add a personal bank account to receive reimbursements, pay invoices, or enable the Expensify Wallet.', + updatePersonalInfo: 'Update bank account', + updatePersonalInfoFailure: 'Unable to update bank account information. Please try again later.', + updateSuccessTitle: 'Bank account updated!', + updateSuccessHeader: 'Bank account updated', + updateSuccessMessage: 'Congrats, your bank account is set up and ready to receive reimbursements.', }, addPersonalBankAccountPage: { enterPassword: 'Enter Expensify password', diff --git a/src/languages/es.ts b/src/languages/es.ts index 9e390308d06fc..4b9f354d3c987 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3214,6 +3214,11 @@ ${amount} para ${merchant} - ${date}`, confirmationStepHeader: 'Verifica tu información.', confirmationStepSubHeader: 'Verifica dos veces los detalles a continuación y marca la casilla de términos para confirmar.', toGetStarted: 'Agrega una cuenta bancaria personal para recibir reembolsos, pagar facturas o habilitar la Cartera de Expensify.', + updatePersonalInfo: 'Actualizar cuenta bancaria', + updatePersonalInfoFailure: 'No se pudo actualizar la información de la cuenta bancaria. Por favor, inténtalo de nuevo más tarde.', + updateSuccessTitle: '¡Cuenta bancaria actualizada!', + updateSuccessHeader: 'Cuenta bancaria actualizada', + updateSuccessMessage: 'Enhorabuena, tu cuenta bancaria está configurada y lista para recibir reembolsos.', }, addPersonalBankAccountPage: { enterPassword: 'Escribe tu contraseña de Expensify', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ea7ca3d26c7ed..29cf7306aaf9a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3310,6 +3310,11 @@ ${ confirmationStepHeader: 'Vérifiez vos informations.', confirmationStepSubHeader: 'Vérifiez attentivement les détails ci-dessous et cochez la case des conditions pour confirmer.', toGetStarted: 'Ajoutez un compte bancaire personnel pour recevoir des remboursements, payer des factures ou activer le Portefeuille Expensify.', + updatePersonalInfo: 'Mettre à jour le compte bancaire', + updatePersonalInfoFailure: 'Impossible de mettre à jour les informations du compte bancaire. Veuillez réessayer plus tard.', + updateSuccessTitle: 'Compte bancaire mis à jour !', + updateSuccessHeader: 'Compte bancaire mis à jour', + updateSuccessMessage: 'Félicitations, votre compte bancaire est configuré et prêt à recevoir des remboursements.', }, addPersonalBankAccountPage: { enterPassword: 'Saisissez le mot de passe Expensify', diff --git a/src/languages/it.ts b/src/languages/it.ts index e101563e73a46..459088b625faf 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3290,6 +3290,11 @@ ${ confirmationStepHeader: 'Controlla le tue informazioni.', confirmationStepSubHeader: 'Controlla attentamente i dettagli qui sotto e seleziona la casella delle condizioni per confermare.', toGetStarted: 'Aggiungi un conto bancario personale per ricevere rimborsi, pagare fatture o attivare l’Expensify Wallet.', + updatePersonalInfo: 'Aggiorna conto bancario', + updatePersonalInfoFailure: 'Impossibile aggiornare le informazioni del conto bancario. Riprova più tardi.', + updateSuccessTitle: 'Conto bancario aggiornato!', + updateSuccessHeader: 'Conto bancario aggiornato', + updateSuccessMessage: 'Complimenti, il tuo conto bancario è configurato e pronto a ricevere rimborsi.', }, addPersonalBankAccountPage: { enterPassword: 'Inserisci la password di Expensify', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 33c0e0c85867a..7404970181f12 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3271,6 +3271,11 @@ ${ confirmationStepHeader: '情報を確認してください。', confirmationStepSubHeader: '下記の詳細を再確認し、利用規約のチェックボックスをオンにして確定してください。', toGetStarted: '個人の銀行口座を追加して、経費精算の受け取りや請求書の支払い、Expensifyウォレットの有効化を行いましょう。', + updatePersonalInfo: '銀行口座を更新', + updatePersonalInfoFailure: '銀行口座情報を更新できませんでした。後でもう一度お試しください。', + updateSuccessTitle: '銀行口座が更新されました!', + updateSuccessHeader: '銀行口座が更新されました', + updateSuccessMessage: 'おめでとうございます。銀行口座の設定が完了し、精算の受け取りができるようになりました。', }, addPersonalBankAccountPage: { enterPassword: 'Expensify のパスワードを入力', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index afc5fcd8eb90c..2386435f03da0 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3287,6 +3287,11 @@ ${ confirmationStepHeader: 'Controleer je gegevens.', confirmationStepSubHeader: 'Controleer de onderstaande gegevens goed en vink het vakje met de voorwaarden aan om te bevestigen.', toGetStarted: 'Voeg een persoonlijke bankrekening toe om terugbetalingen te ontvangen, facturen te betalen of de Expensify Wallet in te schakelen.', + updatePersonalInfo: 'Bankrekening bijwerken', + updatePersonalInfoFailure: 'Kan de bankrekeninggegevens niet bijwerken. Probeer het later opnieuw.', + updateSuccessTitle: 'Bankrekening bijgewerkt!', + updateSuccessHeader: 'Bankrekening bijgewerkt', + updateSuccessMessage: 'Gefeliciteerd, je bankrekening is ingesteld en klaar om terugbetalingen te ontvangen.', }, addPersonalBankAccountPage: { enterPassword: 'Voer Expensify-wachtwoord in', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index f599403fbbfd4..817ace7717122 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3280,6 +3280,11 @@ ${ confirmationStepHeader: 'Sprawdź swoje dane.', confirmationStepSubHeader: 'Sprawdź poniższe szczegóły i zaznacz pole z warunkami, aby potwierdzić.', toGetStarted: 'Dodaj osobiste konto bankowe, aby otrzymywać zwroty wydatków, opłacać faktury lub włączyć Portfel Expensify.', + updatePersonalInfo: 'Aktualizuj konto bankowe', + updatePersonalInfoFailure: 'Nie można zaktualizować informacji o koncie bankowym. Spróbuj ponownie później.', + updateSuccessTitle: 'Konto bankowe zaktualizowane!', + updateSuccessHeader: 'Konto bankowe zaktualizowane', + updateSuccessMessage: 'Gratulacje, Twoje konto bankowe jest skonfigurowane i gotowe do przyjmowania zwrotów.', }, addPersonalBankAccountPage: { enterPassword: 'Wpisz hasło do Expensify', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 37a71eca1c382..6c5f66bec5b8e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3279,6 +3279,11 @@ ${ confirmationStepHeader: 'Verifique suas informações.', confirmationStepSubHeader: 'Verifique os detalhes abaixo e marque a caixa de termos para confirmar.', toGetStarted: 'Adicione uma conta bancária pessoal para receber reembolsos, pagar faturas ou ativar a Carteira Expensify.', + updatePersonalInfo: 'Atualizar conta bancária', + updatePersonalInfoFailure: 'Não foi possível atualizar as informações da conta bancária. Por favor, tente novamente mais tarde.', + updateSuccessTitle: 'Conta bancária atualizada!', + updateSuccessHeader: 'Conta bancária atualizada', + updateSuccessMessage: 'Parabéns, sua conta bancária está configurada e pronta para receber reembolsos.', }, addPersonalBankAccountPage: { enterPassword: 'Insira a senha do Expensify', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8f83764a425e4..026a019b201b3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3223,6 +3223,11 @@ ${ confirmationStepHeader: '请检查您的信息。', confirmationStepSubHeader: '请仔细核对以下详细信息,并勾选条款复选框以确认。', toGetStarted: '添加个人银行账户以接收报销、支付发票或启用 Expensify 钱包。', + updatePersonalInfo: '更新银行账户', + updatePersonalInfoFailure: '无法更新银行账户信息。请稍后重试。', + updateSuccessTitle: '银行账户已更新!', + updateSuccessHeader: '银行账户已更新', + updateSuccessMessage: '恭喜,您的银行账户已设置完成,可以开始接收报销款了。', }, addPersonalBankAccountPage: { enterPassword: '输入 Expensify 密码', diff --git a/src/libs/API/parameters/UpdatePersonalBankAccountInfoParams.ts b/src/libs/API/parameters/UpdatePersonalBankAccountInfoParams.ts new file mode 100644 index 0000000000000..79f5aa4b3a97f --- /dev/null +++ b/src/libs/API/parameters/UpdatePersonalBankAccountInfoParams.ts @@ -0,0 +1,12 @@ +type UpdatePersonalBankAccountInfoParams = { + bankAccountID: number; + companyPhone: string; + legalFirstName: string; + legalLastName: string; + addressStreet: string; + addressCity: string; + addressState: string; + addressZip: string; +}; + +export default UpdatePersonalBankAccountInfoParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index a76a4247b2a5c..ae379efd0e465 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -147,6 +147,7 @@ export type {default as FlagCommentParams} from './FlagCommentParams'; export type {default as UpdateReportPrivateNoteParams} from './UpdateReportPrivateNoteParams'; export type {default as UpdateCompanyInformationForBankAccountParams} from './UpdateCompanyInformationForBankAccountParams'; export type {default as UpdatePersonalDetailsForWalletParams} from './UpdatePersonalDetailsForWalletParams'; +export type {default as UpdatePersonalBankAccountInfoParams} from './UpdatePersonalBankAccountInfoParams'; export type {default as VerifyIdentityParams} from './VerifyIdentityParams'; export type {default as AcceptWalletTermsParams} from './AcceptWalletTermsParams'; export type {default as ChronosRemoveOOOEventParams} from './ChronosRemoveOOOEventParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f99609208270a..94fb3b8ac0f26 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -116,6 +116,7 @@ const WRITE_COMMANDS = { ADD_TEXT_AND_ATTACHMENT: 'AddTextAndAttachment', CONNECT_BANK_ACCOUNT_WITH_PLAID: 'ConnectBankAccountWithPlaid', ADD_PERSONAL_BANK_ACCOUNT: 'AddPersonalBankAccount', + UPDATE_PERSONAL_BANK_ACCOUNT_INFO: 'UpdatePersonalBankAccountInfo', RESTART_BANK_ACCOUNT_SETUP: 'RestartBankAccountSetup', RESEND_VALIDATE_CODE: 'ResendValidateCode', READ_NEWEST_ACTION: 'ReadNewestAction', @@ -671,6 +672,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]: Parameters.AddCommentOrAttachmentParams; [WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_WITH_PLAID]: Parameters.ConnectBankAccountParams; [WRITE_COMMANDS.ADD_PERSONAL_BANK_ACCOUNT]: Parameters.AddPersonalBankAccountParams; + [WRITE_COMMANDS.UPDATE_PERSONAL_BANK_ACCOUNT_INFO]: Parameters.UpdatePersonalBankAccountInfoParams; [WRITE_COMMANDS.RESTART_BANK_ACCOUNT_SETUP]: Parameters.RestartBankAccountSetupParams; [WRITE_COMMANDS.RESEND_VALIDATE_CODE]: null; [WRITE_COMMANDS.READ_NEWEST_ACTION]: Parameters.ReadNewestActionParams; diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index 5dbf4f662ec15..e73a0aa09e364 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -2,6 +2,7 @@ import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; +import type AccountData from '@src/types/onyx/AccountData'; function getDefaultCompanyWebsite(session: OnyxEntry, account: OnyxEntry, shouldShowPublicDomain = false): string { return account?.isFromPublicDomain && !shouldShowPublicDomain ? '' : `https://www.${Str.extractEmailDomain(session?.email ?? '')}`; @@ -30,4 +31,89 @@ function hasPartiallySetupBankAccount(bankAccountList: OnyxEntry isBankAccountPartiallySetup(bankAccount?.accountData?.state)); } -export {getDefaultCompanyWebsite, getLastFourDigits, hasPartiallySetupBankAccount, isBankAccountPartiallySetup, doesPolicyHavePartiallySetupBankAccount}; +/** + * Step numbers for the personal bank account update flow. + */ +const PERSONAL_INFO_STEP = { + NAME: 1, + ADDRESS: 2, + PHONE: 3, +} as const; + +type AdditionalData = AccountData['additionalData']; + +function hasOwnerName(additionalData: AdditionalData): boolean { + return !!additionalData?.firstName && !!additionalData?.lastName; +} + +function hasOwnerAddress(additionalData: AdditionalData): boolean { + return !!additionalData?.addressStreet && !!additionalData?.addressCity && !!additionalData?.addressState && !!additionalData?.addressZipCode; +} + +function hasOwnerPhone(additionalData: AdditionalData): boolean { + return !!additionalData?.companyPhone; +} + +/** + * Check if a US personal bank account in OPEN state is missing required personal information. + */ +function isPersonalBankAccountMissingInfo(accountData: AccountData | undefined): boolean { + if (accountData?.type !== CONST.BANK_ACCOUNT.TYPE.PERSONAL) { + return false; + } + + if (accountData.state !== CONST.BANK_ACCOUNT.STATE.OPEN) { + return false; + } + + // additionalData.country is optional — legacy US accounts may omit it. + // Mirror BankAccount.getCountry() which defaults to US when absent. + const country = accountData.additionalData?.country ?? CONST.COUNTRY.US; + if (country !== CONST.COUNTRY.US) { + return false; + } + + const {additionalData} = accountData; + return !hasOwnerName(additionalData) || !hasOwnerAddress(additionalData) || !hasOwnerPhone(additionalData); +} + +/** + * Returns step numbers that already have data on the bank account and can be skipped in the update flow. + */ +function getCompletedStepsForBankAccount(bankAccountList: OnyxEntry, bankAccountID: number): number[] { + const bankAccount = bankAccountList?.[String(bankAccountID)]; + if (!bankAccount) { + return []; + } + + const {additionalData} = bankAccount.accountData ?? {}; + const completedSteps: number[] = []; + + if (hasOwnerName(additionalData)) { + completedSteps.push(PERSONAL_INFO_STEP.NAME); + } + if (hasOwnerAddress(additionalData)) { + completedSteps.push(PERSONAL_INFO_STEP.ADDRESS); + } + if (hasOwnerPhone(additionalData)) { + completedSteps.push(PERSONAL_INFO_STEP.PHONE); + } + + return completedSteps; +} + +function hasPersonalBankAccountMissingInfo(bankAccountList: OnyxEntry): boolean { + return Object.values(bankAccountList ?? {}).some((bankAccount) => isPersonalBankAccountMissingInfo(bankAccount?.accountData)); +} + +export { + getDefaultCompanyWebsite, + getLastFourDigits, + hasPartiallySetupBankAccount, + hasPersonalBankAccountMissingInfo, + isBankAccountPartiallySetup, + doesPolicyHavePartiallySetupBankAccount, + isPersonalBankAccountMissingInfo, + getCompletedStepsForBankAccount, + PERSONAL_INFO_STEP, +}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index a5533e1671914..5acb5a578c72c 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -446,6 +446,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/AddPersonalBankAccountPage').default, [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT_ENTRY_POINT]: () => require('../../../../pages/settings/Wallet/InternationalDepositAccount/subPages/AccountFlowEntryPoint').default, + [SCREENS.SETTINGS.UPDATE_PERSONAL_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/UpdatePersonalBankAccountPage').default, [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_SELECT_COUNTRY_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/InternationalDepositAccount/CountrySelectionVerifyAccountPage').default, [SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: () => require('../../../../pages/settings/Wallet/BankAccountPurposePage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index 44b4a085a26c1..0e7893fc4f6c0 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -57,6 +57,7 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_ADD_US_BANK_ACCOUNT_ENTRY_POINT, exact: true, }, + [SCREENS.SETTINGS.UPDATE_PERSONAL_BANK_ACCOUNT]: { + path: ROUTES.SETTINGS_UPDATE_PERSONAL_BANK_ACCOUNT.route, + }, [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_SELECT_COUNTRY_VERIFY_ACCOUNT]: { path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT_SELECT_COUNTRY_VERIFY_ACCOUNT, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 202dfbfce6017..b5443ba2d87ca 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -218,6 +218,9 @@ type SettingsNavigatorParamList = { }; [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: undefined; [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT_ENTRY_POINT]: undefined; + [SCREENS.SETTINGS.UPDATE_PERSONAL_BANK_ACCOUNT]: { + subPage?: string; + }; [SCREENS.SETTINGS.BANK_ACCOUNT_PURPOSE]: undefined; [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_SELECT_COUNTRY_VERIFY_ACCOUNT]: undefined; [SCREENS.SETTINGS.RULES.ADD]: undefined; diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index af19c37c49249..a824c531d9b35 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -17,6 +17,7 @@ import type { ShareBankAccountAndSetPayerParams, ShareBankAccountParams, UnshareBankAccountParams, + UpdatePersonalBankAccountInfoParams, ValidateBankAccountWithTransactionsParams, VerifyIdentityForBankAccountParams, } from '@libs/API/parameters'; @@ -37,6 +38,7 @@ import type {Route} from '@src/ROUTES'; import type {InternationalBankAccountForm, PersonalBankAccountForm} from '@src/types/form'; import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, ReimbursementAccountForm, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; import type {BankAccountList, LastPaymentMethod, LastPaymentMethodType, PersonalBankAccount} from '@src/types/onyx'; +import type {BankAccountAdditionalData} from '@src/types/onyx/BankAccount'; import type PlaidBankAccount from '@src/types/onyx/PlaidBankAccount'; import type {BankAccountStep, ReimbursementAccountStep, ReimbursementAccountSubStep} from '@src/types/onyx/ReimbursementAccount'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -150,6 +152,107 @@ function clearPersonalBankAccountErrors() { Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {errors: null}); } +type PersonalBankAccountUpdateData = Pick< + PersonalBankAccountForm, + 'legalFirstName' | 'legalLastName' | 'addressStreet' | 'addressStreet2' | 'addressCity' | 'addressState' | 'addressZipCode' | 'phoneNumber' +>; + +function updatePersonalBankAccountInfo(bankAccountID: number, accountData: PersonalBankAccountUpdateData) { + const formattedStreet = getFormattedStreet(accountData.addressStreet, accountData.addressStreet2); + + const bankAccountKey = String(bankAccountID); + const prevData = bankAccountList?.[bankAccountKey]?.accountData?.additionalData; + + type AdditionalDataFields = Pick; + + const additionalDataUpdate: AdditionalDataFields = { + firstName: accountData.legalFirstName, + lastName: accountData.legalLastName, + addressStreet: formattedStreet, + addressCity: accountData.addressCity, + addressState: accountData.addressState, + addressZipCode: accountData.addressZipCode, + companyPhone: accountData.phoneNumber, + }; + + const parameters: UpdatePersonalBankAccountInfoParams = { + bankAccountID, + companyPhone: accountData.phoneNumber, + legalFirstName: accountData.legalFirstName, + legalLastName: accountData.legalLastName, + addressStreet: formattedStreet, + addressCity: accountData.addressCity, + addressState: accountData.addressState, + addressZip: accountData.addressZipCode, + }; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, + value: { + isLoading: true, + errors: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: { + [bankAccountKey]: { + accountData: { + additionalData: additionalDataUpdate, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, + value: { + isLoading: false, + errors: null, + shouldShowSuccess: true, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, + value: { + isLoading: false, + errors: getMicroSecondOnyxErrorWithTranslationKey('addPersonalBankAccount.updatePersonalInfoFailure'), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: { + [bankAccountKey]: { + accountData: { + additionalData: { + firstName: prevData?.firstName ?? null, + lastName: prevData?.lastName ?? null, + addressStreet: prevData?.addressStreet ?? null, + addressCity: prevData?.addressCity ?? null, + addressState: prevData?.addressState ?? null, + addressZipCode: prevData?.addressZipCode ?? null, + companyPhone: prevData?.companyPhone ?? null, + }, + }, + }, + }, + }, + ], + }; + + API.write(WRITE_COMMANDS.UPDATE_PERSONAL_BANK_ACCOUNT_INFO, parameters, onyxData); +} + /** * Whether after adding a bank account we should continue with the KYC flow. If so, we must specify the fallback route. */ @@ -164,6 +267,14 @@ function clearPersonalBankAccount() { clearPersonalBankAccountSetupType(); } +/** Atomically resets personal bank account state and seeds draft values using Onyx.set to avoid set/merge races. */ +function resetPersonalBankAccountForUpdate(bankAccountID: number, personalBankAccountDraft?: Partial, homeAddressDraft?: Record) { + clearPlaid(); + Onyx.set(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {bankAccountID}); + Onyx.set(ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM_DRAFT, personalBankAccountDraft ?? null); + Onyx.set(ONYXKEYS.FORMS.HOME_ADDRESS_FORM_DRAFT, homeAddressDraft ?? null); +} + function clearOnfidoToken() { Onyx.merge(ONYXKEYS.ONFIDO_TOKEN, ''); Onyx.merge(ONYXKEYS.ONFIDO_APPLICANT_ID, ''); @@ -1545,6 +1656,7 @@ export { addPersonalBankAccount, clearOnfidoToken, clearPersonalBankAccount, + resetPersonalBankAccountForUpdate, setPlaidEvent, openPlaidView, connectBankAccountManually, @@ -1596,4 +1708,5 @@ export { getBankAccountFromID, openBankAccountSharePage, clearShareBankAccountErrors, + updatePersonalBankAccountInfo, }; diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 597e952e408b4..d432d3e32c8dd 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -38,7 +38,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {resetExitSurveyForm} from '@libs/actions/ExitSurvey'; import {closeReactNativeApp} from '@libs/actions/HybridApp'; -import {hasPartiallySetupBankAccount} from '@libs/BankAccountUtils'; +import {hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; import {hasPendingExpensifyCardAction} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import useIsSidebarRouteActive from '@libs/Navigation/helpers/useIsSidebarRouteActive'; @@ -171,7 +171,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr shouldShowRBRForPersonalCard ) { walletBrickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } else if (hasPartiallySetupBankAccount(bankAccountList) || hasPendingCardAction) { + } else if (hasPartiallySetupBankAccount(bankAccountList) || hasPersonalBankAccountMissingInfo(bankAccountList) || hasPendingCardAction) { walletBrickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/AddressStep.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/AddressStep.tsx index d746fda88a887..ecba9775df7be 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/AddressStep.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/AddressStep.tsx @@ -16,12 +16,24 @@ import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/HomeAddressForm'; import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; -function AddressStep({onNext, isEditing}: SubStepProps) { +type AddressStepProps = SubStepProps & { + /** Whether to persist field values as draft on keystroke */ + shouldSaveDraft?: boolean; + + /** Whether to hide the country selector (e.g. when country cannot be changed) */ + shouldHideCountrySelector?: boolean; + + /** Whether the form submit button should be enabled when offline */ + enabledWhenOffline?: boolean; +}; + +function AddressStep({onNext, isEditing, shouldSaveDraft = false, shouldHideCountrySelector = false, enabledWhenOffline}: AddressStepProps) { const styles = useThemeStyles(); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); const [defaultCountry] = useOnyx(ONYXKEYS.COUNTRY); const [bankAccountPersonalDetails] = useOnyx(ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM_DRAFT); + const [homeAddressFormDraft] = useOnyx(ONYXKEYS.FORMS.HOME_ADDRESS_FORM_DRAFT); const address = useMemo(() => { const normalizedAddress = normalizeCountryCode(getCurrentAddress(privatePersonalDetails)) as Address; @@ -43,13 +55,16 @@ function AddressStep({onNext, isEditing}: SubStepProps) { bankAccountPersonalDetails?.country, privatePersonalDetails, ]); + + const draftCountry = shouldSaveDraft ? homeAddressFormDraft?.country : undefined; + const draftState = shouldSaveDraft ? homeAddressFormDraft?.state : undefined; const {translate} = useLocalize(); // Check if country is valid const {street} = address ?? {}; const [street1, street2] = street ? street.split('\n') : [undefined, undefined]; - const [currentCountry, setCurrentCountry] = useState(address?.country ?? defaultCountry ?? CONST.COUNTRY.US); - const [state, setState] = useState(address?.state); + const [currentCountry, setCurrentCountry] = useState(draftCountry ?? address?.country ?? defaultCountry ?? CONST.COUNTRY.US); + const [state, setState] = useState(draftState ?? address?.state); const [city, setCity] = useState(address?.city); const [zipcode, setZipcode] = useState(address?.zip); @@ -57,11 +72,11 @@ function AddressStep({onNext, isEditing}: SubStepProps) { if (!address) { return; } - setState(address?.state); - setCurrentCountry(address?.country); + setState(draftState ?? address?.state); + setCurrentCountry(draftCountry ?? address?.country); setCity(address?.city); setZipcode(address?.zip); - }, [address?.state, address?.country, address?.city, address?.zip, address]); + }, [address?.state, address?.country, address?.city, address?.zip, address, draftCountry, draftState]); const handleAddressChange = (value: unknown, key: unknown) => { const addressPart = value as string; @@ -120,6 +135,9 @@ function AddressStep({onNext, isEditing}: SubStepProps) { street1={street1} street2={street2} zip={zipcode} + shouldSaveDraft={shouldSaveDraft} + shouldHideCountrySelector={shouldHideCountrySelector} + enabledWhenOffline={enabledWhenOffline} /> ); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/LegalNameStep.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/LegalNameStep.tsx index f9c383e19c3b7..d92fb65fd8cb5 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/LegalNameStep.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/LegalNameStep.tsx @@ -10,7 +10,12 @@ import INPUT_IDS from '@src/types/form/PersonalBankAccountForm'; const PERSONAL_INFO_STEP_KEY = INPUT_IDS.BANK_INFO_STEP; const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.FIRST_NAME, PERSONAL_INFO_STEP_KEY.LAST_NAME]; -function LegalNameStep({onNext, onMove, isEditing}: SubStepProps) { +type LegalNameStepProps = SubStepProps & { + /** Whether the form submit button should be enabled when offline */ + enabledWhenOffline?: boolean; +}; + +function LegalNameStep({onNext, onMove, isEditing, enabledWhenOffline}: LegalNameStepProps) { const {translate} = useLocalize(); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); @@ -39,6 +44,7 @@ function LegalNameStep({onNext, onMove, isEditing}: SubStepProps) { firstNameInputID={PERSONAL_INFO_STEP_KEY.FIRST_NAME} lastNameInputID={PERSONAL_INFO_STEP_KEY.LAST_NAME} defaultValues={defaultValues} + enabledWhenOffline={enabledWhenOffline} /> ); } diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/PhoneNumberStep.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/PhoneNumberStep.tsx index 2b7b00323a993..bf1609ee7ae2c 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/PhoneNumberStep.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/PhoneNumberStep.tsx @@ -14,7 +14,15 @@ import INPUT_IDS from '@src/types/form/PersonalBankAccountForm'; const PERSONAL_INFO_STEP_KEY = INPUT_IDS.BANK_INFO_STEP; const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.PHONE_NUMBER]; -function PhoneNumberStep({onNext, onMove, isEditing}: SubStepProps) { +type PhoneNumberStepProps = SubStepProps & { + /** Whether to delay auto-focusing the input to avoid conflicts with navigation animations */ + shouldDelayAutoFocus?: boolean; + + /** Whether the form submit button should be enabled when offline */ + enabledWhenOffline?: boolean; +}; + +function PhoneNumberStep({onNext, onMove, isEditing, shouldDelayAutoFocus, enabledWhenOffline = true}: PhoneNumberStepProps) { const {translate} = useLocalize(); const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); @@ -58,7 +66,8 @@ function PhoneNumberStep({onNext, onMove, isEditing}: SubStepProps) { inputLabel={translate('common.phoneNumber')} inputMode={CONST.INPUT_MODE.TEL} defaultValue={defaultPhoneNumber} - enabledWhenOffline + shouldDelayAutoFocus={shouldDelayAutoFocus} + enabledWhenOffline={enabledWhenOffline} /> ); } diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 28a0b680ac304..87caa45028a3d 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -21,6 +21,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; +import {isPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; import { getAssignedCardSortKey, getCardFeedIcon, @@ -442,6 +443,8 @@ function PaymentMethodList({ methodID: paymentMethod.methodID, description: paymentMethod.description, }; + const isMissingPersonalInfo = isPersonalBankAccountMissingInfo(paymentMethod.accountData); + return { ...paymentMethod, title: paymentMethod.title?.includes(CONST.MASKED_PAN_PREFIX) ? paymentMethod.accountData?.additionalData?.bankName : paymentMethod.title, @@ -462,6 +465,7 @@ function PaymentMethodList({ iconRight: itemIconRight ?? expensifyIcons.ThreeDots, shouldShowRightIcon, canDismissError: true, + isMissingPersonalInfo, }; }); return combinedPaymentMethods; diff --git a/src/pages/settings/Wallet/PaymentMethodListItem.tsx b/src/pages/settings/Wallet/PaymentMethodListItem.tsx index bfc065c0223f3..1ab96e4f9d029 100644 --- a/src/pages/settings/Wallet/PaymentMethodListItem.tsx +++ b/src/pages/settings/Wallet/PaymentMethodListItem.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useRef} from 'react'; +import React, {useRef} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -45,6 +45,8 @@ type PaymentMethodItem = PaymentMethod & { cardID?: number; plaidUrl?: string; onThreeDotsMenuPress?: (e: GestureResponderEvent | KeyboardEvent | undefined) => void; + /** Whether the personal bank account is missing required personal info (name, address, phone) */ + isMissingPersonalInfo?: boolean; isCardFrozen?: boolean; } & BankIcon; @@ -98,6 +100,10 @@ function isAccountInSetupState(account: PaymentMethodItem) { return !!(account.accountData && 'state' in account.accountData && isBankAccountPartiallySetup(account.accountData.state)); } +function isAccountNeedingAction(account: PaymentMethodItem) { + return isAccountInSetupState(account) || !!account.isMissingPersonalInfo; +} + function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems, listItemStyle}: PaymentMethodListItemProps) { const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'FreezeCard', 'QuestionMark']); const theme = useTheme(); @@ -106,8 +112,8 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems const {shouldUseNarrowLayout} = useResponsiveLayout(); const threeDotsMenuRef = useRef<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean; onThreeDotsPress: () => void}>(null); - const isInSetupState = isAccountInSetupState(item); const showThreeDotsMenu = item.shouldShowThreeDotsMenu !== false && !!threeDotsMenuItems; + const isNeedingAction = isAccountNeedingAction(item); // Check if this is a Chase personal bank account connected via Plaid const isChaseAccountConnectedViaPlaid = @@ -116,32 +122,28 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems !!(item.accountData?.additionalData?.plaidAccountID ?? item.accountData?.plaidAccountID); const handleRowPress = (e: GestureResponderEvent | KeyboardEvent | undefined) => { - if (!showThreeDotsMenu || (item.cardID && item.onThreeDotsMenuPress) || isInSetupState) { + if (isNeedingAction || !showThreeDotsMenu || (item.cardID && item.onThreeDotsMenuPress)) { item.onPress?.(e); } else if (threeDotsMenuRef.current) { threeDotsMenuRef.current.onThreeDotsPress(); } }; - const badgeText = useMemo(() => { - if (isInSetupState) { - return translate('common.actionRequired'); - } - if (item.isCardFrozen) { - return translate('cardPage.frozen'); - } - return shouldShowDefaultBadge ? translate('paymentMethodList.defaultPaymentMethod') : undefined; - }, [isInSetupState, item.isCardFrozen, shouldShowDefaultBadge, translate]); + let badgeText; + if (isNeedingAction) { + badgeText = translate('common.actionRequired'); + } else if (item.isCardFrozen) { + badgeText = translate('cardPage.frozen'); + } else if (shouldShowDefaultBadge) { + badgeText = translate('paymentMethodList.defaultPaymentMethod'); + } - const badgeIcon = useMemo(() => { - if (isInSetupState) { - return icons.DotIndicator; - } - if (item.isCardFrozen) { - return icons.FreezeCard; - } - return undefined; - }, [icons.DotIndicator, icons.FreezeCard, isInSetupState, item.isCardFrozen]); + let badgeIcon; + if (isNeedingAction) { + badgeIcon = icons.DotIndicator; + } else if (item.isCardFrozen) { + badgeIcon = icons.FreezeCard; + } return ( + ); +} +UpdateLegalName.displayName = 'UpdateLegalName'; + +function AddressWithDraft({isEditing, onNext, onMove}: SubStepProps) { + return ( +
+ ); +} +AddressWithDraft.displayName = 'AddressWithDraft'; + +function DelayedPhoneNumber({isEditing, onNext, onMove}: SubStepProps) { + return ( + + ); +} +DelayedPhoneNumber.displayName = 'DelayedPhoneNumber'; + +const formPages = [ + {pageName: PAGE_NAME.LEGAL_NAME, component: UpdateLegalName}, + {pageName: PAGE_NAME.ADDRESS, component: AddressWithDraft}, + {pageName: PAGE_NAME.PHONE_NUMBER, component: DelayedPhoneNumber}, +]; + +function getPageNamesForCompletedSteps(completedSteps: number[]): string[] { + return completedSteps.map((step) => PAGE_NAMES.at(step - 1)).filter((name): name is string => !!name); +} + +function getFirstPageName(bankAccountList?: OnyxEntry, bankAccountID?: number): string { + const completedSteps = bankAccountID ? getCompletedStepsForBankAccount(bankAccountList, bankAccountID) : []; + const skipPageNames = new Set(getPageNamesForCompletedSteps(completedSteps)); + const firstPage = PAGE_NAMES.find((name) => !skipPageNames.has(name)); + return firstPage ?? PAGE_NAME.LEGAL_NAME; +} + +function UpdatePersonalBankAccountPage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + const [personalBankAccountDraft] = useOnyx(ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM_DRAFT); + const [homeAddressDraft] = useOnyx(ONYXKEYS.FORMS.HOME_ADDRESS_FORM_DRAFT); + const [personalBankAccount, personalBankAccountResult] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + + const shouldShowSuccess = personalBankAccount?.shouldShowSuccess ?? false; + const bankAccountID = personalBankAccount?.bankAccountID; + const isPersonalBankAccountLoaded = personalBankAccountResult.status === 'loaded'; + + useEffect(() => clearPersonalBankAccount, []); + + useEffect(() => { + if (!isPersonalBankAccountLoaded || bankAccountID) { + return; + } + + Navigation.goBack(ROUTES.SETTINGS_WALLET); + }, [isPersonalBankAccountLoaded, bankAccountID]); + + const completedSteps = bankAccountID ? getCompletedStepsForBankAccount(bankAccountList, bankAccountID) : []; + + const exitFlow = () => { + clearPersonalBankAccount(); + clearDraftValues(ONYXKEYS.FORMS.HOME_ADDRESS_FORM); + clearDraftValues(ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM); + Navigation.goBack(ROUTES.SETTINGS_WALLET); + }; + + const submitPersonalInfo = () => { + if (!personalBankAccount?.bankAccountID || personalBankAccount?.isLoading) { + return; + } + + const existingData = bankAccountList?.[String(personalBankAccount.bankAccountID)]?.accountData?.additionalData; + const currentAddress = getCurrentAddress(privatePersonalDetails); + const [street1, street2] = getStreetLines(currentAddress?.street); + + const legalFirstName = personalBankAccountDraft?.legalFirstName ?? privatePersonalDetails?.legalFirstName ?? existingData?.firstName ?? ''; + const legalLastName = personalBankAccountDraft?.legalLastName ?? privatePersonalDetails?.legalLastName ?? existingData?.lastName ?? ''; + + const addressStreet = personalBankAccountDraft?.addressStreet ?? homeAddressDraft?.addressLine1 ?? street1 ?? existingData?.addressStreet ?? ''; + const addressStreet2 = personalBankAccountDraft?.addressStreet2 ?? homeAddressDraft?.addressLine2 ?? street2 ?? ''; + const addressCity = personalBankAccountDraft?.addressCity ?? homeAddressDraft?.city ?? currentAddress?.city ?? existingData?.addressCity ?? ''; + const addressState = personalBankAccountDraft?.addressState ?? homeAddressDraft?.state ?? currentAddress?.state ?? existingData?.addressState ?? ''; + const addressZipCode = personalBankAccountDraft?.addressZipCode ?? homeAddressDraft?.zipPostCode ?? currentAddress?.zip ?? existingData?.addressZipCode ?? ''; + + const rawPhone = personalBankAccountDraft?.phoneNumber ?? privatePersonalDetails?.phoneNumber ?? existingData?.companyPhone ?? ''; + const parsed = parsePhoneNumber(rawPhone, {regionCode: CONST.COUNTRY.US}); + const phoneNumber = parsed.number?.significant ?? ''; + + updatePersonalBankAccountInfo(personalBankAccount.bankAccountID, { + legalFirstName, + legalLastName, + addressStreet, + addressStreet2, + addressCity, + addressState, + addressZipCode, + phoneNumber, + }); + }; + const skipPageCandidates = getPageNamesForCompletedSteps(completedSteps); + if (skipPageCandidates.length >= formPages.length) { + Log.hmmm('[UpdatePersonalBankAccountPage] All steps already completed but user reached update flow', {bankAccountID}); + } + const skipPages = skipPageCandidates.length >= formPages.length ? [] : skipPageCandidates; + + const firstPageName = getFirstPageName(bankAccountList, personalBankAccount?.bankAccountID); + const firstNonSkippedIndex = formPages.findIndex((p) => p.pageName === firstPageName); + + const {CurrentPage, currentPageName, prevPage, nextPage} = useSubPage({ + pages: formPages, + onFinished: submitPersonalInfo, + skipPages, + startFrom: firstNonSkippedIndex >= 0 ? firstNonSkippedIndex : 0, + buildRoute: (pageName) => ROUTES.SETTINGS_UPDATE_PERSONAL_BANK_ACCOUNT.getRoute(pageName), + }); + + const handleBackButtonPress = () => { + clearPersonalBankAccountErrors(); + if (currentPageName === firstPageName) { + exitFlow(); + return; + } + prevPage(); + }; + + const handleNextPage = () => { + clearPersonalBankAccountErrors(); + nextPage(); + }; + + if (shouldShowSuccess) { + return ( + + + + + + + ); + } + + return ( + + + {}} + /> + + ); +} + +UpdatePersonalBankAccountPage.displayName = 'UpdatePersonalBankAccountPage'; + +export default UpdatePersonalBankAccountPage; +export {getFirstPageName}; diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index 8c36ecb0c7fe3..0496414fecdc8 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -35,16 +35,19 @@ import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {isPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; import {hasDisplayableAssignedCards, isDirectFeed, maskCardNumber} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods, getPaymentMethodDescription} from '@libs/PaymentUtils'; +import {getStreetLines} from '@libs/PersonalDetailsUtils'; import {getActiveAdminWorkspaces, getDescriptionForPolicyDomainCard, hasActiveAdminWorkspaces, hasEligibleActiveAdminFromWorkspaces, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import PaymentMethodList from '@pages/settings/Wallet/PaymentMethodList'; -import {deletePaymentBankAccount, openPersonalBankAccountSetupView, setPersonalBankAccountContinueKYCOnSuccess} from '@userActions/BankAccounts'; +import {getFirstPageName} from '@pages/settings/Wallet/UpdatePersonalBankAccountPage'; +import {deletePaymentBankAccount, openPersonalBankAccountSetupView, resetPersonalBankAccountForUpdate, setPersonalBankAccountContinueKYCOnSuccess} from '@userActions/BankAccounts'; import {deletePersonalCard} from '@userActions/Card'; import {close as closeModal} from '@userActions/Modal'; import {clearWalletError, clearWalletTermsError, deletePaymentCard, getPaymentMethods, makeDefaultPaymentMethod as makeDefaultPaymentMethodPaymentMethods} from '@userActions/PaymentMethods'; @@ -174,6 +177,34 @@ function WalletPage() { }; const onBankAccountRowPressed = ({accountData}: PaymentMethodPressHandlerParams) => { + if (isPersonalBankAccountMissingInfo(accountData) && accountData?.bankAccountID) { + const additionalData = accountData?.additionalData; + const [street1, street2] = additionalData?.addressStreet ? getStreetLines(additionalData.addressStreet) : []; + resetPersonalBankAccountForUpdate( + accountData.bankAccountID, + { + legalFirstName: additionalData?.firstName, + legalLastName: additionalData?.lastName, + addressStreet: street1, + addressStreet2: street2 ?? '', + addressCity: additionalData?.addressCity, + addressState: additionalData?.addressState, + addressZipCode: additionalData?.addressZipCode, + phoneNumber: additionalData?.companyPhone, + }, + { + addressLine1: street1, + addressLine2: street2 ?? '', + city: additionalData?.addressCity, + state: additionalData?.addressState, + zipPostCode: additionalData?.addressZipCode, + country: CONST.COUNTRY.US, + }, + ); + Navigation.navigate(ROUTES.SETTINGS_UPDATE_PERSONAL_BANK_ACCOUNT.getRoute(getFirstPageName(bankAccountList, accountData.bankAccountID))); + return; + } + const accountPolicyID = accountData?.additionalData?.policyID; const bankAccountID = accountData?.bankAccountID; diff --git a/src/types/onyx/BankAccount.ts b/src/types/onyx/BankAccount.ts index 54581f3a07168..518075a345da9 100644 --- a/src/types/onyx/BankAccount.ts +++ b/src/types/onyx/BankAccount.ts @@ -38,6 +38,27 @@ type BankAccountAdditionalData = { /** Powerform files */ achAuthorizationForm?: FileObject[]; }; + + /** First name of the bank account owner */ + firstName?: string; + + /** Last name of the bank account owner */ + lastName?: string; + + /** City of the bank account owner's address */ + addressCity?: string; + + /** State of the bank account owner's address */ + addressState?: string; + + /** Street address of the bank account owner */ + addressStreet?: string; + + /** Zip code of the bank account owner's address */ + addressZipCode?: string; + + /** Phone number of the bank account owner */ + companyPhone?: string; }; /** Model of bank account */ diff --git a/src/types/onyx/PersonalBankAccount.ts b/src/types/onyx/PersonalBankAccount.ts index 3625ea1e8c814..4f41824122477 100644 --- a/src/types/onyx/PersonalBankAccount.ts +++ b/src/types/onyx/PersonalBankAccount.ts @@ -29,6 +29,9 @@ type PersonalBankAccount = { /** If set, continue with the KYC flow after adding a PBA. This specifies the fallback route to use. */ onSuccessFallbackRoute?: Route; + + /** The bank account ID being updated in the personal info update flow */ + bankAccountID?: number; }; export default PersonalBankAccount; diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts new file mode 100644 index 0000000000000..46f4861ab03bf --- /dev/null +++ b/tests/unit/BankAccountUtilsTest.ts @@ -0,0 +1,379 @@ +import { + getCompletedStepsForBankAccount, + getDefaultCompanyWebsite, + getLastFourDigits, + hasPartiallySetupBankAccount, + hasPersonalBankAccountMissingInfo, + isBankAccountPartiallySetup, + isPersonalBankAccountMissingInfo, + PERSONAL_INFO_STEP, +} from '@libs/BankAccountUtils'; +import CONST from '@src/CONST'; +import type {Account, BankAccountList, Session} from '@src/types/onyx'; +import type AccountData from '@src/types/onyx/AccountData'; + +describe('BankAccountUtils', () => { + describe('isPersonalBankAccountMissingInfo', () => { + const completeAccountData: AccountData = { + type: CONST.BANK_ACCOUNT.TYPE.PERSONAL, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: { + country: CONST.COUNTRY.US, + firstName: 'John', + lastName: 'Doe', + addressStreet: '123 Main St', + addressCity: 'New York', + addressState: 'NY', + addressZipCode: '10001', + companyPhone: '+15551234567', + }, + } as AccountData; + + it('returns false for non-personal bank accounts', () => { + const accountData = { + ...completeAccountData, + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(false); + }); + + it('returns false for accounts not in OPEN state', () => { + const accountData = { + ...completeAccountData, + state: CONST.BANK_ACCOUNT.STATE.SETUP, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(false); + }); + + it('returns false for non-US accounts', () => { + const accountData = { + ...completeAccountData, + additionalData: {...completeAccountData.additionalData, country: 'CA'}, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(false); + }); + + it('defaults to US when country is undefined and returns false when all info is present', () => { + const accountData = { + ...completeAccountData, + additionalData: {...completeAccountData.additionalData, country: undefined}, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(false); + }); + + it('defaults to US when country is undefined and returns true when info is missing', () => { + const accountData = { + type: CONST.BANK_ACCOUNT.TYPE.PERSONAL, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {country: undefined}, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(true); + }); + + it('returns false when accountData is undefined', () => { + expect(isPersonalBankAccountMissingInfo(undefined)).toBe(false); + }); + + it('defaults to US when additionalData is undefined and returns true since all fields are missing', () => { + const accountData = { + type: CONST.BANK_ACCOUNT.TYPE.PERSONAL, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(true); + }); + + it.each([ + {field: 'firstName', override: {firstName: ''}}, + {field: 'lastName', override: {lastName: ''}}, + {field: 'addressStreet', override: {addressStreet: ''}}, + {field: 'addressCity', override: {addressCity: ''}}, + {field: 'addressState', override: {addressState: ''}}, + {field: 'addressZipCode', override: {addressZipCode: ''}}, + {field: 'companyPhone', override: {companyPhone: ''}}, + ])('returns true when $field is empty string', ({override}) => { + const accountData = { + ...completeAccountData, + additionalData: {...completeAccountData.additionalData, ...override}, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(true); + }); + + it.each([ + {field: 'firstName', override: {firstName: undefined}}, + {field: 'lastName', override: {lastName: undefined}}, + {field: 'addressStreet', override: {addressStreet: undefined}}, + {field: 'addressCity', override: {addressCity: undefined}}, + {field: 'addressState', override: {addressState: undefined}}, + {field: 'addressZipCode', override: {addressZipCode: undefined}}, + {field: 'companyPhone', override: {companyPhone: undefined}}, + ])('returns true when $field is undefined', ({override}) => { + const accountData = { + ...completeAccountData, + additionalData: {...completeAccountData.additionalData, ...override}, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(true); + }); + + it('returns true when all owner fields are missing (only country present)', () => { + const accountData = { + type: CONST.BANK_ACCOUNT.TYPE.PERSONAL, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {country: CONST.COUNTRY.US}, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(true); + }); + + it('returns true when name and phone are missing but address is present', () => { + const accountData = { + ...completeAccountData, + additionalData: { + country: CONST.COUNTRY.US, + addressStreet: '123 Main St', + addressCity: 'New York', + addressState: 'NY', + addressZipCode: '10001', + }, + } as AccountData; + expect(isPersonalBankAccountMissingInfo(accountData)).toBe(true); + }); + + it('returns false when all info is present', () => { + expect(isPersonalBankAccountMissingInfo(completeAccountData)).toBe(false); + }); + }); + + describe('getLastFourDigits', () => { + it.each([ + {input: '123456789012', expected: '9012', description: 'long account number'}, + {input: '123', expected: '123', description: 'less than 4 characters'}, + {input: '', expected: '', description: 'empty string'}, + {input: '1234', expected: '1234', description: 'exactly 4 characters'}, + ])('returns $expected for $description', ({input, expected}) => { + expect(getLastFourDigits(input)).toBe(expected); + }); + }); + + describe('isBankAccountPartiallySetup', () => { + it.each([ + {state: CONST.BANK_ACCOUNT.STATE.SETUP, name: 'SETUP'}, + {state: CONST.BANK_ACCOUNT.STATE.VERIFYING, name: 'VERIFYING'}, + {state: CONST.BANK_ACCOUNT.STATE.PENDING, name: 'PENDING'}, + ])('returns true for $name state', ({state}) => { + expect(isBankAccountPartiallySetup(state)).toBe(true); + }); + + it.each([ + {state: CONST.BANK_ACCOUNT.STATE.OPEN, name: 'OPEN'}, + {state: undefined, name: 'undefined'}, + {state: '', name: 'empty string'}, + ])('returns false for $name state', ({state}) => { + expect(isBankAccountPartiallySetup(state)).toBe(false); + }); + }); + + describe('hasPartiallySetupBankAccount', () => { + it('returns true when at least one account is in SETUP state', () => { + const bankAccountList = { + accountOne: {accountData: {state: CONST.BANK_ACCOUNT.STATE.OPEN}, bankCurrency: 'USD', bankCountry: 'US'}, + accountTwo: {accountData: {state: CONST.BANK_ACCOUNT.STATE.SETUP}, bankCurrency: 'USD', bankCountry: 'US'}, + } as unknown as BankAccountList; + expect(hasPartiallySetupBankAccount(bankAccountList)).toBe(true); + }); + + it('returns true when at least one account is in VERIFYING state', () => { + const bankAccountList = { + accountOne: {accountData: {state: CONST.BANK_ACCOUNT.STATE.VERIFYING}, bankCurrency: 'USD', bankCountry: 'US'}, + } as unknown as BankAccountList; + expect(hasPartiallySetupBankAccount(bankAccountList)).toBe(true); + }); + + it('returns false when all accounts are in OPEN state', () => { + const bankAccountList = { + accountOne: {accountData: {state: CONST.BANK_ACCOUNT.STATE.OPEN}, bankCurrency: 'USD', bankCountry: 'US'}, + accountTwo: {accountData: {state: CONST.BANK_ACCOUNT.STATE.OPEN}, bankCurrency: 'USD', bankCountry: 'US'}, + } as unknown as BankAccountList; + expect(hasPartiallySetupBankAccount(bankAccountList)).toBe(false); + }); + + it('returns false for empty bank account list', () => { + expect(hasPartiallySetupBankAccount({})).toBe(false); + }); + + it('returns false for null/undefined bank account list', () => { + expect(hasPartiallySetupBankAccount(undefined)).toBe(false); + }); + }); + + describe('getDefaultCompanyWebsite', () => { + it('returns website URL from email domain when not public domain', () => { + const session: Session = {email: 'user@company.com'} as Session; + const account: Account = {isFromPublicDomain: false} as Account; + expect(getDefaultCompanyWebsite(session, account)).toBe('https://www.company.com'); + }); + + it('returns empty string for public domain when shouldShowPublicDomain is false', () => { + const session: Session = {email: 'user@gmail.com'} as Session; + const account: Account = {isFromPublicDomain: true} as Account; + expect(getDefaultCompanyWebsite(session, account, false)).toBe(''); + }); + + it('returns website URL for public domain when shouldShowPublicDomain is true', () => { + const session: Session = {email: 'user@gmail.com'} as Session; + const account: Account = {isFromPublicDomain: true} as Account; + expect(getDefaultCompanyWebsite(session, account, true)).toBe('https://www.gmail.com'); + }); + + it('handles undefined session email', () => { + const session: Session = {} as Session; + const account: Account = {isFromPublicDomain: false} as Account; + expect(getDefaultCompanyWebsite(session, account)).toBe('https://www.'); + }); + + it('handles undefined session', () => { + const account: Account = {isFromPublicDomain: false} as Account; + expect(getDefaultCompanyWebsite(undefined, account)).toBe('https://www.'); + }); + }); + + describe('hasPersonalBankAccountMissingInfo', () => { + it('returns true when at least one account has missing info', () => { + const bankAccountList = { + accountOne: { + accountData: { + type: CONST.BANK_ACCOUNT.TYPE.PERSONAL, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {country: CONST.COUNTRY.US, firstName: 'John'}, + }, + bankCurrency: 'USD', + bankCountry: 'US', + }, + } as unknown as BankAccountList; + expect(hasPersonalBankAccountMissingInfo(bankAccountList)).toBe(true); + }); + + it('returns false when all accounts have complete info', () => { + const bankAccountList = { + accountOne: { + accountData: { + type: CONST.BANK_ACCOUNT.TYPE.PERSONAL, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: { + country: CONST.COUNTRY.US, + firstName: 'John', + lastName: 'Doe', + addressStreet: '123 Main St', + addressCity: 'New York', + addressState: 'NY', + addressZipCode: '10001', + companyPhone: '+15551234567', + }, + }, + bankCurrency: 'USD', + bankCountry: 'US', + }, + } as unknown as BankAccountList; + expect(hasPersonalBankAccountMissingInfo(bankAccountList)).toBe(false); + }); + + it('returns false for empty bank account list', () => { + expect(hasPersonalBankAccountMissingInfo({})).toBe(false); + }); + + it('returns false for undefined bank account list', () => { + expect(hasPersonalBankAccountMissingInfo(undefined)).toBe(false); + }); + }); + + describe('getCompletedStepsForBankAccount', () => { + const bankAccountID = 12345; + const bankAccountKey = String(bankAccountID); + + const fullAdditionalData = { + firstName: 'John', + lastName: 'Doe', + addressStreet: '123 Main St', + addressCity: 'New York', + addressState: 'NY', + addressZipCode: '10001', + companyPhone: '+15551234567', + }; + + it('returns all steps when all data is present', () => { + const bankAccountList = { + [bankAccountKey]: {accountData: {additionalData: fullAdditionalData}, bankCurrency: 'USD', bankCountry: 'US'}, + } as unknown as BankAccountList; + const result = getCompletedStepsForBankAccount(bankAccountList, bankAccountID); + expect(result).toEqual([PERSONAL_INFO_STEP.NAME, PERSONAL_INFO_STEP.ADDRESS, PERSONAL_INFO_STEP.PHONE]); + }); + + it('returns empty array when bank account is not found', () => { + const bankAccountList = {} as BankAccountList; + expect(getCompletedStepsForBankAccount(bankAccountList, bankAccountID)).toEqual([]); + }); + + it('returns empty array when bankAccountList is undefined', () => { + expect(getCompletedStepsForBankAccount(undefined, bankAccountID)).toEqual([]); + }); + + it('returns only NAME step when only name fields are present', () => { + const bankAccountList = { + [bankAccountKey]: {accountData: {additionalData: {firstName: 'John', lastName: 'Doe'}}, bankCurrency: 'USD', bankCountry: 'US'}, + } as unknown as BankAccountList; + expect(getCompletedStepsForBankAccount(bankAccountList, bankAccountID)).toEqual([PERSONAL_INFO_STEP.NAME]); + }); + + it('returns only ADDRESS step when only address fields are present', () => { + const bankAccountList = { + [bankAccountKey]: { + accountData: {additionalData: {addressStreet: '123 Main St', addressCity: 'New York', addressState: 'NY', addressZipCode: '10001'}}, + bankCurrency: 'USD', + bankCountry: 'US', + }, + } as unknown as BankAccountList; + expect(getCompletedStepsForBankAccount(bankAccountList, bankAccountID)).toEqual([PERSONAL_INFO_STEP.ADDRESS]); + }); + + it('returns only PHONE step when only phone is present', () => { + const bankAccountList = { + [bankAccountKey]: {accountData: {additionalData: {companyPhone: '+15551234567'}}, bankCurrency: 'USD', bankCountry: 'US'}, + } as unknown as BankAccountList; + expect(getCompletedStepsForBankAccount(bankAccountList, bankAccountID)).toEqual([PERSONAL_INFO_STEP.PHONE]); + }); + + it('returns empty array when accountData has no additionalData', () => { + const bankAccountList = { + [bankAccountKey]: {accountData: {}, bankCurrency: 'USD', bankCountry: 'US'}, + } as unknown as BankAccountList; + expect(getCompletedStepsForBankAccount(bankAccountList, bankAccountID)).toEqual([]); + }); + + it('does not include NAME when only firstName is present (lastName missing)', () => { + const bankAccountList = { + [bankAccountKey]: {accountData: {additionalData: {firstName: 'John'}}, bankCurrency: 'USD', bankCountry: 'US'}, + } as unknown as BankAccountList; + expect(getCompletedStepsForBankAccount(bankAccountList, bankAccountID)).toEqual([]); + }); + + it('returns multiple steps when some groups are complete', () => { + const bankAccountList = { + [bankAccountKey]: { + accountData: {additionalData: {firstName: 'John', lastName: 'Doe', companyPhone: '+15551234567'}}, + bankCurrency: 'USD', + bankCountry: 'US', + }, + } as unknown as BankAccountList; + expect(getCompletedStepsForBankAccount(bankAccountList, bankAccountID)).toEqual([PERSONAL_INFO_STEP.NAME, PERSONAL_INFO_STEP.PHONE]); + }); + + it('does not include ADDRESS when one address field is missing', () => { + const bankAccountList = { + [bankAccountKey]: { + accountData: {additionalData: {addressStreet: '123 Main St', addressCity: 'New York', addressState: 'NY'}}, + bankCurrency: 'USD', + bankCountry: 'US', + }, + } as unknown as BankAccountList; + expect(getCompletedStepsForBankAccount(bankAccountList, bankAccountID)).toEqual([]); + }); + }); +});