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
[Due for payment 2026-03-30] Expense - Duplicate not resolve when admin "Keep this one" for duplicated expenses on 2 reports #82568
Version Number: v9.3.19-1 Reproducible in staging?: Yes Reproducible in production?: Yes If this was caught during regression testing, add the test name, ID and link from BrowserStack:https://test-management.browserstack.com/projects/2219752/test-runs/TR-5252/folder/13176704/41236827/1189838696 Email or phone of affected tester (no customers): N/A Issue reported by: Applause Internal Team Bug source: Exploratory - Significant User Experience Deterioration Device used: Win 11/Chrome App Component: Money Requests
Action Performed:
Precondition: Employee create and submit two similar manual expenses on two different reports to workspace chat
Please re-state the problem that we are trying to solve in this issue.
Admin uses "Keep this one" to resolve duplicate expenses on two separate reports. The flow completes but the "Review duplicates" button and "Potential duplicate" violation stay visible. The duplicates are not actually resolved.
What is the root cause of that problem?
getIOUActionForTransactions (lines 55-69) accepts a single iouReportID and only scans that one report's action collection:
Both mergeDuplicates and resolveDuplicates pass params.reportID (the kept transaction's report) as the sole report to search. Duplicate transactions on a different report have their IOU actions stored under a different key, so they are never found.
This causes an optimistic data mismatch in mergeDuplicates. Transactions are nullified for ALL duplicates at lines 106-110 (using params.transactionIDList), but the thread cleanup loop at lines 211-225 only iterates iouActionsToDelete — which is the filtered subset from the single report. Cross-report transaction threads and report preview actions are left dangling:
In resolveDuplicates, the same gap means reportActionIDList (hold actions) and orderedTransactionIDList are built from an incomplete iouActionList. The backend receives mismatched arrays and only partially resolves the duplicate set.
What changes do you think we should make in order to solve the problem?
Instead of searching one report for all duplicates, resolve each duplicate's IOU action from its own report. Each transaction already has a reportID field, and getIOUActionForReportID (imported at line 12) does per-report lookups.
src/libs/actions/IOU/Duplicate.ts — in both resolveDuplicates() and mergeDuplicates(), build the IOU action list by iterating each duplicate individually:
constiouActionList=params.transactionIDList.map((duplicateTransactionID)=>{constduplicateTransaction=allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${duplicateTransactionID}`];returngetIOUActionForReportID(duplicateTransaction?.reportID??params.reportID,duplicateTransactionID,);}).filter((action,): action is OnyxTypes.ReportAction<typeofCONST.REPORT.ACTIONS.TYPE.IOU>=>!!action&&isMoneyRequestAction(action),);
allTransactions is already available in both functions. The isMoneyRequestAction type guard produces the correct IOU action type that downstream consumers (hold loop, thread cleanup, ordered ID list) expect.
What alternative solutions did you explore? (Optional)
Refactoring getIOUActionForTransactions to accept multiple report IDs was considered. This would change the function's contract and risk affecting other callers. Per-transaction resolution is a more contained fix.
Please re-state the problem that we are trying to solve in this issue.
Expense - Duplicate not resolve when admin "Keep this one" for duplicated expenses on 2 reports
What is the root cause of that problem?
resolveDuplicates() is using getIOUActionForTransactions(params.transactionIDList, params.reportID) which only searches for IOU actions within a single report (the kept transaction's report). This caused cross-report duplicates to be excluded from the API calls, leaving their violations unresolved.
What changes do you think we should make in order to solve the problem?
We should remove the filtering logic that used getIOUActionForTransactions() with a single reportID
Instead, iterate through each transaction in params.transactionIDList and find its IOU action in its own report (using transaction.reportID)
@@ -395,24 +436,40 @@ function resolveDuplicates(params: MergeDuplicatesParams) {
};
});
- const iouActionList = params.reportID ? getIOUActionForTransactions(params.transactionIDList, params.reportID) : [];- const orderedTransactionIDList = iouActionList- .map((action) => {- const message = getOriginalMessage(action);- return message?.IOUTransactionID;- })- .filter((id): id is string => !!id);-
const optimisticHoldActions: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [];
const failureHoldActions: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [];
const reportActionIDList: string[] = [];
const optimisticHoldTransactionActions: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.TRANSACTION>> = [];
const failureHoldTransactionActions: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.TRANSACTION>> = [];
- for (const action of iouActionList) {- const transactionThreadReportID = action?.childReportID;+ const allReportActions = getAllReportActionsFromIOU();++ // For each duplicate transaction, find its IOU action and create hold actions+ // This handles cross-report duplicates by searching across all reports+ for (const transactionID of params.transactionIDList) {+ const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];+ if (!transaction) {+ continue;+ }++ // Find the IOU action for this transaction in its own report+ const transactionReportID = transaction.reportID;+ const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionReportID}`];+ const iouAction = Object.values(reportActions ?? {}).find((action): action is OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU> => {+ if (!isMoneyRequestAction(action)) {+ return false;+ }+ const message = getOriginalMessage(action);+ return message?.IOUTransactionID === transactionID;+ });++ if (!iouAction) {+ continue;+ }++ const transactionThreadReportID = iouAction.childReportID;
const createdReportAction = buildOptimisticHoldReportAction();
reportActionIDList.push(createdReportAction.reportActionID);
- const transactionID = isMoneyRequestAction(action) ? (getOriginalMessage(action)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID;+
optimisticHoldTransactionActions.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
@@ -482,7 +539,7 @@ function resolveDuplicates(params: MergeDuplicatesParams) {
...otherParams,
transactionID: params.transactionID,
reportActionIDList,
- transactionIDList: orderedTransactionIDList,+ transactionIDList: params.transactionIDList,
dismissedViolationReportActionID: optimisticReportAction.reportActionID,
};
We can do similar with mergeDuplicates if needed
What alternative solutions did you explore? (Optional)
Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.
⚠️@lorretheboy Your proposal is a duplicate of an already existing proposal and has been automatically withdrawn to prevent spam. Please review the existing proposals before submitting a new one.
Please re-state the problem that we are trying to solve in this issue.
When an admin reviews duplicates and clicks "Keep this one" for expenses that live on different reports, the flow finishes but some duplicate violations remain. The duplicates are only partially resolved, so "Review duplicates" and "Potential duplicate" still appear.
What is the root cause of that problem?
resolveDuplicates() builds duplicate actions with getIOUActionForTransactions(params.transactionIDList, params.reportID), which only searches report actions inside a single report (params.reportID, the kept expense's report).
When duplicates span two reports, actions from the other report are missing from iouActionList, so this causes incomplete hold action creation and a mismatched payload (only same-report duplicates are resolved) because the backend receives a filtered set rather than the full duplicate set.
What changes do you think we should make in order to solve the problem?
Update resolveDuplicates() to resolve each duplicate transaction's IOU action from that transaction's own report (transaction.reportID) and construct transactionIDList/reportActionIDList in the same loop so both arrays stay aligned.
This keeps behavior unchanged for same-report duplicates, and fixes cross-report duplicates by ensuring every duplicate transaction contributes both a hold action ID and a matching transaction ID.
What alternative solutions did you explore? (Optional)
Existing proposals correctly identified the single-report lookup, but they were not optimized: one missed the required Root-cause code snippet + Current/Updated blocks, and another posted a patch diff format that does not match proposal requirements.
I considered changing getIOUActionForTransactions() to accept multiple report IDs, but this broadens helper behavior for all callers. A targeted change in resolveDuplicates() is safer and keeps ordering control in the same function that builds the API payload.
⚠️@annaweber830 Your proposal is a duplicate of an already existing proposal and has been automatically withdrawn to prevent spam. Please review the existing proposals before submitting a new one.
@QichenZhu The PR fixing this issue has been merged! The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the BugZero Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]
[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion: N/A.
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
[BugZero Assignee] Create a GH issue for creating/updating the regression test once above steps have been agreed upon.
@trjExpensify - as I, @twisterdotcom am already assigned to this issue as BZ, could you also check this box to confirm the Payment Summary above. Note that no single individual can both confirm the payment amounts and approve the payment itself due to SOX.
Activity
melvin-bot commentedon Feb 16, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @QichenZhu (
External)melvin-bot commentedon Feb 16, 2026
Triggered auto assignment to @twisterdotcom (
Bug), see https://stackoverflow.com/c/expensify/questions/14418 for more details. Please add this bug to a GH project, as outlined in the SO.melvin-bot commentedon Feb 16, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
HiepPP commentedon Feb 16, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
Admin uses "Keep this one" to resolve duplicate expenses on two separate reports. The flow completes but the "Review duplicates" button and "Potential duplicate" violation stay visible. The duplicates are not actually resolved.
What is the root cause of that problem?
getIOUActionForTransactions(lines 55-69) accepts a singleiouReportIDand only scans that one report's action collection:App/src/libs/actions/IOU/Duplicate.ts
Lines 55 to 69 in c029b13
Both
mergeDuplicatesandresolveDuplicatespassparams.reportID(the kept transaction's report) as the sole report to search. Duplicate transactions on a different report have their IOU actions stored under a different key, so they are never found.This causes an optimistic data mismatch in
mergeDuplicates. Transactions are nullified for ALL duplicates at lines 106-110 (usingparams.transactionIDList), but the thread cleanup loop at lines 211-225 only iteratesiouActionsToDelete— which is the filtered subset from the single report. Cross-report transaction threads and report preview actions are left dangling:App/src/libs/actions/IOU/Duplicate.ts
Lines 106 to 110 in c029b13
App/src/libs/actions/IOU/Duplicate.ts
Lines 211 to 225 in c029b13
In
resolveDuplicates, the same gap meansreportActionIDList(hold actions) andorderedTransactionIDListare built from an incompleteiouActionList. The backend receives mismatched arrays and only partially resolves the duplicate set.What changes do you think we should make in order to solve the problem?
Instead of searching one report for all duplicates, resolve each duplicate's IOU action from its own report. Each transaction already has a
reportIDfield, andgetIOUActionForReportID(imported at line 12) does per-report lookups.src/libs/actions/IOU/Duplicate.ts— in bothresolveDuplicates()andmergeDuplicates(), build the IOU action list by iterating each duplicate individually:allTransactionsis already available in both functions. TheisMoneyRequestActiontype guard produces the correct IOU action type that downstream consumers (hold loop, thread cleanup, ordered ID list) expect.What alternative solutions did you explore? (Optional)
Refactoring
getIOUActionForTransactionsto accept multiple report IDs was considered. This would change the function's contract and risk affecting other callers. Per-transaction resolution is a more contained fix.lorretheboy commentedon Feb 16, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
Expense - Duplicate not resolve when admin "Keep this one" for duplicated expenses on 2 reports
What is the root cause of that problem?
resolveDuplicates() is using getIOUActionForTransactions(params.transactionIDList, params.reportID) which only searches for IOU actions within a single report (the kept transaction's report). This caused cross-report duplicates to be excluded from the API calls, leaving their violations unresolved.
App/src/libs/actions/IOU/Duplicate.ts
Line 374 in f22a3a3
What changes do you think we should make in order to solve the problem?
We should remove the filtering logic that used getIOUActionForTransactions() with a single reportID
Instead, iterate through each transaction in params.transactionIDList and find its IOU action in its own report (using transaction.reportID)
We can do similar with
mergeDuplicatesif neededWhat alternative solutions did you explore? (Optional)
Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.
github-actions commentedon Feb 16, 2026
annaweber830 commentedon Feb 16, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
When an admin reviews duplicates and clicks "Keep this one" for expenses that live on different reports, the flow finishes but some duplicate violations remain. The duplicates are only partially resolved, so "Review duplicates" and "Potential duplicate" still appear.
What is the root cause of that problem?
resolveDuplicates()builds duplicate actions withgetIOUActionForTransactions(params.transactionIDList, params.reportID), which only searches report actions inside a single report (params.reportID, the kept expense's report).App/src/libs/actions/IOU/Duplicate.ts
Lines 55 to 69 in 1d699f9
That list is then used to build both
orderedTransactionIDListandreportActionIDListforRESOLVE_DUPLICATES.App/src/libs/actions/IOU/Duplicate.ts
Lines 398 to 486 in 1d699f9
When duplicates span two reports, actions from the other report are missing from
iouActionList, so this causes incomplete hold action creation and a mismatched payload (only same-report duplicates are resolved) because the backend receives a filtered set rather than the full duplicate set.What changes do you think we should make in order to solve the problem?
Update
resolveDuplicates()to resolve each duplicate transaction's IOU action from that transaction's own report (transaction.reportID) and constructtransactionIDList/reportActionIDListin the same loop so both arrays stay aligned.App/src/libs/actions/IOU/Duplicate.ts
Lines 398 to 489 in 1d699f9
Current code:
Updated code:
This keeps behavior unchanged for same-report duplicates, and fixes cross-report duplicates by ensuring every duplicate transaction contributes both a hold action ID and a matching transaction ID.
What alternative solutions did you explore? (Optional)
getIOUActionForTransactions()to accept multiple report IDs, but this broadens helper behavior for all callers. A targeted change inresolveDuplicates()is safer and keeps ordering control in the same function that builds the API payload.github-actions commentedon Feb 16, 2026
40 remaining items
melvin-bot commentedon Mar 23, 2026
@QichenZhu The PR fixing this issue has been merged! The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the BugZero Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]
melvin-bot commentedon Mar 23, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)QichenZhu commentedon Mar 30, 2026
BugZero Checklist:
[Contributor] The offending PR has been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.
Link to comment: fix: Use new ResolveDuplicates when approver is resolving duplicates #48522 (comment)
[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion: N/A.
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
[BugZero Assignee] Create a GH issue for creating/updating the regression test once above steps have been agreed upon.
Link to issue: https://github.com/Expensify/Expensify/issues/617848
Regression Test Proposal
Precondition:
Test:
Do we agree 👍 or 👎
Zapier Logs
Run ID: 00040eee-cae7-a9ec-b674-48324928ef74twisterdotcom commentedon Mar 30, 2026
Payment Summary:
melvin-bot commentedon Mar 30, 2026
Payment Summary
Upwork Job
BugZero Checklist (@twisterdotcom)
melvin-bot commentedon Mar 30, 2026
twisterdotcom commentedon Mar 31, 2026
@QichenZhu has submitted payment for this report on ND here.
SO: https://stackoverflowteams.com/c/expensify/questions/7582
Zapier Logs
Run ID: 00040eee-cdde-aa51-a04c-5bff47844fb4trjExpensify commentedon Mar 31, 2026
Confirming payment summary here is correct.