Skip to content

[No QA] Add getRateForPolicyChange to select a distance rate when the policy changes - #101080

Merged
neil-marcellini merged 5 commits into
mainfrom
claude-rateForPolicyChange
Sep 17, 2026
Merged

neil-marcellini merged 5 commits into
mainfrom
claude-rateForPolicyChange

Conversation

@MelvinBot

Copy link
Copy Markdown
Contributor

Explanation of Change

This is F2 of the frontend tracking issue: the shared helper that picks a distance rate when an expense moves to a different workspace.

Today the only App-side helper for this is getDefaultMileageRate, which sorts the enabled rates by index and takes the first. It ignores the expense date, and it never checks whether the destination workspace already has a rate equivalent to the one the expense is using — so a move reprices the expense even when an identical rate exists on the destination.

This PR adds DistanceRequestUtils.getRateForPolicyChange({transaction, policy, currentRate}), the app-side mirror of Auth's selection rule:

  1. A rate on the destination policy that is equivalent to the expense's current rate — same value, same currency, same distance unit — and valid for the expense date. The unit comes from the policy's distance custom unit, so a unit mismatch disqualifies the whole destination policy.
  2. Otherwise getBestEligibleRateOrPolicyDefault, which is the destination policy's best rate for the expense date, falling back to its default rate.
  3. Otherwise nothing, so the caller keeps the customUnitOutOfPolicy violation.

Two supporting details:

  • The tie-break comparator inside getBestEligibleRate is extracted into compareRatesByDateSpecificity and reused for the matching step, so the "narrowest and most recent date range" rule stays in one place. getBestEligibleRate behavior is unchanged.
  • The helper returns undefined when the selected rate has no customUnitRateID. getDefaultMileageRate returns a fully shaped rate object with an undefined customUnitRateID when the policy has no enabled rates, which a caller doing if (rate) would read as a successful selection.

Note on the signature. The issue lists {transaction, policy}. A workspace distance expense does not store its rate value — only comment.customUnit.customUnitRateID, which resolves against the source policy — and DistanceRequestUtils holds no Onyx connections, so it cannot look that policy up itself. currentRate is therefore an optional third field: F3 and F4 pass the rate they have already resolved, and for a P2P expense the helper derives it from comment.customUnit.defaultP2PRate on the transaction so the documented two-argument call still works.

The server is authoritative — the app computes the same answer only to build optimistic data — so the helper carries the cross-reference comment that getRate already uses. The matching comment on the Auth side is not in this PR, since it lives in a different repository.

No callers change in this PR, so it can land at any time. F3 and F4 wire it up.

AI Tests

Test Result
npm test -- tests/unit/DistanceRequestUtilsTest.ts ✅ 61 passed (9 new)
npm test -- tests/unit/DistanceRateTest.ts tests/unit/useDistanceRateOriginalPolicyTest.ts tests/unit/useTransactionViolationsDistanceRateTest.ts tests/unit/PolicyDistanceRatesUtilsTest.ts ✅ 44 passed
npm run typecheck ✅ passed
npm run lint-changed ✅ passed
npm run spell-changed ✅ passed
npm run lint (full repo) ⚠️ not completed — exceeded the 10 minute limit in this environment. lint-changed runs the same linter over both changed files and passed.
npm run prettier ⚠️ not runnable — no prettier script exists in package.json at this commit.
npm run storybook -- --smoke-test --ci ⚠️ not run — no components or stories are touched by this PR.

Fixed Issues

$ #100557
PROPOSAL: #100557 (comment)

Tests

// TODO: The human co-author must fill out the tests you ran before marking this PR as "ready for review".
// Note: this PR adds a helper with no callers, so there is no user-facing behavior to test manually. The unit tests in tests/unit/DistanceRequestUtilsTest.ts cover the selection rules.

  • Verify that no errors appear in the JS console

Offline tests

Not applicable — this PR adds a pure helper function with no callers and no network behavior.

QA Steps

// TODO: The human co-author must fill out the QA tests you ran before marking this PR as "ready for review", or add "[No QA]" to the PR title.

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

This PR adds a pure helper function with no callers, so there is no UI change to capture.

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

…changes

Co-authored-by: truph01 <truph01@users.noreply.github.com>
@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

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

Files with missing lines Coverage Δ
src/libs/DistanceRequestUtils.ts 94.95% <93.54%> (+1.88%) ⬆️
... and 10 files with indirect coverage changes

