diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 3211a00422e8b..530e6ffdfeeb8 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -55,13 +55,14 @@ import { isPerDiemRequest as isPerDiemRequestTransactionUtils, } from '@libs/TransactionUtils'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; -import {notifyNewAction} from '@userActions/Report'; +import {notifyNewAction, setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import {removeDraftSplitTransaction, removeDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import DistanceRequestUtils from '@src/libs/DistanceRequestUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant, Split, SplitExpense} from '@src/types/onyx/IOU'; @@ -1691,6 +1692,26 @@ function updateSplitTransactions({ } function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransactionsParams) { + // Detect if this will be a reverse split that deletes the expense report. + // When splits are reduced to 1, updateSplitTransactions performs a reverse split which + // optimistically deletes the expense report if it's the last transaction. We need to + // set the navigate-back URL before the deletion to prevent the "Not Found" page. + const splitExpenses = params.transactionData?.splitExpenses ?? []; + const originalTransactionID = params.transactionData?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + const allChildTransactions = getChildTransactions(params.allTransactionsList, params.allReportsList, originalTransactionID, true); + const originalChildTransactions = allChildTransactions.filter((tx) => tx?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID); + const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); + const isReverseSplitOperation = + splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft && allChildTransactions.length === originalChildTransactions.length; + const expenseReportID = params.expenseReport?.reportID; + const isLastTransactionInReport = + isReverseSplitOperation && Object.values(params.allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID).length === 1; + const fallbackReportID = params.expenseReport?.chatReportID ?? params.expenseReport?.parentReportID; + + if (isLastTransactionInReport && fallbackReportID) { + setDeleteTransactionNavigateBackUrl(ROUTES.REPORT_WITH_ID.getRoute(fallbackReportID)); + } + updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); const isSearchPageTopmostFullScreenRoute = isSearchTopmostFullScreenRoute(); const transactionThreadReportID = params.firstIOU?.childReportID; @@ -1721,10 +1742,14 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac return; } + + // If the expense report was deleted by the reverse split, navigate to the parent chat instead + const targetReportID = isLastTransactionInReport && fallbackReportID ? fallbackReportID : (params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)); + if (getSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE)) { - setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)); + setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, targetReportID); } - Navigation.dismissModalWithReport({reportID: params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)}); + Navigation.dismissModalWithReport({reportID: targetReportID}); // After the modal is dismissed, remove the transaction thread report screen // to avoid navigating back to a report removed by the split transaction. diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 01147f28fdfff..7a1c7c25bfa80 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -31,6 +31,7 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import DateUtils from '@src/libs/DateUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {Policy, PolicyTagLists, RecentlyUsedTags, Report, ReportNameValuePairs, SearchResults} from '@src/types/onyx'; import type {Participant as IOUParticipant, SplitExpense} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails, PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; @@ -79,6 +80,7 @@ jest.mock('@src/libs/actions/Report', () => { return { ...originalModule, notifyNewAction: jest.fn(), + setDeleteTransactionNavigateBackUrl: jest.fn(), }; }); @@ -2264,6 +2266,256 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { const isDeleted = report === null || report === undefined || report?.pendingFields?.preview === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; expect(isDeleted).toBe(true); }); + + it('should set navigate-back URL and navigate to parent chat when reverse-split deletes the last transaction in expense report', async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const Navigation = jest.requireMock('@src/libs/Navigation/Navigation'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const Report = jest.requireMock('@src/libs/actions/Report'); + + // Given an expense report that is the only transaction in its parent chat, + // with an existing child transaction from a previous split (in a different report), + // so the expense report will be deleted when the reverse split happens + const chatReport: Report = { + ...createRandomReport(10, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + }; + const expenseReport: Report = { + ...createRandomReport(11, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: chatReport.reportID, + parentReportID: chatReport.reportID, + }; + const originalTransaction: Transaction = { + amount: 10000, + currency: 'USD', + transactionID: 'orig-nav-1', + reportID: expenseReport.reportID, + created: DateUtils.getDBTime(), + merchant: 'test', + }; + const childReport: Report = { + ...createRandomReport(12, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + }; + const childTransaction: Transaction = { + amount: 5000, + currency: 'USD', + transactionID: 'child-nav-1', + reportID: childReport.reportID, + created: DateUtils.getDBTime(), + merchant: 'test', + comment: { + originalTransactionID: originalTransaction.transactionID, + source: CONST.IOU.TYPE.SPLIT, + }, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${childReport.reportID}`, childReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransaction.transactionID}`, originalTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransaction.transactionID}`, childTransaction); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + const reportID1 = expenseReport.reportID; + const policyTags1 = await getPolicyTags(reportID1); + const reports1 = getTransactionAndExpenseReports(reportID1); + + // When the user reduces splits to 1 (triggering a reverse-split that will delete the expense report) + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: reportID1, + originalTransactionID: originalTransaction.transactionID, + splitExpenses: [{transactionID: childTransaction.transactionID, amount: 10000, created: DateUtils.getDBTime()}], + }, + searchContext: { + currentSearchHash: -2, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + betas: [CONST.BETAS.ALL], + policyTags: policyTags1, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports1.transactionReport, + expenseReport: reports1.expenseReport, + }); + + await waitForBatchedUpdates(); + + // Then setDeleteTransactionNavigateBackUrl should be called with the parent chat route + // because the expense report is being deleted and we need to suppress the "Not here" page + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(Report.setDeleteTransactionNavigateBackUrl).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID)); + + // Then navigation should go to the parent chat report instead of the deleted expense report + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: chatReport.reportID}); + }); + + it('should navigate to expense report normally when reverse-split is not the last transaction', async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const Navigation = jest.requireMock('@src/libs/Navigation/Navigation'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const Report = jest.requireMock('@src/libs/actions/Report'); + + // Given an expense report with two transactions (so it won't be deleted by the reverse split), + // and one of those transactions has an existing child from a previous split + const chatReport: Report = { + ...createRandomReport(20, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + }; + const expenseReport: Report = { + ...createRandomReport(21, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: chatReport.reportID, + parentReportID: chatReport.reportID, + }; + const originalTransaction: Transaction = { + amount: 10000, + currency: 'USD', + transactionID: 'orig-nav-2', + reportID: expenseReport.reportID, + created: DateUtils.getDBTime(), + merchant: 'test', + }; + const otherTransaction: Transaction = { + amount: 5000, + currency: 'USD', + transactionID: 'other-nav-2', + reportID: expenseReport.reportID, + created: DateUtils.getDBTime(), + merchant: 'other test', + }; + const childReport: Report = { + ...createRandomReport(22, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + }; + const childTransaction: Transaction = { + amount: 5000, + currency: 'USD', + transactionID: 'child-nav-2', + reportID: childReport.reportID, + created: DateUtils.getDBTime(), + merchant: 'test', + comment: { + originalTransactionID: originalTransaction.transactionID, + source: CONST.IOU.TYPE.SPLIT, + }, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${childReport.reportID}`, childReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransaction.transactionID}`, originalTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${otherTransaction.transactionID}`, otherTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransaction.transactionID}`, childTransaction); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + const reportID2 = expenseReport.reportID; + const policyTags2 = await getPolicyTags(reportID2); + const reports2 = getTransactionAndExpenseReports(reportID2); + + // When the user reduces splits to 1 (triggering a reverse-split, but the expense report still has another transaction) + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: reportID2, + originalTransactionID: originalTransaction.transactionID, + splitExpenses: [{transactionID: childTransaction.transactionID, amount: 10000, created: DateUtils.getDBTime()}], + }, + searchContext: { + currentSearchHash: -2, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + betas: [CONST.BETAS.ALL], + policyTags: policyTags2, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports2.transactionReport, + expenseReport: reports2.expenseReport, + }); + + await waitForBatchedUpdates(); + + // Then setDeleteTransactionNavigateBackUrl should not be called because the expense report + // still has other transactions and won't be deleted + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(Report.setDeleteTransactionNavigateBackUrl).not.toHaveBeenCalled(); + + // Then navigation should go to the expense report since it still exists + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: expenseReport.reportID}); + }); }); describe('updateSplitTransactionsFromSplitExpensesFlow', () => {