Skip to content
Merged
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
202 changes: 109 additions & 93 deletions src/components/SettlementButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
import usePolicy from '@hooks/usePolicy';
import useThemeStyles from '@hooks/useThemeStyles';
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 createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import {isTrackOnboardingChoice} from '@libs/OnboardingUtils';
import {formatPaymentMethods, getActivePaymentType, getBusinessBankAccountOptions, matchesCurrency} from '@libs/PaymentUtils';
Expand All @@ -50,19 +50,19 @@ import {navigateToConciergeChat} from '@userActions/Report';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
import type {AccountData, BankAccount, LastPaymentMethodType, Policy} from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

import type {GestureResponderEvent} from 'react-native';
import type {TupleToUnion} from 'type-fest';

import {delegateEmailSelector} from '@selectors/Account';
import {delegateEmailSelector, isUserValidatedSelector} from '@selectors/Account';
import {hasSeenTourSelector} from '@selectors/Onboarding';
import {personalDetailsLoginSelector} from '@selectors/PersonalDetails';
import truncate from 'lodash/truncate';
import React, {useContext} from 'react';
import React, {useCallback, useContext} from 'react';
import {View} from 'react-native';

import type SettlementButtonProps from './types';
Expand Down Expand Up @@ -127,6 +127,7 @@ function SettlementButton({
const [conciergeReportID = ''] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
const [iouReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport?.reportID}`);
const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(iouReport?.ownerAccountID)});
const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector});
const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED);
const policyEmployeeAccountIDs = getPolicyEmployeeAccountIDs(policy, accountID);
const reportBelongsToWorkspace = policyID ? doesReportBelongToWorkspace(chatReport, policyEmployeeAccountIDs, policyID, conciergeReportID) : false;
Expand Down Expand Up @@ -199,73 +200,76 @@ function SettlementButton({
return formattedPaymentMethods.filter((ba) => (ba.accountData as AccountData)?.type === CONST.BANK_ACCOUNT.TYPE.PERSONAL);
}

// The guards checked after the account-validation gate. Also re-checked when a payment
// 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'),
}).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;
}

if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) {
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id));
return true;
}

return false;
};

const {isUserValidated, verifyAccountAndResume} = useVerifyAccountAndResume((retry?: () => void) => {
// The validation gate returned before these guards could run, so apply them to the resumed.
if (checkForPostValidationBlockers()) {
return;
}
retry?.();
});
const checkForNecessaryAction = useCallback(
(paymentMethodType?: PaymentMethodType) => {
if (isDelegateAccessRestricted) {
showDelegateNoAccessModal();
return true;
}

const checkForNecessaryAction = (paymentMethodType?: PaymentMethodType, retry?: () => void) => {
if (isDelegateAccessRestricted) {
showDelegateNoAccessModal();
return true;
}
if (isAccountLocked) {
showLockedAccountModal();
return true;
}

if (isAccountLocked) {
showLockedAccountModal();
return true;
}
if (!isUserValidated && paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) {
Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.VERIFY_ACCOUNT.path));
return true;
Comment on lines +215 to +217

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 Resume payment after magic-code verification

For an unvalidated user who chooses any non-Pay Elsewhere method, this branch only navigates to the verify-account screen and then returns, so the selected payment/KYC action is discarded when the magic-code flow validates and closes. In the inspected SettlementButton flow, callers such as handlePaymentSelection and the payment-method submenu onSelected handlers all stop on this return, leaving the user back on the report with no payment attempted unless they click Pay again.

Useful? React with 👍 / 👎.

}

if (!isUserValidated && paymentMethodType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE) {
verifyAccountAndResume(retry);
return true;
}
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'),
}).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;
}

return checkForPostValidationBlockers();
};
if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) {
Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id));
return true;
}

const runPaymentAction = (paymentMethodType: PaymentMethodType | undefined, action: () => void) => {
if (checkForNecessaryAction(paymentMethodType, action)) {
return;
}
action();
};
return false;
},
[
isDelegateAccessRestricted,
isAccountLocked,
isUserValidated,
isBankAccountLocked,
policy,
userBillingGracePeriodEnds,
ownerBillingGracePeriodEnd,
showDelegateNoAccessModal,
showLockedAccountModal,
showConfirmModal,
translate,
styles.renderHTML,
styles.flexRow,
conciergeReportID,
introSelected,
currentUserAccountID,
isSelfTourViewed,
betas,
amountOwed,
delegateAccountID,
],
);

const shortFormPayElsewhereButton = {
text: translate('iou.pay'),
Expand Down Expand Up @@ -336,15 +340,17 @@ function SettlementButton({
value: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT,
description: account.description,
shouldIgnoreCompactStyle: true,
onSelected: () =>
runPaymentAction(CONST.IOU.PAYMENT_TYPE.VBBA, () =>
onPress({
paymentType: CONST.IOU.PAYMENT_TYPE.VBBA,
payAsBusiness: true,
methodID: account.methodID,
paymentMethod: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT,
}),
),
onSelected: () => {
if (checkForNecessaryAction(CONST.IOU.PAYMENT_TYPE.VBBA)) {
return;
}
onPress({
paymentType: CONST.IOU.PAYMENT_TYPE.VBBA,
payAsBusiness: true,
methodID: account.methodID,
paymentMethod: CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT,
});
},
});
}
}
Expand Down Expand Up @@ -397,15 +403,17 @@ function SettlementButton({
description: formattedPaymentMethod?.description ?? '',
icon: formattedPaymentMethod?.icon,
shouldUpdateSelectedIndex: true,
onSelected: () =>
runPaymentAction(CONST.IOU.PAYMENT_TYPE.EXPENSIFY, () =>
onPress({
paymentType: CONST.IOU.PAYMENT_TYPE.EXPENSIFY,
payAsBusiness,
methodID: formattedPaymentMethod.methodID,
paymentMethod: formattedPaymentMethod.accountType,
}),
),
onSelected: () => {
if (checkForNecessaryAction(CONST.IOU.PAYMENT_TYPE.EXPENSIFY)) {
return;
}
onPress({
paymentType: CONST.IOU.PAYMENT_TYPE.EXPENSIFY,
payAsBusiness,
methodID: formattedPaymentMethod.methodID,
paymentMethod: formattedPaymentMethod.accountType,
});
},
iconStyles: formattedPaymentMethod?.iconStyles,
iconHeight: formattedPaymentMethod?.iconSize,
iconWidth: formattedPaymentMethod?.iconSize,
Expand Down Expand Up @@ -457,7 +465,12 @@ function SettlementButton({
icon: icons.Cash,
value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE,
shouldUpdateSelectedIndex: true,
onSelected: () => runPaymentAction(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, () => onPress({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, payAsBusiness})),
onSelected: () => {
if (checkForNecessaryAction(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)) {
return;
}
onPress({paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, payAsBusiness});
},
},
];
};
Expand Down Expand Up @@ -564,8 +577,17 @@ function SettlementButton({
});
};

const executePaymentSelection = (event: KYCFlowEvent, selectedOption: string, triggerKYCFlow: TriggerKYCFlow, activePaymentType: ReturnType<typeof getActivePaymentType>) => {
const {paymentType, policyFromPaymentMethod, policyFromContext, shouldSelectPaymentMethod} = activePaymentType;
const handlePaymentSelection = (event: GestureResponderEvent | KeyboardEvent | undefined, selectedOption: string, triggerKYCFlow: (params: ContinueActionParams) => void) => {
const {paymentType, policyFromPaymentMethod, policyFromContext, shouldSelectPaymentMethod} = getActivePaymentType(
selectedOption,
activeAdminPolicies,
businessBankAccountOptions,
policyIDKey,
);

if (checkForNecessaryAction(paymentType)) {
return;
}
const isPayingWithMethod = paymentType !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE;

if ((!!policyFromPaymentMethod || shouldSelectPaymentMethod) && (isPayingWithMethod || !!policyFromPaymentMethod)) {
Expand All @@ -576,12 +598,6 @@ function SettlementButton({
selectPaymentType(event, selectedOption as PaymentMethodType);
};

const handlePaymentSelection = (event: KYCFlowEvent, selectedOption: string, triggerKYCFlow: TriggerKYCFlow) => {
const activePaymentType = getActivePaymentType(selectedOption, activeAdminPolicies, businessBankAccountOptions, policyIDKey);

runPaymentAction(activePaymentType.paymentType, () => executePaymentSelection(event, selectedOption, triggerKYCFlow, activePaymentType));
};

let customText: string;
if (shouldUseShortForm) {
customText = translate('iou.pay');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,7 @@ function useShouldRenderOverlay(condition: boolean, overlayProgress: Animated.Va
toValue: 0,
duration: OVERLAY_TIMING_DURATION,
useNativeDriver: false,
}).start(({finished}) => {
// When the hide animation is interrupted by a new show animation (the condition flipped back to true
// before the animation completed), this callback still fires with finished=false. Setting the state
// to false in that case would permanently hide the overlay that should be visible.
if (!finished) {
return;
}
}).start(() => {
setShouldRenderOverlay(false);
Comment on lines +25 to 26

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 Ignore interrupted overlay hide animations

When the overlay condition flips false and then back true before the 300ms hide animation completes, starting the show animation interrupts the hide animation but this callback still runs with finished=false; it now unconditionally sets shouldRenderOverlay to false while the current condition is true. On wide/super-wide RHP flows this unmounts the dim/click-catcher overlay until the condition cycles again, so quick RHP transitions can leave the underlying pane uncovered.

Useful? React with 👍 / 👎.

});
}
Expand Down
65 changes: 0 additions & 65 deletions src/hooks/useVerifyAccountAndResume.ts

This file was deleted.

10 changes: 0 additions & 10 deletions tests/ui/components/SettlementButtonTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,6 @@ jest.mock('@libs/actions/Policy/Policy', () => ({
createWorkspace: jest.fn(() => ({policyID: 'mock-created-policy-id'})),
}));

const mockVerifyAccountAndResume = jest.fn();

jest.mock('@hooks/useVerifyAccountAndResume', () => ({
__esModule: true,
default: jest.fn(() => ({
isUserValidated: true,
verifyAccountAndResume: mockVerifyAccountAndResume,
})),
}));

const ACCOUNT_ID = 1;
const ACCOUNT_LOGIN = 'test@user.com';
const REPORT_ID = '100';
Expand Down
Loading
Loading