Skip to content
Open
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
9334d49
account for manual reimbursement flow in isPayer
NikkiWines Jun 5, 2026
566eff9
handle reimburse manual flow for workspace workflows
NikkiWines Jun 5, 2026
6bac69b
add tests
NikkiWines Jun 5, 2026
fac689b
define isManualReimbursement earlier
NikkiWines Jun 5, 2026
fffb187
align on policy.achAccount.reimburser
NikkiWines Jun 5, 2026
6b41645
add isPayer test
NikkiWines Jun 5, 2026
3768f72
type checks
NikkiWines Jun 5, 2026
1d62b2b
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 5, 2026
0ccd17d
prettier
NikkiWines Jun 5, 2026
ce42d33
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 8, 2026
b6e6a59
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 9, 2026
19ef76b
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 15, 2026
873a043
fall back on policy owner
NikkiWines Jun 17, 2026
b7ad25c
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 17, 2026
33f2078
update tests
NikkiWines Jun 17, 2026
4b13609
update to reimburse manual when no bank account is selected
NikkiWines Jun 17, 2026
5a13f44
style
NikkiWines Jun 17, 2026
501bafb
allow any admin when only paying elsewhere
NikkiWines Jun 18, 2026
8276218
restrict to when reimbursement choice is no
NikkiWines Jun 18, 2026
174d67d
add test
NikkiWines Jun 18, 2026
d4f2217
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 22, 2026
f49c2f6
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 22, 2026
df3e3bc
allow non-payer admins to pay report, but don't show other other paym…
NikkiWines Jun 22, 2026
5ad0416
default to owner for bank account logic
NikkiWines Jun 22, 2026
e3939ac
add tests
NikkiWines Jun 22, 2026
974e0ad
Merge branch 'nikki-adjust-payer-gbr' of github.com:Expensify/App int…
NikkiWines Jun 22, 2026
be66c82
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 22, 2026
10b5f53
add email to tests
NikkiWines Jun 23, 2026
e3962b8
account for invoices
NikkiWines Jun 23, 2026
00427e8
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 26, 2026
1c5028f
show payer option on report preview
NikkiWines Jun 26, 2026
704dcda
update test
NikkiWines Jun 26, 2026
7066ee9
eslint changes
NikkiWines Jun 26, 2026
9032164
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 29, 2026
72a1d99
fix bank account reset
NikkiWines Jun 29, 2026
42c1c09
reuse isPrimaryPay action
NikkiWines Jun 29, 2026
e8e82af
update tests
NikkiWines Jun 29, 2026
b2b692c
fallback to owner
NikkiWines Jun 29, 2026
75a56c7
updatet tests for bank account clearing
NikkiWines Jun 29, 2026
f29826f
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 29, 2026
d4b6fda
update tests again, undo bad merge
NikkiWines Jun 29, 2026
08a601f
Merge branch 'main' of github.com:Expensify/App into nikki-adjust-pay…
NikkiWines Jun 29, 2026
9009b95
remove files from bad merge
NikkiWines Jun 29, 2026
81030ed
eslint
NikkiWines Jun 29, 2026
101150d
clear bank account on reset
NikkiWines Jun 30, 2026
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: 2 additions & 2 deletions src/hooks/useResetBankAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function useResetBankAccountModal({

const handleConfirm = () => {
if (isNonUSDWorkspace) {
resetNonUSDBankAccount(policyID, policy?.achAccount, achData?.bankAccountID, lastPaymentMethod);
resetNonUSDBankAccount(policyID, policy?.achAccount, achData?.bankAccountID, lastPaymentMethod, policy?.owner);

if (setShouldShowConnectedVerifiedBankAccount) {
setShouldShowConnectedVerifiedBankAccount(false);
Expand All @@ -91,7 +91,7 @@ function useResetBankAccountModal({
ROUTES.BANK_ACCOUNT_NON_USD_SETUP.getRoute({policyID: policyID ?? CONST.POLICY.ID_FAKE, page: CONST.NON_USD_BANK_ACCOUNT.PAGE_NAME.CURRENCY_AND_COUNTRY, backTo}),
);
} else {
resetUSDBankAccount(bankAccountID, session, policyID, policy?.achAccount, lastPaymentMethod);
resetUSDBankAccount(bankAccountID, session, policyID, policy?.achAccount, lastPaymentMethod, policy?.owner);

if (setShouldShowContinueSetupButton) {
setShouldShowContinueSetupButton(false);
Expand Down
15 changes: 10 additions & 5 deletions src/libs/PolicyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,17 +595,22 @@ function isPolicyPayer(policy: OnyxEntry<Policy>, currentUserLogin: string | und
}

const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN;
const isReimburser = policy.reimburser === currentUserLogin;
const isAutoReimbursement = policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES;
const isManualReimbursement = policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL;

if (policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES) {
return policy.reimburser ? isReimburser : isAdmin;
// Reimbursement is disabled for this workspace.
if (!isAutoReimbursement && !isManualReimbursement) {
return false;
}

if (policy.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL) {
const reimburserEmail = policy.achAccount?.reimburser ?? (isManualReimbursement ? policy.owner : undefined);

// No designated reimburser means any workspace admin can pay.
if (!reimburserEmail) {
return isAdmin;
}

return false;
return currentUserLogin === reimburserEmail;
}

/** Check if the passed employee is an approver in the policy's employeeList */
Expand Down
6 changes: 4 additions & 2 deletions src/libs/ReportPreviewActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getValidConnectedIntegration,
hasDynamicExternalWorkflow,
hasIntegrationAutoSync,
isPolicyAdmin,
isPreferredExporter,
isSubmitterApproveBlockedOnSubmitWorkspace,
} from './PolicyUtils';
Expand Down Expand Up @@ -119,6 +120,7 @@ function canPay(
}

const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false);
const canPayReport = isReportPayer || (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && isPolicyAdmin(policy));
const isExpense = isExpenseReport(report);
const isPaymentsEnabled = arePaymentsEnabled(policy);
const isProcessing = isProcessingReport(report);
Expand All @@ -136,7 +138,7 @@ function canPay(

if (
isExpense &&
isReportPayer &&
canPayReport &&
isPaymentsEnabled &&
isReportFinished &&
(reimbursableSpend !== 0 || (nonReimbursableSpend !== 0 && hasOnlyNonReimbursableTransactions(report?.reportID, transactions)))
Expand All @@ -150,7 +152,7 @@ function canPay(

const isIOU = isIOUReport(report);

if (isIOU && isReportPayer && !isReimbursed && reimbursableSpend > 0) {
if (isIOU && canPayReport && !isReimbursed && reimbursableSpend > 0) {
return true;
}

Expand Down
9 changes: 7 additions & 2 deletions src/libs/ReportPrimaryActionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ type IsPrimaryPayActionParams = {
invoiceReceiverPolicy?: Policy;
reportActions?: ReportAction[];
isSecondaryAction?: boolean;
canNonPayerAdminPay?: boolean;
};

function isAddExpenseAction(report: Report, reportTransactions: Transaction[], isChatReportArchived: boolean) {
Expand Down Expand Up @@ -205,6 +206,7 @@ function isPrimaryPayAction({
invoiceReceiverPolicy,
reportActions,
isSecondaryAction,
canNonPayerAdminPay,
}: IsPrimaryPayActionParams) {
if (isArchivedReport(reportNameValuePairs) || isChatReportArchived) {
return false;
Expand All @@ -214,6 +216,8 @@ function isPrimaryPayAction({
return false;
}
const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false);
const canPayReport =
isReportPayer || (canNonPayerAdminPay && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && isPolicyAdminPolicyUtils(policy));
const arePaymentsEnabled = arePaymentsEnabledUtils(policy);
const isReportApproved = isReportApprovedUtils({report});
const isReportClosed = isClosedReportUtils(report);
Expand All @@ -229,7 +233,7 @@ function isPrimaryPayAction({
const {reimbursableSpend, nonReimbursableSpend} = getMoneyRequestSpendBreakdown(report);

if (
isReportPayer &&
canPayReport &&
isExpenseReport &&
arePaymentsEnabled &&
isReportFinished &&
Expand All @@ -244,7 +248,7 @@ function isPrimaryPayAction({

const isIOUReport = isIOUReportUtils(report);

if (isIOUReport && isReportPayer && reimbursableSpend > 0) {
if (isIOUReport && canPayReport && reimbursableSpend > 0) {
return true;
}

Expand Down Expand Up @@ -530,6 +534,7 @@ function getReportPrimaryAction(params: GetReportPrimaryActionParams): ValueOf<t
isChatReportArchived,
invoiceReceiverPolicy,
reportActions,
canNonPayerAdminPay: true,
}) &&
!allExpensesHeld
) {
Expand Down
41 changes: 29 additions & 12 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2841,26 +2841,42 @@ function isPayer(
const reimbursementChoice = policy?.reimbursementChoice;

if (isPaidGroupPolicy(iouReport)) {
if (reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES) {
if (!policy?.achAccount?.reimburser) {
return isAdmin;
}
// Pay-elsewhere / mark-paid flows for disabled reimbursement still allow any admin to act.
if (onlyShowPayElsewhere && reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO) {
return isAdmin;
}

const isAutoReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES;
const isManualReimbursement = reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL;

// Reimbursement is disabled for this workspace.
if (!isAutoReimbursement && !isManualReimbursement) {
return false;
}

const reimburserEmail = policy?.achAccount?.reimburser ?? (isManualReimbursement ? policy?.owner : '');

// If user is the reimburser, or a policy admin with access to the business bank account via sharees, they can pay.
const isReimburser = currentUserEmailParam === policy?.achAccount?.reimburser;
// No designated reimburser means any workspace admin can pay.
if (!reimburserEmail) {
return isAdmin;
Comment thread
NikkiWines marked this conversation as resolved.
}

// Check if the current user has access to the bank account via sharees
const isReimburser = currentUserEmailParam === reimburserEmail;

// If using auto reimbursement, then the reimburser can pay, or an admin with access to the business bank account.
if (isAutoReimbursement) {
const bankAccountID = policy?.achAccount?.bankAccountID;
const bankAccount = bankAccountID ? bankAccountList?.[bankAccountID] : null;
const hasAccessToBankAccount = currentUserEmailParam && bankAccount?.accountData?.sharees ? bankAccount.accountData.sharees.includes(currentUserEmailParam) : false;

return isReimburser || (isAdmin && hasAccessToBankAccount);
}
if (reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL || onlyShowPayElsewhere) {
return isAdmin;
}
return false;

// If using manual reimbursement, only the designated reimburser can pay.
return isReimburser;
}

// Personal workspaces and IOU reports fall back to admin or report manager.
return isAdmin || (isMoneyRequestReport(iouReport) && isManager);
}

Expand Down Expand Up @@ -3051,7 +3067,8 @@ function hasOutstandingChildRequest(
// This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850
const invoiceReceiverPolicy = getPolicy(invoiceReceiverPolicyID);
return (
canIOUBePaid(iouReport, chatReport, policy, bankAccountList, currentUserEmailParam, currentUserAccountIDParam, transactions, undefined, undefined, invoiceReceiverPolicy) ||
(isPayer(currentUserAccountIDParam, currentUserEmailParam, iouReport, bankAccountList, policy, false) &&
canIOUBePaid(iouReport, chatReport, policy, bankAccountList, currentUserEmailParam, currentUserAccountIDParam, transactions, undefined, undefined, invoiceReceiverPolicy)) ||
canApproveIOU(iouReport, policy, reportMetadata, currentUserAccountIDParam, transactions) ||
canSubmitAndIsAwaitingForCurrentUser(
iouReport,
Expand Down
16 changes: 10 additions & 6 deletions src/libs/actions/IOU/ReportWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ function canIOUBePaid(
return invoiceReceiverPolicy?.role === CONST.POLICY.ROLE.ADMIN;
}

const isPayer = isPayerReportUtils(currentUserAccountID, currentUserLogin, iouReport, bankAccountList, policy, onlyShowPayElsewhere);
const isReportPayer = isPayerReportUtils(currentUserAccountID, currentUserLogin, iouReport, bankAccountList, policy, onlyShowPayElsewhere);
const canPay = isReportPayer || (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && isPolicyAdmin(policy));

const {reimbursableSpend, nonReimbursableSpend} = getMoneyRequestSpendBreakdown(iouReport);
const isAutoReimbursable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES ? false : canBeAutoReimbursed(iouReport, policy);
Expand All @@ -232,7 +233,7 @@ function canIOUBePaid(
const canShowMarkedAsPaidForNegativeAmount = onlyShowPayElsewhere && reimbursableSpend < 0;
const isOnlyNonReimbursablePayElsewhere = onlyShowPayElsewhere && nonReimbursableSpend !== 0 && hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions);

if (isIOU && isPayer && !iouSettled && reimbursableSpend > 0) {
if (isIOU && canPay && !iouSettled && reimbursableSpend > 0) {
return true;
}

Expand All @@ -243,7 +244,7 @@ function canIOUBePaid(
}

return (
isPayer &&
canPay &&
isReportFinished &&
!iouSettled &&
(reimbursableSpend > 0 || canShowMarkedAsPaidForNegativeAmount || isOnlyNonReimbursablePayElsewhere) &&
Expand Down Expand Up @@ -297,15 +298,18 @@ function getBadgeFromIOUReport(
currentUserLogin: string,
currentUserAccountID: number,
): ValueOf<typeof CONST.REPORT.ACTION_BADGE> | undefined {
// Show to the actual payer, or to policy admins via the pay-elsewhere path for negative expenses
const canBePaidNow = canIOUBePaid(iouReport, chatReport, policy, undefined, currentUserLogin, currentUserAccountID, undefined, undefined, undefined, invoiceReceiverPolicy);
const isReportPayer = isPayerReportUtils(currentUserAccountID, currentUserLogin, iouReport, undefined, policy, false);
const canBePaidNow =
isReportPayer && canIOUBePaid(iouReport, chatReport, policy, undefined, currentUserLogin, currentUserAccountID, undefined, undefined, undefined, invoiceReceiverPolicy);
if (canBePaidNow) {
return CONST.REPORT.ACTION_BADGE.PAY;
}
// Pay-elsewhere path: covers negative reimbursable spend (mark-as-paid flow for credits).
// Skip the PAY badge when every expense is non-reimbursable — paying is optional and
// should not pin the report in the LHN.
const canBePaidElsewhere = canIOUBePaid(iouReport, chatReport, policy, undefined, currentUserLogin, currentUserAccountID, undefined, true, undefined, invoiceReceiverPolicy);
const canPayElsewhereActor = isPayerReportUtils(currentUserAccountID, currentUserLogin, iouReport, undefined, policy, true);
const canBePaidElsewhere =
canPayElsewhereActor && canIOUBePaid(iouReport, chatReport, policy, undefined, currentUserLogin, currentUserAccountID, undefined, true, undefined, invoiceReceiverPolicy);
if (canBePaidElsewhere) {
return hasOnlyNonReimbursableTransactions(iouReport?.reportID) ? undefined : CONST.REPORT.ACTION_BADGE.PAY;
}
Expand Down
30 changes: 27 additions & 3 deletions src/libs/actions/ReimbursementAccount/resetNonUSDBankAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import type * as OnyxTypes from '@src/types/onyx';
import type {ACHAccount} from '@src/types/onyx/Policy';
import type {OnyxData} from '@src/types/onyx/Request';

function resetNonUSDBankAccount(policyID: string | undefined, achAccount: OnyxEntry<ACHAccount>, bankAccountID?: number, lastUsedPaymentMethod?: OnyxTypes.LastPaymentMethodType) {
function resetNonUSDBankAccount(
policyID: string | undefined,
achAccount: OnyxEntry<ACHAccount>,
bankAccountID?: number,
lastUsedPaymentMethod?: OnyxTypes.LastPaymentMethodType,
policyOwner?: string,
) {
// If there's no bankAccountID, we reset locally without making an API call
if (!bankAccountID) {
const updateData: Array<OnyxUpdate<typeof ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT | typeof ONYXKEYS.COLLECTION.POLICY | typeof ONYXKEYS.REIMBURSEMENT_ACCOUNT>> = [
Expand All @@ -21,7 +27,16 @@ function resetNonUSDBankAccount(policyID: string | undefined, achAccount: OnyxEn
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
achAccount: null,
achAccount: policyOwner
? {
reimburser: policyOwner,
bankAccountID: null,
accountNumber: null,
addressName: null,
bankName: null,
state: null,
}
: null,
},
},
{
Expand Down Expand Up @@ -97,7 +112,16 @@ function resetNonUSDBankAccount(policyID: string | undefined, achAccount: OnyxEn
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
achAccount: null,
achAccount: policyOwner
? {
reimburser: policyOwner,
bankAccountID: null,
accountNumber: null,
addressName: null,
bankName: null,
state: null,
}
: null,
},
});

Expand Down
12 changes: 11 additions & 1 deletion src/libs/actions/ReimbursementAccount/resetUSDBankAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function resetUSDBankAccount(
policyID: string | undefined,
achAccount: ACHAccount | undefined,
lastUsedPaymentMethod?: OnyxTypes.LastPaymentMethodType,
policyOwner?: string,
) {
if (!bankAccountID) {
throw new Error('Missing bankAccountID when attempting to reset free plan bank account');
Expand Down Expand Up @@ -55,7 +56,16 @@ function resetUSDBankAccount(
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
achAccount: null,
achAccount: policyOwner
? {
reimburser: policyOwner,
bankAccountID: null,

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 Preserve the selected payer when resetting the bank account

When a workspace has a preferred payer different from the owner and its bank account is reset/disconnected, this optimistic update overwrites achAccount.reimburser with policyOwner instead of the current achAccount?.reimburser. That makes the manual-reimbursement payer revert to the owner (and routes payer-only badges/next steps to the wrong admin) until a later refresh/server update, even though removing the bank account should not discard the workspace’s selected payer.

Useful? React with 👍 / 👎.

accountNumber: null,
addressName: null,
bankName: null,
state: null,
Comment on lines +63 to +68

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 Clear all ACH fields when preserving the payer

Because this update is applied with Onyx.METHOD.MERGE, replacing achAccount: null with a partial object only clears the listed keys; existing fields omitted from the old ACH account, such as routingNumber and sharees, remain in Onyx after a USD bank-account reset. That leaves stale bank details/share access attached to the policy while the account is considered removed, so clear the whole achAccount first or include every nested ACH field as null when preserving only the reimburser.

Useful? React with 👍 / 👎.

}
: null,
},
},
{
Expand Down
4 changes: 3 additions & 1 deletion src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,9 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) {
break;
case CONST.UPGRADE_FEATURE_INTRO_MAPPING.payments.id: {
let newReimbursementChoice;
if (!!policy?.achAccount && !isCurrencySupportedForDirectReimbursement(policy?.outputCurrency ?? '')) {
const hasConnectedBank =
policy?.achAccount?.bankAccountID && (policy.achAccount.state === CONST.BANK_ACCOUNT.STATE.OPEN || policy.achAccount.state === CONST.BANK_ACCOUNT.STATE.LOCKED);
if (!hasConnectedBank || !isCurrencySupportedForDirectReimbursement(policy?.outputCurrency ?? '')) {
newReimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL;
} else {
newReimbursementChoice = CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES;
Expand Down
Loading
Loading