Co-authored-by: truph01 <truph01@users.noreply.github.com>
@MelvinBot

MelvinBot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Web test steps verification results: ✅ pass

App loaded and was signed in with no visible errors. The full distance-expense flow (FAB → Track distance → Start/Stop waypoints → Confirm details) worked end-to-end and rendered a correct distance rate row ($0.76/mile) with no failed network requests observed.

Confirm details screen showing Distance 0.31 miles and Rate $0.76/mile rows

Step Status
App loads and signs in with no errors in the JS consoleSession was handed off in state=ready; initial snapshot showed the signed-in Home screen (nav tabs, Concierge composer, account info) with no error banners. agent-device has no console-log capture verb on web (logs returns UNSUPPORTED_OPERATION), so absence of visible error UI plus a clean network dump (all requests 200/204, no 4xx/5xx) was used as the best available evidence.
Signed-in Home screen with no error banners
Create a distance expense: open FAB, choose expense creation, pick Distance, add start/finish waypoints, confirm the rate row renders a distance rateOpened the global FAB, selected "Track distance", set Start to "1 Market St, San Francisco" and Stop to "1 Ferry Building, San Francisco" via address autocomplete, clicked Next, chose the recipient (self), and landed on "Confirm details". The panel rendered Distance: 0.31 miles, Rate: $0.76 / mile, Amount: $0.24. Network dump over this flow showed only 200/204 responses (GetRouteForDraft, Proxy_GooglePlaces, Mapbox assets, Ping) with no failed requests. I stopped short of clicking "Create expense" so no real expense record was written.
Confirm details screen showing Distance 0.31 miles and Rate $0.76/mile rows

Scope note: the Tests and QA Steps sections of this PR are still unfilled TODOs for the human co-author, and the PR adds a helper with no callers — so the only documented Web step is "Verify that no errors appear in the JS console". I ran that, and exercised the distance-expense flow the changed file feeds into so the console check had real coverage rather than just a blank app load. I could not re-run tests/unit/DistanceRequestUtilsTest.ts locally (sandbox blocked the jest invocation), so the unit-test results in the PR body are unverified by this run.


view run · no recording available

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Fixed and pushed in 0ab82c4.

Cause: the getRateForPolicyChange signature was written as a multi-line destructure, but it fits in one line under this repo's 190-char printWidth, so oxfmt collapsed it and the Oxfmt check job failed on the resulting diff.

Fix: ran npm run fmt. Formatting only — one line changed in src/libs/DistanceRequestUtils.ts:665, no behavior change.

Oxfmt check now passes, and so does everything else. The only remaining red check is Check independent approval, which just needs a human reviewer — nothing to fix there.

Details

The failing job's output was:

> oxfmt --write .
Finished in 836ms on 9232 files using 4 threads.
src/libs/DistanceRequestUtils.ts
Error: Oxfmt diff detected! Please run `npm run fmt` and commit the changes.

The reformat:

