Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,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_',

Expand Down Expand Up @@ -1557,6 +1560,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<typeof CONST.REPORT.SUBMISSION_METHOD>;
[ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER]: OnyxTypes.PolicyJoinMember;
[ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS]: OnyxTypes.PolicyConnectionSyncProgress;
Expand Down
49 changes: 28 additions & 21 deletions src/components/SettlementButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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';
Expand Down Expand Up @@ -158,6 +158,9 @@ 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 [initiatingBankAccountUnlock] = useOnyx(ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK);

const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const delegateAccountID = useDelegateAccountID();
Expand All @@ -183,26 +186,30 @@ function SettlementButton({
// interrupted by account validation resumes, since the validation gate skipped them.
const checkForPostValidationBlockers = () => {
if (isBankAccountLocked) {
showConfirmModal({
title: translate('bankAccount.lockedBankAccount'),
prompt: (
<View style={[styles.renderHTML, styles.flexRow]}>
<RenderHTML html={translate('bankAccount.youCantPayThis')} />
</View>
),
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) {
showUnlockAlreadyRequestedModal(showConfirmModal, translate);
} else {
showConfirmModal({
title: translate('bankAccount.lockedBankAccount'),
prompt: (
<View style={[styles.renderHTML, styles.flexRow]}>
<RenderHTML html={translate('bankAccount.youCantPayThis')} />
</View>
),
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, initiatingBankAccountUnlock);
navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas});
});
}
return true;
}

Expand Down
2 changes: 2 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3910,6 +3910,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?',
Expand Down
3 changes: 3 additions & 0 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3964,6 +3964,9 @@ ${amount} για ${merchant} - ${date}`,
'Αυτός ο τραπεζικός λογαριασμός δεν μπορεί να διαγραφεί επειδή χρησιμοποιείται για πληρωμές με την Κάρτα Expensify. Αν εξακολουθείτε να θέλετε να διαγράψετε αυτόν τον λογαριασμό, παρακαλούμε επικοινωνήστε με το Concierge.',
sameDepositAndWithdrawalAccount: 'Οι λογαριασμοί κατάθεσης και ανάληψης είναι οι ίδιοι.',
},
unlockAlreadyRequestedTitle: 'Το αίτημα έχει ήδη υποβληθεί',
unlockAlreadyRequestedDescription:
'Το αίτημά σας για ξεκλείδωμα αυτού του τραπεζικού λογαριασμού έχει ήδη αποσταλεί. Το Concierge θα επικοινωνήσει μαζί σας αν χρειαστεί οτιδήποτε άλλο.',
},
addPersonalBankAccount: {
countrySelectionStepHeader: 'Πού βρίσκεται ο τραπεζικός σας λογαριασμός;',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3975,6 +3975,8 @@ const translations = {
lockedBankAccount: 'Locked bank account',
unlockBankAccount: 'Unlock bank account',
youCantPayThis: `You can't pay this report because you have a <a href="${CONST.UNLOCK_BANK_ACCOUNT_HELP_URL}">locked bank account</a>. 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) =>
`<h1>Expensify Business Bank Account ${maskedAccountNumber}</h1><p>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.</p>`,
textUnlockMessage: (maskedAccountNumber: string) =>
Expand Down
2 changes: 2 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3832,6 +3832,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?',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3916,6 +3916,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 ?',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3890,6 +3890,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?',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3858,6 +3858,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの'
deletePaymentBankAccount: 'この銀行口座は Expensify カードの支払いに使用されているため、削除できません。この口座を削除したい場合は、Concierge までご連絡ください。',
sameDepositAndWithdrawalAccount: '入金口座と出金口座が同じです。',
},
unlockAlreadyRequestedTitle: 'リクエストは既に送信されています',
unlockAlreadyRequestedDescription: 'この銀行口座のロック解除リクエストは既に送信されています。追加で必要なことがある場合は、Concierge からご連絡します。',
},
addPersonalBankAccount: {
countrySelectionStepHeader: '銀行口座はどこにありますか?',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3891,6 +3891,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?',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3919,6 +3919,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?',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3879,6 +3879,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?',
Expand Down
2 changes: 2 additions & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3765,6 +3765,8 @@ ${amount},商户:${merchant} - 日期:${date}`,
deletePaymentBankAccount: '此银行账户无法删除,因为它被用于 Expensify 卡付款。如果您仍希望删除此账户,请联系 Concierge。',
sameDepositAndWithdrawalAccount: '存款账户和取款账户相同。',
},
unlockAlreadyRequestedTitle: '请求已提交',
unlockAlreadyRequestedDescription: '您解锁此银行账户的请求已发送。如需其他信息,Concierge 会与您联系。',
},
addPersonalBankAccount: {
countrySelectionStepHeader: '您的银行账户位于哪个国家/地区?',
Expand Down
14 changes: 14 additions & 0 deletions src/libs/BankAccountUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +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';
Expand Down Expand Up @@ -284,6 +288,15 @@ function getRequiredKYBDocuments(externalApiResponses: KYBVerificationResponses)
return requiredDocuments;
}

function showUnlockAlreadyRequestedModal(showConfirmModal: ReturnType<typeof useConfirmModal>['showConfirmModal'], translate: LocaleContextProps['translate']) {
showConfirmModal({
title: translate('bankAccount.unlockAlreadyRequestedTitle'),
prompt: translate('bankAccount.unlockAlreadyRequestedDescription'),
confirmText: translate('common.buttonConfirm'),
shouldShowCancelButton: false,
});
}

export {
getBankAccountSearchLabel,
isFilterableBankAccount,
Expand All @@ -301,6 +314,7 @@ export {
doesPolicyHavePartiallySetupBankAccount,
isPersonalBankAccountMissingInfo,
getCompletedStepsForBankAccount,
showUnlockAlreadyRequestedModal,
PERSONAL_INFO_STEP,
};
export type {BankAccountConnectionStatus, KYBVerificationResponses};
1 change: 1 addition & 0 deletions src/libs/ExportOnyxState/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ const onyxKeysToMaskFragileData = new Set<string>([
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,
Expand Down
31 changes: 28 additions & 3 deletions src/libs/actions/BankAccounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,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';
Expand Down Expand Up @@ -1766,7 +1766,9 @@ function openBankAccountSharePage() {
function initiateBankAccountUnlock(bankAccountID: number, conciergeReportID: string | undefined, optimisticReportActionID: string | null | undefined) {
const authToken = NetworkStore.getAuthToken();

const onyxData: OnyxData<typeof ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS> = {
const nvpUnlockRequestedKey = `${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID}` as const;

const onyxData: OnyxData<typeof ONYXKEYS.INITIATING_BANK_ACCOUNT_UNLOCK | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED> = {
optimisticData: [
{
onyxMethod: Onyx.METHOD.MERGE,
Expand All @@ -1776,6 +1778,11 @@ function initiateBankAccountUnlock(bankAccountID: number, conciergeReportID: str
isSuccess: false,
},
},
{
onyxMethod: Onyx.METHOD.SET,
key: nvpUnlockRequestedKey,
value: new Date().toISOString(),
},
],
successData: [
{
Expand Down Expand Up @@ -1809,6 +1816,11 @@ function initiateBankAccountUnlock(bankAccountID: number, conciergeReportID: str
errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'),
},
},
{
onyxMethod: Onyx.METHOD.SET,
key: nvpUnlockRequestedKey,
value: null,
},
...(optimisticReportActionID && conciergeReportID
? [
{
Expand All @@ -1824,7 +1836,17 @@ 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<InitiatingBankAccountUnlock>,
) {
if (initiatingBankAccountUnlock?.isLoading && initiatingBankAccountUnlock?.bankAccountIDToUnlock === bankAccountID) {
return;
}
Comment on lines +1846 to +1848

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track each in-flight bank account independently

When multiple locked accounts are acted on before their requests finish, this singleton guard remembers only the latest ID. For example, pressing A, then B, then A while responses are slow makes the third call see B as bankAccountIDToUnlock, bypass the guard, and enqueue a second unlock request for A. Track in-flight state per bank-account ID so alternating between accounts cannot evade deduplication.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is based on the existing implementation. Implementing per bank account ID would require a major refactor and is not in scope for this PR.


let optimisticReportActionID: string | undefined;

if (conciergeReportID) {
Expand Down Expand Up @@ -1866,6 +1888,9 @@ function pressLockedBankAccount(bankAccountID: number, translate: LocalizedTrans
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 {
Expand Down
Loading
Loading