From 786daddfcd5b0bca4e426ced7d62ccb318d3e931 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 20 May 2026 21:45:34 +0700 Subject: [PATCH 1/5] refactor(tests): split requestMoney out of IOUTest.ts Extract the requestMoney describe (~2,225 lines) and the related "should have valid parameters" describe (~219 lines) from tests/actions/IOUTest.ts into a new tests/actions/IOU/RequestMoneyTest.ts. This is PR 1 of the planned IOUTest.ts split. IOUTest.ts goes from 7,112 to ~4,666 lines; the new file is ~2,690 lines (preamble + 2 describes). No production code touched; trimmed unused imports in both files; full test suite for both files still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/actions/IOU/RequestMoneyTest.ts | 2618 +++++++++++++++++++++++++ tests/actions/IOUTest.ts | 2462 +---------------------- 2 files changed, 2621 insertions(+), 2459 deletions(-) create mode 100644 tests/actions/IOU/RequestMoneyTest.ts diff --git a/tests/actions/IOU/RequestMoneyTest.ts b/tests/actions/IOU/RequestMoneyTest.ts new file mode 100644 index 000000000000..9d9bf131357f --- /dev/null +++ b/tests/actions/IOU/RequestMoneyTest.ts @@ -0,0 +1,2618 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import {format} from 'date-fns'; +import Onyx from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {SearchQueryJSON, SearchStatus} from '@components/Search/types'; +import {clearAllRelatedReportActionErrors} from '@libs/actions/ClearReportActionErrors'; +import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {deleteReport, notifyNewAction} from '@libs/actions/Report'; +import {subscribeToUserEvents} from '@libs/actions/User'; +import type {ApiCommand} from '@libs/API/types'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import Navigation from '@libs/Navigation/Navigation'; +import {rand64} from '@libs/NumberUtils'; +import type * as PolicyUtils from '@libs/PolicyUtils'; +import {getAllReportActions, getIOUActionForReportID, getOriginalMessage, isActionableTrackExpense, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import type {IOUAction} from '@src/CONST'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import * as API from '@src/libs/API'; +import DateUtils from '@src/libs/DateUtils'; +import * as SearchQueryUtils from '@src/libs/SearchQueryUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy, RecentlyUsedTags, Report} from '@src/types/onyx'; +import type {OriginalMessageMovedTransaction} from '@src/types/onyx/OriginalMessage'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; +import type {Participant} from '@src/types/onyx/Report'; +import type ReportAction from '@src/types/onyx/ReportAction'; +import type {ReportActions} from '@src/types/onyx/ReportAction'; +import type Transaction from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import currencyList from '../../unit/currencyList.json'; +import createPersonalDetails from '../../utils/collections/personalDetails'; +import {createRandomReport} from '../../utils/collections/reports'; +import getOnyxValue from '../../utils/getOnyxValue'; +import type {MockFetch} from '../../utils/TestHelper'; +import {getGlobalFetchMock, getOnyxData, setPersonalDetails, signInWithTestUser, translateLocal} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +import waitForNetworkPromises from '../../utils/waitForNetworkPromises'; + +const topMostReportID = '23423423'; +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissToSuperWideRHP: jest.fn(), + navigateBackToLastSuperWideRHPScreen: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => topMostReportID), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +// In production, requestMoney defers its API.write() call until the target screen's +// content lays out (or a safety timeout fires). In tests there is no target component +// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +jest.mock('@libs/deferredLayoutWrite', () => ({ + registerDeferredWrite: (_key: string, callback: () => void) => callback(), + flushDeferredWrite: jest.fn(), + cancelDeferredWrite: jest.fn(), + hasDeferredWrite: () => false, + getOptimisticWatchKey: () => undefined, + deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), + reserveDeferredWriteChannel: jest.fn(), + resetForTesting: jest.fn(), +})); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +const unapprovedCashHash = 71801560; +const unapprovedCashSimilarSearchHash = 1832274510; +jest.mock('@src/libs/SearchQueryUtils', () => { + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: unapprovedCashSimilarSearchHash, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual('@libs/PolicyUtils'), + isPaidGroupPolicy: jest.fn().mockReturnValue(true), + isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), +})); + +const CARLOS_EMAIL = 'cmartins@expensifail.com'; +const CARLOS_ACCOUNT_ID = 1; +const CARLOS_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; +const JULES_EMAIL = 'jules@expensifail.com'; +const JULES_ACCOUNT_ID = 2; +const JULES_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; +const RORY_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'admin'}; +const VIT_EMAIL = 'vit@expensifail.com'; +const VIT_ACCOUNT_ID = 4; + +OnyxUpdateManager(); +describe('actions/IOU', () => { + const currentUserPersonalDetails: CurrentUserPersonalDetails = { + ...createPersonalDetails(RORY_ACCOUNT_ID), + login: RORY_EMAIL, + email: RORY_EMAIL, + displayName: RORY_EMAIL, + avatar: 'https://example.com/avatar.jpg', + }; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + let mockFetch: MockFetch; + beforeEach(() => { + jest.clearAllTimers(); + global.fetch = getGlobalFetchMock(); + mockFetch = fetch as MockFetch; + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('requestMoney', () => { + it('creates new chat if needed', () => { + const amount = 10000; + const comment = 'Giv money plz'; + const merchant = 'KFC'; + let iouReportID: string | undefined; + let createdAction: OnyxEntry; + let iouAction: OnyxEntry>; + let transactionID: string | undefined; + let transactionThread: OnyxEntry; + let transactionThreadCreatedAction: OnyxEntry; + mockFetch?.pause?.(); + requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + return waitForBatchedUpdates() + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // A chat report, a transaction thread, and an iou report should be created + const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); + const iouReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.IOU); + expect(Object.keys(chatReports).length).toBe(2); + expect(Object.keys(iouReports).length).toBe(1); + const chatReport = chatReports.at(0); + const transactionThreadReport = chatReports.at(1); + const iouReport = iouReports.at(0); + iouReportID = iouReport?.reportID; + transactionThread = transactionThreadReport; + + expect(iouReport?.participants).toEqual({ + [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + }); + + // They should be linked together + expect(chatReport?.participants).toEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); + expect(chatReport?.iouReportID).toBe(iouReport?.reportID); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`, + callback: (iouReportMetadata) => { + Onyx.disconnect(connection); + expect(iouReportMetadata?.isOptimisticReport).toBe(true); + + const loadingStateConnection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${iouReportID}`, + callback: (iouReportLoadingState) => { + Onyx.disconnect(loadingStateConnection); + expect(iouReportLoadingState?.hasOnceLoadedReportActions).toBe(true); + resolve(); + }, + }); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + + // The IOU report should have a CREATED action and IOU action + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); + const createdActions = Object.values(reportActionsForIOUReport ?? {}).filter( + (reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, + ); + const iouActions = Object.values(reportActionsForIOUReport ?? {}).filter( + (reportAction): reportAction is ReportAction => isMoneyRequestAction(reportAction), + ); + expect(Object.values(createdActions).length).toBe(1); + expect(Object.values(iouActions).length).toBe(1); + createdAction = createdActions?.at(0); + iouAction = iouActions?.at(0); + const originalMessage = isMoneyRequestAction(iouAction) ? getOriginalMessage(iouAction) : undefined; + + // The CREATED action should not be created after the IOU action + expect(Date.parse(createdAction?.created ?? '')).toBeLessThan(Date.parse(iouAction?.created ?? '')); + + // The IOUReportID should be correct + expect(originalMessage?.IOUReportID).toBe(iouReportID); + + // The comment should be included in the IOU action + expect(originalMessage?.comment).toBe(comment); + + // The amount in the IOU action should be correct + expect(originalMessage?.amount).toBe(amount); + + // The IOU type should be correct + expect(originalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + + // Both actions should be pending + expect(createdAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(iouAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForTransactionThread) => { + Onyx.disconnect(connection); + + // The transaction thread should have a CREATED action + expect(Object.values(reportActionsForTransactionThread ?? {}).length).toBe(1); + const createdActions = Object.values(reportActionsForTransactionThread ?? {}).filter( + (reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, + ); + expect(Object.values(createdActions).length).toBe(1); + transactionThreadCreatedAction = createdActions.at(0); + + expect(transactionThreadCreatedAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connection); + + // There should be one transaction + expect(Object.values(allTransactions ?? {}).length).toBe(1); + const transaction = Object.values(allTransactions ?? []).find((t) => !isEmptyObject(t)); + transactionID = transaction?.transactionID; + + // The transaction should be attached to the IOU report + expect(transaction?.reportID).toBe(iouReportID); + + // Its amount should match the amount of the expense + expect(transaction?.amount).toBe(amount); + + // The comment should be correct + expect(transaction?.comment?.comment).toBe(comment); + + // It should be pending + expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // The transactionID on the iou action should match the one from the transactions collection + expect(iouAction && getOriginalMessage(iouAction)?.IOUTransactionID).toBe(transactionID); + + expect(transaction?.merchant).toBe(merchant); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.SNAPSHOT, + waitForCollectionCallback: true, + callback: (snapshotData) => { + Onyx.disconnect(connection); + + // Snapshot data shouldn't be updated optimistically for requestMoney when the current search query type is invoice. + expect(snapshotData).toBeUndefined(); + resolve(); + }, + }); + }), + ) + .then(mockFetch?.resume) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); + for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + waitForCollectionCallback: false, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.pendingAction).toBeFalsy(); + resolve(); + }, + }); + }), + ); + }); + + it('updates existing chat report if there is one', () => { + const amount = 10000; + const comment = 'Giv money plz'; + let chatReport: Report = { + reportID: '1234', + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }; + const createdAction: ReportAction = { + reportActionID: rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + }; + let iouReportID: string | undefined; + let iouAction: OnyxEntry>; + let iouCreatedAction: OnyxEntry; + let transactionID: string | undefined; + mockFetch?.pause?.(); + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport) + .then(() => + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, { + [createdAction.reportActionID]: createdAction, + }), + ) + .then(() => { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '(none)', + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // The same chat report should be reused, a transaction thread and an IOU report should be created + expect(Object.values(allReports ?? {}).length).toBe(3); + expect(Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT)?.reportID).toBe(chatReport.reportID); + chatReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT) ?? chatReport; + const iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); + iouReportID = iouReport?.reportID; + + expect(iouReport?.participants).toEqual({ + [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + }); + + // They should be linked together + expect(chatReport.iouReportID).toBe(iouReportID); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + waitForCollectionCallback: false, + callback: (allIOUReportActions) => { + Onyx.disconnect(connection); + + iouCreatedAction = Object.values(allIOUReportActions ?? {}).find((reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + iouAction = Object.values(allIOUReportActions ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const originalMessage = iouAction ? getOriginalMessage(iouAction) : null; + + // The CREATED action should not be created after the IOU action + expect(Date.parse(iouCreatedAction?.created ?? '')).toBeLessThan(Date.parse(iouAction?.created ?? '')); + + // The IOUReportID should be correct + expect(originalMessage?.IOUReportID).toBe(iouReportID); + + // The comment should be included in the IOU action + expect(originalMessage?.comment).toBe(comment); + + // The amount in the IOU action should be correct + expect(originalMessage?.amount).toBe(amount); + + // The IOU action type should be correct + expect(originalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + + // The IOU action should be pending + expect(iouAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connection); + + // There should be one transaction + expect(Object.values(allTransactions ?? {}).length).toBe(1); + const transaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); + transactionID = transaction?.transactionID; + const originalMessage = iouAction ? getOriginalMessage(iouAction) : null; + + // The transaction should be attached to the IOU report + expect(transaction?.reportID).toBe(iouReportID); + + // Its amount should match the amount of the expense + expect(transaction?.amount).toBe(amount); + + // The comment should be correct + expect(transaction?.comment?.comment).toBe(comment); + + expect(transaction?.merchant).toBe(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT); + + // It should be pending + expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // The transactionID on the iou action should match the one from the transactions collection + expect(originalMessage?.IOUTransactionID).toBe(transactionID); + + resolve(); + }, + }); + }), + ) + .then(mockFetch?.resume) + .then(waitForBatchedUpdates) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); + for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.pendingAction).toBeFalsy(); + resolve(); + }, + }); + }), + ); + }); + + it('updates existing IOU report if there is one', () => { + const amount = 10000; + const comment = 'Giv money plz'; + const chatReportID = '1234'; + const iouReportID = '5678'; + let chatReport: OnyxEntry = { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + iouReportID, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }; + const createdAction: ReportAction = { + reportActionID: rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + }; + const existingTransaction: Transaction = { + transactionID: rand64(), + amount: 1000, + comment: { + comment: 'Existing transaction', + attendees: [{email: 'text@expensify.com', displayName: 'Test User', avatarUrl: ''}], + }, + created: DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + merchant: '', + reportID: '', + }; + let iouReport: OnyxEntry = { + reportID: iouReportID, + chatReportID, + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: RORY_ACCOUNT_ID, + managerID: CARLOS_ACCOUNT_ID, + currency: CONST.CURRENCY.USD, + total: existingTransaction.amount, + }; + const iouAction: OnyxEntry> = { + reportActionID: rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: RORY_ACCOUNT_ID, + created: DateUtils.getDBTime(), + originalMessage: { + IOUReportID: iouReportID, + IOUTransactionID: existingTransaction.transactionID, + amount: existingTransaction.amount, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + participantAccountIDs: [RORY_ACCOUNT_ID, CARLOS_ACCOUNT_ID], + }, + }; + let newIOUAction: OnyxEntry>; + let newTransaction: OnyxEntry; + mockFetch?.pause?.(); + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, iouReport ?? null)) + .then(() => + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { + [createdAction.reportActionID]: createdAction, + [iouAction.reportActionID]: iouAction, + }), + ) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransaction.transactionID}`, existingTransaction)) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // No new reports should be created + expect(Object.values(allReports ?? {}).length).toBe(3); + expect(Object.values(allReports ?? {}).find((report) => report?.reportID === chatReportID)).toBeTruthy(); + expect(Object.values(allReports ?? {}).find((report) => report?.reportID === iouReportID)).toBeTruthy(); + + chatReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT); + iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); + + // The total on the iou report should be updated + expect(iouReport?.total).toBe(11000); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); + newIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( + (reportAction): reportAction is ReportAction => + reportAction?.reportActionID !== createdAction.reportActionID && reportAction?.reportActionID !== iouAction?.reportActionID, + ); + + const newOriginalMessage = newIOUAction ? getOriginalMessage(newIOUAction) : null; + + // The IOUReportID should be correct + expect(getOriginalMessage(iouAction)?.IOUReportID).toBe(iouReportID); + + // The comment should be included in the IOU action + expect(newOriginalMessage?.comment).toBe(comment); + + // The amount in the IOU action should be correct + expect(newOriginalMessage?.amount).toBe(amount); + + // The type of the IOU action should be correct + expect(newOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + + // The IOU action should be pending + expect(newIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connection); + + // There should be two transactions + expect(Object.values(allTransactions ?? {}).length).toBe(2); + + newTransaction = Object.values(allTransactions ?? {}).find((transaction) => transaction?.transactionID !== existingTransaction.transactionID); + + expect(newTransaction?.reportID).toBe(iouReportID); + expect(newTransaction?.amount).toBe(amount); + expect(newTransaction?.comment?.comment).toBe(comment); + expect(newTransaction?.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT); + expect(newTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // The transactionID on the iou action should match the one from the transactions collection + expect(isMoneyRequestAction(newIOUAction) ? getOriginalMessage(newIOUAction)?.IOUTransactionID : undefined).toBe(newTransaction?.transactionID); + + resolve(); + }, + }); + }), + ) + .then(mockFetch?.resume) + .then(waitForNetworkPromises) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); + for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connection); + for (const transaction of Object.values(allTransactions ?? {})) { + expect(transaction?.pendingAction).toBeFalsy(); + } + resolve(); + }, + }); + }), + ); + }); + + it('correctly implements RedBrickRoad error handling', () => { + const amount = 10000; + const comment = 'Giv money plz'; + let chatReportID: string | undefined; + let iouReportID: string | undefined; + let createdAction: OnyxEntry; + let iouAction: OnyxEntry>; + let transactionID: string | undefined; + let transactionThreadReport: OnyxEntry; + let transactionThreadAction: OnyxEntry; + mockFetch?.pause?.(); + requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + return ( + waitForBatchedUpdates() + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // A chat report, transaction thread and an iou report should be created + const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); + const iouReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.IOU); + expect(Object.values(chatReports).length).toBe(2); + expect(Object.values(iouReports).length).toBe(1); + const chatReport = chatReports.at(0); + chatReportID = chatReport?.reportID; + transactionThreadReport = chatReports.at(1); + + const iouReport = iouReports.at(0); + iouReportID = iouReport?.reportID; + + expect(chatReport?.participants).toStrictEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); + + // They should be linked together + expect(chatReport?.participants).toStrictEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); + expect(chatReport?.iouReportID).toBe(iouReport?.reportID); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + + // The chat report should have a CREATED action and IOU action + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); + const createdActions = + Object.values(reportActionsForIOUReport ?? {}).filter((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? null; + const iouActions = + Object.values(reportActionsForIOUReport ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ) ?? null; + expect(Object.values(createdActions).length).toBe(1); + expect(Object.values(iouActions).length).toBe(1); + createdAction = createdActions.at(0); + iouAction = iouActions.at(0); + const originalMessage = getOriginalMessage(iouAction); + + // The CREATED action should not be created after the IOU action + expect(Date.parse(createdAction?.created ?? '')).toBeLessThan(Date.parse(iouAction?.created ?? '')); + + // The IOUReportID should be correct + expect(originalMessage?.IOUReportID).toBe(iouReportID); + + // The comment should be included in the IOU action + expect(originalMessage?.comment).toBe(comment); + + // The amount in the IOU action should be correct + expect(originalMessage?.amount).toBe(amount); + + // The type should be correct + expect(originalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + + // Both actions should be pending + expect(createdAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(iouAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connection); + + // There should be one transaction + expect(Object.values(allTransactions ?? {}).length).toBe(1); + const transaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); + transactionID = transaction?.transactionID; + + expect(transaction?.reportID).toBe(iouReportID); + expect(transaction?.amount).toBe(amount); + expect(transaction?.comment?.comment).toBe(comment); + expect(transaction?.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT); + expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // The transactionID on the iou action should match the one from the transactions collection + expect(iouAction && getOriginalMessage(iouAction)?.IOUTransactionID).toBe(transactionID); + + resolve(); + }, + }); + }), + ) + .then((): Promise => { + mockFetch?.fail?.(); + return mockFetch?.resume?.() as Promise; + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForIOUReport) => { + Onyx.disconnect(connection); + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); + iouAction = Object.values(reportActionsForIOUReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(iouAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (reportActionsForTransactionThread) => { + Onyx.disconnect(connection); + expect(Object.values(reportActionsForTransactionThread ?? {}).length).toBe(3); + transactionThreadAction = Object.values( + reportActionsForTransactionThread?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`] ?? {}, + ).find((reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + expect(transactionThreadAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + waitForCollectionCallback: false, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(transaction?.errors).toBeTruthy(); + expect(Object.values(transaction?.errors ?? {}).at(0)).toEqual(translateLocal('iou.error.genericCreateFailureMessage')); + resolve(); + }, + }); + }), + ) + + // If the user clears the errors on the IOU action + .then( + () => + new Promise((resolve) => { + if (iouReportID) { + clearAllRelatedReportActionErrors(iouReportID, iouAction ?? null, iouReportID); + } + resolve(); + }), + ) + + // Then the reportAction from chat report should be removed from Onyx + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForReport) => { + Onyx.disconnect(connection); + iouAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(iouAction).toBeFalsy(); + resolve(); + }, + }); + }), + ) + + // Then the reportAction from iou report should be removed from Onyx + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForReport) => { + Onyx.disconnect(connection); + iouAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(iouAction).toBeFalsy(); + resolve(); + }, + }); + }), + ) + + // Then the reportAction from transaction report should be removed from Onyx + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForReport) => { + Onyx.disconnect(connection); + expect(reportActionsForReport).toMatchObject({}); + resolve(); + }, + }); + }), + ) + + // Along with the associated transaction + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + waitForCollectionCallback: false, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction).toBeFalsy(); + resolve(); + }, + }); + }), + ) + + // If a user clears the errors on the CREATED action (which, technically are just errors on the report) + .then( + () => + new Promise((resolve) => { + if (chatReportID) { + deleteReport(chatReportID); + } + if (transactionThreadReport?.reportID) { + deleteReport(transactionThreadReport?.reportID); + } + resolve(); + }), + ) + + // Then the report should be deleted + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + for (const report of Object.values(allReports ?? {})) { + expect(report).toBeFalsy(); + } + resolve(); + }, + }); + }), + ) + + // All reportActions should also be deleted + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: false, + callback: (allReportActions) => { + Onyx.disconnect(connection); + for (const reportAction of Object.values(allReportActions ?? {})) { + expect(reportAction).toBeFalsy(); + } + resolve(); + }, + }); + }), + ) + + // All transactions should also be deleted + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connection); + for (const transaction of Object.values(allTransactions ?? {})) { + expect(transaction).toBeFalsy(); + } + resolve(); + }, + }); + }), + ) + + // Cleanup + .then(mockFetch?.succeed) + ); + }); + + it('correctly implements RedBrickRoad error handling for ShareTrackedExpense when inviting new user to workspace', async () => { + const amount = 5000; + const comment = 'Shared tracked expense test'; + + // Setup test data - create a self DM report and policy expense chat + const selfDMReport: Report = { + reportID: '1', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT}, + }; + + const policy: Policy = { + id: 'policy123', + name: 'Test Policy', + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + owner: RORY_EMAIL, + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, + employeeList: { + [CARLOS_EMAIL]: { + role: CONST.POLICY.ROLE.ADMIN, + }, + }, + }; + + const policyExpenseChat: Report = { + reportID: '2', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID: policy.id, + participants: { + [RORY_ACCOUNT_ID]: RORY_PARTICIPANT, + [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT, + }, + }; + + // New accountant that is NOT in the workspace employee list (this will trigger the invitation) + const accountant = { + accountID: 999, + login: 'newaccountant@test.com', + email: 'newaccountant@test.com', + }; + + mockFetch?.pause?.(); + + // Setup initial data + await Promise.all([ + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport), + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat), + Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy), + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountant.accountID]: accountant}), + ]); + await waitForBatchedUpdates(); + + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + + // First create a tracked expense in self DM + trackExpense({ + report: selfDMReport, + isDraftPolicy: true, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount, + currency: CONST.CURRENCY.USD, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Test Merchant', + comment, + billable: false, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + draftTransactionIDs: [], + isSelfTourViewed: false, + }); + + mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Capture the created tracked expense data + let selfDMReportID: string | undefined; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (reports) => { + const selfDMReportOnyx = Object.values(reports ?? {}).find((report) => report?.reportID === selfDMReport.reportID); + selfDMReportID = selfDMReportOnyx?.reportID; + }, + }); + + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReportID}`); + const actions = Object.values(reportActions ?? {}); + const linkedTrackedExpenseReportAction = actions.find((action) => action && isMoneyRequestAction(action)); + const actionableWhisperReportActionID = actions.find((action) => action && isActionableTrackExpense(action))?.reportActionID; + + let linkedTrackedExpenseReportID: string | undefined; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + const transaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); + linkedTrackedExpenseReportID = transaction?.reportID; + }, + }); + + // Now pause fetch and share the tracked expense with accountant + mockFetch?.pause?.(); + trackExpense({ + report: policyExpenseChat, + isDraftPolicy: false, + action: CONST.IOU.ACTION.SHARE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, + }, + policyParams: { + policy, + }, + transactionParams: { + amount, + currency: CONST.CURRENCY.USD, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Test Merchant', + comment, + billable: false, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + }, + accountantParams: { + accountant, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + draftTransactionIDs: [], + isSelfTourViewed: false, + }); + await waitForBatchedUpdates(); + + // Simulate network failure + mockFetch?.fail?.(); + await (mockFetch?.resume?.() as Promise); + + // Verify error handling after failure - focus on workspace invitation error + const policyData = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + + // The new accountant should have been added to the employee list with error + const accountantEmployee = policyData?.employeeList?.[accountant.email]; + expect(accountantEmployee).toBeTruthy(); + expect(accountantEmployee?.errors).toBeTruthy(); + expect(Object.values(accountantEmployee?.errors ?? {}).at(0)).toEqual(translateLocal('workspace.people.error.genericAdd')); + + // Cleanup + mockFetch?.succeed?.(); + }); + + it('does not trigger notifyNewAction when doing the money request in a money request report', () => { + requestMoney({ + report: {reportID: '123', type: CONST.REPORT.TYPE.EXPENSE}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount: 1, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment: '', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + expect(notifyNewAction).toHaveBeenCalledTimes(0); + }); + + it('trigger notifyNewAction when doing the money request in a chat report', () => { + requestMoney({ + report: {reportID: '123'}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount: 1, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment: '', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + expect(Navigation.setNavigationActionToMicrotaskQueue).toHaveBeenCalledTimes(1); + }); + + it('should pass isSelfTourViewed true to the request when user has viewed the tour', () => { + const {iouReport} = requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount: 1000, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test Merchant', + comment: 'Test comment', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: true, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + // Verify that the iouReport is created successfully when isSelfTourViewed is true + expect(iouReport).toBeDefined(); + expect(iouReport?.reportID).toBeDefined(); + }); + + it('increase the nonReimbursableTotal only when the expense is not reimbursable', async () => { + const expenseReport: Report = { + ...createRandomReport(0, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + nonReimbursableTotal: 0, + total: 0, + ownerAccountID: RORY_ACCOUNT_ID, + currency: CONST.CURRENCY.USD, + }; + const workspaceChat: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + type: CONST.REPORT.TYPE.CHAT, + iouReportID: expenseReport.reportID, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${workspaceChat.reportID}`, workspaceChat); + + requestMoney({ + report: expenseReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: workspaceChat.reportID, isPolicyExpenseChat: true}, + }, + transactionParams: { + amount: 100, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment: '', + reimbursable: true, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + currentUserEmailParam: 'existing@example.com', + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + + await waitForBatchedUpdates(); + + const nonReimbursableTotal = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + callback: (report) => { + Onyx.disconnect(connection); + resolve(report?.nonReimbursableTotal ?? 0); + }, + }); + }); + + expect(nonReimbursableTotal).toBe(0); + + requestMoney({ + report: expenseReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: workspaceChat.reportID, isPolicyExpenseChat: true}, + }, + transactionParams: { + amount: 100, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment: '', + reimbursable: false, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + + await waitForBatchedUpdates(); + + const newNonReimbursableTotal = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`, + callback: (report) => { + Onyx.disconnect(connection); + resolve(report?.nonReimbursableTotal ?? 0); + }, + }); + }); + + expect(newNonReimbursableTotal).toBe(-100); + }); + + it('should update policyRecentlyUsedTags when tag is provided', async () => { + // Given a policy recently used tags + const transactionTag = 'new tag'; + const policyID = 'A'; + const tagName = 'Tag'; + const expenseReport: Report = { + ...createRandomReport(0, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + nonReimbursableTotal: 0, + total: 0, + ownerAccountID: RORY_ACCOUNT_ID, + currency: CONST.CURRENCY.USD, + policyID, + }; + const policyRecentlyUsedTags: OnyxEntry = { + [tagName]: ['old tag'], + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [tagName]: {name: tagName}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, policyRecentlyUsedTags); + + // When requesting money + requestMoney({ + report: expenseReport, + existingIOUReport: expenseReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: '1', isPolicyExpenseChat: true}, + }, + policyParams: {policyRecentlyUsedTags}, + transactionParams: { + amount: 100, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment: '', + tag: transactionTag, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: currentUserPersonalDetails.accountID, + currentUserEmailParam: currentUserPersonalDetails.login ?? '', + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + waitForBatchedUpdates(); + + // Then the transaction tag should be added to the recently used tags collection + const newPolicyRecentlyUsedTags: RecentlyUsedTags = await new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, + callback: (recentlyUsedTags) => { + resolve(recentlyUsedTags ?? {}); + Onyx.disconnect(connection); + }, + }); + }); + expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); + expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); + }); + + it('should use personalDetails to create expense with participant display name', async () => { + const testPersonalDetails: PersonalDetailsList = { + [CARLOS_ACCOUNT_ID]: { + accountID: CARLOS_ACCOUNT_ID, + login: CARLOS_EMAIL, + displayName: 'Carlos Martinez', + firstName: 'Carlos', + lastName: 'Martinez', + avatar: 'https://example.com/carlos.jpg', + }, + [RORY_ACCOUNT_ID]: { + accountID: RORY_ACCOUNT_ID, + login: RORY_EMAIL, + displayName: 'Rory Smith', + firstName: 'Rory', + lastName: 'Smith', + avatar: 'https://example.com/rory.jpg', + }, + }; + + const amount = 5000; + const comment = 'Test expense with personal details'; + const merchant = 'Test Store'; + + const {iouReport} = requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: testPersonalDetails, + betas: [CONST.BETAS.ALL], + }); + + expect(iouReport).toBeDefined(); + expect(iouReport?.reportID).toBeDefined(); + + // Verify that the expense was created successfully with the personal details + await waitForBatchedUpdates(); + + const createdIouReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`); + expect(createdIouReport).toBeDefined(); + expect(createdIouReport?.ownerAccountID).toBe(RORY_ACCOUNT_ID); + }); + + it('should create expense correctly when personalDetails contains multiple users', async () => { + const testPersonalDetails: PersonalDetailsList = { + [CARLOS_ACCOUNT_ID]: { + accountID: CARLOS_ACCOUNT_ID, + login: CARLOS_EMAIL, + displayName: 'Carlos Martinez', + firstName: 'Carlos', + lastName: 'Martinez', + }, + [JULES_ACCOUNT_ID]: { + accountID: JULES_ACCOUNT_ID, + login: JULES_EMAIL, + displayName: 'Jules Thompson', + firstName: 'Jules', + lastName: 'Thompson', + }, + [RORY_ACCOUNT_ID]: { + accountID: RORY_ACCOUNT_ID, + login: RORY_EMAIL, + displayName: 'Rory Smith', + firstName: 'Rory', + lastName: 'Smith', + }, + [VIT_ACCOUNT_ID]: { + accountID: VIT_ACCOUNT_ID, + login: VIT_EMAIL, + displayName: 'Vit Developer', + firstName: 'Vit', + lastName: 'Developer', + }, + }; + + const amount = 10000; + const {iouReport} = requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: JULES_EMAIL, accountID: JULES_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Multi-user test', + comment: 'Testing with multiple personal details', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: testPersonalDetails, + betas: [CONST.BETAS.ALL], + }); + + expect(iouReport).toBeDefined(); + expect(iouReport?.reportID).toBeDefined(); + + await waitForBatchedUpdates(); + + // Verify the IOU report was created successfully with multiple users in personalDetails + const createdIouReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`); + expect(createdIouReport).toBeDefined(); + // The IOU report should have the correct owner (payee) + expect(createdIouReport?.ownerAccountID).toBe(RORY_ACCOUNT_ID); + }); + + it('should handle empty personalDetails gracefully', async () => { + const amount = 2500; + + const {iouReport} = requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Empty details test', + comment: 'Testing with empty personal details', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: {}, + betas: [CONST.BETAS.ALL], + }); + + // Should still create the expense even with empty personalDetails + expect(iouReport).toBeDefined(); + + await waitForBatchedUpdates(); + + const createdIouReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`); + expect(createdIouReport).toBeDefined(); + }); + + it('should update the parentReportID and parentReportActionID of the transactionThreadReport of the transaction when submitted to another report', async () => { + const amount = 10000; + const comment = 'Send me money please'; + const chatReport: OnyxEntry = { + reportID: '1234', + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }; + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: '10', + }; + const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@test.com'; + + // Given a test user is signed in with Onyx setup and some initial data + await signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); + subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined); + await waitForBatchedUpdates(); + await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + + // Create a tracked expense + trackExpense({ + report: selfDMReport, + isDraftPolicy: true, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: TEST_USER_LOGIN, + payeeAccountID: TEST_USER_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount, + currency: 'USD', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: comment, + billable: false, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + draftTransactionIDs: [], + isSelfTourViewed: false, + }); + await waitForBatchedUpdates(); + + // When fetching all reports from Onyx + const allReports = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (reports) => { + Onyx.disconnect(connection); + resolve(reports); + }, + }); + }); + + // Then we should have exactly 2 reports + expect(Object.values(allReports ?? {}).length).toBe(3); + + // Then one of them should be a chat report with relevant properties + const transactionThreadReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT && report?.parentReportID === selfDMReport.reportID); + expect(transactionThreadReport).toBeTruthy(); + expect(transactionThreadReport).toHaveProperty('reportID'); + expect(transactionThreadReport).toHaveProperty('parentReportActionID'); + + await waitForBatchedUpdates(); + + // When fetching all report actions from Onyx + const allReportActions = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + Onyx.disconnect(connection); + resolve(actions); + }, + }); + }); + + // Then we should find an IOU action with specific properties + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`]; + const createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( + (reportAction): reportAction is ReportAction => reportAction.reportActionID === transactionThreadReport?.parentReportActionID, + ); + expect(createIOUAction).toBeTruthy(); + expect(createIOUAction?.childReportID).toBe(transactionThreadReport?.reportID); + + // When fetching all transactions from Onyx + const allTransactions = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + Onyx.disconnect(connection); + resolve(transactions); + }, + }); + }); + + // Then we should find a specific transaction with relevant properties + const transaction = Object.values(allTransactions ?? {}).find((t) => t); + expect(transaction).toBeTruthy(); + expect(transaction?.amount).toBe(-amount); + expect(transaction?.reportID).toBe(CONST.REPORT.UNREPORTED_REPORT_ID); + expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUTransactionID).toBe(transaction?.transactionID); + + // When: submitting the tracked expense to another user + const {iouReport} = requestMoney({ + action: CONST.IOU.ACTION.SUBMIT, + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, + linkedTrackedExpenseReportAction: createIOUAction, + linkedTrackedExpenseReportID: selfDMReport.reportID, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + existingTransactionDraft: transaction, + draftTransactionIDs: [], + personalDetails: {}, + betas: [CONST.BETAS.ALL], + }); + + await waitForBatchedUpdates(); + + // Then: the parentReportID and parentReportActionID of the transactionThreadReport should be updated correctly + const updatedTransactionThreadReport = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (reports) => { + Onyx.disconnect(connection); + resolve(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`]); + }, + }); + }); + + const iouReportActionID = getIOUActionForReportID(iouReport?.reportID, transaction?.transactionID)?.reportActionID; + expect(updatedTransactionThreadReport).toBeTruthy(); + expect(updatedTransactionThreadReport?.parentReportID).toBe(iouReport?.reportID); + expect(updatedTransactionThreadReport?.parentReportActionID).toBe(iouReportActionID); + + // Also, the fromReportID of movedTransactionAction should be CONST.REPORT.UNREPORTED_REPORT_ID + const updatedTransactionThreadReportActions = getAllReportActions(transactionThreadReport?.reportID); + const movedTransactionAction = Object.values(updatedTransactionThreadReportActions ?? {}).find( + (reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + ); + expect(movedTransactionAction).toBeTruthy(); + const originalMessage = getOriginalMessage(movedTransactionAction) as OriginalMessageMovedTransaction | undefined; + expect(originalMessage?.fromReportID).toBe(CONST.REPORT.UNREPORTED_REPORT_ID); + }); + + it('creates new chat report when participant does not match existing chat report participants', () => { + const amount = 10000; + const comment = 'Test participant mismatch'; + + // Create an existing chat report between RORY and JULES (not CARLOS) + const existingChatReport: Report = { + reportID: '9999', + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [JULES_ACCOUNT_ID]: JULES_PARTICIPANT}, + }; + + mockFetch?.pause?.(); + + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingChatReport.reportID}`, existingChatReport) + .then(() => { + // Request money from CARLOS, but pass the existing chat report with JULES + // This simulates the scenario where submit frequency is disabled and user selects a different participant + requestMoney({ + report: existingChatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test', + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: {}, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // A NEW chat report should be created for RORY and CARLOS + // The existing chat report between RORY and JULES should still exist + const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); + + // There should be at least 2 chat reports (existing one + new one for RORY/CARLOS) + // Plus a transaction thread + expect(chatReports.length).toBeGreaterThanOrEqual(2); + + // Find the chat report that has RORY and CARLOS as participants + const newChatReport = chatReports.find((report) => { + const participantKeys = Object.keys(report?.participants ?? {}).map(Number); + return participantKeys.includes(RORY_ACCOUNT_ID) && participantKeys.includes(CARLOS_ACCOUNT_ID) && participantKeys.length === 2; + }); + + // The new chat report should exist and NOT be the existing one + expect(newChatReport).toBeDefined(); + expect(newChatReport?.reportID).not.toBe(existingChatReport.reportID); + + // The new chat report should have RORY and CARLOS as participants + expect(newChatReport?.participants).toEqual({ + [RORY_ACCOUNT_ID]: RORY_PARTICIPANT, + [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT, + }); + + resolve(); + }, + }); + }), + ) + .then(mockFetch?.resume); + }); + + it('reuses existing chat report when participant matches chat report participants', () => { + const amount = 10000; + const comment = 'Test participant match'; + + // Create an existing chat report between RORY and CARLOS (matching the participant) + const existingChatReport: Report = { + reportID: '8888', + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }; + + mockFetch?.pause?.(); + + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingChatReport.reportID}`, existingChatReport) + .then(() => { + // Request money from CARLOS with matching chat report + requestMoney({ + report: existingChatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test', + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: {}, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // The existing chat report should be reused + const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); + + // Find the chat report that has RORY and CARLOS as participants + const chatReportWithParticipants = chatReports.find((report) => { + const participantKeys = Object.keys(report?.participants ?? {}).map(Number); + return participantKeys.includes(RORY_ACCOUNT_ID) && participantKeys.includes(CARLOS_ACCOUNT_ID) && participantKeys.length === 2; + }); + + // The existing chat report should be reused + expect(chatReportWithParticipants?.reportID).toBe(existingChatReport.reportID); + + resolve(); + }, + }); + }), + ) + .then(mockFetch?.resume); + }); + + it('skips participant validation for policy expense chat participant', () => { + const amount = 10000; + const comment = 'Test policy expense chat'; + const policyID = 'policy123'; + + // Create a policy expense chat report + const policyExpenseChatReport: Report = { + reportID: '7777', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT}, + }; + + mockFetch?.pause?.(); + + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChatReport.reportID}`, policyExpenseChatReport) + .then(() => { + // Request money with isPolicyExpenseChat: true - should skip participant validation + requestMoney({ + report: policyExpenseChatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: policyExpenseChatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test', + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: {}, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // The policy expense chat report should be reused (participant validation skipped) + const policyExpenseChats = Object.values(allReports ?? {}).filter((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + + // The original policy expense chat should still exist + expect(policyExpenseChats.some((report) => report?.reportID === policyExpenseChatReport.reportID)).toBe(true); + + resolve(); + }, + }); + }), + ) + .then(mockFetch?.resume); + }); + + it('skips participant validation when chatReport is a Policy Expense Chat', () => { + const amount = 10000; + const comment = 'Test chatReport is policy expense chat'; + const policyID = 'policy456'; + + // Create a policy expense chat report (the chatReport itself is a policy expense chat) + const policyExpenseChatReport: Report = { + reportID: '6666', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [JULES_ACCOUNT_ID]: JULES_PARTICIPANT}, + }; + + mockFetch?.pause?.(); + + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChatReport.reportID}`, policyExpenseChatReport) + .then(() => { + // Request money from CARLOS but passing a policy expense chat report with different participants (JULES) + // Since the chatReport is a policy expense chat, participant validation should be skipped + requestMoney({ + report: policyExpenseChatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test', + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: {}, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // Even though participants don't match (JULES vs CARLOS), + // since the chatReport is a policy expense chat, it should be reused + // (no new 1:1 DM chat should be created for this case) + const policyExpenseChats = Object.values(allReports ?? {}).filter((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + + // The policy expense chat should still exist + expect(policyExpenseChats.some((report) => report?.reportID === policyExpenseChatReport.reportID)).toBe(true); + + resolve(); + }, + }); + }), + ) + .then(mockFetch?.resume); + }); + + it('skips participant validation for self-DM report with accountID 0', () => { + const amount = 10000; + const comment = 'Test self-DM track expense'; + + // Create a self-DM report (Your Space) + const selfDMReport: Report = { + reportID: '5555', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT}, + }; + + mockFetch?.pause?.(); + + return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport) + .then(() => { + // Track expense in self-DM with accountID: 0 (as getMoneyRequestParticipantsFromReport does) + // This simulates the scenario where user starts an expense from "Your Space" + requestMoney({ + report: selfDMReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + // accountID: 0 is used for self-DM participants (represents the report itself, not another user) + participant: {accountID: 0, reportID: selfDMReport.reportID, isPolicyExpenseChat: false}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test', + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: {}, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // The self-DM report should be reused (participant validation should be skipped) + // No new 1:1 DM chat with accountID 0 should be created + const selfDMReports = Object.values(allReports ?? {}).filter((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM); + + // The original self-DM report should still exist + expect(selfDMReports.some((report) => report?.reportID === selfDMReport.reportID)).toBe(true); + + // There should NOT be a new invalid chat report with accountID 0 participant + const chatReportsWithZeroParticipant = Object.values(allReports ?? {}).filter((report) => { + if (report?.type !== CONST.REPORT.TYPE.CHAT || report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM) { + return false; + } + const participantKeys = Object.keys(report?.participants ?? {}).map(Number); + return participantKeys.includes(0); + }); + + // No chat reports should have accountID 0 as a participant (that would be invalid) + expect(chatReportsWithZeroParticipant.length).toBe(0); + + resolve(); + }, + }); + }), + ) + .then(mockFetch?.resume); + }); + }); + + describe('should have valid parameters', () => { + let writeSpy: jest.SpyInstance; + const isValid = (value: unknown) => !value || typeof value !== 'object' || value instanceof Blob; + + beforeEach(() => { + writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + test.each([ + [WRITE_COMMANDS.REQUEST_MONEY, CONST.IOU.ACTION.CREATE], + [WRITE_COMMANDS.CONVERT_TRACKED_EXPENSE_TO_REQUEST, CONST.IOU.ACTION.SUBMIT], + ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { + // When an expense is created + requestMoney({ + action, + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount: 10000, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'KFC', + comment: '', + linkedTrackedExpenseReportAction: { + reportActionID: '', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-10-30', + }, + actionableWhisperReportActionID: '1', + linkedTrackedExpenseReportID: '1', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + + await waitForBatchedUpdates(); + + // Then the correct API request should be made + expect(writeSpy).toHaveBeenCalledTimes(1); + + const [command, params] = writeSpy.mock.calls.at(0); + expect(command).toBe(expectedCommand); + + // And the parameters should be supported by XMLHttpRequest + for (const value of Object.values(params as Record)) { + expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); + } + }); + + it('adds grouped from snapshot optimistic data for grouped search queries', async () => { + const currentSearchQueryJSON = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: [CONST.SEARCH.STATUS.EXPENSE.DRAFTS, CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING] as SearchStatus, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + groupBy: CONST.SEARCH.GROUP_BY.FROM, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE, + right: 'yes', + }, + inputQuery: 'sortBy:date sortOrder:desc type:expense groupBy:from status:drafts,outstanding', + flatFilters: [], + hash: 71801560, + recentSearchHash: 1043581824, + similarSearchHash: 1832274510, + view: CONST.SEARCH.VIEW.TABLE, + } as SearchQueryJSON; + + const getCurrentSearchQueryJSONSpy = jest.spyOn(SearchQueryUtils, 'getCurrentSearchQueryJSON').mockReturnValue(currentSearchQueryJSON); + + requestMoney({ + action: CONST.IOU.ACTION.CREATE, + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount: 10000, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'KFC', + comment: '', + linkedTrackedExpenseReportAction: { + reportActionID: '', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-10-30', + }, + actionableWhisperReportActionID: '1', + linkedTrackedExpenseReportID: '1', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + personalDetails: {}, + }); + + await waitForBatchedUpdates(); + + expect(writeSpy).toHaveBeenCalledTimes(1); + const [, , requestData] = writeSpy.mock.calls.at(0) as [ApiCommand, Record, {optimisticData?: Array<{key: string}>}]; + const optimisticData = requestData.optimisticData ?? []; + const mainSnapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchQueryJSON.hash}`; + expect(optimisticData.some((update) => update.key === mainSnapshotKey)).toBeTruthy(); + + const newFlatFilters = currentSearchQueryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM); + newFlatFilters.push({ + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: String(RORY_ACCOUNT_ID)}], + }); + const groupedTransactionsQueryJSON = SearchQueryUtils.buildSearchQueryJSON( + SearchQueryUtils.buildSearchQueryString({ + ...currentSearchQueryJSON, + groupBy: undefined, + flatFilters: newFlatFilters, + }), + ); + + expect(groupedTransactionsQueryJSON?.hash).toBeDefined(); + if (!groupedTransactionsQueryJSON) { + throw new Error('Expected grouped transactions query JSON to be defined'); + } + const groupedSnapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${groupedTransactionsQueryJSON.hash}`; + expect(optimisticData.some((update) => update.key === groupedSnapshotKey)).toBeTruthy(); + + getCurrentSearchQueryJSONSpy.mockRestore(); + }); + + test.each([ + [WRITE_COMMANDS.TRACK_EXPENSE, CONST.IOU.ACTION.CREATE], + [WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, CONST.IOU.ACTION.CATEGORIZE], + [WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, CONST.IOU.ACTION.SHARE], + ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + + // When a track expense is created + trackExpense({ + report: {reportID: '123', policyID: 'A'}, + isDraftPolicy: false, + action, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount: 10000, + currency: CONST.CURRENCY.USD, + created: '2024-10-30', + merchant: 'KFC', + receipt: {}, + actionableWhisperReportActionID: '1', + linkedTrackedExpenseReportAction: { + reportActionID: '', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-10-30', + }, + linkedTrackedExpenseReportID: '1', + }, + accountantParams: action === CONST.IOU.ACTION.SHARE ? {accountant: {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL}} : undefined, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + draftTransactionIDs: [], + isSelfTourViewed: false, + }); + + await waitForBatchedUpdates(); + + // Then the correct API request should be made + expect(writeSpy).toHaveBeenCalledTimes(1); + + const [command, params] = writeSpy.mock.calls.at(0); + expect(command).toBe(expectedCommand); + + if (expectedCommand === WRITE_COMMANDS.SHARE_TRACKED_EXPENSE) { + expect(params).toHaveProperty('policyName'); + } + + // And the parameters should be supported by XMLHttpRequest + for (const value of Object.values(params as Record)) { + expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); + } + }); + }); +}); diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 177197afb816..d86e7cb0259f 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -6,7 +6,6 @@ import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry, OnyxMergeCollectionInput} from 'react-native-onyx'; import type {SearchQueryJSON, SearchStatus} from '@components/Search/types'; import useOnyx from '@hooks/useOnyx'; -import {clearAllRelatedReportActionErrors} from '@libs/actions/ClearReportActionErrors'; import {putOnHold} from '@libs/actions/IOU/Hold'; import { initMoneyRequest, @@ -29,33 +28,24 @@ import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; import {removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {createWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; -import {createNewReport, deleteReport, notifyNewAction} from '@libs/actions/Report'; -import {subscribeToUserEvents} from '@libs/actions/User'; -import type {ApiCommand} from '@libs/API/types'; -import {WRITE_COMMANDS} from '@libs/API/types'; +import {createNewReport} from '@libs/actions/Report'; import Log from '@libs/Log'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; -import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; import {getManagerMcTestParticipant} from '@libs/OptionsListUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; -import {getAllReportActions, getIOUActionForReportID, getOriginalMessage, isActionableTrackExpense, isActionOfType, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isActionOfType, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReportAction, createDraftTransactionAndNavigateToParticipantSelector, getReportOrDraftReport} from '@libs/ReportUtils'; -import type {IOUAction} from '@src/CONST'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; -import * as API from '@src/libs/API'; import DateUtils from '@src/libs/DateUtils'; -import * as SearchQueryUtils from '@src/libs/SearchQueryUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {LastSelectedDistanceRates, PersonalDetailsList, Policy, PolicyTagLists, RecentlyUsedTags, Report, ReportNameValuePairs, SearchResults} from '@src/types/onyx'; import type {Participant as IOUParticipant, SplitExpense} from '@src/types/onyx/IOU'; -import type {OriginalMessageMovedTransaction} from '@src/types/onyx/OriginalMessage'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {Participant} from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; -import type {ReportActions} from '@src/types/onyx/ReportAction'; import type Transaction from '@src/types/onyx/Transaction'; import type {SplitShares} from '@src/types/onyx/Transaction'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; @@ -69,7 +59,7 @@ import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; import getOnyxValue from '../utils/getOnyxValue'; import type {MockFetch} from '../utils/TestHelper'; -import {getGlobalFetchMock, getOnyxData, setPersonalDetails, signInWithTestUser, translateLocal} from '../utils/TestHelper'; +import {getGlobalFetchMock, getOnyxData} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForNetworkPromises from '../utils/waitForNetworkPromises'; @@ -629,2232 +619,6 @@ describe('actions/IOU', () => { }); }); - describe('requestMoney', () => { - it('creates new chat if needed', () => { - const amount = 10000; - const comment = 'Giv money plz'; - const merchant = 'KFC'; - let iouReportID: string | undefined; - let createdAction: OnyxEntry; - let iouAction: OnyxEntry>; - let transactionID: string | undefined; - let transactionThread: OnyxEntry; - let transactionThreadCreatedAction: OnyxEntry; - mockFetch?.pause?.(); - requestMoney({ - report: {reportID: ''}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - return waitForBatchedUpdates() - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // A chat report, a transaction thread, and an iou report should be created - const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); - const iouReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.IOU); - expect(Object.keys(chatReports).length).toBe(2); - expect(Object.keys(iouReports).length).toBe(1); - const chatReport = chatReports.at(0); - const transactionThreadReport = chatReports.at(1); - const iouReport = iouReports.at(0); - iouReportID = iouReport?.reportID; - transactionThread = transactionThreadReport; - - expect(iouReport?.participants).toEqual({ - [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, - [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, - }); - - // They should be linked together - expect(chatReport?.participants).toEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); - expect(chatReport?.iouReportID).toBe(iouReport?.reportID); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`, - callback: (iouReportMetadata) => { - Onyx.disconnect(connection); - expect(iouReportMetadata?.isOptimisticReport).toBe(true); - - const loadingStateConnection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${iouReportID}`, - callback: (iouReportLoadingState) => { - Onyx.disconnect(loadingStateConnection); - expect(iouReportLoadingState?.hasOnceLoadedReportActions).toBe(true); - resolve(); - }, - }); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - - // The IOU report should have a CREATED action and IOU action - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); - const createdActions = Object.values(reportActionsForIOUReport ?? {}).filter( - (reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, - ); - const iouActions = Object.values(reportActionsForIOUReport ?? {}).filter( - (reportAction): reportAction is ReportAction => isMoneyRequestAction(reportAction), - ); - expect(Object.values(createdActions).length).toBe(1); - expect(Object.values(iouActions).length).toBe(1); - createdAction = createdActions?.at(0); - iouAction = iouActions?.at(0); - const originalMessage = isMoneyRequestAction(iouAction) ? getOriginalMessage(iouAction) : undefined; - - // The CREATED action should not be created after the IOU action - expect(Date.parse(createdAction?.created ?? '')).toBeLessThan(Date.parse(iouAction?.created ?? '')); - - // The IOUReportID should be correct - expect(originalMessage?.IOUReportID).toBe(iouReportID); - - // The comment should be included in the IOU action - expect(originalMessage?.comment).toBe(comment); - - // The amount in the IOU action should be correct - expect(originalMessage?.amount).toBe(amount); - - // The IOU type should be correct - expect(originalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - - // Both actions should be pending - expect(createdAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(iouAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForTransactionThread) => { - Onyx.disconnect(connection); - - // The transaction thread should have a CREATED action - expect(Object.values(reportActionsForTransactionThread ?? {}).length).toBe(1); - const createdActions = Object.values(reportActionsForTransactionThread ?? {}).filter( - (reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, - ); - expect(Object.values(createdActions).length).toBe(1); - transactionThreadCreatedAction = createdActions.at(0); - - expect(transactionThreadCreatedAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - - // There should be one transaction - expect(Object.values(allTransactions ?? {}).length).toBe(1); - const transaction = Object.values(allTransactions ?? []).find((t) => !isEmptyObject(t)); - transactionID = transaction?.transactionID; - - // The transaction should be attached to the IOU report - expect(transaction?.reportID).toBe(iouReportID); - - // Its amount should match the amount of the expense - expect(transaction?.amount).toBe(amount); - - // The comment should be correct - expect(transaction?.comment?.comment).toBe(comment); - - // It should be pending - expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - // The transactionID on the iou action should match the one from the transactions collection - expect(iouAction && getOriginalMessage(iouAction)?.IOUTransactionID).toBe(transactionID); - - expect(transaction?.merchant).toBe(merchant); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.SNAPSHOT, - waitForCollectionCallback: true, - callback: (snapshotData) => { - Onyx.disconnect(connection); - - // Snapshot data shouldn't be updated optimistically for requestMoney when the current search query type is invoice. - expect(snapshotData).toBeUndefined(); - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); - for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - waitForCollectionCallback: false, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.pendingAction).toBeFalsy(); - resolve(); - }, - }); - }), - ); - }); - - it('updates existing chat report if there is one', () => { - const amount = 10000; - const comment = 'Giv money plz'; - let chatReport: Report = { - reportID: '1234', - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }; - const createdAction: ReportAction = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: DateUtils.getDBTime(), - }; - let iouReportID: string | undefined; - let iouAction: OnyxEntry>; - let iouCreatedAction: OnyxEntry; - let transactionID: string | undefined; - mockFetch?.pause?.(); - return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport) - .then(() => - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, { - [createdAction.reportActionID]: createdAction, - }), - ) - .then(() => { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '(none)', - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // The same chat report should be reused, a transaction thread and an IOU report should be created - expect(Object.values(allReports ?? {}).length).toBe(3); - expect(Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT)?.reportID).toBe(chatReport.reportID); - chatReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT) ?? chatReport; - const iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - iouReportID = iouReport?.reportID; - - expect(iouReport?.participants).toEqual({ - [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, - [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, - }); - - // They should be linked together - expect(chatReport.iouReportID).toBe(iouReportID); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - waitForCollectionCallback: false, - callback: (allIOUReportActions) => { - Onyx.disconnect(connection); - - iouCreatedAction = Object.values(allIOUReportActions ?? {}).find((reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - iouAction = Object.values(allIOUReportActions ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const originalMessage = iouAction ? getOriginalMessage(iouAction) : null; - - // The CREATED action should not be created after the IOU action - expect(Date.parse(iouCreatedAction?.created ?? '')).toBeLessThan(Date.parse(iouAction?.created ?? '')); - - // The IOUReportID should be correct - expect(originalMessage?.IOUReportID).toBe(iouReportID); - - // The comment should be included in the IOU action - expect(originalMessage?.comment).toBe(comment); - - // The amount in the IOU action should be correct - expect(originalMessage?.amount).toBe(amount); - - // The IOU action type should be correct - expect(originalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - - // The IOU action should be pending - expect(iouAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - - // There should be one transaction - expect(Object.values(allTransactions ?? {}).length).toBe(1); - const transaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); - transactionID = transaction?.transactionID; - const originalMessage = iouAction ? getOriginalMessage(iouAction) : null; - - // The transaction should be attached to the IOU report - expect(transaction?.reportID).toBe(iouReportID); - - // Its amount should match the amount of the expense - expect(transaction?.amount).toBe(amount); - - // The comment should be correct - expect(transaction?.comment?.comment).toBe(comment); - - expect(transaction?.merchant).toBe(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT); - - // It should be pending - expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - // The transactionID on the iou action should match the one from the transactions collection - expect(originalMessage?.IOUTransactionID).toBe(transactionID); - - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume) - .then(waitForBatchedUpdates) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); - for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.pendingAction).toBeFalsy(); - resolve(); - }, - }); - }), - ); - }); - - it('updates existing IOU report if there is one', () => { - const amount = 10000; - const comment = 'Giv money plz'; - const chatReportID = '1234'; - const iouReportID = '5678'; - let chatReport: OnyxEntry = { - reportID: chatReportID, - type: CONST.REPORT.TYPE.CHAT, - iouReportID, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }; - const createdAction: ReportAction = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: DateUtils.getDBTime(), - }; - const existingTransaction: Transaction = { - transactionID: rand64(), - amount: 1000, - comment: { - comment: 'Existing transaction', - attendees: [{email: 'text@expensify.com', displayName: 'Test User', avatarUrl: ''}], - }, - created: DateUtils.getDBTime(), - currency: CONST.CURRENCY.USD, - merchant: '', - reportID: '', - }; - let iouReport: OnyxEntry = { - reportID: iouReportID, - chatReportID, - type: CONST.REPORT.TYPE.IOU, - ownerAccountID: RORY_ACCOUNT_ID, - managerID: CARLOS_ACCOUNT_ID, - currency: CONST.CURRENCY.USD, - total: existingTransaction.amount, - }; - const iouAction: OnyxEntry> = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - actorAccountID: RORY_ACCOUNT_ID, - created: DateUtils.getDBTime(), - originalMessage: { - IOUReportID: iouReportID, - IOUTransactionID: existingTransaction.transactionID, - amount: existingTransaction.amount, - currency: CONST.CURRENCY.USD, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - participantAccountIDs: [RORY_ACCOUNT_ID, CARLOS_ACCOUNT_ID], - }, - }; - let newIOUAction: OnyxEntry>; - let newTransaction: OnyxEntry; - mockFetch?.pause?.(); - return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport) - .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, iouReport ?? null)) - .then(() => - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, { - [createdAction.reportActionID]: createdAction, - [iouAction.reportActionID]: iouAction, - }), - ) - .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransaction.transactionID}`, existingTransaction)) - .then(() => { - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // No new reports should be created - expect(Object.values(allReports ?? {}).length).toBe(3); - expect(Object.values(allReports ?? {}).find((report) => report?.reportID === chatReportID)).toBeTruthy(); - expect(Object.values(allReports ?? {}).find((report) => report?.reportID === iouReportID)).toBeTruthy(); - - chatReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT); - iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - - // The total on the iou report should be updated - expect(iouReport?.total).toBe(11000); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); - newIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( - (reportAction): reportAction is ReportAction => - reportAction?.reportActionID !== createdAction.reportActionID && reportAction?.reportActionID !== iouAction?.reportActionID, - ); - - const newOriginalMessage = newIOUAction ? getOriginalMessage(newIOUAction) : null; - - // The IOUReportID should be correct - expect(getOriginalMessage(iouAction)?.IOUReportID).toBe(iouReportID); - - // The comment should be included in the IOU action - expect(newOriginalMessage?.comment).toBe(comment); - - // The amount in the IOU action should be correct - expect(newOriginalMessage?.amount).toBe(amount); - - // The type of the IOU action should be correct - expect(newOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - - // The IOU action should be pending - expect(newIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - - // There should be two transactions - expect(Object.values(allTransactions ?? {}).length).toBe(2); - - newTransaction = Object.values(allTransactions ?? {}).find((transaction) => transaction?.transactionID !== existingTransaction.transactionID); - - expect(newTransaction?.reportID).toBe(iouReportID); - expect(newTransaction?.amount).toBe(amount); - expect(newTransaction?.comment?.comment).toBe(comment); - expect(newTransaction?.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT); - expect(newTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - // The transactionID on the iou action should match the one from the transactions collection - expect(isMoneyRequestAction(newIOUAction) ? getOriginalMessage(newIOUAction)?.IOUTransactionID : undefined).toBe(newTransaction?.transactionID); - - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume) - .then(waitForNetworkPromises) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); - for (const reportAction of Object.values(reportActionsForIOUReport ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - for (const transaction of Object.values(allTransactions ?? {})) { - expect(transaction?.pendingAction).toBeFalsy(); - } - resolve(); - }, - }); - }), - ); - }); - - it('correctly implements RedBrickRoad error handling', () => { - const amount = 10000; - const comment = 'Giv money plz'; - let chatReportID: string | undefined; - let iouReportID: string | undefined; - let createdAction: OnyxEntry; - let iouAction: OnyxEntry>; - let transactionID: string | undefined; - let transactionThreadReport: OnyxEntry; - let transactionThreadAction: OnyxEntry; - mockFetch?.pause?.(); - requestMoney({ - report: {reportID: ''}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - return ( - waitForBatchedUpdates() - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // A chat report, transaction thread and an iou report should be created - const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); - const iouReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.IOU); - expect(Object.values(chatReports).length).toBe(2); - expect(Object.values(iouReports).length).toBe(1); - const chatReport = chatReports.at(0); - chatReportID = chatReport?.reportID; - transactionThreadReport = chatReports.at(1); - - const iouReport = iouReports.at(0); - iouReportID = iouReport?.reportID; - - expect(chatReport?.participants).toStrictEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); - - // They should be linked together - expect(chatReport?.participants).toStrictEqual({[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}); - expect(chatReport?.iouReportID).toBe(iouReport?.reportID); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - - // The chat report should have a CREATED action and IOU action - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); - const createdActions = - Object.values(reportActionsForIOUReport ?? {}).filter((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? null; - const iouActions = - Object.values(reportActionsForIOUReport ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ) ?? null; - expect(Object.values(createdActions).length).toBe(1); - expect(Object.values(iouActions).length).toBe(1); - createdAction = createdActions.at(0); - iouAction = iouActions.at(0); - const originalMessage = getOriginalMessage(iouAction); - - // The CREATED action should not be created after the IOU action - expect(Date.parse(createdAction?.created ?? '')).toBeLessThan(Date.parse(iouAction?.created ?? '')); - - // The IOUReportID should be correct - expect(originalMessage?.IOUReportID).toBe(iouReportID); - - // The comment should be included in the IOU action - expect(originalMessage?.comment).toBe(comment); - - // The amount in the IOU action should be correct - expect(originalMessage?.amount).toBe(amount); - - // The type should be correct - expect(originalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - - // Both actions should be pending - expect(createdAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(iouAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - - // There should be one transaction - expect(Object.values(allTransactions ?? {}).length).toBe(1); - const transaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); - transactionID = transaction?.transactionID; - - expect(transaction?.reportID).toBe(iouReportID); - expect(transaction?.amount).toBe(amount); - expect(transaction?.comment?.comment).toBe(comment); - expect(transaction?.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT); - expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - // The transactionID on the iou action should match the one from the transactions collection - expect(iouAction && getOriginalMessage(iouAction)?.IOUTransactionID).toBe(transactionID); - - resolve(); - }, - }); - }), - ) - .then((): Promise => { - mockFetch?.fail?.(); - return mockFetch?.resume?.() as Promise; - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForIOUReport) => { - Onyx.disconnect(connection); - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(2); - iouAction = Object.values(reportActionsForIOUReport ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - expect(iouAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (reportActionsForTransactionThread) => { - Onyx.disconnect(connection); - expect(Object.values(reportActionsForTransactionThread ?? {}).length).toBe(3); - transactionThreadAction = Object.values( - reportActionsForTransactionThread?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`] ?? {}, - ).find((reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - expect(transactionThreadAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - waitForCollectionCallback: false, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(transaction?.errors).toBeTruthy(); - expect(Object.values(transaction?.errors ?? {}).at(0)).toEqual(translateLocal('iou.error.genericCreateFailureMessage')); - resolve(); - }, - }); - }), - ) - - // If the user clears the errors on the IOU action - .then( - () => - new Promise((resolve) => { - if (iouReportID) { - clearAllRelatedReportActionErrors(iouReportID, iouAction ?? null, iouReportID); - } - resolve(); - }), - ) - - // Then the reportAction from chat report should be removed from Onyx - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForReport) => { - Onyx.disconnect(connection); - iouAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - expect(iouAction).toBeFalsy(); - resolve(); - }, - }); - }), - ) - - // Then the reportAction from iou report should be removed from Onyx - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForReport) => { - Onyx.disconnect(connection); - iouAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - expect(iouAction).toBeFalsy(); - resolve(); - }, - }); - }), - ) - - // Then the reportAction from transaction report should be removed from Onyx - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForReport) => { - Onyx.disconnect(connection); - expect(reportActionsForReport).toMatchObject({}); - resolve(); - }, - }); - }), - ) - - // Along with the associated transaction - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - waitForCollectionCallback: false, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction).toBeFalsy(); - resolve(); - }, - }); - }), - ) - - // If a user clears the errors on the CREATED action (which, technically are just errors on the report) - .then( - () => - new Promise((resolve) => { - if (chatReportID) { - deleteReport(chatReportID); - } - if (transactionThreadReport?.reportID) { - deleteReport(transactionThreadReport?.reportID); - } - resolve(); - }), - ) - - // Then the report should be deleted - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - for (const report of Object.values(allReports ?? {})) { - expect(report).toBeFalsy(); - } - resolve(); - }, - }); - }), - ) - - // All reportActions should also be deleted - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: false, - callback: (allReportActions) => { - Onyx.disconnect(connection); - for (const reportAction of Object.values(allReportActions ?? {})) { - expect(reportAction).toBeFalsy(); - } - resolve(); - }, - }); - }), - ) - - // All transactions should also be deleted - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - for (const transaction of Object.values(allTransactions ?? {})) { - expect(transaction).toBeFalsy(); - } - resolve(); - }, - }); - }), - ) - - // Cleanup - .then(mockFetch?.succeed) - ); - }); - - it('correctly implements RedBrickRoad error handling for ShareTrackedExpense when inviting new user to workspace', async () => { - const amount = 5000; - const comment = 'Shared tracked expense test'; - - // Setup test data - create a self DM report and policy expense chat - const selfDMReport: Report = { - reportID: '1', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT}, - }; - - const policy: Policy = { - id: 'policy123', - name: 'Test Policy', - role: CONST.POLICY.ROLE.ADMIN, - type: CONST.POLICY.TYPE.TEAM, - owner: RORY_EMAIL, - outputCurrency: CONST.CURRENCY.USD, - isPolicyExpenseChatEnabled: true, - employeeList: { - [CARLOS_EMAIL]: { - role: CONST.POLICY.ROLE.ADMIN, - }, - }, - }; - - const policyExpenseChat: Report = { - reportID: '2', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - policyID: policy.id, - participants: { - [RORY_ACCOUNT_ID]: RORY_PARTICIPANT, - [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT, - }, - }; - - // New accountant that is NOT in the workspace employee list (this will trigger the invitation) - const accountant = { - accountID: 999, - login: 'newaccountant@test.com', - email: 'newaccountant@test.com', - }; - - mockFetch?.pause?.(); - - // Setup initial data - await Promise.all([ - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport), - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat), - Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy), - Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountant.accountID]: accountant}), - ]); - await waitForBatchedUpdates(); - - const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; - - // First create a tracked expense in self DM - trackExpense({ - report: selfDMReport, - isDraftPolicy: true, - action: CONST.IOU.ACTION.CREATE, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {accountID: RORY_ACCOUNT_ID}, - }, - transactionParams: { - amount, - currency: CONST.CURRENCY.USD, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'Test Merchant', - comment, - billable: false, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }); - - mockFetch?.resume?.(); - await waitForBatchedUpdates(); - - // Capture the created tracked expense data - let selfDMReportID: string | undefined; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - const selfDMReportOnyx = Object.values(reports ?? {}).find((report) => report?.reportID === selfDMReport.reportID); - selfDMReportID = selfDMReportOnyx?.reportID; - }, - }); - - const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReportID}`); - const actions = Object.values(reportActions ?? {}); - const linkedTrackedExpenseReportAction = actions.find((action) => action && isMoneyRequestAction(action)); - const actionableWhisperReportActionID = actions.find((action) => action && isActionableTrackExpense(action))?.reportActionID; - - let linkedTrackedExpenseReportID: string | undefined; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - const transaction = Object.values(allTransactions ?? {}).find((t) => !isEmptyObject(t)); - linkedTrackedExpenseReportID = transaction?.reportID; - }, - }); - - // Now pause fetch and share the tracked expense with accountant - mockFetch?.pause?.(); - trackExpense({ - report: policyExpenseChat, - isDraftPolicy: false, - action: CONST.IOU.ACTION.SHARE, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, - }, - policyParams: { - policy, - }, - transactionParams: { - amount, - currency: CONST.CURRENCY.USD, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'Test Merchant', - comment, - billable: false, - actionableWhisperReportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - }, - accountantParams: { - accountant, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - - // Simulate network failure - mockFetch?.fail?.(); - await (mockFetch?.resume?.() as Promise); - - // Verify error handling after failure - focus on workspace invitation error - const policyData = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); - - // The new accountant should have been added to the employee list with error - const accountantEmployee = policyData?.employeeList?.[accountant.email]; - expect(accountantEmployee).toBeTruthy(); - expect(accountantEmployee?.errors).toBeTruthy(); - expect(Object.values(accountantEmployee?.errors ?? {}).at(0)).toEqual(translateLocal('workspace.people.error.genericAdd')); - - // Cleanup - mockFetch?.succeed?.(); - }); - - it('does not trigger notifyNewAction when doing the money request in a money request report', () => { - requestMoney({ - report: {reportID: '123', type: CONST.REPORT.TYPE.EXPENSE}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount: 1, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment: '', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - expect(notifyNewAction).toHaveBeenCalledTimes(0); - }); - - it('trigger notifyNewAction when doing the money request in a chat report', () => { - requestMoney({ - report: {reportID: '123'}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount: 1, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment: '', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - expect(Navigation.setNavigationActionToMicrotaskQueue).toHaveBeenCalledTimes(1); - }); - - it('should pass isSelfTourViewed true to the request when user has viewed the tour', () => { - const {iouReport} = requestMoney({ - report: {reportID: ''}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount: 1000, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test Merchant', - comment: 'Test comment', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: true, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - // Verify that the iouReport is created successfully when isSelfTourViewed is true - expect(iouReport).toBeDefined(); - expect(iouReport?.reportID).toBeDefined(); - }); - - it('increase the nonReimbursableTotal only when the expense is not reimbursable', async () => { - const expenseReport: Report = { - ...createRandomReport(0, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - nonReimbursableTotal: 0, - total: 0, - ownerAccountID: RORY_ACCOUNT_ID, - currency: CONST.CURRENCY.USD, - }; - const workspaceChat: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), - type: CONST.REPORT.TYPE.CHAT, - iouReportID: expenseReport.reportID, - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${workspaceChat.reportID}`, workspaceChat); - - requestMoney({ - report: expenseReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {reportID: workspaceChat.reportID, isPolicyExpenseChat: true}, - }, - transactionParams: { - amount: 100, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment: '', - reimbursable: true, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: 123, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - currentUserEmailParam: 'existing@example.com', - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - - await waitForBatchedUpdates(); - - const nonReimbursableTotal = await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - callback: (report) => { - Onyx.disconnect(connection); - resolve(report?.nonReimbursableTotal ?? 0); - }, - }); - }); - - expect(nonReimbursableTotal).toBe(0); - - requestMoney({ - report: expenseReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {reportID: workspaceChat.reportID, isPolicyExpenseChat: true}, - }, - transactionParams: { - amount: 100, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment: '', - reimbursable: false, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - - await waitForBatchedUpdates(); - - const newNonReimbursableTotal = await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`, - callback: (report) => { - Onyx.disconnect(connection); - resolve(report?.nonReimbursableTotal ?? 0); - }, - }); - }); - - expect(newNonReimbursableTotal).toBe(-100); - }); - - it('should update policyRecentlyUsedTags when tag is provided', async () => { - // Given a policy recently used tags - const transactionTag = 'new tag'; - const policyID = 'A'; - const tagName = 'Tag'; - const expenseReport: Report = { - ...createRandomReport(0, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - nonReimbursableTotal: 0, - total: 0, - ownerAccountID: RORY_ACCOUNT_ID, - currency: CONST.CURRENCY.USD, - policyID, - }; - const policyRecentlyUsedTags: OnyxEntry = { - [tagName]: ['old tag'], - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { - [tagName]: {name: tagName}, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, policyRecentlyUsedTags); - - // When requesting money - requestMoney({ - report: expenseReport, - existingIOUReport: expenseReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {reportID: '1', isPolicyExpenseChat: true}, - }, - policyParams: {policyRecentlyUsedTags}, - transactionParams: { - amount: 100, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment: '', - tag: transactionTag, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: currentUserPersonalDetails.accountID, - currentUserEmailParam: currentUserPersonalDetails.login ?? '', - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - waitForBatchedUpdates(); - - // Then the transaction tag should be added to the recently used tags collection - const newPolicyRecentlyUsedTags: RecentlyUsedTags = await new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, - callback: (recentlyUsedTags) => { - resolve(recentlyUsedTags ?? {}); - Onyx.disconnect(connection); - }, - }); - }); - expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); - expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); - }); - - it('should use personalDetails to create expense with participant display name', async () => { - const testPersonalDetails: PersonalDetailsList = { - [CARLOS_ACCOUNT_ID]: { - accountID: CARLOS_ACCOUNT_ID, - login: CARLOS_EMAIL, - displayName: 'Carlos Martinez', - firstName: 'Carlos', - lastName: 'Martinez', - avatar: 'https://example.com/carlos.jpg', - }, - [RORY_ACCOUNT_ID]: { - accountID: RORY_ACCOUNT_ID, - login: RORY_EMAIL, - displayName: 'Rory Smith', - firstName: 'Rory', - lastName: 'Smith', - avatar: 'https://example.com/rory.jpg', - }, - }; - - const amount = 5000; - const comment = 'Test expense with personal details'; - const merchant = 'Test Store'; - - const {iouReport} = requestMoney({ - report: {reportID: ''}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - policyRecentlyUsedCurrencies: [], - isSelfTourViewed: false, - quickAction: undefined, - existingTransactionDraft: undefined, - draftTransactionIDs: [], - personalDetails: testPersonalDetails, - betas: [CONST.BETAS.ALL], - }); - - expect(iouReport).toBeDefined(); - expect(iouReport?.reportID).toBeDefined(); - - // Verify that the expense was created successfully with the personal details - await waitForBatchedUpdates(); - - const createdIouReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`); - expect(createdIouReport).toBeDefined(); - expect(createdIouReport?.ownerAccountID).toBe(RORY_ACCOUNT_ID); - }); - - it('should create expense correctly when personalDetails contains multiple users', async () => { - const testPersonalDetails: PersonalDetailsList = { - [CARLOS_ACCOUNT_ID]: { - accountID: CARLOS_ACCOUNT_ID, - login: CARLOS_EMAIL, - displayName: 'Carlos Martinez', - firstName: 'Carlos', - lastName: 'Martinez', - }, - [JULES_ACCOUNT_ID]: { - accountID: JULES_ACCOUNT_ID, - login: JULES_EMAIL, - displayName: 'Jules Thompson', - firstName: 'Jules', - lastName: 'Thompson', - }, - [RORY_ACCOUNT_ID]: { - accountID: RORY_ACCOUNT_ID, - login: RORY_EMAIL, - displayName: 'Rory Smith', - firstName: 'Rory', - lastName: 'Smith', - }, - [VIT_ACCOUNT_ID]: { - accountID: VIT_ACCOUNT_ID, - login: VIT_EMAIL, - displayName: 'Vit Developer', - firstName: 'Vit', - lastName: 'Developer', - }, - }; - - const amount = 10000; - const {iouReport} = requestMoney({ - report: {reportID: ''}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: JULES_EMAIL, accountID: JULES_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Multi-user test', - comment: 'Testing with multiple personal details', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - policyRecentlyUsedCurrencies: [], - isSelfTourViewed: false, - quickAction: undefined, - existingTransactionDraft: undefined, - draftTransactionIDs: [], - personalDetails: testPersonalDetails, - betas: [CONST.BETAS.ALL], - }); - - expect(iouReport).toBeDefined(); - expect(iouReport?.reportID).toBeDefined(); - - await waitForBatchedUpdates(); - - // Verify the IOU report was created successfully with multiple users in personalDetails - const createdIouReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`); - expect(createdIouReport).toBeDefined(); - // The IOU report should have the correct owner (payee) - expect(createdIouReport?.ownerAccountID).toBe(RORY_ACCOUNT_ID); - }); - - it('should handle empty personalDetails gracefully', async () => { - const amount = 2500; - - const {iouReport} = requestMoney({ - report: {reportID: ''}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Empty details test', - comment: 'Testing with empty personal details', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - policyRecentlyUsedCurrencies: [], - isSelfTourViewed: false, - quickAction: undefined, - existingTransactionDraft: undefined, - draftTransactionIDs: [], - personalDetails: {}, - betas: [CONST.BETAS.ALL], - }); - - // Should still create the expense even with empty personalDetails - expect(iouReport).toBeDefined(); - - await waitForBatchedUpdates(); - - const createdIouReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`); - expect(createdIouReport).toBeDefined(); - }); - - it('should update the parentReportID and parentReportActionID of the transactionThreadReport of the transaction when submitted to another report', async () => { - const amount = 10000; - const comment = 'Send me money please'; - const chatReport: OnyxEntry = { - reportID: '1234', - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }; - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: '10', - }; - const TEST_USER_ACCOUNT_ID = 1; - const TEST_USER_LOGIN = 'test@test.com'; - - // Given a test user is signed in with Onyx setup and some initial data - await signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); - subscribeToUserEvents(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN, undefined); - await waitForBatchedUpdates(); - await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); - const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; - - // Create a tracked expense - trackExpense({ - report: selfDMReport, - isDraftPolicy: true, - action: CONST.IOU.ACTION.CREATE, - participantParams: { - payeeEmail: TEST_USER_LOGIN, - payeeAccountID: TEST_USER_ACCOUNT_ID, - participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, - }, - transactionParams: { - amount, - currency: 'USD', - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: comment, - billable: false, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - - // When fetching all reports from Onyx - const allReports = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - Onyx.disconnect(connection); - resolve(reports); - }, - }); - }); - - // Then we should have exactly 2 reports - expect(Object.values(allReports ?? {}).length).toBe(3); - - // Then one of them should be a chat report with relevant properties - const transactionThreadReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT && report?.parentReportID === selfDMReport.reportID); - expect(transactionThreadReport).toBeTruthy(); - expect(transactionThreadReport).toHaveProperty('reportID'); - expect(transactionThreadReport).toHaveProperty('parentReportActionID'); - - await waitForBatchedUpdates(); - - // When fetching all report actions from Onyx - const allReportActions = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (actions) => { - Onyx.disconnect(connection); - resolve(actions); - }, - }); - }); - - // Then we should find an IOU action with specific properties - const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`]; - const createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.reportActionID === transactionThreadReport?.parentReportActionID, - ); - expect(createIOUAction).toBeTruthy(); - expect(createIOUAction?.childReportID).toBe(transactionThreadReport?.reportID); - - // When fetching all transactions from Onyx - const allTransactions = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - Onyx.disconnect(connection); - resolve(transactions); - }, - }); - }); - - // Then we should find a specific transaction with relevant properties - const transaction = Object.values(allTransactions ?? {}).find((t) => t); - expect(transaction).toBeTruthy(); - expect(transaction?.amount).toBe(-amount); - expect(transaction?.reportID).toBe(CONST.REPORT.UNREPORTED_REPORT_ID); - expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUTransactionID).toBe(transaction?.transactionID); - - // When: submitting the tracked expense to another user - const {iouReport} = requestMoney({ - action: CONST.IOU.ACTION.SUBMIT, - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment, - linkedTrackedExpenseReportAction: createIOUAction, - linkedTrackedExpenseReportID: selfDMReport.reportID, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - isSelfTourViewed: false, - existingTransactionDraft: transaction, - draftTransactionIDs: [], - personalDetails: {}, - betas: [CONST.BETAS.ALL], - }); - - await waitForBatchedUpdates(); - - // Then: the parentReportID and parentReportActionID of the transactionThreadReport should be updated correctly - const updatedTransactionThreadReport = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - Onyx.disconnect(connection); - resolve(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`]); - }, - }); - }); - - const iouReportActionID = getIOUActionForReportID(iouReport?.reportID, transaction?.transactionID)?.reportActionID; - expect(updatedTransactionThreadReport).toBeTruthy(); - expect(updatedTransactionThreadReport?.parentReportID).toBe(iouReport?.reportID); - expect(updatedTransactionThreadReport?.parentReportActionID).toBe(iouReportActionID); - - // Also, the fromReportID of movedTransactionAction should be CONST.REPORT.UNREPORTED_REPORT_ID - const updatedTransactionThreadReportActions = getAllReportActions(transactionThreadReport?.reportID); - const movedTransactionAction = Object.values(updatedTransactionThreadReportActions ?? {}).find( - (reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, - ); - expect(movedTransactionAction).toBeTruthy(); - const originalMessage = getOriginalMessage(movedTransactionAction) as OriginalMessageMovedTransaction | undefined; - expect(originalMessage?.fromReportID).toBe(CONST.REPORT.UNREPORTED_REPORT_ID); - }); - - it('creates new chat report when participant does not match existing chat report participants', () => { - const amount = 10000; - const comment = 'Test participant mismatch'; - - // Create an existing chat report between RORY and JULES (not CARLOS) - const existingChatReport: Report = { - reportID: '9999', - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [JULES_ACCOUNT_ID]: JULES_PARTICIPANT}, - }; - - mockFetch?.pause?.(); - - return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingChatReport.reportID}`, existingChatReport) - .then(() => { - // Request money from CARLOS, but pass the existing chat report with JULES - // This simulates the scenario where submit frequency is disabled and user selects a different participant - requestMoney({ - report: existingChatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test', - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - isSelfTourViewed: false, - betas: [CONST.BETAS.ALL], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - personalDetails: {}, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // A NEW chat report should be created for RORY and CARLOS - // The existing chat report between RORY and JULES should still exist - const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); - - // There should be at least 2 chat reports (existing one + new one for RORY/CARLOS) - // Plus a transaction thread - expect(chatReports.length).toBeGreaterThanOrEqual(2); - - // Find the chat report that has RORY and CARLOS as participants - const newChatReport = chatReports.find((report) => { - const participantKeys = Object.keys(report?.participants ?? {}).map(Number); - return participantKeys.includes(RORY_ACCOUNT_ID) && participantKeys.includes(CARLOS_ACCOUNT_ID) && participantKeys.length === 2; - }); - - // The new chat report should exist and NOT be the existing one - expect(newChatReport).toBeDefined(); - expect(newChatReport?.reportID).not.toBe(existingChatReport.reportID); - - // The new chat report should have RORY and CARLOS as participants - expect(newChatReport?.participants).toEqual({ - [RORY_ACCOUNT_ID]: RORY_PARTICIPANT, - [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT, - }); - - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume); - }); - - it('reuses existing chat report when participant matches chat report participants', () => { - const amount = 10000; - const comment = 'Test participant match'; - - // Create an existing chat report between RORY and CARLOS (matching the participant) - const existingChatReport: Report = { - reportID: '8888', - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }; - - mockFetch?.pause?.(); - - return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingChatReport.reportID}`, existingChatReport) - .then(() => { - // Request money from CARLOS with matching chat report - requestMoney({ - report: existingChatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test', - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - isSelfTourViewed: false, - betas: [CONST.BETAS.ALL], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - personalDetails: {}, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // The existing chat report should be reused - const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); - - // Find the chat report that has RORY and CARLOS as participants - const chatReportWithParticipants = chatReports.find((report) => { - const participantKeys = Object.keys(report?.participants ?? {}).map(Number); - return participantKeys.includes(RORY_ACCOUNT_ID) && participantKeys.includes(CARLOS_ACCOUNT_ID) && participantKeys.length === 2; - }); - - // The existing chat report should be reused - expect(chatReportWithParticipants?.reportID).toBe(existingChatReport.reportID); - - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume); - }); - - it('skips participant validation for policy expense chat participant', () => { - const amount = 10000; - const comment = 'Test policy expense chat'; - const policyID = 'policy123'; - - // Create a policy expense chat report - const policyExpenseChatReport: Report = { - reportID: '7777', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - policyID, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT}, - }; - - mockFetch?.pause?.(); - - return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChatReport.reportID}`, policyExpenseChatReport) - .then(() => { - // Request money with isPolicyExpenseChat: true - should skip participant validation - requestMoney({ - report: policyExpenseChatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: policyExpenseChatReport.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test', - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - isSelfTourViewed: false, - betas: [CONST.BETAS.ALL], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - personalDetails: {}, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // The policy expense chat report should be reused (participant validation skipped) - const policyExpenseChats = Object.values(allReports ?? {}).filter((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - - // The original policy expense chat should still exist - expect(policyExpenseChats.some((report) => report?.reportID === policyExpenseChatReport.reportID)).toBe(true); - - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume); - }); - - it('skips participant validation when chatReport is a Policy Expense Chat', () => { - const amount = 10000; - const comment = 'Test chatReport is policy expense chat'; - const policyID = 'policy456'; - - // Create a policy expense chat report (the chatReport itself is a policy expense chat) - const policyExpenseChatReport: Report = { - reportID: '6666', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - policyID, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [JULES_ACCOUNT_ID]: JULES_PARTICIPANT}, - }; - - mockFetch?.pause?.(); - - return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChatReport.reportID}`, policyExpenseChatReport) - .then(() => { - // Request money from CARLOS but passing a policy expense chat report with different participants (JULES) - // Since the chatReport is a policy expense chat, participant validation should be skipped - requestMoney({ - report: policyExpenseChatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test', - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - isSelfTourViewed: false, - betas: [CONST.BETAS.ALL], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - personalDetails: {}, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // Even though participants don't match (JULES vs CARLOS), - // since the chatReport is a policy expense chat, it should be reused - // (no new 1:1 DM chat should be created for this case) - const policyExpenseChats = Object.values(allReports ?? {}).filter((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - - // The policy expense chat should still exist - expect(policyExpenseChats.some((report) => report?.reportID === policyExpenseChatReport.reportID)).toBe(true); - - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume); - }); - - it('skips participant validation for self-DM report with accountID 0', () => { - const amount = 10000; - const comment = 'Test self-DM track expense'; - - // Create a self-DM report (Your Space) - const selfDMReport: Report = { - reportID: '5555', - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT}, - }; - - mockFetch?.pause?.(); - - return Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport) - .then(() => { - // Track expense in self-DM with accountID: 0 (as getMoneyRequestParticipantsFromReport does) - // This simulates the scenario where user starts an expense from "Your Space" - requestMoney({ - report: selfDMReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - // accountID: 0 is used for self-DM participants (represents the report itself, not another user) - participant: {accountID: 0, reportID: selfDMReport.reportID, isPolicyExpenseChat: false}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test', - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - isSelfTourViewed: false, - betas: [CONST.BETAS.ALL], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - personalDetails: {}, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // The self-DM report should be reused (participant validation should be skipped) - // No new 1:1 DM chat with accountID 0 should be created - const selfDMReports = Object.values(allReports ?? {}).filter((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM); - - // The original self-DM report should still exist - expect(selfDMReports.some((report) => report?.reportID === selfDMReport.reportID)).toBe(true); - - // There should NOT be a new invalid chat report with accountID 0 participant - const chatReportsWithZeroParticipant = Object.values(allReports ?? {}).filter((report) => { - if (report?.type !== CONST.REPORT.TYPE.CHAT || report?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM) { - return false; - } - const participantKeys = Object.keys(report?.participants ?? {}).map(Number); - return participantKeys.includes(0); - }); - - // No chat reports should have accountID 0 as a participant (that would be invalid) - expect(chatReportsWithZeroParticipant.length).toBe(0); - - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume); - }); - }); describe('setMoneyRequestDistanceRate', () => { it('does not set distance rate if transaction is invalid', async () => { // Given an invalid transaction @@ -4705,226 +2469,6 @@ describe('actions/IOU', () => { }); }); - describe('should have valid parameters', () => { - let writeSpy: jest.SpyInstance; - const isValid = (value: unknown) => !value || typeof value !== 'object' || value instanceof Blob; - - beforeEach(() => { - writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - }); - - afterEach(() => { - writeSpy.mockRestore(); - }); - - test.each([ - [WRITE_COMMANDS.REQUEST_MONEY, CONST.IOU.ACTION.CREATE], - [WRITE_COMMANDS.CONVERT_TRACKED_EXPENSE_TO_REQUEST, CONST.IOU.ACTION.SUBMIT], - ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { - // When an expense is created - requestMoney({ - action, - report: {reportID: ''}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount: 10000, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'KFC', - comment: '', - linkedTrackedExpenseReportAction: { - reportActionID: '', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - created: '2024-10-30', - }, - actionableWhisperReportActionID: '1', - linkedTrackedExpenseReportID: '1', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - - await waitForBatchedUpdates(); - - // Then the correct API request should be made - expect(writeSpy).toHaveBeenCalledTimes(1); - - const [command, params] = writeSpy.mock.calls.at(0); - expect(command).toBe(expectedCommand); - - // And the parameters should be supported by XMLHttpRequest - for (const value of Object.values(params as Record)) { - expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); - } - }); - - it('adds grouped from snapshot optimistic data for grouped search queries', async () => { - const currentSearchQueryJSON = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - status: [CONST.SEARCH.STATUS.EXPENSE.DRAFTS, CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING] as SearchStatus, - sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, - sortOrder: CONST.SEARCH.SORT_ORDER.DESC, - groupBy: CONST.SEARCH.GROUP_BY.FROM, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - left: CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE, - right: 'yes', - }, - inputQuery: 'sortBy:date sortOrder:desc type:expense groupBy:from status:drafts,outstanding', - flatFilters: [], - hash: 71801560, - recentSearchHash: 1043581824, - similarSearchHash: 1832274510, - view: CONST.SEARCH.VIEW.TABLE, - } as SearchQueryJSON; - - const getCurrentSearchQueryJSONSpy = jest.spyOn(SearchQueryUtils, 'getCurrentSearchQueryJSON').mockReturnValue(currentSearchQueryJSON); - - requestMoney({ - action: CONST.IOU.ACTION.CREATE, - report: {reportID: ''}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount: 10000, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'KFC', - comment: '', - linkedTrackedExpenseReportAction: { - reportActionID: '', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - created: '2024-10-30', - }, - actionableWhisperReportActionID: '1', - linkedTrackedExpenseReportID: '1', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - personalDetails: {}, - }); - - await waitForBatchedUpdates(); - - expect(writeSpy).toHaveBeenCalledTimes(1); - const [, , requestData] = writeSpy.mock.calls.at(0) as [ApiCommand, Record, {optimisticData?: Array<{key: string}>}]; - const optimisticData = requestData.optimisticData ?? []; - const mainSnapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchQueryJSON.hash}`; - expect(optimisticData.some((update) => update.key === mainSnapshotKey)).toBeTruthy(); - - const newFlatFilters = currentSearchQueryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM); - newFlatFilters.push({ - key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, - filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: String(RORY_ACCOUNT_ID)}], - }); - const groupedTransactionsQueryJSON = SearchQueryUtils.buildSearchQueryJSON( - SearchQueryUtils.buildSearchQueryString({ - ...currentSearchQueryJSON, - groupBy: undefined, - flatFilters: newFlatFilters, - }), - ); - - expect(groupedTransactionsQueryJSON?.hash).toBeDefined(); - if (!groupedTransactionsQueryJSON) { - throw new Error('Expected grouped transactions query JSON to be defined'); - } - const groupedSnapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${groupedTransactionsQueryJSON.hash}`; - expect(optimisticData.some((update) => update.key === groupedSnapshotKey)).toBeTruthy(); - - getCurrentSearchQueryJSONSpy.mockRestore(); - }); - - test.each([ - [WRITE_COMMANDS.TRACK_EXPENSE, CONST.IOU.ACTION.CREATE], - [WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, CONST.IOU.ACTION.CATEGORIZE], - [WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, CONST.IOU.ACTION.SHARE], - ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { - const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; - - // When a track expense is created - trackExpense({ - report: {reportID: '123', policyID: 'A'}, - isDraftPolicy: false, - action, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount: 10000, - currency: CONST.CURRENCY.USD, - created: '2024-10-30', - merchant: 'KFC', - receipt: {}, - actionableWhisperReportActionID: '1', - linkedTrackedExpenseReportAction: { - reportActionID: '', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - created: '2024-10-30', - }, - linkedTrackedExpenseReportID: '1', - }, - accountantParams: action === CONST.IOU.ACTION.SHARE ? {accountant: {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL}} : undefined, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }); - - await waitForBatchedUpdates(); - - // Then the correct API request should be made - expect(writeSpy).toHaveBeenCalledTimes(1); - - const [command, params] = writeSpy.mock.calls.at(0); - expect(command).toBe(expectedCommand); - - if (expectedCommand === WRITE_COMMANDS.SHARE_TRACKED_EXPENSE) { - expect(params).toHaveProperty('policyName'); - } - - // And the parameters should be supported by XMLHttpRequest - for (const value of Object.values(params as Record)) { - expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); - } - }); - }); - describe('calculateDiffAmount', () => { it('should return 0 if iouReport is undefined', () => { const fakeTransaction: Transaction = { From f3e914f82282ca303c8375ed0b126c0027c92cb1 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Mon, 25 May 2026 11:24:16 +0700 Subject: [PATCH 2/5] refactor(tests): finish IOUTest.ts split, drop the file Move all remaining describes out of tests/actions/IOUTest.ts into focused files keyed by the source module they test, then delete the now-empty file. Continues PR #91203 (which moved requestMoney). New files: - tests/actions/TransactionTest.ts (changeTransactionsReport, ~983 lines) - corrects a misfiling; this function is in src/libs/actions/Transaction.ts, not IOU. - tests/actions/OdometerTransactionUtilsTest.ts (Odometer image setters) - tests/actions/IOU/SearchUpdateTest.ts (shouldOptimisticallyUpdateSearch) - tests/actions/IOU/CreateDraftTransactionTest.ts (createDraftTransactionAndNavigateToParticipantSelector) - tests/actions/IOU/MoneyRequestBuilderTest.ts (calculateDiffAmount) - tests/actions/IOU/MoneyRequestSettersTest.ts (combines setMoneyRequestDistanceRate, setMoneyRequestCategory, initMoneyRequest, resetDraftTransactionsCustomUnit, setMoneyRequest helpers) - tests/actions/IOU/SplitReportTotalsTest.ts (Report Totals Calculation for Split Expenses + createSplitsAndOnyxData) - tests/unit/libs/OptionsListUtilsTest.ts (getManagerMcTestParticipant) Deleted: tests/actions/IOUTest.ts (4,656 lines, 0 remaining describes). The `split expense`, `startSplitBill`, and `updateSplitTransactionsFromSplitExpensesFlow` describes that lived in IOUTest.ts were subsets of tests already in tests/actions/IOUTest/SplitTest.ts (verified by comparing it() names); deleting from IOUTest.ts loses no coverage. Each new file copies the shared preamble (imports, jest mocks, account constants) from IOUTest.ts and trims unused identifiers. All 9 new files lint clean, prettier clean; 98 jest tests pass across the new files; SplitTest.ts still passes its 91 tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../actions/IOU/CreateDraftTransactionTest.ts | 308 ++ tests/actions/IOU/MoneyRequestBuilderTest.ts | 277 + tests/actions/IOU/MoneyRequestSettersTest.ts | 838 +++ tests/actions/IOU/SearchUpdateTest.ts | 343 ++ tests/actions/IOU/SplitReportTotalsTest.ts | 728 +++ tests/actions/IOUTest.ts | 4656 ----------------- tests/actions/OdometerTransactionUtilsTest.ts | 212 + tests/actions/TransactionTest.ts | 1148 ++++ tests/unit/libs/OptionsListUtilsTest.ts | 178 + 9 files changed, 4032 insertions(+), 4656 deletions(-) create mode 100644 tests/actions/IOU/CreateDraftTransactionTest.ts create mode 100644 tests/actions/IOU/MoneyRequestBuilderTest.ts create mode 100644 tests/actions/IOU/MoneyRequestSettersTest.ts create mode 100644 tests/actions/IOU/SearchUpdateTest.ts create mode 100644 tests/actions/IOU/SplitReportTotalsTest.ts delete mode 100644 tests/actions/IOUTest.ts create mode 100644 tests/actions/OdometerTransactionUtilsTest.ts create mode 100644 tests/actions/TransactionTest.ts create mode 100644 tests/unit/libs/OptionsListUtilsTest.ts diff --git a/tests/actions/IOU/CreateDraftTransactionTest.ts b/tests/actions/IOU/CreateDraftTransactionTest.ts new file mode 100644 index 000000000000..69b4d8807b45 --- /dev/null +++ b/tests/actions/IOU/CreateDraftTransactionTest.ts @@ -0,0 +1,308 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Onyx from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import '@libs/actions/IOU/MoneyRequest'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import type * as PolicyUtils from '@libs/PolicyUtils'; +import {createDraftTransactionAndNavigateToParticipantSelector} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; +import type Transaction from '@src/types/onyx/Transaction'; +import currencyList from '../../unit/currencyList.json'; +import {createRandomReport} from '../../utils/collections/reports'; +import createRandomTransaction from '../../utils/collections/transaction'; +import type {MockFetch} from '../../utils/TestHelper'; +import {getGlobalFetchMock, getOnyxData} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const topMostReportID = '23423423'; +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissToSuperWideRHP: jest.fn(), + navigateBackToLastSuperWideRHPScreen: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => topMostReportID), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +// In production, requestMoney defers its API.write() call until the target screen's +// content lays out (or a safety timeout fires). In tests there is no target component +// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +jest.mock('@libs/deferredLayoutWrite', () => ({ + registerDeferredWrite: (_key: string, callback: () => void) => callback(), + flushDeferredWrite: jest.fn(), + cancelDeferredWrite: jest.fn(), + hasDeferredWrite: () => false, + getOptimisticWatchKey: () => undefined, + deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), + reserveDeferredWriteChannel: jest.fn(), + resetForTesting: jest.fn(), +})); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +const unapprovedCashHash = 71801560; +const unapprovedCashSimilarSearchHash = 1832274510; +jest.mock('@src/libs/SearchQueryUtils', () => { + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: unapprovedCashSimilarSearchHash, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual('@libs/PolicyUtils'), + isPaidGroupPolicy: jest.fn().mockReturnValue(true), + isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), +})); + +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; + +OnyxUpdateManager(); +describe('actions/IOU', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllTimers(); + global.fetch = getGlobalFetchMock(); + mockFetch = fetch as MockFetch; + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createDraftTransactionAndNavigateToParticipantSelector', () => { + it('should clear existing draft transactions when draftTransactionIDs is provided', async () => { + // Given existing draft transactions + const existingDraftTransaction1: Transaction = {...createRandomTransaction(1), transactionID: 'existing-draft-1'}; + const existingDraftTransaction2: Transaction = {...createRandomTransaction(2), transactionID: 'existing-draft-2'}; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingDraftTransaction1.transactionID}`, existingDraftTransaction1); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingDraftTransaction2.transactionID}`, existingDraftTransaction2); + + // Given a selfDM report and a transaction to categorize + const selfDMReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM); + const transactionToCategorize: Transaction = {...createRandomTransaction(3), transactionID: 'transaction-to-categorize'}; + + // Given a report action ID for the track expense + const reportActionID = '1'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionToCategorize.transactionID}`, transactionToCategorize); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When createDraftTransactionAndNavigateToParticipantSelector is called with draftTransactionIDs + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: selfDMReport.reportID, + actionName: CONST.IOU.ACTION.CATEGORIZE, + reportActionID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + draftTransactionIDs: [existingDraftTransaction1.transactionID, existingDraftTransaction2.transactionID], + activePolicy: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + transaction: transactionToCategorize, + currentUserAccountID: RORY_ACCOUNT_ID, + currentUserEmail: RORY_EMAIL, + currentUserLocalCurrency: '', + }); + await waitForBatchedUpdates(); + + // Then the existing draft transactions should be cleared + let updatedTransactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + updatedTransactionDrafts = val; + }, + }); + + // Old drafts should be cleared + expect(updatedTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingDraftTransaction1.transactionID}`]).toBeFalsy(); + expect(updatedTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingDraftTransaction2.transactionID}`]).toBeFalsy(); + + // New draft should be created for the transaction being categorized + expect(updatedTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionToCategorize.transactionID}`]).toBeTruthy(); + }); + + it('should create a draft transaction with correct data when categorizing', async () => { + // Given a selfDM report and a transaction with specific data + const selfDMReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM); + const originalTransaction: Transaction = { + ...createRandomTransaction(1), + transactionID: 'original-transaction', + amount: 5000, + currency: 'USD', + }; + + // Given a report action ID for the track expense + const reportActionID = '1'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransaction.transactionID}`, originalTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When createDraftTransactionAndNavigateToParticipantSelector is called with empty allTransactionDrafts + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: selfDMReport.reportID, + actionName: CONST.IOU.ACTION.CATEGORIZE, + reportActionID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + draftTransactionIDs: [], + activePolicy: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + transaction: originalTransaction, + currentUserAccountID: RORY_ACCOUNT_ID, + currentUserEmail: RORY_EMAIL, + currentUserLocalCurrency: '', + }); + await waitForBatchedUpdates(); + + // Then a draft transaction should be created with the correct data + let transactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + transactionDrafts = val; + }, + }); + + const draftTransaction = transactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${originalTransaction.transactionID}`]; + expect(draftTransaction).toBeTruthy(); + expect(draftTransaction?.amount).toBe(-originalTransaction.amount); + expect(draftTransaction?.currency).toBe(originalTransaction.currency); + expect(draftTransaction?.actionableWhisperReportActionID).toBe(reportActionID); + expect(draftTransaction?.linkedTrackedExpenseReportID).toBe(selfDMReport.reportID); + }); + + it('should not create draft transaction when transaction is undefined', async () => { + // Given a selfDM report + const selfDMReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When createDraftTransactionAndNavigateToParticipantSelector is called with undefined transaction + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: selfDMReport.reportID, + actionName: CONST.IOU.ACTION.CATEGORIZE, + reportActionID: 'some-report-action-id', + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + draftTransactionIDs: [], + activePolicy: undefined, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + transaction: undefined, + currentUserAccountID: RORY_ACCOUNT_ID, + currentUserEmail: RORY_EMAIL, + currentUserLocalCurrency: '', + }); + await waitForBatchedUpdates(); + + // Then no draft transaction should be created + let transactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + transactionDrafts = val; + }, + }); + + expect(Object.keys(transactionDrafts ?? {}).length).toBe(0); + }); + + it('should not create draft transaction when reportID is undefined', async () => { + // Given a transaction + const transaction: Transaction = {...createRandomTransaction(1), transactionID: 'test-transaction'}; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + + // When createDraftTransactionAndNavigateToParticipantSelector is called with undefined reportID + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: undefined, + actionName: CONST.IOU.ACTION.CATEGORIZE, + reportActionID: 'some-report-action-id', + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + draftTransactionIDs: [], + activePolicy: undefined, + transaction, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + currentUserAccountID: RORY_ACCOUNT_ID, + currentUserEmail: RORY_EMAIL, + currentUserLocalCurrency: '', + }); + await waitForBatchedUpdates(); + + // Then no draft transaction should be created + let transactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + transactionDrafts = val; + }, + }); + + expect(Object.keys(transactionDrafts ?? {}).length).toBe(0); + }); + }); +}); diff --git a/tests/actions/IOU/MoneyRequestBuilderTest.ts b/tests/actions/IOU/MoneyRequestBuilderTest.ts new file mode 100644 index 000000000000..62d5350b8a20 --- /dev/null +++ b/tests/actions/IOU/MoneyRequestBuilderTest.ts @@ -0,0 +1,277 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import '@libs/actions/IOU/MoneyRequest'; +import {calculateDiffAmount} from '@libs/actions/IOU/MoneyRequestBuilder'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import type * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; +import type Transaction from '@src/types/onyx/Transaction'; +import currencyList from '../../unit/currencyList.json'; +import {createRandomReport} from '../../utils/collections/reports'; +import createRandomTransaction from '../../utils/collections/transaction'; +import type {MockFetch} from '../../utils/TestHelper'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const topMostReportID = '23423423'; +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissToSuperWideRHP: jest.fn(), + navigateBackToLastSuperWideRHPScreen: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => topMostReportID), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +// In production, requestMoney defers its API.write() call until the target screen's +// content lays out (or a safety timeout fires). In tests there is no target component +// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +jest.mock('@libs/deferredLayoutWrite', () => ({ + registerDeferredWrite: (_key: string, callback: () => void) => callback(), + flushDeferredWrite: jest.fn(), + cancelDeferredWrite: jest.fn(), + hasDeferredWrite: () => false, + getOptimisticWatchKey: () => undefined, + deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), + reserveDeferredWriteChannel: jest.fn(), + resetForTesting: jest.fn(), +})); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +const unapprovedCashHash = 71801560; +const unapprovedCashSimilarSearchHash = 1832274510; +jest.mock('@src/libs/SearchQueryUtils', () => { + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: unapprovedCashSimilarSearchHash, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual('@libs/PolicyUtils'), + isPaidGroupPolicy: jest.fn().mockReturnValue(true), + isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), +})); + +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; + +OnyxUpdateManager(); +describe('actions/IOU', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllTimers(); + global.fetch = getGlobalFetchMock(); + mockFetch = fetch as MockFetch; + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('calculateDiffAmount', () => { + it('should return 0 if iouReport is undefined', () => { + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + reportID: '1', + amount: 100, + currency: 'USD', + }; + + expect(calculateDiffAmount(undefined, fakeTransaction, fakeTransaction)).toBe(0); + }); + + it('should return 0 when the currency and amount of the transactions are the same', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + reportID: fakeReport.reportID, + amount: 100, + currency: 'USD', + }; + + expect(calculateDiffAmount(fakeReport, fakeTransaction, fakeTransaction)).toBe(0); + }); + + it('should return the difference between the updated amount and the current amount when the currency of the updated and current transactions have the same currency', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + currency: 'USD', + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + amount: 100, + currency: 'USD', + }; + const updatedTransaction = { + ...fakeTransaction, + amount: 200, + currency: 'USD', + }; + + expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBe(-100); + }); + + it('should return null when the currency of the updated and current transactions have different values', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + amount: 100, + currency: 'USD', + }; + const updatedTransaction = { + ...fakeTransaction, + amount: 200, + currency: 'EUR', + }; + + expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBeNull(); + }); + + it('should return 0 when the currency and amount of the transactions are the same for an invoice report', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.INVOICE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + reportID: fakeReport.reportID, + amount: 100, + currency: 'USD', + }; + + expect(calculateDiffAmount(fakeReport, fakeTransaction, fakeTransaction)).toBe(0); + }); + + it('should return the correct diff for an invoice report (same sign convention as expense reports)', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.INVOICE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + currency: 'USD', + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + amount: 100, + currency: 'USD', + }; + const updatedTransaction = { + ...fakeTransaction, + amount: 200, + currency: 'USD', + }; + + expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBe(-100); + }); + + it('should return null when the currency of the updated and current transactions differ for an invoice report', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.INVOICE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + amount: 100, + currency: 'USD', + }; + const updatedTransaction = { + ...fakeTransaction, + amount: 200, + currency: 'EUR', + }; + + expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBeNull(); + }); + }); +}); diff --git a/tests/actions/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts new file mode 100644 index 000000000000..a2a052502d7a --- /dev/null +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -0,0 +1,838 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import {format} from 'date-fns'; +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import { + initMoneyRequest, + resetDraftTransactionsCustomUnit, + setMoneyRequestAmount, + setMoneyRequestBillable, + setMoneyRequestCategory, + setMoneyRequestCreated, + setMoneyRequestDateAttribute, + setMoneyRequestDistanceRate, + setMoneyRequestMerchant, + setMoneyRequestTag, +} from '@libs/actions/IOU/MoneyRequest'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import Log from '@libs/Log'; +import type * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import DateUtils from '@src/libs/DateUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {LastSelectedDistanceRates, Policy, Report} from '@src/types/onyx'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; +import type Transaction from '@src/types/onyx/Transaction'; +import SafeString from '@src/utils/SafeString'; +import currencyList from '../../unit/currencyList.json'; +import createPersonalDetails from '../../utils/collections/personalDetails'; +import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import {createRandomReport} from '../../utils/collections/reports'; +import createRandomTransaction from '../../utils/collections/transaction'; +import getOnyxValue from '../../utils/getOnyxValue'; +import type {MockFetch} from '../../utils/TestHelper'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const topMostReportID = '23423423'; +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissToSuperWideRHP: jest.fn(), + navigateBackToLastSuperWideRHPScreen: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => topMostReportID), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +// In production, requestMoney defers its API.write() call until the target screen's +// content lays out (or a safety timeout fires). In tests there is no target component +// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +jest.mock('@libs/deferredLayoutWrite', () => ({ + registerDeferredWrite: (_key: string, callback: () => void) => callback(), + flushDeferredWrite: jest.fn(), + cancelDeferredWrite: jest.fn(), + hasDeferredWrite: () => false, + getOptimisticWatchKey: () => undefined, + deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), + reserveDeferredWriteChannel: jest.fn(), + resetForTesting: jest.fn(), +})); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +const unapprovedCashHash = 71801560; +const unapprovedCashSimilarSearchHash = 1832274510; +jest.mock('@src/libs/SearchQueryUtils', () => { + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: unapprovedCashSimilarSearchHash, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual('@libs/PolicyUtils'), + isPaidGroupPolicy: jest.fn().mockReturnValue(true), + isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), +})); + +const CARLOS_ACCOUNT_ID = 1; +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; + +OnyxUpdateManager(); +describe('actions/IOU', () => { + const currentUserPersonalDetails: CurrentUserPersonalDetails = { + ...createPersonalDetails(RORY_ACCOUNT_ID), + login: RORY_EMAIL, + email: RORY_EMAIL, + displayName: RORY_EMAIL, + avatar: 'https://example.com/avatar.jpg', + }; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllTimers(); + global.fetch = getGlobalFetchMock(); + mockFetch = fetch as MockFetch; + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('setMoneyRequestDistanceRate', () => { + it('does not set distance rate if transaction is invalid', async () => { + // Given an invalid transaction + const consoleWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {}); + + // When calling setMoneyRequestDistanceRate with invalid transaction + setMoneyRequestDistanceRate(undefined, 'customUnitRateID123', createRandomPolicy(1), false); + // Then a warning should be logged and distance rate should not be set + expect(consoleWarnSpy).toHaveBeenCalledWith('setMoneyRequestDistanceRate is called without a valid transaction, skipping setting distance rate.'); + const distanceRates = await getOnyxValue(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); + expect(distanceRates).toBeUndefined(); + consoleWarnSpy.mockRestore(); + }); + + it('sets the last selected distance rate for valid transaction', async () => { + const policy = createRandomPolicy(1); + // Given a valid transaction + const testTransaction: Transaction = { + transactionID: 'testTransaction123', + amount: 1000, + currency: CONST.CURRENCY.USD, + comment: { + comment: 'Test transaction', + attendees: [], + }, + created: DateUtils.getDBTime(), + merchant: 'Test Merchant', + reportID: 'testReport123', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`, testTransaction); + + // When calling setMoneyRequestDistanceRate with valid transaction + const customUnitRateID = 'customUnitRateID123'; + setMoneyRequestDistanceRate(testTransaction, customUnitRateID, policy, false); + await waitForBatchedUpdates(); + // Then the distance rate should be set in Onyx + const lastDistanceRates = (await getOnyxValue(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES)) as LastSelectedDistanceRates | undefined; + expect(lastDistanceRates?.[policy.id]).toBeDefined(); + expect(lastDistanceRates?.[policy.id]).toBe(customUnitRateID); + }); + + it('sets distance rate and distance unit for draft transaction', async () => { + const policy = createRandomPolicy(1); + policy.customUnits = { + distanceUnitID: { + customUnitID: 'distanceUnitID', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }, + rates: {}, + }, + }; + + const testTransaction: Transaction = { + transactionID: 'testTransaction123', + amount: 1000, + currency: CONST.CURRENCY.USD, + comment: { + comment: 'Test transaction', + }, + created: DateUtils.getDBTime(), + merchant: 'Test Merchant', + reportID: 'testReport123', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`, testTransaction); + + const customUnitRateID = 'customUnitRateID123'; + setMoneyRequestDistanceRate(testTransaction, customUnitRateID, policy, true); + await waitForBatchedUpdates(); + + const transactionDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`); + expect(transactionDraft?.comment?.customUnit?.customUnitRateID).toBe(customUnitRateID); + expect(transactionDraft?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + expect(transactionDraft?.comment?.customUnit?.defaultP2PRate).toBeUndefined(); + }); + + it('converts distance quantity if distance unit changes', async () => { + const policy = createRandomPolicy(1); + policy.customUnits = { + distanceUnitID: { + customUnitID: 'distanceUnitID', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }, + rates: {}, + }, + }; + + const testTransaction: Transaction = { + transactionID: 'testTransaction123', + amount: 1000, + currency: CONST.CURRENCY.USD, + comment: { + comment: 'Test transaction', + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10, + }, + }, + created: DateUtils.getDBTime(), + merchant: 'Test Merchant', + reportID: 'testReport123', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`, testTransaction); + + const customUnitRateID = 'customUnitRateID123'; + setMoneyRequestDistanceRate(testTransaction, customUnitRateID, policy, false); + await waitForBatchedUpdates(); + + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`); + expect(transaction?.comment?.customUnit?.customUnitRateID).toBe(customUnitRateID); + expect(transaction?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + // 10 miles to kilometers = 10 / 0.000621371 * 0.001 = 16.093444978925636 + expect(transaction?.comment?.customUnit?.quantity).toBe(16.093444978925636); + }); + + it('does not convert distance quantity if distance unit changes but it is an odometer request', async () => { + const policy = createRandomPolicy(1); + policy.customUnits = { + distanceUnitID: { + customUnitID: 'distanceUnitID', + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }, + rates: {}, + }, + }; + + const testTransaction: Transaction = { + transactionID: 'testTransaction123', + amount: 1000, + currency: CONST.CURRENCY.USD, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + comment: 'Test transaction', + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 10, + }, + }, + created: DateUtils.getDBTime(), + merchant: 'Test Merchant', + reportID: 'testReport123', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`, testTransaction); + + const customUnitRateID = 'customUnitRateID123'; + setMoneyRequestDistanceRate(testTransaction, customUnitRateID, policy, false); + await waitForBatchedUpdates(); + + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`); + expect(transaction?.comment?.customUnit?.customUnitRateID).toBe(customUnitRateID); + expect(transaction?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + // Quantity should remain 10 for odometer requests + expect(transaction?.comment?.customUnit?.quantity).toBe(10); + }); + + it('does not set defaultP2PRate to null when policy is undefined', async () => { + const testTransaction: Transaction = { + transactionID: 'testTransaction123', + amount: 1000, + currency: CONST.CURRENCY.USD, + comment: { + comment: 'Test transaction', + customUnit: { + defaultP2PRate: CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS, + }, + }, + created: DateUtils.getDBTime(), + merchant: 'Test Merchant', + reportID: 'testReport123', + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`, testTransaction); + + const customUnitRateID = 'customUnitRateID123'; + setMoneyRequestDistanceRate(testTransaction, customUnitRateID, undefined, false); + await waitForBatchedUpdates(); + + const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`); + expect(transaction?.comment?.customUnit?.customUnitRateID).toBe(customUnitRateID); + expect(transaction?.comment?.customUnit?.defaultP2PRate).toBe(CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS); + }); + }); + + describe('setMoneyRequestCategory', () => { + it('should set the associated tax for the category based on the tax expense rules', async () => { + // Given a policy with tax expense rules associated with category + const transactionID = '1'; + const category = 'Advertising'; + const policyID = '2'; + const taxCode = 'id_TAX_EXEMPT'; + const ruleTaxCode = 'id_TAX_RATE_1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + taxCode, + taxAmount: 0, + amount: 100, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + + // When setting the money request category + setMoneyRequestCategory(transactionID, category, fakePolicy); + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount should be updated based on the expense rules + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(ruleTaxCode); + expect(transaction?.taxAmount).toBe(5); + resolve(); + }, + }); + }); + }); + + describe('should not change the tax', () => { + it('if the transaction type is distance', async () => { + // Given a policy with tax expense rules associated with category and a distance transaction + const transactionID = '1'; + const category = 'Advertising'; + const policyID = '2'; + const taxCode = 'id_TAX_EXEMPT'; + const ruleTaxCode = 'id_TAX_RATE_1'; + const taxAmount = 0; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + taxCode, + taxAmount, + amount: 100, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + + // When setting the money request category + setMoneyRequestCategory(transactionID, category, fakePolicy); + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount shouldn't be updated + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(taxCode); + expect(transaction?.taxAmount).toBe(taxAmount); + resolve(); + }, + }); + }); + }); + + it('if there are no tax expense rules', async () => { + // Given a policy without tax expense rules + const transactionID = '1'; + const category = 'Advertising'; + const policyID = '2'; + const taxCode = 'id_TAX_EXEMPT'; + const taxAmount = 0; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + taxCode, + taxAmount, + amount: 100, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + + // When setting the money request category + setMoneyRequestCategory(transactionID, category, fakePolicy); + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount shouldn't be updated + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(taxCode); + expect(transaction?.taxAmount).toBe(taxAmount); + resolve(); + }, + }); + }); + }); + }); + + it('should clear the tax when the policyID is empty', async () => { + // Given a transaction with a tax + const transactionID = '1'; + const taxCode = 'id_TAX_EXEMPT'; + const taxAmount = 0; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + taxCode, + taxAmount, + amount: 100, + }); + + // When setting the money request category without a policyID + setMoneyRequestCategory(transactionID, '', undefined); + await waitForBatchedUpdates(); + + // Then the transaction tax should be cleared + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(''); + expect(transaction?.taxAmount).toBeUndefined(); + resolve(); + }, + }); + }); + }); + }); + + describe('initMoneyRequest', () => { + const fakeReport: Report = { + ...createRandomReport(0, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + managerID: CARLOS_ACCOUNT_ID, + }; + const fakePolicy: Policy = { + ...createRandomPolicy(1), + type: CONST.POLICY.TYPE.TEAM, + outputCurrency: 'USD', + }; + + const fakeParentReport: Report = { + ...createRandomReport(1, undefined), + reportID: fakeReport.reportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + managerID: CARLOS_ACCOUNT_ID, + }; + const fakePersonalPolicy: Pick = { + id: '2', + autoReporting: true, + type: CONST.POLICY.TYPE.PERSONAL, + outputCurrency: 'NZD', + }; + const transactionResult: Transaction = { + amount: 0, + comment: { + attendees: [ + { + email: currentUserPersonalDetails.email ?? '', + login: currentUserPersonalDetails.login, + accountID: 3, + text: currentUserPersonalDetails.login, + selected: true, + reportID: '0', + avatarUrl: SafeString(currentUserPersonalDetails.avatar) ?? '', + displayName: currentUserPersonalDetails.displayName ?? '', + }, + ], + }, + created: '2025-04-01', + currency: 'USD', + iouRequestType: 'manual', + reportID: fakeReport.reportID, + transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + isFromGlobalCreate: true, + merchant: 'Expense', + }; + + const currentDate = '2025-04-01'; + beforeEach(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, null); + await Onyx.merge(`${ONYXKEYS.CURRENT_DATE}`, currentDate); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + return waitForBatchedUpdates(); + }); + + it('should merge transaction draft onyx value', async () => { + await waitForBatchedUpdates() + .then(() => { + initMoneyRequest({ + reportID: fakeReport.reportID, + policy: fakePolicy, + personalPolicy: fakePersonalPolicy, + isFromGlobalCreate: true, + newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + report: fakeReport, + parentReport: fakeParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies: false, + draftTransactionIDs: [], + }); + }) + .then(async () => { + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); + }); + }); + + it('should modify transaction draft when currentIouRequestType is different', async () => { + await waitForBatchedUpdates() + .then(() => { + return initMoneyRequest({ + reportID: fakeReport.reportID, + policy: fakePolicy, + personalPolicy: fakePersonalPolicy, + isFromGlobalCreate: true, + currentIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + report: fakeReport, + parentReport: fakeParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies: false, + draftTransactionIDs: [], + }); + }) + .then(async () => { + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual({ + ...transactionResult, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + }); + }); + }); + it('should return personal currency when policy is missing', async () => { + await waitForBatchedUpdates() + .then(() => { + return initMoneyRequest({ + reportID: fakeReport.reportID, + personalPolicy: fakePersonalPolicy, + isFromGlobalCreate: true, + newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + report: fakeReport, + parentReport: fakeParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies: false, + draftTransactionIDs: [], + }); + }) + .then(async () => { + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual({ + ...transactionResult, + currency: fakePersonalPolicy.outputCurrency, + }); + }); + }); + + it('should remove non-optimistic draft transactions when draftTransactionIDs is provided', async () => { + const otherDraftTransactionID = '123456'; + const otherDraftTransaction: Transaction = { + ...createRandomTransaction(1), + transactionID: otherDraftTransactionID, + }; + + // Set up an additional draft transaction + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${otherDraftTransactionID}`, otherDraftTransaction); + + await waitForBatchedUpdates() + .then(() => { + initMoneyRequest({ + reportID: fakeReport.reportID, + policy: fakePolicy, + personalPolicy: fakePersonalPolicy, + isFromGlobalCreate: true, + newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + report: fakeReport, + parentReport: fakeParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies: false, + draftTransactionIDs: [otherDraftTransactionID], + }); + }) + .then(async () => { + // The other draft transaction should be removed (Onyx returns undefined for removed keys) + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${otherDraftTransactionID}`)).toBeUndefined(); + // The optimistic transaction should be created + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); + }); + }); + + it('should preserve optimistic transaction in draftTransactionIDs while removing others', async () => { + const otherDraftTransactionID = '789012'; + const otherDraftTransaction: Transaction = { + ...createRandomTransaction(2), + transactionID: otherDraftTransactionID, + }; + const existingOptimisticTransaction: Transaction = { + ...createRandomTransaction(3), + transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + }; + + // Set up both draft transactions + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${otherDraftTransactionID}`, otherDraftTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, existingOptimisticTransaction); + + await waitForBatchedUpdates() + .then(() => { + initMoneyRequest({ + reportID: fakeReport.reportID, + policy: fakePolicy, + personalPolicy: fakePersonalPolicy, + isFromGlobalCreate: true, + newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + report: fakeReport, + parentReport: fakeParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies: false, + draftTransactionIDs: [otherDraftTransactionID, CONST.IOU.OPTIMISTIC_TRANSACTION_ID], + }); + }) + .then(async () => { + // The other draft transaction should be removed (Onyx returns undefined for removed keys) + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${otherDraftTransactionID}`)).toBeUndefined(); + // The optimistic transaction should be updated with the new transaction result (not removed) + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); + }); + }); + + it('should remove multiple draft transactions when draftTransactionIDs contains several entries', async () => { + const draftTransactionID1 = '111111'; + const draftTransactionID2 = '222222'; + const draftTransaction1: Transaction = { + ...createRandomTransaction(4), + transactionID: draftTransactionID1, + }; + const draftTransaction2: Transaction = { + ...createRandomTransaction(5), + transactionID: draftTransactionID2, + }; + + // Set up multiple draft transactions + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransactionID1}`, draftTransaction1); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransactionID2}`, draftTransaction2); + + await waitForBatchedUpdates() + .then(() => { + initMoneyRequest({ + reportID: fakeReport.reportID, + policy: fakePolicy, + personalPolicy: fakePersonalPolicy, + isFromGlobalCreate: true, + newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + report: fakeReport, + parentReport: fakeParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies: false, + draftTransactionIDs: [draftTransactionID1, draftTransactionID2], + }); + }) + .then(async () => { + // Both draft transactions should be removed (Onyx returns undefined for removed keys) + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransactionID1}`)).toBeUndefined(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransactionID2}`)).toBeUndefined(); + // The optimistic transaction should be created + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); + }); + }); + }); + + describe('resetDraftTransactionsCustomUnit', () => { + it('should do nothing if transaction is not passed', async () => { + // Call the reset function without a transaction + resetDraftTransactionsCustomUnit(undefined); + await waitForBatchedUpdates(); + const allDraftTransactions = await getOnyxValue(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT); + // Assuming there are no draft transactions, this should be undefined or an empty object + expect(allDraftTransactions).toBeUndefined(); + }); + it('should reset custom unit for a transaction', async () => { + const transactionID = 'transaction_reset_001'; + const fakeTransaction: Transaction = { + transactionID, + amount: 1500, + currency: CONST.CURRENCY.USD, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Test Reset', + reportID: 'report_reset_001', + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 100, + }, + waypoints: { + waypoint0: {lat: 40.7128, lng: -74.006, address: 'NYC', name: 'NYC', keyForList: 'nyc_key'}, + }, + }, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); + await waitForBatchedUpdates(); + // Call the reset function + resetDraftTransactionsCustomUnit(fakeTransaction); + await waitForBatchedUpdates(); + // Verify the transaction's custom unit and waypoints have been reset + const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(updatedTransaction?.comment?.customUnit?.name).toBe(CONST.CUSTOM_UNITS.NAME_DISTANCE); + expect(updatedTransaction?.comment?.customUnit?.quantity).toBe(100); + }); + }); + + describe('setMoneyRequest helpers', () => { + const transactionID = 'testTransaction123'; + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('setMoneyRequestAmount should set amount, currency, and shouldShowOriginalAmount on transaction draft', async () => { + setMoneyRequestAmount(transactionID, 500, 'EUR', true); + await waitForBatchedUpdates(); + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draft?.amount).toBe(500); + expect(draft?.currency).toBe('EUR'); + expect(draft?.shouldShowOriginalAmount).toBe(true); + }); + + it('setMoneyRequestCreated should set created on transaction draft', async () => { + setMoneyRequestCreated(transactionID, '2024-01-15', true); + await waitForBatchedUpdates(); + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draft?.created).toBe('2024-01-15'); + }); + + it('setMoneyRequestDateAttribute should set date attributes on transaction draft', async () => { + setMoneyRequestDateAttribute(transactionID, '2024-01-01', '2024-01-31'); + await waitForBatchedUpdates(); + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draft?.comment?.customUnit?.attributes?.dates?.start).toBe('2024-01-01'); + expect(draft?.comment?.customUnit?.attributes?.dates?.end).toBe('2024-01-31'); + }); + + it('setMoneyRequestMerchant should set merchant on transaction draft', async () => { + setMoneyRequestMerchant(transactionID, 'Coffee Shop', true); + await waitForBatchedUpdates(); + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draft?.merchant).toBe('Coffee Shop'); + }); + + it('setMoneyRequestTag should set tag on transaction draft', async () => { + setMoneyRequestTag(transactionID, 'Engineering'); + await waitForBatchedUpdates(); + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draft?.tag).toBe('Engineering'); + }); + + it('setMoneyRequestBillable should set billable on transaction draft', async () => { + setMoneyRequestBillable(transactionID, true); + await waitForBatchedUpdates(); + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draft?.billable).toBe(true); + }); + }); +}); diff --git a/tests/actions/IOU/SearchUpdateTest.ts b/tests/actions/IOU/SearchUpdateTest.ts new file mode 100644 index 000000000000..ee0476bf4899 --- /dev/null +++ b/tests/actions/IOU/SearchUpdateTest.ts @@ -0,0 +1,343 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {SearchQueryJSON, SearchStatus} from '@components/Search/types'; +import '@libs/actions/IOU/MoneyRequest'; +import {shouldOptimisticallyUpdateSearch} from '@libs/actions/IOU/SearchUpdate'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import type * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; +import currencyList from '../../unit/currencyList.json'; +import {createRandomReport} from '../../utils/collections/reports'; +import createRandomTransaction from '../../utils/collections/transaction'; +import type {MockFetch} from '../../utils/TestHelper'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const topMostReportID = '23423423'; +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissToSuperWideRHP: jest.fn(), + navigateBackToLastSuperWideRHPScreen: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => topMostReportID), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +// In production, requestMoney defers its API.write() call until the target screen's +// content lays out (or a safety timeout fires). In tests there is no target component +// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +jest.mock('@libs/deferredLayoutWrite', () => ({ + registerDeferredWrite: (_key: string, callback: () => void) => callback(), + flushDeferredWrite: jest.fn(), + cancelDeferredWrite: jest.fn(), + hasDeferredWrite: () => false, + getOptimisticWatchKey: () => undefined, + deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), + reserveDeferredWriteChannel: jest.fn(), + resetForTesting: jest.fn(), +})); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +const unapprovedCashHash = 71801560; +const unapprovedCashSimilarSearchHash = 1832274510; +jest.mock('@src/libs/SearchQueryUtils', () => { + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: unapprovedCashSimilarSearchHash, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual('@libs/PolicyUtils'), + isPaidGroupPolicy: jest.fn().mockReturnValue(true), + isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), +})); + +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; + +OnyxUpdateManager(); +describe('actions/IOU', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllTimers(); + global.fetch = getGlobalFetchMock(); + mockFetch = fetch as MockFetch; + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('shouldOptimisticallyUpdateSearch', () => { + it('when the current hash is submit action query it should only return true if the iou report is in draft state', () => { + const transaction = { + ...createRandomTransaction(1), + }; + const currentSearchQueryJSON = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + status: '' as SearchStatus, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION, + right: 'submit', + }, + right: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + left: 'from', + right: '20671314', + }, + }, + inputQuery: 'sortBy:date sortOrder:desc type:expense-report action:submit from:20671314', + flatFilters: [ + { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + value: 'submit', + }, + ], + }, + { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + value: '20671314', + }, + ], + }, + ], + hash: 1920151829, + recentSearchHash: 2100977843, + similarSearchHash: 1855682507, + } as SearchQueryJSON; + const iouReport: Report = {...createRandomReport(2, undefined), type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + + // When the report is in draft status it should return true + expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeTruthy(); + + // If the report is not in draft state it should return false + iouReport.stateNum = CONST.REPORT.STATE_NUM.SUBMITTED; + iouReport.statusNum = CONST.REPORT.STATUS_NUM.SUBMITTED; + expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeFalsy(); + }); + + it('when the current hash is approve action query it should only return true if the iou report is in outstanding state', () => { + const transaction = { + ...createRandomTransaction(1), + }; + const currentSearchQueryJSON = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + status: '' as SearchStatus, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION, + right: 'approve', + }, + right: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + left: 'from', + right: '20671314', + }, + }, + flatFilters: [ + { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + value: 'approve', + }, + ], + }, + { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + value: '20671314', + }, + ], + }, + ], + + hash: 1510971479, + inputQuery: 'sortBy:date sortOrder:desc type:expense-report action:approve to:20671314', + recentSearchHash: 967911777, + similarSearchHash: 1539858783, + } as SearchQueryJSON; + const iouReport: Report = {...createRandomReport(2, undefined), type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + + // When the report is in draft status it should return false + expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeFalsy(); + + // If the report is in outstanding state it should return true + iouReport.stateNum = CONST.REPORT.STATE_NUM.SUBMITTED; + iouReport.statusNum = CONST.REPORT.STATUS_NUM.SUBMITTED; + expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeTruthy(); + }); + + it('when the current hash is unapproved cash action query it should only return true if the iou report is in either draft or outstanding state', () => { + const transaction = { + ...createRandomTransaction(1), + reimbursable: true, + }; + const currentSearchQueryJSON = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + status: '' as SearchStatus, + sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, + sortOrder: CONST.SEARCH.SORT_ORDER.DESC, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + left: 'reimbursable', + + right: 'yes', + }, + flatFilters: [ + { + key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE, + filters: [ + { + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + value: 'yes', + }, + ], + }, + ], + hash: 71801560, + inputQuery: 'sortBy:date sortOrder:desc type:expense groupBy:from status:drafts,outstanding reimbursable:yes', + recentSearchHash: 1043581824, + similarSearchHash: 1832274510, + } as SearchQueryJSON; + + const iouReport: Report = {...createRandomReport(2, undefined), type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; + + // When the report is in draft status it should return true + expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeTruthy(); + + // If the report is in approved state it should return false + iouReport.stateNum = CONST.REPORT.STATE_NUM.APPROVED; + iouReport.statusNum = CONST.REPORT.STATUS_NUM.APPROVED; + expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeFalsy(); + }); + + it('when the current hash includes a policyID filter it should only return true if the iou report matches the policyID filter', () => { + const transaction = { + ...createRandomTransaction(1), + }; + const policyID = '12345'; + const currentSearchQueryJSON = { + type: 'expense', + status: '', + sortBy: 'date', + sortOrder: 'desc', + policyID: [policyID], + filters: null, + inputQuery: `type:expense sortBy:date sortOrder:desc policyID:${policyID}`, + flatFilters: [], + hash: 591785022, + recentSearchHash: 714245044, + similarSearchHash: 1023624110, + rawFilterList: [ + { + key: 'policyID', + operator: 'eq', + value: policyID, + isDefault: true, + }, + ], + } as unknown as SearchQueryJSON; + + // When the IOU report has a matching policyID, it should return true + const matchingIOUReport: Report = { + ...createRandomReport(2, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, matchingIOUReport, false, transaction)).toBeTruthy(); + + // When the IOU report has a different policyID, it should return false + const nonMatchingIOUReport: Report = { + ...createRandomReport(3, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'differentPolicyID', + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, nonMatchingIOUReport, false, transaction)).toBeFalsy(); + }); + }); +}); diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts new file mode 100644 index 000000000000..a2e591998cb0 --- /dev/null +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -0,0 +1,728 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry, OnyxMergeCollectionInput} from 'react-native-onyx'; +import '@libs/actions/IOU/MoneyRequest'; +import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers'; +import {createSplitsAndOnyxData} from '@libs/actions/IOU/Split'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import {rand64} from '@libs/NumberUtils'; +import type * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy, PolicyTagLists, Report} from '@src/types/onyx'; +import type {Participant as IOUParticipant, SplitExpense} from '@src/types/onyx/IOU'; +import type {Participant} from '@src/types/onyx/Report'; +import type {SplitShares} from '@src/types/onyx/Transaction'; +import currencyList from '../../unit/currencyList.json'; +import type {MockFetch} from '../../utils/TestHelper'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const topMostReportID = '23423423'; +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissToSuperWideRHP: jest.fn(), + navigateBackToLastSuperWideRHPScreen: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => topMostReportID), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +// In production, requestMoney defers its API.write() call until the target screen's +// content lays out (or a safety timeout fires). In tests there is no target component +// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +jest.mock('@libs/deferredLayoutWrite', () => ({ + registerDeferredWrite: (_key: string, callback: () => void) => callback(), + flushDeferredWrite: jest.fn(), + cancelDeferredWrite: jest.fn(), + hasDeferredWrite: () => false, + getOptimisticWatchKey: () => undefined, + deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), + reserveDeferredWriteChannel: jest.fn(), + resetForTesting: jest.fn(), +})); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +const unapprovedCashHash = 71801560; +const unapprovedCashSimilarSearchHash = 1832274510; +jest.mock('@src/libs/SearchQueryUtils', () => { + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: unapprovedCashSimilarSearchHash, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual('@libs/PolicyUtils'), + isPaidGroupPolicy: jest.fn().mockReturnValue(true), + isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), +})); + +const CARLOS_EMAIL = 'cmartins@expensifail.com'; +const CARLOS_ACCOUNT_ID = 1; +const CARLOS_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; +const JULES_EMAIL = 'jules@expensifail.com'; +const JULES_ACCOUNT_ID = 2; +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; +const RORY_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'admin'}; +const VIT_EMAIL = 'vit@expensifail.com'; +const VIT_ACCOUNT_ID = 4; + +OnyxUpdateManager(); +describe('actions/IOU', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllTimers(); + global.fetch = getGlobalFetchMock(); + mockFetch = fetch as MockFetch; + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Report Totals Calculation for Split Expenses', () => { + function calculateReportTotalsForSplitExpenses( + expenseReport: Report | undefined, + splitExpenses: SplitExpense[], + allReportsList: Record | undefined, + changesInReportTotal: number, + ): Map { + const reportTotals = new Map(); + const expenseReportID = expenseReport?.reportID; + + if (expenseReportID) { + const expenseReportKey = `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`; + const expenseReportTotal = allReportsList?.[expenseReportKey]?.total ?? expenseReport?.total ?? 0; + reportTotals.set(expenseReportID, expenseReportTotal - changesInReportTotal); + } + + for (const expense of splitExpenses) { + const splitExpenseReportID = expense.reportID; + if (!splitExpenseReportID || reportTotals.has(splitExpenseReportID)) { + continue; + } + + const splitExpenseReport = allReportsList?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpenseReportID}`]; + reportTotals.set(splitExpenseReportID, splitExpenseReport?.total ?? 0); + } + + return reportTotals; + } + + it('should calculate expense report total minus changes when expense report ID exists', () => { + const expenseReport: Report = { + reportID: 'report1', + total: 10000, + } as Report; + + const splitExpenses: SplitExpense[] = []; + const allReportsList = { + [`${ONYXKEYS.COLLECTION.REPORT}report1`]: { + reportID: 'report1', + total: 10000, + } as Report, + }; + const changesInReportTotal = 2000; + + const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); + + expect(result.size).toBe(1); + expect(result.get('report1')).toBe(8000); // 10000 - 2000 + }); + + it('should use expense report total directly when not in allReportsList', () => { + const expenseReport: Report = { + reportID: 'report1', + total: 15000, + } as Report; + + const splitExpenses: SplitExpense[] = []; + const allReportsList = {}; // Empty, so should fall back to expenseReport.total + const changesInReportTotal = 3000; + + const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); + + expect(result.size).toBe(1); + expect(result.get('report1')).toBe(12000); // 15000 - 3000 + }); + + it('should use allReportsList total when it differs from expense report total', () => { + const expenseReport: Report = { + reportID: 'report1', + total: 10000, + } as Report; + + const splitExpenses: SplitExpense[] = []; + const allReportsList = { + [`${ONYXKEYS.COLLECTION.REPORT}report1`]: { + reportID: 'report1', + total: 12000, // Different from expenseReport.total + } as Report, + }; + const changesInReportTotal = 2000; + + const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); + + expect(result.size).toBe(1); + expect(result.get('report1')).toBe(10000); // 12000 - 2000 (uses allReportsList value) + }); + + it('should add split expenses from different reports to the map', () => { + const expenseReport: Report = { + reportID: 'mainReport', + total: 10000, + } as Report; + + const splitExpenses: SplitExpense[] = [ + { + reportID: 'splitReport1', + amount: 2000, + } as SplitExpense, + { + reportID: 'splitReport2', + amount: 3000, + } as SplitExpense, + ]; + + const allReportsList = { + [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { + reportID: 'mainReport', + total: 10000, + } as Report, + [`${ONYXKEYS.COLLECTION.REPORT}splitReport1`]: { + reportID: 'splitReport1', + total: 5000, + } as Report, + [`${ONYXKEYS.COLLECTION.REPORT}splitReport2`]: { + reportID: 'splitReport2', + total: 7000, + } as Report, + }; + const changesInReportTotal = 1000; + + const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); + + expect(result.size).toBe(3); + expect(result.get('mainReport')).toBe(9000); // 10000 - 1000 + expect(result.get('splitReport1')).toBe(5000); + expect(result.get('splitReport2')).toBe(7000); + }); + + it('should skip split expenses without reportID', () => { + const expenseReport: Report = { + reportID: 'mainReport', + total: 10000, + } as Report; + + const splitExpenses: SplitExpense[] = [ + { + reportID: undefined, + amount: 2000, + } as SplitExpense, + { + reportID: 'splitReport1', + amount: 3000, + } as SplitExpense, + ]; + + const allReportsList = { + [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { + reportID: 'mainReport', + total: 10000, + } as Report, + [`${ONYXKEYS.COLLECTION.REPORT}splitReport1`]: { + reportID: 'splitReport1', + total: 5000, + } as Report, + }; + const changesInReportTotal = 1000; + + const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); + + expect(result.size).toBe(2); // Only mainReport and splitReport1 + expect(result.get('mainReport')).toBe(9000); + expect(result.get('splitReport1')).toBe(5000); + }); + + it('should skip split expenses that are already in reportTotals', () => { + const expenseReport: Report = { + reportID: 'mainReport', + total: 10000, + } as Report; + + // Two split expenses with the same reportID + const splitExpenses: SplitExpense[] = [ + { + reportID: 'splitReport1', + amount: 2000, + } as SplitExpense, + { + reportID: 'splitReport1', // Duplicate reportID + amount: 3000, + } as SplitExpense, + { + reportID: 'splitReport2', + amount: 1500, + } as SplitExpense, + ]; + + const allReportsList = { + [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { + reportID: 'mainReport', + total: 10000, + } as Report, + [`${ONYXKEYS.COLLECTION.REPORT}splitReport1`]: { + reportID: 'splitReport1', + total: 5000, + } as Report, + [`${ONYXKEYS.COLLECTION.REPORT}splitReport2`]: { + reportID: 'splitReport2', + total: 3000, + } as Report, + }; + const changesInReportTotal = 1000; + + const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); + + expect(result.size).toBe(3); + expect(result.get('mainReport')).toBe(9000); + expect(result.get('splitReport1')).toBe(5000); // Should only be added once + expect(result.get('splitReport2')).toBe(3000); + }); + + it('should default split expense report total to 0 when not found in allReportsList', () => { + const expenseReport: Report = { + reportID: 'mainReport', + total: 10000, + } as Report; + + const splitExpenses: SplitExpense[] = [ + { + reportID: 'splitReport1', + amount: 2000, + } as SplitExpense, + ]; + + const allReportsList = { + [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { + reportID: 'mainReport', + total: 10000, + } as Report, + // splitReport1 is NOT in allReportsList + }; + const changesInReportTotal = 1000; + + const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); + + expect(result.size).toBe(2); + expect(result.get('mainReport')).toBe(9000); + expect(result.get('splitReport1')).toBe(0); // Defaults to 0 + }); + + it('should handle empty split expenses array', () => { + const expenseReport: Report = { + reportID: 'mainReport', + total: 10000, + } as Report; + + const splitExpenses: SplitExpense[] = []; + const allReportsList = { + [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { + reportID: 'mainReport', + total: 10000, + } as Report, + }; + const changesInReportTotal = 2000; + + const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); + + expect(result.size).toBe(1); + expect(result.get('mainReport')).toBe(8000); + }); + + it('should handle negative changesInReportTotal', () => { + const expenseReport: Report = { + reportID: 'mainReport', + total: 10000, + } as Report; + + const splitExpenses: SplitExpense[] = []; + const allReportsList = { + [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { + reportID: 'mainReport', + total: 10000, + } as Report, + }; + const changesInReportTotal = -2000; // Negative change + + const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); + + expect(result.size).toBe(1); + expect(result.get('mainReport')).toBe(12000); // 10000 - (-2000) = 12000 + }); + }); + + it('handleNavigateAfterExpenseCreate', async () => { + const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; + const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); + const activeReportID = '1'; + const transactionID = '1'; + mockedIsReportTopmostSplitNavigator.mockReturnValue(false); + + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + mockedIsReportTopmostSplitNavigator.mockReturnValue(true); + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + mockedIsReportTopmostSplitNavigator.mockReturnValue(false); + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); + expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); + + spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); + }); + + describe('createSplitsAndOnyxData', () => { + const mockPersonalDetails: PersonalDetailsList = { + [RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL, displayName: 'Rory'}, + [CARLOS_ACCOUNT_ID]: {accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL, displayName: 'Carlos'}, + [JULES_ACCOUNT_ID]: {accountID: JULES_ACCOUNT_ID, login: JULES_EMAIL, displayName: 'Jules'}, + [VIT_ACCOUNT_ID]: {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL, displayName: 'Vit'}, + }; + + const baseTransactionParams = { + amount: 400, + currency: CONST.CURRENCY.USD, + created: '2024-01-01', + merchant: 'Test Merchant', + comment: 'Test split', + tag: '', + category: '', + taxCode: '', + taxAmount: 0, + splitShares: {} as SplitShares, + }; + + const buildParams = ( + overrides: { + participants?: IOUParticipant[]; + existingSplitChatReportID?: string; + transactionParamOverrides?: Partial; + participantsPolicyTags?: Record; + } = {}, + ) => ({ + participants: overrides.participants ?? [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + existingSplitChatReportID: overrides.existingSplitChatReportID, + transactionParams: { + ...baseTransactionParams, + ...overrides.transactionParamOverrides, + }, + policyRecentlyUsedCategories: undefined, + policyRecentlyUsedTags: undefined, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + betas: [CONST.BETAS.ALL], + personalDetails: mockPersonalDetails, + participantsPolicyTags: overrides.participantsPolicyTags ?? {}, + }); + + it('returns valid splitData with chatReportID, transactionID, and reportActionID', () => { + // Given a basic 1:1 split between the current user and one participant + + // When creating splits and Onyx data + const result = createSplitsAndOnyxData(buildParams()); + + // Then splitData should contain all required identifiers + expect(result.splitData.chatReportID).toBeTruthy(); + expect(result.splitData.transactionID).toBeTruthy(); + expect(result.splitData.reportActionID).toBeTruthy(); + }); + + it('includes createdReportActionID in splitData for a new chat', () => { + // Given no existing split chat report + + // When creating splits and Onyx data + const result = createSplitsAndOnyxData(buildParams()); + + // Then splitData should include a createdReportActionID for the new chat + expect(result.splitData.createdReportActionID).toBeTruthy(); + }); + + it('omits createdReportActionID from splitData when using an existing chat', async () => { + // Given an existing chat report already in Onyx + const existingReportID = rand64(); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingReportID}`, { + reportID: existingReportID, + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }); + await waitForBatchedUpdates(); + + // When creating splits referencing that existing chat + const result = createSplitsAndOnyxData(buildParams({existingSplitChatReportID: existingReportID})); + + // Then splitData should not include a createdReportActionID + expect(result.splitData.createdReportActionID).toBeUndefined(); + }); + + it('splits amount equally among all participants when no splitShares are provided', () => { + // Given a $400 expense split between the current user and 3 other participants + const amount = 400; + + // When creating splits without custom splitShares + const result = createSplitsAndOnyxData( + buildParams({ + participants: [ + {accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}, + {accountID: JULES_ACCOUNT_ID, login: JULES_EMAIL}, + {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL}, + ], + transactionParamOverrides: {amount}, + }), + ); + + // Then each of the 4 splits (current user + 3 others) should be $100 + expect(result.splits).toHaveLength(4); + for (const split of result.splits) { + expect(split.amount).toBe(amount / 4); + } + }); + + it('respects custom splitShares amounts when provided', () => { + // Given a $200 expense with custom split: current user pays $150, Carlos pays $50 + const splitShares: SplitShares = { + [RORY_ACCOUNT_ID]: {amount: 150}, + [CARLOS_ACCOUNT_ID]: {amount: 50}, + }; + + // When creating splits with those custom splitShares + const result = createSplitsAndOnyxData( + buildParams({ + transactionParamOverrides: {amount: 200, splitShares}, + }), + ); + + // Then each participant's split should reflect the custom amounts + const currentUserSplit = result.splits.find((s) => s.accountID === RORY_ACCOUNT_ID); + const carlosSplit = result.splits.find((s) => s.accountID === CARLOS_ACCOUNT_ID); + + expect(currentUserSplit?.amount).toBe(150); + expect(carlosSplit?.amount).toBe(50); + }); + + it('uses SET method for the split chat report in optimisticData when creating a new chat', () => { + // Given no existing split chat report + + // When creating splits and Onyx data + const result = createSplitsAndOnyxData(buildParams()); + + // Then the chat report update should use SET to write the new report atomically + const splitChatReportUpdate = result.onyxData.optimisticData?.find( + (update) => + update.key.startsWith(ONYXKEYS.COLLECTION.REPORT) && + !update.key.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) && + !update.key.includes(ONYXKEYS.COLLECTION.REPORT_METADATA), + ); + + expect(splitChatReportUpdate?.onyxMethod).toBe(Onyx.METHOD.SET); + }); + + it('uses MERGE method for the split chat report in optimisticData when reusing an existing chat', async () => { + // Given an existing chat report already in Onyx + const existingReportID = rand64(); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingReportID}`, { + reportID: existingReportID, + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }); + await waitForBatchedUpdates(); + + // When creating splits referencing that existing chat + const result = createSplitsAndOnyxData(buildParams({existingSplitChatReportID: existingReportID})); + + // Then the chat report update should use MERGE to preserve existing fields + const splitChatReportUpdate = result.onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${existingReportID}`); + + expect(splitChatReportUpdate?.onyxMethod).toBe(Onyx.METHOD.MERGE); + }); + + it('adds isOptimisticReport:true to REPORT_METADATA in optimisticData for a new chat', () => { + // Given no existing split chat report + + // When creating splits and Onyx data + const result = createSplitsAndOnyxData(buildParams()); + + // Then optimisticData should flag the new report as optimistic + const reportMetaUpdate = result.onyxData.optimisticData?.find((update) => update.key.startsWith(ONYXKEYS.COLLECTION.REPORT_METADATA)); + + expect(reportMetaUpdate?.value).toMatchObject({isOptimisticReport: true}); + }); + + it('does not include REPORT_METADATA isOptimisticReport in optimisticData for an existing chat', async () => { + // Given an existing chat report already in Onyx + const existingReportID = rand64(); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingReportID}`, { + reportID: existingReportID, + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }); + await waitForBatchedUpdates(); + + // When creating splits referencing that existing chat + const result = createSplitsAndOnyxData(buildParams({existingSplitChatReportID: existingReportID})); + + // Then no REPORT_METADATA entry should be written for the existing report + const reportMetaUpdate = result.onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT_METADATA}${existingReportID}`); + + expect(reportMetaUpdate).toBeUndefined(); + }); + + it('clears pendingAction and pendingFields on the split transaction in successData', () => { + // Given a basic split + + // When creating splits and Onyx data + const result = createSplitsAndOnyxData(buildParams()); + const {transactionID} = result.splitData; + + // Then successData should clear pending state on the split transaction + const txSuccessUpdate = result.onyxData.successData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + + expect(txSuccessUpdate?.value).toMatchObject({pendingAction: null, pendingFields: null}); + }); + + it('includes errors on the split transaction in failureData', () => { + // Given a basic split + + // When creating splits and Onyx data + const result = createSplitsAndOnyxData(buildParams()); + const {transactionID} = result.splitData; + + // Then failureData should include an errors entry on the split transaction for user-visible feedback + const txFailureUpdate = result.onyxData.failureData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + + expect(txFailureUpdate?.value).toHaveProperty('errors'); + }); + + it('sets policy recently used tags in optimisticData for a policy expense chat participant with a tag', async () => { + // Given a workspace expense chat with a known tag list + const policyID = 'test_policy_999'; + const tagListName = 'Department'; + const tagName = 'Engineering'; + + const existingExpenseChatID = rand64(); + await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { + [`${ONYXKEYS.COLLECTION.REPORT}${existingExpenseChatID}`]: { + reportID: existingExpenseChatID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, + isOwnPolicyExpenseChat: true, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT}, + }, + } as OnyxMergeCollectionInput); + await waitForBatchedUpdates(); + + const policyTagsList = { + [tagListName]: { + name: tagListName, + tags: {[tagName]: {name: tagName, enabled: true}}, + }, + }; + + // When splitting an expense with a tag inside that workspace chat + const result = createSplitsAndOnyxData( + buildParams({ + existingSplitChatReportID: existingExpenseChatID, + participants: [ + { + accountID: CARLOS_ACCOUNT_ID, + login: CARLOS_EMAIL, + isPolicyExpenseChat: true, + isOwnPolicyExpenseChat: true, + policyID, + }, + ], + transactionParamOverrides: {tag: tagName}, + participantsPolicyTags: {[policyID]: policyTagsList} as unknown as Record, + }), + ); + + // Then optimisticData should update POLICY_RECENTLY_USED_TAGS with the used tag + const recentlyUsedTagsUpdate = result.onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`); + + expect(recentlyUsedTagsUpdate?.value).toMatchObject({[tagListName]: [tagName]}); + }); + }); +}); diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts deleted file mode 100644 index d86e7cb0259f..000000000000 --- a/tests/actions/IOUTest.ts +++ /dev/null @@ -1,4656 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import {renderHook, waitFor} from '@testing-library/react-native'; -import {format} from 'date-fns'; -import {deepEqual} from 'fast-equals'; -import Onyx from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry, OnyxMergeCollectionInput} from 'react-native-onyx'; -import type {SearchQueryJSON, SearchStatus} from '@components/Search/types'; -import useOnyx from '@hooks/useOnyx'; -import {putOnHold} from '@libs/actions/IOU/Hold'; -import { - initMoneyRequest, - resetDraftTransactionsCustomUnit, - setMoneyRequestAmount, - setMoneyRequestBillable, - setMoneyRequestCategory, - setMoneyRequestCreated, - setMoneyRequestDateAttribute, - setMoneyRequestDistanceRate, - setMoneyRequestMerchant, - setMoneyRequestTag, -} from '@libs/actions/IOU/MoneyRequest'; -import {calculateDiffAmount} from '@libs/actions/IOU/MoneyRequestBuilder'; -import {handleNavigateAfterExpenseCreate} from '@libs/actions/IOU/NavigationHelpers'; -import {shouldOptimisticallyUpdateSearch} from '@libs/actions/IOU/SearchUpdate'; -import {completeSplitBill, createSplitsAndOnyxData, splitBill, startSplitBill} from '@libs/actions/IOU/Split'; -import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate'; -import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; -import {removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; -import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; -import {createWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; -import {createNewReport} from '@libs/actions/Report'; -import Log from '@libs/Log'; -import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; -import {rand64} from '@libs/NumberUtils'; -import {getManagerMcTestParticipant} from '@libs/OptionsListUtils'; -import type * as PolicyUtils from '@libs/PolicyUtils'; -import {getOriginalMessage, isActionOfType, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {buildOptimisticIOUReportAction, createDraftTransactionAndNavigateToParticipantSelector, getReportOrDraftReport} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; -import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; -import DateUtils from '@src/libs/DateUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {LastSelectedDistanceRates, PersonalDetailsList, Policy, PolicyTagLists, RecentlyUsedTags, Report, ReportNameValuePairs, SearchResults} from '@src/types/onyx'; -import type {Participant as IOUParticipant, SplitExpense} from '@src/types/onyx/IOU'; -import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; -import type {Participant} from '@src/types/onyx/Report'; -import type ReportAction from '@src/types/onyx/ReportAction'; -import type Transaction from '@src/types/onyx/Transaction'; -import type {SplitShares} from '@src/types/onyx/Transaction'; -import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import SafeString from '@src/utils/SafeString'; -import {changeTransactionsReport} from '../../src/libs/actions/Transaction'; -import currencyList from '../unit/currencyList.json'; -import createPersonalDetails from '../utils/collections/personalDetails'; -import createRandomPolicy, {createCategoryTaxExpenseRules} from '../utils/collections/policies'; -import {createRandomReport} from '../utils/collections/reports'; -import createRandomTransaction from '../utils/collections/transaction'; -import getOnyxValue from '../utils/getOnyxValue'; -import type {MockFetch} from '../utils/TestHelper'; -import {getGlobalFetchMock, getOnyxData} from '../utils/TestHelper'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -import waitForNetworkPromises from '../utils/waitForNetworkPromises'; - -const topMostReportID = '23423423'; -jest.mock('@src/libs/Navigation/Navigation', () => ({ - navigate: jest.fn(), - dismissModal: jest.fn(), - dismissToPreviousRHP: jest.fn(), - dismissToSuperWideRHP: jest.fn(), - navigateBackToLastSuperWideRHPScreen: jest.fn(), - dismissModalWithReport: jest.fn(), - goBack: jest.fn(), - getTopmostReportId: jest.fn(() => topMostReportID), - setNavigationActionToMicrotaskQueue: jest.fn(), - removeScreenByKey: jest.fn(), - isNavigationReady: jest.fn(() => Promise.resolve()), - getReportRouteByID: jest.fn(), - getActiveRouteWithoutParams: jest.fn(), - getActiveRoute: jest.fn(), - getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), - clearFullscreenPreInsertedFlag: jest.fn(), - revealRouteBeforeDismissingModal: jest.fn(), - navigationRef: { - getRootState: jest.fn(), - isReady: jest.fn(() => true), - }, -})); - -jest.mock('@react-navigation/native'); - -jest.mock('@src/libs/actions/Report', () => { - const originalModule = jest.requireActual('@src/libs/actions/Report'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...originalModule, - notifyNewAction: jest.fn(), - }; -}); -jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); -jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); -// In production, requestMoney defers its API.write() call until the target screen's -// content lays out (or a safety timeout fires). In tests there is no target component -// to flush the deferred write, so we bypass the deferral by executing the callback immediately. -jest.mock('@libs/deferredLayoutWrite', () => ({ - registerDeferredWrite: (_key: string, callback: () => void) => callback(), - flushDeferredWrite: jest.fn(), - cancelDeferredWrite: jest.fn(), - hasDeferredWrite: () => false, - getOptimisticWatchKey: () => undefined, - deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), - reserveDeferredWriteChannel: jest.fn(), - resetForTesting: jest.fn(), -})); -jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); - -const unapprovedCashHash = 71801560; -const unapprovedCashSimilarSearchHash = 1832274510; -jest.mock('@src/libs/SearchQueryUtils', () => { - const actual = jest.requireActual('@src/libs/SearchQueryUtils'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actual, - getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ - hash: unapprovedCashHash, - query: 'test', - type: 'expense', - status: ['drafts', 'outstanding'], - filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, - flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], - inputQuery: '', - recentSearchHash: 89, - similarSearchHash: unapprovedCashSimilarSearchHash, - sortBy: 'tag', - sortOrder: 'asc', - })), - buildCannedSearchQuery: jest.fn(), - }; -}); - -jest.mock('@libs/PolicyUtils', () => ({ - ...jest.requireActual('@libs/PolicyUtils'), - isPaidGroupPolicy: jest.fn().mockReturnValue(true), - isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), -})); - -const CARLOS_EMAIL = 'cmartins@expensifail.com'; -const CARLOS_ACCOUNT_ID = 1; -const CARLOS_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; -const JULES_EMAIL = 'jules@expensifail.com'; -const JULES_ACCOUNT_ID = 2; -const JULES_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; -const RORY_EMAIL = 'rory@expensifail.com'; -const RORY_ACCOUNT_ID = 3; -const RORY_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'admin'}; -const VIT_EMAIL = 'vit@expensifail.com'; -const VIT_ACCOUNT_ID = 4; -const VIT_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; - -const getTransactionAndExpenseReports = (reportID: string) => { - const transactionReport = getReportOrDraftReport(reportID); - const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); - const expenseReport = transactionReport?.type === CONST.REPORT.TYPE.EXPENSE ? transactionReport : parentTransactionReport; - return {transactionReport, expenseReport}; -}; - -const getPolicyTags = async (expenseReportPolicyID: string | undefined) => { - let allPolicyTags: OnyxCollection; - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}`, - waitForCollectionCallback: true, - callback: (value) => { - allPolicyTags = value; - }, - }); - - const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${expenseReportPolicyID}`] ?? {}; - return policyTags; -}; - -OnyxUpdateManager(); -describe('actions/IOU', () => { - const currentUserPersonalDetails: CurrentUserPersonalDetails = { - ...createPersonalDetails(RORY_ACCOUNT_ID), - login: RORY_EMAIL, - email: RORY_EMAIL, - displayName: RORY_EMAIL, - avatar: 'https://example.com/avatar.jpg', - }; - - const getParticipantsPolicyTags = async (participants: IOUParticipant[]) => { - let participantsPolicyTags: Record = {}; - await getOnyxData({ - waitForCollectionCallback: true, - key: `${ONYXKEYS.COLLECTION.POLICY_TAGS}`, - callback: (tags) => { - participantsPolicyTags = participants.reduce>((acc, participant) => { - if (participant.policyID) { - acc[participant.policyID] = tags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${participant.policyID}`] ?? {}; - } - return acc; - }, {}); - }, - }); - return participantsPolicyTags; - }; - - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, - [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - [ONYXKEYS.CURRENCY_LIST]: currencyList, - }, - }); - initOnyxDerivedValues(); - IntlStore.load(CONST.LOCALES.EN); - return waitForBatchedUpdates(); - }); - - let mockFetch: MockFetch; - beforeEach(() => { - jest.clearAllTimers(); - global.fetch = getGlobalFetchMock(); - mockFetch = fetch as MockFetch; - return Onyx.clear().then(waitForBatchedUpdates); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('shouldOptimisticallyUpdateSearch', () => { - it('when the current hash is submit action query it should only return true if the iou report is in draft state', () => { - const transaction = { - ...createRandomTransaction(1), - }; - const currentSearchQueryJSON = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, - status: '' as SearchStatus, - sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, - sortOrder: CONST.SEARCH.SORT_ORDER.DESC, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION, - right: 'submit', - }, - right: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - left: 'from', - right: '20671314', - }, - }, - inputQuery: 'sortBy:date sortOrder:desc type:expense-report action:submit from:20671314', - flatFilters: [ - { - key: CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION, - filters: [ - { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - value: 'submit', - }, - ], - }, - { - key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, - filters: [ - { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - value: '20671314', - }, - ], - }, - ], - hash: 1920151829, - recentSearchHash: 2100977843, - similarSearchHash: 1855682507, - } as SearchQueryJSON; - const iouReport: Report = {...createRandomReport(2, undefined), type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; - - // When the report is in draft status it should return true - expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeTruthy(); - - // If the report is not in draft state it should return false - iouReport.stateNum = CONST.REPORT.STATE_NUM.SUBMITTED; - iouReport.statusNum = CONST.REPORT.STATUS_NUM.SUBMITTED; - expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeFalsy(); - }); - - it('when the current hash is approve action query it should only return true if the iou report is in outstanding state', () => { - const transaction = { - ...createRandomTransaction(1), - }; - const currentSearchQueryJSON = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, - status: '' as SearchStatus, - sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, - sortOrder: CONST.SEARCH.SORT_ORDER.DESC, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - left: CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION, - right: 'approve', - }, - right: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - left: 'from', - right: '20671314', - }, - }, - flatFilters: [ - { - key: CONST.SEARCH.SYNTAX_FILTER_KEYS.ACTION, - filters: [ - { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - value: 'approve', - }, - ], - }, - { - key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, - filters: [ - { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - value: '20671314', - }, - ], - }, - ], - - hash: 1510971479, - inputQuery: 'sortBy:date sortOrder:desc type:expense-report action:approve to:20671314', - recentSearchHash: 967911777, - similarSearchHash: 1539858783, - } as SearchQueryJSON; - const iouReport: Report = {...createRandomReport(2, undefined), type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; - - // When the report is in draft status it should return false - expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeFalsy(); - - // If the report is in outstanding state it should return true - iouReport.stateNum = CONST.REPORT.STATE_NUM.SUBMITTED; - iouReport.statusNum = CONST.REPORT.STATUS_NUM.SUBMITTED; - expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeTruthy(); - }); - - it('when the current hash is unapproved cash action query it should only return true if the iou report is in either draft or outstanding state', () => { - const transaction = { - ...createRandomTransaction(1), - reimbursable: true, - }; - const currentSearchQueryJSON = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, - status: '' as SearchStatus, - sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE, - sortOrder: CONST.SEARCH.SORT_ORDER.DESC, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - left: 'reimbursable', - - right: 'yes', - }, - flatFilters: [ - { - key: CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE, - filters: [ - { - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - value: 'yes', - }, - ], - }, - ], - hash: 71801560, - inputQuery: 'sortBy:date sortOrder:desc type:expense groupBy:from status:drafts,outstanding reimbursable:yes', - recentSearchHash: 1043581824, - similarSearchHash: 1832274510, - } as SearchQueryJSON; - - const iouReport: Report = {...createRandomReport(2, undefined), type: CONST.REPORT.TYPE.EXPENSE, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; - - // When the report is in draft status it should return true - expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeTruthy(); - - // If the report is in approved state it should return false - iouReport.stateNum = CONST.REPORT.STATE_NUM.APPROVED; - iouReport.statusNum = CONST.REPORT.STATUS_NUM.APPROVED; - expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, false, transaction)).toBeFalsy(); - }); - - it('when the current hash includes a policyID filter it should only return true if the iou report matches the policyID filter', () => { - const transaction = { - ...createRandomTransaction(1), - }; - const policyID = '12345'; - const currentSearchQueryJSON = { - type: 'expense', - status: '', - sortBy: 'date', - sortOrder: 'desc', - policyID: [policyID], - filters: null, - inputQuery: `type:expense sortBy:date sortOrder:desc policyID:${policyID}`, - flatFilters: [], - hash: 591785022, - recentSearchHash: 714245044, - similarSearchHash: 1023624110, - rawFilterList: [ - { - key: 'policyID', - operator: 'eq', - value: policyID, - isDefault: true, - }, - ], - } as unknown as SearchQueryJSON; - - // When the IOU report has a matching policyID, it should return true - const matchingIOUReport: Report = { - ...createRandomReport(2, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - }; - expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, matchingIOUReport, false, transaction)).toBeTruthy(); - - // When the IOU report has a different policyID, it should return false - const nonMatchingIOUReport: Report = { - ...createRandomReport(3, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: 'differentPolicyID', - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - }; - expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, nonMatchingIOUReport, false, transaction)).toBeFalsy(); - }); - }); - - describe('createDraftTransactionAndNavigateToParticipantSelector', () => { - it('should clear existing draft transactions when draftTransactionIDs is provided', async () => { - // Given existing draft transactions - const existingDraftTransaction1: Transaction = {...createRandomTransaction(1), transactionID: 'existing-draft-1'}; - const existingDraftTransaction2: Transaction = {...createRandomTransaction(2), transactionID: 'existing-draft-2'}; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingDraftTransaction1.transactionID}`, existingDraftTransaction1); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingDraftTransaction2.transactionID}`, existingDraftTransaction2); - - // Given a selfDM report and a transaction to categorize - const selfDMReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM); - const transactionToCategorize: Transaction = {...createRandomTransaction(3), transactionID: 'transaction-to-categorize'}; - - // Given a report action ID for the track expense - const reportActionID = '1'; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionToCategorize.transactionID}`, transactionToCategorize); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When createDraftTransactionAndNavigateToParticipantSelector is called with draftTransactionIDs - createDraftTransactionAndNavigateToParticipantSelector({ - reportID: selfDMReport.reportID, - actionName: CONST.IOU.ACTION.CATEGORIZE, - reportActionID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - draftTransactionIDs: [existingDraftTransaction1.transactionID, existingDraftTransaction2.transactionID], - activePolicy: undefined, - userBillingGracePeriodEnds: undefined, - amountOwed: 0, - transaction: transactionToCategorize, - currentUserAccountID: RORY_ACCOUNT_ID, - currentUserEmail: RORY_EMAIL, - currentUserLocalCurrency: '', - }); - await waitForBatchedUpdates(); - - // Then the existing draft transactions should be cleared - let updatedTransactionDrafts: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (val) => { - updatedTransactionDrafts = val; - }, - }); - - // Old drafts should be cleared - expect(updatedTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingDraftTransaction1.transactionID}`]).toBeFalsy(); - expect(updatedTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingDraftTransaction2.transactionID}`]).toBeFalsy(); - - // New draft should be created for the transaction being categorized - expect(updatedTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionToCategorize.transactionID}`]).toBeTruthy(); - }); - - it('should create a draft transaction with correct data when categorizing', async () => { - // Given a selfDM report and a transaction with specific data - const selfDMReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM); - const originalTransaction: Transaction = { - ...createRandomTransaction(1), - transactionID: 'original-transaction', - amount: 5000, - currency: 'USD', - }; - - // Given a report action ID for the track expense - const reportActionID = '1'; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransaction.transactionID}`, originalTransaction); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When createDraftTransactionAndNavigateToParticipantSelector is called with empty allTransactionDrafts - createDraftTransactionAndNavigateToParticipantSelector({ - reportID: selfDMReport.reportID, - actionName: CONST.IOU.ACTION.CATEGORIZE, - reportActionID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - draftTransactionIDs: [], - activePolicy: undefined, - userBillingGracePeriodEnds: undefined, - amountOwed: 0, - transaction: originalTransaction, - currentUserAccountID: RORY_ACCOUNT_ID, - currentUserEmail: RORY_EMAIL, - currentUserLocalCurrency: '', - }); - await waitForBatchedUpdates(); - - // Then a draft transaction should be created with the correct data - let transactionDrafts: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (val) => { - transactionDrafts = val; - }, - }); - - const draftTransaction = transactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${originalTransaction.transactionID}`]; - expect(draftTransaction).toBeTruthy(); - expect(draftTransaction?.amount).toBe(-originalTransaction.amount); - expect(draftTransaction?.currency).toBe(originalTransaction.currency); - expect(draftTransaction?.actionableWhisperReportActionID).toBe(reportActionID); - expect(draftTransaction?.linkedTrackedExpenseReportID).toBe(selfDMReport.reportID); - }); - - it('should not create draft transaction when transaction is undefined', async () => { - // Given a selfDM report - const selfDMReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When createDraftTransactionAndNavigateToParticipantSelector is called with undefined transaction - createDraftTransactionAndNavigateToParticipantSelector({ - reportID: selfDMReport.reportID, - actionName: CONST.IOU.ACTION.CATEGORIZE, - reportActionID: 'some-report-action-id', - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - draftTransactionIDs: [], - activePolicy: undefined, - userBillingGracePeriodEnds: undefined, - amountOwed: 0, - transaction: undefined, - currentUserAccountID: RORY_ACCOUNT_ID, - currentUserEmail: RORY_EMAIL, - currentUserLocalCurrency: '', - }); - await waitForBatchedUpdates(); - - // Then no draft transaction should be created - let transactionDrafts: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (val) => { - transactionDrafts = val; - }, - }); - - expect(Object.keys(transactionDrafts ?? {}).length).toBe(0); - }); - - it('should not create draft transaction when reportID is undefined', async () => { - // Given a transaction - const transaction: Transaction = {...createRandomTransaction(1), transactionID: 'test-transaction'}; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - - // When createDraftTransactionAndNavigateToParticipantSelector is called with undefined reportID - createDraftTransactionAndNavigateToParticipantSelector({ - reportID: undefined, - actionName: CONST.IOU.ACTION.CATEGORIZE, - reportActionID: 'some-report-action-id', - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - draftTransactionIDs: [], - activePolicy: undefined, - transaction, - userBillingGracePeriodEnds: undefined, - amountOwed: 0, - currentUserAccountID: RORY_ACCOUNT_ID, - currentUserEmail: RORY_EMAIL, - currentUserLocalCurrency: '', - }); - await waitForBatchedUpdates(); - - // Then no draft transaction should be created - let transactionDrafts: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (val) => { - transactionDrafts = val; - }, - }); - - expect(Object.keys(transactionDrafts ?? {}).length).toBe(0); - }); - }); - - describe('setMoneyRequestDistanceRate', () => { - it('does not set distance rate if transaction is invalid', async () => { - // Given an invalid transaction - const consoleWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {}); - - // When calling setMoneyRequestDistanceRate with invalid transaction - setMoneyRequestDistanceRate(undefined, 'customUnitRateID123', createRandomPolicy(1), false); - // Then a warning should be logged and distance rate should not be set - expect(consoleWarnSpy).toHaveBeenCalledWith('setMoneyRequestDistanceRate is called without a valid transaction, skipping setting distance rate.'); - const distanceRates = await getOnyxValue(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); - expect(distanceRates).toBeUndefined(); - consoleWarnSpy.mockRestore(); - }); - - it('sets the last selected distance rate for valid transaction', async () => { - const policy = createRandomPolicy(1); - // Given a valid transaction - const testTransaction: Transaction = { - transactionID: 'testTransaction123', - amount: 1000, - currency: CONST.CURRENCY.USD, - comment: { - comment: 'Test transaction', - attendees: [], - }, - created: DateUtils.getDBTime(), - merchant: 'Test Merchant', - reportID: 'testReport123', - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`, testTransaction); - - // When calling setMoneyRequestDistanceRate with valid transaction - const customUnitRateID = 'customUnitRateID123'; - setMoneyRequestDistanceRate(testTransaction, customUnitRateID, policy, false); - await waitForBatchedUpdates(); - // Then the distance rate should be set in Onyx - const lastDistanceRates = (await getOnyxValue(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES)) as LastSelectedDistanceRates | undefined; - expect(lastDistanceRates?.[policy.id]).toBeDefined(); - expect(lastDistanceRates?.[policy.id]).toBe(customUnitRateID); - }); - - it('sets distance rate and distance unit for draft transaction', async () => { - const policy = createRandomPolicy(1); - policy.customUnits = { - distanceUnitID: { - customUnitID: 'distanceUnitID', - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - attributes: { - unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, - }, - rates: {}, - }, - }; - - const testTransaction: Transaction = { - transactionID: 'testTransaction123', - amount: 1000, - currency: CONST.CURRENCY.USD, - comment: { - comment: 'Test transaction', - }, - created: DateUtils.getDBTime(), - merchant: 'Test Merchant', - reportID: 'testReport123', - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`, testTransaction); - - const customUnitRateID = 'customUnitRateID123'; - setMoneyRequestDistanceRate(testTransaction, customUnitRateID, policy, true); - await waitForBatchedUpdates(); - - const transactionDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${testTransaction.transactionID}`); - expect(transactionDraft?.comment?.customUnit?.customUnitRateID).toBe(customUnitRateID); - expect(transactionDraft?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); - expect(transactionDraft?.comment?.customUnit?.defaultP2PRate).toBeUndefined(); - }); - - it('converts distance quantity if distance unit changes', async () => { - const policy = createRandomPolicy(1); - policy.customUnits = { - distanceUnitID: { - customUnitID: 'distanceUnitID', - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - attributes: { - unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, - }, - rates: {}, - }, - }; - - const testTransaction: Transaction = { - transactionID: 'testTransaction123', - amount: 1000, - currency: CONST.CURRENCY.USD, - comment: { - comment: 'Test transaction', - customUnit: { - distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - quantity: 10, - }, - }, - created: DateUtils.getDBTime(), - merchant: 'Test Merchant', - reportID: 'testReport123', - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`, testTransaction); - - const customUnitRateID = 'customUnitRateID123'; - setMoneyRequestDistanceRate(testTransaction, customUnitRateID, policy, false); - await waitForBatchedUpdates(); - - const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`); - expect(transaction?.comment?.customUnit?.customUnitRateID).toBe(customUnitRateID); - expect(transaction?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); - // 10 miles to kilometers = 10 / 0.000621371 * 0.001 = 16.093444978925636 - expect(transaction?.comment?.customUnit?.quantity).toBe(16.093444978925636); - }); - - it('does not convert distance quantity if distance unit changes but it is an odometer request', async () => { - const policy = createRandomPolicy(1); - policy.customUnits = { - distanceUnitID: { - customUnitID: 'distanceUnitID', - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - attributes: { - unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, - }, - rates: {}, - }, - }; - - const testTransaction: Transaction = { - transactionID: 'testTransaction123', - amount: 1000, - currency: CONST.CURRENCY.USD, - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, - comment: { - comment: 'Test transaction', - customUnit: { - distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - quantity: 10, - }, - }, - created: DateUtils.getDBTime(), - merchant: 'Test Merchant', - reportID: 'testReport123', - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`, testTransaction); - - const customUnitRateID = 'customUnitRateID123'; - setMoneyRequestDistanceRate(testTransaction, customUnitRateID, policy, false); - await waitForBatchedUpdates(); - - const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`); - expect(transaction?.comment?.customUnit?.customUnitRateID).toBe(customUnitRateID); - expect(transaction?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); - // Quantity should remain 10 for odometer requests - expect(transaction?.comment?.customUnit?.quantity).toBe(10); - }); - - it('does not set defaultP2PRate to null when policy is undefined', async () => { - const testTransaction: Transaction = { - transactionID: 'testTransaction123', - amount: 1000, - currency: CONST.CURRENCY.USD, - comment: { - comment: 'Test transaction', - customUnit: { - defaultP2PRate: CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS, - }, - }, - created: DateUtils.getDBTime(), - merchant: 'Test Merchant', - reportID: 'testReport123', - }; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`, testTransaction); - - const customUnitRateID = 'customUnitRateID123'; - setMoneyRequestDistanceRate(testTransaction, customUnitRateID, undefined, false); - await waitForBatchedUpdates(); - - const transaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${testTransaction.transactionID}`); - expect(transaction?.comment?.customUnit?.customUnitRateID).toBe(customUnitRateID); - expect(transaction?.comment?.customUnit?.defaultP2PRate).toBe(CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS); - }); - }); - - describe('split expense', () => { - const splitMockPersonalDetails: PersonalDetailsList = { - [RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL, displayName: 'Rory'}, - [CARLOS_ACCOUNT_ID]: {accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL, displayName: 'Carlos'}, - [JULES_ACCOUNT_ID]: {accountID: JULES_ACCOUNT_ID, login: JULES_EMAIL, displayName: 'Jules'}, - [VIT_ACCOUNT_ID]: {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL, displayName: 'Vit'}, - }; - - it('creates and updates new chats and IOUs as needed', () => { - jest.setTimeout(10 * 1000); - /* - * Given that: - * - Rory and Carlos have chatted before - * - Rory and Jules have chatted before and have an active IOU report - * - Rory and Vit have never chatted together before - * - There is no existing group chat with the four of them - */ - const amount = 400; - const comment = 'Yes, I am splitting a bill for $4 USD'; - const merchant = 'Yema Kitchen'; - let carlosChatReport: OnyxEntry = { - reportID: rand64(), - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }; - const carlosCreatedAction: OnyxEntry = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: DateUtils.getDBTime(), - reportID: carlosChatReport.reportID, - }; - const julesIOUReportID = rand64(); - let julesChatReport: OnyxEntry = { - reportID: rand64(), - type: CONST.REPORT.TYPE.CHAT, - iouReportID: julesIOUReportID, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [JULES_ACCOUNT_ID]: JULES_PARTICIPANT}, - }; - const julesChatCreatedAction: OnyxEntry = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: DateUtils.getDBTime(), - reportID: julesChatReport.reportID, - }; - const julesCreatedAction: OnyxEntry = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: DateUtils.getDBTime(), - reportID: julesIOUReportID, - }; - jest.advanceTimersByTime(200); - const julesExistingTransaction: OnyxEntry = { - transactionID: rand64(), - amount: 1000, - comment: { - comment: 'This is an existing transaction', - attendees: [{email: 'text@expensify.com', displayName: 'Test User', avatarUrl: ''}], - }, - created: DateUtils.getDBTime(), - currency: '', - merchant: '', - reportID: '', - }; - let julesIOUReport: OnyxEntry = { - reportID: julesIOUReportID, - chatReportID: julesChatReport.reportID, - type: CONST.REPORT.TYPE.IOU, - ownerAccountID: RORY_ACCOUNT_ID, - managerID: JULES_ACCOUNT_ID, - currency: CONST.CURRENCY.USD, - total: julesExistingTransaction?.amount, - }; - const julesExistingIOUAction: OnyxEntry = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - actorAccountID: RORY_ACCOUNT_ID, - created: DateUtils.getDBTime(), - originalMessage: { - IOUReportID: julesIOUReportID, - IOUTransactionID: julesExistingTransaction?.transactionID, - amount: julesExistingTransaction?.amount ?? 0, - currency: CONST.CURRENCY.USD, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - participantAccountIDs: [RORY_ACCOUNT_ID, JULES_ACCOUNT_ID], - }, - reportID: julesIOUReportID, - }; - - let carlosIOUReport: OnyxEntry; - let carlosIOUAction: OnyxEntry>; - let carlosIOUCreatedAction: OnyxEntry; - let carlosTransaction: OnyxEntry; - - let julesIOUAction: OnyxEntry>; - let julesIOUCreatedAction: OnyxEntry; - let julesTransaction: OnyxEntry; - - let vitChatReport: OnyxEntry; - let vitIOUReport: OnyxEntry; - let vitCreatedAction: OnyxEntry; - let vitIOUAction: OnyxEntry>; - let vitTransaction: OnyxEntry; - - let groupChat: OnyxEntry; - let groupCreatedAction: OnyxEntry; - let groupIOUAction: OnyxEntry>; - let groupTransaction: OnyxEntry; - - const reportCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT, [carlosChatReport, julesChatReport, julesIOUReport], (item) => item.reportID); - - const carlosActionsCollectionDataSet = toCollectionDataSet( - `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, - [ - { - [carlosCreatedAction.reportActionID]: carlosCreatedAction, - }, - ], - (item) => item[carlosCreatedAction.reportActionID].reportID, - ); - - const julesActionsCollectionDataSet = toCollectionDataSet( - `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, - [ - { - [julesCreatedAction.reportActionID]: julesCreatedAction, - [julesExistingIOUAction.reportActionID]: julesExistingIOUAction, - }, - ], - (item) => item[julesCreatedAction.reportActionID].reportID, - ); - - const julesCreatedActionsCollectionDataSet = toCollectionDataSet( - `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, - [ - { - [julesChatCreatedAction.reportActionID]: julesChatCreatedAction, - }, - ], - (item) => item[julesChatCreatedAction.reportActionID].reportID, - ); - - return Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, reportCollectionDataSet as OnyxMergeCollectionInput) - .then(() => - Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { - ...carlosActionsCollectionDataSet, - ...julesCreatedActionsCollectionDataSet, - ...julesActionsCollectionDataSet, - } as OnyxMergeCollectionInput), - ) - .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${julesExistingTransaction?.transactionID}`, julesExistingTransaction)) - .then(() => { - // When we split a bill offline - mockFetch?.pause?.(); - splitBill( - // TODO: Migrate after the backend accepts accountIDs - { - participants: [ - [CARLOS_EMAIL, String(CARLOS_ACCOUNT_ID)], - [JULES_EMAIL, String(JULES_ACCOUNT_ID)], - [VIT_EMAIL, String(VIT_ACCOUNT_ID)], - ].map(([email, accountID]) => ({login: email, accountID: Number(accountID)})), - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - amount, - comment, - currency: CONST.CURRENCY.USD, - merchant, - created: '', - tag: '', - existingSplitChatReportID: '', - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: splitMockPersonalDetails, - }, - ); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - - // There should now be 10 reports - expect(Object.values(allReports ?? {}).length).toBe(10); - - // 1. The chat report with Rory + Carlos - carlosChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === carlosChatReport?.reportID); - expect(isEmptyObject(carlosChatReport)).toBe(false); - expect(carlosChatReport?.pendingFields).toBeFalsy(); - - // 2. The IOU report with Rory + Carlos (new) - carlosIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU && report.managerID === CARLOS_ACCOUNT_ID); - expect(isEmptyObject(carlosIOUReport)).toBe(false); - expect(carlosIOUReport?.total).toBe(amount / 4); - - // 3. The chat report with Rory + Jules - julesChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === julesChatReport?.reportID); - expect(isEmptyObject(julesChatReport)).toBe(false); - expect(julesChatReport?.pendingFields).toBeFalsy(); - - // 4. The IOU report with Rory + Jules - julesIOUReport = Object.values(allReports ?? {}).find((report) => report?.reportID === julesIOUReport?.reportID); - expect(isEmptyObject(julesIOUReport)).toBe(false); - expect(julesChatReport?.pendingFields).toBeFalsy(); - expect(julesIOUReport?.total).toBe((julesExistingTransaction?.amount ?? 0) + amount / 4); - - // 5. The chat report with Rory + Vit (new) - vitChatReport = Object.values(allReports ?? {}).find( - (report) => - report?.type === CONST.REPORT.TYPE.CHAT && - deepEqual(report.participants, {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [VIT_ACCOUNT_ID]: VIT_PARTICIPANT}), - ); - expect(isEmptyObject(vitChatReport)).toBe(false); - expect(vitChatReport?.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); - - // 6. The IOU report with Rory + Vit (new) - vitIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU && report.managerID === VIT_ACCOUNT_ID); - expect(isEmptyObject(vitIOUReport)).toBe(false); - expect(vitIOUReport?.total).toBe(amount / 4); - - // 7. The group chat with everyone - groupChat = Object.values(allReports ?? {}).find( - (report) => - report?.type === CONST.REPORT.TYPE.CHAT && - deepEqual(report.participants, { - [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT, - [JULES_ACCOUNT_ID]: JULES_PARTICIPANT, - [VIT_ACCOUNT_ID]: VIT_PARTICIPANT, - [RORY_ACCOUNT_ID]: RORY_PARTICIPANT, - }), - ); - expect(isEmptyObject(groupChat)).toBe(false); - expect(groupChat?.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); - - // The 1:1 chat reports and the IOU reports should be linked together - expect(carlosChatReport?.iouReportID).toBe(carlosIOUReport?.reportID); - expect(carlosIOUReport?.chatReportID).toBe(carlosChatReport?.reportID); - for (const participant of Object.values(carlosIOUReport?.participants ?? {})) { - expect(participant.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); - } - - expect(julesChatReport?.iouReportID).toBe(julesIOUReport?.reportID); - expect(julesIOUReport?.chatReportID).toBe(julesChatReport?.reportID); - - expect(vitChatReport?.iouReportID).toBe(vitIOUReport?.reportID); - expect(vitIOUReport?.chatReportID).toBe(vitChatReport?.reportID); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connection); - - // There should be reportActions on all 7 chat reports + 3 IOU reports in each 1:1 chat - expect(Object.values(allReportActions ?? {}).length).toBe(10); - - const carlosReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${carlosChatReport?.iouReportID}`]; - const julesReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesChatReport?.iouReportID}`]; - const vitReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${vitChatReport?.iouReportID}`]; - const groupReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChat?.reportID}`]; - - // Carlos DM should have two reportActions – the existing CREATED action and a pending IOU action - expect(Object.values(carlosReportActions ?? {}).length).toBe(2); - carlosIOUCreatedAction = Object.values(carlosReportActions ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, - ); - carlosIOUAction = Object.values(carlosReportActions ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const carlosOriginalMessage = carlosIOUAction ? getOriginalMessage(carlosIOUAction) : undefined; - - expect(carlosIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(carlosOriginalMessage?.IOUReportID).toBe(carlosIOUReport?.reportID); - expect(carlosOriginalMessage?.amount).toBe(amount / 4); - expect(carlosOriginalMessage?.comment).toBe(comment); - expect(carlosOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - expect(Date.parse(carlosIOUCreatedAction?.created ?? '')).toBeLessThan(Date.parse(carlosIOUAction?.created ?? '')); - - // Jules DM should have three reportActions, the existing CREATED action, the existing IOU action, and a new pending IOU action - expect(Object.values(julesReportActions ?? {}).length).toBe(3); - expect(julesReportActions?.[julesCreatedAction.reportActionID]).toStrictEqual(julesCreatedAction); - julesIOUCreatedAction = Object.values(julesReportActions ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, - ); - julesIOUAction = Object.values(julesReportActions ?? {}).find( - (reportAction): reportAction is ReportAction => - reportAction.reportActionID !== julesCreatedAction.reportActionID && reportAction.reportActionID !== julesExistingIOUAction.reportActionID, - ); - const julesOriginalMessage = julesIOUAction ? getOriginalMessage(julesIOUAction) : undefined; - - expect(julesIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(julesOriginalMessage?.IOUReportID).toBe(julesIOUReport?.reportID); - expect(julesOriginalMessage?.amount).toBe(amount / 4); - expect(julesOriginalMessage?.comment).toBe(comment); - expect(julesOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - expect(Date.parse(julesIOUCreatedAction?.created ?? '')).toBeLessThan(Date.parse(julesIOUAction?.created ?? '')); - - // Vit DM should have two reportActions – a pending CREATED action and a pending IOU action - expect(Object.values(vitReportActions ?? {}).length).toBe(2); - vitCreatedAction = Object.values(vitReportActions ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, - ); - vitIOUAction = Object.values(vitReportActions ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const vitOriginalMessage = vitIOUAction ? getOriginalMessage(vitIOUAction) : undefined; - - expect(vitCreatedAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(vitIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(vitOriginalMessage?.IOUReportID).toBe(vitIOUReport?.reportID); - expect(vitOriginalMessage?.amount).toBe(amount / 4); - expect(vitOriginalMessage?.comment).toBe(comment); - expect(vitOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - expect(Date.parse(vitCreatedAction?.created ?? '')).toBeLessThan(Date.parse(vitIOUAction?.created ?? '')); - - // Group chat should have two reportActions – a pending CREATED action and a pending IOU action w/ type SPLIT - expect(Object.values(groupReportActions ?? {}).length).toBe(2); - groupCreatedAction = Object.values(groupReportActions ?? {}).find((reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - groupIOUAction = Object.values(groupReportActions ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const groupOriginalMessage = groupIOUAction ? getOriginalMessage(groupIOUAction) : undefined; - - expect(groupCreatedAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(groupIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(groupOriginalMessage).not.toHaveProperty('IOUReportID'); - expect(groupOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.SPLIT); - expect(Date.parse(groupCreatedAction?.created ?? '')).toBeLessThanOrEqual(Date.parse(groupIOUAction?.created ?? '')); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - - /* There should be 5 transactions - * – one existing one with Jules - * - one for each of the three IOU reports - * - one on the group chat w/ deleted report - */ - expect(Object.values(allTransactions ?? {}).length).toBe(5); - expect(allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${julesExistingTransaction?.transactionID}`]).toBeTruthy(); - - carlosTransaction = Object.values(allTransactions ?? {}).find( - (transaction) => carlosIOUAction && transaction?.transactionID === getOriginalMessage(carlosIOUAction)?.IOUTransactionID, - ); - julesTransaction = Object.values(allTransactions ?? {}).find( - (transaction) => julesIOUAction && transaction?.transactionID === getOriginalMessage(julesIOUAction)?.IOUTransactionID, - ); - vitTransaction = Object.values(allTransactions ?? {}).find( - (transaction) => vitIOUAction && transaction?.transactionID === getOriginalMessage(vitIOUAction)?.IOUTransactionID, - ); - groupTransaction = Object.values(allTransactions ?? {}).find((transaction) => transaction?.reportID === CONST.REPORT.SPLIT_REPORT_ID); - - expect(carlosTransaction?.reportID).toBe(carlosIOUReport?.reportID); - expect(julesTransaction?.reportID).toBe(julesIOUReport?.reportID); - expect(vitTransaction?.reportID).toBe(vitIOUReport?.reportID); - expect(groupTransaction).toBeTruthy(); - - expect(carlosTransaction?.amount).toBe(amount / 4); - expect(julesTransaction?.amount).toBe(amount / 4); - expect(vitTransaction?.amount).toBe(amount / 4); - expect(groupTransaction?.amount).toBe(amount); - - expect(carlosTransaction?.comment?.comment).toBe(comment); - expect(julesTransaction?.comment?.comment).toBe(comment); - expect(vitTransaction?.comment?.comment).toBe(comment); - expect(groupTransaction?.comment?.comment).toBe(comment); - - expect(carlosTransaction?.merchant).toBe(merchant); - expect(julesTransaction?.merchant).toBe(merchant); - expect(vitTransaction?.merchant).toBe(merchant); - expect(groupTransaction?.merchant).toBe(merchant); - - expect(carlosTransaction?.comment?.source).toBe(CONST.IOU.TYPE.SPLIT); - expect(julesTransaction?.comment?.source).toBe(CONST.IOU.TYPE.SPLIT); - expect(vitTransaction?.comment?.source).toBe(CONST.IOU.TYPE.SPLIT); - - expect(carlosTransaction?.comment?.originalTransactionID).toBe(groupTransaction?.transactionID); - expect(julesTransaction?.comment?.originalTransactionID).toBe(groupTransaction?.transactionID); - expect(vitTransaction?.comment?.originalTransactionID).toBe(groupTransaction?.transactionID); - - expect(carlosTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(julesTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(vitTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(groupTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - waitForCollectionCallback: false, - callback: (allPersonalDetails) => { - Onyx.disconnect(connection); - expect(allPersonalDetails).toMatchObject({ - [VIT_ACCOUNT_ID]: { - accountID: VIT_ACCOUNT_ID, - displayName: VIT_EMAIL, - login: VIT_EMAIL, - }, - }); - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume) - .then(waitForNetworkPromises) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - for (const report of Object.values(allReports ?? {})) { - if (!report?.pendingFields) { - continue; - } - for (const pendingField of Object.values(report?.pendingFields)) { - expect(pendingField).toBeFalsy(); - } - } - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connection); - for (const reportAction of Object.values(allReportActions ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - for (const transaction of Object.values(allTransactions ?? {})) { - expect(transaction?.pendingAction).toBeFalsy(); - } - resolve(); - }, - }); - }), - ); - }); - - it('should update split chat report lastVisibleActionCreated to the report preview action', async () => { - // Given a expense chat with no expenses - const workspaceReportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, {reportID: workspaceReportID, isOwnPolicyExpenseChat: true}); - - // When the user split bill on the workspace - splitBill({ - participants: [{reportID: workspaceReportID}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: workspaceReportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: splitMockPersonalDetails, - }); - - await waitForBatchedUpdates(); - - // Then the expense chat lastVisibleActionCreated should be updated to the report preview action created - const reportPreviewAction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceReportID}`, - callback: (reportActions) => { - Onyx.disconnect(connection); - resolve(Object.values(reportActions ?? {}).find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); - }, - }); - }); - - await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, - callback: (report) => { - Onyx.disconnect(connection); - expect(report?.lastVisibleActionCreated).toBe(reportPreviewAction?.created); - resolve(report); - }, - }); - }); - }); - - it('correctly sets quickAction', async () => { - // Given a expense chat with no expenses - const workspaceReportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, {reportID: workspaceReportID, isOwnPolicyExpenseChat: true}); - - splitBill({ - participants: [{reportID: workspaceReportID}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: workspaceReportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: splitMockPersonalDetails, - }); - - await waitForBatchedUpdates(); - - expect(await getOnyxValue(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE)).toHaveProperty('isFirstQuickAction', true); - - splitBill({ - participants: [{reportID: workspaceReportID}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: workspaceReportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: {action: CONST.QUICK_ACTIONS.SEND_MONEY, chatReportID: '456'}, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: splitMockPersonalDetails, - }); - await waitForBatchedUpdates(); - - expect(await getOnyxValue(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE)).toMatchObject({ - action: CONST.QUICK_ACTIONS.SPLIT_MANUAL, - isFirstQuickAction: false, - }); - }); - - it('merges policyRecentlyUsedCurrencies when splitting a bill', async () => { - const initialCurrencies = [CONST.CURRENCY.USD]; - await Onyx.set(ONYXKEYS.RECENTLY_USED_CURRENCIES, initialCurrencies); - - splitBill({ - participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.EUR, - merchant: 'test', - created: '', - existingSplitChatReportID: '', - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: initialCurrencies, - policyRecentlyUsedTags: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: splitMockPersonalDetails, - }); - - await waitForBatchedUpdates(); - - const recentlyUsedCurrencies = await getOnyxValue(ONYXKEYS.RECENTLY_USED_CURRENCIES); - expect(recentlyUsedCurrencies).toEqual([CONST.CURRENCY.EUR, ...initialCurrencies]); - }); - - it('should update split chat report lastVisibleActionCreated to the latest IOU action when split bill in a DM', async () => { - // Given a DM chat with no expenses - const reportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { - reportID, - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }); - - // When the user split bill twice on the DM - splitBill({ - participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: reportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: splitMockPersonalDetails, - }); - - await waitForBatchedUpdates(); - - splitBill({ - participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 200, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: reportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - betas: [], - personalDetails: splitMockPersonalDetails, - }); - - await waitForBatchedUpdates(); - - // Then the DM lastVisibleActionCreated should be updated to the second IOU action created - const iouAction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - callback: (reportActions) => { - Onyx.disconnect(connection); - resolve(Object.values(reportActions ?? {}).find((action) => isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU) && getOriginalMessage(action)?.amount === 200)); - }, - }); - }); - - await waitForBatchedUpdates(); - - const report = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - callback: (reportVal) => { - Onyx.disconnect(connection); - resolve(reportVal); - }, - }); - }); - expect(report?.lastVisibleActionCreated).toBe(iouAction?.created); - }); - - it('optimistic transaction should be merged with the draft transaction if it is a distance request', async () => { - // Given a workspace expense chat and a draft split transaction - const workspaceReportID = '1'; - const transactionAmount = 100; - const draftTransaction = { - amount: transactionAmount, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - splitShares: { - [workspaceReportID]: {amount: 100}, - }, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, {reportID: workspaceReportID, isOwnPolicyExpenseChat: true}); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, draftTransaction); - - // When doing a distance split expense - splitBill({ - participants: [{reportID: workspaceReportID}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - existingSplitChatReportID: workspaceReportID, - ...draftTransaction, - comment: '', - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: splitMockPersonalDetails, - }); - - await waitForBatchedUpdates(); - - const optimisticTransaction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - Onyx.disconnect(connection); - resolve(Object.values(transactions ?? {}).find((transaction) => transaction?.amount === -(transactionAmount / 2))); - }, - }); - }); - - // Then the data from the transaction draft should be merged into the optimistic transaction - expect(optimisticTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE); - }); - - it("should update the notification preference of the report to ALWAYS if it's previously hidden", async () => { - // Given a group chat with hidden notification preference - const reportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { - reportID, - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.GROUP, - participants: { - [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, - [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, - }, - }); - - // When the user split bill on the group chat - splitBill({ - participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: reportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: splitMockPersonalDetails, - }); - - await waitForBatchedUpdates(); - - // Then the DM notification preference should be updated to ALWAYS - const report = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - callback: (reportVal) => { - Onyx.disconnect(connection); - resolve(reportVal); - }, - }); - }); - expect(report?.participants?.[RORY_ACCOUNT_ID].notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS); - }); - - it('should update the policyRecentlyUsedTags when tag is provided', async () => { - // Given a policy recently used tags - const policyID = 'A'; - const transactionTag = 'new tag'; - const tagName = 'Tag'; - const policyRecentlyUsedTags: OnyxEntry = { - [tagName]: ['old tag'], - }; - - const policyExpenseChat = { - reportID: '2', - policyID, - isPolicyExpenseChat: true, - isOwnPolicyExpenseChat: true, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { - [tagName]: {name: tagName}, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, policyRecentlyUsedTags); - - // When doing a split bill - splitBill({ - participants: [{isPolicyExpenseChat: true, policyID}], - existingSplitChatReportID: policyExpenseChat.reportID, - currentUserLogin: currentUserPersonalDetails.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - amount: 1, - created: '', - comment: '', - merchant: '', - transactionViolations: undefined, - category: undefined, - tag: transactionTag, - currency: CONST.CURRENCY.USD, - taxCode: '', - taxAmount: 0, - isASAPSubmitBetaEnabled: false, - policyRecentlyUsedTags, - quickAction: {}, - policyRecentlyUsedCurrencies: [], - betas: [CONST.BETAS.ALL], - personalDetails: splitMockPersonalDetails, - }); - - waitForBatchedUpdates(); - - // Then the transaction tag should be added to the recently used tags collection - const newPolicyRecentlyUsedTags: RecentlyUsedTags = await new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, - callback: (recentlyUsedTags) => { - resolve(recentlyUsedTags ?? {}); - Onyx.disconnect(connection); - }, - }); - }); - expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); - expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); - }); - - it('the description should not be parsed again after completing the scan split bill without changing the description', async () => { - const reportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { - reportID, - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.GROUP, - participants: { - [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - }); - - const participants: IOUParticipant[] = [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}]; - const participantsPolicyTags = await getParticipantsPolicyTags(participants); - - // Start a scan split bill - const {splitTransactionID} = startSplitBill({ - participants, - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '# test', - currency: CONST.CURRENCY.USD, - existingSplitChatReportID: reportID, - receipt: {}, - category: undefined, - tag: undefined, - taxCode: '', - taxAmount: 0, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - participantsPolicyTags, - }); - - await waitForBatchedUpdates(); - - let splitTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID}`); - - // Then the description should be parsed correctly - expect(splitTransaction?.comment?.comment).toBe('

test

'); - - const updatedSplitTransaction = splitTransaction - ? { - ...splitTransaction, - amount: 100, - } - : undefined; - - const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); - const iouAction = Object.values(reportActions ?? {}).find((action) => isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU)); - - expect(iouAction).toBeTruthy(); - - // Complete this split bill without changing the description - completeSplitBill(reportID, iouAction, updatedSplitTransaction, RORY_ACCOUNT_ID, false, undefined, {}, [CONST.BETAS.ALL], splitMockPersonalDetails, RORY_EMAIL); - - await waitForBatchedUpdates(); - - splitTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID}`); - - // Then the description should be the same since it was not changed - expect(splitTransaction?.comment?.comment).toBe('

test

'); - }); - - it('should calculate proportional convertedAmount for split transactions with foreign currency', async () => { - jest.setTimeout(10 * 1000); - - // Given: An expense report with AED currency and a USD transaction with convertedAmount - const originalAmount = -1000; - const originalConvertedAmount = -3673; - const reportID = rand64(); - const originalTransactionID = rand64(); - - const expenseReport: Report = { - reportID, - type: CONST.REPORT.TYPE.EXPENSE, - currency: 'AED', - ownerAccountID: RORY_ACCOUNT_ID, - total: originalAmount, - }; - - const originalTransaction = { - transactionID: originalTransactionID, - amount: originalAmount, - modifiedAmount: '', // Empty string - the edge case that was causing the bug - currency: 'USD', - modifiedCurrency: '', - convertedAmount: originalConvertedAmount, - created: DateUtils.getDBTime(), - merchant: 'Test Merchant', - reportID, - comment: {}, - } as unknown as Transaction; - - const transactionThread: Report = { - reportID: rand64(), - type: CONST.REPORT.TYPE.CHAT, - parentReportID: reportID, - parentReportActionID: rand64(), - }; - - const iouAction: ReportAction = { - ...buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: Math.abs(originalAmount), - currency: 'USD', - comment: '', - participants: [], - transactionID: originalTransactionID, - iouReportID: reportID, - }), - childReportID: transactionThread.reportID, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { - [iouAction.reportActionID]: iouAction, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, originalTransaction); - - const splitExpenses: SplitExpense[] = [ - { - transactionID: rand64(), - amount: -500, - created: DateUtils.getDBTime(), - merchant: 'Test Merchant', - }, - { - transactionID: rand64(), - amount: -500, - created: DateUtils.getDBTime(), - merchant: 'Test Merchant', - }, - ]; - - 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 reports = getTransactionAndExpenseReports(reportID); - const policyTags = await getPolicyTags(reports.expenseReport?.policyID); - - // When splitting the expense - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID, - originalTransactionID, - splitExpenses, - splitExpensesTotal: -1000, - }, - searchContext: { - currentSearchHash: -1, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: iouAction, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - iouReportNextStep: undefined, - betas: [CONST.BETAS.ALL], - policyTags, - personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - transactionReport: reports.transactionReport, - expenseReport: reports.expenseReport, - isOffline: false, - }); - - await waitForBatchedUpdates(); - - // Then each split transaction should have proportional convertedAmount - // Formula: Math.round((originalConvertedAmount * splitAmount) / originalAmount) - const expectedProportionalConvertedAmount = -1836; - - const splitTransactions = await new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - Onyx.disconnect(connection); - const splits = Object.values(transactions ?? {}).filter( - (t) => t?.transactionID !== originalTransactionID && t?.comment?.originalTransactionID === originalTransactionID, - ); - resolve(splits as Transaction[]); - }, - }); - }); - - expect(splitTransactions.length).toBe(2); - - for (const splitTransaction of splitTransactions) { - expect(splitTransaction.convertedAmount).toBe(expectedProportionalConvertedAmount); - } - }); - }); - - describe('startSplitBill', () => { - it('should update the policyRecentlyUsedTags when tag is provided', async () => { - // Given a policy recently used tags - const policyID = 'A'; - const transactionTag = 'new tag'; - const tagName = 'Tag'; - const policyRecentlyUsedTags: OnyxEntry = { - [tagName]: ['old tag'], - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { - [tagName]: {name: tagName}, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, policyRecentlyUsedTags); - - const participants: IOUParticipant[] = [{isPolicyExpenseChat: true, policyID}]; - const participantsPolicyTags = await getParticipantsPolicyTags(participants); - - // When doing a split bill with a receipt - startSplitBill({ - participants, - currentUserLogin: currentUserPersonalDetails.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - comment: '', - receipt: {}, - category: undefined, - tag: transactionTag, - currency: CONST.CURRENCY.USD, - taxCode: '', - taxAmount: 0, - policyRecentlyUsedTags, - quickAction: {}, - policyRecentlyUsedCurrencies: [], - participantsPolicyTags, - }); - - waitForBatchedUpdates(); - - // Then the transaction tag should be added to the recently used tags collection - const newPolicyRecentlyUsedTags: RecentlyUsedTags = await new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, - callback: (recentlyUsedTags) => { - resolve(recentlyUsedTags ?? {}); - Onyx.disconnect(connection); - }, - }); - }); - expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); - expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); - }); - }); - - describe('updateSplitTransactionsFromSplitExpensesFlow', () => { - it('should delete the original transaction thread report', async () => { - const expenseReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction: Transaction = { - amount: 100, - currency: 'USD', - transactionID: '1', - reportID: expenseReport.reportID, - created: DateUtils.getDBTime(), - merchant: 'test', - }; - const transactionThread: Report = { - ...createRandomReport(2, undefined), - }; - const iouAction: ReportAction = { - ...buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: transaction.amount, - currency: transaction.currency, - comment: '', - participants: [], - transactionID: transaction.transactionID, - iouReportID: expenseReport.reportID, - }), - childReportID: transactionThread.reportID, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { - [iouAction.reportActionID]: iouAction, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - const draftTransaction: OnyxEntry = { - ...transaction, - comment: { - originalTransactionID: transaction.transactionID, - }, - }; - - 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - const policyTags = await getPolicyTags(reportID); - const reports = getTransactionAndExpenseReports(reportID); - - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID, - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: -2, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: iouAction, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - iouReportNextStep: undefined, - betas: [CONST.BETAS.ALL], - policyTags, - personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - transactionReport: reports.transactionReport, - expenseReport: reports.expenseReport, - isOffline: false, - }); - - await waitForBatchedUpdates(); - - const originalTransactionThread = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${iouAction.childReportID}`, - callback: (val) => { - Onyx.disconnect(connection); - resolve(val); - }, - }); - }); - expect(originalTransactionThread).toBe(undefined); - }); - - it('should remove the original transaction from the search snapshot data', async () => { - // Given a single expense - const expenseReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction: Transaction = { - amount: 100, - currency: 'USD', - transactionID: '1', - reportID: expenseReport.reportID, - created: DateUtils.getDBTime(), - merchant: 'test', - }; - const transactionThread: Report = { - ...createRandomReport(2, undefined), - }; - const iouAction: ReportAction = { - ...buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: transaction.amount, - currency: transaction.currency, - comment: '', - participants: [], - transactionID: transaction.transactionID, - iouReportID: expenseReport.reportID, - }), - childReportID: transactionThread.reportID, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { - [iouAction.reportActionID]: iouAction, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - const draftTransaction: OnyxEntry = { - ...transaction, - comment: { - originalTransactionID: transaction.transactionID, - }, - }; - - // When splitting the expense - const hash = 1; - - 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - const policyTags = await getPolicyTags(reportID); - const reports = getTransactionAndExpenseReports(reportID); - - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID, - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: hash, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: undefined, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - iouReportNextStep: undefined, - betas: [CONST.BETAS.ALL], - policyTags, - personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - transactionReport: reports.transactionReport, - expenseReport: reports.expenseReport, - isOffline: false, - }); - - await waitForBatchedUpdates(); - - // Then the original expense/transaction should be removed from the search snapshot data - const searchSnapshot = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, - callback: (val) => { - Onyx.disconnect(connection); - resolve(val); - }, - }); - }); - expect(searchSnapshot?.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]).toBe(undefined); - }); - - it('should add split transactions optimistically on search snapshot when current search filter is on unapprovedCash', async () => { - const chatReport: Report = createRandomReport(7, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - // Given a single expense - const expenseReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - chatReportID: chatReport.reportID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - }; - const transaction: Transaction = { - amount: 100, - currency: 'USD', - transactionID: '1', - reportID: expenseReport.reportID, - created: DateUtils.getDBTime(), - merchant: 'test', - }; - const transactionThread: Report = { - ...createRandomReport(2, undefined), - }; - const iouAction: ReportAction = { - ...buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: transaction.amount, - currency: transaction.currency, - comment: '', - participants: [], - transactionID: transaction.transactionID, - iouReportID: expenseReport.reportID, - }), - childReportID: transactionThread.reportID, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { - [iouAction.reportActionID]: iouAction, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - const splitTransactionID1 = '34'; - const splitTransactionID2 = '35'; - const draftTransaction: OnyxEntry = { - ...transaction, - comment: { - originalTransactionID: transaction.transactionID, - splitExpenses: [ - {amount: transaction.amount / 2, transactionID: splitTransactionID1, created: ''}, - {amount: transaction.amount / 2, transactionID: splitTransactionID2, created: ''}, - ], - }, - }; - - // When splitting the expense - const hash = 1; - - 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - const policyTags = await getPolicyTags(reportID); - const reports = getTransactionAndExpenseReports(reportID); - - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID, - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: hash, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: undefined, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - iouReportNextStep: undefined, - betas: [CONST.BETAS.ALL], - policyTags, - personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - transactionReport: reports.transactionReport, - expenseReport: reports.expenseReport, - isOffline: false, - }); - - await waitForBatchedUpdates(); - - // Then the split expenses/transactions should be added on the search snapshot data - const searchSnapshot = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`, - callback: (val) => { - Onyx.disconnect(connection); - resolve(val); - }, - }); - }); - expect(searchSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID1}`]).toBeDefined(); - expect(searchSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID2}`]).toBeDefined(); - }); - }); - - describe('setMoneyRequestCategory', () => { - it('should set the associated tax for the category based on the tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount: 0, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - }); - - describe('should not change the tax', () => { - it('if the transaction type is distance', async () => { - // Given a policy with tax expense rules associated with category and a distance transaction - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - }); - - it('should clear the tax when the policyID is empty', async () => { - // Given a transaction with a tax - const transactionID = '1'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - }); - - // When setting the money request category without a policyID - setMoneyRequestCategory(transactionID, '', undefined); - await waitForBatchedUpdates(); - - // Then the transaction tax should be cleared - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(''); - expect(transaction?.taxAmount).toBeUndefined(); - resolve(); - }, - }); - }); - }); - }); - - describe('calculateDiffAmount', () => { - it('should return 0 if iouReport is undefined', () => { - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - reportID: '1', - amount: 100, - currency: 'USD', - }; - - expect(calculateDiffAmount(undefined, fakeTransaction, fakeTransaction)).toBe(0); - }); - - it('should return 0 when the currency and amount of the transactions are the same', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - reportID: fakeReport.reportID, - amount: 100, - currency: 'USD', - }; - - expect(calculateDiffAmount(fakeReport, fakeTransaction, fakeTransaction)).toBe(0); - }); - - it('should return the difference between the updated amount and the current amount when the currency of the updated and current transactions have the same currency', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, - currency: 'USD', - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - amount: 100, - currency: 'USD', - }; - const updatedTransaction = { - ...fakeTransaction, - amount: 200, - currency: 'USD', - }; - - expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBe(-100); - }); - - it('should return null when the currency of the updated and current transactions have different values', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - amount: 100, - currency: 'USD', - }; - const updatedTransaction = { - ...fakeTransaction, - amount: 200, - currency: 'EUR', - }; - - expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBeNull(); - }); - - it('should return 0 when the currency and amount of the transactions are the same for an invoice report', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.INVOICE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - reportID: fakeReport.reportID, - amount: 100, - currency: 'USD', - }; - - expect(calculateDiffAmount(fakeReport, fakeTransaction, fakeTransaction)).toBe(0); - }); - - it('should return the correct diff for an invoice report (same sign convention as expense reports)', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.INVOICE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, - currency: 'USD', - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - amount: 100, - currency: 'USD', - }; - const updatedTransaction = { - ...fakeTransaction, - amount: 200, - currency: 'USD', - }; - - expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBe(-100); - }); - - it('should return null when the currency of the updated and current transactions differ for an invoice report', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.INVOICE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - amount: 100, - currency: 'USD', - }; - const updatedTransaction = { - ...fakeTransaction, - amount: 200, - currency: 'EUR', - }; - - expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBeNull(); - }); - }); - - describe('initMoneyRequest', () => { - const fakeReport: Report = { - ...createRandomReport(0, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - managerID: CARLOS_ACCOUNT_ID, - }; - const fakePolicy: Policy = { - ...createRandomPolicy(1), - type: CONST.POLICY.TYPE.TEAM, - outputCurrency: 'USD', - }; - - const fakeParentReport: Report = { - ...createRandomReport(1, undefined), - reportID: fakeReport.reportID, - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - managerID: CARLOS_ACCOUNT_ID, - }; - const fakePersonalPolicy: Pick = { - id: '2', - autoReporting: true, - type: CONST.POLICY.TYPE.PERSONAL, - outputCurrency: 'NZD', - }; - const transactionResult: Transaction = { - amount: 0, - comment: { - attendees: [ - { - email: currentUserPersonalDetails.email ?? '', - login: currentUserPersonalDetails.login, - accountID: 3, - text: currentUserPersonalDetails.login, - selected: true, - reportID: '0', - avatarUrl: SafeString(currentUserPersonalDetails.avatar) ?? '', - displayName: currentUserPersonalDetails.displayName ?? '', - }, - ], - }, - created: '2025-04-01', - currency: 'USD', - iouRequestType: 'manual', - reportID: fakeReport.reportID, - transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, - isFromGlobalCreate: true, - merchant: 'Expense', - }; - - const currentDate = '2025-04-01'; - beforeEach(async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, null); - await Onyx.merge(`${ONYXKEYS.CURRENT_DATE}`, currentDate); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - return waitForBatchedUpdates(); - }); - - it('should merge transaction draft onyx value', async () => { - await waitForBatchedUpdates() - .then(() => { - initMoneyRequest({ - reportID: fakeReport.reportID, - policy: fakePolicy, - personalPolicy: fakePersonalPolicy, - isFromGlobalCreate: true, - newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - report: fakeReport, - parentReport: fakeParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies: false, - draftTransactionIDs: [], - }); - }) - .then(async () => { - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); - }); - }); - - it('should modify transaction draft when currentIouRequestType is different', async () => { - await waitForBatchedUpdates() - .then(() => { - return initMoneyRequest({ - reportID: fakeReport.reportID, - policy: fakePolicy, - personalPolicy: fakePersonalPolicy, - isFromGlobalCreate: true, - currentIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - report: fakeReport, - parentReport: fakeParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies: false, - draftTransactionIDs: [], - }); - }) - .then(async () => { - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual({ - ...transactionResult, - merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - }); - }); - }); - it('should return personal currency when policy is missing', async () => { - await waitForBatchedUpdates() - .then(() => { - return initMoneyRequest({ - reportID: fakeReport.reportID, - personalPolicy: fakePersonalPolicy, - isFromGlobalCreate: true, - newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - report: fakeReport, - parentReport: fakeParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies: false, - draftTransactionIDs: [], - }); - }) - .then(async () => { - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual({ - ...transactionResult, - currency: fakePersonalPolicy.outputCurrency, - }); - }); - }); - - it('should remove non-optimistic draft transactions when draftTransactionIDs is provided', async () => { - const otherDraftTransactionID = '123456'; - const otherDraftTransaction: Transaction = { - ...createRandomTransaction(1), - transactionID: otherDraftTransactionID, - }; - - // Set up an additional draft transaction - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${otherDraftTransactionID}`, otherDraftTransaction); - - await waitForBatchedUpdates() - .then(() => { - initMoneyRequest({ - reportID: fakeReport.reportID, - policy: fakePolicy, - personalPolicy: fakePersonalPolicy, - isFromGlobalCreate: true, - newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - report: fakeReport, - parentReport: fakeParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies: false, - draftTransactionIDs: [otherDraftTransactionID], - }); - }) - .then(async () => { - // The other draft transaction should be removed (Onyx returns undefined for removed keys) - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${otherDraftTransactionID}`)).toBeUndefined(); - // The optimistic transaction should be created - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); - }); - }); - - it('should preserve optimistic transaction in draftTransactionIDs while removing others', async () => { - const otherDraftTransactionID = '789012'; - const otherDraftTransaction: Transaction = { - ...createRandomTransaction(2), - transactionID: otherDraftTransactionID, - }; - const existingOptimisticTransaction: Transaction = { - ...createRandomTransaction(3), - transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, - }; - - // Set up both draft transactions - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${otherDraftTransactionID}`, otherDraftTransaction); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, existingOptimisticTransaction); - - await waitForBatchedUpdates() - .then(() => { - initMoneyRequest({ - reportID: fakeReport.reportID, - policy: fakePolicy, - personalPolicy: fakePersonalPolicy, - isFromGlobalCreate: true, - newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - report: fakeReport, - parentReport: fakeParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies: false, - draftTransactionIDs: [otherDraftTransactionID, CONST.IOU.OPTIMISTIC_TRANSACTION_ID], - }); - }) - .then(async () => { - // The other draft transaction should be removed (Onyx returns undefined for removed keys) - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${otherDraftTransactionID}`)).toBeUndefined(); - // The optimistic transaction should be updated with the new transaction result (not removed) - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); - }); - }); - - it('should remove multiple draft transactions when draftTransactionIDs contains several entries', async () => { - const draftTransactionID1 = '111111'; - const draftTransactionID2 = '222222'; - const draftTransaction1: Transaction = { - ...createRandomTransaction(4), - transactionID: draftTransactionID1, - }; - const draftTransaction2: Transaction = { - ...createRandomTransaction(5), - transactionID: draftTransactionID2, - }; - - // Set up multiple draft transactions - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransactionID1}`, draftTransaction1); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransactionID2}`, draftTransaction2); - - await waitForBatchedUpdates() - .then(() => { - initMoneyRequest({ - reportID: fakeReport.reportID, - policy: fakePolicy, - personalPolicy: fakePersonalPolicy, - isFromGlobalCreate: true, - newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - report: fakeReport, - parentReport: fakeParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies: false, - draftTransactionIDs: [draftTransactionID1, draftTransactionID2], - }); - }) - .then(async () => { - // Both draft transactions should be removed (Onyx returns undefined for removed keys) - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransactionID1}`)).toBeUndefined(); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransactionID2}`)).toBeUndefined(); - // The optimistic transaction should be created - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); - }); - }); - }); - - describe('changeTransactionsReport', () => { - it('should set the correct optimistic onyx data for reporting a tracked expense', async () => { - let personalDetailsList: OnyxEntry; - let expenseReport: OnyxEntry; - let transaction: OnyxEntry; - let allTransactions: OnyxCollection = {}; - - // Given a signed in account, which owns a workspace, and has a policy expense chat - Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); - const creatorPersonalDetails = personalDetailsList?.[CARLOS_ACCOUNT_ID] ?? {accountID: CARLOS_ACCOUNT_ID}; - - const policyID = generatePolicyID(); - const mockPolicy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, "Carlos's Workspace"), - id: policyID, - outputCurrency: CONST.CURRENCY.USD, - owner: CARLOS_EMAIL, - ownerAccountID: CARLOS_ACCOUNT_ID, - pendingAction: undefined, - }; - - await waitForBatchedUpdates(); - - createNewReport(creatorPersonalDetails, true, false, mockPolicy, [CONST.BETAS.ALL]); - // Create a tracked expense - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: '10', - }; - - const amount = 100; - - const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; - - trackExpense({ - report: selfDMReport, - isDraftPolicy: true, - action: CONST.IOU.ACTION.CREATE, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {accountID: RORY_ACCOUNT_ID}, - }, - transactionParams: { - amount, - currency: CONST.CURRENCY.USD, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'merchant', - billable: false, - reimbursable: false, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - transaction = Object.values(transactions ?? {}).find((t) => !!t); - allTransactions = transactions; - }, - }); - - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.EXPENSE); - }, - }); - - let iouReportActionOnSelfDMReport: OnyxEntry; - let trackExpenseActionableWhisper: OnyxEntry; - - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - iouReportActionOnSelfDMReport = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`] ?? {}).find( - (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU, - ); - trackExpenseActionableWhisper = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport?.reportID}`] ?? {}).find( - (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, - ); - }, - }); - - expect(isMoneyRequestAction(iouReportActionOnSelfDMReport) ? getOriginalMessage(iouReportActionOnSelfDMReport)?.IOUTransactionID : undefined).toBe(transaction?.transactionID); - expect(trackExpenseActionableWhisper).toBeDefined(); - - if (!transaction || !expenseReport) { - return; - } - - const {result} = renderHook(() => { - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`); - return {report}; - }); - - await waitFor(() => { - expect(result.current.report).toBeDefined(); - }); - - const policyTagList = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${mockPolicy.id}`)) ?? {}; - - changeTransactionsReport({ - transactionIDs: [transaction?.transactionID], - isASAPSubmitBetaEnabled: false, - accountID: CARLOS_ACCOUNT_ID, - email: CARLOS_EMAIL, - newReport: result.current.report, - policy: mockPolicy, - allTransactions, - policyTagList, - }); - - let updatedTransaction: OnyxEntry; - let updatedIOUReportActionOnSelfDMReport: OnyxEntry; - let updatedTrackExpenseActionableWhisper: OnyxEntry; - let updatedExpenseReport: OnyxEntry; - - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - updatedTransaction = Object.values(transactions ?? {}).find((t) => t?.transactionID === transaction?.transactionID); - }, - }); - - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - updatedIOUReportActionOnSelfDMReport = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`] ?? {}).find( - (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU, - ); - updatedTrackExpenseActionableWhisper = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport?.reportID}`] ?? {}).find( - (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, - ); - }, - }); - - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - updatedExpenseReport = Object.values(allReports ?? {}).find((r) => r?.reportID === expenseReport?.reportID); - }, - }); - - expect(updatedTransaction?.reportID).toBe(expenseReport?.reportID); - expect(isMoneyRequestAction(updatedIOUReportActionOnSelfDMReport) ? getOriginalMessage(updatedIOUReportActionOnSelfDMReport)?.IOUTransactionID : undefined).toBe(undefined); - expect(updatedTrackExpenseActionableWhisper).toBe(undefined); - expect(updatedExpenseReport?.nonReimbursableTotal).toBe(-amount); - expect(updatedExpenseReport?.total).toBe(-amount); - expect(updatedExpenseReport?.unheldNonReimbursableTotal).toBe(-amount); - }); - - describe('updateSplitTransactionsFromSplitExpensesFlow', () => { - it("should update split transaction's description correctly ", async () => { - const amount = 10000; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let originalTransactionID; - - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - isSelfTourViewed: false, - betas: undefined, - hasActiveAdminPolicies: false, - activePolicy: undefined, - }); - - const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - // Change the approval mode for the policy since default is Submit and Close - setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, {}); - await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - }, - }); - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'NASDAQ', - comment: '*hey* `hey`', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - }, - }); - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; - originalTransactionID = originalMessage?.IOUTransactionID; - }, - }); - - const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); - const draftTransaction: Transaction = { - reportID: originalTransaction?.reportID ?? '456', - transactionID: originalTransaction?.transactionID ?? '234', - amount, - created: originalTransaction?.created ?? DateUtils.getDBTime(), - currency: CONST.CURRENCY.USD, - merchant: originalTransaction?.merchant ?? '', - comment: { - originalTransactionID, - comment: originalTransaction?.comment?.comment ?? '', - splitExpenses: [ - { - transactionID: '235', - amount: amount / 2, - description: 'hey
hey', - created: DateUtils.getDBTime(), - }, - { - transactionID: '234', - amount: amount / 2, - description: '*hey1* `hey`', - created: DateUtils.getDBTime(), - }, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - }; - - 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - const policyTags = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${reportID}`)) ?? {}; - const reports = getTransactionAndExpenseReports(reportID); - - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID, - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - 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, - personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - transactionReport: reports.transactionReport, - expenseReport: reports.expenseReport, - isOffline: false, - }); - await waitForBatchedUpdates(); - - const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); - expect(split1?.comment?.comment).toBe('hey
hey'); - const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}234`); - expect(split2?.comment?.comment).toBe('hey1 hey'); - }); - - it("should not create new expense report if the admin split the employee's expense", async () => { - const amount = 10000; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let originalTransactionID; - - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: RORY_EMAIL, - makeMeAdmin: true, - policyName: "Rory's Workspace", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - isSelfTourViewed: false, - betas: undefined, - hasActiveAdminPolicies: false, - activePolicy: undefined, - }); - - const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - // Change the approval mode for the policy since default is Submit and Close - setWorkspaceApprovalMode(policy, RORY_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, {}); - await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - }, - }); - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: CARLOS_EMAIL, - payeeAccountID: CARLOS_ACCOUNT_ID, - participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'NASDAQ', - comment: '*hey* `hey`', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - }, - }); - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; - originalTransactionID = originalMessage?.IOUTransactionID; - }, - }); - - const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); - const draftTransaction: Transaction = { - reportID: originalTransaction?.reportID ?? '456', - transactionID: originalTransaction?.transactionID ?? '234', - amount, - created: originalTransaction?.created ?? DateUtils.getDBTime(), - currency: CONST.CURRENCY.USD, - merchant: originalTransaction?.merchant ?? '', - comment: { - originalTransactionID, - comment: originalTransaction?.comment?.comment ?? '', - splitExpenses: [ - { - transactionID: '235', - amount: amount / 2, - description: 'hey
hey', - created: DateUtils.getDBTime(), - }, - { - transactionID: '234', - amount: amount / 2, - description: '*hey1* `hey`', - created: DateUtils.getDBTime(), - }, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - }; - - 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - const policyTags = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${reportID}`)) ?? {}; - const reports = getTransactionAndExpenseReports(reportID); - - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID, - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - 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, - personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - transactionReport: reports.transactionReport, - expenseReport: reports.expenseReport, - isOffline: false, - }); - await waitForBatchedUpdates(); - - const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); - expect(split1?.reportID).toBe(expenseReport?.reportID); - const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}234`); - expect(split2?.reportID).toBe(expenseReport?.reportID); - }); - - it('should use splitExpensesTotal in calculation when editing splits', async () => { - // The fix ensures we rely on splitExpensesTotal rather than potentially incorrect backend reportTotal - // This prevents scenarios where backend sends wrong total (e.g., -$2 instead of -$10) - // from causing incorrect report totals (e.g., $24 instead of correct -$10) - - const amount = -10000; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let originalTransactionID; - - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - isSelfTourViewed: false, - betas: undefined, - hasActiveAdminPolicies: false, - activePolicy: undefined, - }); - - const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, {}); - await waitForBatchedUpdates(); - - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - }, - }); - - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test Merchant', - comment: 'Test expense', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - await waitForBatchedUpdates(); - - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - }, - }); - - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; - originalTransactionID = originalMessage?.IOUTransactionID; - }, - }); - - const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); - - // Set up split expenses with explicit splitExpensesTotal - // Using negative amounts to get positive transaction amounts (expense reports store as negative) - const splitExpensesTotal = -8000; // -$80 total for splits - const draftTransaction: Transaction = { - reportID: originalTransaction?.reportID ?? '456', - transactionID: originalTransaction?.transactionID ?? '234', - amount, - created: originalTransaction?.created ?? DateUtils.getDBTime(), - currency: CONST.CURRENCY.USD, - merchant: originalTransaction?.merchant ?? '', - comment: { - originalTransactionID, - comment: originalTransaction?.comment?.comment ?? '', - splitExpenses: [ - { - transactionID: '235', - amount: -5000, - description: 'Split 1', - created: DateUtils.getDBTime(), - }, - { - transactionID: '236', - amount: -3000, - description: 'Split 2', - created: DateUtils.getDBTime(), - }, - ], - splitExpensesTotal, - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - }; - - 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - const policyTags = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${reportID}`)) ?? {}; - const reports = getTransactionAndExpenseReports(reportID); - - // it should use splitExpensesTotal in its calculation - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID, - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - 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, - personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - transactionReport: reports.transactionReport, - expenseReport: reports.expenseReport, - isOffline: false, - }); - await waitForBatchedUpdates(); - - const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); - const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}236`); - - expect(split1).toBeDefined(); - expect(split2).toBeDefined(); - }); - - it('should create hold report actions for split transactions when original transaction is on hold', async () => { - // Given an expense that is on hold - const amount = 10000; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let originalTransactionID: string | undefined; - let transactionThreadReportID: string | undefined; - - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace for Hold Test", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - isSelfTourViewed: false, - betas: undefined, - hasActiveAdminPolicies: false, - activePolicy: undefined, - }); - - const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - // Change the approval mode for the policy since default is Submit and Close - setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, {}); - await waitForBatchedUpdates(); - - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - }, - }); - - // Create the initial expense - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test Merchant', - comment: 'Original expense', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); - await waitForBatchedUpdates(); - - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - }, - }); - - // Get the original transaction ID and transaction thread report ID - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const iouAction = iouActions?.at(0); - const originalMessage = isMoneyRequestAction(iouAction) ? getOriginalMessage(iouAction) : undefined; - originalTransactionID = originalMessage?.IOUTransactionID; - transactionThreadReportID = iouAction?.childReportID; - }, - }); - - // Put the expense on hold - if (originalTransactionID && transactionThreadReportID) { - putOnHold(originalTransactionID, 'Test hold reason', transactionThreadReportID, false, RORY_EMAIL, RORY_ACCOUNT_ID); - } - await waitForBatchedUpdates(); - - // Verify the transaction is on hold - const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); - expect(originalTransaction?.comment?.hold).toBeDefined(); - - // Get the first IOU action for the split flow - let firstIOU: ReportAction | undefined; - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - firstIOU = iouActions?.at(0); - }, - }); - - // Create the draft transaction with split expenses - const draftTransaction: Transaction = { - reportID: originalTransaction?.reportID ?? '456', - transactionID: originalTransaction?.transactionID ?? '234', - amount, - created: originalTransaction?.created ?? DateUtils.getDBTime(), - currency: CONST.CURRENCY.USD, - merchant: originalTransaction?.merchant ?? '', - comment: { - originalTransactionID, - comment: originalTransaction?.comment?.comment ?? '', - hold: originalTransaction?.comment?.hold, - splitExpenses: [ - { - transactionID: 'split-held-tx-1', - amount: amount / 2, - description: 'Split 1', - created: DateUtils.getDBTime(), - }, - { - transactionID: 'split-held-tx-2', - amount: amount / 2, - description: 'Split 2', - created: DateUtils.getDBTime(), - }, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - }; - - 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); - const policyTags = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${reportID}`)) ?? {}; - const reports = getTransactionAndExpenseReports(reportID); - - // When splitting the held expense - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID, - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: -2, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - iouReportNextStep: undefined, - betas: [CONST.BETAS.ALL], - policyTags, - personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - transactionReport: reports.transactionReport, - expenseReport: reports.expenseReport, - isOffline: false, - }); - - await waitForBatchedUpdates(); - - // Then verify the split transactions were created - const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}split-held-tx-1`); - const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}split-held-tx-2`); - - expect(split1).toBeDefined(); - expect(split2).toBeDefined(); - - // Find the transaction thread reports for each split by looking at the IOU actions - let split1ThreadReportID: string | undefined; - let split2ThreadReportID: string | undefined; - - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - for (const action of iouActions) { - const message = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; - if (message?.IOUTransactionID === 'split-held-tx-1') { - split1ThreadReportID = action.childReportID; - } else if (message?.IOUTransactionID === 'split-held-tx-2') { - split2ThreadReportID = action.childReportID; - } - } - }, - }); - - // Verify that split transaction thread IDs exist - expect(split1ThreadReportID).toBeDefined(); - expect(split2ThreadReportID).toBeDefined(); - - // Verify each split transaction thread has hold report actions - // When splitting a held expense, new hold report actions should be created for each split - if (split1ThreadReportID) { - const split1ReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${split1ThreadReportID}`); - const split1HoldActions = Object.values(split1ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD); - const split1CommentActions = Object.values(split1ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); - - // Should have at least one HOLD action and one ADD_COMMENT action (the hold comment) - // The hold actions are created optimistically with pendingAction: ADD, but this - // may be cleared to null after the API call succeeds - expect(split1HoldActions.length).toBeGreaterThanOrEqual(1); - expect(split1CommentActions.length).toBeGreaterThanOrEqual(1); - } - - if (split2ThreadReportID) { - const split2ReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${split2ThreadReportID}`); - const split2HoldActions = Object.values(split2ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD); - const split2CommentActions = Object.values(split2ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); - - // Should have at least one HOLD action and one ADD_COMMENT action (the hold comment) - expect(split2HoldActions.length).toBeGreaterThanOrEqual(1); - expect(split2CommentActions.length).toBeGreaterThanOrEqual(1); - } - }); - }); - }); - - describe('getManagerMcTestParticipant', () => { - it('should return manager mctest participant when personalDetails contains manager_mctest', () => { - // Given personalDetails that include manager_mctest - const managerMcTestAccountID = CONST.ACCOUNT_ID.MANAGER_MCTEST; - const personalDetailsList: PersonalDetailsList = { - [managerMcTestAccountID]: { - accountID: managerMcTestAccountID, - login: CONST.EMAIL.MANAGER_MCTEST, - displayName: 'Manager McTest', - }, - }; - - // When calling getManagerMcTestParticipant with personalDetails - const result = getManagerMcTestParticipant(RORY_ACCOUNT_ID, personalDetailsList); - - // Then it should return a participant with the manager mctest account ID - expect(result).toBeDefined(); - expect(result?.accountID).toBe(managerMcTestAccountID); - }); - - it('should return undefined when personalDetails does not contain manager_mctest', () => { - // Given personalDetails without manager_mctest - const personalDetailsList: PersonalDetailsList = { - [RORY_ACCOUNT_ID]: { - accountID: RORY_ACCOUNT_ID, - login: RORY_EMAIL, - displayName: 'Rory', - }, - }; - - // When calling getManagerMcTestParticipant with personalDetails - const result = getManagerMcTestParticipant(RORY_ACCOUNT_ID, personalDetailsList); - - // Then it should return undefined since manager_mctest is not in the provided personalDetails - expect(result).toBeUndefined(); - }); - - it('should return undefined when personalDetails is empty', () => { - // Given empty personalDetails - const personalDetailsList: PersonalDetailsList = {}; - - // When calling getManagerMcTestParticipant with empty personalDetails - const result = getManagerMcTestParticipant(RORY_ACCOUNT_ID, personalDetailsList); - - // Then it should return undefined - expect(result).toBeUndefined(); - }); - }); - - describe('Report Totals Calculation for Split Expenses', () => { - function calculateReportTotalsForSplitExpenses( - expenseReport: Report | undefined, - splitExpenses: SplitExpense[], - allReportsList: Record | undefined, - changesInReportTotal: number, - ): Map { - const reportTotals = new Map(); - const expenseReportID = expenseReport?.reportID; - - if (expenseReportID) { - const expenseReportKey = `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`; - const expenseReportTotal = allReportsList?.[expenseReportKey]?.total ?? expenseReport?.total ?? 0; - reportTotals.set(expenseReportID, expenseReportTotal - changesInReportTotal); - } - - for (const expense of splitExpenses) { - const splitExpenseReportID = expense.reportID; - if (!splitExpenseReportID || reportTotals.has(splitExpenseReportID)) { - continue; - } - - const splitExpenseReport = allReportsList?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpenseReportID}`]; - reportTotals.set(splitExpenseReportID, splitExpenseReport?.total ?? 0); - } - - return reportTotals; - } - - it('should calculate expense report total minus changes when expense report ID exists', () => { - const expenseReport: Report = { - reportID: 'report1', - total: 10000, - } as Report; - - const splitExpenses: SplitExpense[] = []; - const allReportsList = { - [`${ONYXKEYS.COLLECTION.REPORT}report1`]: { - reportID: 'report1', - total: 10000, - } as Report, - }; - const changesInReportTotal = 2000; - - const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); - - expect(result.size).toBe(1); - expect(result.get('report1')).toBe(8000); // 10000 - 2000 - }); - - it('should use expense report total directly when not in allReportsList', () => { - const expenseReport: Report = { - reportID: 'report1', - total: 15000, - } as Report; - - const splitExpenses: SplitExpense[] = []; - const allReportsList = {}; // Empty, so should fall back to expenseReport.total - const changesInReportTotal = 3000; - - const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); - - expect(result.size).toBe(1); - expect(result.get('report1')).toBe(12000); // 15000 - 3000 - }); - - it('should use allReportsList total when it differs from expense report total', () => { - const expenseReport: Report = { - reportID: 'report1', - total: 10000, - } as Report; - - const splitExpenses: SplitExpense[] = []; - const allReportsList = { - [`${ONYXKEYS.COLLECTION.REPORT}report1`]: { - reportID: 'report1', - total: 12000, // Different from expenseReport.total - } as Report, - }; - const changesInReportTotal = 2000; - - const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); - - expect(result.size).toBe(1); - expect(result.get('report1')).toBe(10000); // 12000 - 2000 (uses allReportsList value) - }); - - it('should add split expenses from different reports to the map', () => { - const expenseReport: Report = { - reportID: 'mainReport', - total: 10000, - } as Report; - - const splitExpenses: SplitExpense[] = [ - { - reportID: 'splitReport1', - amount: 2000, - } as SplitExpense, - { - reportID: 'splitReport2', - amount: 3000, - } as SplitExpense, - ]; - - const allReportsList = { - [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { - reportID: 'mainReport', - total: 10000, - } as Report, - [`${ONYXKEYS.COLLECTION.REPORT}splitReport1`]: { - reportID: 'splitReport1', - total: 5000, - } as Report, - [`${ONYXKEYS.COLLECTION.REPORT}splitReport2`]: { - reportID: 'splitReport2', - total: 7000, - } as Report, - }; - const changesInReportTotal = 1000; - - const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); - - expect(result.size).toBe(3); - expect(result.get('mainReport')).toBe(9000); // 10000 - 1000 - expect(result.get('splitReport1')).toBe(5000); - expect(result.get('splitReport2')).toBe(7000); - }); - - it('should skip split expenses without reportID', () => { - const expenseReport: Report = { - reportID: 'mainReport', - total: 10000, - } as Report; - - const splitExpenses: SplitExpense[] = [ - { - reportID: undefined, - amount: 2000, - } as SplitExpense, - { - reportID: 'splitReport1', - amount: 3000, - } as SplitExpense, - ]; - - const allReportsList = { - [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { - reportID: 'mainReport', - total: 10000, - } as Report, - [`${ONYXKEYS.COLLECTION.REPORT}splitReport1`]: { - reportID: 'splitReport1', - total: 5000, - } as Report, - }; - const changesInReportTotal = 1000; - - const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); - - expect(result.size).toBe(2); // Only mainReport and splitReport1 - expect(result.get('mainReport')).toBe(9000); - expect(result.get('splitReport1')).toBe(5000); - }); - - it('should skip split expenses that are already in reportTotals', () => { - const expenseReport: Report = { - reportID: 'mainReport', - total: 10000, - } as Report; - - // Two split expenses with the same reportID - const splitExpenses: SplitExpense[] = [ - { - reportID: 'splitReport1', - amount: 2000, - } as SplitExpense, - { - reportID: 'splitReport1', // Duplicate reportID - amount: 3000, - } as SplitExpense, - { - reportID: 'splitReport2', - amount: 1500, - } as SplitExpense, - ]; - - const allReportsList = { - [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { - reportID: 'mainReport', - total: 10000, - } as Report, - [`${ONYXKEYS.COLLECTION.REPORT}splitReport1`]: { - reportID: 'splitReport1', - total: 5000, - } as Report, - [`${ONYXKEYS.COLLECTION.REPORT}splitReport2`]: { - reportID: 'splitReport2', - total: 3000, - } as Report, - }; - const changesInReportTotal = 1000; - - const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); - - expect(result.size).toBe(3); - expect(result.get('mainReport')).toBe(9000); - expect(result.get('splitReport1')).toBe(5000); // Should only be added once - expect(result.get('splitReport2')).toBe(3000); - }); - - it('should default split expense report total to 0 when not found in allReportsList', () => { - const expenseReport: Report = { - reportID: 'mainReport', - total: 10000, - } as Report; - - const splitExpenses: SplitExpense[] = [ - { - reportID: 'splitReport1', - amount: 2000, - } as SplitExpense, - ]; - - const allReportsList = { - [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { - reportID: 'mainReport', - total: 10000, - } as Report, - // splitReport1 is NOT in allReportsList - }; - const changesInReportTotal = 1000; - - const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); - - expect(result.size).toBe(2); - expect(result.get('mainReport')).toBe(9000); - expect(result.get('splitReport1')).toBe(0); // Defaults to 0 - }); - - it('should handle empty split expenses array', () => { - const expenseReport: Report = { - reportID: 'mainReport', - total: 10000, - } as Report; - - const splitExpenses: SplitExpense[] = []; - const allReportsList = { - [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { - reportID: 'mainReport', - total: 10000, - } as Report, - }; - const changesInReportTotal = 2000; - - const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); - - expect(result.size).toBe(1); - expect(result.get('mainReport')).toBe(8000); - }); - - it('should handle negative changesInReportTotal', () => { - const expenseReport: Report = { - reportID: 'mainReport', - total: 10000, - } as Report; - - const splitExpenses: SplitExpense[] = []; - const allReportsList = { - [`${ONYXKEYS.COLLECTION.REPORT}mainReport`]: { - reportID: 'mainReport', - total: 10000, - } as Report, - }; - const changesInReportTotal = -2000; // Negative change - - const result = calculateReportTotalsForSplitExpenses(expenseReport, splitExpenses, allReportsList, changesInReportTotal); - - expect(result.size).toBe(1); - expect(result.get('mainReport')).toBe(12000); // 10000 - (-2000) = 12000 - }); - }); - - it('handleNavigateAfterExpenseCreate', async () => { - const mockedIsReportTopmostSplitNavigator = isReportTopmostSplitNavigator as jest.MockedFunction; - const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute'); - const activeReportID = '1'; - const transactionID = '1'; - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: false}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(true); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - mockedIsReportTopmostSplitNavigator.mockReturnValue(false); - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - handleNavigateAfterExpenseCreate({activeReportID, isFromGlobalCreate: true, transactionID, isInvoice: true}); - expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledTimes(0); - - spyOnMergeTransactionIdsHighlightOnSearchRoute.mockReset(); - }); - - describe('resetDraftTransactionsCustomUnit', () => { - it('should do nothing if transaction is not passed', async () => { - // Call the reset function without a transaction - resetDraftTransactionsCustomUnit(undefined); - await waitForBatchedUpdates(); - const allDraftTransactions = await getOnyxValue(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT); - // Assuming there are no draft transactions, this should be undefined or an empty object - expect(allDraftTransactions).toBeUndefined(); - }); - it('should reset custom unit for a transaction', async () => { - const transactionID = 'transaction_reset_001'; - const fakeTransaction: Transaction = { - transactionID, - amount: 1500, - currency: CONST.CURRENCY.USD, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'Test Reset', - reportID: 'report_reset_001', - comment: { - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - customUnit: { - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - quantity: 100, - }, - waypoints: { - waypoint0: {lat: 40.7128, lng: -74.006, address: 'NYC', name: 'NYC', keyForList: 'nyc_key'}, - }, - }, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, fakeTransaction); - await waitForBatchedUpdates(); - // Call the reset function - resetDraftTransactionsCustomUnit(fakeTransaction); - await waitForBatchedUpdates(); - // Verify the transaction's custom unit and waypoints have been reset - const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(updatedTransaction?.comment?.customUnit?.name).toBe(CONST.CUSTOM_UNITS.NAME_DISTANCE); - expect(updatedTransaction?.comment?.customUnit?.quantity).toBe(100); - }); - }); - - describe('setMoneyRequest helpers', () => { - const transactionID = 'testTransaction123'; - - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdates(); - }); - - it('setMoneyRequestAmount should set amount, currency, and shouldShowOriginalAmount on transaction draft', async () => { - setMoneyRequestAmount(transactionID, 500, 'EUR', true); - await waitForBatchedUpdates(); - const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draft?.amount).toBe(500); - expect(draft?.currency).toBe('EUR'); - expect(draft?.shouldShowOriginalAmount).toBe(true); - }); - - it('setMoneyRequestCreated should set created on transaction draft', async () => { - setMoneyRequestCreated(transactionID, '2024-01-15', true); - await waitForBatchedUpdates(); - const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draft?.created).toBe('2024-01-15'); - }); - - it('setMoneyRequestDateAttribute should set date attributes on transaction draft', async () => { - setMoneyRequestDateAttribute(transactionID, '2024-01-01', '2024-01-31'); - await waitForBatchedUpdates(); - const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draft?.comment?.customUnit?.attributes?.dates?.start).toBe('2024-01-01'); - expect(draft?.comment?.customUnit?.attributes?.dates?.end).toBe('2024-01-31'); - }); - - it('setMoneyRequestMerchant should set merchant on transaction draft', async () => { - setMoneyRequestMerchant(transactionID, 'Coffee Shop', true); - await waitForBatchedUpdates(); - const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draft?.merchant).toBe('Coffee Shop'); - }); - - it('setMoneyRequestTag should set tag on transaction draft', async () => { - setMoneyRequestTag(transactionID, 'Engineering'); - await waitForBatchedUpdates(); - const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draft?.tag).toBe('Engineering'); - }); - - it('setMoneyRequestBillable should set billable on transaction draft', async () => { - setMoneyRequestBillable(transactionID, true); - await waitForBatchedUpdates(); - const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draft?.billable).toBe(true); - }); - }); - describe('setMoneyRequestOdometerImage and removeMoneyRequestOdometerImage', () => { - beforeEach(() => { - jest.mock('@libs/OdometerImageUtils', () => ({ - __esModule: true, - default: jest.fn(), - })); - }); - - afterEach(() => { - jest.unmock('@libs/OdometerImageUtils'); - }); - it('should set odometer start image on a draft transaction', async () => { - const transaction = createRandomTransaction(1); - const transactionID = transaction.transactionID; - const file = {uri: 'image.uri', name: 'image.jpg', type: 'image/jpeg', size: 1234}; - const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.START; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction); - - setMoneyRequestOdometerImage(transaction, imageType, file, true, false); - await waitForBatchedUpdates(); - - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draftTransaction?.comment?.odometerStartImage).toEqual(file); - }); - - it('should set odometer end image on a non-draft transaction', async () => { - const transaction = createRandomTransaction(1); - const transactionID = transaction.transactionID; - const file = {uri: 'image.uri', name: 'image.jpg', type: 'image/jpeg', size: 1234}; - const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.END; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); - - setMoneyRequestOdometerImage(transaction, imageType, file, false, false); - await waitForBatchedUpdates(); - - const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - expect(updatedTransaction?.comment?.odometerEndImage).toEqual(file); - }); - - it('should remove odometer start image from a draft transaction', async () => { - const transaction = { - ...createRandomTransaction(1), - comment: { - odometerStartImage: {uri: 'image.uri'}, - }, - } as Transaction; - const transactionID = transaction.transactionID; - const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.START; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction); - - removeMoneyRequestOdometerImage(transaction, imageType, true, false); - await waitForBatchedUpdates(); - - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draftTransaction?.comment?.odometerStartImage).toBeUndefined(); - }); - - it('should remove odometer end image from a non-draft transaction', async () => { - const transaction = { - ...createRandomTransaction(1), - comment: { - odometerEndImage: {uri: 'image.uri'}, - }, - } as Transaction; - const transactionID = transaction.transactionID; - const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.END; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); - - removeMoneyRequestOdometerImage(transaction, imageType, false, false); - await waitForBatchedUpdates(); - - const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - expect(updatedTransaction?.comment?.odometerEndImage).toBeUndefined(); - }); - }); - - describe('createSplitsAndOnyxData', () => { - const mockPersonalDetails: PersonalDetailsList = { - [RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL, displayName: 'Rory'}, - [CARLOS_ACCOUNT_ID]: {accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL, displayName: 'Carlos'}, - [JULES_ACCOUNT_ID]: {accountID: JULES_ACCOUNT_ID, login: JULES_EMAIL, displayName: 'Jules'}, - [VIT_ACCOUNT_ID]: {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL, displayName: 'Vit'}, - }; - - const baseTransactionParams = { - amount: 400, - currency: CONST.CURRENCY.USD, - created: '2024-01-01', - merchant: 'Test Merchant', - comment: 'Test split', - tag: '', - category: '', - taxCode: '', - taxAmount: 0, - splitShares: {} as SplitShares, - }; - - const buildParams = ( - overrides: { - participants?: IOUParticipant[]; - existingSplitChatReportID?: string; - transactionParamOverrides?: Partial; - participantsPolicyTags?: Record; - } = {}, - ) => ({ - participants: overrides.participants ?? [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - existingSplitChatReportID: overrides.existingSplitChatReportID, - transactionParams: { - ...baseTransactionParams, - ...overrides.transactionParamOverrides, - }, - policyRecentlyUsedCategories: undefined, - policyRecentlyUsedTags: undefined, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - betas: [CONST.BETAS.ALL], - personalDetails: mockPersonalDetails, - participantsPolicyTags: overrides.participantsPolicyTags ?? {}, - }); - - it('returns valid splitData with chatReportID, transactionID, and reportActionID', () => { - // Given a basic 1:1 split between the current user and one participant - - // When creating splits and Onyx data - const result = createSplitsAndOnyxData(buildParams()); - - // Then splitData should contain all required identifiers - expect(result.splitData.chatReportID).toBeTruthy(); - expect(result.splitData.transactionID).toBeTruthy(); - expect(result.splitData.reportActionID).toBeTruthy(); - }); - - it('includes createdReportActionID in splitData for a new chat', () => { - // Given no existing split chat report - - // When creating splits and Onyx data - const result = createSplitsAndOnyxData(buildParams()); - - // Then splitData should include a createdReportActionID for the new chat - expect(result.splitData.createdReportActionID).toBeTruthy(); - }); - - it('omits createdReportActionID from splitData when using an existing chat', async () => { - // Given an existing chat report already in Onyx - const existingReportID = rand64(); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingReportID}`, { - reportID: existingReportID, - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }); - await waitForBatchedUpdates(); - - // When creating splits referencing that existing chat - const result = createSplitsAndOnyxData(buildParams({existingSplitChatReportID: existingReportID})); - - // Then splitData should not include a createdReportActionID - expect(result.splitData.createdReportActionID).toBeUndefined(); - }); - - it('splits amount equally among all participants when no splitShares are provided', () => { - // Given a $400 expense split between the current user and 3 other participants - const amount = 400; - - // When creating splits without custom splitShares - const result = createSplitsAndOnyxData( - buildParams({ - participants: [ - {accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}, - {accountID: JULES_ACCOUNT_ID, login: JULES_EMAIL}, - {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL}, - ], - transactionParamOverrides: {amount}, - }), - ); - - // Then each of the 4 splits (current user + 3 others) should be $100 - expect(result.splits).toHaveLength(4); - for (const split of result.splits) { - expect(split.amount).toBe(amount / 4); - } - }); - - it('respects custom splitShares amounts when provided', () => { - // Given a $200 expense with custom split: current user pays $150, Carlos pays $50 - const splitShares: SplitShares = { - [RORY_ACCOUNT_ID]: {amount: 150}, - [CARLOS_ACCOUNT_ID]: {amount: 50}, - }; - - // When creating splits with those custom splitShares - const result = createSplitsAndOnyxData( - buildParams({ - transactionParamOverrides: {amount: 200, splitShares}, - }), - ); - - // Then each participant's split should reflect the custom amounts - const currentUserSplit = result.splits.find((s) => s.accountID === RORY_ACCOUNT_ID); - const carlosSplit = result.splits.find((s) => s.accountID === CARLOS_ACCOUNT_ID); - - expect(currentUserSplit?.amount).toBe(150); - expect(carlosSplit?.amount).toBe(50); - }); - - it('uses SET method for the split chat report in optimisticData when creating a new chat', () => { - // Given no existing split chat report - - // When creating splits and Onyx data - const result = createSplitsAndOnyxData(buildParams()); - - // Then the chat report update should use SET to write the new report atomically - const splitChatReportUpdate = result.onyxData.optimisticData?.find( - (update) => - update.key.startsWith(ONYXKEYS.COLLECTION.REPORT) && - !update.key.includes(ONYXKEYS.COLLECTION.REPORT_ACTIONS) && - !update.key.includes(ONYXKEYS.COLLECTION.REPORT_METADATA), - ); - - expect(splitChatReportUpdate?.onyxMethod).toBe(Onyx.METHOD.SET); - }); - - it('uses MERGE method for the split chat report in optimisticData when reusing an existing chat', async () => { - // Given an existing chat report already in Onyx - const existingReportID = rand64(); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingReportID}`, { - reportID: existingReportID, - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }); - await waitForBatchedUpdates(); - - // When creating splits referencing that existing chat - const result = createSplitsAndOnyxData(buildParams({existingSplitChatReportID: existingReportID})); - - // Then the chat report update should use MERGE to preserve existing fields - const splitChatReportUpdate = result.onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${existingReportID}`); - - expect(splitChatReportUpdate?.onyxMethod).toBe(Onyx.METHOD.MERGE); - }); - - it('adds isOptimisticReport:true to REPORT_METADATA in optimisticData for a new chat', () => { - // Given no existing split chat report - - // When creating splits and Onyx data - const result = createSplitsAndOnyxData(buildParams()); - - // Then optimisticData should flag the new report as optimistic - const reportMetaUpdate = result.onyxData.optimisticData?.find((update) => update.key.startsWith(ONYXKEYS.COLLECTION.REPORT_METADATA)); - - expect(reportMetaUpdate?.value).toMatchObject({isOptimisticReport: true}); - }); - - it('does not include REPORT_METADATA isOptimisticReport in optimisticData for an existing chat', async () => { - // Given an existing chat report already in Onyx - const existingReportID = rand64(); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${existingReportID}`, { - reportID: existingReportID, - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }); - await waitForBatchedUpdates(); - - // When creating splits referencing that existing chat - const result = createSplitsAndOnyxData(buildParams({existingSplitChatReportID: existingReportID})); - - // Then no REPORT_METADATA entry should be written for the existing report - const reportMetaUpdate = result.onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT_METADATA}${existingReportID}`); - - expect(reportMetaUpdate).toBeUndefined(); - }); - - it('clears pendingAction and pendingFields on the split transaction in successData', () => { - // Given a basic split - - // When creating splits and Onyx data - const result = createSplitsAndOnyxData(buildParams()); - const {transactionID} = result.splitData; - - // Then successData should clear pending state on the split transaction - const txSuccessUpdate = result.onyxData.successData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - - expect(txSuccessUpdate?.value).toMatchObject({pendingAction: null, pendingFields: null}); - }); - - it('includes errors on the split transaction in failureData', () => { - // Given a basic split - - // When creating splits and Onyx data - const result = createSplitsAndOnyxData(buildParams()); - const {transactionID} = result.splitData; - - // Then failureData should include an errors entry on the split transaction for user-visible feedback - const txFailureUpdate = result.onyxData.failureData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - - expect(txFailureUpdate?.value).toHaveProperty('errors'); - }); - - it('sets policy recently used tags in optimisticData for a policy expense chat participant with a tag', async () => { - // Given a workspace expense chat with a known tag list - const policyID = 'test_policy_999'; - const tagListName = 'Department'; - const tagName = 'Engineering'; - - const existingExpenseChatID = rand64(); - await Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { - [`${ONYXKEYS.COLLECTION.REPORT}${existingExpenseChatID}`]: { - reportID: existingExpenseChatID, - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - policyID, - isOwnPolicyExpenseChat: true, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT}, - }, - } as OnyxMergeCollectionInput); - await waitForBatchedUpdates(); - - const policyTagsList = { - [tagListName]: { - name: tagListName, - tags: {[tagName]: {name: tagName, enabled: true}}, - }, - }; - - // When splitting an expense with a tag inside that workspace chat - const result = createSplitsAndOnyxData( - buildParams({ - existingSplitChatReportID: existingExpenseChatID, - participants: [ - { - accountID: CARLOS_ACCOUNT_ID, - login: CARLOS_EMAIL, - isPolicyExpenseChat: true, - isOwnPolicyExpenseChat: true, - policyID, - }, - ], - transactionParamOverrides: {tag: tagName}, - participantsPolicyTags: {[policyID]: policyTagsList} as unknown as Record, - }), - ); - - // Then optimisticData should update POLICY_RECENTLY_USED_TAGS with the used tag - const recentlyUsedTagsUpdate = result.onyxData.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`); - - expect(recentlyUsedTagsUpdate?.value).toMatchObject({[tagListName]: [tagName]}); - }); - }); -}); diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts new file mode 100644 index 000000000000..644dd450ce74 --- /dev/null +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -0,0 +1,212 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import '@libs/actions/IOU/MoneyRequest'; +import {removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import type * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; +import type Transaction from '@src/types/onyx/Transaction'; +import currencyList from '../unit/currencyList.json'; +import createRandomTransaction from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; +import type {MockFetch} from '../utils/TestHelper'; +import {getGlobalFetchMock} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const topMostReportID = '23423423'; +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissToSuperWideRHP: jest.fn(), + navigateBackToLastSuperWideRHPScreen: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => topMostReportID), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +// In production, requestMoney defers its API.write() call until the target screen's +// content lays out (or a safety timeout fires). In tests there is no target component +// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +jest.mock('@libs/deferredLayoutWrite', () => ({ + registerDeferredWrite: (_key: string, callback: () => void) => callback(), + flushDeferredWrite: jest.fn(), + cancelDeferredWrite: jest.fn(), + hasDeferredWrite: () => false, + getOptimisticWatchKey: () => undefined, + deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), + reserveDeferredWriteChannel: jest.fn(), + resetForTesting: jest.fn(), +})); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +const unapprovedCashHash = 71801560; +const unapprovedCashSimilarSearchHash = 1832274510; +jest.mock('@src/libs/SearchQueryUtils', () => { + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: unapprovedCashSimilarSearchHash, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual('@libs/PolicyUtils'), + isPaidGroupPolicy: jest.fn().mockReturnValue(true), + isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), +})); + +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; + +OnyxUpdateManager(); +describe('actions/OdometerTransactionUtils', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllTimers(); + global.fetch = getGlobalFetchMock(); + mockFetch = fetch as MockFetch; + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('setMoneyRequestOdometerImage and removeMoneyRequestOdometerImage', () => { + beforeEach(() => { + jest.mock('@libs/OdometerImageUtils', () => ({ + __esModule: true, + default: jest.fn(), + })); + }); + + afterEach(() => { + jest.unmock('@libs/OdometerImageUtils'); + }); + it('should set odometer start image on a draft transaction', async () => { + const transaction = createRandomTransaction(1); + const transactionID = transaction.transactionID; + const file = {uri: 'image.uri', name: 'image.jpg', type: 'image/jpeg', size: 1234}; + const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.START; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction); + + setMoneyRequestOdometerImage(transaction, imageType, file, true, false); + await waitForBatchedUpdates(); + + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draftTransaction?.comment?.odometerStartImage).toEqual(file); + }); + + it('should set odometer end image on a non-draft transaction', async () => { + const transaction = createRandomTransaction(1); + const transactionID = transaction.transactionID; + const file = {uri: 'image.uri', name: 'image.jpg', type: 'image/jpeg', size: 1234}; + const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.END; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + setMoneyRequestOdometerImage(transaction, imageType, file, false, false); + await waitForBatchedUpdates(); + + const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(updatedTransaction?.comment?.odometerEndImage).toEqual(file); + }); + + it('should remove odometer start image from a draft transaction', async () => { + const transaction = { + ...createRandomTransaction(1), + comment: { + odometerStartImage: {uri: 'image.uri'}, + }, + } as Transaction; + const transactionID = transaction.transactionID; + const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.START; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction); + + removeMoneyRequestOdometerImage(transaction, imageType, true, false); + await waitForBatchedUpdates(); + + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + expect(draftTransaction?.comment?.odometerStartImage).toBeUndefined(); + }); + + it('should remove odometer end image from a non-draft transaction', async () => { + const transaction = { + ...createRandomTransaction(1), + comment: { + odometerEndImage: {uri: 'image.uri'}, + }, + } as Transaction; + const transactionID = transaction.transactionID; + const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.END; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + removeMoneyRequestOdometerImage(transaction, imageType, false, false); + await waitForBatchedUpdates(); + + const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(updatedTransaction?.comment?.odometerEndImage).toBeUndefined(); + }); + }); +}); diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts new file mode 100644 index 000000000000..a5a5430e4ba9 --- /dev/null +++ b/tests/actions/TransactionTest.ts @@ -0,0 +1,1148 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import {renderHook, waitFor} from '@testing-library/react-native'; +import {format} from 'date-fns'; +import Onyx from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import useOnyx from '@hooks/useOnyx'; +import {putOnHold} from '@libs/actions/IOU/Hold'; +import '@libs/actions/IOU/MoneyRequest'; +import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate'; +import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {createWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; +import {createNewReport} from '@libs/actions/Report'; +import type * as PolicyUtils from '@libs/PolicyUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getReportOrDraftReport} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import DateUtils from '@src/libs/DateUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy, Report, ReportNameValuePairs} from '@src/types/onyx'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; +import type ReportAction from '@src/types/onyx/ReportAction'; +import type Transaction from '@src/types/onyx/Transaction'; +import {changeTransactionsReport} from '../../src/libs/actions/Transaction'; +import currencyList from '../unit/currencyList.json'; +import createPersonalDetails from '../utils/collections/personalDetails'; +import createRandomPolicy from '../utils/collections/policies'; +import {createRandomReport} from '../utils/collections/reports'; +import getOnyxValue from '../utils/getOnyxValue'; +import type {MockFetch} from '../utils/TestHelper'; +import {getGlobalFetchMock, getOnyxData} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const topMostReportID = '23423423'; +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissToSuperWideRHP: jest.fn(), + navigateBackToLastSuperWideRHPScreen: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => topMostReportID), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +// In production, requestMoney defers its API.write() call until the target screen's +// content lays out (or a safety timeout fires). In tests there is no target component +// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +jest.mock('@libs/deferredLayoutWrite', () => ({ + registerDeferredWrite: (_key: string, callback: () => void) => callback(), + flushDeferredWrite: jest.fn(), + cancelDeferredWrite: jest.fn(), + hasDeferredWrite: () => false, + getOptimisticWatchKey: () => undefined, + deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), + reserveDeferredWriteChannel: jest.fn(), + resetForTesting: jest.fn(), +})); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +const unapprovedCashHash = 71801560; +const unapprovedCashSimilarSearchHash = 1832274510; +jest.mock('@src/libs/SearchQueryUtils', () => { + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: unapprovedCashSimilarSearchHash, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual('@libs/PolicyUtils'), + isPaidGroupPolicy: jest.fn().mockReturnValue(true), + isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), +})); + +const CARLOS_EMAIL = 'cmartins@expensifail.com'; +const CARLOS_ACCOUNT_ID = 1; +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; + +const getTransactionAndExpenseReports = (reportID: string) => { + const transactionReport = getReportOrDraftReport(reportID); + const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); + const expenseReport = transactionReport?.type === CONST.REPORT.TYPE.EXPENSE ? transactionReport : parentTransactionReport; + return {transactionReport, expenseReport}; +}; + +OnyxUpdateManager(); +describe('actions/Transaction', () => { + const currentUserPersonalDetails: CurrentUserPersonalDetails = { + ...createPersonalDetails(RORY_ACCOUNT_ID), + login: RORY_EMAIL, + email: RORY_EMAIL, + displayName: RORY_EMAIL, + avatar: 'https://example.com/avatar.jpg', + }; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllTimers(); + global.fetch = getGlobalFetchMock(); + mockFetch = fetch as MockFetch; + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('changeTransactionsReport', () => { + it('should set the correct optimistic onyx data for reporting a tracked expense', async () => { + let personalDetailsList: OnyxEntry; + let expenseReport: OnyxEntry; + let transaction: OnyxEntry; + let allTransactions: OnyxCollection = {}; + + // Given a signed in account, which owns a workspace, and has a policy expense chat + Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + const creatorPersonalDetails = personalDetailsList?.[CARLOS_ACCOUNT_ID] ?? {accountID: CARLOS_ACCOUNT_ID}; + + const policyID = generatePolicyID(); + const mockPolicy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, "Carlos's Workspace"), + id: policyID, + outputCurrency: CONST.CURRENCY.USD, + owner: CARLOS_EMAIL, + ownerAccountID: CARLOS_ACCOUNT_ID, + pendingAction: undefined, + }; + + await waitForBatchedUpdates(); + + createNewReport(creatorPersonalDetails, true, false, mockPolicy, [CONST.BETAS.ALL]); + // Create a tracked expense + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: '10', + }; + + const amount = 100; + + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + + trackExpense({ + report: selfDMReport, + isDraftPolicy: true, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount, + currency: CONST.CURRENCY.USD, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'merchant', + billable: false, + reimbursable: false, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + draftTransactionIDs: [], + isSelfTourViewed: false, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + transaction = Object.values(transactions ?? {}).find((t) => !!t); + allTransactions = transactions; + }, + }); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + + let iouReportActionOnSelfDMReport: OnyxEntry; + let trackExpenseActionableWhisper: OnyxEntry; + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (allReportActions) => { + iouReportActionOnSelfDMReport = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`] ?? {}).find( + (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU, + ); + trackExpenseActionableWhisper = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport?.reportID}`] ?? {}).find( + (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, + ); + }, + }); + + expect(isMoneyRequestAction(iouReportActionOnSelfDMReport) ? getOriginalMessage(iouReportActionOnSelfDMReport)?.IOUTransactionID : undefined).toBe(transaction?.transactionID); + expect(trackExpenseActionableWhisper).toBeDefined(); + + if (!transaction || !expenseReport) { + return; + } + + const {result} = renderHook(() => { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`); + return {report}; + }); + + await waitFor(() => { + expect(result.current.report).toBeDefined(); + }); + + const policyTagList = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${mockPolicy.id}`)) ?? {}; + + changeTransactionsReport({ + transactionIDs: [transaction?.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: CARLOS_ACCOUNT_ID, + email: CARLOS_EMAIL, + newReport: result.current.report, + policy: mockPolicy, + allTransactions, + policyTagList, + }); + + let updatedTransaction: OnyxEntry; + let updatedIOUReportActionOnSelfDMReport: OnyxEntry; + let updatedTrackExpenseActionableWhisper: OnyxEntry; + let updatedExpenseReport: OnyxEntry; + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + updatedTransaction = Object.values(transactions ?? {}).find((t) => t?.transactionID === transaction?.transactionID); + }, + }); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (allReportActions) => { + updatedIOUReportActionOnSelfDMReport = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`] ?? {}).find( + (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU, + ); + updatedTrackExpenseActionableWhisper = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport?.reportID}`] ?? {}).find( + (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, + ); + }, + }); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + updatedExpenseReport = Object.values(allReports ?? {}).find((r) => r?.reportID === expenseReport?.reportID); + }, + }); + + expect(updatedTransaction?.reportID).toBe(expenseReport?.reportID); + expect(isMoneyRequestAction(updatedIOUReportActionOnSelfDMReport) ? getOriginalMessage(updatedIOUReportActionOnSelfDMReport)?.IOUTransactionID : undefined).toBe(undefined); + expect(updatedTrackExpenseActionableWhisper).toBe(undefined); + expect(updatedExpenseReport?.nonReimbursableTotal).toBe(-amount); + expect(updatedExpenseReport?.total).toBe(-amount); + expect(updatedExpenseReport?.unheldNonReimbursableTotal).toBe(-amount); + }); + + describe('updateSplitTransactionsFromSplitExpensesFlow', () => { + it("should update split transaction's description correctly ", async () => { + const amount = 10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID; + + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + activePolicy: undefined, + }); + + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + // Change the approval mode for the policy since default is Submit and Close + setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, {}); + await waitForBatchedUpdates(); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'NASDAQ', + comment: '*hey* `hey`', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + await waitForBatchedUpdates(); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + }, + }); + + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + const draftTransaction: Transaction = { + reportID: originalTransaction?.reportID ?? '456', + transactionID: originalTransaction?.transactionID ?? '234', + amount, + created: originalTransaction?.created ?? DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + merchant: originalTransaction?.merchant ?? '', + comment: { + originalTransactionID, + comment: originalTransaction?.comment?.comment ?? '', + splitExpenses: [ + { + transactionID: '235', + amount: amount / 2, + description: 'hey
hey', + created: DateUtils.getDBTime(), + }, + { + transactionID: '234', + amount: amount / 2, + description: '*hey1* `hey`', + created: DateUtils.getDBTime(), + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + }; + + 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); + const policyTags = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${reportID}`)) ?? {}; + const reports = getTransactionAndExpenseReports(reportID); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID, + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + 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, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports.transactionReport, + expenseReport: reports.expenseReport, + isOffline: false, + }); + await waitForBatchedUpdates(); + + const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); + expect(split1?.comment?.comment).toBe('hey
hey'); + const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}234`); + expect(split2?.comment?.comment).toBe('hey1 hey'); + }); + + it("should not create new expense report if the admin split the employee's expense", async () => { + const amount = 10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID; + + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: RORY_EMAIL, + makeMeAdmin: true, + policyName: "Rory's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + activePolicy: undefined, + }); + + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + // Change the approval mode for the policy since default is Submit and Close + setWorkspaceApprovalMode(policy, RORY_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, {}); + await waitForBatchedUpdates(); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: CARLOS_EMAIL, + payeeAccountID: CARLOS_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'NASDAQ', + comment: '*hey* `hey`', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + await waitForBatchedUpdates(); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + }, + }); + + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + const draftTransaction: Transaction = { + reportID: originalTransaction?.reportID ?? '456', + transactionID: originalTransaction?.transactionID ?? '234', + amount, + created: originalTransaction?.created ?? DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + merchant: originalTransaction?.merchant ?? '', + comment: { + originalTransactionID, + comment: originalTransaction?.comment?.comment ?? '', + splitExpenses: [ + { + transactionID: '235', + amount: amount / 2, + description: 'hey
hey', + created: DateUtils.getDBTime(), + }, + { + transactionID: '234', + amount: amount / 2, + description: '*hey1* `hey`', + created: DateUtils.getDBTime(), + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + }; + + 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); + const policyTags = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${reportID}`)) ?? {}; + const reports = getTransactionAndExpenseReports(reportID); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID, + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + 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, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports.transactionReport, + expenseReport: reports.expenseReport, + isOffline: false, + }); + await waitForBatchedUpdates(); + + const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); + expect(split1?.reportID).toBe(expenseReport?.reportID); + const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}234`); + expect(split2?.reportID).toBe(expenseReport?.reportID); + }); + + it('should use splitExpensesTotal in calculation when editing splits', async () => { + // The fix ensures we rely on splitExpensesTotal rather than potentially incorrect backend reportTotal + // This prevents scenarios where backend sends wrong total (e.g., -$2 instead of -$10) + // from causing incorrect report totals (e.g., $24 instead of correct -$10) + + const amount = -10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID; + + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + activePolicy: undefined, + }); + + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, {}); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test Merchant', + comment: 'Test expense', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + }, + }); + + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + + // Set up split expenses with explicit splitExpensesTotal + // Using negative amounts to get positive transaction amounts (expense reports store as negative) + const splitExpensesTotal = -8000; // -$80 total for splits + const draftTransaction: Transaction = { + reportID: originalTransaction?.reportID ?? '456', + transactionID: originalTransaction?.transactionID ?? '234', + amount, + created: originalTransaction?.created ?? DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + merchant: originalTransaction?.merchant ?? '', + comment: { + originalTransactionID, + comment: originalTransaction?.comment?.comment ?? '', + splitExpenses: [ + { + transactionID: '235', + amount: -5000, + description: 'Split 1', + created: DateUtils.getDBTime(), + }, + { + transactionID: '236', + amount: -3000, + description: 'Split 2', + created: DateUtils.getDBTime(), + }, + ], + splitExpensesTotal, + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + }; + + 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); + const policyTags = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${reportID}`)) ?? {}; + const reports = getTransactionAndExpenseReports(reportID); + + // it should use splitExpensesTotal in its calculation + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID, + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + 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, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports.transactionReport, + expenseReport: reports.expenseReport, + isOffline: false, + }); + await waitForBatchedUpdates(); + + const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); + const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}236`); + + expect(split1).toBeDefined(); + expect(split2).toBeDefined(); + }); + + it('should create hold report actions for split transactions when original transaction is on hold', async () => { + // Given an expense that is on hold + const amount = 10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID: string | undefined; + let transactionThreadReportID: string | undefined; + + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace for Hold Test", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + activePolicy: undefined, + }); + + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + // Change the approval mode for the policy since default is Submit and Close + setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, {}); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + + // Create the initial expense + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test Merchant', + comment: 'Original expense', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + }); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + + // Get the original transaction ID and transaction thread report ID + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const iouAction = iouActions?.at(0); + const originalMessage = isMoneyRequestAction(iouAction) ? getOriginalMessage(iouAction) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + transactionThreadReportID = iouAction?.childReportID; + }, + }); + + // Put the expense on hold + if (originalTransactionID && transactionThreadReportID) { + putOnHold(originalTransactionID, 'Test hold reason', transactionThreadReportID, false, RORY_EMAIL, RORY_ACCOUNT_ID); + } + await waitForBatchedUpdates(); + + // Verify the transaction is on hold + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + expect(originalTransaction?.comment?.hold).toBeDefined(); + + // Get the first IOU action for the split flow + let firstIOU: ReportAction | undefined; + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + firstIOU = iouActions?.at(0); + }, + }); + + // Create the draft transaction with split expenses + const draftTransaction: Transaction = { + reportID: originalTransaction?.reportID ?? '456', + transactionID: originalTransaction?.transactionID ?? '234', + amount, + created: originalTransaction?.created ?? DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + merchant: originalTransaction?.merchant ?? '', + comment: { + originalTransactionID, + comment: originalTransaction?.comment?.comment ?? '', + hold: originalTransaction?.comment?.hold, + splitExpenses: [ + { + transactionID: 'split-held-tx-1', + amount: amount / 2, + description: 'Split 1', + created: DateUtils.getDBTime(), + }, + { + transactionID: 'split-held-tx-2', + amount: amount / 2, + description: 'Split 2', + created: DateUtils.getDBTime(), + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + }; + + 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 reportID = draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID); + const policyTags = (await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${reportID}`)) ?? {}; + const reports = getTransactionAndExpenseReports(reportID); + + // When splitting the held expense + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID, + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + searchContext: { + currentSearchHash: -2, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + betas: [CONST.BETAS.ALL], + policyTags, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports.transactionReport, + expenseReport: reports.expenseReport, + isOffline: false, + }); + + await waitForBatchedUpdates(); + + // Then verify the split transactions were created + const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}split-held-tx-1`); + const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}split-held-tx-2`); + + expect(split1).toBeDefined(); + expect(split2).toBeDefined(); + + // Find the transaction thread reports for each split by looking at the IOU actions + let split1ThreadReportID: string | undefined; + let split2ThreadReportID: string | undefined; + + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + for (const action of iouActions) { + const message = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; + if (message?.IOUTransactionID === 'split-held-tx-1') { + split1ThreadReportID = action.childReportID; + } else if (message?.IOUTransactionID === 'split-held-tx-2') { + split2ThreadReportID = action.childReportID; + } + } + }, + }); + + // Verify that split transaction thread IDs exist + expect(split1ThreadReportID).toBeDefined(); + expect(split2ThreadReportID).toBeDefined(); + + // Verify each split transaction thread has hold report actions + // When splitting a held expense, new hold report actions should be created for each split + if (split1ThreadReportID) { + const split1ReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${split1ThreadReportID}`); + const split1HoldActions = Object.values(split1ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD); + const split1CommentActions = Object.values(split1ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + + // Should have at least one HOLD action and one ADD_COMMENT action (the hold comment) + // The hold actions are created optimistically with pendingAction: ADD, but this + // may be cleared to null after the API call succeeds + expect(split1HoldActions.length).toBeGreaterThanOrEqual(1); + expect(split1CommentActions.length).toBeGreaterThanOrEqual(1); + } + + if (split2ThreadReportID) { + const split2ReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${split2ThreadReportID}`); + const split2HoldActions = Object.values(split2ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD); + const split2CommentActions = Object.values(split2ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + + // Should have at least one HOLD action and one ADD_COMMENT action (the hold comment) + expect(split2HoldActions.length).toBeGreaterThanOrEqual(1); + expect(split2CommentActions.length).toBeGreaterThanOrEqual(1); + } + }); + }); + }); +}); diff --git a/tests/unit/libs/OptionsListUtilsTest.ts b/tests/unit/libs/OptionsListUtilsTest.ts new file mode 100644 index 000000000000..04091d078aca --- /dev/null +++ b/tests/unit/libs/OptionsListUtilsTest.ts @@ -0,0 +1,178 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import '@libs/actions/IOU/MoneyRequest'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {getManagerMcTestParticipant} from '@libs/OptionsListUtils'; +import type * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; +import type {MockFetch} from '../../utils/TestHelper'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +import currencyList from '../currencyList.json'; + +const topMostReportID = '23423423'; +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissToSuperWideRHP: jest.fn(), + navigateBackToLastSuperWideRHPScreen: jest.fn(), + dismissModalWithReport: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => topMostReportID), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +// In production, requestMoney defers its API.write() call until the target screen's +// content lays out (or a safety timeout fires). In tests there is no target component +// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +jest.mock('@libs/deferredLayoutWrite', () => ({ + registerDeferredWrite: (_key: string, callback: () => void) => callback(), + flushDeferredWrite: jest.fn(), + cancelDeferredWrite: jest.fn(), + hasDeferredWrite: () => false, + getOptimisticWatchKey: () => undefined, + deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), + reserveDeferredWriteChannel: jest.fn(), + resetForTesting: jest.fn(), +})); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +const unapprovedCashHash = 71801560; +const unapprovedCashSimilarSearchHash = 1832274510; +jest.mock('@src/libs/SearchQueryUtils', () => { + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: unapprovedCashSimilarSearchHash, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +jest.mock('@libs/PolicyUtils', () => ({ + ...jest.requireActual('@libs/PolicyUtils'), + isPaidGroupPolicy: jest.fn().mockReturnValue(true), + isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), +})); + +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; + +OnyxUpdateManager(); +describe('libs/OptionsListUtils', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + [ONYXKEYS.CURRENCY_LIST]: currencyList, + }, + }); + initOnyxDerivedValues(); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllTimers(); + global.fetch = getGlobalFetchMock(); + mockFetch = fetch as MockFetch; + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getManagerMcTestParticipant', () => { + it('should return manager mctest participant when personalDetails contains manager_mctest', () => { + // Given personalDetails that include manager_mctest + const managerMcTestAccountID = CONST.ACCOUNT_ID.MANAGER_MCTEST; + const personalDetailsList: PersonalDetailsList = { + [managerMcTestAccountID]: { + accountID: managerMcTestAccountID, + login: CONST.EMAIL.MANAGER_MCTEST, + displayName: 'Manager McTest', + }, + }; + + // When calling getManagerMcTestParticipant with personalDetails + const result = getManagerMcTestParticipant(RORY_ACCOUNT_ID, personalDetailsList); + + // Then it should return a participant with the manager mctest account ID + expect(result).toBeDefined(); + expect(result?.accountID).toBe(managerMcTestAccountID); + }); + + it('should return undefined when personalDetails does not contain manager_mctest', () => { + // Given personalDetails without manager_mctest + const personalDetailsList: PersonalDetailsList = { + [RORY_ACCOUNT_ID]: { + accountID: RORY_ACCOUNT_ID, + login: RORY_EMAIL, + displayName: 'Rory', + }, + }; + + // When calling getManagerMcTestParticipant with personalDetails + const result = getManagerMcTestParticipant(RORY_ACCOUNT_ID, personalDetailsList); + + // Then it should return undefined since manager_mctest is not in the provided personalDetails + expect(result).toBeUndefined(); + }); + + it('should return undefined when personalDetails is empty', () => { + // Given empty personalDetails + const personalDetailsList: PersonalDetailsList = {}; + + // When calling getManagerMcTestParticipant with empty personalDetails + const result = getManagerMcTestParticipant(RORY_ACCOUNT_ID, personalDetailsList); + + // Then it should return undefined + expect(result).toBeUndefined(); + }); + }); +}); From b9c26384a435d90311f95a00df10a85006bee958 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Mon, 25 May 2026 13:11:14 +0700 Subject: [PATCH 3/5] fix(tests): remove orphaned mockFetch assignment in moved test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trim script that ran while splitting IOUTest.ts removed the `let mockFetch: MockFetch;` declaration in 8 of the 9 new files (because `mockFetch` was never read inside those describes) but left the assignment `mockFetch = fetch as MockFetch;` in the shared `beforeEach`. That made `mockFetch` an implicit global — `tsgo` flagged it with TS2304 "Cannot find name 'mockFetch'". Tests still passed only because Babel CommonJS doesn't enforce strict mode at the module top level. Fix: drop both the orphan assignment and the now-unused `MockFetch` type import from the 8 affected files. RequestMoneyTest.ts is unchanged — it has the declaration and three real usages. All 9 moved test files still pass (98/98 tests); typecheck-tsgo no longer reports any mockFetch errors. Reported by https://github.com/Expensify/App/pull/91203#discussion_r3296090632 --- tests/actions/IOU/CreateDraftTransactionTest.ts | 2 -- tests/actions/IOU/MoneyRequestBuilderTest.ts | 2 -- tests/actions/IOU/MoneyRequestSettersTest.ts | 2 -- tests/actions/IOU/SearchUpdateTest.ts | 2 -- tests/actions/IOU/SplitReportTotalsTest.ts | 2 -- tests/actions/OdometerTransactionUtilsTest.ts | 2 -- tests/actions/TransactionTest.ts | 2 -- tests/unit/libs/OptionsListUtilsTest.ts | 2 -- 8 files changed, 16 deletions(-) diff --git a/tests/actions/IOU/CreateDraftTransactionTest.ts b/tests/actions/IOU/CreateDraftTransactionTest.ts index 69b4d8807b45..4b0446cf0f89 100644 --- a/tests/actions/IOU/CreateDraftTransactionTest.ts +++ b/tests/actions/IOU/CreateDraftTransactionTest.ts @@ -14,7 +14,6 @@ import type Transaction from '@src/types/onyx/Transaction'; import currencyList from '../../unit/currencyList.json'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; -import type {MockFetch} from '../../utils/TestHelper'; import {getGlobalFetchMock, getOnyxData} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -122,7 +121,6 @@ describe('actions/IOU', () => { beforeEach(() => { jest.clearAllTimers(); global.fetch = getGlobalFetchMock(); - mockFetch = fetch as MockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); diff --git a/tests/actions/IOU/MoneyRequestBuilderTest.ts b/tests/actions/IOU/MoneyRequestBuilderTest.ts index 62d5350b8a20..600f5f984257 100644 --- a/tests/actions/IOU/MoneyRequestBuilderTest.ts +++ b/tests/actions/IOU/MoneyRequestBuilderTest.ts @@ -14,7 +14,6 @@ import type Transaction from '@src/types/onyx/Transaction'; import currencyList from '../../unit/currencyList.json'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; -import type {MockFetch} from '../../utils/TestHelper'; import {getGlobalFetchMock} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -122,7 +121,6 @@ describe('actions/IOU', () => { beforeEach(() => { jest.clearAllTimers(); global.fetch = getGlobalFetchMock(); - mockFetch = fetch as MockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); diff --git a/tests/actions/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts index a2a052502d7a..3307ed4f5c5b 100644 --- a/tests/actions/IOU/MoneyRequestSettersTest.ts +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -32,7 +32,6 @@ import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/col import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import getOnyxValue from '../../utils/getOnyxValue'; -import type {MockFetch} from '../../utils/TestHelper'; import {getGlobalFetchMock} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -149,7 +148,6 @@ describe('actions/IOU', () => { beforeEach(() => { jest.clearAllTimers(); global.fetch = getGlobalFetchMock(); - mockFetch = fetch as MockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); diff --git a/tests/actions/IOU/SearchUpdateTest.ts b/tests/actions/IOU/SearchUpdateTest.ts index 279e10a5711e..99a1216878e9 100644 --- a/tests/actions/IOU/SearchUpdateTest.ts +++ b/tests/actions/IOU/SearchUpdateTest.ts @@ -14,7 +14,6 @@ import type {Policy, Report} from '@src/types/onyx'; import currencyList from '../../unit/currencyList.json'; import {createRandomReport} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; -import type {MockFetch} from '../../utils/TestHelper'; import {getGlobalFetchMock} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -122,7 +121,6 @@ describe('actions/IOU', () => { beforeEach(() => { jest.clearAllTimers(); global.fetch = getGlobalFetchMock(); - mockFetch = fetch as MockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); diff --git a/tests/actions/IOU/SplitReportTotalsTest.ts b/tests/actions/IOU/SplitReportTotalsTest.ts index a2e591998cb0..ca5f13300780 100644 --- a/tests/actions/IOU/SplitReportTotalsTest.ts +++ b/tests/actions/IOU/SplitReportTotalsTest.ts @@ -17,7 +17,6 @@ import type {Participant as IOUParticipant, SplitExpense} from '@src/types/onyx/ import type {Participant} from '@src/types/onyx/Report'; import type {SplitShares} from '@src/types/onyx/Transaction'; import currencyList from '../../unit/currencyList.json'; -import type {MockFetch} from '../../utils/TestHelper'; import {getGlobalFetchMock} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -133,7 +132,6 @@ describe('actions/IOU', () => { beforeEach(() => { jest.clearAllTimers(); global.fetch = getGlobalFetchMock(); - mockFetch = fetch as MockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index 644dd450ce74..6bd75c60e426 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -14,7 +14,6 @@ import type Transaction from '@src/types/onyx/Transaction'; import currencyList from '../unit/currencyList.json'; import createRandomTransaction from '../utils/collections/transaction'; import getOnyxValue from '../utils/getOnyxValue'; -import type {MockFetch} from '../utils/TestHelper'; import {getGlobalFetchMock} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -122,7 +121,6 @@ describe('actions/OdometerTransactionUtils', () => { beforeEach(() => { jest.clearAllTimers(); global.fetch = getGlobalFetchMock(); - mockFetch = fetch as MockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index 4861f802a17b..9b2a80508282 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -29,7 +29,6 @@ import createPersonalDetails from '../utils/collections/personalDetails'; import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; import getOnyxValue from '../utils/getOnyxValue'; -import type {MockFetch} from '../utils/TestHelper'; import {getGlobalFetchMock, getOnyxData} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -154,7 +153,6 @@ describe('actions/Transaction', () => { beforeEach(() => { jest.clearAllTimers(); global.fetch = getGlobalFetchMock(); - mockFetch = fetch as MockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); diff --git a/tests/unit/libs/OptionsListUtilsTest.ts b/tests/unit/libs/OptionsListUtilsTest.ts index 04091d078aca..bff50691cc0d 100644 --- a/tests/unit/libs/OptionsListUtilsTest.ts +++ b/tests/unit/libs/OptionsListUtilsTest.ts @@ -10,7 +10,6 @@ import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Policy} from '@src/types/onyx'; -import type {MockFetch} from '../../utils/TestHelper'; import {getGlobalFetchMock} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; import currencyList from '../currencyList.json'; @@ -119,7 +118,6 @@ describe('libs/OptionsListUtils', () => { beforeEach(() => { jest.clearAllTimers(); global.fetch = getGlobalFetchMock(); - mockFetch = fetch as MockFetch; return Onyx.clear().then(waitForBatchedUpdates); }); From b4a1e0543c9ac63da375c4e403dce3cf2b912fcf Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Mon, 25 May 2026 14:56:34 +0700 Subject: [PATCH 4/5] fix(tests): use currentUserAccountIDParam/EmailParam for requestMoney calls requestMoney expects flat currentUserAccountIDParam and currentUserEmailParam fields per the RequestMoneyInformation type, not the nested currentUser shape used by trackExpense. Reverts 22 requestMoney call sites that had been mis-converted during the IOUTest split. Co-Authored-By: Claude Opus 4.7 --- tests/actions/IOU/RequestMoneyTest.ts | 57 ++++++++++++++++++--------- tests/actions/TransactionTest.ts | 12 ++++-- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/tests/actions/IOU/RequestMoneyTest.ts b/tests/actions/IOU/RequestMoneyTest.ts index 2b63f21f08c1..6041c90d8dbf 100644 --- a/tests/actions/IOU/RequestMoneyTest.ts +++ b/tests/actions/IOU/RequestMoneyTest.ts @@ -199,7 +199,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, draftTransactionIDs: [], @@ -463,7 +464,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, draftTransactionIDs: [], @@ -695,7 +697,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, draftTransactionIDs: [], @@ -863,7 +866,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, draftTransactionIDs: [], @@ -1382,7 +1386,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, draftTransactionIDs: [], @@ -1413,7 +1418,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, draftTransactionIDs: [], @@ -1444,7 +1450,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, draftTransactionIDs: [], @@ -1538,7 +1545,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, draftTransactionIDs: [], @@ -1675,7 +1683,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, @@ -1747,7 +1756,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, @@ -1790,7 +1800,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, @@ -1946,7 +1957,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, @@ -2021,7 +2033,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, @@ -2107,7 +2120,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, @@ -2184,7 +2198,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, @@ -2256,7 +2271,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, @@ -2329,7 +2345,8 @@ describe('actions/IOU', () => { shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, transactionViolations: {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, @@ -2419,7 +2436,8 @@ describe('actions/IOU', () => { }, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, @@ -2491,7 +2509,8 @@ describe('actions/IOU', () => { }, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, transactionViolations: {}, policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index 9b2a80508282..75a777a47f02 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -375,7 +375,8 @@ describe('actions/Transaction', () => { }, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, @@ -553,7 +554,8 @@ describe('actions/Transaction', () => { }, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, @@ -736,7 +738,8 @@ describe('actions/Transaction', () => { }, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, - currentUser: {accountID: 123, email: 'existing@example.com'}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, @@ -929,7 +932,8 @@ describe('actions/Transaction', () => { }, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, transactionViolations: {}, policyRecentlyUsedCurrencies: [], existingTransactionDraft: undefined, From 596873bcd1f9d4faa0d4914234a43dbf65f8f9d7 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 26 May 2026 16:59:22 +0700 Subject: [PATCH 5/5] merge main --- tests/unit/libs/OptionsListUtilsTest.ts | 176 ------------------------ 1 file changed, 176 deletions(-) delete mode 100644 tests/unit/libs/OptionsListUtilsTest.ts diff --git a/tests/unit/libs/OptionsListUtilsTest.ts b/tests/unit/libs/OptionsListUtilsTest.ts deleted file mode 100644 index bff50691cc0d..000000000000 --- a/tests/unit/libs/OptionsListUtilsTest.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import Onyx from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; -import '@libs/actions/IOU/MoneyRequest'; -import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; -import {getManagerMcTestParticipant} from '@libs/OptionsListUtils'; -import type * as PolicyUtils from '@libs/PolicyUtils'; -import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; -import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Policy} from '@src/types/onyx'; -import {getGlobalFetchMock} from '../../utils/TestHelper'; -import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; -import currencyList from '../currencyList.json'; - -const topMostReportID = '23423423'; -jest.mock('@src/libs/Navigation/Navigation', () => ({ - navigate: jest.fn(), - dismissModal: jest.fn(), - dismissToPreviousRHP: jest.fn(), - dismissToSuperWideRHP: jest.fn(), - navigateBackToLastSuperWideRHPScreen: jest.fn(), - dismissModalWithReport: jest.fn(), - goBack: jest.fn(), - getTopmostReportId: jest.fn(() => topMostReportID), - setNavigationActionToMicrotaskQueue: jest.fn(), - removeScreenByKey: jest.fn(), - isNavigationReady: jest.fn(() => Promise.resolve()), - getReportRouteByID: jest.fn(), - getActiveRouteWithoutParams: jest.fn(), - getActiveRoute: jest.fn(), - getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), - clearFullscreenPreInsertedFlag: jest.fn(), - revealRouteBeforeDismissingModal: jest.fn(), - navigationRef: { - getRootState: jest.fn(), - isReady: jest.fn(() => true), - }, -})); - -jest.mock('@react-navigation/native'); - -jest.mock('@src/libs/actions/Report', () => { - const originalModule = jest.requireActual('@src/libs/actions/Report'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...originalModule, - notifyNewAction: jest.fn(), - }; -}); -jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); -jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); -// In production, requestMoney defers its API.write() call until the target screen's -// content lays out (or a safety timeout fires). In tests there is no target component -// to flush the deferred write, so we bypass the deferral by executing the callback immediately. -jest.mock('@libs/deferredLayoutWrite', () => ({ - registerDeferredWrite: (_key: string, callback: () => void) => callback(), - flushDeferredWrite: jest.fn(), - cancelDeferredWrite: jest.fn(), - hasDeferredWrite: () => false, - getOptimisticWatchKey: () => undefined, - deferOrExecuteWrite: (apiWrite: () => void) => apiWrite(), - reserveDeferredWriteChannel: jest.fn(), - resetForTesting: jest.fn(), -})); -jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); - -const unapprovedCashHash = 71801560; -const unapprovedCashSimilarSearchHash = 1832274510; -jest.mock('@src/libs/SearchQueryUtils', () => { - const actual = jest.requireActual('@src/libs/SearchQueryUtils'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actual, - getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ - hash: unapprovedCashHash, - query: 'test', - type: 'expense', - status: ['drafts', 'outstanding'], - filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, - flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], - inputQuery: '', - recentSearchHash: 89, - similarSearchHash: unapprovedCashSimilarSearchHash, - sortBy: 'tag', - sortOrder: 'asc', - })), - buildCannedSearchQuery: jest.fn(), - }; -}); - -jest.mock('@libs/PolicyUtils', () => ({ - ...jest.requireActual('@libs/PolicyUtils'), - isPaidGroupPolicy: jest.fn().mockReturnValue(true), - isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), -})); - -const RORY_EMAIL = 'rory@expensifail.com'; -const RORY_ACCOUNT_ID = 3; - -OnyxUpdateManager(); -describe('libs/OptionsListUtils', () => { - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, - [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, - [ONYXKEYS.CURRENCY_LIST]: currencyList, - }, - }); - initOnyxDerivedValues(); - IntlStore.load(CONST.LOCALES.EN); - return waitForBatchedUpdates(); - }); - - beforeEach(() => { - jest.clearAllTimers(); - global.fetch = getGlobalFetchMock(); - return Onyx.clear().then(waitForBatchedUpdates); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('getManagerMcTestParticipant', () => { - it('should return manager mctest participant when personalDetails contains manager_mctest', () => { - // Given personalDetails that include manager_mctest - const managerMcTestAccountID = CONST.ACCOUNT_ID.MANAGER_MCTEST; - const personalDetailsList: PersonalDetailsList = { - [managerMcTestAccountID]: { - accountID: managerMcTestAccountID, - login: CONST.EMAIL.MANAGER_MCTEST, - displayName: 'Manager McTest', - }, - }; - - // When calling getManagerMcTestParticipant with personalDetails - const result = getManagerMcTestParticipant(RORY_ACCOUNT_ID, personalDetailsList); - - // Then it should return a participant with the manager mctest account ID - expect(result).toBeDefined(); - expect(result?.accountID).toBe(managerMcTestAccountID); - }); - - it('should return undefined when personalDetails does not contain manager_mctest', () => { - // Given personalDetails without manager_mctest - const personalDetailsList: PersonalDetailsList = { - [RORY_ACCOUNT_ID]: { - accountID: RORY_ACCOUNT_ID, - login: RORY_EMAIL, - displayName: 'Rory', - }, - }; - - // When calling getManagerMcTestParticipant with personalDetails - const result = getManagerMcTestParticipant(RORY_ACCOUNT_ID, personalDetailsList); - - // Then it should return undefined since manager_mctest is not in the provided personalDetails - expect(result).toBeUndefined(); - }); - - it('should return undefined when personalDetails is empty', () => { - // Given empty personalDetails - const personalDetailsList: PersonalDetailsList = {}; - - // When calling getManagerMcTestParticipant with empty personalDetails - const result = getManagerMcTestParticipant(RORY_ACCOUNT_ID, personalDetailsList); - - // Then it should return undefined - expect(result).toBeUndefined(); - }); - }); -});