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-08-25] [$250] [Exploratory] 92500 - Expense -Deleted split moved from 1:1 chat to Self DM with negative amount after clear cache #92521
Version Number: 9.3.98-0 Reproducible in staging?: Yes Reproducible in production?: Yes If this was caught during regression testing, add the test name, ID and link from BrowserStack:#92351 Email or phone of affected tester (no customers):[email protected] Issue reported by: Applause Internal Team Bug source: Exploratory - Significant User Experience Deterioration Device used: Motorola MotoG60 / Android 12 (Hybrid app) - Windows 11 / Chrome App Component: Money Requests
Action Performed:
Prerequisite: Account has Self DM.
Prerequisite 2: Have a private room created with at least one member invited.
Open the Expensify app.
Navigate to a private room conversation.
Tap on "+" button > "Split Expense"
Enter any amount and create the split expense.
Navigate to "Account" > "Troubleshoot" > "Clear cache and restart"
Return to "Inbox" and open the individual chat where split IOU was sent.
Long tap on IOU preview and delete it.
Navigate to Self DM.
Note that split was moved to Self DM with negative amount.
Expected Result:
Split deleted from individual chat should not be moved to Self DM.
Actual Result:
Split deleted from individual chat after clearing cache, is moved to Self DM with negative amount.
Deleting a classic Split Bill from the 1:1 chat gets misrouted to the split-edit path instead of the delete path after a cache clear, because the code that tells a "bill split" apart from a newer "expense split" relies on optimistic Onyx data that isn't restored after "Clear cache and restart".
When a Split Expense is created in a room, the flow builds one parent split transaction pinned to the synthetic SPLIT_REPORT_ID (-2) carrying comment.splits, plus one child transaction per participant in each 1:1 IOU, each stamped with comment.source = 'split' and comment.originalTransactionID = <parent id> (Split.ts:1440-1460, Split.ts:1758-1774, Split.ts:1906-1914). That parent transaction — and its comment.splits — exists only in optimistic data and is not re-hydrated from the server after a cache clear.
On delete, useDeleteTransactions.ts:215-225 looks up the parent via allTransactions[originalTransactionID] (now undefined) and calls getOriginalTransactionWithSplitInfo. The only signal distinguishing a bill split from an expense split is the parent's comment.splits (TransactionUtils/index.ts:2894-2908). With the parent missing, isExpenseSplit hits its fallback and returns true for any child that has originalTransactionID + source === 'split' (TransactionUtils/index.ts:2873-2874). The classic split is therefore misclassified as an expense split and routed into updateSplitTransactions (the split re-build/restore path) instead of deleteMoneyRequest, which restores the surviving split portion onto the Self DM (where negative amounts are allowed) rather than deleting it.
Evidence & data flow
Create Split Expense in a room → parent txn on SPLIT_REPORT_ID (-2) with comment.splits; per-participant 1:1 children with comment.source='split' + comment.originalTransactionID (Split.ts:1440-1460, 1758-1774).
Clear cache & restart → server re-hydrates the real 1:1 IOU + child, but not the transient -2 parent that held comment.splits.
Delete the 1:1 preview → originalTransaction = allTransactions[originalTransactionID] is undefined (useDeleteTransactions.ts:215).
isExpenseSplit(txn, undefined) returns true via the no-parent fallback (TransactionUtils/index.ts:2873-2874) — whereas with the parent present it would return !parent.comment.splits = false (2883).
if (isExpenseSplit && originalTransactionID) sends it to the split-update branch instead of deleteMoneyRequest (useDeleteTransactions.ts:225) → the portion is restored on the Self DM with its negative sign.
Confidence: Medium-High. The classification/misroute is verified in code end-to-end. Not confirmed against production logs, and the exact final amount-sign math in the restore path wasn't line-verified — but that is downstream of the misroute, which is the actual defect.
What changes do you think we should make in order to solve the problem?
Make the bill-split vs expense-split classification not depend on the optimistic-only -2 parent transaction, so a cache clear can't flip a classic split into the expense-split path:
In isExpenseSplit, the !originalTransaction fallback (TransactionUtils/index.ts:2873-2874) should not blindly treat "child has source==='split' but parent is missing" as an expense split. A missing parent is exactly the ambiguous case — default it to the non-destructive branch (treat as a bill split / route to deleteMoneyRequest) rather than the restore path.
Better, use a durable, server-persisted signal to tell the two split types apart instead of the transient comment.splits on the -2 parent (e.g. a persisted marker on the child, or the child's own report/type), so the decision is correct even before/without the parent being hydrated.
Either way, deleting a classic split from the 1:1 chat should always go through deleteMoneyRequest and must never re-home the portion onto the Self DM.
These share the split ↔ Self-DM delete/restore machinery and may be resolved or affected by the same fix.
Next Steps for Contributor+ team: To accept:@MelvinBot implement [this](https://github.com/Expensify/App/issues/92521) to create a draft PR. To refine:@MelvinBot <your feedback> To reject: Explain why you are rejecting Melvin's proposal.
📣 @marufsharifi You have been assigned to this job!
Please apply to the Upwork job and leave a comment on the Github issue letting us know when we can expect a PR to be ready for review 🧑💻
Once you apply to this job, your Upwork ID will be stored and you will be automatically hired for future jobs!
Keep in mind: Code of Conduct | Contributing 📖
Activity
applause-bot commentedon Jun 3, 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/4b64440a329024262247648893a7eb64d3956bf21925fec104adc66b61bcddff
MelvinBot commentedon Jul 17, 2026
Proposal
What is the root cause of that problem?
Deleting a classic Split Bill from the 1:1 chat gets misrouted to the split-edit path instead of the delete path after a cache clear, because the code that tells a "bill split" apart from a newer "expense split" relies on optimistic Onyx data that isn't restored after "Clear cache and restart".
When a Split Expense is created in a room, the flow builds one parent split transaction pinned to the synthetic
SPLIT_REPORT_ID(-2) carryingcomment.splits, plus one child transaction per participant in each 1:1 IOU, each stamped withcomment.source = 'split'andcomment.originalTransactionID = <parent id>(Split.ts:1440-1460,Split.ts:1758-1774,Split.ts:1906-1914). That parent transaction — and itscomment.splits— exists only in optimistic data and is not re-hydrated from the server after a cache clear.On delete,
useDeleteTransactions.ts:215-225looks up the parent viaallTransactions[originalTransactionID](nowundefined) and callsgetOriginalTransactionWithSplitInfo. The only signal distinguishing a bill split from an expense split is the parent'scomment.splits(TransactionUtils/index.ts:2894-2908). With the parent missing,isExpenseSplithits its fallback and returnstruefor any child that hasoriginalTransactionID+source === 'split'(TransactionUtils/index.ts:2873-2874). The classic split is therefore misclassified as an expense split and routed intoupdateSplitTransactions(the split re-build/restore path) instead ofdeleteMoneyRequest, which restores the surviving split portion onto the Self DM (where negative amounts are allowed) rather than deleting it.Evidence & data flow
SPLIT_REPORT_ID(-2) withcomment.splits; per-participant 1:1 children withcomment.source='split'+comment.originalTransactionID(Split.ts:1440-1460,1758-1774).-2parent that heldcomment.splits.originalTransaction = allTransactions[originalTransactionID]isundefined(useDeleteTransactions.ts:215).isExpenseSplit(txn, undefined)returnstruevia the no-parent fallback (TransactionUtils/index.ts:2873-2874) — whereas with the parent present it would return!parent.comment.splits=false(2883).if (isExpenseSplit && originalTransactionID)sends it to the split-update branch instead ofdeleteMoneyRequest(useDeleteTransactions.ts:225) → the portion is restored on the Self DM with its negative sign.Confidence: Medium-High. The classification/misroute is verified in code end-to-end. Not confirmed against production logs, and the exact final amount-sign math in the restore path wasn't line-verified — but that is downstream of the misroute, which is the actual defect.
What changes do you think we should make in order to solve the problem?
Make the bill-split vs expense-split classification not depend on the optimistic-only
-2parent transaction, so a cache clear can't flip a classic split into the expense-split path:isExpenseSplit, the!originalTransactionfallback (TransactionUtils/index.ts:2873-2874) should not blindly treat "child hassource==='split'but parent is missing" as an expense split. A missing parent is exactly the ambiguous case — default it to the non-destructive branch (treat as a bill split / route todeleteMoneyRequest) rather than the restore path.comment.splitson the-2parent (e.g. a persisted marker on the child, or the child's own report/type), so the decision is correct even before/without the parent being hydrated.Either way, deleting a classic split from the 1:1 chat should always go through
deleteMoneyRequestand must never re-home the portion onto the Self DM.Related issues (same split / Self-DM delete area)
These share the split ↔ Self-DM delete/restore machinery and may be resolved or affected by the same fix.
Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/92521)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
melvin-bot commentedon Jul 17, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @sobitneupane (
External)melvin-bot commentedon Jul 17, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022077910326199691029
55 remaining items
sobitneupane commentedon Aug 25, 2026
@mallenexpensify @marufsharifi is the C+ of this issue. Could you please unassign me and assign it to @marufsharifi?
melvin-bot commentedon Aug 26, 2026
📣 @marufsharifi You have been assigned to this job!
Please apply to the Upwork job and leave a comment on the Github issue letting us know when we can expect a PR to be ready for review 🧑💻
Once you apply to this job, your Upwork ID will be stored and you will be automatically hired for future jobs!
Keep in mind: Code of Conduct | Contributing 📖
mallenexpensify commentedon Aug 26, 2026
Payment Summary
Contributor: @lorretheboy paid $250 via Upwork
Contributor+: @marufsharifi paid $250 via Upwork
Both, can you please accept the job below? Please reply here and tag me once you have.
https://www.upwork.com/jobs/~022092759056467726021
marufsharifi commentedon Aug 27, 2026
@mallenexpensify, I've accepted the offer. thanks.
mallenexpensify commentedon Aug 27, 2026
@marufsharifi paid, summary updated above. Thx.
@lorretheboy can you please accept the job below? Please reply here and tag me once you have.
https://www.upwork.com/jobs/~022092759056467726021
lorretheboy commentedon Aug 30, 2026
@mallenexpensify I accepted it. Thank you
mallenexpensify commentedon Aug 31, 2026
@lorretheboy paid, summary updated above. Thx.