[$250] Reports - Split is not reverted when split expense is deleted on Reports
If you haven’t already, check out our contributing guidelines for onboarding and email [email protected] to request to join our Slack channel!
Version Number: 9.2.75-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: https://github.com/Expensify/App/pull/72086 Email or phone of affected tester (no customers): [email protected] Issue reported by: Applause Internal Team Bug source: Pull Request QA execution Device used: Mac 26.1 / Chrome App Component: Money Requests
Action Performed:
- Go to staging.new.expensify.com
- Go to workspace chat.
- Create an expense.
- Open the report.
- Click More > Split > Save.
- Open any split and delete it.
- Open the remaining split. → Split label is removed from Amount field title - Expected.
- Delete the expense.
- Create another expense.
- Go to Reports > Expense.
- Select the expense via checkbox.
- Click dropdown button > Split > Save.
- Select any split via checkbox.
- Click dropdown button > Delete > Delete it.
- Open the remaining split.
Expected Result:
Split label will be removed from Amount field title which indicates that the split is reverted.
Actual Result:
Split label is not removed from Amount field title when split is deleted on Reports. Split is not reverted.
Workaround:
Unknown
Platforms:
- [x] Android: App
- [ ] Android: mWeb Chrome
- [ ] iOS: App
- [ ] iOS: mWeb Safari
- [ ] iOS: mWeb Chrome
- [ ] Windows: Chrome
- [x] MacOS: Chrome / Safari
- [ ] MacOS: Desktop
Screenshots/Videos
https://github.com/user-attachments/assets/f2d87643-5525-46b7-b136-45efcb337cf8
Upwork Automation - Do Not Edit
- Upwork Job URL: https://www.upwork.com/jobs/~022001452262676015929
- Upwork Job ID: 2001452262676015929
- Last Price Increase: 2025-12-25
Issue Owner
Current Issue Owner: @situchan
Triggered auto assignment to @lydiabarclay (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.
Proposal
Please re-state the problem that we are trying to solve in this issue.
Split label will be removed from Amount field title which indicates that the split is reverted.
What is the root cause of that problem?
The root cause is that the deleteMoneyRequestOnSearch function in src/libs/actions/Search.ts does not detect or handle split transactions.
When a user deletes a split expense from the Reports > Expense list view:
- The function receives the transaction IDs to delete
- It directly calls the
DELETE_MONEY_REQUEST_ON_SEARCHAPI without checking if the transaction is part of a split - It does not call
updateSplitTransactionsto handle split reversion or updates - As a result, the split structure remains intact even when only one split remains
In contrast, when deleting from the report detail view, work like below:
- Detects split transactions
- Groups them by original transaction ID
- Calls
updateSplitTransactionswhen appropriate - Triggers the
REVERT_SPLIT_TRANSACTIONAPI when only one split remains
The search deletion path lacks this split detection and handling logic entirely. https://github.com/Expensify/App/blob/8cd91dce98b21ad3d8d537ed453e051437fa7dc8/src/pages/Search/SearchPage.tsx#L808-L811 https://github.com/Expensify/App/blob/8cd91dce98b21ad3d8d537ed453e051437fa7dc8/src/libs/actions/Search.ts#L700-L735
What changes do you think we should make in order to solve the problem?
We should enhance the deleteMoneyRequestOnSearch function to detect and handle split transactions The changes should be limited to:
https://github.com/Expensify/App/blob/8cd91dce98b21ad3d8d537ed453e051437fa7dc8/src/pages/Search/SearchPage.tsx#L807-L811
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.runAfterInteractions(() => {
deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys, allTransactions, allReports, allReportActions, activePolicy, currentUserPersonalDetails, false);
clearSelectedTransactions();
});
https://github.com/Expensify/App/blob/8cd91dce98b21ad3d8d537ed453e051437fa7dc8/src/libs/actions/Search.ts#L700-L736
function deleteMoneyRequestOnSearch(
hash: number,
transactionIDList: string[],
allTransactions?: OnyxCollection<Transaction>,
allReports?: OnyxCollection<Report>,
allReportActions?: OnyxCollection<ReportActions>,
policy?: OnyxEntry<Policy>,
currentUserPersonalDetails?: {accountID: number; login: string},
isASAPSubmitBetaEnabled?: boolean,
) {
const {optimisticData: loadingOptimisticData, finallyData} = getOnyxLoadingData(hash);
// Group transactions by original transaction ID to handle splits
const splitTransactionsByOriginalTransactionID: Record<string, string[]> = {};
const nonSplitTransactionIDs: string[] = [];
if (allTransactions && allReports) {
for (const transactionID of transactionIDList) {
const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
if (!transaction) {
nonSplitTransactionIDs.push(transactionID);
continue;
}
const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.comment?.originalTransactionID}`];
const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction);
const originalTransactionID = transaction?.comment?.originalTransactionID;
if (isExpenseSplit && originalTransactionID) {
splitTransactionsByOriginalTransactionID[originalTransactionID] ??= [];
splitTransactionsByOriginalTransactionID[originalTransactionID].push(transactionID);
} else {
nonSplitTransactionIDs.push(transactionID);
}
}
// Handle split transactions
for (const [originalTransactionID, splitIDs] of Object.entries(splitTransactionsByOriginalTransactionID)) {
const splitIDsSet = new Set(splitIDs);
const childTransactions = getChildTransactions(allTransactions, allReports, originalTransactionID).filter(
(transaction) => !splitIDsSet.has(transaction?.transactionID ?? String(CONST.DEFAULT_NUMBER_ID)),
);
// If no child transactions remain, treat as non-split deletion
if (childTransactions.length === 0) {
nonSplitTransactionIDs.push(...splitIDs);
continue;
}
// If only one split remains, revert the split
if (childTransactions.length === 1) {
const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`];
// Try to get IOU report ID from child transaction first
let iouReportIDForSearch: string | undefined;
if (childTransactions.length > 0) {
const firstChildTransaction = childTransactions[0];
const childTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${firstChildTransaction?.reportID}`];
iouReportIDForSearch = childTransactionReport?.parentReportID;
}
const originalTransactionIouActions = getIOUActionForTransactions([originalTransactionID], iouReportIDForSearch);
const iouReportID = isMoneyRequestAction(originalTransactionIouActions.at(0)) ? getOriginalMessage(originalTransactionIouActions.at(0))?.IOUReportID : undefined;
const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`];
const expenseReportID = iouReportID ?? String(CONST.DEFAULT_NUMBER_ID);
const allReportNameValuePairs: OnyxCollection<ReportNameValuePairs> = {};
const policyCategories: OnyxEntry<PolicyCategories> = undefined;
const policyRecentlyUsedCategories: OnyxEntry<PolicyRecentlyUsedCategories> = undefined;
const transactionViolations: OnyxCollection<TransactionViolations> = {};
updateSplitTransactions({
allTransactionsList: allTransactions,
allReportsList: allReports,
allReportNameValuePairsList: allReportNameValuePairs,
transactionData: {
reportID: expenseReportID,
originalTransactionID,
splitExpenses: childTransactions.map((childTransaction) => initSplitExpenseItemData(childTransaction)),
},
searchContext: {
currentSearchHash: hash,
},
policyCategories,
policy,
policyRecentlyUsedCategories,
iouReport,
firstIOU: originalTransactionIouActions.at(0),
isASAPSubmitBetaEnabled: isASAPSubmitBetaEnabled ?? false,
currentUserPersonalDetails: currentUserPersonalDetails ?? {accountID: 0, login: ''},
transactionViolations,
});
continue;
}
// If multiple splits remain, update the splits
const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`];
let iouReportIDForSearch: string | undefined;
if (childTransactions.length > 0) {
const firstChildTransaction = childTransactions[0];
const childTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${firstChildTransaction?.reportID}`];
iouReportIDForSearch = childTransactionReport?.parentReportID;
}
const originalTransactionIouActions = getIOUActionForTransactions([originalTransactionID], iouReportIDForSearch);
const iouReportID = isMoneyRequestAction(originalTransactionIouActions.at(0)) ? getOriginalMessage(originalTransactionIouActions.at(0))?.IOUReportID : undefined;
const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`];
const expenseReportID = iouReportID ?? String(CONST.DEFAULT_NUMBER_ID);
const allReportNameValuePairs: OnyxCollection<ReportNameValuePairs> = {};
const policyCategories: OnyxEntry<PolicyCategories> = undefined;
const policyRecentlyUsedCategories: OnyxEntry<PolicyRecentlyUsedCategories> = undefined;
const transactionViolations: OnyxCollection<TransactionViolations> = {};
updateSplitTransactions({
allTransactionsList: allTransactions,
allReportsList: allReports,
allReportNameValuePairsList: allReportNameValuePairs,
transactionData: {
reportID: expenseReportID,
originalTransactionID,
splitExpenses: childTransactions.map((childTransaction) => initSplitExpenseItemData(childTransaction)),
},
searchContext: {
currentSearchHash: hash,
},
policyCategories,
policy,
policyRecentlyUsedCategories,
iouReport,
firstIOU: originalTransactionIouActions.at(0),
isASAPSubmitBetaEnabled: isASAPSubmitBetaEnabled ?? false,
currentUserPersonalDetails: currentUserPersonalDetails ?? {accountID: 0, login: ''},
transactionViolations,
});
}
} else {
// Fallback to original behavior if collections not provided
nonSplitTransactionIDs.push(...transactionIDList);
}
// Delete non-split transactions
for (const transactionID of nonSplitTransactionIDs) {
// @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const optimisticData: OnyxUpdate[] = [
...loadingOptimisticData,
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
value: {
data: {
[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE},
},
},
},
];
const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
value: {
data: {
// @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {pendingAction: null},
},
},
},
];
API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList: [transactionID]}, {optimisticData, failureData, finallyData});
}
}
What alternative solutions did you explore? (Optional)
Will review asap
Job added to Upwork: https://www.upwork.com/jobs/~022001452262676015929
Triggered auto assignment to Contributor-plus team member for initial proposal review - @situchan (External)
Proposal
Please re-state the problem that we are trying to solve in this issue.
Split label is not removed from Amount field title when split is deleted on Reports. Split is not reverted.
What is the root cause of that problem?
deleteMoneyRequestOnSearch delete the transaction instead of revert the split expense
https://github.com/Expensify/App/blob/c43ba15505f538fd4604a1218e07f3692a045f77/src/hooks/useDeleteTransactions.ts#L28-L100
https://github.com/Expensify/App/blob/c43ba15505f538fd4604a1218e07f3692a045f77/src/pages/Search/SearchPage.tsx#L792
What changes do you think we should make in order to solve the problem?
-
For the split expense, we should create a new API endpoint like
RevertSplitTransactionsOnSearchto revert the split when clicking on the delete option on search -
We can create a hook to handle delete transaction on search
function useSearchDeleteTransactions() {
const {currentSearchHash} = useSearchContext();
const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false});
const deleteTransactions = useCallback((transactionIDs: string[]) => {
const transactions = transactionIDs.map((transactionID) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]);
const {splitTransactionIDsByOriginalTransactionID, nonSplitTransactionsIDs} = transactions.reduce(
(acc, transaction) => {
const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.comment?.originalTransactionID}`];
const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction);
const originalTransactionID = transaction?.comment?.originalTransactionID;
if (isExpenseSplit && originalTransactionID) {
acc.splitTransactionIDsByOriginalTransactionID[originalTransactionID] ??= [];
acc.splitTransactionIDsByOriginalTransactionID[originalTransactionID].push(transaction.transactionID);
} else {
acc.nonSplitTransactionsIDs.push(transaction?.transactionID ?? '');
}
return acc;
},
{splitTransactionIDsByOriginalTransactionID: {}, nonSplitTransactionsIDs: []} as {
splitTransactionIDsByOriginalTransactionID: Record<string, string[]>;
nonSplitTransactionsIDs: string[];
},
);
//call the logic to revert the split on search with new API endpoit based on `splitTransactionIDsByOriginalTransactionID` data
// revertSplitTransactionsOnSearch(currentSearchHash, splitTransactionIDsByOriginalTransactionID);
deleteMoneyRequestOnSearch(currentSearchHash, nonSplitTransactionsIDs);
}, [currentSearchHash, allTransactions]);
return {
deleteTransactions,
};
}
- Use this hook to handle the delete action here
const {deleteTransactions} = useSearchDeleteTransactions();
deleteTransactions(selectedTransactionsKeys);
https://github.com/Expensify/App/blob/c43ba15505f538fd4604a1218e07f3692a045f77/src/pages/Search/SearchPage.tsx#L792
What alternative solutions did you explore? (Optional)
If we don't support revert split on bulk action, we can hide the delete option if selectedTransactionsKeys contain the expense split transaction.
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.
@situchan Eep! 4 days overdue now. Issues have feelings too...
📣 It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? 💸
@situchan @lydiabarclay this issue was created 2 weeks ago. Are we close to approving a proposal? If not, what's blocking us from getting this issue assigned? Don't hesitate to create a thread in #expensify-open-source to align faster in real time. Thanks!
@situchan Still overdue 6 days?! Let's take care of this!
@situchan 12 days overdue. Walking. Toward. The. Light...
📣 It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? 💸