Skip to content

Allow $0 distance rates and $0 distance expenses, including splits - #104756

Open
FitseTLT wants to merge 35 commits into
Expensify:mainfrom
FitseTLT:allow-zero-distance-rate
Open

FitseTLT wants to merge 35 commits into
Expensify:mainfrom
FitseTLT:allow-zero-distance-rate

Conversation

@FitseTLT

Copy link
Copy Markdown
Contributor

Explanation of Change

Lets workspaces use a $0 distance rate, for mileage that's tracked but not reimbursed.

  • Rates: validateRateValue accepts 0 and only rejects negative rates. lowRateError now reads "Rate can't be negative".
  • Expenses: a $0 rate is no longer treated as a missing rate (!rate becomes rate === undefined). This removes the "Pending..." merchant, the false "Rate out of policy" error and the disabled rate field. A distance of 0 stays blocked.
  • Splits: a $0 rate split is always $0, so these splits go by distance. The initial split, "Make splits even" and split by date divide the distance evenly. The amount and percent fields are read-only, with the info message "Amount can't be edited when the original total is $0.00." "Split" shows for $0 distance expenses, and saving accepts $0 parts that have a distance.

Saving a $0 split also needs the Auth change in https://github.com/Expensify/Auth/pull/25159. Without it, SplitTransaction returns 402 Distance rate missing from policy.

Fixed Issues

$ https://github.com/Expensify/Expensify/issues/685239
PROPOSAL: N/A