-function getRateForPolicyChange({
-    transaction,
-    policy,
-    currentRate,
-}: {
-    transaction: OnyxEntry<Transaction>;
-    policy: OnyxEntry<Policy>;
-    currentRate?: MileageRate;
-}): MileageRate | undefined {
+function getRateForPolicyChange({transaction, policy, currentRate}: {transaction: OnyxEntry<Transaction>; policy: OnyxEntry<Policy>; currentRate?: MileageRate}): MileageRate | undefined {

Re-running npm run fmt after the change is a no-op, so the formatter is settled.

Verification before pushing:

Test Result
npm run fmt (idempotent re-run) ✅ no diff
npm run typecheck ✅ passed
npx eslint src/libs/DistanceRequestUtils.ts ✅ no new errors
npm test -- tests/unit/DistanceRequestUtilsTest.ts ✅ 61 passed

Two notes on the local run: npm run lint-changed aborts with git merge-base failed in this environment because the checkout is shallow, so I linted the file directly instead — the one error it reports is a pre-existing no-unsafe-type-assertion at line 107, untouched by this PR and grandfathered by the seatbelt ratchet. The CI ESLint check passes.

One correction to the PR description. The AI Tests table says npm run prettier is "not runnable — no prettier script exists in package.json". That is true but misleading: this repo formats with oxfmt, and the script is npm run fmt. That is the command the Oxfmt check gate runs, and skipping it is what let this through. Worth running npm run fmt before future pushes.

@MelvinBot

MelvinBot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Android test steps verification results: ✅ pass

The getRateForPolicyChange helper added in this PR has no callers, so distance-rate resolution in the live "Create expense → Distance" flow still runs through the pre-existing code path. That flow works correctly on standalone Android NewDot and no JS errors were observed.

Confirm details screen showing Distance 0.31 miles and Rate Default Rate

Step Status
App loads and signs in with no JS errors in the logsReached the signed-in Home screen ("Good afternoon, Melvin.") after completing sign-in and onboarding. Grepped app.log for FATAL, RCTFatalException, Unhandled JS Exception, console.error, TypeError, ReferenceError — none found. Two dev-only React Navigation warnings appeared during onboarding/app-switcher navigation (DISMISS_MODAL not handled, navigation object hasn't been initialized yet) — standard RN dev warnings, not crashes, unrelated to the distance-rate code this PR touches.
Signed-in Home screen on Android
Create a distance expense: open FAB, choose expense creation, pick Distance, add start/finish waypoints, confirm the rate row renders a distance rateOpened the FAB → "Track distance", entered Start = "1 Market St, San Francisco, CA" and Stop = "1 Ferry Building, San Francisco, CA" on the Map tab, then tapped Next. The "Confirm details" screen rendered Distance: 0.31 miles, Rate: Default Rate, Amount: $0.24, Category: Car — the rate row renders a distance rate correctly. No error banners or JS exceptions appeared through this flow.
Confirm details screen showing Distance 0.31 miles and Rate Default Rate

Scope note: the Tests and QA Steps sections of this PR are still unfilled TODOs for the human co-author, and the PR adds a helper with no callers — so the only documented step is "Verify that no errors appear in the JS console". I ran that, and exercised the distance-expense flow the changed file feeds into so the check had real coverage rather than just a blank app load.

Session note: the pinned Android session started signed-out — CI's automated sign-in replay failed at the email-fill step. Re-probing the same selectors on a fresh snapshot resolved them immediately, so sign-in was completed by hand and the run proceeded. This looks like a one-off flake in the prestart replay rather than selector drift, so no flow fix was published.


view run · no recording available

@truph01

truph01 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot Merge main and mark this PR as ready

…hange

Co-authored-by: truph01 <truph01@users.noreply.github.com>
@MelvinBot
MelvinBot marked this pull request as ready for review September 15, 2026 03:32
@MelvinBot
MelvinBot requested a review from a team as a code owner September 15, 2026 03:32
@melvin-bot
melvin-bot Bot requested review from truph01 and removed request for a team September 15, 2026 03:32
@melvin-bot

melvin-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

@truph01 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot
melvin-bot Bot requested review from garrettmknight and removed request for a team September 15, 2026 03:32
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Merged main and marked this PR as ready for review.

  • Merge commit: d1d53e9 (446 commits from main, no conflicts)
  • Only failing check before the merge was Check independent approval, which needs a human reviewer

⚠️ Two things still need you: the Tests and QA Steps sections still hold the // TODO: **The human co-author** must fill out… placeholders, and most of the PR Author Checklist is unchecked. I left both alone on purpose — those are yours to fill in. Reviewers will see the placeholder text until you do.

AI tests re-run after the merge
Test Result
npm test -- tests/unit/DistanceRequestUtilsTest.ts ✅ 61 passed
npm test -- tests/unit/DistanceRateTest.ts tests/unit/useDistanceRateOriginalPolicyTest.ts tests/unit/useTransactionViolationsDistanceRateTest.ts tests/unit/PolicyDistanceRatesUtilsTest.ts ✅ 44 passed
npm run typecheck ✅ passed
npm run lint on the changed files ✅ passed
npm run spell-changed ✅ 0 issues
npm test (full suite) ⚠️ not run — too slow for this environment; CI covers it
npm run storybook -- --smoke-test --ci ⚠️ not run — no components or stories touched

main bumped react-native-pager-view 8.0.0 → 9.0.4 in the merge. I could not run npm install in this environment, so the tests above ran against the pre-merge node_modules. Nothing in the diff touches pager view, and CI installs fresh — but that's the one gap.

Changed files are unchanged by the merge: src/libs/DistanceRequestUtils.ts and tests/unit/DistanceRequestUtilsTest.ts.

@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: d1d53e96ed

ℹ️ 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 Outdated
function getRateForPolicyChange({transaction, policy, currentRate}: {transaction: OnyxEntry<Transaction>; policy: OnyxEntry<Policy>; currentRate?: MileageRate}): MileageRate | undefined {
const expenseDate = getFormattedCreated(transaction);
const mileageRates = getMileageRates(policy);
const rateToMatch = currentRate ?? (isCustomUnitRateIDForP2P(transaction) ? getRateForP2P(getCurrency(transaction), transaction) : undefined);

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 Use the transaction unit when matching P2P rates

When an existing P2P expense's stored distanceUnit differs from the currently cached default P2P unit—for example, after the personal-policy currency/default changes—this derives rateToMatch through getRateForP2P(). That function takes the rate from transaction.comment.customUnit.defaultP2PRate but, whenever a default is loaded, takes its unit from the current global default rather than the transaction. Consequently, an equivalent destination rate using the expense's actual unit is rejected and the fallback rate can reprice the expense; construct the P2P match from the transaction's saved rate, currency, and distanceUnit instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in a21b300 — this is the same issue truph01 raised, so replying here too.

The unit is now read off the transaction via getDistanceUnit(transaction, p2pRate), the way getRate does it: src/libs/DistanceRequestUtils.ts:663-666.

One correction to the suggestion: the saved rate and currency were already coming from the transaction. getRateForP2P is called with getCurrency(transaction), so rate resolves to transaction.comment.customUnit.defaultP2PRate and currency to the transaction's currency in both branches. Only the unit was taken from the global default.

@truph01 truph01 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@MelvinBot Please check these comments

Comment thread src/libs/DistanceRequestUtils.ts Outdated
function getRateForPolicyChange({transaction, policy, currentRate}: {transaction: OnyxEntry<Transaction>; policy: OnyxEntry<Policy>; currentRate?: MileageRate}): MileageRate | undefined {
const expenseDate = getFormattedCreated(transaction);
const mileageRates = getMileageRates(policy);
const rateToMatch = currentRate ?? (isCustomUnitRateIDForP2P(transaction) ? getRateForP2P(getCurrency(transaction), transaction) : undefined);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2P unit comes from the global default, not the expense

getRateForP2P returns unit: p2pRate.unit whenever the stored default exists, so a P2P expense recorded in km matches against mi and gets repriced — could we wrap it in getDistanceUnit(transaction, ...) the way getRate does?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in a21b300.

getRateForP2P returns unit: defaultRate ? p2pRate.unit : fallbackUnit, so once the global default loads the unit comes from Onyx, not the expense. A P2P expense saved in km then matched as mi, every destination rate was disqualified, and the fallback repriced it.

I wrapped it the way getRate does — src/libs/DistanceRequestUtils.ts:663-666:

const p2pRate = isCustomUnitRateIDForP2P(transaction) ? getRateForP2P(getCurrency(transaction), transaction) : undefined;
const rateToMatch = currentRate ?? (p2pRate ? {...p2pRate, unit: getDistanceUnit(transaction, p2pRate)} : undefined);

rate and currency already come from the transaction (getRateForP2P is called with getCurrency(transaction), so both branches resolve to the expense's own values) — the unit was the only field taken from the global default.

Added two tests that both fail without this change: one where the destination policy is in km and the match must still be found, and one where it is in mi and the match must be rejected.

Comment thread src/libs/DistanceRequestUtils.ts Outdated
return bScore - aScore;
}

if (aScore === 2 && bScore === 2) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bScore === 2 is unreachable

The aScore !== bScore check above already returned, so aScore === 2 implies bScore === 2 — drop the second half?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right — dropped in a21b300. The aScore !== bScore early return above guarantees the scores are equal, so bScore === 2 was dead. Left a one-line comment saying why aScore === 2 is enough to know both rates are fully bounded: src/libs/DistanceRequestUtils.ts:586-587.

Comment thread src/libs/DistanceRequestUtils.ts Outdated

return Object.values(mileageRates)
.filter(
(rate) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Duplicate eligibility + sort logic

getRateMatchingCurrentRate re-implements the enabled/isRateEligibleForDate/sort chain from getBestEligibleRate — could we filter by value/currency/unit first and then hand the result to getBestEligibleRate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in a21b300 — filter by value/currency/unit, then hand the subset to getBestEligibleRate, exactly as you suggested. src/libs/DistanceRequestUtils.ts:639-643:

const equivalentRates = Object.fromEntries(
    Object.entries(mileageRates).filter(([, rate]) => rate.rate === currentRate.rate && rate.currency === currentRate.currency && rate.unit === currentRate.unit),
);

return getBestEligibleRate(equivalentRates, expenseDate);

The enabled/isRateEligibleForDate/sort chain now lives in one place, so the "narrowest and most recent date range" rule can't drift between the two. This also resolves your separate comment about the redundant enabled filter.

Comment thread src/libs/DistanceRequestUtils.ts Outdated

return Object.values(mileageRates)
.filter(
(rate) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

enabled filter is already applied

getMileageRates(policy) drops disabled rates by default, so rate.enabled !== false here never fires — is it worth keeping, given the "does not match a disabled rate" test passes without it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and it's gone as of a21b300. getMileageRates(policy) defaults includeDisabledRates to false and drops disabled rates at src/libs/DistanceRequestUtils.ts:56, so the check never fired here.

It dropped out naturally with the refactor from your other comment: the filter is now value/currency/unit only, and the remaining enabled !== false guard lives in getBestEligibleRate, which is exported and can be called with rate sets that do include disabled rates.

…etBestEligibleRate

Co-authored-by: truph01 <truph01@users.noreply.github.com>
@MelvinBot
MelvinBot requested a review from a team as a code owner September 15, 2026 04:37
@melvin-bot
melvin-bot Bot requested review from neil-marcellini and truph01 and removed request for a team September 15, 2026 04:37
@melvin-bot

melvin-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

@truph01 @neil-marcellini One of you needs to copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Addressed all four review comments in a21b300 and replied in each thread.

Comment Change
P2P unit comes from the global default rateToMatch now reads the unit off the transaction via getDistanceUnit(transaction, p2pRate), the way getRate does — DistanceRequestUtils.ts:663-666
bScore === 2 unreachable Dropped — DistanceRequestUtils.ts:586-587
Duplicate eligibility + sort logic getRateMatchingCurrentRate filters by value/currency/unit, then defers to getBestEligibleRateDistanceRequestUtils.ts:639-643
Redundant enabled filter Gone with the refactor above

The codex bot flagged the same P2P unit problem, so that thread is answered too.

The P2P unit one was a real bug, not just cleanup: a P2P expense saved in km was matched as mi once the global default loaded, which disqualified every destination rate and let the fallback reprice the expense. I added two tests for it — both fail without the fix (verified by reverting it).

AI tests
Test Result
npm test -- tests/unit/DistanceRequestUtilsTest.ts ✅ 63 passed (2 new)
npm test on the 5 related distance suites + DefaultP2PMileageRateTest ✅ 113 passed total
npm run typecheck ✅ passed
npm run lint on the changed files ✅ passed
npm run spell-changed ✅ 0 issues
npm run fmt ✅ no changes
npm test (full suite) ⚠️ not run — too slow for this environment; CI covers it

getRateMatchingCurrentRate keeps its own early return for a missing rate/currency, since an unresolved current rate should mean "no match" rather than "fall through to the best eligible rate".

The Tests and QA Steps placeholders and the unchecked author checklist from my last comment are still open.

@truph01

truph01 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: a21b3006df

ℹ️ 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 on lines +646 to +659
/**
* Selects the distance rate to use for an expense that is moving to a different workspace.
*
* Selection order:
* 1. A rate on the destination policy that is equivalent to the expense's current rate.
* 2. The destination policy's best rate for the expense date, falling back to its default rate.
* 3. Nothing, when the destination policy has no usable rate. Callers keep the `customUnitOutOfPolicy` violation in that case.
*
* `currentRate` resolves against the source policy, which this module cannot look up, so callers that have it pass it in. It is
* optional only because a P2P expense carries its rate value on the transaction itself.
*
* Let's ensure this logic is consistent with the logic in the backend (Auth), which is authoritative here. The app computes the
* same answer only to build optimistic data.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@MelvinBot Shorten this comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Shortened in 40500a2 — 14 lines down to 9. Collapsed the numbered selection order into one sentence and dropped the "only to build optimistic data" clause, which the getRate cross-reference already implies.

/**
 * Selects the distance rate for an expense moving to a different workspace: an equivalent rate on the destination policy, else its
 * best rate for the expense date, else its default rate, else nothing so the caller keeps the `customUnitOutOfPolicy` violation.
 *
 * `currentRate` resolves against the source policy, which this module cannot look up, so callers pass it in. It is optional because
 * a P2P expense carries its rate on the transaction.
 *
 * Let's ensure this logic is consistent with the logic in the backend (Auth), which is authoritative here.
 */

Comment-only change. typecheck, lint, oxfmt, spell-changed, and DistanceRequestUtilsTest (63 passed) all pass.

@truph01

truph01 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot Update PR title to "[No QA] Add getRateForPolicyChange to select a distance rate when the policy changes"

Co-authored-by: truph01 <truph01@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

⚠️ MelvinBot CI Fix Loop Detected

MelvinBot has made 5 consecutive commits trying to fix CI failures. Automatic CI fix dispatches are now paused for this PR.

Next steps: A human should review the recent commits and resolve the conflicting CI errors manually.

Comment thread src/libs/DistanceRequestUtils.ts
@MelvinBot MelvinBot changed the title Add getRateForPolicyChange to select a distance rate when the policy changes [No QA] Add getRateForPolicyChange to select a distance rate when the policy changes Sep 15, 2026
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Title updated to [No QA] Add getRateForPolicyChange to select a distance rate when the policy changes.

The PR body's QA Steps section still holds the // TODO: **The human co-author** must fill out… placeholder. With [No QA] in the title that section no longer needs steps, so you can replace the placeholder with [No QA] when you fill in the Tests section.

@garrettmknight garrettmknight left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good from a product perspective.

@neil-marcellini neil-marcellini left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Great work, thank you!

@neil-marcellini
neil-marcellini merged commit fe77019 into main Sep 17, 2026
43 of 56 checks passed
@neil-marcellini
neil-marcellini deleted the claude-rateForPolicyChange branch September 17, 2026 14:36
@github-actions

Copy link
Copy Markdown
Contributor

🚧 neil-marcellini has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/neil-marcellini in version: 9.4.82-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 cancelled 🔪
🍎 iOS 🍎 cancelled 🔪

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 No help site changes are required for this PR.

This PR adds DistanceRequestUtils.getRateForPolicyChange and extracts compareRatesByDateSpecificity. It changes no callers, so there is no user-facing behavior to document yet. Publishing a docs update now would describe auto-selection that users don't get.

The section that will need updating — but not yet

docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md:168-172 currently documents the pre-project behavior:

When a Distance expense is moved to another Workspace, it keeps its original unit and rate.

If the rate isn't valid in the new Workspace, the expense will show a "Rate not valid for this workspace" violation. Selecting a valid rate will update the expense.

Once auto-select actually ships, that becomes wrong — the expense gets a valid rate automatically and Concierge posts a message on the report. The right PRs to pair a docs update with are F3 and F4 in [Tracking] Auto-select distance rate on workspace change (frontend), both still open. F3 is the one that closes Expense-Rate violation disappears after moving expense to another report and makes the move-expense flow auto-select, so that is the earliest point the help site is measurably stale.

I did not create a draft PR, so there is no linked help site PR to mark Ready for review.

@truph01, please confirm you agree this PR needs no docs update. If you'd rather land the docs change now and let it sit ahead of F3/F4, say so and I'll open the draft PR against that section.


view run

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/neil-marcellini in version: 9.4.86-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@MelvinBot

Copy link
Copy Markdown
Contributor Author

No help site changes are required for this PR, so I did not create a draft docs PR.

This PR adds getRateForPolicyChange and extracts compareRatesByDateSpecificity, but nothing calls the new helper. I confirmed that on main after the merge — the only references are the definition, the export, and the unit tests:

src/libs/DistanceRequestUtils.ts:692   (definition)
src/libs/DistanceRequestUtils.ts:1019  (export)
tests/unit/DistanceRequestUtilsTest.ts (13 test references)

getBestEligibleRate behavior is unchanged by the extraction, so no user-facing behavior moved. There is nothing for a member or admin to do differently today.

The article that will need updating is already written, and it is still correct. docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md:168-172 documents today's behavior:

When a Distance expense is moved to another Workspace, it keeps its original unit and rate.
If the rate isn't valid in the new Workspace, the expense will show a "Rate not valid for this workspace" violation. Selecting a valid rate will update the expense.

That is accurate right now. It becomes wrong only once F3 and F4 wire the helper up, because the app will then auto-select a rate on the destination workspace — an equivalent rate first, otherwise the destination's best rate for the expense date, otherwise its default rate — and fall back to the violation only when the destination has no usable rate at all.

My recommendation: update that section in the PR that adds the first caller, not here. Publishing the new behavior while it is unwired would describe something the product does not do yet. How Expensify selects a distance rate based on the expense date in Set-distance-rates.md needs no change either way — the selection rules it describes are unchanged.

Tracking issue for the follow-up work: [Tracking] Auto-select distance rate on workspace change (frontend)

If you would rather I open the draft docs PR now so the copy is ready to merge alongside F3, say the word and I will write it against Distance-Expenses.md.


There is no linked help site PR to review, so the usual "confirm it reflects the current behavior, then mark it Ready for review" step does not apply here.


view run

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/neil-marcellini in version: 9.4.89-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@MelvinBot

MelvinBot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Help site changes are required — draft PR: Docs updates for E/A#101080 clarify distance rate behavior when moving expenses between workspaces. HelpDot applied, assigned to truph01.

I said twice before that no docs change was needed. That was wrong, and I want to be precise about why: I checked whether this PR changed behavior (it doesn't — getRateForPolicyChange still has no callers). I did not check whether the article was accurate to begin with. It isn't, and hasn't been for some time.

Two inaccuracies in docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md, neither caused by this PR:

1. "Moved to a different Workspace" was wrong for expense-level moves. The article claims a moved expense always "keeps its original unit and rate." True for report moves, false for expense moves — getChangeTransactionsReportOnyxData auto-selects the destination policy's default rate and recomputes unit, quantity, amount, merchant, and currency. A committed test asserts it: a 10-mile USD expense adopts the destination's GBP rate (tests/actions/TransactionTest.ts:453-540). Report moves genuinely don't touch the rate — there are no rate references anywhere in src/libs/actions/Report/.

2. The article contradicted itself on rate editing. One FAQ says "It is not possible to adjust the distance rate or unit at the expense level"; the next tells users to select a valid rate on the expense. The rate is editable (ReportUtils.ts:5461-5464, rendered interactive at MoneyRequestView.tsx:1041-1045); only the unit is admin-controlled.

Two things to know before you review:

  • I could not verify labels against the live UI. The browser session for this run was dead — zero accessibility nodes and no network activity, an infrastructure failure rather than an app bug. I verified Rate (en.ts:382) and Rate not valid for this workspace (en.ts:10414) against the language file instead. Please confirm the Rate row label in the product.
  • Auth is unverified. For report-level moves I can only confirm the client keeps the rate and raises no client-side violation. Whether Auth reprices server-side is unconfirmed — I have no access to that repo. getRateForPolicyChange's own comment says Auth is authoritative, which implies it does select a rate. If Auth reprices report moves, the second paragraph of that FAQ answer is still wrong and needs an internal engineer to settle it.

The PR documents behavior shipping today. When F3 and F4 of [Tracking] Auto-select distance rate on workspace change (frontend) wire up the new helper, the expense-level paragraph needs another pass, since selection becomes date-aware and prefers an equivalent rate over the default.

Checks run and one unrelated finding
Check Result
npm run spell-changed (cspell — CI merge gate) ✅ 1 file, 0 issues
./.github/scripts/createDocsRoutes.sh docs/routes.yml already up to date
npm run lint / typecheck / test / storybook ⚠️ not run — no TS, TSX, or JS touched

Unrelated finding worth a separate look: mergeReports calls getChangeTransactionsReportOnyxData but never destructures transactionIDToUpdatedCustomUnitRateID, so a rate swapped during a report merge is applied optimistically in Onyx and never sent to the server. I did not touch this — flagging only.

I could not push to your fork, so the branch lives upstream. Instructions to take ownership are in a comment on the docs PR.

@truph01, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR Ready for review


view run · no recording available

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/luacmartins in version: 9.4.89-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 failure ❌

Bundle Size Analysis (Sentry):

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

Labels

Melvin-Test-Android Melvin-Test-Web Triggers Melvin to run the testing steps of the PR on web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants