From 5c3a1c99a79b4d561d11f370e753729f690550f6 Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Fri, 28 Aug 2026 14:44:38 -0600 Subject: [PATCH 01/10] prevent repeated bank account unlock requests from being sent --- src/ONYXKEYS.ts | 4 ++ src/components/SettlementButton/index.tsx | 51 +++++++++++-------- src/languages/de.ts | 2 + src/languages/el.ts | 3 ++ src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 2 + src/languages/nl.ts | 2 + src/languages/pl.ts | 2 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 2 + src/libs/actions/BankAccounts.ts | 13 ++++- .../items/UnlockBankAccount.tsx | 12 +++++ .../settings/Wallet/WalletPage/index.tsx | 17 ++++++- .../workflows/tabs/WorkflowsPaymentsTab.tsx | 11 ++++ 17 files changed, 109 insertions(+), 22 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index c072d66ac9c0..3fb0cbde1ade 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1019,6 +1019,9 @@ const ONYXKEYS = { NVP_EXPENSIFY_REPORT_PDF_FILENAME: 'nvp_expensify_report_PDFFilename_', + /** Marker written when the user has already requested to unlock a locked business bank account */ + NVP_LOCKED_VBA_UNLOCK_REQUESTED: 'nvp_expensify_vbaUnlockRequested_', + /** The last submission method (Submit / Submit via PDF) the user chose on a given workspace, so the Submit button can default to it. Keyed by policyID. */ NVP_PREFERRED_REPORT_SUBMISSION_METHOD: 'preferredReportSubmissionMethod_', @@ -1531,6 +1534,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.SELECTED_DISTANCE_REQUEST_TAB]: OnyxTypes.SelectedTabRequest; [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME]: string; + [ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED]: string; [ONYXKEYS.COLLECTION.NVP_PREFERRED_REPORT_SUBMISSION_METHOD]: ValueOf; [ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER]: OnyxTypes.PolicyJoinMember; [ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS]: OnyxTypes.PolicyConnectionSyncProgress; diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index 5d69422a4bfc..8b2d4548c4ce 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -156,6 +156,8 @@ function SettlementButton({ const {isBetaEnabled} = usePermissions(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + // eslint-disable-next-line rulesdir/no-default-id-values + const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${policy?.achAccount?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID}`); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -181,26 +183,35 @@ function SettlementButton({ // interrupted by account validation resumes, since the validation gate skipped them. const checkForPostValidationBlockers = () => { if (isBankAccountLocked) { - showConfirmModal({ - title: translate('bankAccount.lockedBankAccount'), - prompt: ( - - - - ), - confirmText: translate('bankAccount.unlockBankAccount'), - cancelText: translate('common.cancel'), - shouldDisableConfirmButtonWhenOffline: true, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM) { - return; - } - if (policy?.achAccount?.bankAccountID === undefined) { - return; - } - pressLockedBankAccount(policy?.achAccount?.bankAccountID, translate, conciergeReportID, delegateAccountID); - navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas); - }); + if (unlockRequestedAt) { + showConfirmModal({ + title: translate('bankAccount.unlockAlreadyRequestedTitle'), + prompt: translate('bankAccount.unlockAlreadyRequestedDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + } else { + showConfirmModal({ + title: translate('bankAccount.lockedBankAccount'), + prompt: ( + + + + ), + confirmText: translate('bankAccount.unlockBankAccount'), + cancelText: translate('common.cancel'), + shouldDisableConfirmButtonWhenOffline: true, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + if (policy?.achAccount?.bankAccountID === undefined) { + return; + } + pressLockedBankAccount(policy?.achAccount?.bankAccountID, translate, conciergeReportID, delegateAccountID); + navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas); + }); + } return true; } diff --git a/src/languages/de.ts b/src/languages/de.ts index 917ce9ae46fc..43341e291787 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3847,6 +3847,8 @@ ${amount} für ${merchant} – ${date}`, 'Dieses Bankkonto kann nicht gelöscht werden, da es für Zahlungen mit der Expensify Karte verwendet wird. Wenn Sie dieses Konto trotzdem löschen möchten, wenden Sie sich bitte an Concierge.', sameDepositAndWithdrawalAccount: 'Die Einzahlungs- und Auszahlungskonten sind identisch.', }, + unlockAlreadyRequestedTitle: 'Anfrage bereits eingereicht', + unlockAlreadyRequestedDescription: 'Ihre Anfrage zur Entsperrung dieses Bankkontos wurde bereits gesendet. Concierge meldet sich bei Ihnen, falls noch etwas benötigt wird.', }, addPersonalBankAccount: { countrySelectionStepHeader: 'Wo befindet sich dein Bankkonto?', diff --git a/src/languages/el.ts b/src/languages/el.ts index 2bd407346353..d943acf1dbd5 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3902,6 +3902,9 @@ ${amount} για ${merchant} - ${date}`, 'Αυτός ο τραπεζικός λογαριασμός δεν μπορεί να διαγραφεί επειδή χρησιμοποιείται για πληρωμές με την Κάρτα Expensify. Αν εξακολουθείτε να θέλετε να διαγράψετε αυτόν τον λογαριασμό, παρακαλούμε επικοινωνήστε με το Concierge.', sameDepositAndWithdrawalAccount: 'Οι λογαριασμοί κατάθεσης και ανάληψης είναι οι ίδιοι.', }, + unlockAlreadyRequestedTitle: 'Το αίτημα έχει ήδη υποβληθεί', + unlockAlreadyRequestedDescription: + 'Το αίτημά σας για ξεκλείδωμα αυτού του τραπεζικού λογαριασμού έχει ήδη αποσταλεί. Το Concierge θα επικοινωνήσει μαζί σας αν χρειαστεί οτιδήποτε άλλο.', }, addPersonalBankAccount: { countrySelectionStepHeader: 'Πού βρίσκεται ο τραπεζικός σας λογαριασμός;', diff --git a/src/languages/en.ts b/src/languages/en.ts index 842602a438c3..dc303d71533d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3909,6 +3909,8 @@ const translations = { lockedBankAccount: 'Locked bank account', unlockBankAccount: 'Unlock bank account', youCantPayThis: `You can't pay this report because you have a locked bank account. Tap below and Concierge will help with the next steps to unlock it.`, + unlockAlreadyRequestedTitle: 'Request already submitted', + unlockAlreadyRequestedDescription: 'Your request to unlock this bank account has already been sent. Concierge will reach out if anything else is needed.', htmlUnlockMessage: (maskedAccountNumber: string) => `

Expensify Business Bank Account ${maskedAccountNumber}

Thank you for submitting a request to unlock your bank account. Withdrawal requests can be rejected due to insufficient funds, or if the bank account has not been enabled for direct debit. We will review your case and reach out to you if we need anything else to resolve this issue.

`, textUnlockMessage: (maskedAccountNumber: string) => diff --git a/src/languages/es.ts b/src/languages/es.ts index cbecbaa2fe6a..a5620555f187 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3769,6 +3769,8 @@ ${amount} para ${merchant} - ${date}`, 'Esta cuenta bancaria no se puede eliminar porque se utiliza para pagos con la tarjeta Expensify. Si aún deseas eliminar esta cuenta, por favor contacta con Concierge.', sameDepositAndWithdrawalAccount: 'Las cuentas de depósito y retiro son las mismas.', }, + unlockAlreadyRequestedTitle: 'Solicitud ya enviada', + unlockAlreadyRequestedDescription: 'Tu solicitud para desbloquear esta cuenta bancaria ya ha sido enviada. Concierge se pondrá en contacto contigo si se necesita algo más.', }, addPersonalBankAccount: { countrySelectionStepHeader: '¿Dónde está ubicada tu cuenta bancaria?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c30d4a77e4cb..a91165adbf06 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3855,6 +3855,8 @@ ${amount} pour ${merchant} - ${date}`, 'Ce compte bancaire ne peut pas être supprimé car il est utilisé pour les paiements par Carte Expensify. Si vous souhaitez tout de même supprimer ce compte, veuillez contacter Concierge.', sameDepositAndWithdrawalAccount: 'Les comptes de dépôt et de retrait sont identiques.', }, + unlockAlreadyRequestedTitle: 'Demande déjà soumise', + unlockAlreadyRequestedDescription: 'Votre demande de déverrouillage de ce compte bancaire a déjà été envoyée. Concierge vous contactera si autre chose est nécessaire.', }, addPersonalBankAccount: { countrySelectionStepHeader: 'Où se situe votre compte bancaire ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index bf9ddc450af3..dec41c40fe83 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3830,6 +3830,8 @@ ${amount} per ${merchant} - ${date}`, 'Questo conto bancario non può essere eliminato perché viene utilizzato per i pagamenti con Carta Expensify. Se desideri comunque eliminare questo conto, contatta Concierge.', sameDepositAndWithdrawalAccount: 'I conti di deposito e prelievo sono gli stessi.', }, + unlockAlreadyRequestedTitle: 'Richiesta già inviata', + unlockAlreadyRequestedDescription: 'La tua richiesta di sblocco di questo conto bancario è già stata inviata. Concierge ti contatterà se servirà altro.', }, addPersonalBankAccount: { countrySelectionStepHeader: 'Dove si trova il tuo conto bancario?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d7f3e2caa1eb..aaedc60c5eb6 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3791,6 +3791,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' deletePaymentBankAccount: 'この銀行口座は Expensify カードの支払いに使用されているため、削除できません。この口座を削除したい場合は、Concierge までご連絡ください。', sameDepositAndWithdrawalAccount: '入金口座と出金口座が同じです。', }, + unlockAlreadyRequestedTitle: 'リクエストは既に送信されています', + unlockAlreadyRequestedDescription: 'この銀行口座のロック解除リクエストは既に送信されています。追加で必要なことがある場合は、Concierge からご連絡します。', }, addPersonalBankAccount: { countrySelectionStepHeader: '銀行口座はどこにありますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 9ecb102d887c..acfa037ef04f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3829,6 +3829,8 @@ ${amount} voor ${merchant} - ${date}`, 'Deze bankrekening kan niet worden verwijderd omdat hij wordt gebruikt voor betalingen met de Expensify Kaart. Als je deze rekening toch wilt verwijderen, neem dan contact op met Concierge.', sameDepositAndWithdrawalAccount: 'De stortings- en opname­rekeningen zijn hetzelfde.', }, + unlockAlreadyRequestedTitle: 'Verzoek al ingediend', + unlockAlreadyRequestedDescription: 'Je verzoek om deze bankrekening te deblokkeren is al verzonden. Concierge neemt contact met je op als er nog iets anders nodig is.', }, addPersonalBankAccount: { countrySelectionStepHeader: 'Waar is je bankrekening gevestigd?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a09fcf792928..b22f2ca7d282 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3856,6 +3856,8 @@ ${amount} dla ${merchant} - ${date}`, 'To konto bankowe nie może zostać usunięte, ponieważ jest używane do płatności Kartą Expensify. Jeśli mimo to chcesz usunąć to konto, skontaktuj się z Concierge.', sameDepositAndWithdrawalAccount: 'Konta wpłat i wypłat są takie same.', }, + unlockAlreadyRequestedTitle: 'Wniosek został już złożony', + unlockAlreadyRequestedDescription: 'Twoja prośba o odblokowanie tego konta bankowego została już wysłana. Concierge skontaktuje się z tobą, jeśli będzie potrzebne coś jeszcze.', }, addPersonalBankAccount: { countrySelectionStepHeader: 'Gdzie znajduje się Twoje konto bankowe?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c0e8f0a2bc45..6044c92f1cd3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3818,6 +3818,8 @@ ${amount} para ${merchant} - ${date}`, 'Essa conta bancária não pode ser excluída porque é usada para pagamentos do Cartão Expensify. Se ainda assim quiser excluir essa conta, entre em contato com o Concierge.', sameDepositAndWithdrawalAccount: 'As contas de depósito e saque são as mesmas.', }, + unlockAlreadyRequestedTitle: 'Solicitação já enviada', + unlockAlreadyRequestedDescription: 'Sua solicitação para desbloquear esta conta bancária já foi enviada. O Concierge vai entrar em contato se for necessário mais alguma coisa.', }, addPersonalBankAccount: { countrySelectionStepHeader: 'Onde fica localizada a sua conta bancária?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 197392392408..095fa0926770 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3699,6 +3699,8 @@ ${amount},商户:${merchant} - 日期:${date}`, deletePaymentBankAccount: '此银行账户无法删除,因为它被用于 Expensify 卡付款。如果您仍希望删除此账户,请联系 Concierge。', sameDepositAndWithdrawalAccount: '存款账户和取款账户相同。', }, + unlockAlreadyRequestedTitle: '请求已提交', + unlockAlreadyRequestedDescription: '您解锁此银行账户的请求已发送。如需其他信息,Concierge 会与您联系。', }, addPersonalBankAccount: { countrySelectionStepHeader: '您的银行账户位于哪个国家/地区?', diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 2aabddb4b404..95123e96f1c0 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -41,7 +41,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; 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 {BankAccountList, InitiatingBankAccountUnlock, 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'; @@ -74,6 +74,13 @@ Onyx.connectWithoutView({ callback: (value) => (bankAccountList = value), }); +let initiatingBankAccountUnlock: OnyxEntry; + +Onyx.connectWithoutView({ + key: ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK, + callback: (value) => (initiatingBankAccountUnlock = value), +}); + type AccountFormValues = typeof ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM | typeof ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM; type OpenPersonalBankAccountSetupViewProps = { @@ -1860,6 +1867,10 @@ function initiateBankAccountUnlock(bankAccountID: number, conciergeReportID: str } function pressLockedBankAccount(bankAccountID: number, translate: LocalizedTranslate, conciergeReportID: string | undefined, delegateAccountID: number | undefined) { + if (initiatingBankAccountUnlock?.isLoading && initiatingBankAccountUnlock?.bankAccountIDToUnlock === bankAccountID) { + return; + } + let optimisticReportActionID: string | undefined; if (conciergeReportID) { diff --git a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx index 4856ec92c0bc..5fa513bb87ca 100644 --- a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx +++ b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx @@ -1,5 +1,6 @@ import BaseWidgetItem from '@components/BaseWidgetItem'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -34,6 +35,8 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); + const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID}`); + const {showConfirmModal} = useConfirmModal(); const title = policyName ? translate('homePage.timeSensitiveSection.unlockBankAccount.workspaceTitle') : translate('homePage.timeSensitiveSection.unlockBankAccount.personalTitle'); @@ -42,6 +45,15 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) : translate('homePage.timeSensitiveSection.unlockBankAccount.personalSubtitle'); const handleCtaPress = () => { + if (unlockRequestedAt) { + showConfirmModal({ + title: translate('bankAccount.unlockAlreadyRequestedTitle'), + prompt: translate('bankAccount.unlockAlreadyRequestedDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } pressLockedBankAccount(bankAccountID, translate, conciergeReportID, delegateAccountID); navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas); }; diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index 610c2e156a93..4070e20cbbec 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -99,6 +99,11 @@ function WalletPage() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [lockedVBAUnlockRequestedNVPs] = useOnyx(ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED); + const lockedVBAUnlockRequestedNVPsRef = useRef(lockedVBAUnlockRequestedNVPs); + useEffect(() => { + lockedVBAUnlockRequestedNVPsRef.current = lockedVBAUnlockRequestedNVPs; + }, [lockedVBAUnlockRequestedNVPs]); const delegateAccountID = useDelegateAccountID(); const isUserValidated = userAccount?.validated ?? false; const {isBetaEnabled} = usePermissions(); @@ -158,7 +163,17 @@ function WalletPage() { paymentMethodButtonRef.current = event?.currentTarget as HTMLDivElement; if (accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED && accountData?.bankAccountID) { - pressLockedBankAccount(accountData?.bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID); + const unlockRequestedAt = lockedVBAUnlockRequestedNVPsRef.current?.[`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${accountData.bankAccountID}`]; + if (unlockRequestedAt) { + showConfirmModal({ + title: translate('bankAccount.unlockAlreadyRequestedTitle'), + prompt: translate('bankAccount.unlockAlreadyRequestedDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } + pressLockedBankAccount(accountData.bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID); navigateToConciergeChat(conciergeReportID ?? undefined, introSelected, currentUserAccountID, isSelfTourViewed, betas); return; } diff --git a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx index eaa624e9c7f1..54c44b7cefef 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx @@ -132,6 +132,8 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { const bankTitle = addressName.includes(CONST.MASKED_PAN_PREFIX) ? bankName : addressName; const bankAccountID = isBankAccountFullySetup ? policy?.achAccount?.bankAccountID : bankAccountConnectedToWorkspace?.methodID; const state = isBankAccountFullySetup ? (policy?.achAccount?.state ?? '') : (bankAccountConnectedToWorkspace?.accountData?.state ?? ''); + // eslint-disable-next-line rulesdir/no-default-id-values + const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID ?? CONST.DEFAULT_NUMBER_ID}`); const isAccountInSetupState = isBankAccountPartiallySetup(state); const isBusinessBankAccountLocked = state === CONST.BANK_ACCOUNT.STATE.LOCKED; const canChangePayer = canWritePayments && !isAccountInSetupState; @@ -191,6 +193,15 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { } // User who is reimburser can initiate unlocking process if (state === CONST.BANK_ACCOUNT.STATE.LOCKED && bankAccountID && isUserReimburser) { + if (unlockRequestedAt) { + showConfirmModal({ + title: translate('bankAccount.unlockAlreadyRequestedTitle'), + prompt: translate('bankAccount.unlockAlreadyRequestedDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } pressLockedBankAccount(bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID); navigateToConciergeChat(conciergeReportID ?? undefined, introSelected, currentUserAccountID, isSelfTourViewed, betas); return; From 2fbf82138c3d97d71e1fe4fafccd701e132c7f3a Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Fri, 28 Aug 2026 15:17:23 -0600 Subject: [PATCH 02/10] fix failing jest test --- src/libs/ExportOnyxState/common.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index bd52aa48f06d..87b3395d31a9 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -368,6 +368,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD, ONYXKEYS.COLLECTION.MERGE_TRANSACTION, ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST, + ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED, ONYXKEYS.COLLECTION.PASSKEY_CREDENTIALS, ONYXKEYS.COLLECTION.PENDING_CONCIERGE_RESPONSE, ONYXKEYS.COLLECTION.POLICY_CATEGORIES, From e12ec103f741efba11aee6e200553d2a67a87b37 Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Fri, 28 Aug 2026 15:27:39 -0600 Subject: [PATCH 03/10] use a shared method to display the modal --- src/components/SettlementButton/index.tsx | 9 ++------- src/libs/BankAccountUtils.ts | 12 ++++++++++++ .../TimeSensitiveSection/items/UnlockBankAccount.tsx | 8 ++------ src/pages/settings/Wallet/WalletPage/index.tsx | 9 ++------- .../workflows/tabs/WorkflowsPaymentsTab.tsx | 9 ++------- 5 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index 8b2d4548c4ce..dfcbe2f8ef9f 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -25,7 +25,7 @@ import useVerifyAccountAndResume from '@hooks/useVerifyAccountAndResume'; import {createWorkspace, generateDefaultWorkspaceName, isCurrencySupportedForDirectReimbursement, isCurrencySupportedForGlobalReimbursement} from '@libs/actions/Policy/Policy'; import {navigateToBankAccountRoute} from '@libs/actions/ReimbursementAccount'; import {getLastPolicyBankAccountID, getLastPolicyPaymentMethod} from '@libs/actions/Search'; -import {isBankAccountPartiallySetup} from '@libs/BankAccountUtils'; +import {isBankAccountPartiallySetup, showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods, getActivePaymentType, getBusinessBankAccountOptions, matchesCurrency} from '@libs/PaymentUtils'; import {isPaidGroupPolicy, isPolicyAdmin, sortPoliciesByName} from '@libs/PolicyUtils'; @@ -184,12 +184,7 @@ function SettlementButton({ const checkForPostValidationBlockers = () => { if (isBankAccountLocked) { if (unlockRequestedAt) { - showConfirmModal({ - title: translate('bankAccount.unlockAlreadyRequestedTitle'), - prompt: translate('bankAccount.unlockAlreadyRequestedDescription'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); + showUnlockAlreadyRequestedModal(showConfirmModal, translate); } else { showConfirmModal({ title: translate('bankAccount.lockedBankAccount'), diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index 2c496ee487f2..2b3d6e45cdf2 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -1,5 +1,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type useConfirmModal from '@hooks/useConfirmModal'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; @@ -381,6 +383,15 @@ function getInternationalBankAccountDetailsErrors( return errors; } +function showUnlockAlreadyRequestedModal(showConfirmModal: ReturnType['showConfirmModal'], translate: LocaleContextProps['translate']) { + showConfirmModal({ + title: translate('bankAccount.unlockAlreadyRequestedTitle'), + prompt: translate('bankAccount.unlockAlreadyRequestedDescription'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); +} + export { hasValidInternationalBankAccountDetails, hasValidAccountDetailsInternationalFields, @@ -404,6 +415,7 @@ export { doesPolicyHavePartiallySetupBankAccount, isPersonalBankAccountMissingInfo, getCompletedStepsForBankAccount, + showUnlockAlreadyRequestedModal, PERSONAL_INFO_STEP, }; export type {BankAccountConnectionStatus, KYBVerificationResponses}; diff --git a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx index 5fa513bb87ca..db03aabda049 100644 --- a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx +++ b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx @@ -9,6 +9,7 @@ import useOnyx from '@hooks/useOnyx'; import {pressLockedBankAccount} from '@libs/actions/BankAccounts'; import {navigateToConciergeChat} from '@libs/actions/Report'; +import {showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import colors from '@styles/theme/colors'; @@ -46,12 +47,7 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) const handleCtaPress = () => { if (unlockRequestedAt) { - showConfirmModal({ - title: translate('bankAccount.unlockAlreadyRequestedTitle'), - prompt: translate('bankAccount.unlockAlreadyRequestedDescription'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); + showUnlockAlreadyRequestedModal(showConfirmModal, translate); return; } pressLockedBankAccount(bankAccountID, translate, conciergeReportID, delegateAccountID); diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index 4070e20cbbec..2176746db033 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -33,7 +33,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; +import {isPersonalBankAccountMissingInfo, showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import {hasDisplayableAssignedCards, isDirectFeed, maskCardNumber} from '@libs/CardUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -165,12 +165,7 @@ function WalletPage() { if (accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED && accountData?.bankAccountID) { const unlockRequestedAt = lockedVBAUnlockRequestedNVPsRef.current?.[`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${accountData.bankAccountID}`]; if (unlockRequestedAt) { - showConfirmModal({ - title: translate('bankAccount.unlockAlreadyRequestedTitle'), - prompt: translate('bankAccount.unlockAlreadyRequestedDescription'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); + showUnlockAlreadyRequestedModal(showConfirmModal, translate); return; } pressLockedBankAccount(accountData.bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID); diff --git a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx index 54c44b7cefef..bcf52a02ed78 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx @@ -26,7 +26,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearPolicyErrorField, isCurrencySupportedForDirectReimbursement, isCurrencySupportedForGlobalReimbursement, setWorkspaceReimbursement} from '@libs/actions/Policy/Policy'; -import {getBankAccountConnectionStatus, isBankAccountPartiallySetup} from '@libs/BankAccountUtils'; +import {getBankAccountConnectionStatus, isBankAccountPartiallySetup, showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPaymentMethodDescription} from '@libs/PaymentUtils'; @@ -194,12 +194,7 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { // User who is reimburser can initiate unlocking process if (state === CONST.BANK_ACCOUNT.STATE.LOCKED && bankAccountID && isUserReimburser) { if (unlockRequestedAt) { - showConfirmModal({ - title: translate('bankAccount.unlockAlreadyRequestedTitle'), - prompt: translate('bankAccount.unlockAlreadyRequestedDescription'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); + showUnlockAlreadyRequestedModal(showConfirmModal, translate); return; } pressLockedBankAccount(bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID); From 552a193b3cd4ad0b15a3f72b860991b2fd85df72 Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Fri, 28 Aug 2026 15:33:52 -0600 Subject: [PATCH 04/10] fix performance issues with the Onyx key subscriptions --- src/pages/settings/Wallet/WalletPage/index.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index 2176746db033..a13d823480b2 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -99,11 +99,9 @@ function WalletPage() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [lockedVBAUnlockRequestedNVPs] = useOnyx(ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED); - const lockedVBAUnlockRequestedNVPsRef = useRef(lockedVBAUnlockRequestedNVPs); - useEffect(() => { - lockedVBAUnlockRequestedNVPsRef.current = lockedVBAUnlockRequestedNVPs; - }, [lockedVBAUnlockRequestedNVPs]); + const lockedBankAccountID = Object.values(bankAccountList).find((account) => account?.accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED)?.accountData?.bankAccountID; + // eslint-disable-next-line rulesdir/no-default-id-values + const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${lockedBankAccountID ?? CONST.DEFAULT_NUMBER_ID}`); const delegateAccountID = useDelegateAccountID(); const isUserValidated = userAccount?.validated ?? false; const {isBetaEnabled} = usePermissions(); @@ -163,7 +161,6 @@ function WalletPage() { paymentMethodButtonRef.current = event?.currentTarget as HTMLDivElement; if (accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED && accountData?.bankAccountID) { - const unlockRequestedAt = lockedVBAUnlockRequestedNVPsRef.current?.[`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${accountData.bankAccountID}`]; if (unlockRequestedAt) { showUnlockAlreadyRequestedModal(showConfirmModal, translate); return; From f09b6df7d03ca53eee3ea54a8f3793043aa9fbd4 Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Fri, 28 Aug 2026 15:40:47 -0600 Subject: [PATCH 05/10] remove Onyx.connectWithoutView call and pass the Onyx value as a parameter instead --- src/components/SettlementButton/index.tsx | 3 ++- src/libs/actions/BankAccounts.ts | 15 +++++++-------- .../items/UnlockBankAccount.tsx | 3 ++- src/pages/settings/Wallet/WalletPage/index.tsx | 3 ++- .../workflows/tabs/WorkflowsPaymentsTab.tsx | 3 ++- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index dfcbe2f8ef9f..3641a29b1314 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -158,6 +158,7 @@ function SettlementButton({ const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); // eslint-disable-next-line rulesdir/no-default-id-values const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${policy?.achAccount?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID}`); + const [initiatingBankAccountUnlock] = useOnyx(ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -203,7 +204,7 @@ function SettlementButton({ if (policy?.achAccount?.bankAccountID === undefined) { return; } - pressLockedBankAccount(policy?.achAccount?.bankAccountID, translate, conciergeReportID, delegateAccountID); + pressLockedBankAccount(policy?.achAccount?.bankAccountID, translate, conciergeReportID, delegateAccountID, initiatingBankAccountUnlock); navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas); }); } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 95123e96f1c0..992f5863e2db 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -74,13 +74,6 @@ Onyx.connectWithoutView({ callback: (value) => (bankAccountList = value), }); -let initiatingBankAccountUnlock: OnyxEntry; - -Onyx.connectWithoutView({ - key: ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK, - callback: (value) => (initiatingBankAccountUnlock = value), -}); - type AccountFormValues = typeof ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM | typeof ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM; type OpenPersonalBankAccountSetupViewProps = { @@ -1866,7 +1859,13 @@ function initiateBankAccountUnlock(bankAccountID: number, conciergeReportID: str return API.write(WRITE_COMMANDS.INITIATE_BANK_ACCOUNT_UNLOCK, {bankAccountID, authToken, optimisticReportActionID}, onyxData); } -function pressLockedBankAccount(bankAccountID: number, translate: LocalizedTranslate, conciergeReportID: string | undefined, delegateAccountID: number | undefined) { +function pressLockedBankAccount( + bankAccountID: number, + translate: LocalizedTranslate, + conciergeReportID: string | undefined, + delegateAccountID: number | undefined, + initiatingBankAccountUnlock: OnyxEntry, +) { if (initiatingBankAccountUnlock?.isLoading && initiatingBankAccountUnlock?.bankAccountIDToUnlock === bankAccountID) { return; } diff --git a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx index db03aabda049..9d8c74f93b5e 100644 --- a/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx +++ b/src/pages/home/TimeSensitiveSection/items/UnlockBankAccount.tsx @@ -37,6 +37,7 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID}`); + const [initiatingBankAccountUnlock] = useOnyx(ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK); const {showConfirmModal} = useConfirmModal(); const title = policyName ? translate('homePage.timeSensitiveSection.unlockBankAccount.workspaceTitle') : translate('homePage.timeSensitiveSection.unlockBankAccount.personalTitle'); @@ -50,7 +51,7 @@ function UnlockBankAccount({bankAccountID, policyName}: UnlockBankAccountProps) showUnlockAlreadyRequestedModal(showConfirmModal, translate); return; } - pressLockedBankAccount(bankAccountID, translate, conciergeReportID, delegateAccountID); + pressLockedBankAccount(bankAccountID, translate, conciergeReportID, delegateAccountID, initiatingBankAccountUnlock); navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas); }; diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index a13d823480b2..b83d30b6f737 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -102,6 +102,7 @@ function WalletPage() { const lockedBankAccountID = Object.values(bankAccountList).find((account) => account?.accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED)?.accountData?.bankAccountID; // eslint-disable-next-line rulesdir/no-default-id-values const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${lockedBankAccountID ?? CONST.DEFAULT_NUMBER_ID}`); + const [initiatingBankAccountUnlock] = useOnyx(ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK); const delegateAccountID = useDelegateAccountID(); const isUserValidated = userAccount?.validated ?? false; const {isBetaEnabled} = usePermissions(); @@ -165,7 +166,7 @@ function WalletPage() { showUnlockAlreadyRequestedModal(showConfirmModal, translate); return; } - pressLockedBankAccount(accountData.bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID); + pressLockedBankAccount(accountData.bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID, initiatingBankAccountUnlock); navigateToConciergeChat(conciergeReportID ?? undefined, introSelected, currentUserAccountID, isSelfTourViewed, betas); return; } diff --git a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx index bcf52a02ed78..3880b18bef86 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx @@ -134,6 +134,7 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { const state = isBankAccountFullySetup ? (policy?.achAccount?.state ?? '') : (bankAccountConnectedToWorkspace?.accountData?.state ?? ''); // eslint-disable-next-line rulesdir/no-default-id-values const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID ?? CONST.DEFAULT_NUMBER_ID}`); + const [initiatingBankAccountUnlock] = useOnyx(ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK); const isAccountInSetupState = isBankAccountPartiallySetup(state); const isBusinessBankAccountLocked = state === CONST.BANK_ACCOUNT.STATE.LOCKED; const canChangePayer = canWritePayments && !isAccountInSetupState; @@ -197,7 +198,7 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { showUnlockAlreadyRequestedModal(showConfirmModal, translate); return; } - pressLockedBankAccount(bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID); + pressLockedBankAccount(bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID, initiatingBankAccountUnlock); navigateToConciergeChat(conciergeReportID ?? undefined, introSelected, currentUserAccountID, isSelfTourViewed, betas); return; } From 7d481dcd38e659faa9b1ea7892289bf7d604bf9d Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Fri, 28 Aug 2026 15:48:11 -0600 Subject: [PATCH 06/10] add unit tests for the unlock bank account behaviour --- tests/unit/BankAccountUtilsTest.ts | 16 ++++++++ .../UnlockBankAccountTest.tsx | 38 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts index ba7c454bb400..f47142244d33 100644 --- a/tests/unit/BankAccountUtilsTest.ts +++ b/tests/unit/BankAccountUtilsTest.ts @@ -18,6 +18,7 @@ import { isUserDOBVerificationRequired, PERSONAL_INFO_STEP, shouldShowInternationalDetailOnConfirmation, + showUnlockAlreadyRequestedModal, } from '@libs/BankAccountUtils'; import type {KYBVerificationResponses} from '@libs/BankAccountUtils'; @@ -816,6 +817,21 @@ describe('BankAccountUtils', () => { }); }); + describe('showUnlockAlreadyRequestedModal', () => { + it('calls showConfirmModal with the unlock-already-requested content', () => { + const showConfirmModal = jest.fn(); + const translate = (key: string) => key; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + showUnlockAlreadyRequestedModal(showConfirmModal as any, translate); + expect(showConfirmModal).toHaveBeenCalledWith({ + title: 'bankAccount.unlockAlreadyRequestedTitle', + prompt: 'bankAccount.unlockAlreadyRequestedDescription', + confirmText: 'common.buttonConfirm', + shouldShowCancelButton: false, + }); + }); + }); + describe('getInternationalBankAccountDetailsValues', () => { const iban = 'AT483200000012345864'; diff --git a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx index 8d08765bfccc..bfb4ae543b9f 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx @@ -2,6 +2,7 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import {pressLockedBankAccount} from '@libs/actions/BankAccounts'; import {navigateToConciergeChat} from '@libs/actions/Report'; +import {showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; @@ -65,6 +66,10 @@ jest.mock('@libs/actions/Report', () => ({ navigateToConciergeChat: jest.fn(), })); +jest.mock('@libs/BankAccountUtils', () => ({ + showUnlockAlreadyRequestedModal: jest.fn(), +})); + const ADMIN_ACCOUNT_ID = 12345; const LOCKED_BANK_ACCOUNT_ID = 99; const POLICY_ID = 'policy_1'; @@ -302,6 +307,37 @@ describe('TimeSensitiveSection - UnlockBankAccount', () => { } }); + it('shows the already-requested modal and skips pressLockedBankAccount when the NVP is set', 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 Onyx.set(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${LOCKED_BANK_ACCOUNT_ID}`, '2024-01-01T00:00:00.000Z'); + await waitForBatchedUpdates(); + + renderTimeSensitiveSection(); + + const cta = screen.getByText('homePage.timeSensitiveSection.ctaFix'); + fireEvent.press(cta); + + expect(showUnlockAlreadyRequestedModal).toHaveBeenCalled(); + expect(pressLockedBankAccount).not.toHaveBeenCalled(); + }); + 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); @@ -328,7 +364,7 @@ describe('TimeSensitiveSection - UnlockBankAccount', () => { 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(pressLockedBankAccount).toHaveBeenCalledWith(LOCKED_BANK_ACCOUNT_ID, expect.any(Function), CONCIERGE_REPORT_ID, undefined, undefined); expect(navigateToConciergeChat).toHaveBeenCalledWith(CONCIERGE_REPORT_ID, undefined, ADMIN_ACCOUNT_ID, false, undefined); }); }); From ef8725a8200ac5d4e31c10d51820999650138fb9 Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Fri, 28 Aug 2026 16:10:53 -0600 Subject: [PATCH 07/10] fix typescript and lint checks --- tests/unit/BankAccountUtilsTest.ts | 16 ---------------- .../UnlockBankAccountTest.tsx | 10 ++++++---- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts index f47142244d33..ba7c454bb400 100644 --- a/tests/unit/BankAccountUtilsTest.ts +++ b/tests/unit/BankAccountUtilsTest.ts @@ -18,7 +18,6 @@ import { isUserDOBVerificationRequired, PERSONAL_INFO_STEP, shouldShowInternationalDetailOnConfirmation, - showUnlockAlreadyRequestedModal, } from '@libs/BankAccountUtils'; import type {KYBVerificationResponses} from '@libs/BankAccountUtils'; @@ -817,21 +816,6 @@ describe('BankAccountUtils', () => { }); }); - describe('showUnlockAlreadyRequestedModal', () => { - it('calls showConfirmModal with the unlock-already-requested content', () => { - const showConfirmModal = jest.fn(); - const translate = (key: string) => key; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - showUnlockAlreadyRequestedModal(showConfirmModal as any, translate); - expect(showConfirmModal).toHaveBeenCalledWith({ - title: 'bankAccount.unlockAlreadyRequestedTitle', - prompt: 'bankAccount.unlockAlreadyRequestedDescription', - confirmText: 'common.buttonConfirm', - shouldShowCancelButton: false, - }); - }); - }); - describe('getInternationalBankAccountDetailsValues', () => { const iban = 'AT483200000012345864'; diff --git a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx index bfb4ae543b9f..85717fca802a 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx @@ -2,7 +2,6 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import {pressLockedBankAccount} from '@libs/actions/BankAccounts'; import {navigateToConciergeChat} from '@libs/actions/Report'; -import {showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import OnyxListItemProvider from '@src/components/OnyxListItemProvider'; import CONST from '@src/CONST'; @@ -14,6 +13,7 @@ import type * as NativeNavigation from '@react-navigation/native'; import Onyx from 'react-native-onyx'; +import {getShowConfirmModalOption, mockShowConfirmModal, resetMockConfirmModal} from '../../../../utils/mockUseConfirmModal'; import waitForBatchedUpdates from '../../../../utils/waitForBatchedUpdates'; jest.mock('@react-navigation/native', () => ({ @@ -66,8 +66,9 @@ jest.mock('@libs/actions/Report', () => ({ navigateToConciergeChat: jest.fn(), })); -jest.mock('@libs/BankAccountUtils', () => ({ - showUnlockAlreadyRequestedModal: jest.fn(), +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: mockShowConfirmModal, closeModal: jest.fn()}), })); const ADMIN_ACCOUNT_ID = 12345; @@ -94,6 +95,7 @@ describe('TimeSensitiveSection - UnlockBankAccount', () => { }); beforeEach(async () => { + resetMockConfirmModal(); await Onyx.clear(); await Onyx.set(ONYXKEYS.ACCOUNT, {primaryLogin: 'admin@example.com'}); await waitForBatchedUpdates(); @@ -334,7 +336,7 @@ describe('TimeSensitiveSection - UnlockBankAccount', () => { const cta = screen.getByText('homePage.timeSensitiveSection.ctaFix'); fireEvent.press(cta); - expect(showUnlockAlreadyRequestedModal).toHaveBeenCalled(); + expect(getShowConfirmModalOption('title')).toBe('bankAccount.unlockAlreadyRequestedTitle'); expect(pressLockedBankAccount).not.toHaveBeenCalled(); }); From 6f97279a120d7c74f8981062e6829ea82ecd5d3f Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Thu, 10 Sep 2026 01:06:07 -0600 Subject: [PATCH 08/10] restore from main --- .../isDeployChecklistLocked/index.js | 255 +++++++++++++++++- .../javascript/proposalPoliceComment/index.js | 255 +++++++++++++++++- 2 files changed, 500 insertions(+), 10 deletions(-) diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index f0d47fc82f50..d228861485f1 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -3431,7 +3431,9 @@ var require_CONST = __commonJS({ REGISTER_AUTHENTICATION_KEY: "register_authentication_key", REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", - REPORT_CARD_FRAUD: "report_card_fraud" + REPORT_CARD_FRAUD: "report_card_fraud", + ISSUE_CARD: "issue_card", + UPDATE_CARD: "update_card" }, EXPENSIFY_CARD: { FEED_NAME: "Expensify Card", @@ -23108,9 +23110,22 @@ var require_ExpensiMark = __commonJS({ var Logger_1 = __importDefault(require_Logger()); var Utils = __importStar(require_utils()); var EXTRAS_DEFAULT = {}; + var ASCII_DIGIT_START = "0".charCodeAt(0); + var ASCII_DIGIT_END = "9".charCodeAt(0); + var ASCII_UPPERCASE_START = "A".charCodeAt(0); + var ASCII_UPPERCASE_END = "Z".charCodeAt(0); + var ASCII_LOWERCASE_START = "a".charCodeAt(0); + var ASCII_LOWERCASE_END = "z".charCodeAt(0); + var ASCII_WHITESPACE_END = " ".charCodeAt(0); + var NON_BREAKING_SPACE_CODE = 160; + var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; + var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join("|")}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); + var BOLD_MARKDOWN_REGEX = /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; + var STRIKETHROUGH_MARKDOWN_REGEX = /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; var SLACK_SPAN_NEW_LINE_TAG = ''; var VICTORY_CHART_REGEX = /]*\/>|]*>[\s\S]*?<\/VictoryChart>/gi; var VICTORY_CHART_PLACEHOLDER_DELIMITER = String.fromCharCode(0); @@ -23150,6 +23165,218 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function isAsciiAlphaNumeric(character) { + if (!character) { + return false; + } + const code = character.charCodeAt(0); + return code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END || code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END || code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END; + } + function isWordCharacter(character) { + return character === "_" || isAsciiAlphaNumeric(character); + } + function canOpenBoldMarkdown(text, position, isProtected) { + if (isProtected) { + return false; + } + const nextCharacter = text[position + 1]; + if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith("", tagStart + 1); + if (tagEnd === -1) { + return void 0; + } + const tag = text.slice(tagStart + 1, tagEnd).trim(); + const isClosingTag = tag.startsWith("/"); + const tagName = (_b = (_a = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a === void 0 ? void 0 : _a[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + if (tagName && PROTECTED_TAG_NAMES.has(tagName)) { + if (isClosingTag) { + const matchingTagIndex = protectedTags.lastIndexOf(tagName); + if (matchingTagIndex !== -1) { + protectedTags.splice(matchingTagIndex, 1); + } + } else if (!tag.endsWith("/")) { + protectedTags.push(tagName); + } + } + return tagEnd + 1; + } + function isHostnameCharacter(character) { + return !!character && (isAsciiAlphaNumeric(character) || character === "-" || character === "."); + } + function isUrlBoundarySpace(character) { + const code = character.charCodeAt(0); + return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE; + } + function getProtocolAt(text, position) { + var _a; + const firstCharacter = (_a = text[position]) === null || _a === void 0 ? void 0 : _a.toLowerCase(); + if (firstCharacter !== "h" && firstCharacter !== "f") { + return void 0; + } + return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); + } + function findHostnameEnd(text, hostnameStart, dotPosition) { + let hostnameEnd = dotPosition + 1; + while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { + hostnameEnd++; + } + if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { + return void 0; + } + return hostnameEnd; + } + function extendUrlCandidateBoundaries(text, start, end) { + let candidateStart = start; + while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) { + candidateStart--; + } + let candidateEnd = end; + while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== "<") { + candidateEnd++; + } + return { start: candidateStart, end: candidateEnd }; + } + function findUrlCandidates(text) { + const candidates = []; + const protectedTags = []; + let index = 0; + let hostnameRunStart = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + hostnameRunStart = index; + continue; + } + if (protectedTags.length > 0) { + index++; + hostnameRunStart = index; + continue; + } + const matchedProtocol = getProtocolAt(text, index); + if (matchedProtocol) { + const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + candidates.push(candidate2); + index = candidate2.end; + hostnameRunStart = candidate2.end; + continue; + } + if (!isHostnameCharacter(text[index])) { + hostnameRunStart = index + 1; + index++; + continue; + } + if (text[index] !== ".") { + index++; + continue; + } + const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); + if (hostnameEnd === void 0) { + index++; + continue; + } + const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + candidates.push(candidate); + index = candidate.end; + hostnameRunStart = candidate.end; + } + return candidates; + } + function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { + if (!text.includes(marker)) { + return text; + } + const markers = []; + const protectedTags = []; + let index = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + continue; + } + if (text[index] === marker) { + markers.push({ position: index, isProtected: protectedTags.length > 0 }); + } + index++; + } + if (markers.length < 2) { + return text; + } + const output = []; + const candidateRegex = regexp; + let outputStart = 0; + let openingMarker; + for (const currentMarker of markers) { + const markerPosition = currentMarker.position; + if (openingMarker === void 0) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) { + continue; + } + if (openingMarker.isProtected) { + openingMarker = void 0; + continue; + } + const openingPosition = openingMarker.position; + const prefixLength = openingPosition > 0 ? 1 : 0; + const suffixLength = markerPosition + 1 < text.length ? 1 : 0; + const candidateStart = openingPosition - prefixLength; + const candidateEnd = markerPosition + 1 + suffixLength; + const candidate = text.slice(candidateStart, candidateEnd); + candidateRegex.lastIndex = 0; + const candidateMatch = candidateRegex.exec(candidate); + if (!candidateMatch) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + candidateRegex.lastIndex = 0; + const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement); + if (replacedCandidate !== candidate) { + const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length; + output.push(text.slice(outputStart, openingPosition)); + output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd)); + outputStart = markerPosition + 1; + openingMarker = void 0; + continue; + } + openingMarker = void 0; + } + if (output.length === 0) { + return text; + } + output.push(text.slice(outputStart)); + return output.join(""); + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
> (|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -23570,7 +23797,7 @@ var require_ExpensiMark = __commonJS({ name: "autolink", process: (textToProcess, replacement) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -23675,7 +23902,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - regex: /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), replacement: (_extras, match, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -23685,7 +23912,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - regex: /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match, g1) => g1.includes("") || containsNonPairTag(g1) ? match : `${g1}` }, { @@ -24075,7 +24302,25 @@ ${g2} /** * Checks matched URLs for validity and replace valid links with html elements */ - modifyTextForUrlLinks(regex2, textToCheck, replacement) { + modifyTextForUrlLinks(regex2, textToCheck, replacement, shouldScanForUrls = false) { + if (shouldScanForUrls) { + const candidates = findUrlCandidates(textToCheck); + if (candidates.length === 0) { + return textToCheck; + } + const output = []; + const candidateRegex = regex2; + let outputStart = 0; + for (const { start, end } of candidates) { + const candidate = textToCheck.slice(start, end); + candidateRegex.lastIndex = 0; + output.push(textToCheck.slice(outputStart, start)); + output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement)); + outputStart = end; + } + output.push(textToCheck.slice(outputStart)); + return output.join(""); + } let match = regex2.exec(textToCheck); let replacedText = ""; let startIndex = 0; diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 3aad2c81f960..84529ca85767 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -24245,7 +24245,9 @@ var require_CONST = __commonJS({ REGISTER_AUTHENTICATION_KEY: "register_authentication_key", REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", - REPORT_CARD_FRAUD: "report_card_fraud" + REPORT_CARD_FRAUD: "report_card_fraud", + ISSUE_CARD: "issue_card", + UPDATE_CARD: "update_card" }, EXPENSIFY_CARD: { FEED_NAME: "Expensify Card", @@ -43922,9 +43924,22 @@ var require_ExpensiMark = __commonJS({ var Logger_1 = __importDefault(require_Logger()); var Utils = __importStar(require_utils2()); var EXTRAS_DEFAULT = {}; + var ASCII_DIGIT_START = "0".charCodeAt(0); + var ASCII_DIGIT_END = "9".charCodeAt(0); + var ASCII_UPPERCASE_START = "A".charCodeAt(0); + var ASCII_UPPERCASE_END = "Z".charCodeAt(0); + var ASCII_LOWERCASE_START = "a".charCodeAt(0); + var ASCII_LOWERCASE_END = "z".charCodeAt(0); + var ASCII_WHITESPACE_END = " ".charCodeAt(0); + var NON_BREAKING_SPACE_CODE = 160; + var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; + var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join("|")}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); + var BOLD_MARKDOWN_REGEX = /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; + var STRIKETHROUGH_MARKDOWN_REGEX = /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; var SLACK_SPAN_NEW_LINE_TAG = ''; var VICTORY_CHART_REGEX = /]*\/>|]*>[\s\S]*?<\/VictoryChart>/gi; var VICTORY_CHART_PLACEHOLDER_DELIMITER = String.fromCharCode(0); @@ -43964,6 +43979,218 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function isAsciiAlphaNumeric(character) { + if (!character) { + return false; + } + const code = character.charCodeAt(0); + return code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END || code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END || code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END; + } + function isWordCharacter(character) { + return character === "_" || isAsciiAlphaNumeric(character); + } + function canOpenBoldMarkdown(text, position, isProtected) { + if (isProtected) { + return false; + } + const nextCharacter = text[position + 1]; + if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith("", tagStart + 1); + if (tagEnd === -1) { + return void 0; + } + const tag = text.slice(tagStart + 1, tagEnd).trim(); + const isClosingTag = tag.startsWith("/"); + const tagName = (_b = (_a3 = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a3 === void 0 ? void 0 : _a3[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + if (tagName && PROTECTED_TAG_NAMES.has(tagName)) { + if (isClosingTag) { + const matchingTagIndex = protectedTags.lastIndexOf(tagName); + if (matchingTagIndex !== -1) { + protectedTags.splice(matchingTagIndex, 1); + } + } else if (!tag.endsWith("/")) { + protectedTags.push(tagName); + } + } + return tagEnd + 1; + } + function isHostnameCharacter(character) { + return !!character && (isAsciiAlphaNumeric(character) || character === "-" || character === "."); + } + function isUrlBoundarySpace(character) { + const code = character.charCodeAt(0); + return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE; + } + function getProtocolAt(text, position) { + var _a3; + const firstCharacter = (_a3 = text[position]) === null || _a3 === void 0 ? void 0 : _a3.toLowerCase(); + if (firstCharacter !== "h" && firstCharacter !== "f") { + return void 0; + } + return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); + } + function findHostnameEnd(text, hostnameStart, dotPosition) { + let hostnameEnd = dotPosition + 1; + while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { + hostnameEnd++; + } + if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { + return void 0; + } + return hostnameEnd; + } + function extendUrlCandidateBoundaries(text, start, end) { + let candidateStart = start; + while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) { + candidateStart--; + } + let candidateEnd = end; + while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== "<") { + candidateEnd++; + } + return { start: candidateStart, end: candidateEnd }; + } + function findUrlCandidates(text) { + const candidates = []; + const protectedTags = []; + let index = 0; + let hostnameRunStart = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + hostnameRunStart = index; + continue; + } + if (protectedTags.length > 0) { + index++; + hostnameRunStart = index; + continue; + } + const matchedProtocol = getProtocolAt(text, index); + if (matchedProtocol) { + const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + candidates.push(candidate2); + index = candidate2.end; + hostnameRunStart = candidate2.end; + continue; + } + if (!isHostnameCharacter(text[index])) { + hostnameRunStart = index + 1; + index++; + continue; + } + if (text[index] !== ".") { + index++; + continue; + } + const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); + if (hostnameEnd === void 0) { + index++; + continue; + } + const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + candidates.push(candidate); + index = candidate.end; + hostnameRunStart = candidate.end; + } + return candidates; + } + function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { + if (!text.includes(marker)) { + return text; + } + const markers = []; + const protectedTags = []; + let index = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + continue; + } + if (text[index] === marker) { + markers.push({ position: index, isProtected: protectedTags.length > 0 }); + } + index++; + } + if (markers.length < 2) { + return text; + } + const output = []; + const candidateRegex = regexp; + let outputStart = 0; + let openingMarker; + for (const currentMarker of markers) { + const markerPosition = currentMarker.position; + if (openingMarker === void 0) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) { + continue; + } + if (openingMarker.isProtected) { + openingMarker = void 0; + continue; + } + const openingPosition = openingMarker.position; + const prefixLength = openingPosition > 0 ? 1 : 0; + const suffixLength = markerPosition + 1 < text.length ? 1 : 0; + const candidateStart = openingPosition - prefixLength; + const candidateEnd = markerPosition + 1 + suffixLength; + const candidate = text.slice(candidateStart, candidateEnd); + candidateRegex.lastIndex = 0; + const candidateMatch = candidateRegex.exec(candidate); + if (!candidateMatch) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + candidateRegex.lastIndex = 0; + const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement); + if (replacedCandidate !== candidate) { + const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length; + output.push(text.slice(outputStart, openingPosition)); + output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd)); + outputStart = markerPosition + 1; + openingMarker = void 0; + continue; + } + openingMarker = void 0; + } + if (output.length === 0) { + return text; + } + output.push(text.slice(outputStart)); + return output.join(""); + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
    > (|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -44384,7 +44611,7 @@ var require_ExpensiMark = __commonJS({ name: "autolink", process: (textToProcess, replacement) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -44489,7 +44716,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - regex: /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), replacement: (_extras, match2, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -44499,7 +44726,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - regex: /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match2, g1) => g1.includes("") || containsNonPairTag(g1) ? match2 : `${g1}` }, { @@ -44889,7 +45116,25 @@ ${g2} /** * Checks matched URLs for validity and replace valid links with html elements */ - modifyTextForUrlLinks(regex2, textToCheck, replacement) { + modifyTextForUrlLinks(regex2, textToCheck, replacement, shouldScanForUrls = false) { + if (shouldScanForUrls) { + const candidates = findUrlCandidates(textToCheck); + if (candidates.length === 0) { + return textToCheck; + } + const output = []; + const candidateRegex = regex2; + let outputStart = 0; + for (const { start, end } of candidates) { + const candidate = textToCheck.slice(start, end); + candidateRegex.lastIndex = 0; + output.push(textToCheck.slice(outputStart, start)); + output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement)); + outputStart = end; + } + output.push(textToCheck.slice(outputStart)); + return output.join(""); + } let match2 = regex2.exec(textToCheck); let replacedText = ""; let startIndex = 0; From f9614cfe4c8dfd3f97367e0f90fed81cd7f340b6 Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Thu, 17 Sep 2026 10:15:44 -0600 Subject: [PATCH 09/10] set nvp_expensify_vbaUnlockRequested_ optimistically while offline --- src/libs/actions/BankAccounts.ts | 14 +++++++++++++- .../TimeSensitiveSection/UnlockBankAccountTest.tsx | 1 - 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index e795a60881fd..11342d6c3073 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -1766,7 +1766,9 @@ function openBankAccountSharePage() { function initiateBankAccountUnlock(bankAccountID: number, conciergeReportID: string | undefined, optimisticReportActionID: string | null | undefined) { const authToken = NetworkStore.getAuthToken(); - const onyxData: OnyxData = { + const nvpUnlockRequestedKey = `${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID}` as const; + + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1776,6 +1778,11 @@ function initiateBankAccountUnlock(bankAccountID: number, conciergeReportID: str isSuccess: false, }, }, + { + onyxMethod: Onyx.METHOD.SET, + key: nvpUnlockRequestedKey, + value: new Date().toISOString(), + }, ], successData: [ { @@ -1809,6 +1816,11 @@ function initiateBankAccountUnlock(bankAccountID: number, conciergeReportID: str errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), }, }, + { + onyxMethod: Onyx.METHOD.SET, + key: nvpUnlockRequestedKey, + value: null, + }, ...(optimisticReportActionID && conciergeReportID ? [ { diff --git a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx index b617c64b6547..706da5f7691f 100644 --- a/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx +++ b/tests/unit/pages/home/TimeSensitiveSection/UnlockBankAccountTest.tsx @@ -315,7 +315,6 @@ describe('TimeSensitiveSection - UnlockBankAccount', () => { name: POLICY_NAME, role: CONST.POLICY.ROLE.ADMIN, type: CONST.POLICY.TYPE.TEAM, - isPolicyExpenseChatEnabled: true, achAccount: { bankAccountID: LOCKED_BANK_ACCOUNT_ID, accountNumber: 'XXXXXXXX1234', From 2f7477c53c28a221ac48eefb5cf518efcb0ba3f5 Mon Sep 17 00:00:00 2001 From: Akinwale Ariwodola Date: Thu, 17 Sep 2026 10:58:49 -0600 Subject: [PATCH 10/10] properly handle unlock requested while offline on the Wallet page --- src/libs/actions/BankAccounts.ts | 3 +++ src/pages/settings/Wallet/WalletPage/index.tsx | 6 ++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 11342d6c3073..ed47f8dd45ae 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -1888,6 +1888,9 @@ function pressLockedBankAccount( bankAccountIDToUnlock: bankAccountID, optimisticReportActionID: optimisticReportActionID ?? null, }); + + // Write the NVP immediately so the "already requested" guard fires on the next press. + Onyx.merge(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID}`, new Date().toISOString()); } export { diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index aa02e72a404d..256d5a085ab5 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -98,9 +98,7 @@ function WalletPage() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); - const lockedBankAccountID = Object.values(bankAccountList).find((account) => account?.accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED)?.accountData?.bankAccountID; - // eslint-disable-next-line rulesdir/no-default-id-values - const [unlockRequestedAt] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${lockedBankAccountID ?? CONST.DEFAULT_NUMBER_ID}`); + const [nvpLockedVbaUnlockRequested] = useOnyx(ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED); const [initiatingBankAccountUnlock] = useOnyx(ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK); const delegateAccountID = useDelegateAccountID(); const isUserValidated = userAccount?.validated ?? false; @@ -159,7 +157,7 @@ function WalletPage() { paymentMethodButtonRef.current = event?.currentTarget as HTMLDivElement; if (accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED && accountData?.bankAccountID) { - if (unlockRequestedAt) { + if (nvpLockedVbaUnlockRequested?.[`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${accountData.bankAccountID}`]) { showUnlockAlreadyRequestedModal(showConfirmModal, translate); return; }