Skip to content

[Due for payment 2026-03-30] Expense - Duplicate not resolve when admin "Keep this one" for duplicated expenses on 2 reports #82568

Closed

Description

@lanitochka17

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


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

  1. Open https://staging.new.expensify.com
  2. Admin navigate to employee workspace chat
  3. Admin click on one of 2 expense reports
  4. Admin click on Review duplicates button
  5. Click on "Keep this one" of any expense and complete flow

Expected Result:

The duplicated expense should be resolved

Actual Result:

Review duplicated button and violent Potential duplicate are still displayed

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

Bug7077612_1771047457083.Recording__1820.mp4

View all open jobs on GitHub

Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    ExternalAdded to denote the issue can be worked on by a contributor
    BugSomething is broken. Auto assigns a BugZero manager.
    on Feb 16, 2026
  2. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Feb 16, 2026
  3. melvin-bot commented on Feb 16, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @QichenZhu (External)

  4. melvin-bot commented on Feb 16, 2026

    @melvin-bot

    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.

  5. melvin-bot commented on Feb 16, 2026

    @melvin-bot

    Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.

  6. HiepPP commented on Feb 16, 2026

    @HiepPP

    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 single iouReportID and only scans that one report's action collection:

    function getIOUActionForTransactions(transactionIDList: Array<string | undefined>, iouReportID: string | undefined): Array<OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU>> {
    const allReportActions = getAllReportActionsFromIOU();
    return Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`] ?? {})?.filter(
    (reportAction): reportAction is OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU> => {
    if (!isMoneyRequestAction(reportAction)) {
    return false;
    }
    const message = getOriginalMessage(reportAction);
    if (!message?.IOUTransactionID) {
    return false;
    }
    return transactionIDList.includes(message.IOUTransactionID);
    },
    );
    }

    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:

    const optimisticTransactionDuplicatesData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.TRANSACTION>> = params.transactionIDList.map((id) => ({
    onyxMethod: Onyx.METHOD.SET,
    key: `${ONYXKEYS.COLLECTION.TRANSACTION}${id}`,
    value: null,
    }));

    for (const [index, iouAction] of Object.entries(iouActionsToDelete)) {
    const transactionThreadID = iouAction.childReportID;
    const shouldDeleteTransactionThread = !!transactionThreadID;
    const cleanUpTransactionThreadReportOnyxDataForIouAction = getCleanUpTransactionThreadReportOnyxData({
    transactionThreadID,
    shouldDeleteTransactionThread,
    reportAction: iouAction,
    updatedReportPreviewAction,
    shouldAddUpdatedReportPreviewActionToOnyxData: Number(index) === iouActionsToDelete.length - 1,
    });
    cleanUpTransactionThreadReportsOptimisticData.push(...cleanUpTransactionThreadReportOnyxDataForIouAction.optimisticData);
    cleanUpTransactionThreadReportsSuccessData.push(...cleanUpTransactionThreadReportOnyxDataForIouAction.successData);
    cleanUpTransactionThreadReportsFailureData.push(...cleanUpTransactionThreadReportOnyxDataForIouAction.failureData);
    updatedReportPreviewAction = cleanUpTransactionThreadReportOnyxDataForIouAction.updatedReportPreviewAction;
    }

    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:

    const iouActionList = params.transactionIDList
      .map((duplicateTransactionID) => {
        const duplicateTransaction =
          allTransactions[
            `${ONYXKEYS.COLLECTION.TRANSACTION}${duplicateTransactionID}`
          ];
        return getIOUActionForReportID(
          duplicateTransaction?.reportID ?? params.reportID,
          duplicateTransactionID,
        );
      })
      .filter(
        (
          action,
        ): action is OnyxTypes.ReportAction<typeof CONST.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.

  7. lorretheboy commented on Feb 16, 2026

    @lorretheboy
    Contributor

    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.

    const iouActionList = params.reportID ? getIOUActionForTransactions(params.transactionIDList, params.reportID) : [];

    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.

  8. github-actions commented on Feb 16, 2026

    @github-actions
    Contributor

    ⚠️ @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.

  9. annaweber830 commented on Feb 16, 2026

    @annaweber830
    Contributor

    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 with getIOUActionForTransactions(params.transactionIDList, params.reportID), which only searches report actions inside a single report (params.reportID, the kept expense's report).

    function getIOUActionForTransactions(transactionIDList: Array<string | undefined>, iouReportID: string | undefined): Array<OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU>> {
    const allReportActions = getAllReportActionsFromIOU();
    return Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`] ?? {})?.filter(
    (reportAction): reportAction is OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU> => {
    if (!isMoneyRequestAction(reportAction)) {
    return false;
    }
    const message = getOriginalMessage(reportAction);
    if (!message?.IOUTransactionID) {
    return false;
    }
    return transactionIDList.includes(message.IOUTransactionID);
    },
    );
    }

    That list is then used to build both orderedTransactionIDList and reportActionIDList for RESOLVE_DUPLICATES.

    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 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}`,
    value: {
    comment: {
    hold: createdReportAction.reportActionID,
    },
    },
    });
    failureHoldTransactionActions.push({
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
    value: {
    comment: {
    hold: null,
    },
    },
    });
    optimisticHoldActions.push({
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
    value: {
    [createdReportAction.reportActionID]: createdReportAction,
    },
    });
    failureHoldActions.push({
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
    value: {
    [createdReportAction.reportActionID]: {
    errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericHoldExpenseFailureMessage'),
    },
    },
    });
    }
    const transactionThreadReportID = params.reportID ? getIOUActionForTransactions([params.transactionID], params.reportID).at(0)?.childReportID : undefined;
    const optimisticReportAction = buildOptimisticDismissedViolationReportAction({
    reason: 'manual',
    violationName: CONST.VIOLATIONS.DUPLICATED_TRANSACTION,
    });
    const optimisticReportActionData: OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS> = {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
    value: {
    [optimisticReportAction.reportActionID]: optimisticReportAction,
    },
    };
    const failureReportActionData: OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS> = {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
    value: {
    [optimisticReportAction.reportActionID]: null,
    },
    };
    const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [];
    const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [];
    optimisticData.push(optimisticTransactionData, ...optimisticTransactionViolations, ...optimisticHoldActions, ...optimisticHoldTransactionActions, optimisticReportActionData);
    failureData.push(failureTransactionData, ...failureTransactionViolations, ...failureHoldActions, ...failureHoldTransactionActions, failureReportActionData);
    const {reportID, transactionIDList, receiptID, ...otherParams} = params;
    const parameters: ResolveDuplicatesParams = {
    ...otherParams,
    transactionID: params.transactionID,
    reportActionIDList,
    transactionIDList: orderedTransactionIDList,
    dismissedViolationReportActionID: optimisticReportAction.reportActionID,

    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.

    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 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}`,
    value: {
    comment: {
    hold: createdReportAction.reportActionID,
    },
    },
    });
    failureHoldTransactionActions.push({
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
    value: {
    comment: {
    hold: null,
    },
    },
    });
    optimisticHoldActions.push({
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
    value: {
    [createdReportAction.reportActionID]: createdReportAction,
    },
    });
    failureHoldActions.push({
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
    value: {
    [createdReportAction.reportActionID]: {
    errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericHoldExpenseFailureMessage'),
    },
    },
    });
    }
    const transactionThreadReportID = params.reportID ? getIOUActionForTransactions([params.transactionID], params.reportID).at(0)?.childReportID : undefined;
    const optimisticReportAction = buildOptimisticDismissedViolationReportAction({
    reason: 'manual',
    violationName: CONST.VIOLATIONS.DUPLICATED_TRANSACTION,
    });
    const optimisticReportActionData: OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS> = {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
    value: {
    [optimisticReportAction.reportActionID]: optimisticReportAction,
    },
    };
    const failureReportActionData: OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS> = {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
    value: {
    [optimisticReportAction.reportActionID]: null,
    },
    };
    const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [];
    const failureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [];
    optimisticData.push(optimisticTransactionData, ...optimisticTransactionViolations, ...optimisticHoldActions, ...optimisticHoldTransactionActions, optimisticReportActionData);
    failureData.push(failureTransactionData, ...failureTransactionViolations, ...failureHoldActions, ...failureHoldTransactionActions, failureReportActionData);
    const {reportID, transactionIDList, receiptID, ...otherParams} = params;
    const parameters: ResolveDuplicatesParams = {
    ...otherParams,
    transactionID: params.transactionID,
    reportActionIDList,
    transactionIDList: orderedTransactionIDList,
    dismissedViolationReportActionID: optimisticReportAction.reportActionID,
    };
    API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData});

    Current code:

    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 createdReportAction = buildOptimisticHoldReportAction();
        reportActionIDList.push(createdReportAction.reportActionID);
        const transactionID = isMoneyRequestAction(action) ? (getOriginalMessage(action)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID;
        // ...
    }
    
    const parameters: ResolveDuplicatesParams = {
        ...otherParams,
        transactionID: params.transactionID,
        reportActionIDList,
        transactionIDList: orderedTransactionIDList,
        dismissedViolationReportActionID: optimisticReportAction.reportActionID,
    };

    Updated code:

    const duplicateActions = params.transactionIDList
        .map((duplicateTransactionID) => {
            const duplicateTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${duplicateTransactionID}`];
            const duplicateAction = getIOUActionForReportID(duplicateTransaction?.reportID, duplicateTransactionID);
    
            if (!duplicateAction || !isMoneyRequestAction(duplicateAction)) {
                return undefined;
            }
    
            return {duplicateTransactionID, duplicateAction};
        })
        .filter(
            (entry): entry is {duplicateTransactionID: string; duplicateAction: OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU>} => !!entry,
        );
    
    const optimisticHoldActions: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [];
    const failureHoldActions: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS>> = [];
    const reportActionIDList: string[] = [];
    const orderedTransactionIDList: string[] = [];
    const optimisticHoldTransactionActions: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.TRANSACTION>> = [];
    const failureHoldTransactionActions: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.TRANSACTION>> = [];
    
    for (const {duplicateTransactionID, duplicateAction} of duplicateActions) {
        const transactionThreadReportID = duplicateAction.childReportID;
        const createdReportAction = buildOptimisticHoldReportAction();
    
        reportActionIDList.push(createdReportAction.reportActionID);
        orderedTransactionIDList.push(duplicateTransactionID);
    
        optimisticHoldTransactionActions.push({
            onyxMethod: Onyx.METHOD.MERGE,
            key: `${ONYXKEYS.COLLECTION.TRANSACTION}${duplicateTransactionID}`,
            value: {comment: {hold: createdReportAction.reportActionID}},
        });
        // ... existing optimistic/failure report action updates remain unchanged
    }
    
    const parameters: ResolveDuplicatesParams = {
        ...otherParams,
        transactionID: params.transactionID,
        reportActionIDList,
        transactionIDList: orderedTransactionIDList,
        dismissedViolationReportActionID: optimisticReportAction.reportActionID,
    };

    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.
  10. github-actions commented on Feb 16, 2026

    @github-actions
    Contributor

    ⚠️ @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.

  11. 40 remaining items

  12. melvin-bot commented on Mar 23, 2026

    @melvin-bot

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

  13. melvin-bot commented on Mar 23, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  14. QichenZhu commented on Mar 30, 2026

    @QichenZhu
    Contributor

    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:

    • A workspace with at least one admin and one employee.

    Test:

    1. [Employee] Create and submit a manual expense in the workspace chat.
    2. [Employee] Create a new empty report, add a duplicate expense, and submit it.
    3. [Admin] Navigate to the employee's workspace chat.
    4. [Admin] Open one of the two expenses.
    5. [Admin] Click the Review duplicates button.
    6. [Admin] Click Keep this one on any expense and complete the flow.
    7. [Admin] Verify the duplication violation on the kept expense is cleared and the other expense is held.

    Do we agree 👍 or 👎

    Zapier Logs Run ID: 00040eee-cae7-a9ec-b674-48324928ef74
  15. twisterdotcom commented on Mar 30, 2026

    @twisterdotcom
    Contributor

    Payment Summary:

  16. melvin-bot commented on Mar 30, 2026

    @melvin-bot

    Payment Summary

    Upwork Job

    BugZero Checklist (@twisterdotcom)

    • (if NewFeature) I have created a PR for any necessary HelpDot updates, or I confirmed no updates are necessary.
    • I have verified the correct assignees and roles are listed above and updated the necessary manual offers
    • I have verified that there are no duplicate or incorrect contracts on Upwork for this job (https://www.upwork.com/ab/applicants//hired)
    • I have verified the PR was not reverted
    • I have applied any discounts due to bugs/regressions introduced by this PR
    • I have paid out the Upwork contracts or cancelled the ones that are incorrect
    • I have verified the payment summary above is correct
  17. melvin-bot commented on Mar 30, 2026

    @melvin-bot
  18. twisterdotcom commented on Mar 31, 2026

    @twisterdotcom
    Contributor

    @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-5bff47844fb4
  19. trjExpensify commented on Mar 31, 2026

    @trjExpensify
    Contributor

    Confirming payment summary here is correct.

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

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorMelvin TriageAutomatic AI Analysis of an issue

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions