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
14 changes: 1 addition & 13 deletions src/components/ButtonWithDropdownMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
anchorAlignment = defaultAnchorAlignment,
buttonRef,
onPress,
onPrimaryPress,
options,
onOptionSelected,
onSubItemSelected,
Expand Down Expand Up @@ -165,11 +164,7 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
setIsMenuVisible(!isMenuVisible);
return;
}
if (onPrimaryPress) {
onPrimaryPress();
} else if (selectedItem?.onSelected) {
selectedItem.onSelected();
} else if (selectedItem?.value) {
if (selectedItem?.value) {
onPress(e, selectedItem.value);
}
} else {
Expand All @@ -196,13 +191,6 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
const handlePress = (event?: GestureResponderEvent | KeyboardEvent) => {
if (!isSplitButton) {
setIsMenuVisible(!isMenuVisible);
} else if (onPrimaryPress) {
onPrimaryPress();
} else if (selectedItem?.onSelected) {
// Honor the item's own handler (as the dropdown menu does) so the main split-button press performs the exact
// action of the defaulted item — e.g. paying directly with a specific bank account — instead of the generic
// value-based path, which would lose the item's context (like a `methodID`) and route through a fallback flow.
selectedItem.onSelected();
} else if (selectedItem?.value) {
onPress(event, selectedItem.value);
}
Expand Down
7 changes: 0 additions & 7 deletions src/components/ButtonWithDropdownMenu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,6 @@ type ButtonWithDropdownMenuProps<TValueType> = WithSentryLabel & {
/** Whether the button should use split style or not */
isSplitButton?: boolean;

/**
* Action for the main split-button press, when the button face represents something no option in the list does —
* e.g. paying from an account that is deliberately not offered in the menu. Takes precedence over the selected
* option's own handler, which would otherwise act on an unrelated option.
*/
onPrimaryPress?: () => void;

/** Whether to use keyboard shortcuts for confirmation or not */
useKeyboardShortcuts?: boolean;

Expand Down
11 changes: 3 additions & 8 deletions src/components/SettlementButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {getLastPolicyBankAccountID, getLastPolicyPaymentMethod} from '@libs/acti
import {isBankAccountPartiallySetup} from '@libs/BankAccountUtils';
import Navigation from '@libs/Navigation/Navigation';
import {formatPaymentMethods, getActivePaymentType, getBusinessBankAccountOptions, matchesCurrency} from '@libs/PaymentUtils';
import {getAccessiblePolicyBankAccount, isPaidGroupPolicy, isPolicyAdmin, sortPoliciesByName} from '@libs/PolicyUtils';
import {isPaidGroupPolicy, isPolicyAdmin, sortPoliciesByName} from '@libs/PolicyUtils';
import {hasRequestFromCurrentAccount} from '@libs/ReportActionsUtils';
import {
doesReportBelongToWorkspace,
Expand Down Expand Up @@ -148,15 +148,10 @@ function SettlementButton({
const hasSinglePolicy = !isExpenseReport && activeAdminPolicies.length === 1;
const hasMultiplePolicies = !isExpenseReport && activeAdminPolicies.length > 1;
const formattedPaymentMethods = formatPaymentMethods(bankAccountList ?? {}, fundList ?? {}, styles, translate);
// Only show the workspace account when it is present in the current user's bankAccountList.
// The normal ShareBankAccountAndUpdatePolicyReimburser flow creates a payer-owned copy
// and updates the policy to use it, but we've observed accounts where the payer doesn't have
// access to the workspace account for some reason.
const policyBankAccount = getAccessiblePolicyBankAccount(policy, bankAccountList);
const canUsePolicyBankAccount = !!policyBankAccount;
const hasIntentToPay =
((formattedPaymentMethods.length === 1 && isIOUReport(iouReport)) ||
(canUsePolicyBankAccount && (policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.OPEN || policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.LOCKED))) &&
policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.OPEN ||
policy?.achAccount?.state === CONST.BANK_ACCOUNT.STATE.LOCKED) &&
!lastPaymentMethod;
const {isBetaEnabled} = usePermissions();
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
Expand Down
9 changes: 3 additions & 6 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import {getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils';
import Navigation from '@libs/Navigation/Navigation';
import TransitionTracker from '@libs/Navigation/TransitionTracker';
import {getLoginByAccountID} from '@libs/PersonalDetailsUtils';
import {canAccessPolicyBankAccount, getConnectedIntegration, isSubmitPolicy} from '@libs/PolicyUtils';
import {getConnectedIntegration, isSubmitPolicy} from '@libs/PolicyUtils';
import {getReportAccountingExportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils';
import {
canEditMultipleTransactions,
Expand Down Expand Up @@ -907,18 +907,15 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const policyIDsWithVBBA = useMemo(() => {
const result = [];
for (const policy of Object.values(policies ?? {})) {
// Bulk pay funds from the workspace bank account when no account was picked in the menu, so a workspace only
// counts here if the workspace account is actually shared with the current user. Anyone else — including a
// payer the account was never shared with — has to open the report and pick an account of their own.
if (!policy || !canAccessPolicyBankAccount(policy, bankAccountList)) {
if (!policy?.achAccount?.bankAccountID) {
continue;
}

result.push(policy.id);
}

return result;
}, [policies, bankAccountList]);
}, [policies]);

const exportSearchData = searchResults?.data;
const exportSearchType = searchResults?.search.type ?? queryJSON?.type;
Expand Down
34 changes: 4 additions & 30 deletions src/libs/PaymentUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {approveMoneyRequest} from './actions/IOU/ReportWorkflow';
import {isBankAccountPartiallySetup} from './BankAccountUtils';
import BankAccountModel from './models/BankAccount';
import Navigation from './Navigation/Navigation';
import {getAccessiblePolicyBankAccount, wasPaidWithPolicyBankAccount} from './PolicyUtils';
import {shouldRestrictUserBillableActions} from './SubscriptionUtils';

type KYCFlowEvent = GestureResponderEvent | KeyboardEvent | undefined;
Expand Down Expand Up @@ -342,23 +341,10 @@ function getActivePaymentType(
/**
* Get the last 4 digits of a bank account used for payment.
*
* @param accountNumber - Masked account number stored on the payment action itself. It is the only viewer-independent
* source, so it wins over any local lookup: the payer's account is not in every viewer's `bankAccountList`, and
* falling back to the policy account would show a different account to different people for the same payment.
* @param payerAccountID - Who made the payment. The workspace account is only a valid guess when the payment came from
* the designated payer; for anyone else it belongs to a different bank account than the one actually used.
* `policyACHAccountNumber` is the account number of the policy's default reimbursement account
* (`policy.achAccount.accountNumber`), used as a fallback when the payment doesn't name an account.
*/
function getBankAccountLastFourDigits(
bankAccountID: number | undefined,
bankAccountList: OnyxEntry<Record<string, BankAccount>>,
policy: OnyxEntry<Policy>,
accountNumber?: string,
payerAccountID?: number,
): string {
if (accountNumber) {
return accountNumber.slice(-4);
}

function getBankAccountLastFourDigits(bankAccountID: number | undefined, bankAccountList: OnyxEntry<Record<string, BankAccount>>, policyACHAccountNumber: string | undefined): string {
const bankAccount = bankAccountID ? bankAccountList?.[bankAccountID] : null;

if (bankAccount?.accountData?.accountNumber) {
Expand All @@ -369,19 +355,7 @@ function getBankAccountLastFourDigits(
if (bankAccountID != null) {
return '';
}

// Nothing on the action identifies the account, so the workspace account is a guess. Only make it for a payment by
// the designated payer — showing a non-payer admin's payment as the workspace account is wrong for every viewer,
// and it is exactly what makes the payer and the payer's colleagues see two different accounts.
if (!wasPaidWithPolicyBankAccount(policy, payerAccountID)) {
return '';
}

// Resolve the workspace account through `bankAccountList` when we can. `achAccount.accountNumber` goes stale while
// `achAccount.bankAccountID` moves on, so the two can name different accounts; the ID is the one that was debited.
const policyBankAccount = getAccessiblePolicyBankAccount(policy, bankAccountList);

return (policyBankAccount?.accountData?.accountNumber ?? policy?.achAccount?.accountNumber)?.slice(-4) ?? '';
return policyACHAccountNumber?.slice(-4) ?? '';
}

export {
Expand Down
75 changes: 0 additions & 75 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import ROUTES from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm';
import type {PolicyType} from '@src/types/form/WorkspaceConfirmationForm';
import type {
BankAccount,
BankAccountList,
OnyxInputOrEntry,
PersonalDetailsList,
Policy,
Expand Down Expand Up @@ -728,75 +726,6 @@ function isPolicyPayer(policy: OnyxEntry<Policy>, currentUserLogin: string | und
return canPayOnPolicy && currentUserLogin === reimburserEmail;
}

/**
* Whether an admin/payments admin who isn't the designated workspace payer can still pay reports on the policy.
* Unlike `isPolicyPayer`/`isPayer`, this must not drive active prompting (badges, GBRs, next steps, pay to-dos) —
* those stay payer-only.
*/
function canAdminPayReport(policy: OnyxInputOrEntry<Policy>, currentUserLogin: string): boolean {
// The admin pay path is for workspace expense reports. Personal policies should only offer Pay to the actual payer.
if (!isGroupPolicy(policy)) {
return false;
}

// Mirrors `isPolicyPayer`: reimbursement must be explicitly configured. Checking `arePaymentsEnabled` here would also
// match an unset `reimbursementChoice`, surfacing Pay on a policy whose payments aren't configured yet.
const isReimbursementConfigured =
policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL;

return isReimbursementConfigured && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS);
}

/**
* The workspace's connected bank account as it appears in the current user's own `bankAccountList`, or undefined when
* the account is not shared with them.
*
* Membership in `bankAccountList` is the only reliable signal, and it is deliberately not softened for the designated
* payer: the backend only enumerates an account for the users it is shared with, and it debits some other account when
* asked to pay from one it did not share. Being the payer (or even the workspace owner) does not imply that share.
*
* Read the account number off the returned account rather than off `policy.achAccount`. The two disagree in practice —
* `achAccount.accountNumber` goes stale while `achAccount.bankAccountID` already points at a different account — and
* printing the stale number is how the button ends up naming an account other than the one that gets debited.
*/
function getAccessiblePolicyBankAccount(policy: OnyxEntry<Policy>, bankAccountList: OnyxEntry<BankAccountList>): BankAccount | undefined {
const policyBankAccountID = policy?.achAccount?.bankAccountID;

if (!policyBankAccountID) {
return undefined;
}

return bankAccountList?.[policyBankAccountID];
}

/**
* Whether the user can actually pay from the workspace's connected bank account. This gates every place that would
* otherwise default a payment to `policy.achAccount` — paying with, or displaying, an account the user has no access to
* is always wrong. See `getAccessiblePolicyBankAccount` for why `bankAccountList` is the authority.
*/
function canAccessPolicyBankAccount(policy: OnyxEntry<Policy>, bankAccountList: OnyxEntry<BankAccountList>): boolean {
return !!getAccessiblePolicyBankAccount(policy, bankAccountList);
}

/**
* Whether a payment made by `payerAccountID` can be assumed to have been funded by the workspace's connected bank
* account.
*
* Only the designated payer pays out of the workspace account; any other admin pays from an account of their own. Their
* payment must never be attributed to the workspace account, because that account is what every *other* viewer would
* otherwise fall back to — which is how the same payment ends up showing two different accounts to two people.
*/
function wasPaidWithPolicyBankAccount(policy: OnyxEntry<Policy>, payerAccountID: number | undefined): boolean {
const reimburserEmail = policy?.reimburser ?? policy?.achAccount?.reimburser;

// With no designated payer, every admin pays out of the workspace account, so any payer qualifies.
if (!reimburserEmail) {
return true;
}

return !!payerAccountID && getKnownAccountIDByLogin(reimburserEmail) === payerAccountID;
}

/** Check if the passed employee is an approver in the policy's employeeList */
function isPolicyApprover(policy: OnyxEntry<Policy>, employeeLogin: string) {
if (policy?.approver === employeeLogin) {
Expand Down Expand Up @@ -3283,10 +3212,6 @@ export {
isPolicyOwner,
isPolicyMember,
isPolicyPayer,
canAdminPayReport,
canAccessPolicyBankAccount,
getAccessiblePolicyBankAccount,
wasPaidWithPolicyBankAccount,
getReimburserEmail,
PAYER_ROLES,
canRolePay,
Expand Down
8 changes: 2 additions & 6 deletions src/libs/ReportNameUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {getForReportAction, getMovedReportID} from './ModifiedExpenseMessage';
import {getCurrentUserEmail} from './Network/NetworkStore';
import Parser from './Parser';
import {temporaryGetDisplayNameOrDefault} from './PersonalDetailsUtils';
import {getCleanedTagName, isPolicyAdmin, isPolicyFieldListEmpty, wasPaidWithPolicyBankAccount} from './PolicyUtils';
import {getCleanedTagName, isPolicyAdmin, isPolicyFieldListEmpty} from './PolicyUtils';
import {
getActionableCard3DSTransactionApprovalMessage,
getActionableCardFraudAlertResolutionMessage,
Expand Down Expand Up @@ -804,11 +804,7 @@ function computeReportNameBasedOnReportAction({

if (isMoneyRequestAction(parentReportAction)) {
const originalMessage = getOriginalMessage(parentReportAction);
// Prefer the account stored on the action: the payer is not always the workspace payer, so the policy's
// ACH account can belong to a different bank account than the one the report was actually paid with, and
// attributing it to a non-payer admin's payment shows a different account to every other viewer.
const policyAccountNumber = wasPaidWithPolicyBankAccount(reportPolicy, parentReportAction?.actorAccountID) ? reportPolicy?.achAccount?.accountNumber : undefined;
const last4Digits = (originalMessage?.accountNumber ?? policyAccountNumber)?.slice(-4) ?? '';
const last4Digits = originalMessage?.accountNumber?.slice(-4) ?? reportPolicy?.achAccount?.accountNumber?.slice(-4) ?? '';

if (originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) {
if (originalMessage.paymentType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) {
Expand Down
9 changes: 7 additions & 2 deletions src/libs/ReportPreviewActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import type {ValueOf} from 'type-fest';

import {
arePaymentsEnabled,
canAdminPayReport,
canMemberWrite,
getSubmitToAccountID,
getValidConnectedIntegration,
hasDynamicExternalWorkflow,
hasIntegrationAutoSync,
isArchivedOrPendingDeletePolicy,
isGroupPolicy,
isPreferredExporter,
isSubmitterApproveBlockedOnSubmitWorkspace,
} from './PolicyUtils';
Expand Down Expand Up @@ -138,7 +139,11 @@ function canPay(
const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false);

// The admin pay path is for workspace expense reports. Personal policies should only offer Pay to the actual payer.
const canPayReport = isReportPayer || canAdminPayReport(policy, currentUserLogin);
const canPayReport =
isReportPayer ||
(isGroupPolicy(policy) &&
policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL &&
canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS));
const isPaymentsEnabled = arePaymentsEnabled(policy);
const isProcessing = isProcessingReport(report);
const isApprovalEnabled = policy ? policy.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false;
Expand Down
12 changes: 9 additions & 3 deletions src/libs/ReportPrimaryActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {ValueOf} from 'type-fest';

import {
arePaymentsEnabled as arePaymentsEnabledUtils,
canAdminPayReport,
canMemberWrite,
getManagerAccountID,
getSubmitToAccountID,
getValidConnectedIntegration,
Expand Down Expand Up @@ -230,7 +230,14 @@ function isPrimaryPayAction({
return false;
}
const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false);
const canPayReport = isReportPayer || (!!canNonPayerAdminPay && canAdminPayReport(policy, currentUserLogin));

// The admin pay path is for workspace expense reports. Personal policies should only offer Pay to the actual payer.
const canPayReport =
isReportPayer ||
(canNonPayerAdminPay &&
isGroupPolicy(policy) &&
policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL &&
canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS));
const arePaymentsEnabled = arePaymentsEnabledUtils(policy);
const isReportApproved = isReportApprovedUtils({report});
const isReportClosed = isClosedReportUtils(report);
Expand Down Expand Up @@ -515,7 +522,6 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf<t
isChatReportArchived,
invoiceReceiverPolicy,
reportActions,
canNonPayerAdminPay: true,
}) && allExpensesHeld;
const expensesToHold = getAllExpensesToHoldIfApplicable(report, reportActions, reportTransactions, policy, currentUserAccountID);

Expand Down
Loading
Loading