Skip to content

Fix negative transaction total after moving IOU report to a workspace#91079

Open
trasnake87 wants to merge 3 commits into
Expensify:mainfrom
trasnake87:fix-iou-negative-amount-90219
Open

Fix negative transaction total after moving IOU report to a workspace#91079
trasnake87 wants to merge 3 commits into
Expensify:mainfrom
trasnake87:fix-iou-negative-amount-90219

Conversation

@trasnake87
Copy link
Copy Markdown

Explanation of Change

When an IOU report is moved to a workspace, convertIOUReportToExpenseReport builds optimistic transaction data that negates amount and modifiedAmount to match the expense-report sign convention, but it never negates convertedAmount / convertedTaxAmount.

The per-row TOTAL column renders through getConvertedAmount, which returns the opposite sign of the stored convertedAmount for expense reports (expense-report transactions are stored with negative converted amounts — see the existing fixture in tests/unit/ReportUtilsTest.ts). Because the IOU convertedAmount was left positive, getConvertedAmount returns a negative value once the report becomes an expense report, so each transaction's TOTAL cell flips negative. The report-level header total stays correct because it is negated separately, which matches the reported behavior (only the table cells go wrong).

This change negates convertedAmount and convertedTaxAmount in the optimistic transaction data so the stored shape matches the backend representation of expense-report transactions. A conditional spread is used so absent values (e.g. an offline expense with no backend-computed conversion yet) are not overwritten with -undefined/-0. The same buggy loop is duplicated in createWorkspaceFromIOUPayment (paying an IOU by creating a new workspace), so the fix is applied in both places to fully close the bug class. transactionFailureData is built from the unmodified transaction, so rollback on failure already restores the original positive values.

Fixed Issues

$ #90219
PROPOSAL: #90219 (comment)

Tests

  1. Have a workspace.
  2. Open a DM with any user.
  3. Submit two expenses to that user.
  4. Open the report.
  5. Click More > Change workspace and select any workspace.
  6. Verify the per-transaction Total column keeps its original positive value (it does not flip to a negative amount), and the report header total remains correct.
  7. Repeat using the flow that pays an IOU by creating a new workspace (pay the IOU and choose to create a new workspace) and verify the transaction totals stay positive there as well.

Automated coverage: a unit test was added in tests/actions/ReportTest.ts (convertIOUReportToExpenseReport › should negate convertedAmount and convertedTaxAmount...). It converts an IOU report whose transaction has positive convertedAmount/convertedTaxAmount and asserts the optimistic data stores them negated (and that the failure data restores the originals). The test fails on main and passes with this change.

  • Verify that no errors appear in the JS console

Offline tests

  1. Go offline.
  2. Perform the same More > Change workspace flow on a report with two expenses.
  3. Verify the transaction Total column stays positive while offline (the optimistic data is correct on its own).
  4. Go back online and verify the values remain correct after the server response reconciles (no flip on reconnect).

QA Steps

Same as the Tests section above.

  • 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 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 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 used JaimeGPT to get English > Spanish translation. I then posted it 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 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)
    • 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.ts 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 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 the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If 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.

@trasnake87 trasnake87 requested review from a team as code owners May 19, 2026 13:08
@melvin-bot melvin-bot Bot requested review from FitseTLT and joekaufmanexpensify and removed request for a team and FitseTLT May 19, 2026 13:08
@melvin-bot
Copy link
Copy Markdown

melvin-bot Bot commented May 19, 2026

@FitseTLT 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 a team and removed request for a team May 19, 2026 13:08
@melvin-bot melvin-bot Bot requested review from FitseTLT and inimaga and removed request for a team May 19, 2026 13:08
@melvin-bot
Copy link
Copy Markdown

melvin-bot Bot commented May 19, 2026

@FitseTLT @inimaga 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]

Comment thread src/libs/actions/Report/index.ts Outdated
...transaction,
amount: -transaction.amount,
modifiedAmount: hasValidModifiedAmount(transaction) ? -Number(transaction.modifiedAmount) : '',
...(transaction.convertedAmount != null && {convertedAmount: -transaction.convertedAmount}),
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.

❌ CONSISTENCY-3 (docs)

The transaction sign-conversion logic (negating amount, modifiedAmount, convertedAmount, convertedTaxAmount) is now identically duplicated between convertIOUReportToExpenseReport in src/libs/actions/Report/index.ts and createWorkspaceFromIOUPayment in src/libs/actions/Policy/Policy.ts. Adding new fields to negate (like this PR does) requires updating both locations in lockstep, which is error-prone.

Extract the negation logic into a shared helper function, for example:

function negateTransactionAmounts(transaction: Transaction): Transaction {
    return {
        ...transaction,
        amount: -transaction.amount,
        modifiedAmount: hasValidModifiedAmount(transaction) ? -Number(transaction.modifiedAmount) : '',
        ...(transaction.convertedAmount != null && {convertedAmount: -transaction.convertedAmount}),
        ...(transaction.convertedTaxAmount != null && {convertedTaxAmount: -transaction.convertedTaxAmount}),
    };
}

Then use it in both call sites:

transactionsOptimisticData[...] = negateTransactionAmounts(transaction);

Reviewed at: 36d06bc | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Comment thread src/libs/actions/Policy/Policy.ts Outdated
...transaction,
amount: -transaction.amount,
modifiedAmount: hasValidModifiedAmount(transaction) ? -Number(transaction.modifiedAmount) : '',
...(transaction.convertedAmount != null && {convertedAmount: -transaction.convertedAmount}),
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.

❌ CONSISTENCY-3 (docs)

This is the same duplicated transaction sign-conversion block that also exists in src/libs/actions/Report/index.ts (convertIOUReportToExpenseReport). See the comment on that file for the suggested shared helper extraction.


Reviewed at: 36d06bc | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Copy link
Copy Markdown

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

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: 36d06bcf4b

ℹ️ 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/actions/Report/index.ts Outdated
Comment on lines +6904 to +6905
...(transaction.convertedAmount != null && {convertedAmount: -transaction.convertedAmount}),
...(transaction.convertedTaxAmount != null && {convertedTaxAmount: -transaction.convertedTaxAmount}),
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 Normalize converted amounts before flipping sign

When an IOU transaction already has a negative convertedAmount (the transaction helpers explicitly handle IOU amounts that are stored negative), this unconditional negation stores a positive converted amount on the new expense report. Expense report rendering flips converted amounts for display, so that input will make the converted total/tax show negative again; use the expense-report sign convention directly, e.g. negate the absolute value, and apply the same fix to the duplicate conversion in createWorkspaceFromIOUPayment.

Useful? React with 👍 / 👎.

@trasnake87
Copy link
Copy Markdown
Author

Addressed the review feedback in 7876b36:

  • Extracted the IOU→expense-report sign conversion into a single getExpenseReportSignedTransaction helper in TransactionUtils, and used it in both convertIOUReportToExpenseReport and createWorkspaceFromIOUPayment so the negation logic is no longer duplicated across the two call sites.
  • Normalized the converted amounts: since IOU transactions can be stored with either sign, the helper now uses -Math.abs(convertedAmount) / -Math.abs(convertedTaxAmount). The expense-report convention flips the stored sign for display, so storing a negative magnitude keeps the per-row TOTAL positive even when the source IOU stored the converted amounts negative. A plain negation would have flipped a negative-stored value back to positive and reproduced the bug for that case.
  • Added a regression test in ReportTest.ts covering an IOU transaction with negative-stored converted amounts (it fails with a plain negation, passes with the magnitude normalization).

amount/modifiedAmount keep their existing negation behavior unchanged. jest and eslint are green.

Copy link
Copy Markdown
Contributor

@joekaufmanexpensify joekaufmanexpensify left a comment

Choose a reason for hiding this comment

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

Makes sense 👍

When an IOU report is converted to an expense report, the optimistic
transaction data negated amount and modifiedAmount to match the
expense-report sign convention but left convertedAmount and
convertedTaxAmount untouched. Because getConvertedAmount flips the sign
for expense reports, the per-row TOTAL column rendered the still-positive
convertedAmount as a negative value.

Negate convertedAmount and convertedTaxAmount in the optimistic data in
both convertIOUReportToExpenseReport and createWorkspaceFromIOUPayment,
using a conditional spread so absent values are not overwritten.
Rollback data is built from the unmodified transaction, so failure
restores the original values.
…unts

Move the IOU-to-expense-report transaction sign conversion into a single
getExpenseReportSignedTransaction helper in TransactionUtils so the logic
is no longer duplicated between convertIOUReportToExpenseReport and
createWorkspaceFromIOUPayment.

IOU transactions can be stored with either sign, so negate the absolute
value of convertedAmount/convertedTaxAmount. The expense-report convention
flips the stored sign for display, so a negative magnitude keeps the table
total positive even when the source IOU stored the converted amounts
negative. Added a regression test covering that case.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
src/libs/TransactionUtils/index.ts 88.73% <100.00%> (+0.07%) ⬆️
src/libs/actions/Policy/Policy.ts 69.97% <100.00%> (+0.20%) ⬆️
src/libs/actions/Report/index.ts 69.25% <100.00%> (-0.13%) ⬇️
... and 44 files with indirect coverage changes

@FitseTLT
Copy link
Copy Markdown
Contributor

…conversion

Cover the Policy.ts call site of getExpenseReportSignedTransaction with a
test that feeds an IOU transaction whose converted amounts are stored
negative and asserts the optimistic data stores a negative magnitude (so
the expense-report sign convention renders a positive table total) and
that the failure data restores the original values.
@trasnake87
Copy link
Copy Markdown
Author

@FitseTLT good catch — the Policy.ts call site wasn't exercised by my existing tests (the regression coverage all lived in tests/actions/ReportTest.ts against convertIOUReportToExpenseReport).

I added a matching test in tests/actions/PolicyTest.ts under the createWorkspaceFromIOUPayment describe block. It feeds the function an IOU transaction whose convertedAmount/convertedTaxAmount are stored negative (the case the Codex review flagged earlier), spies on API.write, and asserts that the transaction collection in optimisticData ends up with a negative magnitude (so the expense-report sign convention renders a positive total) and that the failureData restores the original values.

Verified the test catches the pre-fix behavior: temporarily replacing -Math.abs(...) with a plain -transaction.convertedAmount in the helper makes the new test fail with Expected: -6000 / Received: 6000. With the helper restored, the test is green alongside the three existing createWorkspaceFromIOUPayment tests. npx eslint and npx prettier --check are clean on the change.

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.

3 participants