You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[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
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:
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.
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:
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.
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.
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?
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.
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.
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.
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.
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.
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.
Toggle Visibility & Enforcement: In MoneyRequestView.tsx:536:
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?
In src/libs/actions/Policy/Policy.ts, update getCashExpenseReimbursableMode to treat undefined consistently with expense creation:
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.
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.
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"
npm run prettier is not a script in this repo; formatting is covered by the lint run.
Two notes:
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.
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.
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
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
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]
[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:
Activity
MelvinBot commentedon Sep 18, 2026
Proposal
What is the root cause of that problem?
The App has two readers of
policy.defaultReimbursablethat disagree about whatundefinedmeans. 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.getCashExpenseReimbursableModeinsrc/libs/actions/Policy/Policy.ts:6886-6904, uses plain truthiness, soundefinedreads as false. WithdisabledFields.reimbursable === trueit falls through toALWAYS_NON_REIMBURSABLE.calculateDefaultReimbursableinsrc/libs/IOUUtils.ts:470, uses?? true, soundefinedreads as true.defaultReimbursabledisabledFields.reimbursableundefinedtruereimbursable: true❌undefinedundefinedreimbursable: true❌falsetruefalse✅The reimbursable toggle is also hidden in exactly the broken case, which is why there is no signal. In
MoneyRequestView.tsx:531-537the "transaction differs from the policy default" escape hatch is gated onpolicy?.defaultReimbursable !== undefined, so it short-circuits precisely whendefaultReimbursableis unset.useFooterDerivedFlags.ts:97-98has 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?? trueand started seeding new workspaces withdefaultReimbursable: true, but leftgetCashExpenseReimbursableModeuntouched. That is why this only bites workspaces whose policy blob has nodefaultReimbursable— which is also why it is not reproducible on a freshly created workspace.Supporting evidence
?? trueexpression is duplicated at three live call sites, not one:IOUUtils.ts:470,ExpenseDefaultsSetter.tsx:39(the path a normal manual/cash expense takes), andSubmitDetailsPage.tsx:259.disabledFields.reimbursable. The lock is consulted only for display, for moving a transaction (Transaction.ts:1409) and for splits (SplitExpenseItems.ts:89). The?? truefallback is the only thing enforcing the rule at creation.IndividualExpenseRulesSectionRevamp.tsx:103has a?? REIMBURSABLE_DEFAULTfallback, but it only fires whenpolicyitself is nullish, so it does not rescue theundefinedfield case.setPolicyReimbursableModewrites 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 noSetPolicyReimbursableModecall appears for this account.defaultReimbursable: falseand only the server ignores it. If that were true the App would computefalseand sendreimbursable: falseexplicitly, 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.tests/actions/PolicyTest.ts:2499-2672coverssetPolicyReimbursableModewith explicit booleans only. Nothing exercisesgetCashExpenseReimbursableModeorcalculateDefaultReimbursable, and nothing setsdefaultReimbursabletoundefined.What changes do you think we should make in order to solve the problem?
undefinedmean the same thing in both readers. GivegetCashExpenseReimbursableModean explicitdefaultReimbursable ?? trueso 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.disabledFields.reimbursableas well: when the field is locked, derive the value from the policy default rather than from?? true. Apply this in all three copies, and foldExpenseDefaultsSetter.tsxandSubmitDetailsPage.tsxintocalculateDefaultReimbursableso there is one source of truth.policy?.defaultReimbursable !== undefinedguard inMoneyRequestView.tsx, and add the same "differs from the policy default" escape hatch touseFooterDerivedFlags.ts, so an expense that disagrees with a locked policy is always correctable instead of silently stuck.getCashExpenseReimbursableModeandcalculateDefaultReimbursablecoveringdefaultReimbursable: undefinedagainst eachdisabledFields.reimbursablevalue.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
defaultReimbursablefor 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
defaultReimbursableabsent. 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
melvin-bot commentedon Sep 18, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @cretadn22 (
External)melvin-bot commentedon Sep 18, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
iprasen commentedon Sep 19, 2026
Proposal
What is the root cause of that problem?
The app has an inconsistency in how
policy.defaultReimbursableis interpreted whenundefined:IOUUtils.calculateDefaultReimbursable(IOUUtils.ts:470),ExpenseDefaultsSetter.tsx:39, andSubmitDetailsPage.tsx:259:policy.defaultReimbursableisundefined, expense creation defaults totrue(reimbursable).getCashExpenseReimbursableMode(src/libs/actions/Policy/Policy.ts:6895):!policy.defaultReimbursableevaluatesundefinedas falsy, the Rules screen displays "Cash expenses are always non-reimbursable" (or non-reimbursable default) even thoughdefaultReimbursablewas never explicitly set tofalse.MoneyRequestView.tsx:536:isCurrentTransactionReimbursableDifferentFromPolicyDefaultcheckspolicy?.defaultReimbursable !== undefined. WhendisabledFields.reimbursableis enabled, the toggle should be strictly hidden and lock transactions topolicy.defaultReimbursable.What changes do you think we should make in order to solve the problem?
src/libs/actions/Policy/Policy.ts, updategetCashExpenseReimbursableModeto treatundefinedconsistently with expense creation:defaultReimbursableis true and!policy.disabledFields?.reimbursable, returnREIMBURSABLE_DEFAULT.defaultReimbursableis false and!policy.disabledFields?.reimbursable, returnNON_REIMBURSABLE_DEFAULT.defaultReimbursableis true andpolicy.disabledFields?.reimbursable, returnALWAYS_REIMBURSABLE.defaultReimbursableis false andpolicy.disabledFields?.reimbursable, returnALWAYS_NON_REIMBURSABLE.MoneyRequestView.tsxanduseFooterDerivedFlags.ts, ensure that whenpolicy?.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
defaultReimbursableis never undefined was considered, but frontend normalization viadefaultReimbursable ?? trueimmediately aligns the Rules screen with existing expense creation defaults across all platforms.melvin-bot commentedon Sep 21, 2026
@cretadn22 Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
cretadn22 commentedon Sep 22, 2026
@MelvinBot Implement this
MelvinBot commentedon Sep 22, 2026
🤖 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.defaultReimbursablemean the same thing in every reader (reimbursable, matching the backend), and makesdisabledFields.reimbursableauthoritative at expense creation time.getCashExpenseReimbursableModereadsdefaultReimbursable ?? true, so a policy with no stored value stops falsely advertising "always non-reimbursable"src/libs/actions/Policy/Policy.ts:6886getDefaultReimbursableForPolicy— single source of truth for the creation-time default; honors the lock for group workspacessrc/libs/IOUUtils.ts:454?? truecopies now call that helperIOUUtils.ts:498,ExpenseDefaultsSetter.tsx:39,SubmitDetailsPage.tsx:259defaultReimbursable !== undefinedbail-out that hid the toggle on drift is removed, and the same escape hatch is added to the confirmation footerMoneyRequestView.tsx:532,DynamicSplitExpenseEditPage.tsx:238,useFooterDerivedFlags.ts:97defaultReimbursable: undefined×disabledFields.reimbursablecombinationtests/actions/PolicyTest.ts,tests/unit/IOUUtilsTest.tsuseFooterDerivedFlagsneededreimbursableon its transaction slice, soselectors.ts:231was widened by one field.AI tests run
npm run typechecknpm run lint-changednpm run spell-changednpm run react-compiler-compliance-check(5 changed TSX/hook files)tests/unit/IOUUtilsTest.tstests/actions/PolicyTest.tsMoneyRequestViewTest,SubmitDetailsPageTest,MoneyRequestConfirmationListFooter,selectorsTest,IOURequestStepConfirmationPageTestnpm run prettieris not a script in this repo; formatting is covered by the lint run.Two notes:
TestsandQAsections are left for you to fill in.CreateMoneyRequestnever consultsPolicy::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 ofdefaultReimbursable.melvin-bot commentedon Sep 25, 2026
@cretadn22 Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!
14 remaining items
[-]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[/+]melvin-bot commentedon Oct 21, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
melvin-bot commentedon Oct 21, 2026
Triggered auto assignment to @puneetlath, see https://stackoverflow.com/c/expensify/questions/7972 for more details.
[-][$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[/+]melvin-bot commentedon Oct 23, 2026
@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]
cretadn22 commentedon Oct 26, 2026
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:
Test:
Do we agree 👍 or 👎