diff --git a/assets/images/bank-lock.svg b/assets/images/bank-lock.svg new file mode 100644 index 000000000000..fe1fc4268fb1 --- /dev/null +++ b/assets/images/bank-lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 75c44899d772..4b929e95dfec 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -19,6 +19,7 @@ import FallbackWorkspaceAvatar from '@assets/images/avatars/fallback-workspace-a import NotificationsAvatar from '@assets/images/avatars/notifications-avatar.svg'; import ActiveRoomAvatar from '@assets/images/avatars/room.svg'; import BackArrow from '@assets/images/back-left.svg'; +import BankLock from '@assets/images/bank-lock.svg'; import Bank from '@assets/images/bank.svg'; import Basket from '@assets/images/basket.svg'; import BedCircleSlash from '@assets/images/bed-circle-slash.svg'; @@ -278,6 +279,7 @@ const Expensicons = { AttachmentNotFound, BackArrow, Bank, + BankLock, Basket, CircularArrowBackwards, Bill, diff --git a/src/languages/de.ts b/src/languages/de.ts index 50ae1d7b15eb..d1ba8e937b65 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -959,6 +959,12 @@ const translations: TranslationDeepObject = { }, validateAccount: {title: 'Bestätigen Sie Ihr Konto, um Expensify weiter zu verwenden', subtitle: 'Konto', cta: 'Bestätigen'}, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, + unlockBankAccount: { + workspaceTitle: 'Ihr Geschäftskonto wurde gesperrt', + personalTitle: 'Ihr Bankkonto wurde gesperrt', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: 'Geldbörse', + }, }, assignedCards: 'Ihre Expensify Karten', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} verbleibend`, diff --git a/src/languages/en.ts b/src/languages/en.ts index e47a77dc3fd1..5b491bc05791 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1005,6 +1005,12 @@ const translations = { title: "We couldn't bill your card on file", subtitle: 'Subscription', }, + unlockBankAccount: { + workspaceTitle: 'Your business bank account has been locked', + personalTitle: 'Your bank account has been locked', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: 'Wallet', + }, }, freeTrialSection: { title: ({days}: {days: number}) => `Free trial: ${days} ${days === 1 ? 'day' : 'days'} left!`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 8f20942518ad..49369ca7bdc6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -866,7 +866,7 @@ const translations: TranslationDeepObject = { }, fixPersonalCardConnection: { title: ({cardName}: {cardName?: string}) => (cardName ? `Arreglar la conexión de la tarjeta personal de ${cardName}` : 'Arreglar la conexión de la tarjeta personal'), - subtitle: 'Monedero', + subtitle: 'Billetera', }, fixAccountingConnection: { title: ({integrationName}: {integrationName: string}) => `Reconectar con ${integrationName}`, @@ -903,6 +903,12 @@ const translations: TranslationDeepObject = { title: 'No pudimos cobrar a la tarjeta registrada.', subtitle: 'Suscripción', }, + unlockBankAccount: { + workspaceTitle: 'Tu cuenta bancaria empresarial ha sido bloqueada', + personalTitle: 'Tu cuenta bancaria ha sido bloqueada', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: 'Billetera', + }, }, freeTrialSection: { title: ({days}: {days: number}) => `Prueba gratuita: ${days} ${days === 1 ? 'día' : 'días'} restantes!`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1729f47218f1..fb51e087e2bf 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -962,6 +962,12 @@ const translations: TranslationDeepObject = { }, validateAccount: {title: 'Validez votre compte pour continuer à utiliser Expensify', subtitle: 'Compte', cta: 'Valider'}, fixFailedBilling: {title: 'Nous n’avons pas pu débiter votre carte enregistrée', subtitle: 'Abonnement'}, + unlockBankAccount: { + workspaceTitle: 'Votre compte bancaire professionnel a été verrouillé', + personalTitle: 'Votre compte bancaire a été verrouillé', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: 'Portefeuille', + }, }, assignedCards: 'Vos cartes Expensify', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} restant`, diff --git a/src/languages/it.ts b/src/languages/it.ts index e8063c6f98e2..e7af7a04a364 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -959,6 +959,12 @@ const translations: TranslationDeepObject = { }, validateAccount: {title: 'Conferma il tuo account per continuare a usare Expensify', subtitle: 'Account', cta: 'Conferma'}, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, + unlockBankAccount: { + workspaceTitle: 'Il conto bancario della tua azienda è stato bloccato', + personalTitle: 'Il tuo conto bancario è stato bloccato', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: 'Portafoglio', + }, }, assignedCards: 'Le tue Carte Expensify', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} rimanenti`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4cbb27914ecc..07c672281b1c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -942,6 +942,12 @@ const translations: TranslationDeepObject = { fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, validateAccount: {title: 'Expensify を引き続きご利用いただくには、アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, fixFailedBilling: {title: '登録されているカードから請求できませんでした', subtitle: 'サブスクリプション'}, + unlockBankAccount: { + workspaceTitle: 'ビジネス用銀行口座がロックされました', + personalTitle: 'あなたの銀行口座はロックされています', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: 'ウォレット', + }, }, assignedCards: 'お客様の Expensify カード', assignedCardsRemaining: ({amount}: {amount: string}) => `残額:${amount}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ae0bebb3b668..fabc408981d4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -958,6 +958,12 @@ const translations: TranslationDeepObject = { }, validateAccount: {title: 'Valideer je account om Expensify te blijven gebruiken', subtitle: 'Account', cta: 'Valideren'}, fixFailedBilling: {title: 'We konden je kaart in ons bestand niet belasten', subtitle: 'Abonnement'}, + unlockBankAccount: { + workspaceTitle: 'Je zakelijke bankrekening is geblokkeerd', + personalTitle: 'Je bankrekening is vergrendeld', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: 'Portemonnee', + }, }, assignedCards: 'Je Expensify Kaarten', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} resterend`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 88713cd6c5b7..e9278456a58a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -959,6 +959,12 @@ const translations: TranslationDeepObject = { }, validateAccount: {title: 'Zweryfikuj swoje konto, aby dalej korzystać z Expensify', subtitle: 'Konto', cta: 'Zatwierdź'}, fixFailedBilling: {title: 'Nie mogliśmy obciążyć zapisanej karty', subtitle: 'Subskrypcja'}, + unlockBankAccount: { + workspaceTitle: 'Twoje firmowe konto bankowe zostało zablokowane', + personalTitle: 'Twoje konto bankowe zostało zablokowane', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: 'Portfel', + }, }, assignedCards: 'Twoje Karty Expensify', assignedCardsRemaining: ({amount}: {amount: string}) => `Pozostało ${amount}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 727b38476f77..f213740a7f0d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -957,6 +957,12 @@ const translations: TranslationDeepObject = { }, validateAccount: {title: 'Valide sua conta para continuar usando o Expensify', subtitle: 'Conta', cta: 'Validar'}, fixFailedBilling: {title: 'Não foi possível cobrar o cartão cadastrado', subtitle: 'Assinatura'}, + unlockBankAccount: { + workspaceTitle: 'Sua conta bancária comercial foi bloqueada', + personalTitle: 'Sua conta bancária foi bloqueada', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: 'Carteira', + }, }, assignedCards: 'Seus Cartões Expensify', assignedCardsRemaining: ({amount}: {amount: string}) => `${amount} restante`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 4d3c952931b7..604a121148b2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -925,6 +925,12 @@ const translations: TranslationDeepObject = { }, validateAccount: {title: '验证您的账户以继续使用 Expensify', subtitle: '账户', cta: '验证'}, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, + unlockBankAccount: { + workspaceTitle: '您的企业银行账户已被锁定', + personalTitle: '您的银行账户已被锁定', + workspaceSubtitle: ({policyName}: {policyName: string}) => policyName, + personalSubtitle: '钱包', + }, }, assignedCards: '你的 Expensify 卡', assignedCardsRemaining: ({amount}: {amount: string}) => `剩余 ${amount}`, diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveLockedBankAccount.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveLockedBankAccount.ts new file mode 100644 index 000000000000..9520d7f4e1c3 --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveLockedBankAccount.ts @@ -0,0 +1,65 @@ +import {primaryLoginSelector} from '@selectors/Account'; +import useOnyx from '@hooks/useOnyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {BankAccountList, Policy} from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; + +type LockedBankAccount = { + /** Stable key used to render this account widget */ + key: string; + + /** The ID of the locked bank account */ + bankAccountID: number; + + /** The policy name — undefined means personal account */ + policyName?: string; +}; + +function useTimeSensitiveLockedBankAccount(adminPolicies: Policy[] | undefined) { + const [bankAccountList = getEmptyObject()] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [primaryLogin] = useOnyx(ONYXKEYS.ACCOUNT, {selector: primaryLoginSelector}); + const lockedBankAccounts: LockedBankAccount[] = []; + const workspaceLockedBankAccountIDs = new Set(); + + for (const policy of adminPolicies ?? []) { + const achAccount = policy.achAccount; + if (achAccount?.state !== CONST.BANK_ACCOUNT.STATE.LOCKED || !achAccount.bankAccountID) { + continue; + } + + const isCurrentUserReimburser = !!primaryLogin && achAccount.reimburser === primaryLogin; + if (!isCurrentUserReimburser) { + continue; + } + + workspaceLockedBankAccountIDs.add(achAccount.bankAccountID); + lockedBankAccounts.push({ + key: `workspace-${policy.id}-${achAccount.bankAccountID}`, + bankAccountID: achAccount.bankAccountID, + policyName: policy.name, + }); + } + + for (const account of Object.values(bankAccountList ?? {})) { + const {bankAccountID, state, type} = account?.accountData ?? {}; + const isPersonalAccount = type === undefined || type === CONST.BANK_ACCOUNT.TYPE.PERSONAL; + + if (!isPersonalAccount) { + continue; + } + + if (state === CONST.BANK_ACCOUNT.STATE.LOCKED && bankAccountID && !workspaceLockedBankAccountIDs.has(bankAccountID)) { + lockedBankAccounts.push({ + key: `personal-${bankAccountID}`, + bankAccountID, + }); + } + } + + return { + lockedBankAccounts, + }; +} + +export default useTimeSensitiveLockedBankAccount; diff --git a/src/pages/home/TimeSensitiveSection/index.tsx b/src/pages/home/TimeSensitiveSection/index.tsx index 7c9225e8fbf6..01377df78687 100644 --- a/src/pages/home/TimeSensitiveSection/index.tsx +++ b/src/pages/home/TimeSensitiveSection/index.tsx @@ -20,6 +20,7 @@ import type {ConnectionName, PolicyConnectionName} from '@src/types/onyx/Policy' import useTimeSensitiveAddPaymentCard from './hooks/useTimeSensitiveAddPaymentCard'; import useTimeSensitiveBilling from './hooks/useTimeSensitiveBilling'; import useTimeSensitiveCards from './hooks/useTimeSensitiveCards'; +import useTimeSensitiveLockedBankAccount from './hooks/useTimeSensitiveLockedBankAccount'; import ActivateCard from './items/ActivateCard'; import AddPaymentCard from './items/AddPaymentCard'; import AddShippingAddress from './items/AddShippingAddress'; @@ -28,6 +29,7 @@ import FixCompanyCardConnection from './items/FixCompanyCardConnection'; import FixFailedBilling from './items/FixFailedBilling'; import FixPersonalCardConnection from './items/FixPersonalCardConnection'; import ReviewCardFraud from './items/ReviewCardFraud'; +import UnlockBankAccount from './items/UnlockBankAccount'; import ValidateAccount from './items/ValidateAccount'; type BrokenAccountingConnection = { @@ -80,6 +82,7 @@ function TimeSensitiveSection() { }); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); + const {lockedBankAccounts} = useTimeSensitiveLockedBankAccount(adminPolicies); // Get card feed errors for company card connections (Release 4) const cardFeedErrors = useCardFeedErrors(); @@ -153,6 +156,7 @@ function TimeSensitiveSection() { // If a widget has additional conditions in the render (e.g. && !!discountInfo), those // must be reflected here to avoid showing an empty "Time sensitive" section. const hasAnyTimeSensitiveContent = + lockedBankAccounts.length > 0 || shouldShowValidateAccount || shouldShowFixFailedBilling || shouldShowReviewCardFraud || @@ -174,9 +178,10 @@ function TimeSensitiveSection() { // 4. Add payment card (trial ended, no payment card) // 5. Broken bank connections (company cards) // 6. Broken bank connections (personal cards) - // 7. Broken accounting connections - // 8. Expensify card shipping - // 9. Expensify card activation + // 7. Locked bank accounts (workspace VBAs and personal) + // 8. Broken accounting connections + // 9. Expensify card shipping + // 10. Expensify card activation return ( @@ -231,7 +236,16 @@ function TimeSensitiveSection() { ); })} - {/* Priority 7: Broken accounting connections */} + {/* Priority 7: Locked bank accounts */} + {lockedBankAccounts.map((lockedBankAccount) => ( + + ))} + + {/* Priority 8: Broken accounting connections */} {brokenAccountingConnections.map((connection) => ( ))} - {/* Priority 8: Expensify card shipping */} + {/* Priority 9: Expensify card shipping */} {shouldShowAddShippingAddress && cardsNeedingShippingAddress.map((card) => ( ))} - {/* Priority 9: Expensify card activation */} + {/* Priority 10: Expensify card activation */} {shouldShowActivateCard && cardsNeedingActivation.map((card) => ( { + pressLockedBankAccount(bankAccountID, translate, conciergeReportID, delegateAccountID); + navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas); + }; + + return ( + + ); +} + +export default UnlockBankAccount; diff --git a/tests/unit/hooks/useTimeSensitiveLockedBankAccount.test.ts b/tests/unit/hooks/useTimeSensitiveLockedBankAccount.test.ts new file mode 100644 index 000000000000..48c2cb1f24b2 --- /dev/null +++ b/tests/unit/hooks/useTimeSensitiveLockedBankAccount.test.ts @@ -0,0 +1,306 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useTimeSensitiveLockedBankAccount from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveLockedBankAccount'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {BankAccount, BankAccountList} from '@src/types/onyx'; +import type Policy from '@src/types/onyx/Policy'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const PRIMARY_LOGIN = 'user@example.com'; +const OTHER_LOGIN = 'other@example.com'; + +function makePolicy(overrides: Partial & {id: string}): Policy { + return { + ...overrides, + name: overrides.name ?? `Policy ${overrides.id}`, + } as Policy; +} + +function makeBankAccount(bankAccountID: number, state: string, type?: string): BankAccount { + return { + bankCurrency: 'USD', + bankCountry: 'US', + accountData: { + bankAccountID, + state, + type, + }, + } as BankAccount; +} + +describe('useTimeSensitiveLockedBankAccount', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('returns empty array when adminPolicies is undefined and BANK_ACCOUNT_LIST is empty', () => { + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount(undefined)); + + expect(result.current.lockedBankAccounts).toEqual([]); + }); + + it('returns empty array when adminPolicies is an empty array and BANK_ACCOUNT_LIST is empty', () => { + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([])); + + expect(result.current.lockedBankAccounts).toEqual([]); + }); + + it('returns a workspace entry when the policy has a locked achAccount and current user is the reimburser', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: PRIMARY_LOGIN}); + await waitForBatchedUpdates(); + + const policy = makePolicy({ + id: 'policy1', + name: 'Acme Corp', + achAccount: { + bankAccountID: 100, + accountNumber: '****1234', + routingNumber: '123456789', + addressName: 'Test Account', + bankName: 'Test Bank', + reimburser: PRIMARY_LOGIN, + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([policy])); + + expect(result.current.lockedBankAccounts).toHaveLength(1); + expect(result.current.lockedBankAccounts.at(0)).toMatchObject({ + bankAccountID: 100, + policyName: 'Acme Corp', + }); + }); + + it('skips the workspace entry when the current user is NOT the reimburser', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: PRIMARY_LOGIN}); + await waitForBatchedUpdates(); + + const policy = makePolicy({ + id: 'policy1', + achAccount: { + bankAccountID: 100, + accountNumber: '****1234', + routingNumber: '123456789', + addressName: 'Test Account', + bankName: 'Test Bank', + reimburser: OTHER_LOGIN, + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([policy])); + + expect(result.current.lockedBankAccounts).toHaveLength(0); + }); + + it('renders a personal entry when BANK_ACCOUNT_LIST has a locked PERSONAL account', async () => { + const bankAccountList: BankAccountList = { + '200': makeBankAccount(200, CONST.BANK_ACCOUNT.STATE.LOCKED, CONST.BANK_ACCOUNT.TYPE.PERSONAL), + }; + + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, bankAccountList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([])); + + const entry = result.current.lockedBankAccounts.at(0); + expect(result.current.lockedBankAccounts).toHaveLength(1); + expect(entry?.bankAccountID).toBe(200); + expect(entry?.policyName).toBeUndefined(); + }); + + it('renders a personal entry when accountData.type is undefined (treated as personal)', async () => { + const bankAccountList: BankAccountList = { + '201': makeBankAccount(201, CONST.BANK_ACCOUNT.STATE.LOCKED, undefined), + }; + + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, bankAccountList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([])); + + expect(result.current.lockedBankAccounts).toHaveLength(1); + expect(result.current.lockedBankAccounts.at(0)?.bankAccountID).toBe(201); + }); + + it('skips a BANK_ACCOUNT_LIST entry whose accountData.type is BUSINESS', async () => { + const bankAccountList: BankAccountList = { + '300': makeBankAccount(300, CONST.BANK_ACCOUNT.STATE.LOCKED, CONST.BANK_ACCOUNT.TYPE.BUSINESS), + }; + + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, bankAccountList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([])); + + expect(result.current.lockedBankAccounts).toHaveLength(0); + }); + + it('suppresses the personal widget when the same bankAccountID is also a workspace locked account', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: PRIMARY_LOGIN}); + + const bankAccountList: BankAccountList = { + '100': makeBankAccount(100, CONST.BANK_ACCOUNT.STATE.LOCKED, CONST.BANK_ACCOUNT.TYPE.PERSONAL), + }; + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, bankAccountList); + await waitForBatchedUpdates(); + + const policy = makePolicy({ + id: 'policy1', + achAccount: { + bankAccountID: 100, + accountNumber: '****1234', + routingNumber: '123456789', + addressName: 'Test Account', + bankName: 'Test Bank', + reimburser: PRIMARY_LOGIN, + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([policy])); + + // Only one entry — the workspace one; personal is deduped + expect(result.current.lockedBankAccounts).toHaveLength(1); + expect(result.current.lockedBankAccounts.at(0)?.policyName).toBeDefined(); + }); + + it('shows the personal widget when a non-reimburser admin has the same bankAccountID as a workspace account', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: PRIMARY_LOGIN}); + + const bankAccountList: BankAccountList = { + '100': makeBankAccount(100, CONST.BANK_ACCOUNT.STATE.LOCKED, CONST.BANK_ACCOUNT.TYPE.PERSONAL), + }; + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, bankAccountList); + await waitForBatchedUpdates(); + + const policy = makePolicy({ + id: 'policy1', + achAccount: { + bankAccountID: 100, + accountNumber: '****1234', + routingNumber: '123456789', + addressName: 'Test Account', + bankName: 'Test Bank', + reimburser: OTHER_LOGIN, + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([policy])); + + // Option A fix: workspaceLockedBankAccountIDs.add(...) only runs after the reimburser check, + // so no workspace widget is shown but the personal widget is NOT suppressed. + expect(result.current.lockedBankAccounts).toHaveLength(1); + expect(result.current.lockedBankAccounts.at(0)?.policyName).toBeUndefined(); + expect(result.current.lockedBankAccounts.at(0)?.key).toBe('personal-100'); + }); + + it('handles null/undefined BANK_ACCOUNT_LIST without throwing', async () => { + // Do not merge any bank account list — Onyx will return undefined/null + await waitForBatchedUpdates(); + + expect(() => { + renderHook(() => useTimeSensitiveLockedBankAccount([])); + }).not.toThrow(); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([])); + expect(result.current.lockedBankAccounts).toEqual([]); + }); + + it('handles nullish entries inside BANK_ACCOUNT_LIST without throwing', async () => { + // Simulate a partially cleared Onyx collection where an entry is null + const bankAccountList = {'999': null} as unknown as BankAccountList; + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, bankAccountList); + await waitForBatchedUpdates(); + + expect(() => { + renderHook(() => useTimeSensitiveLockedBankAccount([])); + }).not.toThrow(); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([])); + expect(result.current.lockedBankAccounts).toEqual([]); + }); + + it('emits keys in workspace-policyID-bankAccountID format for workspace entries', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: PRIMARY_LOGIN}); + await waitForBatchedUpdates(); + + const policy = makePolicy({ + id: 'policy42', + achAccount: { + bankAccountID: 777, + accountNumber: '****5678', + routingNumber: '987654321', + addressName: 'Test Account', + bankName: 'Test Bank', + reimburser: PRIMARY_LOGIN, + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([policy])); + + expect(result.current.lockedBankAccounts.at(0)?.key).toBe('workspace-policy42-777'); + }); + + it('emits keys in personal-bankAccountID format for personal entries', async () => { + const bankAccountList: BankAccountList = { + '888': makeBankAccount(888, CONST.BANK_ACCOUNT.STATE.LOCKED, CONST.BANK_ACCOUNT.TYPE.PERSONAL), + }; + + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, bankAccountList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([])); + + expect(result.current.lockedBankAccounts.at(0)?.key).toBe('personal-888'); + }); + + it('does not render a workspace entry when achAccount state is not LOCKED', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {primaryLogin: PRIMARY_LOGIN}); + await waitForBatchedUpdates(); + + const policy = makePolicy({ + id: 'policy1', + achAccount: { + bankAccountID: 100, + accountNumber: '****1234', + routingNumber: '123456789', + addressName: 'Test Account', + bankName: 'Test Bank', + reimburser: PRIMARY_LOGIN, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + }, + }); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([policy])); + + expect(result.current.lockedBankAccounts).toHaveLength(0); + }); + + it('does not render a personal entry when the account state is not LOCKED', async () => { + const bankAccountList: BankAccountList = { + '200': makeBankAccount(200, CONST.BANK_ACCOUNT.STATE.OPEN, CONST.BANK_ACCOUNT.TYPE.PERSONAL), + }; + + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, bankAccountList); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useTimeSensitiveLockedBankAccount([])); + + expect(result.current.lockedBankAccounts).toHaveLength(0); + }); +}); diff --git a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx new file mode 100644 index 000000000000..631ec94a5743 --- /dev/null +++ b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx @@ -0,0 +1,317 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import {pressLockedBankAccount} from '@libs/actions/BankAccounts'; +import {navigateToConciergeChat} from '@libs/actions/Report'; +import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import TimeSensitiveSection from '@src/pages/home/TimeSensitiveSection'; +import waitForBatchedUpdates from '../../../../utils/waitForBatchedUpdates'; + +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: jest.fn((key: string) => key)}))); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: jest.fn(() => ({ + EnvelopeOpenStar: () => null, + BankLock: () => null, + })), +})); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveAddPaymentCard', () => + jest.fn(() => ({ + shouldShowAddPaymentCard: false, + })), +); + +jest.mock('@src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveCards', () => + jest.fn(() => ({ + shouldShowAddShippingAddress: false, + shouldShowActivateCard: false, + shouldShowReviewCardFraud: false, + cardsNeedingShippingAddress: [], + cardsNeedingActivation: [], + cardsWithFraud: [], + })), +); + +jest.mock('@hooks/useCardFeedErrors', () => + jest.fn(() => ({ + cardsWithBrokenFeedConnection: {}, + personalCardsWithBrokenConnection: {}, + })), +); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'admin@example.com', accountID: 12345}))); + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false}))); + +jest.mock('@libs/actions/BankAccounts', () => ({ + pressLockedBankAccount: jest.fn(), +})); + +jest.mock('@libs/actions/Report', () => ({ + navigateToConciergeChat: jest.fn(), +})); + +const ADMIN_ACCOUNT_ID = 12345; +const LOCKED_BANK_ACCOUNT_ID = 99; +const POLICY_ID = 'policy_1'; +const POLICY_NAME = 'My Workspace'; +const CONCIERGE_REPORT_ID = 'concierge_report_1'; + +const renderTimeSensitiveSection = () => + render( + + + , + ); + +describe('TimeSensitiveSection - UnlockBankAccount', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.ACCOUNT, {primaryLogin: 'admin@example.com'}); + await waitForBatchedUpdates(); + }); + + it('renders UnlockBankAccount widget when a workspace policy has achAccount.state === LOCKED', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@example.com', accountID: ADMIN_ACCOUNT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { + id: POLICY_ID, + name: POLICY_NAME, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + isPolicyExpenseChatEnabled: true, + achAccount: { + bankAccountID: LOCKED_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXXXX1234', + routingNumber: '123456789', + addressName: 'Test Bank', + bankName: 'Test Bank', + reimburser: 'admin@example.com', + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.getByText('homePage.timeSensitiveSection.unlockBankAccount.workspaceTitle')).toBeTruthy(); + }); + + it('renders UnlockBankAccount widget when BANK_ACCOUNT_LIST has an entry with accountData.state === LOCKED', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@example.com', accountID: ADMIN_ACCOUNT_ID}); + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + [`bankAccount-${LOCKED_BANK_ACCOUNT_ID}`]: { + bankCurrency: 'USD', + bankCountry: 'US', + accountData: { + bankAccountID: LOCKED_BANK_ACCOUNT_ID, + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + type: CONST.BANK_ACCOUNT.TYPE.PERSONAL, + }, + }, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.getByText('homePage.timeSensitiveSection.unlockBankAccount.personalTitle')).toBeTruthy(); + }); + + it('does NOT render personal UnlockBankAccount for locked business bank accounts', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@example.com', accountID: ADMIN_ACCOUNT_ID}); + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + [`bankAccount-${LOCKED_BANK_ACCOUNT_ID}`]: { + bankCurrency: 'USD', + bankCountry: 'US', + accountData: { + bankAccountID: LOCKED_BANK_ACCOUNT_ID, + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + }, + }, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.queryByText('homePage.timeSensitiveSection.unlockBankAccount.personalTitle')).toBeNull(); + }); + + it('does NOT render UnlockBankAccount when bank account state is OPEN', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@example.com', accountID: ADMIN_ACCOUNT_ID}); + await Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, { + [`bankAccount-${LOCKED_BANK_ACCOUNT_ID}`]: { + bankCurrency: 'USD', + bankCountry: 'US', + accountData: { + bankAccountID: LOCKED_BANK_ACCOUNT_ID, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + }, + }, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.queryByText('homePage.timeSensitiveSection.unlockBankAccount.workspaceTitle')).toBeNull(); + expect(screen.queryByText('homePage.timeSensitiveSection.unlockBankAccount.personalTitle')).toBeNull(); + }); + + it('does NOT render UnlockBankAccount for non-admin users (workspace VBA case)', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: 'member@example.com', accountID: ADMIN_ACCOUNT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { + id: POLICY_ID, + name: POLICY_NAME, + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + isPolicyExpenseChatEnabled: true, + achAccount: { + bankAccountID: LOCKED_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXXXX1234', + routingNumber: '123456789', + addressName: 'Test Bank', + bankName: 'Test Bank', + reimburser: 'admin@example.com', + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + expect(screen.queryByText('homePage.timeSensitiveSection.unlockBankAccount.workspaceTitle')).toBeNull(); + }); + + it('renders multiple UnlockBankAccount widgets when multiple locked accounts exist', async () => { + const SECOND_POLICY_ID = 'policy_2'; + const SECOND_LOCKED_BANK_ACCOUNT_ID = 100; + + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@example.com', accountID: ADMIN_ACCOUNT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { + id: POLICY_ID, + name: 'Workspace One', + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + isPolicyExpenseChatEnabled: true, + achAccount: { + bankAccountID: LOCKED_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXXXX1234', + routingNumber: '123456789', + addressName: 'Test Bank', + bankName: 'Test Bank', + reimburser: 'admin@example.com', + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${SECOND_POLICY_ID}`, { + id: SECOND_POLICY_ID, + name: 'Workspace Two', + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + isPolicyExpenseChatEnabled: true, + achAccount: { + bankAccountID: SECOND_LOCKED_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXXXX5678', + routingNumber: '987654321', + addressName: 'Another Bank', + bankName: 'Another Bank', + reimburser: 'admin@example.com', + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + const widgets = screen.getAllByText('homePage.timeSensitiveSection.unlockBankAccount.workspaceTitle'); + expect(widgets).toHaveLength(2); + }); + + it('does not log duplicate key warnings when multiple workspaces share a locked bankAccountID', async () => { + const SECOND_POLICY_ID = 'policy_2'; + const duplicateKeyWarningMessage = 'Encountered two children with the same key'; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@example.com', accountID: ADMIN_ACCOUNT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { + id: POLICY_ID, + name: 'Workspace One', + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + isPolicyExpenseChatEnabled: true, + achAccount: { + bankAccountID: LOCKED_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXXXX1234', + routingNumber: '123456789', + addressName: 'Test Bank', + bankName: 'Test Bank', + reimburser: 'admin@example.com', + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${SECOND_POLICY_ID}`, { + id: SECOND_POLICY_ID, + name: 'Workspace Two', + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + isPolicyExpenseChatEnabled: true, + achAccount: { + bankAccountID: LOCKED_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXXXX5678', + routingNumber: '987654321', + addressName: 'Another Bank', + bankName: 'Another Bank', + reimburser: 'admin@example.com', + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + const widgets = screen.getAllByText('homePage.timeSensitiveSection.unlockBankAccount.workspaceTitle'); + expect(widgets).toHaveLength(2); + + const duplicateKeyWarnings = consoleErrorSpy.mock.calls.flat().filter((arg) => typeof arg === 'string' && arg.includes(duplicateKeyWarningMessage)); + expect(duplicateKeyWarnings).toHaveLength(0); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('calls pressLockedBankAccount and navigates to Concierge when CTA is pressed', async () => { + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@example.com', accountID: ADMIN_ACCOUNT_ID}); + await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, CONCIERGE_REPORT_ID); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { + id: POLICY_ID, + name: POLICY_NAME, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + isPolicyExpenseChatEnabled: true, + achAccount: { + bankAccountID: LOCKED_BANK_ACCOUNT_ID, + accountNumber: 'XXXXXXXX1234', + routingNumber: '123456789', + addressName: 'Test Bank', + bankName: 'Test Bank', + reimburser: 'admin@example.com', + state: CONST.BANK_ACCOUNT.STATE.LOCKED, + }, + }); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + const cta = screen.getByText('homePage.timeSensitiveSection.ctaFix'); + fireEvent.press(cta); + + expect(pressLockedBankAccount).toHaveBeenCalledWith(LOCKED_BANK_ACCOUNT_ID, expect.any(Function), CONCIERGE_REPORT_ID, undefined); + expect(navigateToConciergeChat).toHaveBeenCalledWith(CONCIERGE_REPORT_ID, undefined, ADMIN_ACCOUNT_ID, false, undefined); + }); +});