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);
+ });
+});