Tests

  1. As a workspace admin, open Workspace settings > Distance rates.
  2. Add a rate with a value of 0.
  3. Verify the rate saves with no error.
  4. Edit an existing rate and type -1.
  5. Verify the minus sign isn't accepted, so a negative rate can't be entered.
  6. Clear the rate and type ..
  7. Verify the input shows 0. and saves as $0.00.
  8. Create a distance expense on the workspace with any distance type (Map, Manual, GPS or Odometer), and select the $0 rate.
  9. Verify the confirmation page shows an amount of $0.00 and the rate's name, not "Pending...".
  10. Verify the rate field is pressable and shows no "Rate out of policy" error.
  11. Submit the expense.
  12. Open the expense and verify it shows the $0 rate with no "Rate out of policy" error.
  13. Create a manual distance expense with a distance of 0.
  14. Verify the zero distance is rejected.
  15. Open the $0 distance expense from step 11 and open the More menu.
  16. Verify "Split" is available.
  17. Tap "Split".
  18. Verify there are two splits, each $0 with half of the distance.
  19. Verify the split amounts are read-only and the info message "Amount can't be edited when the original total is $0.00." shows above Save.
  20. Switch to the Percent tab and verify the percentages are read-only and the same message shows. Switch back to the Amount tab.
  21. Tap "Add split", then tap "Make splits even".
  22. Verify the distance is spread evenly across the three splits and every amount stays $0.
  23. Save the split.
  24. Verify the splits are created, each with its distance and a $0 amount (requires https://github.com/Expensify/Auth/pull/25159).
  25. Create a distance expense with a rate above $0 and split it.
  26. Verify the amounts are editable, no info message shows, and amounts and distances are calculated as before.
  • Verify that no errors appear in the JS console

Offline tests

  1. Go offline.
  2. Create a distance expense on the $0 rate.
  3. Verify the expense shows as pending with its distance, the $0.00 rate in the merchant, and an amount of $0.00.
  4. Go online.
  5. Verify the expense is saved.

QA Steps

Same as tests. Step 24 requires https://github.com/Expensify/Auth/pull/25159 to be deployed.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If the PR adds or modifies the UI:
    • I asked an AI agent to review the changes for accessibility issues and addressed its findings.
    • I tested with a screen reader (VoiceOver on macOS) and verified all new/changed elements are reachable with a logical focus order.
    • I verified all new/changed elements have meaningful accessible names and roles.
    • I verified state changes are announced (e.g. checked/unchecked, expanded/collapsed, selected).
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

NicolasBonet and others added 27 commits October 2, 2026 12:59
…stance-rate

# Conflicts:
#	src/components/SelectionList/ListItem/SplitListItem.tsx
#	src/libs/PolicyDistanceRatesUtils.ts
… Split when the rate is on another workspace
…stance-rate

# Conflicts:
#	src/pages/iou/DynamicSplitExpensePage.tsx
- Split a nonzero expense whose rate is now $0 with its historical rate
- Give a $0 split of a positive rate expense no distance, and only accept $0 splits on a $0 rate
- Put the remainder of a $0 rate distance on the first split, like Make splits even
- Lock a split's amount only when the split itself is on a $0 rate
- Look up the distance rate across workspaces before offering Split
…-rate

# Conflicts:
#	src/components/MoneyRequestHeaderSecondaryActions.tsx
#	src/libs/ReportSecondaryActionUtils.ts
@FitseTLT
FitseTLT requested review from a team as code owners October 22, 2026 15:02
@FitseTLT
FitseTLT requested review from marufsharifi and removed request for parasharrajat October 22, 2026 15:02
@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
...tConfirmationList/hooks/useDistanceRequestState.ts 100.00% <100.00%> (ø)
...oneyRequestConfirmationList/sections/RateField.tsx 69.87% <100.00%> (ø)
...c/components/ReportActionItem/MoneyRequestView.tsx 78.76% <100.00%> (ø)
...c/components/Search/SearchWriteActionsProvider.tsx 95.69% <100.00%> (+0.04%) ⬆️
src/components/Search/selectionBuilders.ts 99.19% <ø> (ø)
...omponents/SelectionList/ListItem/SplitListItem.tsx 94.68% <100.00%> (ø)
...onList/ListItem/SplitListItem/SplitAmountInput.tsx 96.15% <100.00%> (+96.15%) ⬆️
...st/ListItem/SplitListItem/SplitPercentageInput.tsx 100.00% <100.00%> (+100.00%) ⬆️
src/hooks/useFrozenSplitTransactionIDs.ts 100.00% <ø> (ø)
src/hooks/useSelectedTransactionsActions.ts 80.00% <ø> (ø)
... and 13 more
... and 60 files with indirect coverage changes

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7ed2e21a7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/libs/DistanceRequestUtils.ts Resolved
Comment on lines +277 to +278
const isRateBeforeZeroRateChange = selectedRate.rate !== fallbackMileageRate.rate && (selectedRate.rate === 0 || fallbackMileageRate.rate === 0);
if (isRateBeforeZeroRateChange && customUnitRateID === fallbackMileageRate.customUnitRateID && isUsableSplitRate(fallbackMileageRate.rate)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve historical rates for independently rated splits

When editing an existing distance split whose child was assigned a rate different from the parent, a later workspace change of that child rate to or from $0 is not handled: this condition only substitutes a historical rate when the child's ID equals the parent's fallback ID. A child created at $0 and later changed positive is removed from zeroRateSplitTransactionIDs, so its valid zero amount blocks every save; the reverse transition leaves a nonzero amount locked to a live $0 rate. Infer the historical rate from the child transaction/split's own amount and quantity rather than limiting the transition logic to the parent's rate ID.

Useful? React with 👍 / 👎.

@FitseTLT

FitseTLT commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Update: working on issues i found while I reviewed the code
Eg.
non-zero rate distance expense updated to zero amount can be split - which I think is not expected

@trjExpensify

Copy link
Copy Markdown
Contributor

@FitseTLT can @marufsharifi begin the review?

@FitseTLT

Copy link
Copy Markdown
Contributor Author

Nope still working

@FitseTLT

FitseTLT commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

@marufsharifi @NicolasBonet bugs 2 and 3 are edge cases where it is impossible to determine if an expense is really zero rate at the time of creation.

Case 1 — exact-zero collision (BUG#2): Not fixable with this math: once the live rate is exactly $0 and the stored amount is also $0, "always was $0" and "was positive but rounded/changed to $0" produce identical inputs, so there's nothing left to distinguish them.

Case 2 — fully-excluded collision (BUG#3): Also not fixable with this math: once reimbursableDistance is 0 (trip shorter than the exclusion), a genuinely-$0-rate trip and a positive-rate trip that's just fully excluded both produce amount = 0, reimbursableDistance = 0 with a positive current rate — identical inputs, no way to tell which happened.

Both need the rate value persisted at creation time rather than reconstructed from amount/quantity/rate after the fact. One workaround I found is parsing from the frozen merchant text of the transaction but I can't be 100% sure if it is a perfect solution.

My question is if allowing splits for $0 amount distance expenses is the fact that we can split distances of the original transaction what if we allow spliting distance expense of zero amount with non-zero distance as a whole. This would simplify a lot of our code and be certain it will for all cases. Let me know your opinons otherwise I will apply the calculation from merchant field to determine zero rates.

@FitseTLT

Copy link
Copy Markdown
Contributor Author

@marufsharifi back to you

@marufsharifi

Copy link
Copy Markdown
Contributor

@FitseTLT, could you please merge the main. thanks.

@marufsharifi

Copy link
Copy Markdown
Contributor

reviewing.

@trjExpensify

Copy link
Copy Markdown
Contributor

@marufsharifi do you think you can complete the review today?

@marufsharifi

Copy link
Copy Markdown
Contributor

@NicolasBonet, could you please share your thoughts here: #104756 (comment), thanks.

@NicolasBonet

Copy link
Copy Markdown
Contributor

@FitseTLT could you please elaborate or exemplify what this means?

My question is if allowing splits for $0 amount distance expenses is the fact that we can split distances of the original transaction what if we allow spliting distance expense of zero amount with non-zero distance as a whole. This would simplify a lot of our code and be certain it will for all cases. Let me know your opinons otherwise I will apply the calculation from merchant field to determine zero rates.

@FitseTLT

Copy link
Copy Markdown
Contributor Author

Ok @NicolasBonet
Before this pr split is disabled for 0 amount expense. From what I understand from the work so far in this pr you were trying to only allow a distance expense to be split even if it has 0 amount if the distance rate it was created with is 0 right?

What I am saying is the logic to determine if the expense was created with 0 rate or not doesn't always work? Like for the cases of Bug 2 and 3 @marufsharifi reported.
Example: lets say there was an unreported expense whose distance rate is deleted from the workspace (so it's value cannot be determined) to determine if it was created with a zero rate you guys assumed a zero amount with non-zero distance must have been from a zero rate but the problem is amount of distance request can be updated to zero amount even with a non-zero rate. We can't differentiate between the two cases.

Workaround: the only workaround is to calculate from the merchant of the transaction but I am not sure how sustainable that fix is.

Proposal: instead what I propose is because we are allowing split with zero amount based on the logic that the distances can be split why don't we allow split of 0-amount transactions whether they have a zero rate or not. WDYT

@NicolasBonet

Copy link
Copy Markdown
Contributor

I think that works, thoughts @trjExpensify ?

@trjExpensify

trjExpensify commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

why don't we allow split of 0-amount transactions whether they have a zero rate or not

Does that mean allow regular $0 expenses to be split? (CC: @garrettmknight @lakchote to this convo on split btw)

@lakchote

lakchote commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

@trjExpensify I read @FitseTLT's proposal as distance only. A $0 distance expense still has a distance to split, whether the rate was $0 or the amount was edited down to $0, so we don't have to tell those two cases apart.

A regular $0 expense would normally split into parts that are all $0, so each part could only differ by category, tag or date. Creating those splits already works on the backend, it's only blocked in the app.

Removing a part later can fail, though. If another part is already on an approved, paid or closed report and a third one can still be edited, what's left adds up to $0 and the backend refuses it. The app only shows "Unexpected error editing this expense. Please try again later."

So if we allow it, that check needs to change too on the backend.

@FitseTLT

Copy link
Copy Markdown
Contributor Author

@trjExpensify I read @FitseTLT's proposal as distance only. A $0 distance expense still has a distance to split, whether the rate was $0 or the amount was edited down to $0, so we don't have to tell those two cases apart.

A regular $0 expense would normally split into parts that are all $0, so each part could only differ by category, tag or date. Creating those splits already works on the backend, it's only blocked in the app.

Removing a part later can fail, though. If another part is already on an approved, paid or closed report and a third one can still be edited, what's left adds up to $0 and the backend refuses it. The app only shows "Unexpected error editing this expense. Please try again later."

So if we allow it, that check needs to change too on the backend.

@lakchote So does this change be made for Zero-amount distance expenses too. This issue is scoped on zero amount distance expenses. If I got your point correct other split fields can be updated when one is already approved. Like here

2026-10-30.19-39-26.mp4

@trjExpensify

Copy link
Copy Markdown
Contributor

So does this change be made for Zero-amount distance expenses too. This issue is scoped on zero amount distance expenses

Sorry @FitseTLT, I don't understand this sentence.

@FitseTLT

FitseTLT commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

So does this change be made for Zero-amount distance expenses too. This issue is scoped on zero amount distance expenses

Sorry @FitseTLT, I don't understand this sentence.

In change, I meant the BE change that has to be made @lakchote is claiming; Should the BE change be made for distance expenses too or we can continue on working on the FE changes in this pr without BE change.

But the main point is what should we do next here 😄

@lakchote

lakchote commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

@FitseTLT no, the backend change isn't needed for distance expenses. The check that refuses remaining splits adding up to $0 skips them, so you can keep going with the App changes here.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants