App icon indicating copy to clipboard operation
App copied to clipboard

[$250] Reports - Split is not reverted when split expense is deleted on Reports

Open nlemma opened this issue 4 weeks ago • 9 comments

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:

  1. Go to staging.new.expensify.com
  2. Go to workspace chat.
  3. Create an expense.
  4. Open the report.
  5. Click More > Split > Save.
  6. Open any split and delete it.
  7. Open the remaining split. → Split label is removed from Amount field title - Expected.
  8. Delete the expense.
  9. Create another expense.
  10. Go to Reports > Expense.
  11. Select the expense via checkbox.
  12. Click dropdown button > Split > Save.
  13. Select any split via checkbox.
  14. Click dropdown button > Delete > Delete it.
  15. 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

View all open jobs on GitHub

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 OwnerCurrent Issue Owner: @situchan

nlemma avatar Dec 11 '25 15:12 nlemma

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.

melvin-bot[bot] avatar Dec 11 '25 15:12 melvin-bot[bot]

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:

  1. The function receives the transaction IDs to delete
  2. It directly calls the DELETE_MONEY_REQUEST_ON_SEARCH API without checking if the transaction is part of a split
  3. It does not call updateSplitTransactions to handle split reversion or updates
  4. 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 updateSplitTransactions when appropriate
  • Triggers the REVERT_SPLIT_TRANSACTION API 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)

annaweber830 avatar Dec 14 '25 21:12 annaweber830

Will review asap

lydiabarclay avatar Dec 16 '25 00:12 lydiabarclay

Job added to Upwork: https://www.upwork.com/jobs/~022001452262676015929

melvin-bot[bot] avatar Dec 18 '25 00:12 melvin-bot[bot]

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

melvin-bot[bot] avatar Dec 18 '25 00:12 melvin-bot[bot]

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?

  1. For the split expense, we should create a new API endpoint like RevertSplitTransactionsOnSearch to revert the split when clicking on the delete option on search

  2. 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,
    };
}
  1. 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.

nkdengineer avatar Dec 18 '25 08:12 nkdengineer

@situchan Eep! 4 days overdue now. Issues have feelings too...

melvin-bot[bot] avatar Dec 24 '25 00:12 melvin-bot[bot]

📣 It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? 💸

melvin-bot[bot] avatar Dec 25 '25 16:12 melvin-bot[bot]

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

melvin-bot[bot] avatar Dec 25 '25 21:12 melvin-bot[bot]

@situchan Still overdue 6 days?! Let's take care of this!

melvin-bot[bot] avatar Dec 26 '25 00:12 melvin-bot[bot]

@situchan 12 days overdue. Walking. Toward. The. Light...

melvin-bot[bot] avatar Jan 01 '26 00:01 melvin-bot[bot]

📣 It's been a week! Do we have any satisfactory proposals yet? Do we need to adjust the bounty for this issue? 💸

melvin-bot[bot] avatar Jan 01 '26 16:01 melvin-bot[bot]