Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions src/libs/actions/IOU/Split.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
252 changes: 252 additions & 0 deletions tests/actions/IOUTest/SplitTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -79,6 +80,7 @@ jest.mock('@src/libs/actions/Report', () => {
return {
...originalModule,
notifyNewAction: jest.fn(),
setDeleteTransactionNavigateBackUrl: jest.fn(),
};
});

Expand Down Expand Up @@ -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<Transaction>;
let allReports: OnyxCollection<Report>;
let allReportNameValuePairs: OnyxCollection<ReportNameValuePairs>;
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<Transaction>;
let allReports: OnyxCollection<Report>;
let allReportNameValuePairs: OnyxCollection<ReportNameValuePairs>;
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', () => {
Expand Down
Loading