Skip to content

[Due for payment 2026-10-30] [CFI] [$500] Allow any workspace admin to pay reports #95750

Open

Description

@joekaufmanexpensify

Coming from this Slack thread, @sakluger flagged a classic/NewDot parity gap regarding payments.

Details

  • When no bank account is connected to a workspace, any workspace already sees the option to pay/mark as paid in NewDot. This part works correctly today and needs no changes.
  • The gap: when a bank account is connected to a workspace, NewDot only shows the pay button to the admin set as the workspace payer. Classic shows it to any workspace admin.
  • Proposal: any workspace admin should see the option to pay (via any VBA they have access to) or manually mark as paid, regardless of whether a bank account is connected to the workspace.
    • In the event the workspace doesn't have a VBA, they should be prompted to add one in situ when they try to pay via business account (the existing payment button logic).
  • An admin adding/paying with a bank account that's not the current workspace bank account shouldn't change the workspace bank account, same as now.
  • We should not actively prompt admins that aren't the workspace payer to pay via badges/GBRs. That stays as-is (per https://github.com/Expensify/Expensify/issues/637864).
  • One note: an admin that isn't the workspace payer who tries to pay a report via ACH in Classic sees an error, despite being able to mark as paid. I don't think we should go this direction in NewDot, since we already allow the admin set as the workspace payer to select the bank account to use at the time of payment in both NewDot and Classic. It feels more consistent to also allow the non-payer admin to select the bank account they want to use or mark as paid. Curious if anyone disagrees?
  • I don't think this issue should require any backend changes. Let me know if anyone feels differently.
  • This issue is only about who can see and use the pay button. It shouldn't change which options appear or how it behaves for a given report (e.g., non-reimbursable-only reports showing "Mark as Paid" only in the report detail view, not the report preview or the Spend page).

Non-payer workspace admin view when workspace has bank account - NewDot

Image

Non-payer workspace admin view when workspace has bank account - OldDot

Image

Non-payer workspace admin view when workspace has no bank account - NewDot

Image

Non-payer workspace admin view when workspace has no bank account - OldDot

Image
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @ikevin127

