Skip to content

[Due for payment 2026-10-30] [$175] The “Cash expenses are always non-reimbursable” rule is not enforced. Users can still mark cash expenses as reimbursable, and new cash expenses are created as reimbursable by default #101587

Open

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number:
Reproducible in staging?: Needs Reproduction (Unable to reproduce)
Reproducible in production?: Needs Reproduction
If this was caught during regression testing, add the test name, ID and link from BrowserStack:
Email or phone of affected tester (no customers):
Logs: https://stackoverflow.com/c/expensify/questions/4856
Expensify/Expensify Issue URL:
Issue reported by: @rairibeiro
Slack conversation (hyperlinked to channel name): #Expensify Bugs

Action Performed:

1.  As a workspace admin on a Collect/Control workspace, go to Workspace → Rules → Individual expenses → Cash expenses.
2.  Select “Cash expenses are always non-reimbursable” and save.
3.  As a workspace member, submit a new cash (manual/SmartScanned) expense to that workspace.
4.  Open the expense and inspect the Reimbursable field.
5.  As an admin, open an already-submitted cash expense and try to toggle Reimbursable on.

Expected Result:

•   Every new cash expense is created with reimbursable: false.
•   The Reimbursable toggle is hidden or locked off for members and admins.
•   Any attempt to set reimbursable: true is rejected server-side and forced back to non-reimbursable.

Actual Result:

•   New cash expenses are created with reimbursable: true.
•   Expenses stay reimbursable through submit and approval, so they land in the reimbursement queue.
•   The admin sees the rule as correctly set in the UI, so there is no signal anything is wrong.

Root Cause — two separate gaps:

  1. defaultReimbursable absent means opposite things in App and Auth.

setPolicyReimbursableMode in src/libs/actions/Policy/Policy.ts writes defaultReimbursable: false and disabledFields.reimbursable: true as a pair. When only disabledFields.reimbursable is present, the App renders “always non-reimbursable”, but Auth’s getDefaultReimbursable treats an absent value as true. That makes Policy::isForcingNonReimbursable return false and silently disables enforcement. No SetPolicyReimbursableMode call appears for this account since 2026-08-25, consistent with the pair never being written together.

  1. CreateMoneyRequest never consults isForcingNonReimbursable.

Policy::isForcingNonReimbursable is only checked in UpdateMoneyRequest.cpp, UpdateSplitTransaction, and SplitTransaction — not on the create path. So a wrong value at creation is never corrected. On the client side, calculateDefaultReimbursable in src/libs/IOUUtils.ts returns reportPolicy?.defaultReimbursable ?? true and hard-codes true outside the policy-expense-chat case. It never reads disabledFields.reimbursable.

Gap 2 means the rule fails even on workspaces where defaultReimbursable: false is stored correctly.

Workaround:

Re-save the rule: pick a different cash-expense option, save, then re-select “always non-reimbursable” and save. That writes the missing defaultReimbursable: false. Existing expenses need manual un-checking while unapproved.

Platforms:

Select the officially supported platforms where the issue was reproduced:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

View all open jobs on GitHub

Issue OwnerCurrent Issue Owner: @cretadn22

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Sep 18, 2026
  2. MelvinBot commented on Sep 18, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    The App has two readers of policy.defaultReimbursable that disagree about what undefined means. When the field is absent, the Rules page says "Cash expenses are always non-reimbursable" while expense creation defaults to reimbursable — and the toggle that would reveal the mismatch is hidden.

    defaultReimbursable disabledFields.reimbursable Rules page shows New cash expense gets
    undefined true always non-reimbursable reimbursable: true ❌
    undefined undefined non-reimbursable by default reimbursable: true ❌
    false true always non-reimbursable false ✅

    The reimbursable toggle is also hidden in exactly the broken case, which is why there is no signal. In MoneyRequestView.tsx:531-537 the "transaction differs from the policy default" escape hatch is gated on policy?.defaultReimbursable !== undefined, so it short-circuits precisely when defaultReimbursable is unset. useFooterDerivedFlags.ts:97-98 has no escape hatch at all. The expense is created reimbursable and the user sees no control at confirmation or in the expense details.

    Where the divergence came from: feat: reimbursable features (merged 2025-08-18) shipped both sides using !!policy?.defaultReimbursable, so they agreed. Fix: Reimbursable Toggle Missing for Collect Workspace Users — Expenses Defaulting to Non-Reimbursable (merged 2025-10-08) flipped the creation side to ?? true and started seeding new workspaces with defaultReimbursable: true, but left getCashExpenseReimbursableMode untouched. That is why this only bites workspaces whose policy blob has no defaultReimbursable — which is also why it is not reproducible on a freshly created workspace.

    Supporting evidence
    • The ?? true expression is duplicated at three live call sites, not one: IOUUtils.ts:470, ExpenseDefaultsSetter.tsx:39 (the path a normal manual/cash expense takes), and SubmitDetailsPage.tsx:259.
    • No creation-path code reads disabledFields.reimbursable. The lock is consulted only for display, for moving a transaction (Transaction.ts:1409) and for splits (SplitExpenseItems.ts:89). The ?? true fallback is the only thing enforcing the rule at creation.
    • IndividualExpenseRulesSectionRevamp.tsx:103 has a ?? REIMBURSABLE_DEFAULT fallback, but it only fires when policy itself is nullish, so it does not rescue the undefined field case.
    • setPolicyReimbursableMode writes both fields together, so re-saving the rule repairs the policy. That matches the workaround in the report, and it is consistent with the reporter's note that no SetPolicyReimbursableMode call appears for this account.
    • Alternative I considered and ruled out: that the policy correctly stores defaultReimbursable: false and only the server ignores it. If that were true the App would compute false and send reimbursable: false explicitly, so the new expense would be non-reimbursable locally. The report says new expenses are reimbursable, which only happens client-side when the field is absent.
    • No test coverage. tests/actions/PolicyTest.ts:2499-2672 covers setPolicyReimbursableMode with explicit booleans only. Nothing exercises getCashExpenseReimbursableMode or calculateDefaultReimbursable, and nothing sets defaultReimbursable to undefined.

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

    1. Make undefined mean the same thing in both readers. Give getCashExpenseReimbursableMode an explicit defaultReimbursable ?? true so it matches the creation side. A policy with no stored value then correctly displays "reimbursable by default" instead of falsely claiming the rule is on.
    2. Honor the lock at creation. Have the creation-time default read disabledFields.reimbursable as well: when the field is locked, derive the value from the policy default rather than from ?? true. Apply this in all three copies, and fold ExpenseDefaultsSetter.tsx and SubmitDetailsPage.tsx into calculateDefaultReimbursable so there is one source of truth.
    3. Stop hiding the toggle on drift. Drop the policy?.defaultReimbursable !== undefined guard in MoneyRequestView.tsx, and add the same "differs from the policy default" escape hatch to useFooterDerivedFlags.ts, so an expense that disagrees with a locked policy is always correctable instead of silently stuck.
    4. Add unit tests for getCashExpenseReimbursableMode and calculateDefaultReimbursable covering defaultReimbursable: undefined against each disabledFields.reimbursable value.

    Separately, the report also notes that the create path is not corrected server-side, so a wrong value at creation is never fixed. That part is outside this repo — an internal engineer should confirm the backend applies the same enforcement on create that it already applies on update.

    What alternative solutions did you explore? (Optional)

    A one-time backfill that writes defaultReimbursable for every policy where it is absent. That repairs existing data but leaves the two readers inconsistent, so the next policy that reaches the App without the field breaks the same way. Worth doing in addition to the code fix, not instead of it.

    Related issues

    Confidence

    High on the App-side mechanism — the contradiction, the toggle-hiding logic, and the PR that introduced the split are all verified in code and git history. One link is unconfirmed: I could not check that the affected workspace's policy actually has defaultReimbursable absent. Reading that field on the reported policy would settle it. A live reproduction was not possible in this run; the test session failed before reaching the workspace rules, and a freshly created workspace is not expected to reproduce it anyway.


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


    view run · no recording available

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Sep 18, 2026
  4. melvin-bot commented on Sep 18, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @cretadn22 (External)

  5. melvin-bot commented on Sep 18, 2026

    @melvin-bot

    Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.

  6. iprasen commented on Sep 19, 2026

    @iprasen

    Proposal

    What is the root cause of that problem?

    The app has an inconsistency in how policy.defaultReimbursable is interpreted when undefined:

    1. Expense Creation: In IOUUtils.calculateDefaultReimbursable (IOUUtils.ts:470), ExpenseDefaultsSetter.tsx:39, and SubmitDetailsPage.tsx:259:
      (policy?.defaultReimbursable ?? true)
      When policy.defaultReimbursable is undefined, expense creation defaults to true (reimbursable).
    2. Rules Page Display: In getCashExpenseReimbursableMode (src/libs/actions/Policy/Policy.ts:6895):
      if (!policy.disabledFields?.reimbursable && !policy.defaultReimbursable) {
          return CONST.POLICY.CASH_EXPENSE_REIMBURSEMENT_CHOICES.NON_REIMBURSABLE_DEFAULT;
      }
      Because !policy.defaultReimbursable evaluates undefined as falsy, the Rules screen displays "Cash expenses are always non-reimbursable" (or non-reimbursable default) even though defaultReimbursable was never explicitly set to false.
    3. Toggle Visibility & Enforcement: In MoneyRequestView.tsx:536:
      (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault)
      where isCurrentTransactionReimbursableDifferentFromPolicyDefault checks policy?.defaultReimbursable !== undefined. When disabledFields.reimbursable is enabled, the toggle should be strictly hidden and lock transactions to policy.defaultReimbursable.

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

    1. In src/libs/actions/Policy/Policy.ts, update getCashExpenseReimbursableMode to treat undefined consistently with expense creation:
      const defaultReimbursable = policy.defaultReimbursable ?? true;
      • When defaultReimbursable is true and !policy.disabledFields?.reimbursable, return REIMBURSABLE_DEFAULT.
      • When defaultReimbursable is false and !policy.disabledFields?.reimbursable, return NON_REIMBURSABLE_DEFAULT.
      • When defaultReimbursable is true and policy.disabledFields?.reimbursable, return ALWAYS_REIMBURSABLE.
      • When defaultReimbursable is false and policy.disabledFields?.reimbursable, return ALWAYS_NON_REIMBURSABLE.
    2. In MoneyRequestView.tsx and useFooterDerivedFlags.ts, ensure that when policy?.disabledFields?.reimbursable === true, the reimbursable toggle is strictly disabled/hidden so users cannot manually mark cash expenses as reimbursable when the rule is enforced.

    What alternative solutions did you explore? (Optional)

    Migrating all policies on the backend to guarantee defaultReimbursable is never undefined was considered, but frontend normalization via defaultReimbursable ?? true immediately aligns the Rules screen with existing expense creation defaults across all platforms.

  7. melvin-bot commented on Sep 21, 2026

    @melvin-bot

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

  8. cretadn22 commented on Sep 22, 2026

    @cretadn22
    Contributor

    @MelvinBot Implement this

  9. MelvinBot commented on Sep 22, 2026

    @MelvinBot
    Contributor

    🤖 Draft PR created: Enforce the cash-expense reimbursable rule when defaultReimbursable is absent

    Please mark it as "Ready for review" when it is ready for review.

    What it does: makes an absent policy.defaultReimbursable mean the same thing in every reader (reimbursable, matching the backend), and makes disabledFields.reimbursable authoritative at expense creation time.

    Change File
    getCashExpenseReimbursableMode reads defaultReimbursable ?? true, so a policy with no stored value stops falsely advertising "always non-reimbursable" src/libs/actions/Policy/Policy.ts:6886
    New getDefaultReimbursableForPolicy — single source of truth for the creation-time default; honors the lock for group workspaces src/libs/IOUUtils.ts:454
    The three duplicated ?? true copies now call that helper IOUUtils.ts:498, ExpenseDefaultsSetter.tsx:39, SubmitDetailsPage.tsx:259
    The defaultReimbursable !== undefined bail-out that hid the toggle on drift is removed, and the same escape hatch is added to the confirmation footer MoneyRequestView.tsx:532, DynamicSplitExpenseEditPage.tsx:238, useFooterDerivedFlags.ts:97
    Unit tests for all three functions across every defaultReimbursable: undefined × disabledFields.reimbursable combination tests/actions/PolicyTest.ts, tests/unit/IOUUtilsTest.ts

    useFooterDerivedFlags needed reimbursable on its transaction slice, so selectors.ts:231 was widened by one field.

    AI tests run
    Check Result
    npm run typecheck ✅ passed
    npm run lint-changed ✅ passed
    npm run spell-changed ✅ 10 files, 0 issues
    npm run react-compiler-compliance-check (5 changed TSX/hook files) ✅ passed
    tests/unit/IOUUtilsTest.ts ✅ 125 passed
    tests/actions/PolicyTest.ts ✅ 221 passed
    MoneyRequestViewTest, SubmitDetailsPageTest, MoneyRequestConfirmationListFooter, selectorsTest, IOURequestStepConfirmationPageTest ✅ 96 passed
    Split / transaction / policy-rules suites ✅ 146 passed

    npm run prettier is not a script in this repo; formatting is covered by the lint run.

    Two notes:

    1. I could not verify this in a browser. Web prep failed in this run, so there was no way to drive NewDot. Everything above is verified by typecheck, lint, and unit tests only — the manual Tests and QA sections are left for you to fill in.
    2. The backend half is still open. As the report says, CreateMoneyRequest never consults Policy::isForcingNonReimbursable, so a wrong value at creation is never corrected server-side. That is outside this repo and needs an internal engineer. This PR fixes the App-side gap; existing broken workspaces still need either the re-save workaround or a backfill of defaultReimbursable.
  10. melvin-bot commented on Sep 25, 2026

    @melvin-bot

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

  11. 14 remaining items

  12. changed the title [-]The “Cash expenses are always non-reimbursable” rule is not enforced. Users can still mark cash expenses as reimbursable, and new cash expenses are created as reimbursable by default[/-] [+][$175] The “Cash expenses are always non-reimbursable” rule is not enforced. Users can still mark cash expenses as reimbursable, and new cash expenses are created as reimbursable by default[/+] on Oct 21, 2026
  13. melvin-bot commented on Oct 21, 2026

    @melvin-bot

    Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.

  14. melvin-bot commented on Oct 21, 2026

    @melvin-bot

    Triggered auto assignment to @puneetlath, see https://stackoverflow.com/c/expensify/questions/7972 for more details.

  15. changed the title [-][$175] The “Cash expenses are always non-reimbursable” rule is not enforced. Users can still mark cash expenses as reimbursable, and new cash expenses are created as reimbursable by default[/-] [+][Due for payment 2026-10-30] [$175] The “Cash expenses are always non-reimbursable” rule is not enforced. Users can still mark cash expenses as reimbursable, and new cash expenses are created as reimbursable by default[/+] on Oct 23, 2026
  16. melvin-bot commented on Oct 23, 2026

    @melvin-bot

    @cretadn22

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.5.18-1 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

    If no regressions arise, payment will be issued on 2026-10-30. 🎊

    The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the Contributor+ Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]. If no checklist is needed for this issue, you can click: [no checklist button]

  17. cretadn22 commented on Oct 26, 2026

    @cretadn22
    Contributor

    BugZero 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
      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.

    • [BugZero Assignee] Create a GH issue for creating/updating the regression test once above steps have been agreed upon.

      Link to issue:

    Regression Test Proposal

    Preconditions:

    As a workspace admin in a Collect/Control workspace:

    1. Navigate to Workspace → Rules → Individual expenses → Cash expenses.
    2. Select “Cash expenses are always non-reimbursable” and save the changes.

    Test:

    1. Create two expense reports:
    2. Report A: Contains one expense.
    3. Report B: Contains two expenses.
    4. Open the expense detail view for each report and verify that:
    5. The Non-reimbursable label appears immediately after the amount field.
    6. The Reimbursable toggle is hidden.

    Do we agree 👍 or 👎

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

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.ExternalAdded to denote the issue can be worked on by a contributorWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions