Skip to content

[Due for payment 2026-08-07] [$250] "Potential duplicate" shown on second expense online after removing first offline #94533

Closed

Description

@applause-bot

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: 9.4.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: #91196
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: MacBook Air 26.5 Chrome, iPhone 15 iOS 26.5
App Component: Money Requests

Action Performed:

  1. Sign in to ND with a new account, selecting "Manage" purpose
  2. Create two expenses with the same amount, merchant, category, and date
  3. Navigate to Spend/ Expenses
  4. Click on the first expense, click on the Report field, select Remove from report - violations disappear
  5. Repeat step 4, select the existing report - violations are displayed
  6. Go offline, remove the first expense from the report again - violations disappear
  7. Go online

Expected Result:

"Potential duplicate" violation is not displayed on the second expense, as the first one was removed from the report

Actual Result:

"Potential duplicate" violation appears on the second expense online, when the first one was removed from the report offline

Workaround:

Unknown

Platforms:

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

Screenshots/Videos

Bug7190393_1782344908144.remove_from_report_offline_1.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Rizky Gunawan
Bug ID: 7190393

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Jun 25, 2026
  2. applause-bot commented on Jun 25, 2026

    @applause-bot
    Author

    While Applause doesn't embed attachments for this bug, here are the links for quick view:
    https://platform.applause.com/services/links/v1/external/2083cbb1b26b22f7c1f588073aa7bf6c15b495613041b1dca7f7854e89205989

  3. MelvinBot commented on Jun 25, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    When you remove an expense from a report, the "remove from report" action routes through changeTransactionsReport with the destination set to the unreported self-DM, so the isUnreported branch runs. That branch optimistically strips the DUPLICATED_TRANSACTION violation from each sibling duplicate — but it only pushes that change to optimisticData, never to successData. See Transaction.ts:1148-1161.

    Because there is no successData re-apply for the siblings, when the app comes back online a queued/concurrent OpenReport (or the command's own server response) re-pushes the stale one-sided duplicate violation onto the remaining expense, and nothing re-asserts the cleaned value — so "Potential duplicate" reappears.

    This is a known race: the dedicated helper removeTransactionFromDuplicateTransactionViolation (used by delete/edit flows) handles it correctly by also pushing the cleaned value to successData, with an inline comment describing this exact failure mode at TransactionUtils/index.ts:2429-2436. The changeTransactionsReport path hand-rolls its own sibling clearing and omits that step.

    Why it only repros offline → online (and not in the plain online flow)

    Online, the optimistic clear and the server response land almost together, so the visible flicker is brief or absent. Offline, the optimistic clear persists while the request sits queued; on reconnect the CHANGE_TRANSACTIONS_REPORT request flushes and a sibling OpenReport response (which still carries the pre-move violation) overwrites the unprotected optimistic clear. The moved transaction itself is unaffected because it does get matching successData/failureData (Transaction.ts:1162-1178) — only the siblings are left unprotected.

    Note: the App does not compute duplicates itself; the set comes from the backend violation.data.duplicates list. This proposal assumes the server already clears the sibling's duplicate violation after the move and the only gap is the client overwrite. If production logs show the backend still re-emits DUPLICATED_TRANSACTION on the surviving expense post-move, a backend fix would also be needed.

    What changes do you think we should make in order to solve the problem?

    In the isUnreported branch of changeTransactionsReport, give the sibling violation updates the same lifecycle protection the moved transaction already has: for each sibling, push the cleaned violations value to successData (so it survives the online sync) and push the original violations to failureData (so it restores on error). The cleanest approach is to reuse the existing removeTransactionFromDuplicateTransactionViolation helper for the siblings instead of the inline optimisticData-only loop, since it already implements the correct three-way optimistic/success/failure pattern and trims data.duplicates precisely.

    What alternative solutions did you explore? (Optional)

    Adding only successData without failureData would fix the reported symptom but would leave siblings inconsistent if the move fails offline — reusing the helper covers both.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/94533) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Jun 25, 2026
  5. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on Jun 25, 2026
  6. lorretheboy commented on Jun 25, 2026

    @lorretheboy
    Contributor

    Proposal

    What is the root cause of that problem?

    When a transaction is moved off a report (isUnreported), changeTransactionsReport clears the duplicate violation on the other member of the duplicate pair (the "sibling"). But that sibling clear is pushed to optimisticData only — unlike the moved transaction itself, which also gets matching successData and failureData:

    // Optimistically clear all violations for the transaction when moving to self DM report
    if (isUnreported) {
    const duplicateTransactionIDs = currentTransactionViolations[transaction.transactionID]?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data
    ?.duplicates;
    if (duplicateTransactionIDs) {
    for (const id of duplicateTransactionIDs) {
    const siblingViolations = allTransactionViolation?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? [];
    optimisticData.push({
    onyxMethod: Onyx.METHOD.SET,
    key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`,
    value: siblingViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION),
    });
    }
    }
    optimisticData.push({
    onyxMethod: Onyx.METHOD.SET,
    key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`,
    value: null,
    });
    successData.push({
    onyxMethod: Onyx.METHOD.SET,
    key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`,
    value: null,
    });
    failureData.push({
    onyxMethod: Onyx.METHOD.SET,
    key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`,
    value: currentTransactionViolations[transaction.transactionID],
    });
    }

    Online (step 4) this is fine: the optimistic clear is never disturbed, and the server processes the removal immediately. Offline → online is different. While the write sits in the persisted queue, reconnecting triggers a fresh data pull from the server, where the sibling still carries the DUPLICATED_TRANSACTION violation (the queued removal hasn't run yet). That pull re-merges the stale violation onto the sibling, clobbering the optimistic-only clear. Because there is no successData entry for the sibling, nothing re-clears it once the queued request finally resolves — so the "Potential duplicate" violation reappears on the second expense.

    In short: sibling violation clear is optimistic-only → offline reconnect refetch reapplies the server's stale duplicate violation → no success-data clear to override it → violation comes back.

    What changes do you think we should make in order to solve the problem?

    Make the sibling clear authoritative, the same way the moved transaction's clear already is. Inside the sibling loop here:

    if (duplicateTransactionIDs) {
    for (const id of duplicateTransactionIDs) {
    const siblingViolations = allTransactionViolation?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? [];
    optimisticData.push({
    onyxMethod: Onyx.METHOD.SET,
    key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`,
    value: siblingViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION),
    });
    }
    }

    in addition to the optimisticData push, also push a matching successData entry that sets the sibling's ${TRANSACTION_VIOLATIONS}${id} to siblingViolations with the DUPLICATED_TRANSACTION violation filtered out, and a failureData entry restoring siblingViolations so an error rolls the sibling back. This mirrors what the moved transaction already does and guarantees the clear is reapplied after the request resolves, surviving any reconnect refetch.

    What alternative solutions did you explore? (Optional)

    Keep only optimistic data but rebuild the sibling's failureData/restore from allTransactionViolation — but without successData the clear is still not reasserted after a reconnect refetch, so this would not fix the offline case. Mirroring success + failure for the sibling is the minimal correct change.

    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.

  7. aswin-s commented on Jun 25, 2026

    @aswin-s
    Contributor

    Please re-state the problem that we are trying to solve in this issue.

    When a user removes the first of two duplicate expenses from a report while offline and then comes back online, the "Potential duplicate" violation reappears on the second expense even though the first expense was successfully removed from the report. The expected behavior is that the violation on the second expense should stay cleared once the first expense is removed.

    What is the root cause of that problem?

    In src/libs/actions/Transaction.ts, inside changeTransactionsReport, the block at lines 1148–1178 optimistically clears the DUPLICATED_TRANSACTION violation from sibling transactions (lines 1152–1160) when moving a transaction to the unreported state (isUnreported). However, the corresponding failureData entries (lines 1174–1178) only restore the removed transaction's own violations — they do not restore the sibling transactions' violations that were cleared in the optimistic update.

    The same gap exists for successData: when the API call eventually succeeds, there is no successData entry for the sibling transactions either. This means that if a queued command (such as OpenReport resolving between the optimistic apply and the server response) re-introduces a one-sided duplicate violation on the sibling, there is nothing to re-apply the cleaned state on success. The server then re-derives duplicate violations from scratch and re-attaches them to the sibling, overwriting the now-stale optimistic clear.

    The fix lives in the same isUnreported block: both the successData and failureData arrays must include a SET entry for each siblingID, mirroring the optimistic clear pushed at lines 1155–1159.

    What changes do you think we should make in order to solve the problem?

    1. src/libs/actions/Transaction.ts (lines 1152–1160) — After pushing the optimistic clear for each sibling, also push matching successData and failureData entries for that sibling:
    // Inside the isUnreported block, after the optimisticData push for each sibling:
    successData.push({
        onyxMethod: Onyx.METHOD.SET,
        key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${siblingID}`,
        value: siblingViolationsWithoutDuplicate, // the same cleaned value used in optimisticData
    });
    
    failureData.push({
        onyxMethod: Onyx.METHOD.SET,
        key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${siblingID}`,
        value: siblingViolations, // the original violations before the optimistic clear
    });

    The successData entry ensures that even if a stale server push re-introduces a one-sided violation between the optimistic apply and the API response, the final committed state reflects the cleared violation. The failureData entry restores the sibling's original violations if the API call fails.

    1. src/libs/actions/Transaction.ts (lines 1168–1172) — Similarly confirm that any existing successData entry for the removed transaction's own violations is also present; the sibling entries above must be added alongside it, not replace it.

    2. Confirm that useUndeleteTransactions (which also calls changeTransactionsReport) passes through the full transactions array — the PR Remove Onyx.connect() usage for ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS as allTransactionViolations from src/libs/actions/IOU/Transaction.ts #91196 diff already changed the parameter from allTransactions: OnyxCollection to transactions: Transaction[], so verify that the sibling IDs are still resolvable from allTransactionViolation at line 1154 without depending on the removed allTransactions collection.

    What alternative solutions did you explore? (Optional)

    Calling removeTransactionFromDuplicateTransactionViolation (used in DeleteMoneyRequest.ts:738) inside changeTransactionsReport for the isUnreported path would also clear sibling violations symmetrically, but adding successData and failureData entries in the existing inline block is a smaller, more targeted change that avoids introducing a dependency between two separate action modules.

  8. melvin-bot commented on Jun 25, 2026

    @melvin-bot

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

  9. sumairq commented on Jun 25, 2026

    @sumairq

    🚫 Duplicated proposal withdrawn by 🤖 ProposalPolice.

  10. melvin-bot commented on Jun 25, 2026

    @melvin-bot
  11. changed the title [-]"Potential duplicate" shown on second expense online after removing first offline[/-] [+][$250] "Potential duplicate" shown on second expense online after removing first offline[/+] on Jun 25, 2026
  12. MobileMage commented on Jun 25, 2026

    @MobileMage
    Contributor

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    When two expenses are duplicates of each other (both carry the duplicatedTransaction violation referencing the other) and the user removes the first expense from its report while offline, the "Potential duplicate" violation disappears optimistically on the second expense as expected. But the moment the device goes back online, the violation reappears on the second expense even though its only duplicate is gone. The optimistic clear is not durable across the online reconciliation.

    What is the root cause of that problem?

    The "Remove from report" action calls removeFromReport in useReportSelectionActions, which invokes changeTransactionsReport with no newReport, so reportID resolves to CONST.REPORT.UNREPORTED_REPORT_ID and isUnreported is true. Inside that branch, the action tries to clear the duplicate violation not only on the moved transaction but also on its siblings (the other transactions that listed the moved one in their data.duplicates). The moved transaction (A) gets its violations wiped across optimisticData, successData, and failureData, but the sibling clear (transaction B) is only written to optimisticData — there is no successData and no failureData for the sibling:

    const duplicateTransactionIDs = currentTransactionViolations[transaction.transactionID]?.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)?.data
    ?.duplicates;
    if (duplicateTransactionIDs) {
    for (const id of duplicateTransactionIDs) {
    const siblingViolations = allTransactionViolation?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? [];
    optimisticData.push({
    onyxMethod: Onyx.METHOD.SET,
    key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`,
    value: siblingViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION),
    });
    }
    }

    Because the sibling clear lives only in optimisticData, it is fragile. While offline, the request sits in the queue and B looks fine. When the device reconnects, the queued CHANGE_TRANSACTIONS_REPORT is flushed alongside other queued/refreshing commands (an OpenReport/Search refresh resolving between the optimistic apply and the server response). Those responses re-push B's TRANSACTION_VIOLATIONS entry from the server — which still contains the stale one-sided duplicatedTransaction violation, since the CHANGE_TRANSACTIONS_REPORT parameters never tell the backend which siblings to re-evaluate (the params only carry transactionList, reportID, and the report-action/thread map):

    const parameters: ChangeTransactionsReportParams = {
    transactionList: transactionIDs.join(','),
    reportID,
    transactionIDToReportActionAndThreadData: JSON.stringify(transactionIDToReportActionAndThreadData),
    ...(Object.keys(transactionIDToUpdatedCustomUnitRateID).length > 0 && {
    transactionIDToUpdatedCustomUnitRateID: JSON.stringify(transactionIDToUpdatedCustomUnitRateID),
    }),
    };

    With no successData to re-apply the cleaned violation list after the server response lands, B's violation is resurrected. This is exactly the failure mode the codebase already documented and solved elsewhere: the shared helper removeTransactionFromDuplicateTransactionViolation in TransactionUtils writes the cleaned sibling list to optimisticData, successData, AND failureData, with a comment stating the successData exists precisely "so that stale data from a queued command (e.g. OpenReport resolving between optimistic apply and the server response) doesn't re-introduce one-sided duplicate violations." The inline loop in changeTransactionsReport is a hand-rolled version of that helper that simply forgot the successData (and failureData) leg.

    What changes do you think we should make in order to solve the problem?

    In the sibling-clear loop of changeTransactionsReport, mirror the established pattern from removeTransactionFromDuplicateTransactionViolation: push the same cleaned sibling value to successData (so the cleared state survives the online reconciliation) and to failureData (restoring the original siblingViolations if the move fails). The cleaned value is already computed for the optimistic write, so we just reuse it:

    const cleanedSiblingViolations = siblingViolations.filter((v) => v.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION);
    optimisticData.push({onyxMethod: Onyx.METHOD.SET, key, value: cleanedSiblingViolations});
    successData.push({onyxMethod: Onyx.METHOD.SET, key, value: cleanedSiblingViolations});
    failureData.push({onyxMethod: Onyx.METHOD.SET, key, value: siblingViolations});

    This is safe and narrowly scoped: it only affects transactions that already appeared in the moved transaction's data.duplicates, and only the duplicatedTransaction violation is stripped (all other sibling violations are preserved by the existing filter). Adding failureData actually closes a second latent bug — today, if the move fails, the sibling's violation is never restored. Ideally we would reuse removeTransactionFromDuplicateTransactionViolation directly since it also reduces (rather than fully strips) the sibling list when the sibling has more than one duplicate, but that helper needs the full OnyxCollection<Transaction> for getValidDuplicateTransactionIDs, which changeTransactionsReport is not currently passed; matching the helper's three-leg write is the minimal correct fix without widening the function signature.

    What alternative solutions did you explore? (Optional)

    Threading the full transaction collection into changeTransactionsReport so it can delegate entirely to removeTransactionFromDuplicateTransactionViolation (gaining its multi-duplicate "reduce instead of strip" handling), but that expands the prop signature and every caller for a scenario the inline loop already covers — adding the missing successData/failureData legs fixes the reported bug with far less surface area.

  13. 68 remaining items

  14. melvin-bot commented on Aug 6, 2026

    @melvin-bot

    Triggered auto assignment to @mallenexpensify (Awaiting Payment)

  15. melvin-bot commented on Aug 6, 2026

    @melvin-bot

    Payment Summary

    Resolving PRs:

    Upwork Job

    BugZero Checklist (@mallenexpensify)

    • I have confirmed assignees, roles, and Upwork contracts look correct
    • I have paid out Upwork contracts / manual NewDot requests
  16. x-dev90 commented on Aug 10, 2026

    @x-dev90
    Contributor

    @mallenexpensify, could you please help with the payment for this issue. thanks.

  17. mallenexpensify commented on Aug 11, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor: @x-dev90 paid $250 via Upwork
    Contributor+: @nyomanjyotisa due $250 via NewDot

    @x-dev90 can you please accept the job below? Please reply here and tag me once you have.
    If you're not sent an offer in Upwork when you're assigned to an issue, please tag me. If an Upwork job exists, please submit a proposal.
    https://www.upwork.com/jobs/~022086977015188086070

  18. x-dev90 commented on Aug 11, 2026

    @x-dev90
    Contributor

    @mallenexpensify, The offer accepted. thanks.

  19. mallenexpensify commented on Aug 12, 2026

    @mallenexpensify
    Contributor

    @x-dev90 paid, summary updated above. Thx.

  20. nyomanjyotisa commented on Oct 19, 2026

    @nyomanjyotisa
    Member

    @mallenexpensify I think I missed requesting this $250 payment. Can I create the NewDot expense now?

  21. mallenexpensify commented on Oct 19, 2026

    @mallenexpensify
    Contributor

    @nyomanjyotisa yes, if you haven't been paid, request payment now.
    In the future, try to request payment in the same month as the job was finished (or as soon after as possible)

  22. trjExpensify commented on Oct 19, 2026

    @trjExpensify
    Contributor

    approved $250 for @nyomanjyotisa

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 contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions