You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[$250] Hold - Approve modal displays same amount full report and pending expense #102333
The Approve options get both amounts from report-level totals that the server sends. They don't use the report's transactions. getNonHeldAndFullAmount reads:
"Approve only X" from report.unheldTotal
"Approve Y" from reimbursableTotal + nonReimbursableTotal
The employee who places the hold gets updated unheldTotal values right away, from optimistic data in putOnHold. The approver doesn't. The approver does receive the transaction's hold, which is why the "Approve only" option appears. But their copy of the report still has unheldTotal equal to the full total, so both options show the same amount.
What changes do you think we should make in order to solve the problem?
In getNonHeldAndFullAmount, calculate the held amount from allReportTransactions. Every caller already passes these, and the approver has them.
Add up the amounts of held transactions in the report currency. Convert when a transaction's currency differs from the report's.
For the pay branch, count only reimbursable held transactions.
Set the non-held amount to the full total minus the held amount.
Use the same helper for the partial approve and partial pay amounts, so the amount shown matches the amount submitted.
Add a unit test. Use a report whose unheldTotal equals total and one held transaction, and check that the non-held amount excludes the held transaction.
Separately, an internal engineer should check whether the backend sends updated unheld totals to other report participants after a hold.
What alternative solutions did you explore? (Optional)
Keep the report fields, but switch to the transaction-based amount only when a held expense exists and unheldTotal equals the total. This change is smaller, but it misses cases where the report fields are only partly stale.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/102333) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
changed the title [-]Hold - Approve modal displays same amount full report and pending expense[/-][+][$175] Hold - Approve modal displays same amount full report and pending expense[/+]on Sep 25, 2026
When a user puts an expense on hold, the hold status is immediately saved on the transaction object (transaction.comment.hold = true).
In ExpenseHeaderApprovalButton.tsx, allReportTransactions is passed into getNonHeldAndFullAmount. hasHeldExpenses(allReportTransactions) detects the hold and causes the dropdown menu to appear.
However, getNonHeldAndFullAmount computes unheldTotal strictly from iouReport?.unheldTotal or iouReport?.unheldReimbursableTotal. Because iouReport stored totals in Onyx are not yet updated with the new unheld split until the report re-computes on the backend, iouReport?.unheldTotal remains undefined or equal to the full total.
As a result, unheldTotal defaults to the full report total, causing both dropdown options ("Approve only <amount>" and "Approve <amount>") in getApprovalDropdownOptions to display the identical full report amount.
What changes do you think we should make in order to solve the problem?
In src/libs/ReportUtils.ts inside getNonHeldAndFullAmount:
When allReportTransactions contains held expenses, compute the unheld amounts directly from allReportTransactions (filtering transactions where !isHold(transaction)) as an optimistic fallback when iouReport?.unheldTotal is missing or equal to the full total:
Calculate unheldTransactionTotal from allReportTransactions.filter(t => !isHold(t)) (separating reimbursable vs non-reimbursable amounts).
Use this derived amount for unheldTotal and unheldReimbursableTotal when report-level unheld fields have not yet synced from the backend.
This ensures the partial approval dropdown immediately displays the true pending (non-held) amount in sync with the user's optimistic hold actions.
What alternative solutions did you explore? (Optional)
We could also update iouReport.unheldTotal optimistically in the putOnHold action, but calculating the client fallback inside getNonHeldAndFullAmount using the already-provided allReportTransactions is cleaner, self-contained, and guarantees accurate display across all report types and viewers.
Test Plan:
As an employee, submit two expenses (e.g. $50 and $50) in a workspace report.
Open one of the expenses and put it on Hold.
As an approver, navigate to the report and click on the "Approve" button.
Verify the dropdown displays:
"Approve only $50" (for the unheld pending expense).
"Approve $100" (for the full report total).
Confirm clicking "Approve only" approves only the non-held amount.
getNonHeldAndFullAmount builds the Approve dropdown amounts from report-level unheld* totals.
The problem is that after an expense is put on hold, the approver can already receive the held state on the transaction, so the partial-approve option appears, while their copy of the report may still have stale unheldTotal / unheldReimbursableTotal values equal to the full total.
That makes both options display the same amount.
The same stale report fields are also used by the partial approve/pay paths, so this can affect not only the label shown to the user, but also the optimistic amount submitted.
What changes do you think we should make in order to solve the problem?
Keep preferring backend-computed unheld* fields when they are consistent, but add a fallback when held transactions prove those fields are stale.
Instead of summing unheld transactions, derive the non-held amount as:
full report total - sum(held transaction amounts in report currency)
This is safer because some callers may have only a partial transaction list; summing the visible unheld transactions could therefore undercount the real pending amount.
I would:
Add a small shared numeric helper near getNonHeldAndFullAmount.
Use the transaction-derived fallback only when:
held transactions exist, and
the report-level unheld value is missing or still equal to the full total.
For held transactions:
use the report-currency amount / converted amount when needed;
for the Pay branch, subtract only reimbursable held transactions.
Reuse the same helper in:
getNonHeldAndFullAmount
the partial approve path in ReportWorkflow
the partial pay path in PayMoneyRequest
This keeps the displayed amount and submitted amount consistent without broadly replacing the backend totals logic.
What alternative solutions did you explore?
Always recalculating the unheld amount from all unheld transactions would be broader and can be incorrect when a caller has an incomplete transaction list.
Updating only the optimistic unheld* fields in putOnHold also would not fully solve this case, because that optimistic update runs on the client that places the hold, not necessarily on the approver's client.
Test Plan
Add focused unit coverage for:
Stale approver case: unheldTotal === fullTotal with one held and one unheld transaction → partial amount excludes the held expense.
Backend already correct: existing reduced unheldTotal is preserved.
Reimbursable vs non-reimbursable held transactions for the Pay path.
Mixed-currency held transaction using its report-currency converted amount.
All-held case keeps the partial option suppressed.
Also verify that the same calculated partial amount is used by the approve/pay submission paths.
Manual repro:
Employee submits two expenses and holds one.
Approver opens the Approve menu.
"Approve only X" shows the non-held amount and differs from "Approve Y".
Choosing the partial action submits that same amount.
🚨 Edited by proposal-police: This proposal was edited at 2026-10-15 20:08:36 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue
When an employee submits multiple expenses on a workspace report and puts one of the expenses on hold, clicking the Approve button as an approver opens the partial-vs-full approval modal/dropdown where both the non-held ("pending expense") option and the full report option display the same full report amount, instead of displaying the non-held amount for pending expenses and the total amount for the full report.
What is the root cause of that problem?
In src/libs/ReportUtils.ts inside getNonHeldAndFullAmount (lines 12508–12553), the non-held amount is computed solely from the report-level fields iouReport?.unheldReimbursableTotal, iouReport?.unheldTotal, and iouReport?.unheldNonReimbursableTotal:
When an expense is placed on hold (putOnHold in src/libs/actions/IOU/Hold.ts) or when the report object in Onyx has not updated unheldTotal / unheldReimbursableTotal to subtract the held transaction's amount (or when unheldTotal still equals total while individual transactions in allReportTransactions already have comment.hold violation/status), getUnheldReimbursableTotal(iouReport) and iouReport?.unheldTotal still reflect the full report total (or 0 when unheldTotal is undefined). Furthermore, when unheldTotal equals total on a positive/IOU report, adjustedUnheldTotal >= 0 in hasValidNonHeldAmount (line 12544) still evaluates to true, causing the UI to render both options with the identical full report total.
What changes do you think we should make in order to solve the problem?
In src/libs/ReportUtils.ts inside getNonHeldAndFullAmount (lines 12508–12553):
When hasHeldExpenses(allReportTransactions) is true and all report transactions share the report currency (or when iouReport?.unheldTotal === undefined / Math.abs(unheldTotal) >= Math.abs(total) despite held transactions existing in allReportTransactions), derive the unheld reimbursable and non-reimbursable totals directly from allReportTransactions by summing the non-held transactions (!isOnHoldTransactionUtils(transaction)).
Ensure hasValidNonHeldAmount requires Math.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal) in all cases (not only negative expense reports) so the partial approval option is never shown with an amount equal to the full report amount.
--- a/src/libs/ReportUtils.ts+++ b/src/libs/ReportUtils.ts@@ -12518,18 +12518,36 @@ function getNonHeldAndFullAmount(
// sum of the underlying transactions, so deriving from reimbursableTotal/nonReimbursableTotal keeps
// displayed amounts in sync with what the user actually entered.
const reimbursableTotal = getReimbursableTotal(iouReport);
- const unheldReimbursableTotal = getUnheldReimbursableTotal(iouReport);+ let unheldReimbursableTotal = getUnheldReimbursableTotal(iouReport);+ const hasHeldExpensesLocal = hasHeldExpenses(allReportTransactions);
let total: number;
let unheldTotal: number;
if (shouldExcludeNonReimbursables) {
total = reimbursableTotal;
unheldTotal = unheldReimbursableTotal;
} else {
total = reimbursableTotal + (iouReport?.nonReimbursableTotal ?? 0);
unheldTotal = iouReport?.unheldTotal ?? unheldReimbursableTotal + (iouReport?.unheldNonReimbursableTotal ?? 0);
}
+ const canCalculateFromTransactions =+ allReportTransactions.length > 0 &&+ allReportTransactions.every((transaction) => transaction.currency === iouReport?.currency);++ if (hasHeldExpensesLocal && canCalculateFromTransactions && (iouReport?.unheldTotal === undefined || Math.abs(unheldTotal) >= Math.abs(total))) {+ const nonHeldTransactions = allReportTransactions.filter((transaction) => !isOnHoldTransactionUtils(transaction));+ unheldTotal = nonHeldTransactions.reduce((sum, transaction) => {+ if (shouldExcludeNonReimbursables && !isTransactionReimbursable(transaction)) {+ return sum;+ }+ return sum + getAmount(transaction, isExpenseReport(iouReport));+ }, 0) * coefficient;+ }+
const adjustedUnheldTotal = unheldTotal * coefficient;
const adjustedTotal = total * coefficient;
- const hasHeldExpensesLocal = hasHeldExpenses(allReportTransactions);
const hasValidNonHeldAmount =
hasHeldExpensesLocal &&
+ Math.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal) &&
// For normal cases (positive amounts or IOU reports)
(adjustedUnheldTotal >= 0 ||
// For expense reports with negative amounts, check if amounts are meaningful
- (isExpenseReport(iouReport) && Math.abs(adjustedUnheldTotal) > 0 && Math.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal)));+ (isExpenseReport(iouReport) && Math.abs(adjustedUnheldTotal) > 0));
What alternative solutions did you explore? (Optional)
We also considered updating unheldTotal and unheldReimbursableTotal solely inside the optimistic Onyx data of putOnHold / unholdRequest in src/libs/actions/IOU/Hold.ts. However, computing the transaction-level fallback in getNonHeldAndFullAmount (where allReportTransactions is already passed in) handles both optimistic holds and any cases where the report-level unheldTotal lags behind transaction state across clients.
📣 @open5588! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
Format:
Contributor details
Your Expensify account email: <REPLACE EMAIL HERE>
Upwork Profile Link: <REPLACE LINK HERE>
⚠️@open5588 Thanks for your proposal. Please update it to follow the proposal template, as proposals are only reviewed if they follow that format (note the mandatory sections).
⚠️@RajGahoi Thanks for your interest. To be considered for this job, please post a proposal following the proposal template (note the mandatory sections). Comments claiming the job without one are not reviewed.
In getNonHeldAndFullAmount inside src/libs/ReportUtils.ts, calculating unheldTotal relies exclusively on iouReport.unheldTotal (or iouReport.unheldReimbursableTotal). When an expense is placed on hold and unheldTotal has not yet been updated in Onyx or still equals iouReport.total, unheldTotal defaults to the full report amount even though allReportTransactions is passed into the function.
Additionally, in hasValidNonHeldAmount, the condition is (adjustedUnheldTotal >= 0 || ...). For expense reports, amounts are stored as negative numbers in Onyx and multiplied by coefficient = -1, resulting in a positive adjustedUnheldTotal. This causes adjustedUnheldTotal >= 0 to always short-circuit to true, making the second check Math.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal) completely unreachable dead code. As a result, stale unheld totals that match the full report amount are incorrectly considered valid and displayed in the modal.
What changes do you think we should make in order to solve the problem?
In src/libs/ReportUtils.ts, update getNonHeldAndFullAmount so that when hasHeldExpenses(allReportTransactions) is true and the report unheld total is missing or equal to total, we compute the fallback unheld total from allReportTransactions provided all active transactions match the report currency. Filter out transactions that are on hold (!TransactionUtils.isOnHold(transaction)), transactions pending deletion, and non-reimbursable expenses when shouldExcludeNonReimbursables is true, summing their effective amounts using TransactionUtils.getAmount.
In hasValidNonHeldAmount, remove the short-circuiting adjustedUnheldTotal >= 0 branch and require that hasHeldExpensesLocal is true, adjustedUnheldTotal > 0, and Math.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal).
Additionally, update putOnHold and unholdRequest in src/libs/actions/IOU to optimistically update iouReport.unheldTotal (and unheldReimbursableTotal when reimbursable) if the transaction currency matches the report currency, keeping Onyx synchronized across all views.
What alternative solutions did you explore? (Optional)
If we only fix the boolean condition in hasValidNonHeldAmount without deriving the unheld sum or optimistic updates, hasValidNonHeldAmount evaluates to false when unheldTotal equals total, which hides the amount next to the button entirely instead of showing the correct non-held sum.
We also considered unconditionally deriving the unheld total client-side for all reports, but this would break multi-currency expense reports where foreign transaction amounts require server-side conversion rates.
Please re-state the problem that we are trying to solve in this issue.
In the Approve expense report modal, when an expense report contains a held expense, the modal displays the same full report amount for both the total report and the pending/non-held amount, instead of calculating and displaying the unheld non-pending amount.
What is the root cause of that problem?
In src/libs/ReportUtils.ts:
Unreachable Validation Guard in hasValidNonHeldAmount:
For expense reports, amounts are multiplied by coefficient = -1, resulting in a non-negative adjustedUnheldTotal >= 0 whenever an unheld total exists. Because adjustedUnheldTotal >= 0 evaluates to true, the condition immediately short-circuits. The secondary check Math.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal) is never evaluated. Consequently, stale unheld totals that match the full report amount are erroneously treated as valid distinct non-held amounts and rendered in the modal.
Missing Local Fallback Computation in getNonHeldAndFullAmount:
When an expense is placed on hold and the optimistic iouReport.unheldTotal is missing or temporarily equals iouReport.total, getNonHeldAndFullAmount simply returns iouReport.unheldTotal without deriving the actual non-held sum from the provided allReportTransactions collection.
What changes do you think we should make in order to solve the problem?
Fix hasValidNonHeldAmount in src/libs/ReportUtils.ts:
Remove the short-circuiting check and explicitly require that:
Held expenses exist on the report (hasHeldExpensesLocal).
adjustedUnheldTotal > 0.
The unheld amount is strictly different from the full report amount:
Add Local Fallback Summation in getNonHeldAndFullAmount (src/libs/ReportUtils.ts):
When hasHeldExpenses(allReportTransactions) is true and iouReport.unheldTotal === iouReport.total, iterate over allReportTransactions for single-currency reports, filtering out transactions where TransactionUtils.isOnHold(transaction) or isTransactionPendingDelete(transaction), and compute the active non-held total using TransactionUtils.getAmount.
Optimistic Sync in Actions (src/libs/actions/IOU/index.ts):
Ensure putOnHold and unholdRequest optimistically deduct/restore transaction amounts from iouReport.unheldTotal when the transaction currency matches the report currency.
Automated Unit Tests in tests/unit/ReportUtilsTest.ts:
Verify hasValidNonHeldAmount returns false when unheldTotal === total even if non-held expenses exist.
Verify getNonHeldAndFullAmount correctly calculates the deducted amount when 1 of 2 transactions is held.
What alternative solutions did you explore? (Optional)
Deriving unheld total entirely client-side unconditionally: Rejected because foreign multi-currency transactions require backend conversion rates. Deriving locally only as fallback for matching single-currency reports preserves currency accuracy.
⚠️@open5588 Thanks for your interest. To be considered for this job, please post a proposal following the proposal template (note the mandatory sections). Comments claiming the job without one are not reviewed.
⚠️@open5588 Thanks for your interest. To be considered for this job, please post a proposal following the proposal template (note the mandatory sections). Comments claiming the job without one are not reviewed.
Activity
applause-bot commentedon Sep 25, 2026
While Applause doesn't embed attachments for this bug, here are the links for quick view:
https://platform.applause.com/services/links/v1/external/4e847f2a253fb9b17e2fbe16d41937da05e410a7b1c85c855da85848d72bb5e6
MelvinBot commentedon Sep 25, 2026
Proposal
What is the root cause of that problem?
The Approve options get both amounts from report-level totals that the server sends. They don't use the report's transactions.
getNonHeldAndFullAmountreads:report.unheldTotalreimbursableTotal + nonReimbursableTotalThe transactions are used for only one thing: to check whether any expense is held.
The employee who places the hold gets updated
unheldTotalvalues right away, from optimistic data inputOnHold. The approver doesn't. The approver does receive the transaction's hold, which is why the "Approve only" option appears. But their copy of the report still hasunheldTotalequal to the full total, so both options show the same amount.Supporting details
ExpenseHeaderApprovalButton.tsx:86) and the Spend > Reports hold modal (ExpenseReportListItem.tsx:276-299).unheldReimbursableTotalin the same function.ReportWorkflow.ts:514-520,PayMoneyRequest.ts:260-262). If those fields are stale, the optimistic amount is wrong too.What changes do you think we should make in order to solve the problem?
In
getNonHeldAndFullAmount, calculate the held amount fromallReportTransactions. Every caller already passes these, and the approver has them.unheldTotalequalstotaland one held transaction, and check that the non-held amount excludes the held transaction.Separately, an internal engineer should check whether the backend sends updated unheld totals to other report participants after a hold.
What alternative solutions did you explore? (Optional)
Keep the report fields, but switch to the transaction-based amount only when a held expense exists and
unheldTotalequals the total. This change is smaller, but it misses cases where the report fields are only partly stale.Possibly related: Spend - Approve modal inconsistency when approved form Spend and report. It involves the same approve-with-hold amounts, but a different trigger ($0 held expense).
Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/102333)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run
melvin-bot commentedon Sep 25, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @brunovjk (
External)[-]Hold - Approve modal displays same amount full report and pending expense[/-][+][$175] Hold - Approve modal displays same amount full report and pending expense[/+]melvin-bot commentedon Sep 25, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022103555129237584541
ashusatyarthi-wq commentedon Sep 25, 2026
Proposal
What is the root cause of that problem?
In
src/libs/ReportUtils.tswithingetNonHeldAndFullAmount:transaction.comment.hold = true).ExpenseHeaderApprovalButton.tsx,allReportTransactionsis passed intogetNonHeldAndFullAmount.hasHeldExpenses(allReportTransactions)detects the hold and causes the dropdown menu to appear.getNonHeldAndFullAmountcomputesunheldTotalstrictly fromiouReport?.unheldTotaloriouReport?.unheldReimbursableTotal. BecauseiouReportstored totals in Onyx are not yet updated with the new unheld split until the report re-computes on the backend,iouReport?.unheldTotalremains undefined or equal to the full total.unheldTotaldefaults to the full report total, causing both dropdown options ("Approve only <amount>"and"Approve <amount>") ingetApprovalDropdownOptionsto display the identical full report amount.What changes do you think we should make in order to solve the problem?
In
src/libs/ReportUtils.tsinsidegetNonHeldAndFullAmount:When
allReportTransactionscontains held expenses, compute the unheld amounts directly fromallReportTransactions(filtering transactions where!isHold(transaction)) as an optimistic fallback wheniouReport?.unheldTotalis missing or equal to the full total:unheldTransactionTotalfromallReportTransactions.filter(t => !isHold(t))(separating reimbursable vs non-reimbursable amounts).unheldTotalandunheldReimbursableTotalwhen report-level unheld fields have not yet synced from the backend.This ensures the partial approval dropdown immediately displays the true pending (non-held) amount in sync with the user's optimistic hold actions.
What alternative solutions did you explore? (Optional)
We could also update
iouReport.unheldTotaloptimistically in theputOnHoldaction, but calculating the client fallback insidegetNonHeldAndFullAmountusing the already-providedallReportTransactionsis cleaner, self-contained, and guarantees accurate display across all report types and viewers.Test Plan:
ashusatyarthi-wq commentedon Sep 25, 2026
Contributor details
Your Expensify account email: ashusatyarthi@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~010e53044b5828fe60
melvin-bot commentedon Sep 25, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
fix: derive unheld amount from transactions when report unheld total …
hgreco85 commentedon Sep 28, 2026
Proposal
What is the root cause of that problem?
getNonHeldAndFullAmountbuilds the Approve dropdown amounts from report-levelunheld*totals.The problem is that after an expense is put on hold, the approver can already receive the held state on the transaction, so the partial-approve option appears, while their copy of the report may still have stale
unheldTotal/unheldReimbursableTotalvalues equal to the full total.That makes both options display the same amount.
The same stale report fields are also used by the partial approve/pay paths, so this can affect not only the label shown to the user, but also the optimistic amount submitted.
What changes do you think we should make in order to solve the problem?
Keep preferring backend-computed
unheld*fields when they are consistent, but add a fallback when held transactions prove those fields are stale.Instead of summing unheld transactions, derive the non-held amount as:
full report total - sum(held transaction amounts in report currency)This is safer because some callers may have only a partial transaction list; summing the visible unheld transactions could therefore undercount the real pending amount.
I would:
getNonHeldAndFullAmount.getNonHeldAndFullAmountReportWorkflowPayMoneyRequestThis keeps the displayed amount and submitted amount consistent without broadly replacing the backend totals logic.
What alternative solutions did you explore?
Always recalculating the unheld amount from all unheld transactions would be broader and can be incorrect when a caller has an incomplete transaction list.
Updating only the optimistic
unheld*fields inputOnHoldalso would not fully solve this case, because that optimistic update runs on the client that places the hold, not necessarily on the approver's client.Test Plan
Add focused unit coverage for:
unheldTotal === fullTotalwith one held and one unheld transaction → partial amount excludes the held expense.unheldTotalis preserved.Also verify that the same calculated partial amount is used by the approve/pay submission paths.
Manual repro:
71 remaining items
open5588 commentedon Oct 15, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-10-15 20:08:36 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue
When an employee submits multiple expenses on a workspace report and puts one of the expenses on hold, clicking the Approve button as an approver opens the partial-vs-full approval modal/dropdown where both the non-held ("pending expense") option and the full report option display the same full report amount, instead of displaying the non-held amount for pending expenses and the total amount for the full report.
What is the root cause of that problem?
In
src/libs/ReportUtils.tsinsidegetNonHeldAndFullAmount(lines12508–12553), the non-held amount is computed solely from the report-level fieldsiouReport?.unheldReimbursableTotal,iouReport?.unheldTotal, andiouReport?.unheldNonReimbursableTotal:When an expense is placed on hold (
putOnHoldinsrc/libs/actions/IOU/Hold.ts) or when the report object in Onyx has not updatedunheldTotal/unheldReimbursableTotalto subtract the held transaction's amount (or whenunheldTotalstill equalstotalwhile individual transactions inallReportTransactionsalready havecomment.holdviolation/status),getUnheldReimbursableTotal(iouReport)andiouReport?.unheldTotalstill reflect the full report total (or0whenunheldTotalisundefined). Furthermore, whenunheldTotalequalstotalon a positive/IOU report,adjustedUnheldTotal >= 0inhasValidNonHeldAmount(line12544) still evaluates totrue, causing the UI to render both options with the identical full report total.What changes do you think we should make in order to solve the problem?
In
src/libs/ReportUtils.tsinsidegetNonHeldAndFullAmount(lines12508–12553):hasHeldExpenses(allReportTransactions)istrueand all report transactions share the report currency (or wheniouReport?.unheldTotal === undefined/Math.abs(unheldTotal) >= Math.abs(total)despite held transactions existing inallReportTransactions), derive the unheld reimbursable and non-reimbursable totals directly fromallReportTransactionsby summing the non-held transactions (!isOnHoldTransactionUtils(transaction)).hasValidNonHeldAmountrequiresMath.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal)in all cases (not only negative expense reports) so the partial approval option is never shown with an amount equal to the full report amount.What alternative solutions did you explore? (Optional)
We also considered updating
unheldTotalandunheldReimbursableTotalsolely inside the optimistic Onyx data ofputOnHold/unholdRequestinsrc/libs/actions/IOU/Hold.ts. However, computing the transaction-level fallback ingetNonHeldAndFullAmount(whereallReportTransactionsis already passed in) handles both optimistic holds and any cases where the report-levelunheldTotallags behind transaction state across clients.melvin-bot commentedon Oct 15, 2026
📣 @open5588! 📣
Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
Please follow these steps:
Format:
github-actions commentedon Oct 15, 2026
RajGahoi commentedon Oct 18, 2026
melvin-bot commentedon Oct 18, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
github-actions commentedon Oct 18, 2026
jdymitarai commentedon Oct 19, 2026
Upwork Profile: https://www.upwork.com/freelancers/~01f541511c3fcd21d9
Expensify Account: o10040115@gmail.com
Proposal
What is the root cause of that problem?
In
getNonHeldAndFullAmountinsidesrc/libs/ReportUtils.ts, calculatingunheldTotalrelies exclusively oniouReport.unheldTotal(oriouReport.unheldReimbursableTotal). When an expense is placed on hold andunheldTotalhas not yet been updated in Onyx or still equalsiouReport.total,unheldTotaldefaults to the full report amount even thoughallReportTransactionsis passed into the function.Additionally, in
hasValidNonHeldAmount, the condition is(adjustedUnheldTotal >= 0 || ...). For expense reports, amounts are stored as negative numbers in Onyx and multiplied bycoefficient = -1, resulting in a positiveadjustedUnheldTotal. This causesadjustedUnheldTotal >= 0to always short-circuit to true, making the second checkMath.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal)completely unreachable dead code. As a result, stale unheld totals that match the full report amount are incorrectly considered valid and displayed in the modal.What changes do you think we should make in order to solve the problem?
In
src/libs/ReportUtils.ts, updategetNonHeldAndFullAmountso that whenhasHeldExpenses(allReportTransactions)is true and the report unheld total is missing or equal tototal, we compute the fallback unheld total fromallReportTransactionsprovided all active transactions match the report currency. Filter out transactions that are on hold (!TransactionUtils.isOnHold(transaction)), transactions pending deletion, and non-reimbursable expenses whenshouldExcludeNonReimbursablesis true, summing their effective amounts usingTransactionUtils.getAmount.In
hasValidNonHeldAmount, remove the short-circuitingadjustedUnheldTotal >= 0branch and require thathasHeldExpensesLocalis true,adjustedUnheldTotal > 0, andMath.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal).Additionally, update
putOnHoldandunholdRequestinsrc/libs/actions/IOUto optimistically updateiouReport.unheldTotal(andunheldReimbursableTotalwhen reimbursable) if the transaction currency matches the report currency, keeping Onyx synchronized across all views.What alternative solutions did you explore? (Optional)
If we only fix the boolean condition in
hasValidNonHeldAmountwithout deriving the unheld sum or optimistic updates,hasValidNonHeldAmountevaluates to false whenunheldTotalequalstotal, which hides the amount next to the button entirely instead of showing the correct non-held sum.We also considered unconditionally deriving the unheld total client-side for all reports, but this would break multi-currency expense reports where foreign transaction amounts require server-side conversion rates.
RajGahoi commentedon Oct 19, 2026
FREELANCE PROPOSAL: Expensify Issue #102333
Issue Link: #102333
Bounty: $250 USD
Upwork Freelancer Profile: https://www.upwork.com/freelancers/~014898525b9c53fc32
Expensify Account Email: gahoiraj01@gmail.com
Contributor Details
gahoiraj01@gmail.comhttps://www.upwork.com/freelancers/~014898525b9c53fc32Proposal
Please re-state the problem that we are trying to solve in this issue.
In the Approve expense report modal, when an expense report contains a held expense, the modal displays the same full report amount for both the total report and the pending/non-held amount, instead of calculating and displaying the unheld non-pending amount.
What is the root cause of that problem?
In
src/libs/ReportUtils.ts:Unreachable Validation Guard in
hasValidNonHeldAmount:For expense reports, amounts are multiplied by
coefficient = -1, resulting in a non-negativeadjustedUnheldTotal >= 0whenever an unheld total exists. BecauseadjustedUnheldTotal >= 0evaluates totrue, the condition immediately short-circuits. The secondary checkMath.abs(adjustedUnheldTotal) !== Math.abs(adjustedTotal)is never evaluated. Consequently, stale unheld totals that match the full report amount are erroneously treated as valid distinct non-held amounts and rendered in the modal.Missing Local Fallback Computation in
getNonHeldAndFullAmount:When an expense is placed on hold and the optimistic
iouReport.unheldTotalis missing or temporarily equalsiouReport.total,getNonHeldAndFullAmountsimply returnsiouReport.unheldTotalwithout deriving the actual non-held sum from the providedallReportTransactionscollection.What changes do you think we should make in order to solve the problem?
Fix
hasValidNonHeldAmountinsrc/libs/ReportUtils.ts:Remove the short-circuiting check and explicitly require that:
hasHeldExpensesLocal).adjustedUnheldTotal > 0.Add Local Fallback Summation in
getNonHeldAndFullAmount(src/libs/ReportUtils.ts):When
hasHeldExpenses(allReportTransactions)is true andiouReport.unheldTotal === iouReport.total, iterate overallReportTransactionsfor single-currency reports, filtering out transactions whereTransactionUtils.isOnHold(transaction)orisTransactionPendingDelete(transaction), and compute the active non-held total usingTransactionUtils.getAmount.Optimistic Sync in Actions (
src/libs/actions/IOU/index.ts):Ensure
putOnHoldandunholdRequestoptimistically deduct/restore transaction amounts fromiouReport.unheldTotalwhen the transaction currency matches the report currency.Automated Unit Tests in
tests/unit/ReportUtilsTest.ts:hasValidNonHeldAmountreturnsfalsewhenunheldTotal === totaleven if non-held expenses exist.getNonHeldAndFullAmountcorrectly calculates the deducted amount when 1 of 2 transactions is held.What alternative solutions did you explore? (Optional)
open5588 commentedon Oct 19, 2026
melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
github-actions commentedon Oct 19, 2026
open5588 commentedon Oct 19, 2026
melvin-bot commentedon Oct 19, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
github-actions commentedon Oct 19, 2026