Activity

  1. melvin-bot commented on Jul 9, 2026

    @melvin-bot

    Triggered auto assignment to Design team member for new feature review - @dannymcclain (NewFeature)

  2. joekaufmanexpensify commented on Jul 9, 2026

    @joekaufmanexpensify
    ContributorAuthor

    @dannymcclain I don't think we need any mocks here. We're just showing the existing pay button to a broader group of admins. Let me know if you feel differently. Going ahead with soliciting proposals for now. 👍

  3. joekaufmanexpensify commented on Jul 9, 2026

    @joekaufmanexpensify
    ContributorAuthor

    @MelvinBot please submit a proposal for this issue following Expensify's normal proposal guidelines.

  4. MelvinBot commented on Jul 9, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    The pay button is gated on being the single designated workspace payer, not on being an admin.

    In isPayer, once a workspace has a designated reimburser — which is exactly the case when a bank account is connected (policy.achAccount.reimburser) — only that reimburser is treated as a payer. Other admins qualify only if they are a sharee of that specific VBA (auto reimbursement) or not at all (manual):

    The designated-payer gate

    There is already a "any admin / Payments Admin can pay" fallback layered on top of isPayer, but it is scoped to REIMBURSEMENT_MANUAL only, so it doesn't cover a bank-connected workspace that reimburses automatically:

    The fallback is manual-only in all three pay-button surfaces

    Net effect: on a workspace with a bank account connected + automatic reimbursement, a non-payer workspace admin or Payments Admin sees no pay button — the OldDot/NewDot parity gap.

    What changes do you think we should make in order to solve the problem?

    Separate "who can pay" (button visibility — should be broad) from "who is the designated payer" (prompting — stays narrow).

    1. Broaden the non-payer-admin fallback in the three pay-button surfaces (canIOUBePaid, ReportPreviewActionUtils, ReportPrimaryActionUtils) so it applies whenever payments are enabled, not just manual reimbursement — i.e. gate on arePaymentsEnabled(policy) instead of reimbursementChoice === REIMBURSEMENT_MANUAL. Any workspace admin or Payments Admin (canMemberWrite(policy, login, WORKFLOWS_PAYMENTS)) then sees the pay button regardless of being the designated payer or a VBA sharee.
    2. Mirror the same broadening in isPolicyPayer so the Search/Reports-page pay button matches.
    3. Leave isPayer itself unchanged. It also feeds the PAY badge (getBadgeFromIOUReport) and next-step/GBR messaging (NextStepUtils). Keeping it gated on the designated payer ensures non-payer admins still see the button but are not actively prompted via badges/GBRs — matching the issue requirement and Expensify/Expensify#637864.

    No backend changes needed. A non-payer admin without a VBA who taps "pay via business account" hits the existing add-bank-account flow in situ; "mark as paid" already works via the pay-elsewhere path.

    What alternative solutions did you explore? (Optional)

    Broadening isPayer directly (one edit) — rejected, because it is shared by the badge/GBR/next-step logic, so it would start actively prompting non-payer admins to pay, which the issue explicitly rules out.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/95750) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  5. joekaufmanexpensify commented on Jul 9, 2026

    @joekaufmanexpensify
    ContributorAuthor

    Opening up to other proposals now so C+ can decide between Melvin and others.

  6. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Jul 9, 2026
  7. aswin-s commented on Jul 9, 2026

    @aswin-s
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On NewDot Web, when a workspace has a connected business bank account, the pay/mark-as-paid button is only shown to the admin designated as the workspace payer (reimburser). Classic shows it to any workspace admin — and now, with the newly added Payments Admin role (PR #94018), any Payments Admin should also see it. Expected: any workspace admin or payments admin can see and use the pay button (choosing a VBA they have access to, being prompted to add one, or marking as paid), regardless of whether the workspace has a bank account connected.

    What is the root cause of that problem?

    isPayer in src/libs/ReportUtils.ts:2820 defines isAdmin as policy?.role === CONST.POLICY.ROLE.ADMIN (line 2835), which excludes the new PAYMENTS_ADMIN role added in PR #94018. Additionally, in the auto-reimbursement branch (lines 2864–2869), the function returns isReimburser || (isAdmin && hasAccessToBankAccount) — so a non-reimburser workspace/payments admin who isn't on the connected bank account's sharees list returns false, hiding the pay button. Classic instead lets any admin see the button and pick a VBA (or be prompted to add one) at pay time.

    What changes do you think we should make in order to solve the problem?

    1. src/libs/ReportUtils.ts:2835 — broaden isAdmin to also treat CONST.POLICY.ROLE.PAYMENTS_ADMIN as an admin for payment authorization (e.g. [ROLE.ADMIN, ROLE.PAYMENTS_ADMIN].includes(policy?.role) when policy type isn't PERSONAL).
    2. src/libs/ReportUtils.ts:2864-2869 (auto-reimbursement branch) — allow any workspace/payments admin to see the button even without bank-account sharee access; drop the hasAccessToBankAccount gate here so the return becomes isReimburser || isAdmin. The in-situ VBA prompt / bank-picker at pay time handles the "no access to current VBA" case per the issue.
    3. src/libs/ReportUtils.ts:2872-2873 (manual-reimbursement branch) — same relaxation: return true for isAdmin in addition to isReimburser, so any admin can mark-as-paid or pay through their accessible VBA.
    4. Verify downstream consumers (PayPrimaryAction.tsx, PayActionButton.tsx, PayActionCell.tsx, MoneyReportHeaderSecondaryActions.tsx, MoneyReportHeaderSelectionDropdown.tsx, SearchUIUtils.ts, useSearchBulkActions.ts, useSelectionModeReportActions.ts, AnimatedSettlementButton.tsx, MoneyReportHeaderModals.tsx, actions/IOU/ReportWorkflow.ts) pass the current user's policy through isPayer unchanged — no per-call-site changes needed since the gate lives in one function.
    5. Keep GBR/badge prompting logic untouched (issue explicitly excludes it) — this change only affects who sees/uses the pay button, matching Classic parity.

    What alternative solutions did you explore? (Optional)

  8. lorretheboy commented on Jul 9, 2026

    @lorretheboy
    Contributor

    Proposal

    What is the root cause of that problem?

    The pay-button gates already have an escape that lets any admin/payments admin pay when they aren't the designated payer, but it's hard-coded to manual reimbursement only. Connecting a bank account flips the policy's reimbursementChoice to reimburseYes and sets achAccount.reimburser (the workspace payer), which kills both paths at once:

    1. isPayer in its auto-reimbursement branch only returns true for the designated reimburser, or an admin who is a sharee of the workspace's exact bank account:

    App/src/libs/ReportUtils.ts

    Lines 2863 to 2873 in b794ad3

    // 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 using manual reimbursement, only the designated reimburser can pay.
    return isReimburser;

    1. The non-payer-admin escape in canIOUBePaid requires reimbursementChoice === REIMBURSEMENT_MANUAL, which is no longer true once a bank account is connected:

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

    The same manual-only escape is duplicated in the primary-action and report-preview gates:

    const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false);
    const canPayReport =
    isReportPayer ||
    (canNonPayerAdminPay &&
    policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL &&
    canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS));

    const isReportPayer = isPayer(currentUserAccountID, currentUserLogin, report, bankAccountList, policy, false);
    const canPayReport =
    isReportPayer ||
    (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS));

    So: bank account connected → choice becomes reimburseYes → the manual-only escape stops applying → only isPayer gates the button → only the workspace payer (or a bank-account sharee) sees Pay. Classic shows it to any workspace/payments admin. Note this isn't a regression from PR #94018 — that PR only added the Payments Admin role/UI; the manual-only restriction predates it, and payments admins already satisfy the escape's canMemberWrite(..., WORKFLOWS_PAYMENTS) check via their permission bundle.

    What changes do you think we should make in order to solve the problem?

    Broaden the non-payer-admin escape so it applies whenever reimbursement is enabled (manual or direct), instead of manual only. Since the identical expression is duplicated in three places, extract it into a shared helper in PolicyUtils (next to isPolicyPayer), roughly:

    function canAdminPayReport(policy, currentUserLogin) {
        const isReimbursementEnabled =
            policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL ||
            policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES;
        return isReimbursementEnabled && canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS);
    }

    and use it in the three gates above (canIOUBePaid, isPrimaryPayAction, and the report-preview canPay) in place of the inline manual-only check. canMemberWrite(..., WORKFLOWS_PAYMENTS) is write-all for admins and explicitly granted to payments admins, so both roles are covered.

    Deliberately do not change isPayer (or isPolicyPayer). Those still drive the LHN Pay badge, GBR (hasOutstandingChildRequest), next-step copy, and Search pay suggestions — e.g. the badge separately requires isReportPayer before calling canIOUBePaid:

    const isReportPayer = isPayerReportUtils(currentUserAccountID, currentUserLogin, iouReport, undefined, policy, false);
    const canBePaidNow =
    (isInvoiceReportReportUtils(iouReport) || isReportPayer) &&
    canIOUBePaid(iouReport, chatReport, policy, undefined, currentUserLogin, currentUserAccountID, undefined, undefined, undefined, invoiceReceiverPolicy);

    so non-payer admins get the button but are not actively prompted via badges/GBRs, as the issue requires. Payment itself needs no changes: the settlement button already lists only bank accounts the user has access to, prompts them to add a VBA (KYC wall) if they have none, and paying from a different VBA doesn't change the workspace bank account.

    What alternative solutions did you explore? (Optional)

    Making isPayer's auto-reimbursement branch return true for any admin/payments admin (canPayOnPolicy). Rejected because isPayer also feeds badges, GBRs, and next-step messaging, which must stay payer-only per the issue.

  9. 150 remaining items

  10. lorretheboy commented on Oct 25, 2026

    @lorretheboy
    Contributor

    @flodnv Yes, the changes in #103239 cover Payments Admins who aren’t the designated payer

  11. melvin-bot commented on Oct 27, 2026

    @melvin-bot

    @ikevin127 Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

  12. ikevin127 commented on Oct 28, 2026

    @ikevin127
    Contributor

    Contributor+ Checklist:

    • [Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.

      Link to the comment on the PR: N/A - Result of the original design, NewDot has only shown the Pay button to the designated workspace payer whenever a bank account is connected, so letting non-payer admins pay is a new parity feature rather than a regression.
      Link to the comment on the Issue: N/A

    • [Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.

      Link to discussion: N/A

    • [Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.

    Regression Test Proposal

    Precondition:

    • Account A owns a Collect/Control workspace with a verified business bank account connected, and A is set as the Payer in Workspace > Workflows > Payments.
    • Account B is a second admin on that workspace, is not the Payer, and has no access to the workspace bank account.
    • Account C is a member of the workspace and has an approved expense report with a reimbursable expense.
    1. Sign in as Account B.
    2. Open Account C's workspace chat.
    3. Verify that the approved report preview shows the Pay button.
    4. Open the expense report.
    5. Verify that the Pay button appears in the report header.
    6. Click Pay.
    7. Verify that the payment options include "Pay with business account" and "Mark as paid".
    8. Click "Mark as paid" and confirm.
    9. Verify that the report is marked as paid and the workspace bank account in Workspace > Workflows > Payments is unchanged.

    Do we agree 👍 or 👎.

  13. ikevin127 commented on Oct 28, 2026

    @ikevin127
    Contributor

    @chuckdries Given that this has landed on 3rd PR, and you said after 1st PR revert here:

    Neither of these were due to mistakes by the contributor, so we will not be reducing the payout

    then we had 2nd PR rework which had 1 regression reported (not sure whether this was BE related or a FE oversight).

    Finally, we landed the 3rd rework PR and no regressions reported so far. Question is: do we get the full $500 or some penalty for that 2nd rework PR regression ? IMO 50% penalty seems unfair given the amount of work involved (3 PRs reviewed) to land this feature.

    Please let me know what you think as payment date approaches 🙌

  14. chuckdries commented on Oct 28, 2026

    @chuckdries
    Contributor

    We ended up solving the regression on the 2nd PR on the frontend. That said, I agree that the regression penalty feels unfair in this case since it was more due to confusion about requirements than a code oversight. In the future, let's all try to raise questions we're uncertain about at design or review time (speaking to myself as much as anyone else)!

  15. added
    Awaiting PaymentAuto-added when associated PR is deployed to production
    and removed on Oct 29, 2026
  16. melvin-bot commented on Oct 29, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionDailyKSv2ExternalAdded to denote the issue can be worked on by a contributorNewFeatureSomething to build that is a new item.

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions