Skip to content

Fix: apply currency default tax rate when currency changes in manual expense create flow#94496

Open
MelvinBot wants to merge 26 commits into
mainfrom
claude-foreignTaxNotAppliedOnCurrencyChange
Open

Fix: apply currency default tax rate when currency changes in manual expense create flow#94496
MelvinBot wants to merge 26 commits into
mainfrom
claude-foreignTaxNotAppliedOnCurrencyChange

Conversation

@MelvinBot

@MelvinBot MelvinBot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

When creating a manual expense on a tax-enabled workspace, changing the currency before reaching the confirmation page did not re-apply the new currency's default tax rate — the previous (workspace-currency) default stayed on the confirmation page and on the created expense instead of switching to the foreign-currency default.

The create flow's navigateToNextPage only recomputed the tax code when moving a transaction from track-expense, so a normal currency change left the stale taxCode/taxAmount on the draft. The confirmation page's TaxController was supposed to correct this, but its shouldKeepCurrentTaxSelection heuristic relies on usePrevious(transaction.currency), which seeds with the current value on a fresh mount (the confirmation step is a separate route that remounts on Back→Next) — so it mistook the auto-applied default for a manual selection and suppressed the correction.

This fixes it at the source in AmountSubmission.ts: the track-expense-only tax recompute is generalized to also run whenever the currency actually changed, so the persisted draft is always correct regardless of the confirmation page's render timing. To avoid the regression of overwriting a tax rate the user picked manually, the recompute is gated on isCurrentTaxAutoDefault — it only re-applies the currency default when the current tax code is still the auto-applied default for the previous currency (using the same getDefaultTaxCode heuristic the confirmation page already uses). A manually-selected non-default rate is preserved across the currency change.

This implements the approach the issue requester specified in #93772 (comment).

Fixed Issues

$ #93772
PROPOSAL: #93772 (comment)

Tests

// TODO: The human co-author must fill out the tests you ran before marking this PR as "ready for review".
// Please describe what tests you performed that validate your change worked.

Suggested manual steps:

  1. On a tax-enabled collect workspace, set the workspace-currency default tax to Default (e.g. 5%) and the foreign-currency default to Foreign (e.g. 10%).
  2. Start a manual expense in the workspace's default currency and continue to the confirmation page — verify the Default rate is applied.
  3. Go Back, change the currency to a foreign one, press Next — verify the confirmation page and the created expense now show the Foreign rate and recalculated tax amount.
  4. Switch the currency back to the workspace currency — verify the Default rate is restored.
  5. Regression: manually select a non-default tax rate without changing the currency — verify it is preserved.
  • Verify that no errors appear in the JS console

Offline tests

Same as Tests.

QA Steps

// TODO: The human co-author must fill out the QA tests you ran before marking this PR as "ready for review".
// Please describe what QA needs to do to validate your changes and what areas they need to test for regressions.

Same as Tests.

  • 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 / Chrome
    • iOS / native
    • iOS / Safari
    • MacOS / Chrome / Safari
    • MacOS / Desktop
  • 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 any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • 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 shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I verified the translation was requested/reviewed in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • 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 approved by marketing by adding the Waiting for Copy label for a copy review on the original GH to get the correct copy.
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • 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)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • If we are not using the full Onyx data that we loaded, I've added the proper selector in order to ensure the component only re-renders when the data it is using changes
      • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • 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(themeColors.componentBG))
  • 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 a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • 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.
  • I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.

Screenshots/Videosundefined

MelvinBot and others added 3 commits June 24, 2026 19:33
Co-authored-by: Eskalifer1 <Eskalifer1@users.noreply.github.com>
Co-authored-by: Eskalifer1 <Eskalifer1@users.noreply.github.com>
Co-authored-by: Eskalifer1 <Eskalifer1@users.noreply.github.com>
@MelvinBot
MelvinBot requested a review from a team June 24, 2026 19:38
@MelvinBot

Copy link
Copy Markdown
Contributor Author

I created this PR from an upstream branch since I don't have push access to your fork.

To take ownership of this branch and be able to push updates, run:

git remote add upstream https://github.com/Expensify/App.git
git fetch upstream claude-foreignTaxNotAppliedOnCurrencyChange
git checkout -b claude-foreignTaxNotAppliedOnCurrencyChange upstream/claude-foreignTaxNotAppliedOnCurrencyChange
git push -u origin claude-foreignTaxNotAppliedOnCurrencyChange

Then you can close this PR and open a new one from your fork.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 I looked into the failing checks. No code fix is needed — the only failing check is PR Author Checklist / checklist, which is on the "expected to be incomplete until a human signs off" list.

It fails because the PR body still has the placeholder // TODO: The human co-author must fill out... in the Tests / QA Steps sections and the author checklist boxes are unchecked. That check turns green once you (the human co-author) fill in the testing steps and tick the boxes — it is not a code problem.

Every code-related check is green or passing:

  • ✅ ESLint, Prettier, React Compiler Compliance, knip, unused styles, spellcheck
  • ✅ All 8 test (jest) jobs, Bun tests, Storybook tests
  • 🟢 typecheck and the build / perf jobs were still running when I checked, with no failures (I verified the new getCurrency / getDefaultTaxCode / getTaxValue usages in AmountSubmission.ts are all imported, so typecheck should pass).

Next step: complete the Tests/QA sections and the PR Author Checklist, then the checklist check will clear. Re-ping me if any of the still-running checks ends up failing.

@lakchote

Copy link
Copy Markdown
Contributor

@Eskalifer1 can you push this one forward?

@Eskalifer1

Copy link
Copy Markdown
Contributor

Hi @lakchote, I don't know if I'll be able to finish this task today, but I think it'll be ready by Monday!

While I was working on the testing, I had a question:

🔍 Behavior Inconsistency: Edit flow always resets manually chosen tax rate on currency change

📋 Context

This PR fixes a bug where the tax rate on the confirmation page was not updated after changing currency in the create flow. As part of the fix, a guard was introduced to preserve the user's manually chosen tax rate:

const isCurrentTaxAutoDefault =
  !transaction?.taxCode || transaction?.taxCode === previousDefaultTaxCode;
if (selectedCurrency !== previousCurrency && isCurrentTaxAutoDefault) {
  // update to new currency's default
}

🐛 The Problem

The edit flow (isEditing === true branch) had the same issue but in the opposite direction — it always resets the tax rate to the currency default when the currency changes, even if the user manually selected a different rate:

// Before
const taxCode =
  (selectedCurrency !== transactionCurrency
    ? defaultTaxCode
    : transactionTaxCode) ?? defaultTaxCode;

Reproduction steps

  1. Create an expense in a workspace with multiple tax rates (e.g. Default 5%, Custom 10%, Tax empt 0%)
  2. Manually change the tax rate to Tax empt 0%
  3. Open the expense for edit → go to Amount → change the currency
  4. Save

Expected: Tax empt 0% is preserved (user explicitly chose it)
Actual: Tax rate silently resets to the currency default

✅ The Fix

Applied the same isCurrentTaxAutoDefault guard to the edit flow:

const previousDefaultTaxCode = getDefaultTaxCode(
  policy,
  currentTransaction,
  transactionCurrency,
);
const isCurrentTaxAutoDefault =
  !transactionTaxCode || transactionTaxCode === previousDefaultTaxCode;
const taxCode =
  (selectedCurrency !== transactionCurrency && isCurrentTaxAutoDefault
    ? defaultTaxCode
    : transactionTaxCode) ?? defaultTaxCode;

This makes create and edit flows consistent: auto-default tax rates follow the new currency, manually chosen rates are preserved. 🎯

❓ Should we fix this or we should make create flow a bit simpler and always reset tax no matter if user changed tax manually?

@Eskalifer1

Copy link
Copy Markdown
Contributor

Video of bug above!

edit-flow-bug.mov

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot I have found a bug:

🔍 RCA: Tax rate not updated when changing currency from FAB flow

📋 Summary

When a user starts creating an expense from the FAB (Floating Action Button), changes the currency on the amount step, and navigates forward, the tax rate on the confirmation page is not updated to match the new currency — it always shows the workspace default tax rate.

🔍 Root Cause

The bug is caused by policy being undefined on the amount step screen when the flow is started from the FAB.

IOURequestStepAmount.tsx:

const policyID = isTrackExpense ? policyForMovingExpensesID : report?.policyID;
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);

When starting from the workspace chat, report?.policyID is defined — policy resolves correctly.

When starting from the FAB, there is no report context. report is undefined or an empty object, so policyID = undefined and therefore policy = undefined for the entire lifetime of the amount step screen.

🔗 How policy = undefined causes the bug
In submitAmount → navigateToNextPage, the tax update block introduced to fix the workspace-chat flow runs as follows:

const previousDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousCurrency);
const isCurrentTaxAutoDefault = !transaction?.taxCode || transaction?.taxCode === previousDefaultTaxCode;

if (isMovingTransactionFromTrackExpense(action) || (selectedCurrency !== previousCurrency && isCurrentTaxAutoDefault)) {
    const taxCode = getDefaultTaxCode(policy, transaction, selectedCurrency);
    if (taxCode) {
        setMoneyRequestTaxRate(transactionID, taxCode);
        ...
    }
}

Since policy = undefined, getDefaultTaxCode(undefined, ...) always returns undefined. The if (taxCode) guard is never entered, so setMoneyRequestTaxRate is never called. The transaction draft is persisted to Onyx with taxCode still undefined.

🤔 Why does the initial tax display correctly?
initMoneyRequest does not write taxCode to the draft — the field is simply absent from the initial transaction object. On the confirmation page, usePolicyForTransaction resolves the correct policy independently through its own fallback logic (reportPolicy ?? policyDraft), and TaxController sets the default tax code via useEffect on mount.

This is why the initial value appears correct — it is computed on the confirmation page at render time, not persisted in the draft.

🚨 Why does the currency change not propagate?
After Back → currency change → Next, setMoneyRequestTaxRate is skipped as described above. The draft still has taxCode = undefined.

The confirmation page remounts fresh. useTaxAmount uses usePrevious to detect currency changes:

const previousDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousTransactionCurrency);
const shouldKeepCurrentTaxSelection = hasTaxRateWithMatchingValue(policy, transaction) && transaction?.taxCode !== previousDefaultTaxCode;

Because the confirmation page is a fresh mount, previousTransactionCurrency initializes from the current (already new) currency. This means previousDefaultTaxCode equals the foreign currency default. Meanwhile transaction.taxCode is undefined, which does not equal the foreign default — so shouldKeepCurrentTaxSelection = true, and TaxController early-returns without applying the new currency's tax rate.

The result: the workspace default tax rate remains on the confirmation page regardless of the selected currency.

How would you fix this?

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot take a look at this comment: #94496 (comment)

@Eskalifer1

Copy link
Copy Markdown
Contributor

Hi @lakchote, please take a look at this comment.

And i have one more problem. For some reason Melvin does not post any response here, He adds 👀 emoji, but never post a response

@lakchote

Copy link
Copy Markdown
Contributor

Hi @lakchote, please take a look at this comment.

And i have one more problem. For some reason Melvin does not post any response here, He adds 👀 emoji, but never post a response

I think the expected result is correct, I've verified on Classic:

  1. Create an expense in a workspace with multiple tax rates (e.g. Default 5%, Custom 10%, Tax empt 0%)
  2. Manually change the tax rate to Tax empt 0%
  3. Open the expense for edit → go to Amount → change the currency
  4. Save
  5. Expected: Tax empt 0% is preserved (user explicitly chose it)

Do you confirm @trjExpensify?

@lakchote

Copy link
Copy Markdown
Contributor

And i have one more problem. For some reason Melvin does not post any response here, He adds 👀 emoji, but never post a response

cc @Julesssss @justinpersaud maybe Melvin should post a message in case of failure?

@lakchote

Copy link
Copy Markdown
Contributor

@MelvinBot there's a bug identified in #94496 (comment), how would you fix this?

@trjExpensify

Copy link
Copy Markdown
Contributor

Do you confirm @trjExpensify?

If that's what we do on Classic, makes sense to do the same here. 👍

@Julesssss

Julesssss commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot there's a bug identified in #94496 (comment), how would you fix this?

Thanks for reporting, created an internal issue here to fix that asap. In the meantime, it might help if you are a bit more direct about Melvin responding to you:

MelvinBot create a bug and post it here... [details]

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot take a look at this comment

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Your RCA is right about the core gap: from the FAB, IOURequestStepAmount.tsx:67 resolves policyID from report?.policyID, which is empty in global-create, so policy is undefined and the amount-step recompute at AmountSubmission.ts:275 can never fire (getDefaultTaxCode(undefined, …) is always undefined). The amount step genuinely cannot fix the FAB flow — the workspace isn't chosen until after it — so the fix has to live on the confirmation page.

One correction to the mechanism (doesn't change your conclusion): on the second confirmation visit transaction.taxCode is not undefined. TaxController already persisted the workspace default to the draft on the first visit. So on the way back through with the new currency:

  • hasTaxRateWithMatchingValue(policy, transaction) is true (the stale workspace-default code still maps to a valid rate/value), and
  • taxCode (workspace default) !== previousDefaultTaxCode (foreign default),

shouldKeepCurrentTaxSelection is true and useTaxAmount.ts:59 suppresses the correction. (The usePrevious seeding is actually a red herring here — because the draft carries a real matching rate, the outcome is identical even with the "correct" previous currency.)

How I'd fix it: teach shouldKeepCurrentTaxSelection to recognize a stale auto-default — a taxCode that equals one of the policy's two auto-defaults (defaultExternalID / foreignTaxDefault) but isn't the default for the current currency. In that case it's leftover auto-applied state, not a user choice, so we let TaxController re-apply the current currency's default.

In useTaxAmount.ts:

const previousDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousTransactionCurrency);

// A tax code the user never manually chose: it is one of the policy's auto-defaults,
// but not the default for the *current* currency — i.e. left over from before a currency
// change (e.g. the FAB flow, where the amount step has no policy to recompute it).
const isStaleAutoDefault =
    (transaction?.taxCode === policy?.taxRates?.defaultExternalID || transaction?.taxCode === policy?.taxRates?.foreignTaxDefault) &&
    transaction?.taxCode !== defaultTaxCode;

const shouldKeepCurrentTaxSelection = hasTaxRateWithMatchingValue(policy, transaction) && transaction?.taxCode !== previousDefaultTaxCode && !isStaleAutoDefault;

Why this holds up:

  • FAB bug case — stale workspace default on a foreign currency → isStaleAutoDefault is true → correction re-applies the foreign default. ✅
  • Manual non-default pick (with or without a currency change) → the code isn't one of the two policy defaults → isStaleAutoDefault is false → selection preserved. ✅ (This is the regression your amount-step gate was protecting.)
  • Already-correct default → no-op.

Same design keeps the amount-step gate for the workspace-chat flow (it corrects the draft before confirmation when the policy is known) and adds the confirmation-page safety net for the FAB flow where the policy isn't. It shares the one unavoidable limitation the amount-step gate already has: manually re-selecting a code that happens to equal a currency default is indistinguishable from the auto-applied state and will be treated as auto.

Want me to implement this on the branch and add a useTaxAmount unit test for the FAB currency-change case?

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Find every possible regression or risk with solution you have mentioned here

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Good call to pressure-test it — the heuristic has a real, new regression, plus a few smaller risks. Here's the full list.

🚑 Headline regression: user can no longer manually pick a rate that equals the other currency's default

Because isStaleAutoDefault is recomputed on every render (not just across a currency change), it fights a live manual selection. Manual tax picks write taxCode straight to the draft (DynamicIOURequestStepTaxRatePage.tsx:141), and there is no "tax was manually set" flag on the transaction to disambiguate (isManuallySet at Transaction.ts:475 is vendor-only).

Repro: foreign-currency expense on the confirmation page → open the tax picker → select the workspace Default rate. My clause flags it (taxCode === defaultExternalID && !== defaultTaxCode which is foreignTaxDefault) → !isStaleAutoDefault is false → TaxController immediately re-applies the foreign default. The selection snaps back and can never stick. Symmetric for picking the Foreign default while on the workspace currency.

This is worse than the amount-step gate, which only fires on a currency change. Today (main) this selection is preserved — so my change introduces the revert. Verified against useTaxAmount.ts:56-59.

Other risks

  • Inherent ambiguity (also hits FAB): a legitimately, deliberately chosen default-that-doesn't-match-the-currency is indistinguishable from a stale auto-default, so it's silently reset. The amount-step gate has the same blind spot, but mine extends it to the confirmation page and to the no-currency-change case above.
  • Undefined-code false match: if policy.taxRates.foreignTaxDefault (or defaultExternalID) is unconfigured/undefined and taxCode is also undefined, the === comparison matches spuriously. It's currently masked because hasTaxRateWithMatchingValue is already false for an unset code, but it's fragile — guard with a truthy check on transaction?.taxCode.
  • Broad blast radius: TaxController / useTaxAmount are shared across manual, split, and invoice create flows. All need a currency+tax regression pass, not just manual expense.
  • Distance requests — likely inert but verify: getDefaultTaxCode keys distance off the rate's taxRateExternalID, not the two policy defaults (TransactionUtils.ts:2131-2146), so my clause should never fire for distance (tax there is currency-independent). Worth confirming it stays inert.
  • Minor UX: in the revert cases there's a brief flicker (chosen rate → default) plus an extra Onyx write per revert.
  • Track-expense move: unaffected — TaxController early-returns for isMovingTransactionFromTrackExpense before reaching shouldKeepCurrentTaxSelection (TaxController.tsx:34), and that flow has its own recompute in AmountField.tsx:243-251.

Recommended mitigation

The root problem behind almost all of these is the missing auto-vs-manual signal. The clean fix is to persist a "tax manually set" flag on the draft: set it in the tax-rate picker action (DynamicIOURequestStepTaxRatePage.tsx:141), and in useTaxAmount make shouldKeepCurrentTaxSelection true whenever that flag is set. Auto-applied defaults (no flag) are then always free to follow the currency, and manual picks are always respected — which kills both the headline regression and the FAB bug's ambiguity, and lets the fragile isStaleAutoDefault heuristic go away entirely.

If a flag is too invasive for this PR, the cheaper containment is to only run the stale-default correction when a currency change is actually detected (e.g. compare against a persisted "currency when tax was last applied" marker) rather than on every render — that removes the live-revert-without-currency-change case, though it leaves the deliberate-default ambiguity.

Want me to implement the manual-set-flag approach?

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Why can't we use something like that?

--- a/src/pages/iou/request/step/IOURequestStepAmount.tsx
+++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx
@@ -68,7 +68,7 @@ function IOURequestStepAmount({
     const selfDMReport = useSelfDMReport();
     const isReportArchived = useReportIsArchived(report?.reportID);
-    const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+    const [reportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
     const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`);
     const iouOrExpenseReport = useReportOrReportDraft(report?.chatReportID);
     const actualChatReportID = iouOrExpenseReport && isMoneyRequestReport(iouOrExpenseReport) ? iouOrExpenseReport.chatReportID : undefined;
@@ -79,6 +79,14 @@ function IOURequestStepAmount({
     const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`);
     const defaultExpensePolicy = useDefaultExpensePolicy();
     const personalPolicy = usePersonalPolicy();
+
+    // In the FAB flow, report?.policyID is undefined so reportPolicy is undefined.
+    // Fall back to the policy derived from already-chosen participants (workspace expense chat)
+    // so tax-rate logic in submitAmount has a policy when the user returns from the confirmation page
+    // after having selected a workspace participant.
+    const participantPolicyID = transaction?.participants?.find((p) => p.isPolicyExpenseChat)?.policyID;
+    const [participantPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${participantPolicyID}`);
+    const policy = reportPolicy ?? participantPolicy;
     const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END);
     const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector});
     const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);

What possible regressions have this solution?

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Yes, that works — and it's arguably a cleaner fix than my confirmation-page suggestion, because it keeps the correction in one place and lets the FAB flow reuse the amount-step gate you already built. The Participant type has both isPolicyExpenseChat and policyID, so it's type-valid, and on the return trip the draft already carries the workspace participant, so participantPolicy resolves. With a defined policy, navigateToNextPage then recomputes exactly as it does for the workspace-chat flow — including the isCurrentTaxAutoDefault guard that preserves a manual selection. So functionally it fixes the reported repro.

The main thing I'd change, plus the regressions to watch:

1. policy now diverges from policyID / policyCategories (most important). Your diff only redirects policy. But policyID and policyCategories still key off report?.policyID, which stays undefined in the FAB flow. So submitAmount receives policy = participantPolicy but policyCategories = undefined, and the two now describe different workspaces. Cleaner to derive a single fallback and key all three off it:

const participantPolicyID = transaction?.participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID;
const policyID = (isTrackExpense ? policyForMovingExpensesID : report?.policyID) ?? participantPolicyID;
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`);

2. It broadens two other amount-step behaviors in the FAB return trip. Both currently get undefined for policy in that flow and would now get the participant policy — worth a quick check that neither changes behavior:

3. Split / multiple participants. .find(… isPolicyExpenseChat) takes the first workspace participant. Fine for single-workspace expenses; just be aware it picks arbitrarily if a flow ever mixes participants across policies.

4. Coverage. This only corrects currency changes that route back through this amount step — which is the reported repro (Back → change currency → Next always does). If there's any path that changes currency without re-entering IOURequestStepAmount, it wouldn't be caught, whereas the confirmation-page fix would. I believe currency edits always route through here, so this is likely a non-issue — worth confirming.

Net: I'd go with your approach using the single-policyID-fallback form in #1, and sanity-check #2. Want me to apply that on the branch and add a submitAmount unit test for the FAB currency-change case?

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot What is about second point? How can i test it, maybe we have now some bug related to this on main branch? Can you descripe a bit more, so i could check it and maybe our changes will fix it?

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Good questions — and yes, the "second point" is a real bug you can reproduce on main today, in both the workspace-chat flow and the FAB flow. This PR fixes the first; the FAB one needs the confirmation-page change.

What the second point actually says

By "second point" I mean my correction: on the second trip to the confirmation page, the draft's taxCode is not empty. On the first trip, TaxController already wrote the workspace-currency default into the draft — setMoneyRequestTaxRateValues merges taxCode/taxValue/taxAmount into TRANSACTION_DRAFT (default isDraft = true), and going Back to the amount step doesn't clear it.

So when you come back with the new currency, the draft still carries the old currency's default. That leftover value is what makes useTaxAmount.ts:58-59 misfire:

  • hasTaxRateWithMatchingValue(policy, transaction)true (the old default is a valid rate with a matching value), and
  • taxCode (old default) !== previousDefaultTaxCode (new default) → true,

shouldKeepCurrentTaxSelection = true, so TaxController early-returns and never applies the new currency's default.

Is there a bug on main? Yes

On main there is no amount-step recompute at all (this PR adds it), so both manual-expense entry points show the stale tax rate after a currency change. This PR's AmountSubmission.ts:275 recompute corrects the workspace-chat flow (policy is known there). It does not correct the FAB flow, because policy is undefined at the amount step (IOURequestStepAmount.tsx:67) — that's the gap my proposed confirmation-page fix closes.

How to reproduce / test it

Prereq: tax-enabled collect workspace; workspace-currency default = Default (e.g. 5%), foreign-currency default = Foreign (e.g. 10%).

FAB flow (the still-broken one):

  1. Global FAB → Create expense → Manual.
  2. Enter an amount in the workspace currency (e.g. USD) → Next → pick the workspace as participant → confirmation. Tax shows Default 5%. (This first visit persists taxCode=Default into the draft — the second point.)
  3. Back to the amount step, change currency to a foreign one (e.g. EUR), press Next.
  4. Bug: confirmation still shows Default 5%. Expected: Foreign 10%.

This reproduces on main and on this branch. As a contrast, run the same steps starting from inside the workspace chat (not the FAB) — on this branch that already shows Foreign 10%, which isolates the failure to the FAB-only path where the policy is missing at the amount step.

Unit level: there's already tests/unit/hooks/useTaxAmount.test.ts. You can add a "stale auto-default" case: transaction.taxCode = the workspace default, current currency foreign so getDefaultTaxCode returns the foreign default, and hasTaxRateWithMatchingValue mocked to true → assert shouldKeepCurrentTaxSelection is false (my fix) rather than true (current). That test fails today and passes with the isStaleAutoDefault guard from my previous comment.

(I verified the mechanism against the code — the setMoneyRequestTaxRateValues draft write, the useTaxAmount heuristic, and the missing policy at the amount step — but did not run a live device repro; the manual steps above are the fastest way to confirm on your side.)

Want me to push the confirmation-page fix + that unit test to the branch so you can just pull and verify?

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Yep, that's actually the point we want to fix. So i proposed to fix it via workspace.

I was asking about this second point:

  1. It broadens two other amount-step behaviors in the FAB return trip. Both currently get undefined for policy in that flow and would now get the participant policy — worth a quick check that neither changes behavior:

shouldEnableNegative(report, policy, …) (whether negative amounts are allowed).
policyID={policy?.id} passed to MoneyRequestAmountForm.

Is there any bug related to this one or not? It goes without saying that we're fixing the bug with taxes. I was asking if there might be any other issues that this could affect that aren't related to taxes?

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Short answer: no — neither introduces a non-tax bug. Both are gated behind iouType branches that the participant-policy fallback never lands in, so allowNegative and the passed policyID stay exactly what they are today.

The key reason both are safe: the ?? participantPolicyID fallback only flips policy from undefined → defined in the global-create (iouType === CREATE) flow — that's the only flow where report?.policyID is undefined. In a submit-from-workspace-chat flow report?.policyID is already set, so the fallback is inert. So we only need to check that a defined policy is harmless when iouType === CREATE.

1. shouldEnableNegative — no change. policy only feeds isGroupPolicyType, which is one OR-term inside supportsNegativeAmounts (ReportUtils.ts:1957-1964). In the CREATE flow, isCreatingNewIOU is true — the call at IOURequestStepAmount.tsx:114 passes 4 args, so isNewManualExpenseFlow defaults to false, making isCreatingNewIOU = (iouType === CREATE). That short-circuits supportsNegativeAmounts to true regardless of policy, so allowNegative is true whether policy is undefined or the participant policy. No behavior change.

2. policyID={policy?.id} — no change. Inside MoneyRequestAmountForm, policyID is consumed by exactly one thing: the SettlementButton, which renders only when iouType === CONST.IOU.TYPE.PAY && skipConfirmation (MoneyRequestAmountForm.tsx:212-234). The FAB manual-create flow is iouType === CREATE, so that branch never renders — the plain "Next" Button does, and it doesn't touch policyID. So the now-defined policy?.id is simply unused here.

So the only observable effect of the fallback in the FAB return trip is the tax recompute you intend. The one thing worth a sanity check isn't these two lines but point #1 from before — keying policyCategories off the same fallback policyID so policy and policyCategories don't describe different workspaces in submitAmount.

Want me to apply the single-policyID-fallback version and add a submitAmount unit test for the FAB currency-change case?

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Yes, let's implement this change, also include every possible improvement

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Take a look at all the other suggested changes that we haven't implemented yet, and implement them. They all relate to comments.

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

Copy link
Copy Markdown
Contributor Author

Done — pushed the remaining comment-only suggestions in 7117f3d.

Removed (all were lakchote's "remove this comment" suggestions still open against the current diff):

Already applied earlier, so nothing to do:

  • Create-path comment blocks → edd77b0 and 2aa7c6c.
  • Helper JSDoc trim → cd71be8.
  • Empty-tax preservation in the helper → 7ab258e.

No logic changed — these are comment-only deletions, so no typecheck/test impact. oxfmt left the files clean.

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Let's merge main

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Merge main

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Merge main into this branch

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot merge main and resolve conflicts

…ppliedOnCurrencyChange

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

# Conflicts:
#	src/libs/IOUAmountSubmission.ts
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Merged main and resolved the conflict in c166047.

One conflict, in the file this PR edits — which main moved and refactored (src/pages/iou/request/step/AmountSubmission.tssrc/libs/IOUAmountSubmission.ts, with imports switched from @libs/ aliases to relative paths). The conflict was confined to the import block; the function bodies merged cleanly.

Resolution: kept main's new relative-path import block and dropped the duplicated alias-style block, then re-added the two imports this PR needs that main's block didn't have (its version predates this PR's tax logic):

  • isTaxTrackingEnabled from ./PolicyUtils
  • isPolicyExpenseChat in the ./ReportUtils import

Both are used by the currency-change tax gate at IOUAmountSubmission.ts:230-234. All of this PR's logic (the isTaxCodeAutoDefaultForCurrency helper, the create/edit heal paths, the trimmed JSDoc) survived intact.

Validation:

  • npm run typecheck-tsgo — clean.
  • npm test tests/unit/IOUAmountSubmissionTest.ts — 24/24 pass (the suite was renamed to IOUAmountSubmissionTest.ts on main).

⚠️ I couldn't run ESLint here (it OOMs in this sandbox), so please rely on CI for the lint gate — the import additions match the exact style of the adjacent relative-path imports, so no ordering issues are expected.

@Eskalifer1

Copy link
Copy Markdown
Contributor

@lakchote Take a look at this again pls!

@lakchote

lakchote commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

@lakchote Take a look at this again pls!

@Eskalifer1 I'm looking to fix it in the BE so the FE and BE are on par, then I'll review it again

@lakchote

Copy link
Copy Markdown
Contributor

BE PR is still in review.

@lakchote

Copy link
Copy Markdown
Contributor

BE PR has been merged (waiting for deploy now). @Eskalifer1 can you fix conflicts please?

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Let's merge main and resolve conflicts

…ppliedOnCurrencyChange

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

# Conflicts:
#	src/libs/IOUAmountSubmission.ts
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Merged main and resolved the conflict. The branch is now mergeable (dd52b39a08).

This wasn't a trivial conflictmain landed a large refactor of the file this PR touches, so please give the resolution a careful review before merging.

What happened

main moved src/pages/iou/request/step/AmountSubmission.tssrc/libs/IOUAmountSubmission.ts and split the monolithic submitAmount into helper functions (buildSubmitAmountContext, submitCreateAmount, submitEditAmount, submitSkipConfirmation*, …). It also independently changed the tax-code derivation in both the create and edit paths. Those changes overlapped directly with this PR's tax logic.

How I resolved it

Rather than hand-merge the clashing hunks, I took main's fully-refactored version as the base and re-applied this PR's three documented changes on top, so main's new architecture is preserved verbatim:

  1. Added the isTaxCodeAutoDefaultForCurrency helper.
  2. Create path (submitCreateAmount): generalized the track-expense-only tax recompute to also run on a genuine currency change, gated on isTaxEnabled && selectedCurrency !== previousCurrency && isCurrentTaxAutoDefault, using getDefaultTaxCode(...) for the new tax code.
  3. Edit path (submitEditAmount): applied the same isCurrentTaxAutoDefault guard plus the isTransactionTaxCodeValid healing fallback.

I also added the two imports main didn't have (isTaxTrackingEnabled from PolicyUtils, isPolicyExpenseChat from ReportUtils). The tests in tests/unit/IOUAmountSubmissionTest.ts (the renamed AmountSubmissionTest.ts) auto-merged and still cover both the create and edit flows.

One intentional detail: main's create path had inlined the tax-code lookup as selectedCurrency !== policy?.outputCurrency ? foreignTaxDefault : defaultExternalID. My resolution restores this PR's getDefaultTaxCode(policy, transaction, selectedCurrency) — equivalent for non-distance transactions and more correct for distance requests, matching the approach this PR was built on.

The full resolution is contained in the merge commit dd52b39a08.

⚠️ Verification

I could not run the unit tests, typecheck, or lint in this environment (they're permission-gated here), and browser verification was unavailable. I validated the resolution statically: no conflict markers remain, the imports resolve to real exports with matching signatures, and the diff is exactly the three intended changes. Before marking ready for review, the human co-author should run npm run typecheck, npm test -- tests/unit/IOUAmountSubmissionTest.ts, and re-verify the manual test steps — especially since main independently changed this tax logic.

@Eskalifer1

Copy link
Copy Markdown
Contributor

@lakchote Let me know when BE will be ready, so i can test all again. Thank you!

@lakchote

lakchote commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@lakchote Let me know when BE will be ready, so i can test all again. Thank you!

BE is now ready @Eskalifer1, feel free to test again, thanks!

@Eskalifer1

Copy link
Copy Markdown
Contributor

@MelvinBot Let's merge main and resolve conflicts

…ppliedOnCurrencyChange

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

# Conflicts:
#	src/libs/IOUAmountSubmission.ts
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Merged main and resolved conflicts — pushed as 1ee4be6.

There was a single conflict in src/libs/IOUAmountSubmission.ts, in the import block — both sides touched the ./ReportUtils import line:

  • This PR added isTaxTrackingEnabled from ./PolicyUtils and kept isPolicyExpenseChat in the ./ReportUtils import (both used at line 523).
  • main added getLoginByAccountID from ./PersonalDetailsUtils (used at line 606).

I kept both sets of imports since all three symbols are used in the merged file, preserving alphabetical import order. typecheck-tsgo and lint-changed both pass. The PR is now mergeable (no remaining conflicts).

@Eskalifer1

Copy link
Copy Markdown
Contributor

@codex review

@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: 1ee4be6dcb

ℹ️ 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/IOUAmountSubmission.ts Outdated
const previousCurrency = getCurrency(transaction);
const isCurrentTaxAutoDefault = isTaxCodeAutoDefaultForCurrency(policy, transaction, previousCurrency, transaction?.taxCode);
const isPolicyExpenseChatParticipant = transaction?.participants?.some((participant) => participant.isPolicyExpenseChat) ?? false;
const isTaxEnabled = isTaxTrackingEnabled(isPolicyExpenseChat(report) || isPolicyExpenseChatParticipant, policy, false);

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 Include track-expense tax contexts in the currency guard

In the Track expense amount step from a self-DM/default workspace, report is not a policy expense chat and the transaction does not have a policy-expense-chat participant, even though the screen has resolved a tax-enabled policy via usePolicyForMovingExpenses and the confirmation list still shows tax for isTrackExpense. With this guard, Back → change currency → Next leaves the old default tax code in the draft for that Track flow, so the stale workspace-currency tax is preserved instead of switching to the foreign default.

Useful? React with 👍 / 👎.

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 What do you think about this one?

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.

I think Codex is right — this is a real gap (medium-high confidence). I haven't changed anything.

The mismatch: the guard at IOUAmountSubmission.ts:519 derives isTaxEnabled from isPolicyExpenseChat(report) || isPolicyExpenseChatParticipant only. For a Track expense from a self-DM, report isn't a policy expense chat and there's no policy-expense-chat participant, so isTaxEnabled is false — even though policy is a real, tax-enabled policy resolved via usePolicyForMovingExpenses (IOURequestStepAmount.tsx:82-89).

Meanwhile the confirmation list decides whether to show tax with isTaxTrackingEnabled(isPolicyExpenseChat || isTrackExpense, …) at MoneyRequestConfirmationList.tsx:298 — it does include isTrackExpense. So tax is shown for the track flow, but the amount-step currency recompute is skipped → Back → change currency → Next keeps the stale (previous-currency) default. The first disjunct isMovingTransactionFromTrackExpense(action) doesn't save it either — that only fires when moving an existing tracked expense, not creating one.

The parameter is literally named isPolicyExpenseChatOrUnreportedExpense (PolicyUtils.ts:1412), and every other caller that gates track-expense tax includes a track/unreported term (MoneyRequestConfirmationList.tsx:298, IOURequestStepDistanceRate.tsx:114, useExpenseSubmission.ts:309) — this new guard is the only one that omits it.

Suggested fix — mirror what the confirmation list uses so the guard matches when tax is actually displayed. iouType is already in args:

const isTaxEnabled = isTaxTrackingEnabled(
    isPolicyExpenseChat(report) || isPolicyExpenseChatParticipant || iouType === CONST.IOU.TYPE.TRACK || isExpenseUnreported(transaction),
    policy,
    false,
);

(isExpenseUnreported would need importing from TransactionUtils.)

Worth a manual check to confirm: self-DM Track expense on a tax-enabled workspace → verify tax shows on confirmation, then Back → change currency → Next and verify the foreign default is applied (and that a manually-picked rate is still preserved).

Want me to implement this?

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 Let's implement this one

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.

🤖 Implemented in 927d463.

I extended the isTaxEnabled guard in submitCreateAmount to include the track-expense tax contexts, mirroring what MoneyRequestConfirmationList's shouldShowTax uses — so the currency recompute now runs for a Track expense from a self-DM/default workspace, not just for policy-expense-chat contexts:

const isTaxEnabled = isTaxTrackingEnabled(
    isPolicyExpenseChat(report) || isPolicyExpenseChatParticipant || iouType === CONST.IOU.TYPE.TRACK || isExpenseUnreported(transaction),
    policy,
    false,
);

isExpenseUnreported is now imported from ./TransactionUtils. typecheck-tsgo and lint both pass.

Please still do the manual check I flagged: self-DM Track expense on a tax-enabled workspace → confirm tax shows on the confirmation page, then Back → change currency → Next and verify the foreign default is applied (and a manually-picked rate is still preserved).

@Eskalifer1

Copy link
Copy Markdown
Contributor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 927d463239

ℹ️ 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".

@Eskalifer1

Copy link
Copy Markdown
Contributor

Seems like Be changes working well!

I think we can merge it now

cc: @lakchote

lakchote
lakchote previously approved these changes Jul 21, 2026

@lakchote lakchote 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.

LGTM

const previousCurrency = getCurrency(transaction);
const isCurrentTaxAutoDefault = isTaxCodeAutoDefaultForCurrency(policy, transaction, previousCurrency, transaction?.taxCode);
const isPolicyExpenseChatParticipant = transaction?.participants?.some((participant) => participant.isPolicyExpenseChat) ?? false;
// Mirror the tax contexts the confirmation list uses (see MoneyRequestConfirmationList's shouldShowTax): a Track

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.

NAB: put an empty line before the comment, can you handle that as a follow-up @Eskalifer1 please?

Suggested change
// Mirror the tax contexts the confirmation list uses (see MoneyRequestConfirmationList's shouldShowTax): a Track
// Mirror the tax contexts the confirmation list uses (see MoneyRequestConfirmationList's shouldShowTax): a Track

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.

Sure!

@MelvinBot Let's apply this change

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 — pushed in 5696f8b. Added the empty line before the // Mirror the tax contexts… comment as suggested.

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