Fix duplicate Expensify Card transactions on draft reports#93646
Conversation
|
@tylerkaraszewski Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
joekaufmanexpensify
left a comment
There was a problem hiding this comment.
Good for product 👍
|
Should we have a C+ review this? Not sure why I was assigned as first reviewer. |
Pullbear automatically assigned you. Just let me confirm this discussion before we move on. |
|
@cead22 I addressed the changes and merged main to keep updated. |
@KioCoan can you please share the onyx commands for each of the test steps so I can test this? Thanks |
const reportID = '<PASTE_REPORT_ID>';
const base = {reportID, bank: 'Expensify Card', amount: -1234, currency: 'USD', created: '2026-06-25'};Pending + posted on the same chain -> only the posted row should remain (run line 1, watch the pending appear, then line 2, watch it vanish): Onyx.merge(`transactions_dedupPending1`, {...base, transactionID: 'dedupPending1', status: 'Pending', parentTransactionID: '', merchant: 'Pending auth'});
Onyx.merge(`transactions_dedupPosted1`, {...base, transactionID: 'dedupPosted1', status: 'Posted', parentTransactionID: 'dedupPending1', merchant: 'Posted clearing'});Genuinely pending, no posted sibling (stays visible): Onyx.merge(`transactions_lonePending`, {...base, transactionID: 'lonePending', status: 'Pending', parentTransactionID: '', merchant: 'Lone pending'}); |
|
@KioCoan thanks! can you merge main, and also update the test and QA sections with the updated onyx steps? |
|
Failed TypeScript check is not related to this PR. Seems like there is a fix in review, I'll merge main again once it's approved. |
|
Checks are passing now. @cead22. |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / Safari |
|
🚧 cead22 has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/cead22 in version: 9.4.25-0 🚀
|
|
🚀 Deployed to production by https://github.com/cristipaval in version: 9.4.25-2 🚀
Bundle Size Analysis (Sentry): |
Explanation of Change
When an Expensify Card authorization settles, the backend creates a separate posted/clearing transaction (carrying
parentTransactionID= the root pending auth'stransactionID), moves the original pending auth to the hidden report (reportID = -4), and sends a real-time OnyxMERGE_COLLECTIONnull to clear the pending from clients.The problem is on a fresh load: the report-transaction query only returns
reportID > 0, so a hidden pending is simply absent from theOpenReportresponse (never sent as a null), andOpenReportwrites transactions additively viaMERGE_COLLECTIONand never reconciles stale rows. So a client that was offline or closed when the card settled (the common case, since cards settle 1-3 days later) misses the real-time null and keeps the stale pending in local Onyx with its old draftreportID, rendering it as a duplicate beside the posted row. Expensify Classic has no persistent client store, so it always reflects the current DB state, which is why it is unaffected.This PR adds a value-based deduplication at the single report-scoped aggregation chokepoint (
getAllNonDeletedTransactions). When a posted Expensify Card transaction is present, any pending card auth on the same chain (matched byparentTransactionID/transactionID) is filtered out before render. The guard is conservative: it only ever hides a pending row when a posted sibling from the same chain exists. Genuinely pending rows with no posted counterpart, non-card transactions, and splits are all left untouched. Totals are unaffected because the header reads the server-authoritativereport.total, not a client sum of the list.A backend follow-up (Auth) will purge the stale pending at the source on
OpenReport; this client-side fix is what closes the deploy blocker because it also covers offline/stale clients that never re-fetch.Fixed Issues
#93321
Tests
Pendingcard row with the draftreportID, plus aPostedcard row whoseparentTransactionIDis the pending'stransactionID).Offline tests
QA Steps
The duplicate is a client-side data condition (a stale pending row that survives in local Onyx after the card settled), so the most reliable way to reproduce it on staging is to inject the transactions via Onyx. Open any report on staging, open the JS console, and set
reportIDto the open report's ID:Posted clearingrow), not two.PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
Before (duplicate pending + posted rows):

After (single posted row):
