diff --git a/src/components/AddUnreportedExpenseFooter.tsx b/src/components/AddUnreportedExpenseFooter.tsx index f7c1d0c4d6adb..d1ce2395d0fed 100644 --- a/src/components/AddUnreportedExpenseFooter.tsx +++ b/src/components/AddUnreportedExpenseFooter.tsx @@ -7,7 +7,7 @@ import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {isIOUReport} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; -import {convertBulkTrackedExpensesToIOU} from '@userActions/IOU'; +import {convertBulkTrackedExpensesToIOU} from '@userActions/IOU/TrackExpense'; import {changeTransactionsReport} from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index e86b5aa05bd56..4160c8519e37e 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -23,8 +23,9 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionViolations from '@hooks/useTransactionViolations'; -import {deleteTrackExpense, markRejectViolationAsResolved} from '@libs/actions/IOU'; +import {markRejectViolationAsResolved} from '@libs/actions/IOU'; import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; +import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {setNameValuePair} from '@libs/actions/User'; diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 26262802c5688..49d4d6cb0fc85 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -24,7 +24,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; -import type {CreateDistanceRequestInformation, CreateTrackExpenseParams, RequestMoneyInformation} from '.'; +import type {CreateDistanceRequestInformation, RequestMoneyInformation} from '.'; import { createDistanceRequest, getAllReportActionsFromIOU, @@ -36,10 +36,11 @@ import { getMoneyRequestParticipantsFromReport, getUserAccountID, requestMoney, - trackExpense, } from '.'; import type {PerDiemExpenseInformation} from './PerDiem'; import {submitPerDiemExpense} from './PerDiem'; +import type {CreateTrackExpenseParams} from './TrackExpense'; +import {trackExpense} from './TrackExpense'; function getIOUActionForTransactions(transactionIDList: Array, iouReportID: string | undefined): Array> { const allReportActions = getAllReportActionsFromIOU(); diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index c13d2c4dd1953..96e02170303b1 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -52,9 +52,9 @@ import { setMoneyRequestParticipantsFromReport, setMoneyRequestPendingFields, setMultipleMoneyRequestParticipantsFromReport, - trackExpense, } from './index'; import {resetSplitShares, startSplitBill} from './Split'; +import {trackExpense} from './TrackExpense'; type CreateTransactionParams = { transactions: Transaction[]; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 18bb428c98b2b..1c29ea1f5d1ba 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -1070,4 +1070,4 @@ export { submitPerDiemExpenseForSelfDM, }; -export type {PerDiemExpenseTransactionParams, PerDiemExpenseInformation}; +export type {PerDiemExpenseTransactionParams, PerDiemExpenseInformation, BaseTransactionParams}; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 3211a00422e8b..7c3cd4cfde1b0 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -79,7 +79,6 @@ import { getAllPersonalDetails, getAllReports, getAllTransactions, - getDeleteTrackExpenseInformation, getMoneyRequestInformation, getMoneyRequestParticipantsFromReport, getOrCreateOptimisticSplitChatReport, @@ -91,6 +90,7 @@ import { mergePolicyRecentlyUsedCurrencies, } from './index'; import type {BuildOnyxDataForMoneyRequestKeys, MoneyRequestInformationParams, OneOnOneIOUReport, StartSplitBilActionParams, UpdateMoneyRequestDataKeys} from './index'; +import {getDeleteTrackExpenseInformation} from './TrackExpense'; type IOURequestType = ValueOf; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts new file mode 100644 index 0000000000000..e122b4cac9afb --- /dev/null +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -0,0 +1,2620 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-redundant-type-constituents, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/no-unsafe-return */ +import {fastMerge} from 'expensify-common'; +import {InteractionManager} from 'react-native'; +import type {OnyxCollection, OnyxEntry, OnyxInputValue, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import ReceiptGeneric from '@assets/images/receipt-generic.png'; +import * as API from '@libs/API'; +import type { + CategorizeTrackedExpenseParams as CategorizeTrackedExpenseApiParams, + CreateWorkspaceParams, + DeleteMoneyRequestParams, + ShareTrackedExpenseParams, + TrackExpenseParams, +} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import DateUtils from '@libs/DateUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import GoogleTagManager from '@libs/GoogleTagManager'; +import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils} from '@libs/IOUUtils'; +import isFileUploadable from '@libs/isFileUploadable'; +import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; +import Log from '@libs/Log'; +import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; +import * as NumberUtils from '@libs/NumberUtils'; +import Parser from '@libs/Parser'; +import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; +import {getMemberAccountIDsForWorkspace, hasDependentTags, isControlPolicy, isPaidGroupPolicy} from '@libs/PolicyUtils'; +import { + getAllReportActions, + getLastVisibleAction, + getLastVisibleMessage, + getOriginalMessage, + getReportAction, + getReportActionHtml, + getReportActionText, + getTrackExpenseActionableWhisper, + isActionableTrackExpense, + isMoneyRequestAction, +} from '@libs/ReportActionsUtils'; +import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; +import { + buildOptimisticActionableTrackExpenseWhisper, + buildOptimisticCreatedReportAction, + buildOptimisticExpenseReport, + buildOptimisticMoneyRequestEntities, + buildOptimisticMovedTransactionAction, + buildOptimisticReportPreview, + buildOptimisticSelfDMReport, + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + findSelfDMReportID, + generateReportID, + getParsedComment, + getReportOrDraftReport, + getReportRecipientAccountIDs, + isDraftReport, + isMoneyRequestReport as isMoneyRequestReportReportUtils, + isSelfDM, + shouldCreateNewMoneyRequestReport as shouldCreateNewMoneyRequestReportReportUtils, + updateReportPreview, +} from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; +import { + buildOptimisticTransaction, + getAmount, + getCurrency, + getMerchant, + getRateID, + getWaypoints, + isCustomUnitRateIDForP2P, + isDistanceRequest as isDistanceRequestTransactionUtils, + isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils, + isManualDistanceRequest as isManualDistanceRequestTransactionUtils, + isMapDistanceRequest, + isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, + isScanRequest as isScanRequestTransactionUtils, +} from '@libs/TransactionUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import {clearByKey as clearPdfByOnyxKey} from '@userActions/CachedPDFPaths'; +import {buildAddMembersToWorkspaceOnyxData, buildUpdateWorkspaceMembersRoleOnyxData} from '@userActions/Policy/Member'; +import {buildPolicyData} from '@userActions/Policy/Policy'; +import type {BuildPolicyDataKeys} from '@userActions/Policy/Policy'; +import {buildInviteToRoomOnyxData, notifyNewAction} from '@userActions/Report'; +import {sanitizeRecentWaypoints} from '@userActions/Transaction'; +import {removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; +import type {IOUAction} from '@src/CONST'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Accountant, Attendee, Participant} from '@src/types/onyx/IOU'; +import type {QuickActionName} from '@src/types/onyx/QuickAction'; +import type {OnyxData} from '@src/types/onyx/Request'; +import type {Receipt, ReceiptSource, WaypointCollection} from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +// eslint-disable-next-line import/no-cycle +import type { + BasePolicyParams, + BuildOnyxDataForMoneyRequestKeys, + GpsPoint as GPSPoint, + ReplaceReceipt, + RequestMoneyInformation, + RequestMoneyParticipantParams, + StartSplitBilActionParams, +} from './index'; +// eslint-disable-next-line import/no-cycle +import { + buildMinimalTransactionForFormula, + deleteMoneyRequest, + getAllReports, + getAllTransactions, + getAllTransactionViolations, + getCleanUpTransactionThreadReportOnyxData, + getMoneyRequestInformation, + getNavigationUrlOnMoneyRequestDelete, + getReceiptError, + getReportPreviewAction, + getSearchOnyxUpdate, + handleNavigateAfterExpenseCreate, +} from './index'; +import type {BaseTransactionParams} from './PerDiem'; + +let allTransactionDrafts: NonNullable> = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (value) => { + allTransactionDrafts = value ?? {}; + }, +}); + +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, +}); + +let allTransactions: NonNullable> = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + allTransactions = {}; + return; + } + + allTransactions = value; + }, +}); + +let allReportActions: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + if (!actions) { + return; + } + allReportActions = actions; + }, +}); + +let userAccountID = -1; +let currentUserEmail = ''; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + currentUserEmail = value?.email ?? ''; + userAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID; + }, +}); + +type TrackExpenseInformation = { + createdWorkspaceParams?: CreateWorkspaceParams; + iouReport?: OnyxTypes.Report; + chatReport: OnyxTypes.Report; + transaction: OnyxTypes.Transaction; + iouAction: OptimisticIOUReportAction; + createdChatReportActionID?: string; + createdIOUReportActionID?: string; + reportPreviewAction?: OnyxTypes.ReportAction; + transactionThreadReportID: string; + createdReportActionIDForThread: string | undefined; + actionableWhisperReportActionIDParam?: string; + optimisticReportID: string | undefined; + optimisticReportActionID: string | undefined; + onyxData: OnyxData; +}; + +type TrackedExpenseTransactionParams = Omit & { + waypoints?: string; + distance?: number; + transactionID: string | undefined; + receipt?: Receipt; + taxCode: string; + taxAmount: number; + attendees?: Attendee[]; +}; + +type TrackedExpensePolicyParams = { + policy: OnyxEntry; + policyID: string | undefined; + isDraftPolicy?: boolean; +}; + +type TrackedExpenseReportInformation = { + moneyRequestPreviewReportActionID: string | undefined; + moneyRequestReportID: string | undefined; + moneyRequestCreatedReportActionID: string | undefined; + actionableWhisperReportActionID: string | undefined; + linkedTrackedExpenseReportAction: OnyxTypes.ReportAction; + linkedTrackedExpenseReportID: string; + transactionThreadReportID: string | undefined; + reportPreviewReportActionID: string | undefined; + chatReportID: string | undefined; + isLinkedTrackedExpenseReportArchived: boolean | undefined; +}; + +type TrackedExpenseParams = { + onyxData?: OnyxData< + | BuildOnyxDataForTrackExpenseKeys + | BuildPolicyDataKeys + | typeof ONYXKEYS.NVP_RECENT_WAYPOINTS + | typeof ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE + | typeof ONYXKEYS.GPS_DRAFT_DETAILS + | typeof ONYXKEYS.SELF_DM_REPORT_ID + >; + reportInformation: TrackedExpenseReportInformation; + transactionParams: TrackedExpenseTransactionParams; + policyParams: TrackedExpensePolicyParams; + createdWorkspaceParams?: CreateWorkspaceParams; + accountantParams?: TrackExpenseAccountantParams; +}; + +type TrackExpenseTransactionParams = { + amount: number; + currency: string; + created: string | undefined; + merchant?: string; + comment?: string; + distance?: number; + receipt?: Receipt; + category?: string; + tag?: string; + taxCode?: string; + taxAmount?: number; + billable?: boolean; + reimbursable?: boolean; + validWaypoints?: WaypointCollection; + gpsPoint?: GPSPoint; + actionableWhisperReportActionID?: string; + linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction; + linkedTrackedExpenseReportID?: string; + customUnitRateID?: string; + attendees?: Attendee[]; + isLinkedTrackedExpenseReportArchived?: boolean; + odometerStart?: number; + odometerEnd?: number; + isFromGlobalCreate?: boolean; + gpsCoordinates?: string; +}; + +type TrackExpenseAccountantParams = { + accountant?: Accountant; +}; + +type CreateTrackExpenseParams = { + report: OnyxEntry; + isDraftPolicy: boolean; + action?: IOUAction; + participantParams: RequestMoneyParticipantParams; + policyParams?: BasePolicyParams; + transactionParams: TrackExpenseTransactionParams; + existingTransaction?: OnyxEntry; + accountantParams?: TrackExpenseAccountantParams; + isRetry?: boolean; + shouldPlaySound?: boolean; + shouldHandleNavigation?: boolean; + isASAPSubmitBetaEnabled: boolean; + currentUserAccountIDParam: number; + currentUserEmailParam: string; + introSelected: OnyxEntry; + activePolicyID: string | undefined; + quickAction: OnyxEntry; + recentWaypoints: OnyxEntry; + betas: OnyxEntry; + draftTransactionIDs: string[] | undefined; + isSelfTourViewed: boolean; +}; + +type GetTrackExpenseInformationTransactionParams = { + comment: string; + amount: number; + currency: string; + created: string; + merchant: string; + receipt: OnyxEntry; + category?: string; + tag?: string; + taxCode?: string; + taxAmount?: number; + billable?: boolean; + reimbursable?: boolean; + linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction; + attendees?: Attendee[]; + distance?: number; + odometerStart?: number; + odometerEnd?: number; + gpsCoordinates?: string; +}; + +type GetTrackExpenseInformationParticipantParams = { + payeeEmail?: string; + payeeAccountID?: number; + participant: Participant; +}; + +type GetTrackExpenseInformationParams = { + parentChatReport: OnyxEntry; + moneyRequestReportID?: string; + existingTransaction?: OnyxEntry; + existingTransactionID?: string; + participantParams: GetTrackExpenseInformationParticipantParams; + policyParams: BasePolicyParams; + transactionParams: GetTrackExpenseInformationTransactionParams; + retryParams?: StartSplitBilActionParams | CreateTrackExpenseParams | RequestMoneyInformation | ReplaceReceipt; + isASAPSubmitBetaEnabled: boolean; + currentUserAccountIDParam: number; + currentUserEmailParam: string; + introSelected: OnyxEntry; + activePolicyID: string | undefined; + quickAction: OnyxEntry; + betas: OnyxEntry; + isSelfTourViewed: boolean; +}; + +type DeleteTrackExpenseParams = { + chatReportID: string | undefined; + chatReport: OnyxEntry | undefined; + transactionID: string | undefined; + reportAction: OnyxTypes.ReportAction; + iouReport: OnyxEntry; + chatIOUReport: OnyxEntry; + transactions: OnyxCollection; + violations: OnyxCollection; + isSingleTransactionView: boolean | undefined; + isChatReportArchived: boolean | undefined; + isChatIOUReportArchived: boolean | undefined; + allTransactionViolationsParam: OnyxCollection; + currentUserAccountID: number; +}; + +type BuildOnyxDataForTrackExpenseKeys = + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.COLLECTION.REPORT_METADATA + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.REPORT_VIOLATIONS; + +/** Helper function to get optimistic fields violations onyx data */ +function getFieldViolationsOnyxData(iouReport: OnyxTypes.Report): OnyxData { + const missingFields: OnyxTypes.ReportFieldsViolations = {}; + const excludedFields = Object.values(CONST.REPORT_VIOLATIONS_EXCLUDED_FIELDS) as string[]; + + for (const field of Object.values(iouReport.fieldList ?? {})) { + if (excludedFields.includes(field.fieldID) || !!field.value || !!field.defaultValue) { + continue; + } + // in case of missing field violation the empty object is indicator. + missingFields[field.fieldID] = {}; + } + + return { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${iouReport.reportID}`, + value: { + fieldRequired: missingFields, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${iouReport.reportID}`, + value: null, + }, + ], + }; +} + +type BuildOnyxDataForTrackExpenseParams = { + chat: {report: OnyxInputValue; previewAction: OnyxInputValue}; + iou: {report: OnyxInputValue; createdAction: OptimisticCreatedReportAction; action: OptimisticIOUReportAction}; + transactionParams: {transaction: OnyxTypes.Transaction; threadReport: OptimisticChatReport | null; threadCreatedReportAction: OptimisticCreatedReportAction | null}; + policyParams: {policy?: OnyxInputValue; tagList?: OnyxInputValue; categories?: OnyxInputValue}; + shouldCreateNewMoneyRequestReport: boolean; + existingTransactionThreadReportID?: string; + actionableTrackExpenseWhisper?: OnyxInputValue; + retryParams?: StartSplitBilActionParams | CreateTrackExpenseParams | RequestMoneyInformation | ReplaceReceipt; + participant?: Participant; + isASAPSubmitBetaEnabled: boolean; + quickAction: OnyxEntry; +}; + +/** Builds the Onyx data for track expense */ +function buildOnyxDataForTrackExpense({ + chat, + iou, + transactionParams, + policyParams = {}, + shouldCreateNewMoneyRequestReport, + existingTransactionThreadReportID, + actionableTrackExpenseWhisper, + retryParams, + participant, + isASAPSubmitBetaEnabled, + quickAction, +}: BuildOnyxDataForTrackExpenseParams): OnyxData { + const {report: chatReport, previewAction: reportPreviewAction} = chat; + const {report: iouReport, createdAction: iouCreatedAction, action: iouAction} = iou; + const {transaction, threadReport: transactionThreadReport, threadCreatedReportAction: transactionThreadCreatedReportAction} = transactionParams; + const {policy, tagList: policyTagList, categories: policyCategories} = policyParams; + + const isScanRequest = isScanRequestTransactionUtils(transaction); + const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); + const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); + + const onyxData: OnyxData = { + optimisticData: [], + successData: [], + failureData: [], + }; + + const isSelfDMReport = isSelfDM(chatReport); + let newQuickAction: QuickActionName = isSelfDMReport ? CONST.QUICK_ACTIONS.TRACK_MANUAL : CONST.QUICK_ACTIONS.REQUEST_MANUAL; + if (isScanRequest) { + newQuickAction = isSelfDMReport ? CONST.QUICK_ACTIONS.TRACK_SCAN : CONST.QUICK_ACTIONS.REQUEST_SCAN; + } else if (isDistanceRequest) { + newQuickAction = isSelfDMReport ? CONST.QUICK_ACTIONS.TRACK_DISTANCE : CONST.QUICK_ACTIONS.REQUEST_DISTANCE; + } + const existingTransactionThreadReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTransactionThreadReportID}`] ?? null; + + if (chatReport) { + onyxData.optimisticData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + ...chatReport, + lastMessageText: getReportActionText(iouAction), + lastMessageHtml: getReportActionHtml(iouAction), + lastReadTime: DateUtils.getDBTime(), + // do not update iouReportID if auto submit beta is enabled and it is a scan request + iouReportID: isASAPSubmitBetaEnabled && isScanRequest ? null : iouReport?.reportID, + lastVisibleActionCreated: shouldCreateNewMoneyRequestReport ? reportPreviewAction?.created : chatReport.lastVisibleActionCreated, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: newQuickAction, + chatReportID: chatReport.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }, + ); + + if (actionableTrackExpenseWhisper && !iouReport) { + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [actionableTrackExpenseWhisper.reportActionID]: actionableTrackExpenseWhisper, + }, + }); + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + lastReadTime: actionableTrackExpenseWhisper.created, + lastVisibleActionCreated: actionableTrackExpenseWhisper.created, + lastMessageText: CONST.ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE, + }, + }); + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [actionableTrackExpenseWhisper.reportActionID]: {pendingAction: null, errors: null}, + }, + }); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: {[actionableTrackExpenseWhisper.reportActionID]: null}, + }); + } + } + + if (iouReport) { + onyxData.optimisticData?.push( + { + onyxMethod: shouldCreateNewMoneyRequestReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + ...iouReport, + lastMessageText: getReportActionText(iouAction), + lastMessageHtml: getReportActionHtml(iouAction), + pendingFields: { + ...(shouldCreateNewMoneyRequestReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + }, + }, + }, + shouldCreateNewMoneyRequestReport + ? { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouCreatedAction.reportActionID]: iouCreatedAction as OnyxTypes.ReportAction, + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + ...(reportPreviewAction && {[reportPreviewAction.reportActionID]: reportPreviewAction}), + }, + }, + ); + if (shouldCreateNewMoneyRequestReport) { + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReport.reportID}`, + value: { + isOptimisticReport: true, + }, + }); + } + } else { + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + }); + } + + onyxData.optimisticData?.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transaction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + ...transactionThreadReport, + pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionThreadReport?.reportID}`, + value: { + isOptimisticReport: true, + }, + }, + ); + + if (!isEmptyObject(transactionThreadCreatedReportAction)) { + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: transactionThreadCreatedReportAction, + }, + }); + } + + if (iouReport) { + onyxData.successData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: { + ...(shouldCreateNewMoneyRequestReport + ? { + [iouCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + } + : {}), + [iouAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + ...(reportPreviewAction && {[reportPreviewAction.reportActionID]: {pendingAction: null}}), + }, + }, + ); + if (shouldCreateNewMoneyRequestReport) { + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReport.reportID}`, + value: { + isOptimisticReport: false, + }, + }); + } + } else { + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [iouAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + ...(reportPreviewAction && {[reportPreviewAction.reportActionID]: {pendingAction: null}}), + }, + }); + } + + onyxData.successData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionThreadReport?.reportID}`, + value: { + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + pendingAction: null, + pendingFields: clearedPendingFields, + routes: null, + }, + }, + ); + + if (!isEmptyObject(transactionThreadCreatedReportAction)) { + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }); + } + + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: quickAction ?? null, + }); + + if (iouReport) { + onyxData.failureData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: null, + errorFields: { + ...(shouldCreateNewMoneyRequestReport ? {createChat: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage')} : {}), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + ...(shouldCreateNewMoneyRequestReport + ? { + [iouCreatedAction.reportActionID]: { + errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, retryParams), + }, + [iouAction.reportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + } + : { + [iouAction.reportActionID]: { + errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, retryParams), + }, + }), + }, + }, + ); + } else { + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [iouAction.reportActionID]: { + errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, retryParams), + }, + }, + }); + } + + onyxData.failureData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + lastReadTime: chatReport?.lastReadTime, + lastMessageText: chatReport?.lastMessageText, + lastMessageHtml: chatReport?.lastMessageHtml, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + pendingFields: null, + errorFields: existingTransactionThreadReport + ? null + : { + createChat: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, retryParams), + pendingFields: clearedPendingFields, + }, + }, + ); + + if (transactionThreadCreatedReportAction?.reportActionID) { + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [transactionThreadCreatedReportAction?.reportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + }); + } + + const searchUpdate = getSearchOnyxUpdate({ + transaction, + participant, + transactionThreadReportID: transactionThreadReport?.reportID, + }); + + if (searchUpdate) { + if (searchUpdate.optimisticData) { + onyxData.optimisticData?.push(...searchUpdate.optimisticData); + } + if (searchUpdate.successData) { + onyxData.successData?.push(...searchUpdate.successData); + } + } + + // We don't need to compute violations unless we're on a paid policy + if (!policy || !isPaidGroupPolicy(policy) || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + return onyxData; + } + + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + policy, + policyTagList ?? {}, + policyCategories ?? {}, + hasDependentTags(policy, policyTagList ?? {}), + false, + ); + + if (violationsOnyxData) { + onyxData.optimisticData?.push(violationsOnyxData); + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`, + value: [], + }); + } + + // Show field violations only for control policies + if (isControlPolicy(policy) && iouReport) { + const {optimisticData: fieldViolationsOptimisticData, failureData: fieldViolationsFailureData} = getFieldViolationsOnyxData(iouReport); + onyxData.optimisticData?.push(...(fieldViolationsOptimisticData ?? [])); + onyxData.failureData?.push(...(fieldViolationsFailureData ?? [])); + } + + return onyxData; +} + +function getDeleteTrackExpenseInformation( + chatReport: OnyxEntry, + transactionID: string | undefined, + reportAction: OnyxTypes.ReportAction, + isChatReportArchived: boolean | undefined, + shouldDeleteTransactionFromOnyx = true, + isMovingTransactionFromTrackExpense = false, + actionableWhisperReportActionID = '', + resolution = '', + shouldRemoveIOUTransaction = true, +) { + // STEP 1: Get all collections we're updating + const transaction = getAllTransactions()[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const transactionViolations = getAllTransactionViolations()[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + const transactionThreadID = reportAction.childReportID; + + // STEP 2: Decide if we need to: + // 1. Delete the transactionThread - delete if we're not moving the transaction + // 2. Update the moneyRequestPreview to show [Deleted expense] - update if the transactionThread exists AND it isn't being deleted and we're not moving the transaction + const shouldDeleteTransactionThread = !isMovingTransactionFromTrackExpense && !!transactionThreadID; + + const shouldShowDeletedRequestMessage = !isMovingTransactionFromTrackExpense && !!transactionThreadID && !shouldDeleteTransactionThread; + + // STEP 3: Update the IOU reportAction. + const updatedReportAction = { + [reportAction.reportActionID]: { + pendingAction: shouldShowDeletedRequestMessage ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + previousMessage: reportAction.message, + message: [ + { + type: 'COMMENT', + html: '', + text: '', + isEdited: true, + isDeletedParentAction: shouldShowDeletedRequestMessage, + }, + ], + originalMessage: { + IOUTransactionID: shouldRemoveIOUTransaction ? null : transactionID, + }, + errors: undefined, + }, + ...(actionableWhisperReportActionID && {[actionableWhisperReportActionID]: {originalMessage: {resolution}}}), + } as OnyxTypes.ReportActions; + let canUserPerformWriteAction = true; + if (chatReport) { + canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(chatReport, isChatReportArchived); + } + const lastVisibleAction = getLastVisibleAction(chatReport?.reportID, canUserPerformWriteAction, updatedReportAction); + const {lastMessageText = '', lastMessageHtml = ''} = getLastVisibleMessage(chatReport?.reportID, canUserPerformWriteAction, updatedReportAction); + + // STEP 4: Build Onyx data + const optimisticData: Array< + OnyxUpdate + > = []; + + if (shouldDeleteTransactionFromOnyx && shouldRemoveIOUTransaction) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: null, + }); + } + if (!shouldRemoveIOUTransaction) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }); + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: null, + }); + + const cleanUpTransactionThreadReportOnyxData = getCleanUpTransactionThreadReportOnyxData({ + transactionThreadID, + shouldDeleteTransactionThread, + }); + optimisticData.push(...cleanUpTransactionThreadReportOnyxData.optimisticData); + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: updatedReportAction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + lastMessageText, + lastVisibleActionCreated: lastVisibleAction?.created, + lastMessageHtml: !lastMessageHtml ? lastMessageText : lastMessageHtml, + }, + }, + ); + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ]; + + // Ensure that any remaining data is removed upon successful completion, even if the server sends a report removal response. + // This is done to prevent the removal update from lingering in the applyHTTPSOnyxUpdates function. + successData.push(...cleanUpTransactionThreadReportOnyxData.successData); + + const failureData: Array< + OnyxUpdate + > = []; + + if (shouldDeleteTransactionFromOnyx && shouldRemoveIOUTransaction) { + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: transaction ?? null, + }); + } + if (!shouldRemoveIOUTransaction) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingAction: null, + }, + }); + } + + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: transactionViolations ?? null, + }); + + failureData.push(...cleanUpTransactionThreadReportOnyxData.failureData); + + if (actionableWhisperReportActionID) { + const actionableWhisperReportAction = getReportAction(chatReport?.reportID, actionableWhisperReportActionID); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [actionableWhisperReportActionID]: { + originalMessage: { + resolution: isActionableTrackExpense(actionableWhisperReportAction) ? (getOriginalMessage(actionableWhisperReportAction)?.resolution ?? null) : null, + }, + }, + }, + }); + } + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportAction.reportActionID]: { + ...reportAction, + pendingAction: null, + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDeleteFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: chatReport ?? null, + }, + ); + + const parameters: DeleteMoneyRequestParams = { + transactionID, + reportActionID: reportAction.reportActionID, + }; + + return {parameters, optimisticData, successData, failureData, shouldDeleteTransactionThread, chatReport}; +} + +/* Gathers all the data needed to make an expense. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then + * it creates optimistic versions of them and uses those instead + */ +function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): TrackExpenseInformation { + const { + parentChatReport, + moneyRequestReportID = '', + existingTransaction, + existingTransactionID, + participantParams, + policyParams, + transactionParams, + retryParams, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam, + currentUserEmailParam, + introSelected, + activePolicyID, + quickAction, + betas, + isSelfTourViewed, + } = params; + const {payeeAccountID = userAccountID, payeeEmail = currentUserEmail, participant} = participantParams; + const {policy, policyCategories, policyTagList} = policyParams; + const { + comment, + amount, + currency, + created, + distance, + merchant, + receipt, + category, + tag, + taxCode, + taxAmount, + billable, + reimbursable, + linkedTrackedExpenseReportAction, + attendees, + odometerStart, + odometerEnd, + gpsCoordinates, + } = transactionParams; + + const onyxData: OnyxData = { + optimisticData: [], + successData: [], + failureData: [], + }; + + const isPolicyExpenseChat = participant.isPolicyExpenseChat; + + // STEP 1: Get existing chat report + let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null; + + // If no chat report is passed, defaults to the self-DM report + if (!chatReport) { + chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${findSelfDMReportID()}`] ?? null; + } + + // If we are still missing the chat report then optimistically create the self-DM report and use it + let optimisticReportID: string | undefined; + let optimisticReportActionID: string | undefined; + if (!chatReport) { + const currentTime = DateUtils.getDBTime(); + const selfDMReport = buildOptimisticSelfDMReport(currentTime); + const selfDMCreatedReportAction = buildOptimisticCreatedReportAction(currentUserEmail ?? '', currentTime); + optimisticReportID = selfDMReport.reportID; + optimisticReportActionID = selfDMCreatedReportAction.reportActionID; + chatReport = selfDMReport; + + onyxData.optimisticData?.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, + value: { + ...selfDMReport, + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SELF_DM_REPORT_ID, + value: selfDMReport.reportID, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReportID}`, + value: {isOptimisticReport: true}, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReportID}`, + value: { + [optimisticReportActionID]: selfDMCreatedReportAction, + }, + }, + ); + onyxData.successData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, + value: { + pendingFields: { + createChat: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReportID}`, + value: {isOptimisticReport: false}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReportID}`, + value: { + [optimisticReportActionID]: { + pendingAction: null, + }, + }, + }, + ); + onyxData.failureData?.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReportID}`, + value: null, + }, + ); + } + + // Check if the report is a draft + const isDraftReportLocal = isDraftReport(chatReport?.reportID); + + let createdWorkspaceParams: CreateWorkspaceParams | undefined; + + if (isDraftReportLocal) { + const workspaceData = buildPolicyData({ + policyOwnerEmail: undefined, + makeMeAdmin: policy?.makeMeAdmin, + policyName: policy?.name, + policyID: policy?.id, + expenseReportId: chatReport?.reportID, + engagementChoice: CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE, + currentUserAccountIDParam, + currentUserEmailParam, + introSelected, + activePolicyID, + isSelfTourViewed, + }); + createdWorkspaceParams = workspaceData.params; + onyxData.optimisticData?.push(...(workspaceData.optimisticData ?? [])); + onyxData.successData?.push(...(workspaceData.successData ?? [])); + onyxData.failureData?.push(...(workspaceData.failureData ?? [])); + } + + // STEP 2: If not in the self-DM flow, we need to use the expense report. + // For this, first use the chatReport.iouReportID property. Build a new optimistic expense report if needed. + const shouldUseMoneyReport = !!isPolicyExpenseChat && chatReport.chatType !== CONST.REPORT.CHAT_TYPE.SELF_DM; + + let iouReport: OnyxInputValue = null; + let shouldCreateNewMoneyRequestReport = false; + + // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation + const optimisticTransactionID = existingTransactionID ?? NumberUtils.rand64(); + const optimisticExpenseReportID = generateReportID(); + + if (shouldUseMoneyReport) { + if (moneyRequestReportID) { + iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`] ?? null; + } else { + iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; + } + const isScanRequest = isScanRequestTransactionUtils({amount, receipt}); + shouldCreateNewMoneyRequestReport = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas); + if (!iouReport || shouldCreateNewMoneyRequestReport) { + const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticExpenseReportID, created, amount, currency, merchant); + + iouReport = buildOptimisticExpenseReport({ + chatReportID: chatReport.reportID, + policyID: chatReport.policyID, + payeeAccountID, + total: amount, + currency, + nonReimbursableTotal: amount, + betas, + optimisticIOUReportID: optimisticExpenseReportID, + reportTransactions, + }); + } else { + iouReport = {...iouReport}; + // Because of the Expense reports are stored as negative values, we subtract the total from the amount + if (iouReport?.currency === currency) { + if (!Number.isNaN(iouReport.total) && iouReport.total !== undefined && typeof iouReport.nonReimbursableTotal === 'number') { + iouReport.total -= amount; + iouReport.nonReimbursableTotal -= amount; + } + + if (typeof iouReport.unheldTotal === 'number' && typeof iouReport.unheldNonReimbursableTotal === 'number') { + iouReport.unheldTotal -= amount; + iouReport.unheldNonReimbursableTotal -= amount; + } + } + } + } + + // If shouldUseMoneyReport is true, the iouReport was defined. + // But we'll use the `shouldUseMoneyReport && iouReport` check further instead of `shouldUseMoneyReport` to avoid TS errors. + + // STEP 3: Build optimistic receipt and transaction + const existingTransactionData = existingTransaction ?? allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; + const isDistanceRequest = existingTransactionData && isDistanceRequestTransactionUtils(existingTransactionData); + const isManualDistanceRequest = existingTransactionData && isManualDistanceRequestTransactionUtils(existingTransactionData); + const isOdometerDistanceRequest = existingTransactionData && isOdometerDistanceRequestTransactionUtils(existingTransactionData); + const isGPSDistanceRequest = existingTransactionData && isGPSDistanceRequestTransactionUtils(existingTransactionData); + let optimisticTransaction = buildOptimisticTransaction({ + existingTransactionID: optimisticTransactionID, + existingTransaction: existingTransactionData, + policy, + transactionParams: { + amount: -amount, + currency, + reportID: shouldUseMoneyReport && iouReport ? iouReport.reportID : CONST.REPORT.UNREPORTED_REPORT_ID, + comment, + distance, + created, + merchant, + receipt, + category, + tag, + taxCode, + taxAmount: taxAmount ? -taxAmount : undefined, + billable, + pendingFields: isDistanceRequest && !isManualDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, + reimbursable, + filename: existingTransactionData?.receipt?.filename, + attendees, + odometerStart: isOdometerDistanceRequest ? odometerStart : undefined, + odometerEnd: isOdometerDistanceRequest ? odometerEnd : undefined, + gpsCoordinates: isGPSDistanceRequest ? gpsCoordinates : undefined, + }, + }); + if (iouReport) { + iouReport.transactionCount = (iouReport.transactionCount ?? 0) + 1; + } + + // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction + // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction + // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. + // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 + // to remind me to do this. + if (isDistanceRequest) { + optimisticTransaction = fastMerge(existingTransactionData, optimisticTransaction, false); + } + + // STEP 4: Build optimistic reportActions. We need: + // 1. CREATED action for the iouReport (if tracking in the Expense chat) + // 2. IOU action for the iouReport (if tracking in the Expense chat), otherwise – for chatReport + // 3. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread + // 4. REPORT_PREVIEW action for the chatReport (if tracking in the Expense chat) + const [, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = buildOptimisticMoneyRequestEntities({ + iouReport: shouldUseMoneyReport && iouReport ? iouReport : chatReport, + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount, + currency, + comment, + payeeEmail, + participants: [participant], + transactionID: optimisticTransaction.transactionID, + isPersonalTrackingExpense: !shouldUseMoneyReport, + existingTransactionThreadReportID: linkedTrackedExpenseReportAction?.childReportID, + linkedTrackedExpenseReportAction, + }); + + let reportPreviewAction: OnyxInputValue> = null; + if (shouldUseMoneyReport && iouReport) { + reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : getReportPreviewAction(chatReport.reportID, iouReport.reportID); + + if (reportPreviewAction) { + reportPreviewAction = updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); + } else { + reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction); + // Generated ReportPreview action is a parent report action of the iou report. + // We are setting the iou report's parentReportActionID to display subtitle correctly in IOU page when offline. + iouReport.parentReportActionID = reportPreviewAction.reportActionID; + } + } + + let actionableTrackExpenseWhisper: OnyxInputValue = null; + if (!isPolicyExpenseChat) { + actionableTrackExpenseWhisper = buildOptimisticActionableTrackExpenseWhisper(iouAction, optimisticTransaction.transactionID); + } + + // STEP 5: Build Onyx Data + const trackExpenseOnyxData = buildOnyxDataForTrackExpense({ + participant, + chat: {report: chatReport, previewAction: reportPreviewAction}, + iou: {report: iouReport, action: iouAction, createdAction: optimisticCreatedActionForIOUReport}, + transactionParams: { + transaction: optimisticTransaction, + threadCreatedReportAction: optimisticCreatedActionForTransactionThread, + threadReport: optimisticTransactionThread ?? {}, + }, + policyParams: {policy, tagList: policyTagList, categories: policyCategories}, + shouldCreateNewMoneyRequestReport, + actionableTrackExpenseWhisper, + retryParams, + isASAPSubmitBetaEnabled, + quickAction, + }); + + onyxData.optimisticData?.push(...(trackExpenseOnyxData.optimisticData ?? [])); + onyxData.successData?.push(...(trackExpenseOnyxData.successData ?? [])); + onyxData.failureData?.push(...(trackExpenseOnyxData.failureData ?? [])); + + return { + createdWorkspaceParams, + chatReport, + iouReport: iouReport ?? undefined, + transaction: optimisticTransaction, + iouAction, + createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOUReport.reportActionID : undefined, + reportPreviewAction: reportPreviewAction ?? undefined, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID, + actionableWhisperReportActionIDParam: actionableTrackExpenseWhisper?.reportActionID, + optimisticReportID, + optimisticReportActionID, + onyxData, + }; +} + +const getConvertTrackedExpenseInformation = ( + transactionID: string | undefined, + actionableWhisperReportActionID: string | undefined, + moneyRequestReportID: string | undefined, + linkedTrackedExpenseReportAction: OnyxTypes.ReportAction, + linkedTrackedExpenseReportID: string, + transactionThreadReportID: string | undefined, + resolution: IOUAction, + isLinkedTrackedExpenseReportArchived: boolean | undefined, +) => { + const optimisticData: Array< + OnyxUpdate + > = []; + const successData: Array> = []; + const failureData: Array< + OnyxUpdate + > = []; + + // Delete the transaction from the track expense report + const { + optimisticData: deleteOptimisticData, + successData: deleteSuccessData, + failureData: deleteFailureData, + } = getDeleteTrackExpenseInformation( + allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedTrackedExpenseReportID}`], + transactionID, + linkedTrackedExpenseReportAction, + isLinkedTrackedExpenseReportArchived, + false, + true, + actionableWhisperReportActionID, + resolution, + true, + ); + + optimisticData?.push(...deleteOptimisticData); + successData?.push(...deleteSuccessData); + failureData?.push(...deleteFailureData); + + // Build modified expense report action with the transaction changes + const modifiedExpenseReportAction = buildOptimisticMovedTransactionAction(transactionThreadReportID, linkedTrackedExpenseReportID ?? CONST.REPORT.UNREPORTED_REPORT_ID); + + optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportAction.reportActionID]: modifiedExpenseReportAction, + }, + }); + successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportAction.reportActionID]: {pendingAction: null}, + }, + }); + failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportAction.reportActionID]: { + ...modifiedExpenseReportAction, + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, + }, + }); + + return {optimisticData, successData, failureData, modifiedExpenseReportActionID: modifiedExpenseReportAction.reportActionID}; +}; + +type GetConvertTrackedExpenseWorkspaceFailureDataParams = { + iouReportID: string; + iouCreatedReportActionID: string | undefined; + iouReportActionID: string; + chatReportID: string; + chatPreviewReportActionID: string; + transactionID: string; + linkedTrackedExpenseReportID: string; + linkedTrackedExpenseReportActionID: string; + transactionThreadReportID: string | undefined; + modifiedExpenseReportActionID: string; +}; + +function getConvertTrackedExpenseWorkspaceFailureData({ + iouReportID, + iouCreatedReportActionID, + iouReportActionID, + chatReportID, + chatPreviewReportActionID, + transactionID, + linkedTrackedExpenseReportID, + linkedTrackedExpenseReportActionID, + transactionThreadReportID, + modifiedExpenseReportActionID, +}: GetConvertTrackedExpenseWorkspaceFailureDataParams): Array> { + const additionalFailureData: Array> = []; + const previousIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; + const shouldClearOptimisticIOUReport = !previousIOUReport || previousIOUReport.pendingFields?.createChat === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; + + if (shouldClearOptimisticIOUReport) { + additionalFailureData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`, + value: null, + }, + ); + } else { + additionalFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + value: previousIOUReport, + }); + } + + const previousReportPreviewAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`]?.[chatPreviewReportActionID]; + additionalFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: { + [chatPreviewReportActionID]: previousReportPreviewAction ?? null, + }, + }); + + const previousIOUReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`]; + const previousIOUAction = previousIOUReportActions?.[iouReportActionID]; + additionalFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, + value: { + [iouReportActionID]: previousIOUAction ?? null, + ...(iouCreatedReportActionID ? {[iouCreatedReportActionID]: previousIOUReportActions?.[iouCreatedReportActionID] ?? null} : {}), + }, + }); + + additionalFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingAction: null, + reportID: linkedTrackedExpenseReportID, + status: CONST.TRANSACTION.STATUS.POSTED, + }, + }); + + if (transactionThreadReportID) { + additionalFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportActionID]: null, + }, + }); + } + + additionalFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${linkedTrackedExpenseReportID}`, + value: { + [linkedTrackedExpenseReportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + pendingAction: null, + }, + }, + }); + + return additionalFailureData; +} + +type ConvertTrackedWorkspaceParams = { + category: string | undefined; + tag: string | undefined; + taxCode: string; + taxAmount: number; + billable: boolean | undefined; + policyID: string; + receipt: Receipt | undefined; + waypoints?: string; + customUnitID?: string; + customUnitRateID?: string; + reimbursable?: boolean; +}; + +type AddTrackedExpenseToPolicyParam = { + amount: number; + currency: string; + comment: string; + created: string; + merchant: string; + transactionID: string; + reimbursable: boolean; + actionableWhisperReportActionID: string | undefined; + moneyRequestReportID: string; + reportPreviewReportActionID: string; + modifiedExpenseReportActionID: string; + moneyRequestCreatedReportActionID: string | undefined; + moneyRequestPreviewReportActionID: string; + distance: number | undefined; + attendees: string | undefined; +} & ConvertTrackedWorkspaceParams; + +type ConvertTrackedExpenseToRequestParams = { + payerParams: { + accountID: number; + email: string; + }; + transactionParams: { + transactionID: string; + actionableWhisperReportActionID: string | undefined; + linkedTrackedExpenseReportAction: OnyxTypes.ReportAction; + linkedTrackedExpenseReportID: string; + amount: number; + currency: string; + comment: string; + merchant: string; + created: string; + attendees?: Attendee[]; + transactionThreadReportID?: string; + distance?: number; + isLinkedTrackedExpenseReportArchived: boolean | undefined; + waypoints?: string; + customUnitRateID?: string; + isDistance?: boolean; + }; + chatParams: { + reportID: string; + createdReportActionID: string | undefined; + reportPreviewReportActionID: string; + }; + iouParams: { + reportID: string; + createdReportActionID: string | undefined; + reportActionID: string; + }; + onyxData: OnyxData; + workspaceParams?: ConvertTrackedWorkspaceParams; +}; + +function addTrackedExpenseToPolicy(parameters: AddTrackedExpenseToPolicyParam, onyxData: OnyxData) { + API.write(WRITE_COMMANDS.ADD_TRACKED_EXPENSE_TO_POLICY, parameters, onyxData); +} + +function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrackedExpenseToRequestParams) { + const {payerParams, transactionParams, chatParams, iouParams, onyxData, workspaceParams} = convertTrackedExpenseParams; + const {accountID: payerAccountID, email: payerEmail} = payerParams; + const { + transactionID, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + amount, + distance, + currency, + comment, + merchant, + created, + attendees, + transactionThreadReportID, + isLinkedTrackedExpenseReportArchived, + waypoints, + customUnitRateID, + isDistance, + } = transactionParams; + const optimisticData: Array> = []; + const successData: Array> = []; + const failureData: Array> = []; + + optimisticData?.push(...(onyxData.optimisticData ?? [])); + successData?.push(...(onyxData.successData ?? [])); + failureData?.push(...(onyxData.failureData ?? [])); + + const convertTrackedExpenseInformation = getConvertTrackedExpenseInformation( + transactionID, + actionableWhisperReportActionID, + iouParams.reportID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + CONST.IOU.ACTION.SUBMIT, + isLinkedTrackedExpenseReportArchived, + ); + optimisticData?.push(...(convertTrackedExpenseInformation.optimisticData ?? [])); + successData?.push(...(convertTrackedExpenseInformation.successData ?? [])); + failureData?.push(...(convertTrackedExpenseInformation.failureData ?? [])); + + if (transactionThreadReportID) { + const transactionThreadReport = getReportOrDraftReport(transactionThreadReportID); + + optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + parentReportActionID: iouParams.reportActionID, + parentReportID: iouParams.reportID, + }, + }); + + failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + parentReportActionID: transactionThreadReport?.parentReportActionID, + parentReportID: transactionThreadReport?.parentReportID, + }, + }); + } + + if (workspaceParams) { + const additionalFailureData = getConvertTrackedExpenseWorkspaceFailureData({ + iouReportID: iouParams.reportID, + iouCreatedReportActionID: iouParams.createdReportActionID, + iouReportActionID: iouParams.reportActionID, + chatReportID: chatParams.reportID, + chatPreviewReportActionID: chatParams.reportPreviewReportActionID, + transactionID, + linkedTrackedExpenseReportID, + linkedTrackedExpenseReportActionID: linkedTrackedExpenseReportAction.reportActionID, + transactionThreadReportID, + modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID, + }); + + // Removing the ghost IOU report on API failure which can cause unexpected errors. + failureData?.push(...additionalFailureData); + + const params = { + amount, + distance, + currency, + comment, + created, + merchant, + attendees: attendees ? JSON.stringify(attendees) : undefined, + reimbursable: true, + transactionID, + actionableWhisperReportActionID, + moneyRequestReportID: iouParams.reportID, + moneyRequestCreatedReportActionID: iouParams.createdReportActionID, + moneyRequestPreviewReportActionID: iouParams.reportActionID, + modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID, + reportPreviewReportActionID: chatParams.reportPreviewReportActionID, + ...workspaceParams, + }; + + addTrackedExpenseToPolicy(params, {optimisticData, successData, failureData}); + return; + } + + const parameters = { + attendees, + amount, + distance, + currency, + comment, + created, + merchant, + payerAccountID, + payerEmail, + chatReportID: chatParams.reportID, + transactionID, + actionableWhisperReportActionID, + createdChatReportActionID: chatParams.createdReportActionID, + moneyRequestReportID: iouParams.reportID, + moneyRequestCreatedReportActionID: iouParams.createdReportActionID, + moneyRequestPreviewReportActionID: iouParams.reportActionID, + transactionThreadReportID, + modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID, + reportPreviewReportActionID: chatParams.reportPreviewReportActionID, + isDistance, + customUnitRateID, + waypoints, + }; + API.write(WRITE_COMMANDS.CONVERT_TRACKED_EXPENSE_TO_REQUEST, parameters, {optimisticData, successData, failureData}); +} + +/** + * Move multiple tracked expenses from self-DM to an IOU report + */ +function convertBulkTrackedExpensesToIOU({ + transactionIDs, + iouReport, + chatReport, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam, + currentUserEmailParam, + transactionViolations, + policyRecentlyUsedCurrencies, + quickAction, + personalDetails, + betas, +}: { + transactionIDs: string[]; + iouReport: OnyxEntry; + chatReport: OnyxEntry; + isASAPSubmitBetaEnabled: boolean; + currentUserAccountIDParam: number; + currentUserEmailParam: string; + transactionViolations: OnyxCollection; + policyRecentlyUsedCurrencies: string[]; + quickAction: OnyxEntry; + personalDetails: OnyxEntry; + betas: OnyxEntry; +}) { + const iouReportID = iouReport?.reportID; + + if (!iouReport || !isMoneyRequestReportReportUtils(iouReport)) { + Log.warn('[convertBulkTrackedExpensesToIOU] Invalid IOU report', {iouReportID}); + return; + } + + if (!chatReport?.reportID) { + Log.warn('[convertBulkTrackedExpensesToIOU] No chat report found for IOU', {iouReportID}); + return; + } + + const participantAccountIDs = getReportRecipientAccountIDs(iouReport, userAccountID); + const payerAccountID = participantAccountIDs.at(0); + + if (!payerAccountID) { + Log.warn('[convertBulkTrackedExpensesToIOU] No payer found', {iouReportID, participantAccountIDs}); + return; + } + + const payerEmail = personalDetails?.[payerAccountID]?.login ?? ''; + const selfDMReportID = findSelfDMReportID(); + + if (!selfDMReportID) { + Log.warn('[convertBulkTrackedExpensesToIOU] Self DM not found'); + return; + } + + const selfDMReportActions = getAllReportActions(selfDMReportID); + + for (const transactionID of transactionIDs) { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + Log.warn('[convertBulkTrackedExpensesToIOU] Transaction not found', {transactionID}); + continue; + } + + const linkedTrackedExpenseReportAction = Object.values(selfDMReportActions).find((action) => { + if (!isMoneyRequestAction(action)) { + return false; + } + const originalMessage = getOriginalMessage(action); + return originalMessage?.IOUTransactionID === transactionID; + }); + + if (!linkedTrackedExpenseReportAction) { + Log.warn('[convertBulkTrackedExpensesToIOU] Tracked expense IOU action not found', {transactionID}); + continue; + } + + const actionableWhisperReportActionID = getTrackExpenseActionableWhisper(transactionID, selfDMReportID)?.reportActionID; + + const commentText = typeof transaction.comment === 'string' ? transaction.comment : (transaction.comment?.comment ?? ''); + const parsedComment = getParsedComment(Parser.htmlToMarkdown(commentText)); + + const attendees = transaction.comment?.attendees; + + const transactionThreadReportID = (linkedTrackedExpenseReportAction as OnyxTypes.ReportAction).childReportID; + + if (!transactionThreadReportID) { + Log.warn('[convertBulkTrackedExpensesToIOU] No transaction thread found for tracked expense, skipping', { + transactionID, + actionReportActionID: (linkedTrackedExpenseReportAction as OnyxTypes.ReportAction).reportActionID, + }); + continue; + } + + const participantParams = { + payeeAccountID: userAccountID, + payeeEmail: currentUserEmail, + participant: { + accountID: payerAccountID, + login: payerEmail, + }, + }; + + const transactionParams = { + amount: getAmount(transaction), + currency: getCurrency(transaction), + comment: parsedComment, + merchant: getMerchant(transaction), + created: transaction.created, + attendees, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: selfDMReportID, + isLinkedTrackedExpenseReportArchived: false, + }; + + const { + payerAccountID: moneyRequestPayerAccountID, + payerEmail: moneyRequestPayerEmail, + iouReport: moneyRequestIOUReport, + chatReport: moneyRequestChatReport, + transaction: moneyRequestTransaction, + iouAction, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewAction, + transactionThreadReportID: moneyRequestTransactionThreadReportID, + onyxData, + } = getMoneyRequestInformation({ + parentChatReport: chatReport, + participantParams, + transactionParams, + moneyRequestReportID: iouReportID, + existingTransactionID: transactionID, + existingTransaction: transaction, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam, + currentUserEmailParam, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + personalDetails, + betas, + }); + + const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); + const transactionWaypoints = getWaypoints(transaction); + const sanitizedWaypointsForBulk = transactionWaypoints ? JSON.stringify(sanitizeRecentWaypoints(transactionWaypoints)) : undefined; + + const convertParams: ConvertTrackedExpenseToRequestParams = { + payerParams: { + accountID: moneyRequestPayerAccountID, + email: moneyRequestPayerEmail, + }, + transactionParams: { + amount: getAmount(transaction), + currency: getCurrency(transaction), + comment: parsedComment, + merchant: getMerchant(transaction), + created: transaction.created, + attendees, + transactionID: moneyRequestTransaction.transactionID, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: selfDMReportID, + transactionThreadReportID: moneyRequestTransactionThreadReportID, + isLinkedTrackedExpenseReportArchived: false, + isDistance: isDistanceRequest, + customUnitRateID: isDistanceRequest ? getRateID(transaction) : undefined, + waypoints: isDistanceRequest ? sanitizedWaypointsForBulk : undefined, + distance: isDistanceRequest ? (transaction.comment?.customUnit?.quantity ?? undefined) : undefined, + }, + chatParams: { + reportID: moneyRequestChatReport.reportID, + createdReportActionID: createdChatReportActionID, + reportPreviewReportActionID: reportPreviewAction.reportActionID, + }, + iouParams: { + reportID: moneyRequestIOUReport.reportID, + createdReportActionID: createdIOUReportActionID, + reportActionID: iouAction.reportActionID, + }, + onyxData, + }; + + convertTrackedExpenseToRequest(convertParams); + } +} + +function categorizeTrackedExpense(trackedExpenseParams: TrackedExpenseParams) { + const {onyxData, reportInformation, transactionParams, policyParams, createdWorkspaceParams} = trackedExpenseParams; + const {optimisticData, successData, failureData} = onyxData ?? {}; + const {transactionID} = transactionParams; + const {isDraftPolicy} = policyParams; + const { + actionableWhisperReportActionID, + moneyRequestReportID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + isLinkedTrackedExpenseReportArchived, + } = reportInformation; + const { + optimisticData: moveTransactionOptimisticData, + successData: moveTransactionSuccessData, + failureData: moveTransactionFailureData, + modifiedExpenseReportActionID, + } = getConvertTrackedExpenseInformation( + transactionID, + actionableWhisperReportActionID, + moneyRequestReportID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + CONST.IOU.ACTION.CATEGORIZE, + isLinkedTrackedExpenseReportArchived, + ); + + optimisticData?.push(...moveTransactionOptimisticData); + successData?.push(...moveTransactionSuccessData); + failureData?.push(...moveTransactionFailureData); + + const parameters: CategorizeTrackedExpenseApiParams = { + ...{ + ...reportInformation, + linkedTrackedExpenseReportAction: undefined, + }, + ...policyParams, + ...transactionParams, + modifiedExpenseReportActionID, + policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, + policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, + adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, + adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, + engagementChoice: createdWorkspaceParams?.engagementChoice, + guidedSetupData: createdWorkspaceParams?.guidedSetupData, + description: transactionParams.comment, + customUnitID: createdWorkspaceParams?.customUnitID, + customUnitRateID: createdWorkspaceParams?.customUnitRateID ?? transactionParams.customUnitRateID, + attendees: transactionParams.attendees ? JSON.stringify(transactionParams.attendees) : undefined, + }; + + API.write(WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); + + // If a draft policy was used, then the CategorizeTrackedExpense command will create a real one + // so let's track that conversion here + if (isDraftPolicy) { + GoogleTagManager.publishEvent(CONST.ANALYTICS.EVENT.WORKSPACE_CREATED, userAccountID); + } +} + +function shareTrackedExpense(trackedExpenseParams: TrackedExpenseParams) { + const {onyxData: trackedExpenseOnyxData, reportInformation, transactionParams, policyParams, createdWorkspaceParams, accountantParams} = trackedExpenseParams; + + const policyID = policyParams?.policyID; + const chatReportID = reportInformation?.chatReportID; + const accountantEmail = addSMSDomainIfPhoneNumber(accountantParams?.accountant?.login); + const accountantAccountID = accountantParams?.accountant?.accountID; + + if (!policyID || !chatReportID || !accountantEmail || !accountantAccountID) { + return; + } + + const onyxData: OnyxData< + | BuildOnyxDataForTrackExpenseKeys + | BuildPolicyDataKeys + | typeof ONYXKEYS.NVP_RECENT_WAYPOINTS + | typeof ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE + | typeof ONYXKEYS.GPS_DRAFT_DETAILS + | typeof ONYXKEYS.SELF_DM_REPORT_ID + > = { + optimisticData: trackedExpenseOnyxData?.optimisticData ?? [], + successData: trackedExpenseOnyxData?.successData ?? [], + failureData: trackedExpenseOnyxData?.failureData ?? [], + }; + + const {transactionID} = transactionParams; + const { + actionableWhisperReportActionID, + moneyRequestPreviewReportActionID, + moneyRequestCreatedReportActionID, + reportPreviewReportActionID, + moneyRequestReportID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + isLinkedTrackedExpenseReportArchived, + } = reportInformation; + + const convertTrackedExpenseInformation = getConvertTrackedExpenseInformation( + transactionID, + actionableWhisperReportActionID, + moneyRequestReportID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + CONST.IOU.ACTION.SHARE, + isLinkedTrackedExpenseReportArchived, + ); + + onyxData.optimisticData?.push(...(convertTrackedExpenseInformation.optimisticData ?? [])); + onyxData.successData?.push(...(convertTrackedExpenseInformation.successData ?? [])); + onyxData.failureData?.push(...(convertTrackedExpenseInformation.failureData ?? [])); + + const policyEmployeeList = policyParams?.policy?.employeeList; + if (policyParams.policy && !policyEmployeeList?.[accountantEmail]) { + const policyMemberAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policyEmployeeList, false, false)); + const { + optimisticData: addAccountantToWorkspaceOptimisticData, + successData: addAccountantToWorkspaceSuccessData, + failureData: addAccountantToWorkspaceFailureData, + } = buildAddMembersToWorkspaceOnyxData({[accountantEmail]: accountantAccountID}, policyParams.policy, policyMemberAccountIDs, CONST.POLICY.ROLE.ADMIN, formatPhoneNumber); + onyxData.optimisticData?.push(...addAccountantToWorkspaceOptimisticData); + onyxData.successData?.push(...addAccountantToWorkspaceSuccessData); + onyxData.failureData?.push(...addAccountantToWorkspaceFailureData); + } else if (policyEmployeeList?.[accountantEmail].role !== CONST.POLICY.ROLE.ADMIN) { + const { + optimisticData: addAccountantToWorkspaceOptimisticData, + successData: addAccountantToWorkspaceSuccessData, + failureData: addAccountantToWorkspaceFailureData, + } = buildUpdateWorkspaceMembersRoleOnyxData(policyParams?.policy, [accountantEmail], [accountantAccountID], CONST.POLICY.ROLE.ADMIN); + onyxData.optimisticData?.push(...addAccountantToWorkspaceOptimisticData); + onyxData.successData?.push(...addAccountantToWorkspaceSuccessData); + onyxData.failureData?.push(...addAccountantToWorkspaceFailureData); + } + + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`]; + const chatReportParticipants = chatReport?.participants; + if (chatReport && !chatReportParticipants?.[accountantAccountID]) { + const { + optimisticData: inviteAccountantToRoomOptimisticData, + successData: inviteAccountantToRoomSuccessData, + failureData: inviteAccountantToRoomFailureData, + } = buildInviteToRoomOnyxData(chatReport, {[accountantEmail]: accountantAccountID}, formatPhoneNumber); + onyxData.optimisticData?.push(...inviteAccountantToRoomOptimisticData); + onyxData.successData?.push(...inviteAccountantToRoomSuccessData); + onyxData.failureData?.push(...inviteAccountantToRoomFailureData); + } + + const parameters: ShareTrackedExpenseParams = { + ...transactionParams, + policyID, + moneyRequestPreviewReportActionID, + moneyRequestReportID, + moneyRequestCreatedReportActionID, + actionableWhisperReportActionID, + modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID, + reportPreviewReportActionID, + policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, + policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, + adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, + adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, + engagementChoice: createdWorkspaceParams?.engagementChoice, + guidedSetupData: createdWorkspaceParams?.guidedSetupData, + policyName: createdWorkspaceParams?.policyName, + description: transactionParams.comment, + customUnitID: createdWorkspaceParams?.customUnitID, + customUnitRateID: createdWorkspaceParams?.customUnitRateID ?? transactionParams.customUnitRateID, + attendees: transactionParams.attendees ? JSON.stringify(transactionParams.attendees) : undefined, + accountantEmail, + }; + + API.write(WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, parameters, onyxData); +} + +/** + * Track an expense + */ +function trackExpense(params: CreateTrackExpenseParams) { + const { + report, + action, + isDraftPolicy, + participantParams, + policyParams: policyData = {}, + existingTransaction, + transactionParams: transactionData, + accountantParams, + shouldHandleNavigation = true, + shouldPlaySound = true, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam, + currentUserEmailParam, + introSelected, + activePolicyID, + quickAction, + recentWaypoints = [], + betas, + draftTransactionIDs = [], + isSelfTourViewed, + } = params; + const {participant, payeeAccountID, payeeEmail} = participantParams; + const {policy, policyCategories, policyTagList} = policyData; + const parsedComment = getParsedComment(transactionData.comment ?? ''); + transactionData.comment = parsedComment; + const { + amount, + currency, + created = '', + merchant = '', + comment = '', + distance, + receipt, + category, + tag, + taxCode = '', + taxAmount = 0, + billable, + reimbursable, + gpsPoint, + validWaypoints, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + customUnitRateID, + attendees, + odometerStart, + odometerEnd, + isFromGlobalCreate, + gpsCoordinates, + } = transactionData; + const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); + const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; + const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; + const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseIOUUtils(action); + + // Pass an open receipt so the distance expense will show a map with the route optimistically + const trackedReceipt = validWaypoints ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN, name: 'receipt-generic.png'} : receipt; + const sanitizedWaypoints = validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined; + + const retryParams: CreateTrackExpenseParams = { + ...params, + report, + isDraftPolicy, + action, + participantParams: { + participant, + payeeAccountID, + payeeEmail, + }, + transactionParams: { + amount, + currency, + created, + merchant, + comment, + distance, + receipt: undefined, + category, + tag, + taxCode, + taxAmount, + billable, + reimbursable, + validWaypoints, + gpsPoint, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + customUnitRateID, + }, + quickAction, + isSelfTourViewed, + }; + + const { + createdWorkspaceParams, + iouReport, + chatReport, + transaction, + iouAction, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewAction, + transactionThreadReportID, + createdReportActionIDForThread, + actionableWhisperReportActionIDParam, + optimisticReportID, + optimisticReportActionID, + onyxData: trackExpenseInformationOnyxData, + } = getTrackExpenseInformation({ + parentChatReport: currentChatReport, + moneyRequestReportID, + existingTransaction, + existingTransactionID: + isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && isMoneyRequestAction(linkedTrackedExpenseReportAction) + ? getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID + : undefined, + participantParams: { + participant, + payeeAccountID, + payeeEmail, + }, + transactionParams: { + comment, + amount, + distance, + currency, + created, + merchant, + receipt: trackedReceipt, + category, + tag, + taxCode, + taxAmount, + billable, + reimbursable, + linkedTrackedExpenseReportAction, + attendees, + odometerStart, + odometerEnd, + gpsCoordinates, + }, + policyParams: { + policy, + policyCategories, + policyTagList, + }, + retryParams, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam, + currentUserEmailParam, + introSelected, + activePolicyID, + quickAction, + betas, + isSelfTourViewed, + }) ?? {}; + const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport?.reportID; + const onyxData: TrackedExpenseParams['onyxData'] = trackExpenseInformationOnyxData; + + const recentServerValidatedWaypoints = recentWaypoints.filter((item) => !item.pendingAction); + onyxData?.failureData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.NVP_RECENT_WAYPOINTS}`, + value: recentServerValidatedWaypoints, + }); + + const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); + + const isDistanceRequest = + isMapDistanceRequest(transaction) || isManualDistanceRequestTransactionUtils(transaction) || isOdometerDistanceRequestTransactionUtils(transaction) || isGPSDistanceRequest; + + if (isDistanceRequest) { + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + onyxData?.optimisticData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE, + value: transaction?.iouRequestType, + }); + } + + const mileageRate = isCustomUnitRateIDForP2P(transaction) ? undefined : customUnitRateID; + if (shouldPlaySound) { + playSound(SOUNDS.DONE); + } + + switch (action) { + case CONST.IOU.ACTION.CATEGORIZE: { + if (!linkedTrackedExpenseReportAction || !linkedTrackedExpenseReportID) { + return; + } + const transactionParams: TrackedExpenseTransactionParams = { + transactionID: transaction?.transactionID, + amount, + currency, + comment, + distance, + merchant, + created, + taxCode, + taxAmount, + category, + tag, + billable, + reimbursable, + receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, + waypoints: sanitizedWaypoints, + customUnitRateID: mileageRate, + attendees, + }; + const policyParams: TrackedExpensePolicyParams = { + policyID: chatReport?.policyID, + policy, + isDraftPolicy, + }; + const reportInformation: TrackedExpenseReportInformation = { + moneyRequestPreviewReportActionID: iouAction?.reportActionID, + moneyRequestReportID: iouReport?.reportID, + moneyRequestCreatedReportActionID: createdIOUReportActionID, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + reportPreviewReportActionID: reportPreviewAction?.reportActionID, + chatReportID: chatReport?.reportID, + isLinkedTrackedExpenseReportArchived: transactionData.isLinkedTrackedExpenseReportArchived, + }; + const trackedExpenseParams: TrackedExpenseParams = { + onyxData, + reportInformation, + transactionParams, + policyParams, + createdWorkspaceParams, + }; + + categorizeTrackedExpense(trackedExpenseParams); + break; + } + case CONST.IOU.ACTION.SHARE: { + if (!linkedTrackedExpenseReportAction || !linkedTrackedExpenseReportID) { + return; + } + const transactionParams: TrackedExpenseTransactionParams = { + transactionID: transaction?.transactionID, + amount, + currency, + comment, + distance, + merchant, + created, + taxCode: taxCode ?? '', + taxAmount: taxAmount ?? 0, + category, + tag, + billable, + reimbursable, + receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, + waypoints: sanitizedWaypoints, + customUnitRateID: mileageRate, + attendees, + }; + const policyParams: TrackedExpensePolicyParams = { + policyID: chatReport?.policyID, + policy, + }; + const reportInformation: TrackedExpenseReportInformation = { + moneyRequestPreviewReportActionID: iouAction?.reportActionID, + moneyRequestReportID: iouReport?.reportID, + moneyRequestCreatedReportActionID: createdIOUReportActionID, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID, + reportPreviewReportActionID: reportPreviewAction?.reportActionID, + chatReportID: chatReport?.reportID, + isLinkedTrackedExpenseReportArchived: transactionData.isLinkedTrackedExpenseReportArchived, + }; + const trackedExpenseParams: TrackedExpenseParams = { + onyxData, + reportInformation, + transactionParams, + policyParams, + createdWorkspaceParams, + accountantParams, + }; + shareTrackedExpense(trackedExpenseParams); + break; + } + default: { + if (isGPSDistanceRequest) { + onyxData?.optimisticData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.GPS_DRAFT_DETAILS, + value: null, + }); + } + + const parameters: TrackExpenseParams = { + amount, + attendees: attendees ? JSON.stringify(attendees) : undefined, + currency, + comment, + distance: distance !== undefined ? roundToTwoDecimalPlaces(distance) : undefined, + created, + merchant, + iouReportID: iouReport?.reportID, + // If we are passing an optimisticReportID then we are creating a new chat (selfDM) and we don't have an *existing* chatReportID + chatReportID: optimisticReportID ? undefined : chatReport?.reportID, + transactionID: transaction?.transactionID, + reportActionID: iouAction?.reportActionID, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewReportActionID: reportPreviewAction?.reportActionID, + optimisticReportID, + optimisticReportActionID, + // Tracked expenses in the CREATE flow are unreported and not tied to a policy + policyID: undefined, + receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, + receiptState: trackedReceipt?.state, + reimbursable, + category, + tag, + taxCode, + taxAmount, + taxPolicyID: policy?.id, + billable, + // This needs to be a string of JSON because of limitations with the fetch() API and nested objects + receiptGpsPoints: gpsPoint ? JSON.stringify(gpsPoint) : undefined, + transactionThreadReportID, + createdReportActionIDForThread, + waypoints: sanitizedWaypoints, + customUnitRateID, + description: parsedComment, + gpsCoordinates, + isDistance: + isGPSDistanceRequest || + isMapDistanceRequest(transaction) || + isManualDistanceRequestTransactionUtils(transaction) || + isOdometerDistanceRequestTransactionUtils(transaction), + odometerStart, + odometerEnd, + }; + if (actionableWhisperReportActionIDParam) { + parameters.actionableWhisperReportActionID = actionableWhisperReportActionIDParam; + } + + API.write(WRITE_COMMANDS.TRACK_EXPENSE, parameters, onyxData); + } + } + + if (shouldHandleNavigation) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => removeDraftTransactionsByIDs(draftTransactionIDs)); + } + + if (!params.isRetry) { + handleNavigateAfterExpenseCreate({ + activeReportID, + transactionID: transaction?.transactionID, + isFromGlobalCreate, + shouldHandleNavigation, + }); + } + + notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam); +} + +/** + * Calculate the URL to navigate to after a track expense deletion + * @param chatReportID - The ID of the chat report containing the track expense + * @param transactionID - The ID of the track expense being deleted + * @param reportAction - The report action associated with the track expense + * @param isSingleTransactionView - Whether we're in single transaction view + * @returns The URL to navigate to + */ +function getNavigationUrlAfterTrackExpenseDelete( + chatReportID: string | undefined, + chatReport: OnyxEntry | undefined, + transactionID: string | undefined, + reportAction: OnyxTypes.ReportAction, + iouReport: OnyxEntry, + chatIOUReport: OnyxEntry, + isChatReportArchived: boolean | undefined, + isSingleTransactionView = false, +): Route | undefined { + if (!chatReportID || !transactionID) { + return undefined; + } + + // If not a self DM, handle it as a regular money request + if (!isSelfDM(chatReport)) { + return getNavigationUrlOnMoneyRequestDelete(transactionID, reportAction, iouReport, chatIOUReport, isChatReportArchived, isSingleTransactionView); + } + + // Only navigate if in single transaction view and the thread will be deleted + if (isSingleTransactionView && chatReport?.reportID) { + // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. + return ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID); + } + + return undefined; +} + +function deleteTrackExpense({ + chatReportID, + chatReport, + transactionID, + reportAction, + iouReport, + chatIOUReport, + transactions, + violations, + isSingleTransactionView = false, + isChatReportArchived, + isChatIOUReportArchived, + allTransactionViolationsParam, + currentUserAccountID, +}: DeleteTrackExpenseParams) { + if (!chatReportID || !transactionID) { + return; + } + + const urlToNavigateBack = getNavigationUrlAfterTrackExpenseDelete( + chatReportID, + chatReport, + transactionID, + reportAction, + iouReport, + chatIOUReport, + isSingleTransactionView, + isChatIOUReportArchived, + ); + + // STEP 1: Get all collections we're updating + if (!isSelfDM(chatReport)) { + deleteMoneyRequest({ + transactionID, + reportAction, + transactions, + violations, + iouReport, + chatReport: chatIOUReport, + isChatIOUReportArchived, + isSingleTransactionView, + allTransactionViolationsParam, + currentUserAccountID, + }); + return urlToNavigateBack; + } + + const whisperAction = getTrackExpenseActionableWhisper(transactionID, chatReportID); + const actionableWhisperReportActionID = whisperAction?.reportActionID; + const {parameters, optimisticData, successData, failureData} = getDeleteTrackExpenseInformation( + chatReport, + transactionID, + reportAction, + isChatReportArchived, + undefined, + undefined, + actionableWhisperReportActionID, + CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING, + false, + ); + + // STEP 6: Make the API request + API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); + clearPdfByOnyxKey(transactionID); + + // STEP 7: Navigate the user depending on which page they are on and which resources were deleted + return urlToNavigateBack; +} + +export { + addTrackedExpenseToPolicy, + buildOnyxDataForTrackExpense, + categorizeTrackedExpense, + convertBulkTrackedExpensesToIOU, + convertTrackedExpenseToRequest, + deleteTrackExpense, + getDeleteTrackExpenseInformation, + getNavigationUrlAfterTrackExpenseDelete, + getTrackExpenseInformation, + shareTrackedExpense, + trackExpense, +}; + +export type {ConvertTrackedExpenseToRequestParams, CreateTrackExpenseParams, DeleteTrackExpenseParams, TrackExpenseInformation}; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index fa2a0e485c441..bcd283784d5b3 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -16,9 +16,7 @@ import type { AddReportApproverParams, ApproveMoneyRequestParams, AssignReportToMeParams, - CategorizeTrackedExpenseParams as CategorizeTrackedExpenseApiParams, CreateDistanceRequestParams, - CreateWorkspaceParams, DeleteMoneyRequestParams, DetachReceiptParams, MarkTransactionViolationAsResolvedParams, @@ -30,9 +28,7 @@ import type { RequestMoneyParams, RetractReportParams, SetNameValuePairParams, - ShareTrackedExpenseParams, SubmitReportParams, - TrackExpenseParams, UnapproveExpenseReportParams, UpdateMoneyRequestParams, } from '@libs/API/parameters'; @@ -44,7 +40,6 @@ import {getMicroSecondOnyxErrorObject, getMicroSecondOnyxErrorWithTranslationKey import {readFileAsync} from '@libs/fileDownload/FileUtils'; import type {MinimalTransaction} from '@libs/Formula'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import GoogleTagManager from '@libs/GoogleTagManager'; import {getGPSRoutes, getGPSWaypoints} from '@libs/GPSDraftDetailsUtils'; import { calculateAmount as calculateIOUAmount, @@ -56,7 +51,6 @@ import { import isFileUploadable from '@libs/isFileUploadable'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import * as Localize from '@libs/Localize'; -import Log from '@libs/Log'; import isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP'; import isReportOpenInSuperWideRHP from '@libs/Navigation/helpers/isReportOpenInSuperWideRHP'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; @@ -68,19 +62,16 @@ import {buildNextStepNew, buildOptimisticNextStep} from '@libs/NextStepUtils'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import * as NumberUtils from '@libs/NumberUtils'; import {getManagerMcTestParticipant, getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; -import Parser from '@libs/Parser'; import {getCustomUnitID} from '@libs/PerDiemRequestUtils'; import {getAccountIDsByLogins, getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import { arePaymentsEnabled, getDistanceRateCustomUnit, - getMemberAccountIDsForWorkspace, getPolicy, getSubmitToAccountID, hasDependentTags, hasDynamicExternalWorkflow, - isControlPolicy, isDelayedSubmissionEnabled, isPaidGroupPolicy, isPolicyAdmin, @@ -92,13 +83,10 @@ import { getLastVisibleAction, getLastVisibleMessage, getOriginalMessage, - getReportAction, getReportActionHtml, getReportActionMessage, getReportActionText, - getTrackExpenseActionableWhisper, hasPendingDEWApprove, - isActionableTrackExpense, isCreatedAction, isDeletedAction, isMoneyRequestAction, @@ -106,7 +94,6 @@ import { } from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; import { - buildOptimisticActionableTrackExpenseWhisper, buildOptimisticAddCommentReportAction, buildOptimisticApprovedReportAction, buildOptimisticCancelPaymentReportAction, @@ -127,12 +114,10 @@ import { buildOptimisticReopenedReportAction, buildOptimisticReportPreview, buildOptimisticRetractedReportAction, - buildOptimisticSelfDMReport, buildOptimisticSubmittedReportAction, buildOptimisticUnapprovedReportAction, canBeAutoReimbursed, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - findSelfDMReportID, generateReportID, getAllHeldTransactions as getAllHeldTransactionsReportUtils, getApprovalChain, @@ -145,7 +130,6 @@ import { getPersonalDetailsForAccountID, getReportNotificationPreference, getReportOrDraftReport, - getReportRecipientAccountIDs, getReportTransactions, getTransactionDetails, hasHeldExpenses as hasHeldExpensesReportUtils, @@ -155,7 +139,6 @@ import { isArchivedReport, isClosedReport as isClosedReportUtil, isDeprecatedGroupDM, - isDraftReport, isExpenseReport, isGroupChat, isIndividualInvoiceRoom, @@ -203,20 +186,15 @@ import { getCurrency, getDistanceInMeters, getMerchant, - getRateID, getUpdatedTransaction, - getWaypoints, hasAnyTransactionWithoutRTERViolation, hasDuplicateTransactions, hasSmartScanFailedWithMissingFields, hasSubmissionBlockingViolations, - isCustomUnitRateIDForP2P, isDistanceRequest as isDistanceRequestTransactionUtils, isDuplicate, isFetchingWaypointsFromServer, - isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, - isMapDistanceRequest, isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, isOnHold, isPending, @@ -230,12 +208,11 @@ import { import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import {clearByKey as clearPdfByOnyxKey} from '@userActions/CachedPDFPaths'; import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors'; -import {buildAddMembersToWorkspaceOnyxData, buildUpdateWorkspaceMembersRoleOnyxData} from '@userActions/Policy/Member'; import {buildPolicyData, generatePolicyID} from '@userActions/Policy/Policy'; import type {BuildPolicyDataKeys} from '@userActions/Policy/Policy'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import type {GuidedSetupData} from '@userActions/Report'; -import {buildInviteToRoomOnyxData, completeOnboarding, notifyNewAction, optimisticReportLastData} from '@userActions/Report'; +import {completeOnboarding, notifyNewAction, optimisticReportLastData} from '@userActions/Report'; import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeRecentWaypoints} from '@userActions/Transaction'; import {removeDraftTransaction, removeDraftTransactions, removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; @@ -253,7 +230,6 @@ import type {ErrorFields, Errors, PendingAction, PendingFields} from '@src/types import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {Unit} from '@src/types/onyx/Policy'; -import type {QuickActionName} from '@src/types/onyx/QuickAction'; import type RecentlyUsedTags from '@src/types/onyx/RecentlyUsedTags'; import type {ReportNextStep} from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; @@ -261,28 +237,17 @@ import type {OnyxData} from '@src/types/onyx/Request'; import type {Comment, Receipt, ReceiptSource, Routes, SplitShares, TransactionChanges, TransactionCustomUnit, WaypointCollection} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +// eslint-disable-next-line import/no-cycle +import type {BaseTransactionParams} from './PerDiem'; +// eslint-disable-next-line import/no-cycle +import {convertTrackedExpenseToRequest} from './TrackExpense'; +// eslint-disable-next-line import/no-cycle +import type {CreateTrackExpenseParams} from './TrackExpense'; type IOURequestType = ValueOf; type OneOnOneIOUReport = OnyxTypes.Report | undefined | null; -type BaseTransactionParams = { - amount: number; - modifiedAmount?: number; - currency: string; - created: string; - merchant: string; - comment: string; - category?: string; - tag?: string; - taxCode?: string; - taxAmount?: number; - billable?: boolean; - reimbursable?: boolean; - customUnitRateID?: string; - isFromGlobalCreate?: boolean; -}; - type InitMoneyRequestParams = { reportID: string; policy?: OnyxEntry; @@ -362,66 +327,6 @@ type RejectMoneyRequestData = { urlToNavigateBack: Route | undefined; }; -type TrackExpenseInformation = { - createdWorkspaceParams?: CreateWorkspaceParams; - iouReport?: OnyxTypes.Report; - chatReport: OnyxTypes.Report; - transaction: OnyxTypes.Transaction; - iouAction: OptimisticIOUReportAction; - createdChatReportActionID?: string; - createdIOUReportActionID?: string; - reportPreviewAction?: OnyxTypes.ReportAction; - transactionThreadReportID: string; - createdReportActionIDForThread: string | undefined; - actionableWhisperReportActionIDParam?: string; - optimisticReportID: string | undefined; - optimisticReportActionID: string | undefined; - onyxData: OnyxData; -}; - -type TrackedExpenseTransactionParams = Omit & { - waypoints?: string; - distance?: number; - transactionID: string | undefined; - receipt?: Receipt; - taxCode: string; - taxAmount: number; - attendees?: Attendee[]; -}; - -type TrackedExpensePolicyParams = { - policy: OnyxEntry; - policyID: string | undefined; - isDraftPolicy?: boolean; -}; -type TrackedExpenseReportInformation = { - moneyRequestPreviewReportActionID: string | undefined; - moneyRequestReportID: string | undefined; - moneyRequestCreatedReportActionID: string | undefined; - actionableWhisperReportActionID: string | undefined; - linkedTrackedExpenseReportAction: OnyxTypes.ReportAction; - linkedTrackedExpenseReportID: string; - transactionThreadReportID: string | undefined; - reportPreviewReportActionID: string | undefined; - chatReportID: string | undefined; - isLinkedTrackedExpenseReportArchived: boolean | undefined; -}; -type TrackedExpenseParams = { - onyxData?: OnyxData< - | BuildOnyxDataForTrackExpenseKeys - | BuildPolicyDataKeys - | typeof ONYXKEYS.NVP_RECENT_WAYPOINTS - | typeof ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE - | typeof ONYXKEYS.GPS_DRAFT_DETAILS - | typeof ONYXKEYS.SELF_DM_REPORT_ID - >; - reportInformation: TrackedExpenseReportInformation; - transactionParams: TrackedExpenseTransactionParams; - policyParams: TrackedExpensePolicyParams; - createdWorkspaceParams?: CreateWorkspaceParams; - accountantParams?: TrackExpenseAccountantParams; -}; - type SplitData = { chatReportID: string; transactionID: string; @@ -668,108 +573,6 @@ type CreateSplitsAndOnyxDataParams = { personalDetails: OnyxEntry; }; -type TrackExpenseTransactionParams = { - amount: number; - currency: string; - created: string | undefined; - merchant?: string; - comment?: string; - distance?: number; - receipt?: Receipt; - category?: string; - tag?: string; - taxCode?: string; - taxAmount?: number; - billable?: boolean; - reimbursable?: boolean; - validWaypoints?: WaypointCollection; - gpsPoint?: GPSPoint; - actionableWhisperReportActionID?: string; - linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction; - linkedTrackedExpenseReportID?: string; - customUnitRateID?: string; - attendees?: Attendee[]; - isLinkedTrackedExpenseReportArchived?: boolean; - odometerStart?: number; - odometerEnd?: number; - isFromGlobalCreate?: boolean; - gpsCoordinates?: string; -}; - -type TrackExpenseAccountantParams = { - accountant?: Accountant; -}; - -type CreateTrackExpenseParams = { - report: OnyxEntry; - isDraftPolicy: boolean; - action?: IOUAction; - participantParams: RequestMoneyParticipantParams; - policyParams?: BasePolicyParams; - transactionParams: TrackExpenseTransactionParams; - existingTransaction?: OnyxEntry; - accountantParams?: TrackExpenseAccountantParams; - isRetry?: boolean; - shouldPlaySound?: boolean; - shouldHandleNavigation?: boolean; - isASAPSubmitBetaEnabled: boolean; - currentUserAccountIDParam: number; - currentUserEmailParam: string; - introSelected: OnyxEntry; - activePolicyID: string | undefined; - quickAction: OnyxEntry; - recentWaypoints: OnyxEntry; - betas: OnyxEntry; - draftTransactionIDs: string[] | undefined; - isSelfTourViewed: boolean; -}; - -type GetTrackExpenseInformationTransactionParams = { - comment: string; - amount: number; - currency: string; - created: string; - merchant: string; - receipt: OnyxEntry; - category?: string; - tag?: string; - taxCode?: string; - taxAmount?: number; - billable?: boolean; - reimbursable?: boolean; - linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction; - attendees?: Attendee[]; - distance?: number; - odometerStart?: number; - odometerEnd?: number; - gpsCoordinates?: string; -}; - -type GetTrackExpenseInformationParticipantParams = { - payeeEmail?: string; - payeeAccountID?: number; - participant: Participant; -}; - -type GetTrackExpenseInformationParams = { - parentChatReport: OnyxEntry; - moneyRequestReportID?: string; - existingTransaction?: OnyxEntry; - existingTransactionID?: string; - participantParams: GetTrackExpenseInformationParticipantParams; - policyParams: BasePolicyParams; - transactionParams: GetTrackExpenseInformationTransactionParams; - retryParams?: StartSplitBilActionParams | CreateTrackExpenseParams | RequestMoneyInformation | ReplaceReceipt; - isASAPSubmitBetaEnabled: boolean; - currentUserAccountIDParam: number; - currentUserEmailParam: string; - introSelected: OnyxEntry; - activePolicyID: string | undefined; - quickAction: OnyxEntry; - betas: OnyxEntry; - isSelfTourViewed: boolean; -}; - let allPersonalDetails: OnyxTypes.PersonalDetailsList = {}; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, @@ -821,22 +624,6 @@ type GetSearchOnyxUpdateParams = { transactionThreadReportID: string | undefined; }; -type DeleteTrackExpenseParams = { - chatReportID: string | undefined; - chatReport: OnyxEntry | undefined; - transactionID: string | undefined; - reportAction: OnyxTypes.ReportAction; - iouReport: OnyxEntry; - chatIOUReport: OnyxEntry; - transactions: OnyxCollection; - violations: OnyxCollection; - isSingleTransactionView: boolean | undefined; - isChatReportArchived: boolean | undefined; - isChatIOUReportArchived: boolean | undefined; - allTransactionViolationsParam: OnyxCollection; - currentUserAccountID: number; -}; - type DeleteMoneyRequestFunctionParams = { transactionID: string | undefined; reportAction: OnyxTypes.ReportAction; @@ -1766,39 +1553,6 @@ function getReceiptError( ); } -/** Helper function to get optimistic fields violations onyx data */ -function getFieldViolationsOnyxData(iouReport: OnyxTypes.Report): OnyxData { - const missingFields: OnyxTypes.ReportFieldsViolations = {}; - const excludedFields = Object.values(CONST.REPORT_VIOLATIONS_EXCLUDED_FIELDS) as string[]; - - for (const field of Object.values(iouReport.fieldList ?? {})) { - if (excludedFields.includes(field.fieldID) || !!field.value || !!field.defaultValue) { - continue; - } - // in case of missing field violation the empty object is indicator. - missingFields[field.fieldID] = {}; - } - - return { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${iouReport.reportID}`, - value: { - fieldRequired: missingFields, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_VIOLATIONS}${iouReport.reportID}`, - value: null, - }, - ], - }; -} - type BuildOnyxDataForTestDriveIOUParams = { transaction: OnyxTypes.Transaction; iouOptimisticParams: MoneyRequestOptimisticParams['iou']; @@ -2565,1609 +2319,1168 @@ function buildOnyxDataForMoneyRequest(moneyRequestParams: BuildOnyxDataForMoneyR return onyxData; } -type BuildOnyxDataForTrackExpenseParams = { - chat: {report: OnyxInputValue; previewAction: OnyxInputValue}; - iou: {report: OnyxInputValue; createdAction: OptimisticCreatedReportAction; action: OptimisticIOUReportAction}; - transactionParams: {transaction: OnyxTypes.Transaction; threadReport: OptimisticChatReport | null; threadCreatedReportAction: OptimisticCreatedReportAction | null}; - policyParams: {policy?: OnyxInputValue; tagList?: OnyxInputValue; categories?: OnyxInputValue}; - shouldCreateNewMoneyRequestReport: boolean; - existingTransactionThreadReportID?: string; - actionableTrackExpenseWhisper?: OnyxInputValue; - retryParams?: StartSplitBilActionParams | CreateTrackExpenseParams | RequestMoneyInformation | ReplaceReceipt; - participant?: Participant; - isASAPSubmitBetaEnabled: boolean; - quickAction: OnyxEntry; -}; - -type BuildOnyxDataForTrackExpenseKeys = - | typeof ONYXKEYS.COLLECTION.REPORT - | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - | typeof ONYXKEYS.COLLECTION.REPORT_METADATA - | typeof ONYXKEYS.COLLECTION.TRANSACTION - | typeof ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE - | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - | typeof ONYXKEYS.COLLECTION.REPORT_VIOLATIONS; - -/** Builds the Onyx data for track expense */ -function buildOnyxDataForTrackExpense({ - chat, - iou, - transactionParams, - policyParams = {}, - shouldCreateNewMoneyRequestReport, - existingTransactionThreadReportID, - actionableTrackExpenseWhisper, - retryParams, - participant, - isASAPSubmitBetaEnabled, - quickAction, -}: BuildOnyxDataForTrackExpenseParams): OnyxData { - const {report: chatReport, previewAction: reportPreviewAction} = chat; - const {report: iouReport, createdAction: iouCreatedAction, action: iouAction} = iou; - const {transaction, threadReport: transactionThreadReport, threadCreatedReportAction: transactionThreadCreatedReportAction} = transactionParams; - const {policy, tagList: policyTagList, categories: policyCategories} = policyParams; - - const isScanRequest = isScanRequestTransactionUtils(transaction); - const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); - const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); - - const onyxData: OnyxData = { - optimisticData: [], - successData: [], - failureData: [], - }; - - const isSelfDMReport = isSelfDM(chatReport); - let newQuickAction: QuickActionName = isSelfDMReport ? CONST.QUICK_ACTIONS.TRACK_MANUAL : CONST.QUICK_ACTIONS.REQUEST_MANUAL; - if (isScanRequest) { - newQuickAction = isSelfDMReport ? CONST.QUICK_ACTIONS.TRACK_SCAN : CONST.QUICK_ACTIONS.REQUEST_SCAN; - } else if (isDistanceRequest) { - newQuickAction = isSelfDMReport ? CONST.QUICK_ACTIONS.TRACK_DISTANCE : CONST.QUICK_ACTIONS.REQUEST_DISTANCE; +/** + * Recalculates the report name using the policy's custom title formula. + * This is needed when report totals change (e.g., adding expenses or changing reimbursable status) + * to ensure the report title reflects the updated values like {report:reimbursable}. + */ +function recalculateOptimisticReportName(iouReport: OnyxTypes.Report, policy: OnyxEntry): string | undefined { + if (!policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]) { + return undefined; } - const existingTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTransactionThreadReportID}`] ?? null; - - if (chatReport) { - onyxData.optimisticData?.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, - value: { - ...chatReport, - lastMessageText: getReportActionText(iouAction), - lastMessageHtml: getReportActionHtml(iouAction), - lastReadTime: DateUtils.getDBTime(), - // do not update iouReportID if auto submit beta is enabled and it is a scan request - iouReportID: isASAPSubmitBetaEnabled && isScanRequest ? null : iouReport?.reportID, - lastVisibleActionCreated: shouldCreateNewMoneyRequestReport ? reportPreviewAction?.created : chatReport.lastVisibleActionCreated, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, - value: { - action: newQuickAction, - chatReportID: chatReport.reportID, - isFirstQuickAction: isEmptyObject(quickAction), - }, - }, - ); + const titleFormula = policy.fieldList[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]?.defaultValue ?? ''; + if (!titleFormula) { + return undefined; + } + return populateOptimisticReportFormula(titleFormula, iouReport as Parameters[1], policy); +} - if (actionableTrackExpenseWhisper && !iouReport) { - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - [actionableTrackExpenseWhisper.reportActionID]: actionableTrackExpenseWhisper, - }, - }); - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, - value: { - lastReadTime: actionableTrackExpenseWhisper.created, - lastVisibleActionCreated: actionableTrackExpenseWhisper.created, - lastMessageText: CONST.ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE, - }, - }); - onyxData.successData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - [actionableTrackExpenseWhisper.reportActionID]: {pendingAction: null, errors: null}, - }, - }); - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: {[actionableTrackExpenseWhisper.reportActionID]: null}, - }); - } +function maybeUpdateReportNameForFormulaTitle(iouReport: OnyxTypes.Report, policy: OnyxEntry): OnyxTypes.Report { + const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${iouReport.reportID}`]; + const titleField = reportNameValuePairs?.expensify_text_title; + if (titleField?.type !== CONST.REPORT_FIELD_TYPES.FORMULA) { + return iouReport; } - if (iouReport) { - onyxData.optimisticData?.push( - { - onyxMethod: shouldCreateNewMoneyRequestReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, - value: { - ...iouReport, - lastMessageText: getReportActionText(iouAction), - lastMessageHtml: getReportActionHtml(iouAction), - pendingFields: { - ...(shouldCreateNewMoneyRequestReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), - }, - }, - }, - shouldCreateNewMoneyRequestReport - ? { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, - value: { - [iouCreatedAction.reportActionID]: iouCreatedAction as OnyxTypes.ReportAction, - [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, - }, - } - : { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, - value: { - [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - ...(reportPreviewAction && {[reportPreviewAction.reportActionID]: reportPreviewAction}), - }, - }, - ); - if (shouldCreateNewMoneyRequestReport) { - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReport.reportID}`, - value: { - isOptimisticReport: true, - }, - }); - } - } else { - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, - }, - }); + const updatedReportName = recalculateOptimisticReportName(iouReport, policy); + if (!updatedReportName) { + return iouReport; } - onyxData.optimisticData?.push( - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: transaction, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, - value: { - ...transactionThreadReport, - pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionThreadReport?.reportID}`, - value: { - isOptimisticReport: true, - }, - }, - ); + return {...iouReport, reportName: updatedReportName}; +} - if (!isEmptyObject(transactionThreadCreatedReportAction)) { - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, - value: { - [transactionThreadCreatedReportAction.reportActionID]: transactionThreadCreatedReportAction, - }, - }); - } +/** + * Gathers all the data needed to submit an expense. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then + * it creates optimistic versions of them and uses those instead + */ +function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInformationParams): MoneyRequestInformation { + const { + parentChatReport, + existingIOUReport, + transactionParams, + participantParams, + policyParams = {}, + existingTransaction, + existingTransactionID, + moneyRequestReportID = '', + retryParams, + newReportTotal, + newNonReimbursableTotal, + testDriveCommentReportActionID, + optimisticChatReportID, + optimisticCreatedReportActionID, + optimisticIOUReportID, + optimisticReportPreviewActionID, + shouldGenerateTransactionThreadReport = true, + isSplitExpense, + action, + currentReportActionID, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam, + currentUserEmailParam, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + personalDetails, + betas, + } = moneyRequestInformation; + const {payeeAccountID = userAccountID, payeeEmail = currentUserEmail, participant} = participantParams; + const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; + const { + attendees, + amount, + distance, + modifiedAmount, + comment = '', + currency, + source = '', + created, + merchant, + receipt, + category, + tag, + taxCode, + taxAmount, + billable, + reimbursable = true, + linkedTrackedExpenseReportAction, + pendingAction, + pendingFields = {}, + type, + count, + rate, + unit, + customUnit, + waypoints, + odometerStart, + odometerEnd, + } = transactionParams; - if (iouReport) { - onyxData.successData?.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: { - pendingFields: null, - errorFields: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, - value: { - ...(shouldCreateNewMoneyRequestReport - ? { - [iouCreatedAction.reportActionID]: { - pendingAction: null, - errors: null, - }, - } - : {}), - [iouAction.reportActionID]: { - pendingAction: null, - errors: null, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - ...(reportPreviewAction && {[reportPreviewAction.reportActionID]: {pendingAction: null}}), - }, - }, - ); - if (shouldCreateNewMoneyRequestReport) { - onyxData.successData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReport.reportID}`, - value: { - isOptimisticReport: false, - }, - }); + const payerEmail = addSMSDomainIfPhoneNumber(participant.login ?? ''); + const payerAccountID = Number(participant.accountID); + const isPolicyExpenseChat = participant.isPolicyExpenseChat; + + // STEP 1: Get existing chat report OR build a new optimistic one + let isNewChatReport = false; + let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null; + + // If the participant is not a policy expense chat, we need to ensure the chatReport matches the participant. + // This can happen when submit frequency is disabled and the user selects a different participant on the confirm page. + // We verify that the chatReport participants match the expected participants. If it's a workspace chat or + // the participants don't match, we'll find/create the correct 1:1 DM chat report. + // We also check if the chatReport itself is a Policy Expense Chat to avoid incorrectly validating Policy Expense Chats. + // We also skip validation for self-DM reports since they use accountID 0 for the participant (representing the report itself). + // We also skip validation for group chats and deprecated group DMs since they can have more than 2 participants. + if (chatReport && !isPolicyExpenseChat && !isPolicyExpenseChatReportUtil(chatReport) && !isSelfDM(chatReport) && !isGroupChat(chatReport) && !isDeprecatedGroupDM(chatReport)) { + const parentChatReportParticipants = Object.keys(chatReport.participants ?? {}).map(Number); + const expectedParticipants = [payerAccountID, payeeAccountID].sort(); + const sortedParentChatReportParticipants = parentChatReportParticipants.sort(); + + const participantsMatch = + expectedParticipants.length === sortedParentChatReportParticipants.length && expectedParticipants.every((id, index) => id === sortedParentChatReportParticipants.at(index)); + + if (!participantsMatch) { + chatReport = null; } - } else { - onyxData.successData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - [iouAction.reportActionID]: { - pendingAction: null, - errors: null, - }, - ...(reportPreviewAction && {[reportPreviewAction.reportActionID]: {pendingAction: null}}), - }, - }); } - onyxData.successData?.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, - value: { - pendingFields: null, - errorFields: null, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${transactionThreadReport?.reportID}`, - value: { - isOptimisticReport: false, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: { - pendingAction: null, - pendingFields: clearedPendingFields, - routes: null, - }, - }, - ); + // If this is a policyExpenseChat, the chatReport must exist and we can get it from Onyx. + // report is null if the flow is initiated from the global create menu. However, participant always stores the reportID if it exists, which is the case for policyExpenseChats + if (!chatReport && isPolicyExpenseChat) { + chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.reportID}`] ?? null; + } - if (!isEmptyObject(transactionThreadCreatedReportAction)) { - onyxData.successData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, - value: { - [transactionThreadCreatedReportAction.reportActionID]: { - pendingAction: null, - errors: null, - }, - }, + if (!chatReport) { + chatReport = getChatByParticipants([payerAccountID, payeeAccountID]) ?? null; + } + + // If we still don't have a report, it likely doesn't exist and we need to build an optimistic one + if (!chatReport) { + isNewChatReport = true; + chatReport = buildOptimisticChatReport({ + participantList: [payerAccountID, payeeAccountID], + optimisticReportID: optimisticChatReportID, }); } - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, - value: quickAction ?? null, + // STEP 2: Get the Expense/IOU report. If the existingIOUReport or moneyRequestReportID has been provided, we want to add the transaction to this specific report. + // If no such reportID has been provided, let's use the chatReport.iouReportID property. In case that is not present, build a new optimistic Expense/IOU report. + let iouReport: OnyxInputValue = null; + if (existingIOUReport) { + iouReport = existingIOUReport; + } else if (moneyRequestReportID) { + iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`] ?? null; + } else if (!allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`]?.errorFields?.createChat) { + iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; + } + + const isScanRequest = isScanRequestTransactionUtils({ + amount, + receipt, + ...(existingTransaction && { + iouRequestType: existingTransaction.iouRequestType, + }), }); - if (iouReport) { - onyxData.failureData?.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, - value: { - pendingFields: null, - errorFields: { - ...(shouldCreateNewMoneyRequestReport ? {createChat: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage')} : {}), - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, - value: { - ...(shouldCreateNewMoneyRequestReport - ? { - [iouCreatedAction.reportActionID]: { - errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, retryParams), - }, - [iouAction.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), - }, - } - : { - [iouAction.reportActionID]: { - errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, retryParams), - }, - }), - }, - }, - ); - } else { - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - [iouAction.reportActionID]: { - errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, retryParams), - }, - }, - }); - } + const shouldCreateNewMoneyRequestReport = isSplitExpense ? false : shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas, action); - onyxData.failureData?.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, - value: { - lastReadTime: chatReport?.lastReadTime, - lastMessageText: chatReport?.lastMessageText, - lastMessageHtml: chatReport?.lastMessageHtml, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, - value: { - pendingFields: null, - errorFields: existingTransactionThreadReport - ? null - : { - createChat: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: { - errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, undefined, CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE, retryParams), - pendingFields: clearedPendingFields, - }, - }, - ); + // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation + const optimisticTransactionID = existingTransactionID ?? NumberUtils.rand64(); + const optimisticReportID = optimisticIOUReportID ?? generateReportID(); - if (transactionThreadCreatedReportAction?.reportActionID) { - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, - value: { - [transactionThreadCreatedReportAction?.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), - }, - }, - }); - } + if (!iouReport || shouldCreateNewMoneyRequestReport) { + const nonReimbursableTotal = reimbursable ? 0 : amount; + const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticReportID, created, amount, currency, merchant); - const searchUpdate = getSearchOnyxUpdate({ - transaction, - participant, - transactionThreadReportID: transactionThreadReport?.reportID, - }); + iouReport = isPolicyExpenseChat + ? buildOptimisticExpenseReport({ + chatReportID: chatReport.reportID, + policyID: chatReport.policyID, + payeeAccountID, + total: amount, + currency, + nonReimbursableTotal, + optimisticIOUReportID: optimisticReportID, + reportTransactions, + betas, + }) + : buildOptimisticIOUReport(payeeAccountID, payerAccountID, amount, chatReport.reportID, currency, undefined, undefined, optimisticReportID); + } else if (isPolicyExpenseChat) { + iouReport = {...iouReport}; + // Because of the Expense reports are stored as negative values, we subtract the total from the amount + if (iouReport?.currency === currency) { + if (!Number.isNaN(iouReport.total) && iouReport.total !== undefined) { + // Use newReportTotal in scenarios where the total is based on more than just the current transaction, and we need to override it manually + if (newReportTotal) { + iouReport.total = newReportTotal; + } else { + iouReport.total -= amount; + } - if (searchUpdate) { - if (searchUpdate.optimisticData) { - onyxData.optimisticData?.push(...searchUpdate.optimisticData); - } - if (searchUpdate.successData) { - onyxData.successData?.push(...searchUpdate.successData); - } - } + if (!reimbursable) { + if (newNonReimbursableTotal !== undefined) { + iouReport.nonReimbursableTotal = newNonReimbursableTotal; + } else { + iouReport.nonReimbursableTotal = (iouReport.nonReimbursableTotal ?? 0) - amount; + } + } - // We don't need to compute violations unless we're on a paid policy - if (!policy || !isPaidGroupPolicy(policy) || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { - return onyxData; + iouReport = maybeUpdateReportNameForFormulaTitle(iouReport, policy); + } + if (typeof iouReport.unheldTotal === 'number') { + // Use newReportTotal in scenarios where the total is based on more than just the current transaction amount, and we need to override it manually + if (newReportTotal) { + iouReport.unheldTotal = newReportTotal; + } else { + iouReport.unheldTotal -= amount; + } + } + } + } else { + iouReport = updateIOUOwnerAndTotal(iouReport, payeeAccountID, amount, currency); } - const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( - transaction, - [], + // STEP 3: Build an optimistic transaction with the receipt + const isDistanceRequest = existingTransaction && isDistanceRequestTransactionUtils(existingTransaction); + const isManualDistanceRequest = existingTransaction && isManualDistanceRequestTransactionUtils(existingTransaction); + let optimisticTransaction = buildOptimisticTransaction({ + existingTransactionID: optimisticTransactionID, + existingTransaction, + originalTransactionID: transactionParams.originalTransactionID, policy, - policyTagList ?? {}, - policyCategories ?? {}, - hasDependentTags(policy, policyTagList ?? {}), - false, - ); + transactionParams: { + amount: isExpenseReport(iouReport) ? -amount : amount, + distance, + ...(modifiedAmount !== undefined && {modifiedAmount: isExpenseReport(iouReport) ? -modifiedAmount : modifiedAmount}), + currency, + reportID: iouReport.reportID, + comment, + attendees, + created, + merchant, + receipt, + category, + tag, + taxCode, + source, + taxAmount: isExpenseReport(iouReport) ? -(taxAmount ?? 0) : taxAmount, + billable, + pendingAction, + pendingFields: isDistanceRequest && !isManualDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, ...pendingFields} : pendingFields, + reimbursable: isPolicyExpenseChat ? reimbursable : true, + type, + count, + rate, + unit, + customUnit, + waypoints, + odometerStart, + odometerEnd, + }, + isDemoTransactionParam: isSelectedManagerMcTest(participant.login) || transactionParams.receipt?.isTestDriveReceipt, + }); - if (violationsOnyxData) { - onyxData.optimisticData?.push(violationsOnyxData); - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`, - value: [], - }); - } + iouReport.transactionCount = (iouReport.transactionCount ?? 0) + 1; + + const optimisticPolicyRecentlyUsedCategories = mergePolicyRecentlyUsedCategories(category, policyRecentlyUsedCategories); + const optimisticPolicyRecentlyUsedTags = buildOptimisticPolicyRecentlyUsedTags({ + // TODO: Replace getPolicyTagsData (https://github.com/Expensify/App/issues/72721) and getPolicyRecentlyUsedTagsData (https://github.com/Expensify/App/issues/71491) with useOnyx hook + // eslint-disable-next-line @typescript-eslint/no-deprecated + policyTags: getPolicyTagsData(iouReport.policyID), + policyRecentlyUsedTags, + transactionTags: tag, + }); + const optimisticPolicyRecentlyUsedCurrencies = mergePolicyRecentlyUsedCurrencies(currency, policyRecentlyUsedCurrencies); + + // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction + // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction + // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. + // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 + // to remind me to do this. + if (isDistanceRequest && existingTransaction) { + // For split expenses, exclude merchant from merge to preserve merchant from splitExpense + if (isSplitExpense) { + // Preserve merchant from transactionParams (splitExpense.merchant) before merge + const preservedMerchant = merchant || optimisticTransaction.merchant; + const {merchant: omittedMerchant, ...existingTransactionWithoutMerchant} = existingTransaction; + optimisticTransaction = fastMerge(existingTransactionWithoutMerchant, optimisticTransaction, false) as OnyxTypes.Transaction; - // Show field violations only for control policies - if (isControlPolicy(policy) && iouReport) { - const {optimisticData: fieldViolationsOptimisticData, failureData: fieldViolationsFailureData} = getFieldViolationsOnyxData(iouReport); - onyxData.optimisticData?.push(...(fieldViolationsOptimisticData ?? [])); - onyxData.failureData?.push(...(fieldViolationsFailureData ?? [])); + // Explicitly set merchant from splitExpense to ensure it's not overwritten + optimisticTransaction.merchant = preservedMerchant; + } else { + optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false); + } } - return onyxData; -} + if (isSplitExpense && existingTransaction) { + const {convertedAmount: originalConvertedAmount, ...existingTransactionWithoutConvertedAmount} = existingTransaction; + optimisticTransaction = fastMerge(existingTransactionWithoutConvertedAmount, optimisticTransaction, false); -function getDeleteTrackExpenseInformation( - chatReport: OnyxEntry, - transactionID: string | undefined, - reportAction: OnyxTypes.ReportAction, - isChatReportArchived: boolean | undefined, - shouldDeleteTransactionFromOnyx = true, - isMovingTransactionFromTrackExpense = false, - actionableWhisperReportActionID = '', - resolution = '', - shouldRemoveIOUTransaction = true, -) { - // STEP 1: Get all collections we're updating - const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; - const transactionThreadID = reportAction.childReportID; + // Calculate proportional convertedAmount for the split based on the original conversion rate + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- modifiedAmount can be empty string + const originalAmount = Number(existingTransaction.modifiedAmount) || existingTransaction.amount; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- modifiedAmount can be empty string + const splitAmount = Number(optimisticTransaction.modifiedAmount) || optimisticTransaction.amount; + if (originalConvertedAmount && originalAmount && splitAmount) { + optimisticTransaction.convertedAmount = Math.round((originalConvertedAmount * splitAmount) / originalAmount); + } + } - // STEP 2: Decide if we need to: - // 1. Delete the transactionThread - delete if we're not moving the transaction - // 2. Update the moneyRequestPreview to show [Deleted expense] - update if the transactionThread exists AND it isn't being deleted and we're not moving the transaction - const shouldDeleteTransactionThread = !isMovingTransactionFromTrackExpense && !!transactionThreadID; + // STEP 4: Build optimistic reportActions. We need: + // 1. CREATED action for the chatReport + // 2. CREATED action for the iouReport + // 3. IOU action for the iouReport + // 4. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread + // 5. REPORT_PREVIEW action for the chatReport + // Note: The CREATED action for the IOU report must be optimistically generated before the IOU action so there's no chance that it appears after the IOU action in the chat + const [optimisticCreatedActionForChat, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = + buildOptimisticMoneyRequestEntities({ + iouReport, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount, + currency, + comment, + payeeEmail, + participants: [participant], + transactionID: optimisticTransaction.transactionID, + paymentType: isSelectedManagerMcTest(participant.login) || transactionParams.receipt?.isTestDriveReceipt ? CONST.IOU.PAYMENT_TYPE.ELSEWHERE : undefined, + existingTransactionThreadReportID: linkedTrackedExpenseReportAction?.childReportID, + optimisticCreatedReportActionID, + linkedTrackedExpenseReportAction, + shouldGenerateTransactionThreadReport, + reportActionID: currentReportActionID, + }); - const shouldShowDeletedRequestMessage = !isMovingTransactionFromTrackExpense && !!transactionThreadID && !shouldDeleteTransactionThread; + let reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : getReportPreviewAction(chatReport.reportID, iouReport.reportID); - // STEP 3: Update the IOU reportAction. - const updatedReportAction = { - [reportAction.reportActionID]: { - pendingAction: shouldShowDeletedRequestMessage ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - previousMessage: reportAction.message, - message: [ - { - type: 'COMMENT', - html: '', - text: '', - isEdited: true, - isDeletedParentAction: shouldShowDeletedRequestMessage, - }, - ], - originalMessage: { - IOUTransactionID: shouldRemoveIOUTransaction ? null : transactionID, - }, - errors: undefined, - }, - ...(actionableWhisperReportActionID && {[actionableWhisperReportActionID]: {originalMessage: {resolution}}}), - } as OnyxTypes.ReportActions; - let canUserPerformWriteAction = true; - if (chatReport) { - canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(chatReport, isChatReportArchived); + if (reportPreviewAction) { + reportPreviewAction = updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); + } else { + reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction, undefined, optimisticReportPreviewActionID); + chatReport.lastVisibleActionCreated = reportPreviewAction.created; + + // Generated ReportPreview action is a parent report action of the iou report. + // We are setting the iou report's parentReportActionID to display subtitle correctly in IOU page when offline. + iouReport.parentReportActionID = reportPreviewAction.reportActionID; } - const lastVisibleAction = getLastVisibleAction(chatReport?.reportID, canUserPerformWriteAction, updatedReportAction); - const {lastMessageText = '', lastMessageHtml = ''} = getLastVisibleMessage(chatReport?.reportID, canUserPerformWriteAction, updatedReportAction); - // STEP 4: Build Onyx data - const optimisticData: Array< - OnyxUpdate - > = []; - - if (shouldDeleteTransactionFromOnyx && shouldRemoveIOUTransaction) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: null, - }); - } - if (!shouldRemoveIOUTransaction) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - }, - }); - } + const shouldCreateOptimisticPersonalDetails = isNewChatReport && !(personalDetails?.[payerAccountID] ?? allPersonalDetails[payerAccountID]); + // Add optimistic personal details for participant + const optimisticPersonalDetailListAction = shouldCreateOptimisticPersonalDetails + ? { + [payerAccountID]: { + accountID: payerAccountID, + // Disabling this line since participant.displayName can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayName: formatPhoneNumber(participant.displayName || payerEmail), + login: participant.login, + isOptimisticPersonalDetail: true, + }, + } + : {}; - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, - value: null, + const predictedNextStatus = + iouReport.statusNum ?? (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.OPEN); + const hasViolations = hasViolationsReportUtils(iouReport.reportID, transactionViolations, currentUserAccountIDParam, currentUserEmailParam); + // buildOptimisticNextStep is used in parallel + // eslint-disable-next-line @typescript-eslint/no-deprecated + const optimisticNextStepDeprecated = buildNextStepNew({ + report: iouReport, + predictedNextStatus, + policy, + currentUserAccountIDParam, + currentUserEmailParam, + hasViolations, + isASAPSubmitBetaEnabled, }); - const cleanUpTransactionThreadReportOnyxData = getCleanUpTransactionThreadReportOnyxData({ - transactionThreadID, - shouldDeleteTransactionThread, + const optimisticNextStep = buildOptimisticNextStep({ + report: iouReport, + predictedNextStatus, + policy, + currentUserAccountIDParam, + currentUserEmailParam, + hasViolations, + isASAPSubmitBetaEnabled, }); - optimisticData.push(...cleanUpTransactionThreadReportOnyxData.optimisticData); - optimisticData.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: updatedReportAction, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, - value: { - lastMessageText, - lastVisibleActionCreated: lastVisibleAction?.created, - lastMessageHtml: !lastMessageHtml ? lastMessageText : lastMessageHtml, - }, + // STEP 5: Build Onyx Data + const {optimisticData, successData, failureData} = buildOnyxDataForMoneyRequest({ + participant, + isNewChatReport, + shouldCreateNewMoneyRequestReport, + shouldGenerateTransactionThreadReport, + policyParams: { + policy, + policyCategories, + policyTagList, }, - ); - - const successData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - [reportAction.reportActionID]: { - pendingAction: null, - errors: null, - }, + optimisticParams: { + chat: { + report: chatReport, + createdAction: optimisticCreatedActionForChat, + reportPreviewAction, }, - }, - ]; - - // Ensure that any remaining data is removed upon successful completion, even if the server sends a report removal response. - // This is done to prevent the removal update from lingering in the applyHTTPSOnyxUpdates function. - successData.push(...cleanUpTransactionThreadReportOnyxData.successData); - - const failureData: Array< - OnyxUpdate - > = []; - - if (shouldDeleteTransactionFromOnyx && shouldRemoveIOUTransaction) { - failureData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: transaction ?? null, - }); - } - if (!shouldRemoveIOUTransaction) { - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - pendingAction: null, + iou: { + report: iouReport, + createdAction: optimisticCreatedActionForIOUReport, + action: iouAction, }, - }); - } - - failureData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, - value: transactionViolations ?? null, - }); - - failureData.push(...cleanUpTransactionThreadReportOnyxData.failureData); - - if (actionableWhisperReportActionID) { - const actionableWhisperReportAction = getReportAction(chatReport?.reportID, actionableWhisperReportActionID); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - [actionableWhisperReportActionID]: { - originalMessage: { - resolution: isActionableTrackExpense(actionableWhisperReportAction) ? (getOriginalMessage(actionableWhisperReportAction)?.resolution ?? null) : null, - }, - }, + transactionParams: { + transaction: optimisticTransaction, + transactionThreadReport: optimisticTransactionThread, + transactionThreadCreatedReportAction: optimisticCreatedActionForTransactionThread, }, - }); - } - failureData.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: { - [reportAction.reportActionID]: { - ...reportAction, - pendingAction: null, - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericDeleteFailureMessage'), - }, + policyRecentlyUsed: { + categories: optimisticPolicyRecentlyUsedCategories, + tags: optimisticPolicyRecentlyUsedTags, + currencies: optimisticPolicyRecentlyUsedCurrencies, }, + personalDetailListAction: optimisticPersonalDetailListAction, + nextStepDeprecated: optimisticNextStepDeprecated, + nextStep: optimisticNextStep, + testDriveCommentReportActionID, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, - value: chatReport ?? null, - }, - ); + retryParams, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam, + currentUserEmailParam, + hasViolations, + quickAction, + personalDetails, + }); - const parameters: DeleteMoneyRequestParams = { - transactionID, - reportActionID: reportAction.reportActionID, + return { + payerAccountID, + payerEmail, + iouReport, + chatReport, + transaction: optimisticTransaction, + iouAction, + createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : undefined, + createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOUReport.reportActionID : undefined, + reportPreviewAction, + transactionThreadReportID: optimisticTransactionThread?.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID, + onyxData: { + optimisticData, + successData, + failureData, + }, }; +} + +function mergePolicyRecentlyUsedCategories(category: string | undefined, policyRecentlyUsedCategories: OnyxEntry) { + let mergedCategories: string[]; + if (category) { + const categoriesArray = Array.isArray(policyRecentlyUsedCategories) ? policyRecentlyUsedCategories : []; + const categoriesWithNew = [category, ...categoriesArray]; + mergedCategories = Array.from(new Set(categoriesWithNew)); + } else { + mergedCategories = policyRecentlyUsedCategories ?? []; + } + return mergedCategories; +} - return {parameters, optimisticData, successData, failureData, shouldDeleteTransactionThread, chatReport}; +function mergePolicyRecentlyUsedCurrencies(currency: string | undefined, policyRecentlyUsedCurrencies: string[]) { + let mergedCurrencies: string[]; + const currenciesArray = policyRecentlyUsedCurrencies ?? []; + if (currency) { + const currenciesWithNew = [currency, ...currenciesArray]; + mergedCurrencies = Array.from(new Set(currenciesWithNew)); + } else { + mergedCurrencies = currenciesArray; + } + return mergedCurrencies.slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW); } /** - * Recalculates the report name using the policy's custom title formula. - * This is needed when report totals change (e.g., adding expenses or changing reimbursable status) - * to ensure the report title reflects the updated values like {report:reimbursable}. + * Compute the diff amount when we update the transaction */ -function recalculateOptimisticReportName(iouReport: OnyxTypes.Report, policy: OnyxEntry): string | undefined { - if (!policy?.fieldList?.[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]) { - return undefined; - } - const titleFormula = policy.fieldList[CONST.POLICY.FIELDS.FIELD_LIST_TITLE]?.defaultValue ?? ''; - if (!titleFormula) { - return undefined; +function calculateDiffAmount( + iouReport: OnyxTypes.OnyxInputOrEntry, + updatedTransaction: OnyxTypes.OnyxInputOrEntry, + transaction: OnyxEntry, +): number | null { + if (!iouReport) { + return 0; } - return populateOptimisticReportFormula(titleFormula, iouReport as Parameters[1], policy); -} + const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); + const updatedCurrency = getCurrency(updatedTransaction); + const currentCurrency = getCurrency(transaction); -function maybeUpdateReportNameForFormulaTitle(iouReport: OnyxTypes.Report, policy: OnyxEntry): OnyxTypes.Report { - const reportNameValuePairs = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${iouReport.reportID}`]; - const titleField = reportNameValuePairs?.expensify_text_title; - if (titleField?.type !== CONST.REPORT_FIELD_TYPES.FORMULA) { - return iouReport; + const currentAmount = getAmount(transaction, isExpenseReportLocal); + const updatedAmount = getAmount(updatedTransaction, isExpenseReportLocal); + + if (updatedCurrency === currentCurrency && currentAmount === updatedAmount) { + return 0; } - const updatedReportName = recalculateOptimisticReportName(iouReport, policy); - if (!updatedReportName) { - return iouReport; + if (updatedCurrency === iouReport.currency && currentCurrency === iouReport.currency) { + // Calculate the diff between the updated amount and the current amount if the currency of the updated and current transactions have the same currency as the report + return updatedAmount - currentAmount; } - return {...iouReport, reportName: updatedReportName}; + return null; } -/** - * Gathers all the data needed to submit an expense. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then - * it creates optimistic versions of them and uses those instead - */ -function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInformationParams): MoneyRequestInformation { +type GetUpdateMoneyRequestParamsType = { + transactionID: string | undefined; + transactionThreadReport: OnyxEntry; + transactionChanges: TransactionChanges; + policy: OnyxEntry; + policyTagList: OnyxTypes.OnyxInputOrEntry; + policyRecentlyUsedTags?: OnyxEntry; + policyCategories: OnyxTypes.OnyxInputOrEntry; + policyRecentlyUsedCategories?: OnyxEntry; + violations?: OnyxEntry; + hash?: number; + allowNegative?: boolean; + newTransactionReportID?: string | undefined; + iouReport: OnyxEntry; + shouldBuildOptimisticModifiedExpenseReportAction?: boolean; + currentUserAccountIDParam: number; + currentUserEmailParam: string; + isASAPSubmitBetaEnabled: boolean; + policyRecentlyUsedCurrencies?: string[]; + iouReportNextStep: OnyxEntry; + isSplitTransaction?: boolean; +}; + +type UpdateMoneyRequestDataKeys = + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES + | typeof ONYXKEYS.RECENTLY_USED_CURRENCIES + | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.NVP_RECENT_ATTENDEES + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.COLLECTION.NEXT_STEP + | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT; + +function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): UpdateMoneyRequestData { const { - parentChatReport, - existingIOUReport, - transactionParams, - participantParams, - policyParams = {}, - existingTransaction, - existingTransactionID, - moneyRequestReportID = '', - retryParams, - newReportTotal, - newNonReimbursableTotal, - testDriveCommentReportActionID, - optimisticChatReportID, - optimisticCreatedReportActionID, - optimisticIOUReportID, - optimisticReportPreviewActionID, - shouldGenerateTransactionThreadReport = true, - isSplitExpense, - action, - currentReportActionID, - isASAPSubmitBetaEnabled, + transactionID, + transactionThreadReport, + transactionChanges, + policy, + policyTagList, + policyRecentlyUsedTags, + policyCategories, + policyRecentlyUsedCategories, + violations, + hash, + allowNegative, + newTransactionReportID, + iouReport, + shouldBuildOptimisticModifiedExpenseReportAction = true, currentUserAccountIDParam, currentUserEmailParam, - transactionViolations, - quickAction, + isASAPSubmitBetaEnabled, policyRecentlyUsedCurrencies, - personalDetails, - betas, - } = moneyRequestInformation; - const {payeeAccountID = userAccountID, payeeEmail = currentUserEmail, participant} = participantParams; - const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; - const { - attendees, - amount, - distance, - modifiedAmount, - comment = '', - currency, - source = '', - created, - merchant, - receipt, - category, - tag, - taxCode, - taxAmount, - billable, - reimbursable = true, - linkedTrackedExpenseReportAction, - pendingAction, - pendingFields = {}, - type, - count, - rate, - unit, - customUnit, - waypoints, - odometerStart, - odometerEnd, - } = transactionParams; + iouReportNextStep, + isSplitTransaction, + } = params; + const optimisticData: Array< + OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES + | typeof ONYXKEYS.RECENTLY_USED_CURRENCIES + | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.NVP_RECENT_ATTENDEES + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.COLLECTION.NEXT_STEP + > + > = []; + const successData: Array< + OnyxUpdate + > = []; + const failureData: Array< + OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.COLLECTION.NEXT_STEP + > + > = []; - const payerEmail = addSMSDomainIfPhoneNumber(participant.login ?? ''); - const payerAccountID = Number(participant.accountID); - const isPolicyExpenseChat = participant.isPolicyExpenseChat; + // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData + const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const clearedPendingFields = getClearedPendingFields(transactionChanges); + const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')])); - // STEP 1: Get existing chat report OR build a new optimistic one - let isNewChatReport = false; - let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null; + // Step 2: Get all the collections being updated + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - // If the participant is not a policy expense chat, we need to ensure the chatReport matches the participant. - // This can happen when submit frequency is disabled and the user selects a different participant on the confirm page. - // We verify that the chatReport participants match the expected participants. If it's a workspace chat or - // the participants don't match, we'll find/create the correct 1:1 DM chat report. - // We also check if the chatReport itself is a Policy Expense Chat to avoid incorrectly validating Policy Expense Chats. - // We also skip validation for self-DM reports since they use accountID 0 for the participant (representing the report itself). - // We also skip validation for group chats and deprecated group DMs since they can have more than 2 participants. - if (chatReport && !isPolicyExpenseChat && !isPolicyExpenseChatReportUtil(chatReport) && !isSelfDM(chatReport) && !isGroupChat(chatReport) && !isDeprecatedGroupDM(chatReport)) { - const parentChatReportParticipants = Object.keys(chatReport.participants ?? {}).map(Number); - const expectedParticipants = [payerAccountID, payeeAccountID].sort(); - const sortedParentChatReportParticipants = parentChatReportParticipants.sort(); + const isTransactionOnHold = isOnHold(transaction); + const isFromExpenseReport = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); + const updatedTransaction: OnyxEntry = transaction + ? getUpdatedTransaction({ + transaction, + transactionChanges, + isFromExpenseReport, + isSplitTransaction, + policy, + }) + : undefined; - const participantsMatch = - expectedParticipants.length === sortedParentChatReportParticipants.length && expectedParticipants.every((id, index) => id === sortedParentChatReportParticipants.at(index)); + const transactionDetails = getTransactionDetails(updatedTransaction, undefined, undefined, allowNegative); - if (!participantsMatch) { - chatReport = null; - } + if (transactionDetails?.waypoints) { + // This needs to be a JSON string since we're sending this to the MapBox API + transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints); } - // If this is a policyExpenseChat, the chatReport must exist and we can get it from Onyx. - // report is null if the flow is initiated from the global create menu. However, participant always stores the reportID if it exists, which is the case for policyExpenseChats - if (!chatReport && isPolicyExpenseChat) { - chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.reportID}`] ?? null; - } + const dataToIncludeInParams: Partial = Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => key in transactionChanges)); - if (!chatReport) { - chatReport = getChatByParticipants([payerAccountID, payeeAccountID]) ?? null; - } + const apiParams: UpdateMoneyRequestParams = { + ...dataToIncludeInParams, + reportID: iouReport?.reportID, + transactionID, + }; - // If we still don't have a report, it likely doesn't exist and we need to build an optimistic one - if (!chatReport) { - isNewChatReport = true; - chatReport = buildOptimisticChatReport({ - participantList: [payerAccountID, payeeAccountID], - optimisticReportID: optimisticChatReportID, + const hasPendingWaypoints = 'waypoints' in transactionChanges; + const hasModifiedDistanceRate = 'customUnitRateID' in transactionChanges; + const hasModifiedCreated = 'created' in transactionChanges; + const hasModifiedAmount = 'amount' in transactionChanges; + const hasModifiedMerchant = 'merchant' in transactionChanges; + // For split transactions, the merchant and amount are already computed in transactionChanges, + // so we can build a valid optimistic MODIFIED_EXPENSE even when waypoints are pending. + const hasSplitDistanceMessageFields = !!isSplitTransaction && hasModifiedMerchant && hasModifiedAmount; + if (transaction && updatedTransaction && (hasPendingWaypoints || hasModifiedDistanceRate)) { + // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors + successData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + value: null, }); - } - // STEP 2: Get the Expense/IOU report. If the existingIOUReport or moneyRequestReportID has been provided, we want to add the transaction to this specific report. - // If no such reportID has been provided, let's use the chatReport.iouReportID property. In case that is not present, build a new optimistic Expense/IOU report. - let iouReport: OnyxInputValue = null; - if (existingIOUReport) { - iouReport = existingIOUReport; - } else if (moneyRequestReportID) { - iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`] ?? null; - } else if (!allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`]?.errorFields?.createChat) { - iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; + // Revert the transaction's amount to the original value on failure. + // The IOU Report will be fully reverted in the failureData further below. + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + amount: transaction.amount, + modifiedAmount: transaction.modifiedAmount, + modifiedMerchant: transaction.modifiedMerchant, + modifiedCurrency: transaction.modifiedCurrency, + reportID: transaction.reportID, + }, + }); } - const isScanRequest = isScanRequestTransactionUtils({ - amount, - receipt, - ...(existingTransaction && { - iouRequestType: existingTransaction.iouRequestType, - }), - }); - - const shouldCreateNewMoneyRequestReport = isSplitExpense ? false : shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas, action); - - // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation - const optimisticTransactionID = existingTransactionID ?? NumberUtils.rand64(); - const optimisticReportID = optimisticIOUReportID ?? generateReportID(); + // Step 3: Build the modified expense report actions + // We don't create a modified report action if: + // - we're updating the waypoints (unless it's a split transaction with computed merchant + amount) + // - we're updating the distance rate while the waypoints are still pending + // - we're merging two expenses (server does not create MODIFIED_EXPENSE in this flow) + // In these cases, there isn't a valid optimistic mileage data we can use, + // and the report action is created on the server with the distance-related response from the MapBox API. + // For split transactions, the merchant and amount are already available in transactionChanges, + // so we can build the optimistic report action even when waypoints are pending. + const updatedReportAction = shouldBuildOptimisticModifiedExpenseReportAction + ? buildOptimisticModifiedExpenseReportAction(transactionThreadReport, transaction, transactionChanges, isFromExpenseReport, policy, updatedTransaction, allowNegative) + : null; + if ((!hasPendingWaypoints || hasSplitDistanceMessageFields) && !(hasModifiedDistanceRate && isFetchingWaypointsFromServer(transaction)) && updatedReportAction) { + apiParams.reportActionID = updatedReportAction.reportActionID; - if (!iouReport || shouldCreateNewMoneyRequestReport) { - const nonReimbursableTotal = reimbursable ? 0 : amount; - const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticReportID, created, amount, currency, merchant); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction, + }, + }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + lastReadTime: updatedReportAction.created, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, + value: { + lastReadTime: transactionThreadReport?.lastReadTime, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [updatedReportAction.reportActionID]: {pendingAction: null}, + }, + }); - iouReport = isPolicyExpenseChat - ? buildOptimisticExpenseReport({ - chatReportID: chatReport.reportID, - policyID: chatReport.policyID, - payeeAccountID, - total: amount, - currency, - nonReimbursableTotal, - optimisticIOUReportID: optimisticReportID, - reportTransactions, - betas, - }) - : buildOptimisticIOUReport(payeeAccountID, payerAccountID, amount, chatReport.reportID, currency, undefined, undefined, optimisticReportID); - } else if (isPolicyExpenseChat) { - iouReport = {...iouReport}; - // Because of the Expense reports are stored as negative values, we subtract the total from the amount - if (iouReport?.currency === currency) { - if (!Number.isNaN(iouReport.total) && iouReport.total !== undefined) { - // Use newReportTotal in scenarios where the total is based on more than just the current transaction, and we need to override it manually - if (newReportTotal) { - iouReport.total = newReportTotal; - } else { - iouReport.total -= amount; - } + // Don't push error to failureData when updating distance requests + // The error will be handled by API response for distance requests + const isDistanceTransaction = transaction && isDistanceRequestTransactionUtils(transaction); - if (!reimbursable) { - if (newNonReimbursableTotal !== undefined) { - iouReport.nonReimbursableTotal = newNonReimbursableTotal; - } else { - iouReport.nonReimbursableTotal = (iouReport.nonReimbursableTotal ?? 0) - amount; - } - } + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + value: { + [updatedReportAction.reportActionID]: isDistanceTransaction + ? null + : { + ...(updatedReportAction as OnyxTypes.ReportAction), + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, + }, + }); + } - iouReport = maybeUpdateReportNameForFormulaTitle(iouReport, policy); + // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. + const calculatedDiffAmount = calculateDiffAmount(iouReport, updatedTransaction, transaction); + // If calculatedDiffAmount is null it means we cannot calculate the new iou report total from front-end due to currency differences. + const isTotalIndeterminate = calculatedDiffAmount === null; + const diff = calculatedDiffAmount ?? 0; + + let updatedMoneyRequestReport: OnyxTypes.OnyxInputOrEntry; + if (!iouReport) { + updatedMoneyRequestReport = null; + } else if ((isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport)) && !Number.isNaN(iouReport.total) && iouReport.total !== undefined) { + // For expense report, the amount is negative, so we should subtract total from diff + updatedMoneyRequestReport = { + ...iouReport, + total: iouReport.total - diff, + }; + if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { + updatedMoneyRequestReport.nonReimbursableTotal -= diff; + } + if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { + updatedMoneyRequestReport.nonReimbursableTotal += updatedTransaction.reimbursable ? -updatedTransaction.amount : updatedTransaction.amount; + } + if (!isTransactionOnHold) { + if (typeof updatedMoneyRequestReport.unheldTotal === 'number') { + updatedMoneyRequestReport.unheldTotal -= diff; } - if (typeof iouReport.unheldTotal === 'number') { - // Use newReportTotal in scenarios where the total is based on more than just the current transaction amount, and we need to override it manually - if (newReportTotal) { - iouReport.unheldTotal = newReportTotal; - } else { - iouReport.unheldTotal -= amount; - } + if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { + updatedMoneyRequestReport.unheldNonReimbursableTotal -= diff; } + if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { + updatedMoneyRequestReport.unheldNonReimbursableTotal += updatedTransaction.reimbursable ? -updatedTransaction.amount : updatedTransaction.amount; + } + } + + // Only recalculate reportName when reimbursable status changes and the report uses a formula title + if ('reimbursable' in transactionChanges) { + updatedMoneyRequestReport = maybeUpdateReportNameForFormulaTitle(updatedMoneyRequestReport, policy); } } else { - iouReport = updateIOUOwnerAndTotal(iouReport, payeeAccountID, amount, currency); + updatedMoneyRequestReport = updateIOUOwnerAndTotal( + iouReport, + updatedReportAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID, + diff, + getCurrency(transaction), + false, + true, + isTransactionOnHold, + ); } - // STEP 3: Build an optimistic transaction with the receipt - const isDistanceRequest = existingTransaction && isDistanceRequestTransactionUtils(existingTransaction); - const isManualDistanceRequest = existingTransaction && isManualDistanceRequestTransactionUtils(existingTransaction); - let optimisticTransaction = buildOptimisticTransaction({ - existingTransactionID: optimisticTransactionID, - existingTransaction, - originalTransactionID: transactionParams.originalTransactionID, - policy, - transactionParams: { - amount: isExpenseReport(iouReport) ? -amount : amount, - distance, - ...(modifiedAmount !== undefined && {modifiedAmount: isExpenseReport(iouReport) ? -modifiedAmount : modifiedAmount}), - currency, - reportID: iouReport.reportID, - comment, - attendees, - created, - merchant, - receipt, - category, - tag, - taxCode, - source, - taxAmount: isExpenseReport(iouReport) ? -(taxAmount ?? 0) : taxAmount, - billable, - pendingAction, - pendingFields: isDistanceRequest && !isManualDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, ...pendingFields} : pendingFields, - reimbursable: isPolicyExpenseChat ? reimbursable : true, - type, - count, - rate, - unit, - customUnit, - waypoints, - odometerStart, - odometerEnd, + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {...updatedMoneyRequestReport, ...(isTotalIndeterminate && {pendingFields: {total: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}})}, }, - isDemoTransactionParam: isSelectedManagerMcTest(participant.login) || transactionParams.receipt?.isTestDriveReceipt, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.parentReportID}`, + value: getOutstandingChildRequest(updatedMoneyRequestReport), + }, + ); + if (updatedReportAction && isOneTransactionThread(transactionThreadReport ?? undefined, iouReport ?? undefined, undefined)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: { + lastReadTime: updatedReportAction.created, + }, + }); + } + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, }); - iouReport.transactionCount = (iouReport.transactionCount ?? 0) + 1; - - const optimisticPolicyRecentlyUsedCategories = mergePolicyRecentlyUsedCategories(category, policyRecentlyUsedCategories); - const optimisticPolicyRecentlyUsedTags = buildOptimisticPolicyRecentlyUsedTags({ - // TODO: Replace getPolicyTagsData (https://github.com/Expensify/App/issues/72721) and getPolicyRecentlyUsedTagsData (https://github.com/Expensify/App/issues/71491) with useOnyx hook - // eslint-disable-next-line @typescript-eslint/no-deprecated - policyTags: getPolicyTagsData(iouReport.policyID), - policyRecentlyUsedTags, - transactionTags: tag, + // Optimistically modify the transaction and the transaction thread + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + errorFields: null, + reportID: newTransactionReportID ?? updatedTransaction?.reportID, + }, }); - const optimisticPolicyRecentlyUsedCurrencies = mergePolicyRecentlyUsedCurrencies(currency, policyRecentlyUsedCurrencies); - - // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction - // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction - // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. - // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 - // to remind me to do this. - if (isDistanceRequest && existingTransaction) { - // For split expenses, exclude merchant from merge to preserve merchant from splitExpense - if (isSplitExpense) { - // Preserve merchant from transactionParams (splitExpense.merchant) before merge - const preservedMerchant = merchant || optimisticTransaction.merchant; - const {merchant: omittedMerchant, ...existingTransactionWithoutMerchant} = existingTransaction; - optimisticTransaction = fastMerge(existingTransactionWithoutMerchant, optimisticTransaction, false) as OnyxTypes.Transaction; - // Explicitly set merchant from splitExpense to ensure it's not overwritten - optimisticTransaction.merchant = preservedMerchant; - } else { - optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false); - } + if (updatedReportAction && transactionThreadReport?.reportID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: { + lastActorAccountID: updatedReportAction.actorAccountID, + }, + }); } - if (isSplitExpense && existingTransaction) { - const {convertedAmount: originalConvertedAmount, ...existingTransactionWithoutConvertedAmount} = existingTransaction; - optimisticTransaction = fastMerge(existingTransactionWithoutConvertedAmount, optimisticTransaction, false); - - // Calculate proportional convertedAmount for the split based on the original conversion rate - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- modifiedAmount can be empty string - const originalAmount = Number(existingTransaction.modifiedAmount) || existingTransaction.amount; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- modifiedAmount can be empty string - const splitAmount = Number(optimisticTransaction.modifiedAmount) || optimisticTransaction.amount; - if (originalConvertedAmount && originalAmount && splitAmount) { - optimisticTransaction.convertedAmount = Math.round((originalConvertedAmount * splitAmount) / originalAmount); + if (isScanning(transaction) && ('amount' in transactionChanges || 'currency' in transactionChanges)) { + if (transactionThreadReport?.parentReportActionID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: { + [transactionThreadReport?.parentReportActionID]: { + originalMessage: { + whisperedTo: [], + }, + }, + }, + }); } - } - - // STEP 4: Build optimistic reportActions. We need: - // 1. CREATED action for the chatReport - // 2. CREATED action for the iouReport - // 3. IOU action for the iouReport - // 4. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread - // 5. REPORT_PREVIEW action for the chatReport - // Note: The CREATED action for the IOU report must be optimistically generated before the IOU action so there's no chance that it appears after the IOU action in the chat - const [optimisticCreatedActionForChat, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = - buildOptimisticMoneyRequestEntities({ - iouReport, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount, - currency, - comment, - payeeEmail, - participants: [participant], - transactionID: optimisticTransaction.transactionID, - paymentType: isSelectedManagerMcTest(participant.login) || transactionParams.receipt?.isTestDriveReceipt ? CONST.IOU.PAYMENT_TYPE.ELSEWHERE : undefined, - existingTransactionThreadReportID: linkedTrackedExpenseReportAction?.childReportID, - optimisticCreatedReportActionID, - linkedTrackedExpenseReportAction, - shouldGenerateTransactionThreadReport, - reportActionID: currentReportActionID, - }); - let reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : getReportPreviewAction(chatReport.reportID, iouReport.reportID); + if (iouReport?.parentReportActionID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.parentReportID}`, + value: { + [iouReport.parentReportActionID]: { + originalMessage: { + whisperedTo: [], + }, + }, + }, + }); + } + } - if (reportPreviewAction) { - reportPreviewAction = updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); - } else { - reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction, undefined, optimisticReportPreviewActionID); - chatReport.lastVisibleActionCreated = reportPreviewAction.created; + // Update recently used categories if the category is changed + const hasModifiedCategory = 'category' in transactionChanges; + if (hasModifiedCategory) { + const optimisticPolicyRecentlyUsedCategories = mergePolicyRecentlyUsedCategories(transactionChanges.category, policyRecentlyUsedCategories); + if (optimisticPolicyRecentlyUsedCategories.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport?.policyID}`, + value: optimisticPolicyRecentlyUsedCategories, + }); + } + } - // Generated ReportPreview action is a parent report action of the iou report. - // We are setting the iou report's parentReportActionID to display subtitle correctly in IOU page when offline. - iouReport.parentReportActionID = reportPreviewAction.reportActionID; + // Update recently used currencies if the currency is changed + if ('currency' in transactionChanges) { + const optimisticRecentlyUsedCurrencies = mergePolicyRecentlyUsedCurrencies(transactionChanges.currency, policyRecentlyUsedCurrencies ?? []); + if (optimisticRecentlyUsedCurrencies.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RECENTLY_USED_CURRENCIES, + value: optimisticRecentlyUsedCurrencies, + }); + } } - const shouldCreateOptimisticPersonalDetails = isNewChatReport && !(personalDetails?.[payerAccountID] ?? allPersonalDetails[payerAccountID]); - // Add optimistic personal details for participant - const optimisticPersonalDetailListAction = shouldCreateOptimisticPersonalDetails - ? { - [payerAccountID]: { - accountID: payerAccountID, - // Disabling this line since participant.displayName can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - displayName: formatPhoneNumber(participant.displayName || payerEmail), - login: participant.login, - isOptimisticPersonalDetail: true, - }, - } - : {}; + // Update recently used categories if the tag is changed + const hasModifiedTag = 'tag' in transactionChanges; + if (hasModifiedTag) { + const optimisticPolicyRecentlyUsedTags = buildOptimisticPolicyRecentlyUsedTags({ + // TODO: Replace getPolicyTagsData (https://github.com/Expensify/App/issues/72721) and getPolicyRecentlyUsedTagsData (https://github.com/Expensify/App/issues/71491) with useOnyx hook + // eslint-disable-next-line @typescript-eslint/no-deprecated + policyTags: getPolicyTagsData(iouReport?.policyID), + policyRecentlyUsedTags, + transactionTags: transactionChanges.tag, + }); + if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport?.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + } - const predictedNextStatus = - iouReport.statusNum ?? (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.OPEN); - const hasViolations = hasViolationsReportUtils(iouReport.reportID, transactionViolations, currentUserAccountIDParam, currentUserEmailParam); - // buildOptimisticNextStep is used in parallel - // eslint-disable-next-line @typescript-eslint/no-deprecated - const optimisticNextStepDeprecated = buildNextStepNew({ - report: iouReport, - predictedNextStatus, - policy, - currentUserAccountIDParam, - currentUserEmailParam, - hasViolations, - isASAPSubmitBetaEnabled, - }); + if ('attendees' in transactionChanges) { + // Update violation limit, if we modify attendees. The given limit value is for a single attendee, if we have multiple attendees we should multiply limit by attendee count + const overLimitViolation = violations?.find((violation) => violation.name === 'overLimit'); + if (overLimitViolation) { + const limitForSingleAttendee = ViolationsUtils.getViolationAmountLimit(overLimitViolation); + if (limitForSingleAttendee * (transactionChanges?.attendees?.length ?? 1) > Math.abs(getAmount(transaction))) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: violations?.filter((violation) => violation.name !== 'overLimit') ?? [], + }); + } + } + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_RECENT_ATTENDEES, + value: lodashUnionBy( + transactionChanges.attendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, email})), + recentAttendees, + (attendee) => attendee.email || attendee.displayName, + ).slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW), + }); + } - const optimisticNextStep = buildOptimisticNextStep({ - report: iouReport, - predictedNextStatus, - policy, - currentUserAccountIDParam, - currentUserEmailParam, - hasViolations, - isASAPSubmitBetaEnabled, - }); + if (Array.isArray(apiParams?.attendees)) { + apiParams.attendees = JSON.stringify(apiParams?.attendees); + } - // STEP 5: Build Onyx Data - const {optimisticData, successData, failureData} = buildOnyxDataForMoneyRequest({ - participant, - isNewChatReport, - shouldCreateNewMoneyRequestReport, - shouldGenerateTransactionThreadReport, - policyParams: { - policy, - policyCategories, - policyTagList, - }, - optimisticParams: { - chat: { - report: chatReport, - createdAction: optimisticCreatedActionForChat, - reportPreviewAction, - }, - iou: { - report: iouReport, - createdAction: optimisticCreatedActionForIOUReport, - action: iouAction, - }, - transactionParams: { - transaction: optimisticTransaction, - transactionThreadReport: optimisticTransactionThread, - transactionThreadCreatedReportAction: optimisticCreatedActionForTransactionThread, - }, - policyRecentlyUsed: { - categories: optimisticPolicyRecentlyUsedCategories, - tags: optimisticPolicyRecentlyUsedTags, - currencies: optimisticPolicyRecentlyUsedCurrencies, - }, - personalDetailListAction: optimisticPersonalDetailListAction, - nextStepDeprecated: optimisticNextStepDeprecated, - nextStep: optimisticNextStep, - testDriveCommentReportActionID, + // Clear out the error fields and loading states on success + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + isLoading: false, + errorFields: null, + routes: null, }, - retryParams, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam, - currentUserEmailParam, - hasViolations, - quickAction, - personalDetails, }); - return { - payerAccountID, - payerEmail, - iouReport, - chatReport, - transaction: optimisticTransaction, - iouAction, - createdChatReportActionID: isNewChatReport ? optimisticCreatedActionForChat.reportActionID : undefined, - createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOUReport.reportActionID : undefined, - reportPreviewAction, - transactionThreadReportID: optimisticTransactionThread?.reportID, - createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID, - onyxData: { - optimisticData, - successData, - failureData, + // Clear out loading states, pending fields, and add the error fields + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...transaction, + pendingFields: clearedPendingFields, + isLoading: false, + errorFields, + reportID: transaction?.reportID, }, - }; -} + }); -function mergePolicyRecentlyUsedCategories(category: string | undefined, policyRecentlyUsedCategories: OnyxEntry) { - let mergedCategories: string[]; - if (category) { - const categoriesArray = Array.isArray(policyRecentlyUsedCategories) ? policyRecentlyUsedCategories : []; - const categoriesWithNew = [category, ...categoriesArray]; - mergedCategories = Array.from(new Set(categoriesWithNew)); - } else { - mergedCategories = policyRecentlyUsedCategories ?? []; - } - return mergedCategories; -} - -function mergePolicyRecentlyUsedCurrencies(currency: string | undefined, policyRecentlyUsedCurrencies: string[]) { - let mergedCurrencies: string[]; - const currenciesArray = policyRecentlyUsedCurrencies ?? []; - if (currency) { - const currenciesWithNew = [currency, ...currenciesArray]; - mergedCurrencies = Array.from(new Set(currenciesWithNew)); - } else { - mergedCurrencies = currenciesArray; + if (iouReport) { + // Reset the iouReport to its original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: {...iouReport, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, + }); } - return mergedCurrencies.slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW); -} - -/** - * Gathers all the data needed to make an expense. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then - * it creates optimistic versions of them and uses those instead - */ -function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): TrackExpenseInformation { - const { - parentChatReport, - moneyRequestReportID = '', - existingTransaction, - existingTransactionID, - participantParams, - policyParams, - transactionParams, - retryParams, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam, - currentUserEmailParam, - introSelected, - activePolicyID, - quickAction, - betas, - isSelfTourViewed, - } = params; - const {payeeAccountID = userAccountID, payeeEmail = currentUserEmail, participant} = participantParams; - const {policy, policyCategories, policyTagList} = policyParams; - const { - comment, - amount, - currency, - created, - distance, - merchant, - receipt, - category, - tag, - taxCode, - taxAmount, - billable, - reimbursable, - linkedTrackedExpenseReportAction, - attendees, - odometerStart, - odometerEnd, - gpsCoordinates, - } = transactionParams; - - const onyxData: OnyxData = { - optimisticData: [], - successData: [], - failureData: [], - }; - - const isPolicyExpenseChat = participant.isPolicyExpenseChat; - // STEP 1: Get existing chat report - let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null; - - // If no chat report is passed, defaults to the self-DM report - if (!chatReport) { - chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${findSelfDMReportID()}`] ?? null; - } + const hasModifiedCurrency = 'currency' in transactionChanges; + const hasModifiedComment = 'comment' in transactionChanges; + const hasModifiedReimbursable = 'reimbursable' in transactionChanges; + const hasModifiedTaxCode = 'taxCode' in transactionChanges; + const hasModifiedDate = 'date' in transactionChanges; + const hasModifiedAttendees = 'attendees' in transactionChanges; - // If we are still missing the chat report then optimistically create the self-DM report and use it - let optimisticReportID: string | undefined; - let optimisticReportActionID: string | undefined; - if (!chatReport) { - const currentTime = DateUtils.getDBTime(); - const selfDMReport = buildOptimisticSelfDMReport(currentTime); - const selfDMCreatedReportAction = buildOptimisticCreatedReportAction(currentUserEmail ?? '', currentTime); - optimisticReportID = selfDMReport.reportID; - optimisticReportActionID = selfDMCreatedReportAction.reportActionID; - chatReport = selfDMReport; + const isInvoice = isInvoiceReportReportUtils(iouReport); + if ( + transactionID && + policy && + isPaidGroupPolicy(policy) && + !isInvoice && + updatedTransaction && + (hasPendingWaypoints || + hasModifiedTag || + hasModifiedCategory || + hasModifiedComment || + hasModifiedMerchant || + hasModifiedDistanceRate || + hasModifiedDate || + hasModifiedCurrency || + hasModifiedAmount || + hasModifiedCreated || + hasModifiedReimbursable || + hasModifiedTaxCode || + hasModifiedAttendees) + ) { + const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; + // If the amount, currency or date have been modified, we remove the duplicate violations since they would be out of date as the transaction has changed + let optimisticViolations = + hasModifiedAmount || hasModifiedDate || hasModifiedCurrency + ? currentTransactionViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION) + : currentTransactionViolations; + optimisticViolations = + hasModifiedCategory && transactionChanges.category === '' + ? optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY) + : optimisticViolations; + if (hasPendingWaypoints) { + optimisticViolations = optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.NO_ROUTE); + } - onyxData.optimisticData?.push( - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + updatedTransaction, + optimisticViolations, + policy, + policyTagList ?? {}, + policyCategories ?? {}, + hasDependentTags(policy, policyTagList ?? {}), + isInvoice, + isSelfDM(iouReport), + iouReport, + isFromExpenseReport, + ); + optimisticData.push(violationsOnyxData); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: currentTransactionViolations, + }); + if (hash) { + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, value: { - ...selfDMReport, - pendingFields: { - createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: violationsOnyxData.value, }, }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.SELF_DM_REPORT_ID, - value: selfDMReport.reportID, - }, - { + }); + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReportID}`, - value: {isOptimisticReport: true}, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReportID}`, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, value: { - [optimisticReportActionID]: selfDMCreatedReportAction, + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: currentTransactionViolations, + }, }, - }, - ); - onyxData.successData?.push( - { + }); + } + if ( + violationsOnyxData && + ((iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN) === CONST.REPORT.STATUS_NUM.OPEN || + (hasModifiedReimbursable && iouReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED)) + ) { + const currentNextStep = iouReportNextStep ?? {}; + const shouldFixViolations = Array.isArray(violationsOnyxData.value) && violationsOnyxData.value.length > 0; + const moneyRequestReport = updatedMoneyRequestReport ?? iouReport ?? undefined; + const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, currentUserAccountIDParam, currentUserEmailParam); + const optimisticNextStep = buildOptimisticNextStep({ + report: moneyRequestReport, + predictedNextStatus: iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN, + shouldFixViolations, + currentUserAccountIDParam, + currentUserEmailParam, + hasViolations, + isASAPSubmitBetaEnabled, + policy, + }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport?.reportID}`, + // buildOptimisticNextStep is used in parallel + // eslint-disable-next-line @typescript-eslint/no-deprecated + value: buildNextStepNew({ + report: moneyRequestReport, + predictedNextStatus: iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN, + shouldFixViolations, + currentUserAccountIDParam, + currentUserEmailParam, + hasViolations, + isASAPSubmitBetaEnabled, + policy, + }), + }); + optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, value: { + nextStep: optimisticNextStep, pendingFields: { - createChat: null, + nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }, - }, - { + }); + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReportID}`, - value: {isOptimisticReport: false}, - }, - { + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport?.reportID}`, + value: currentNextStep, + }); + failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, value: { - [optimisticReportActionID]: { - pendingAction: null, + nextStep: iouReport?.nextStep ?? null, + pendingFields: { + nextStep: null, }, }, - }, - ); - onyxData.failureData?.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, - value: null, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReportID}`, - value: null, - }, - { + }); + successData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticReportID}`, - value: null, - }, - ); + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: { + pendingFields: { + nextStep: null, + }, + }, + }); + } } - // Check if the report is a draft - const isDraftReportLocal = isDraftReport(chatReport?.reportID); - - let createdWorkspaceParams: CreateWorkspaceParams | undefined; - - if (isDraftReportLocal) { - const workspaceData = buildPolicyData({ - policyOwnerEmail: undefined, - makeMeAdmin: policy?.makeMeAdmin, - policyName: policy?.name, - policyID: policy?.id, - expenseReportId: chatReport?.reportID, - engagementChoice: CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE, - currentUserAccountIDParam, - currentUserEmailParam, - introSelected, - activePolicyID, - isSelfTourViewed, - }); - createdWorkspaceParams = workspaceData.params; - onyxData.optimisticData?.push(...(workspaceData.optimisticData ?? [])); - onyxData.successData?.push(...(workspaceData.successData ?? [])); - onyxData.failureData?.push(...(workspaceData.failureData ?? [])); - } - - // STEP 2: If not in the self-DM flow, we need to use the expense report. - // For this, first use the chatReport.iouReportID property. Build a new optimistic expense report if needed. - const shouldUseMoneyReport = !!isPolicyExpenseChat && chatReport.chatType !== CONST.REPORT.CHAT_TYPE.SELF_DM; - - let iouReport: OnyxInputValue = null; - let shouldCreateNewMoneyRequestReport = false; - - // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation - const optimisticTransactionID = existingTransactionID ?? NumberUtils.rand64(); - const optimisticExpenseReportID = generateReportID(); - - if (shouldUseMoneyReport) { - if (moneyRequestReportID) { - iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`] ?? null; - } else { - iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; - } - const isScanRequest = isScanRequestTransactionUtils({amount, receipt}); - shouldCreateNewMoneyRequestReport = shouldCreateNewMoneyRequestReportReportUtils(iouReport, chatReport, isScanRequest, betas); - if (!iouReport || shouldCreateNewMoneyRequestReport) { - const reportTransactions = buildMinimalTransactionForFormula(optimisticTransactionID, optimisticExpenseReportID, created, amount, currency, merchant); - - iouReport = buildOptimisticExpenseReport({ - chatReportID: chatReport.reportID, - policyID: chatReport.policyID, - payeeAccountID, - total: amount, - currency, - nonReimbursableTotal: amount, - betas, - optimisticIOUReportID: optimisticExpenseReportID, - reportTransactions, - }); - } else { - iouReport = {...iouReport}; - // Because of the Expense reports are stored as negative values, we subtract the total from the amount - if (iouReport?.currency === currency) { - if (!Number.isNaN(iouReport.total) && iouReport.total !== undefined && typeof iouReport.nonReimbursableTotal === 'number') { - iouReport.total -= amount; - iouReport.nonReimbursableTotal -= amount; - } - - if (typeof iouReport.unheldTotal === 'number' && typeof iouReport.unheldNonReimbursableTotal === 'number') { - iouReport.unheldTotal -= amount; - iouReport.unheldNonReimbursableTotal -= amount; - } - } - } - } - - // If shouldUseMoneyReport is true, the iouReport was defined. - // But we'll use the `shouldUseMoneyReport && iouReport` check further instead of `shouldUseMoneyReport` to avoid TS errors. - - // STEP 3: Build optimistic receipt and transaction - const existingTransactionData = existingTransaction ?? allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; - const isDistanceRequest = existingTransactionData && isDistanceRequestTransactionUtils(existingTransactionData); - const isManualDistanceRequest = existingTransactionData && isManualDistanceRequestTransactionUtils(existingTransactionData); - const isOdometerDistanceRequest = existingTransactionData && isOdometerDistanceRequestTransactionUtils(existingTransactionData); - const isGPSDistanceRequest = existingTransactionData && isGPSDistanceRequestTransactionUtils(existingTransactionData); - let optimisticTransaction = buildOptimisticTransaction({ - existingTransactionID: optimisticTransactionID, - existingTransaction: existingTransactionData, - policy, - transactionParams: { - amount: -amount, - currency, - reportID: shouldUseMoneyReport && iouReport ? iouReport.reportID : CONST.REPORT.UNREPORTED_REPORT_ID, - comment, - distance, - created, - merchant, - receipt, - category, - tag, - taxCode, - taxAmount: taxAmount ? -taxAmount : undefined, - billable, - pendingFields: isDistanceRequest && !isManualDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, - reimbursable, - filename: existingTransactionData?.receipt?.filename, - attendees, - odometerStart: isOdometerDistanceRequest ? odometerStart : undefined, - odometerEnd: isOdometerDistanceRequest ? odometerEnd : undefined, - gpsCoordinates: isGPSDistanceRequest ? gpsCoordinates : undefined, - }, - }); - if (iouReport) { - iouReport.transactionCount = (iouReport.transactionCount ?? 0) + 1; - } - - // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction - // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction - // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. - // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 - // to remind me to do this. - if (isDistanceRequest) { - optimisticTransaction = fastMerge(existingTransactionData, optimisticTransaction, false); - } - - // STEP 4: Build optimistic reportActions. We need: - // 1. CREATED action for the iouReport (if tracking in the Expense chat) - // 2. IOU action for the iouReport (if tracking in the Expense chat), otherwise – for chatReport - // 3. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread - // 4. REPORT_PREVIEW action for the chatReport (if tracking in the Expense chat) - const [, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = buildOptimisticMoneyRequestEntities({ - iouReport: shouldUseMoneyReport && iouReport ? iouReport : chatReport, - type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, - amount, - currency, - comment, - payeeEmail, - participants: [participant], - transactionID: optimisticTransaction.transactionID, - isPersonalTrackingExpense: !shouldUseMoneyReport, - existingTransactionThreadReportID: linkedTrackedExpenseReportAction?.childReportID, - linkedTrackedExpenseReportAction, - }); - - let reportPreviewAction: OnyxInputValue> = null; - if (shouldUseMoneyReport && iouReport) { - reportPreviewAction = shouldCreateNewMoneyRequestReport ? null : getReportPreviewAction(chatReport.reportID, iouReport.reportID); - - if (reportPreviewAction) { - reportPreviewAction = updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); - } else { - reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction); - // Generated ReportPreview action is a parent report action of the iou report. - // We are setting the iou report's parentReportActionID to display subtitle correctly in IOU page when offline. - iouReport.parentReportActionID = reportPreviewAction.reportActionID; - } - } - - let actionableTrackExpenseWhisper: OnyxInputValue = null; - if (!isPolicyExpenseChat) { - actionableTrackExpenseWhisper = buildOptimisticActionableTrackExpenseWhisper(iouAction, optimisticTransaction.transactionID); + // Reset the transaction thread to its original state + if (transactionThreadReport?.reportID) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: transactionThreadReport, + }); } - // STEP 5: Build Onyx Data - const trackExpenseOnyxData = buildOnyxDataForTrackExpense({ - participant, - chat: {report: chatReport, previewAction: reportPreviewAction}, - iou: {report: iouReport, action: iouAction, createdAction: optimisticCreatedActionForIOUReport}, - transactionParams: { - transaction: optimisticTransaction, - threadCreatedReportAction: optimisticCreatedActionForTransactionThread, - threadReport: optimisticTransactionThread ?? {}, - }, - policyParams: {policy, tagList: policyTagList, categories: policyCategories}, - shouldCreateNewMoneyRequestReport, - actionableTrackExpenseWhisper, - retryParams, - isASAPSubmitBetaEnabled, - quickAction, - }); - - onyxData.optimisticData?.push(...(trackExpenseOnyxData.optimisticData ?? [])); - onyxData.successData?.push(...(trackExpenseOnyxData.successData ?? [])); - onyxData.failureData?.push(...(trackExpenseOnyxData.failureData ?? [])); - return { - createdWorkspaceParams, - chatReport, - iouReport: iouReport ?? undefined, - transaction: optimisticTransaction, - iouAction, - createdIOUReportActionID: shouldCreateNewMoneyRequestReport ? optimisticCreatedActionForIOUReport.reportActionID : undefined, - reportPreviewAction: reportPreviewAction ?? undefined, - transactionThreadReportID: optimisticTransactionThread.reportID, - createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID, - actionableWhisperReportActionIDParam: actionableTrackExpenseWhisper?.reportActionID, - optimisticReportID, - optimisticReportActionID, - onyxData, + params: apiParams, + onyxData: {optimisticData, successData, failureData}, }; } /** - * Compute the diff amount when we update the transaction + * @param transactionID + * @param transactionThreadReportID + * @param transactionChanges + * @param [transactionChanges.created] Present when updated the date field + * @param policy May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) + * @param [shouldBuildOptimisticModifiedExpenseReportAction=true] When true, build an optimistic MODIFIED_EXPENSE report action. */ -function calculateDiffAmount( - iouReport: OnyxTypes.OnyxInputOrEntry, - updatedTransaction: OnyxTypes.OnyxInputOrEntry, - transaction: OnyxEntry, -): number | null { - if (!iouReport) { - return 0; - } - const isExpenseReportLocal = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); - const updatedCurrency = getCurrency(updatedTransaction); - const currentCurrency = getCurrency(transaction); - - const currentAmount = getAmount(transaction, isExpenseReportLocal); - const updatedAmount = getAmount(updatedTransaction, isExpenseReportLocal); - - if (updatedCurrency === currentCurrency && currentAmount === updatedAmount) { - return 0; - } - - if (updatedCurrency === iouReport.currency && currentCurrency === iouReport.currency) { - // Calculate the diff between the updated amount and the current amount if the currency of the updated and current transactions have the same currency as the report - return updatedAmount - currentAmount; - } - - return null; -} - -type GetUpdateMoneyRequestParamsType = { - transactionID: string | undefined; - transactionThreadReport: OnyxEntry; - transactionChanges: TransactionChanges; - policy: OnyxEntry; - policyTagList: OnyxTypes.OnyxInputOrEntry; - policyRecentlyUsedTags?: OnyxEntry; - policyCategories: OnyxTypes.OnyxInputOrEntry; - policyRecentlyUsedCategories?: OnyxEntry; - violations?: OnyxEntry; - hash?: number; - allowNegative?: boolean; - newTransactionReportID?: string | undefined; - iouReport: OnyxEntry; - shouldBuildOptimisticModifiedExpenseReportAction?: boolean; - currentUserAccountIDParam: number; - currentUserEmailParam: string; - isASAPSubmitBetaEnabled: boolean; - policyRecentlyUsedCurrencies?: string[]; - iouReportNextStep: OnyxEntry; - isSplitTransaction?: boolean; -}; - -type UpdateMoneyRequestDataKeys = - | typeof ONYXKEYS.COLLECTION.REPORT - | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - | typeof ONYXKEYS.COLLECTION.TRANSACTION - | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES - | typeof ONYXKEYS.RECENTLY_USED_CURRENCIES - | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - | typeof ONYXKEYS.NVP_RECENT_ATTENDEES - | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.NEXT_STEP - | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT; - -function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): UpdateMoneyRequestData { - const { - transactionID, - transactionThreadReport, - transactionChanges, - policy, - policyTagList, - policyRecentlyUsedTags, - policyCategories, - policyRecentlyUsedCategories, - violations, - hash, - allowNegative, - newTransactionReportID, - iouReport, - shouldBuildOptimisticModifiedExpenseReportAction = true, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - policyRecentlyUsedCurrencies, - iouReportNextStep, - isSplitTransaction, - } = params; - const optimisticData: Array< - OnyxUpdate< - | typeof ONYXKEYS.COLLECTION.REPORT - | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - | typeof ONYXKEYS.COLLECTION.TRANSACTION - | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES - | typeof ONYXKEYS.RECENTLY_USED_CURRENCIES - | typeof ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - | typeof ONYXKEYS.NVP_RECENT_ATTENDEES - | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.NEXT_STEP - > - > = []; - const successData: Array< - OnyxUpdate - > = []; - const failureData: Array< - OnyxUpdate< - | typeof ONYXKEYS.COLLECTION.TRANSACTION - | typeof ONYXKEYS.COLLECTION.REPORT - | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.NEXT_STEP - > - > = []; +function getUpdateTrackExpenseParams( + transactionID: string | undefined, + transactionThreadReportID: string | undefined, + transactionChanges: TransactionChanges, + policy: OnyxEntry, + shouldBuildOptimisticModifiedExpenseReportAction = true, +): UpdateMoneyRequestData< + typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT +> { + const optimisticData: Array> = []; + const successData: Array> = []; + const failureData: Array> = []; // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData - const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const pendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); const clearedPendingFields = getClearedPendingFields(transactionChanges); const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')])); // Step 2: Get all the collections being updated + const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - - const isTransactionOnHold = isOnHold(transaction); - const isFromExpenseReport = isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport); - const updatedTransaction: OnyxEntry = transaction + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; + const updatedTransaction = transaction ? getUpdatedTransaction({ transaction, transactionChanges, - isFromExpenseReport, - isSplitTransaction, + isFromExpenseReport: false, policy, }) - : undefined; - - const transactionDetails = getTransactionDetails(updatedTransaction, undefined, undefined, allowNegative); + : null; + const transactionDetails = getTransactionDetails(updatedTransaction); if (transactionDetails?.waypoints) { // This needs to be a JSON string since we're sending this to the MapBox API @@ -4178,18 +3491,12 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U const apiParams: UpdateMoneyRequestParams = { ...dataToIncludeInParams, - reportID: iouReport?.reportID, + reportID: chatReport?.reportID, transactionID, }; const hasPendingWaypoints = 'waypoints' in transactionChanges; const hasModifiedDistanceRate = 'customUnitRateID' in transactionChanges; - const hasModifiedCreated = 'created' in transactionChanges; - const hasModifiedAmount = 'amount' in transactionChanges; - const hasModifiedMerchant = 'merchant' in transactionChanges; - // For split transactions, the merchant and amount are already computed in transactionChanges, - // so we can build a valid optimistic MODIFIED_EXPENSE even when waypoints are pending. - const hasSplitDistanceMessageFields = !!isSplitTransaction && hasModifiedMerchant && hasModifiedAmount; if (transaction && updatedTransaction && (hasPendingWaypoints || hasModifiedDistanceRate)) { // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors successData.push({ @@ -4207,273 +3514,80 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U amount: transaction.amount, modifiedAmount: transaction.modifiedAmount, modifiedMerchant: transaction.modifiedMerchant, - modifiedCurrency: transaction.modifiedCurrency, - reportID: transaction.reportID, }, }); } // Step 3: Build the modified expense report actions // We don't create a modified report action if: - // - we're updating the waypoints (unless it's a split transaction with computed merchant + amount) + // - we're updating the waypoints // - we're updating the distance rate while the waypoints are still pending // - we're merging two expenses (server does not create MODIFIED_EXPENSE in this flow) // In these cases, there isn't a valid optimistic mileage data we can use, - // and the report action is created on the server with the distance-related response from the MapBox API. - // For split transactions, the merchant and amount are already available in transactionChanges, - // so we can build the optimistic report action even when waypoints are pending. + // and the report action is created on the server with the distance-related response from the MapBox API + const allowNegative = shouldEnableNegative(transactionThread ?? undefined); const updatedReportAction = shouldBuildOptimisticModifiedExpenseReportAction - ? buildOptimisticModifiedExpenseReportAction(transactionThreadReport, transaction, transactionChanges, isFromExpenseReport, policy, updatedTransaction, allowNegative) + ? buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, false, policy, updatedTransaction, allowNegative) : null; - if ((!hasPendingWaypoints || hasSplitDistanceMessageFields) && !(hasModifiedDistanceRate && isFetchingWaypointsFromServer(transaction)) && updatedReportAction) { + if (!hasPendingWaypoints && !(hasModifiedDistanceRate && isFetchingWaypointsFromServer(transaction)) && updatedReportAction) { apiParams.reportActionID = updatedReportAction.reportActionID; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, value: { [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction, }, }); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, - value: { - lastReadTime: updatedReportAction.created, - }, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport?.reportID}`, - value: { - lastReadTime: transactionThreadReport?.lastReadTime, - }, - }); successData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, value: { [updatedReportAction.reportActionID]: {pendingAction: null}, }, }); - - // Don't push error to failureData when updating distance requests - // The error will be handled by API response for distance requests - const isDistanceTransaction = transaction && isDistanceRequestTransactionUtils(transaction); - failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport?.reportID}`, - value: { - [updatedReportAction.reportActionID]: isDistanceTransaction - ? null - : { - ...(updatedReportAction as OnyxTypes.ReportAction), - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), - }, - }, - }); - } - - // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. - const calculatedDiffAmount = calculateDiffAmount(iouReport, updatedTransaction, transaction); - // If calculatedDiffAmount is null it means we cannot calculate the new iou report total from front-end due to currency differences. - const isTotalIndeterminate = calculatedDiffAmount === null; - const diff = calculatedDiffAmount ?? 0; - - let updatedMoneyRequestReport: OnyxTypes.OnyxInputOrEntry; - if (!iouReport) { - updatedMoneyRequestReport = null; - } else if ((isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport)) && !Number.isNaN(iouReport.total) && iouReport.total !== undefined) { - // For expense report, the amount is negative, so we should subtract total from diff - updatedMoneyRequestReport = { - ...iouReport, - total: iouReport.total - diff, - }; - if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { - updatedMoneyRequestReport.nonReimbursableTotal -= diff; - } - if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { - updatedMoneyRequestReport.nonReimbursableTotal += updatedTransaction.reimbursable ? -updatedTransaction.amount : updatedTransaction.amount; - } - if (!isTransactionOnHold) { - if (typeof updatedMoneyRequestReport.unheldTotal === 'number') { - updatedMoneyRequestReport.unheldTotal -= diff; - } - if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { - updatedMoneyRequestReport.unheldNonReimbursableTotal -= diff; - } - if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { - updatedMoneyRequestReport.unheldNonReimbursableTotal += updatedTransaction.reimbursable ? -updatedTransaction.amount : updatedTransaction.amount; - } - } - - // Only recalculate reportName when reimbursable status changes and the report uses a formula title - if ('reimbursable' in transactionChanges) { - updatedMoneyRequestReport = maybeUpdateReportNameForFormulaTitle(updatedMoneyRequestReport, policy); - } - } else { - updatedMoneyRequestReport = updateIOUOwnerAndTotal( - iouReport, - updatedReportAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID, - diff, - getCurrency(transaction), - false, - true, - isTransactionOnHold, - ); - } - - optimisticData.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {...updatedMoneyRequestReport, ...(isTotalIndeterminate && {pendingFields: {total: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}})}, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.parentReportID}`, - value: getOutstandingChildRequest(updatedMoneyRequestReport), - }, - ); - if (updatedReportAction && isOneTransactionThread(transactionThreadReport ?? undefined, iouReport ?? undefined, undefined)) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, value: { - lastReadTime: updatedReportAction.created, + [updatedReportAction.reportActionID]: { + ...(updatedReportAction as OnyxTypes.ReportAction), + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, }, }); } - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {pendingAction: null, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, - }); + // Step 4: Update the report preview message (and report header) so LHN amount tracked is correct. // Optimistically modify the transaction and the transaction thread optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, value: { ...updatedTransaction, + pendingFields, errorFields: null, - reportID: newTransactionReportID ?? updatedTransaction?.reportID, }, }); - if (updatedReportAction && transactionThreadReport?.reportID) { + if (updatedReportAction) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, value: { lastActorAccountID: updatedReportAction.actorAccountID, }, }); } - if (isScanning(transaction) && ('amount' in transactionChanges || 'currency' in transactionChanges)) { - if (transactionThreadReport?.parentReportActionID) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, - value: { - [transactionThreadReport?.parentReportActionID]: { - originalMessage: { - whisperedTo: [], - }, - }, - }, - }); - } - - if (iouReport?.parentReportActionID) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.parentReportID}`, - value: { - [iouReport.parentReportActionID]: { - originalMessage: { - whisperedTo: [], - }, - }, - }, - }); - } - } - - // Update recently used categories if the category is changed - const hasModifiedCategory = 'category' in transactionChanges; - if (hasModifiedCategory) { - const optimisticPolicyRecentlyUsedCategories = mergePolicyRecentlyUsedCategories(transactionChanges.category, policyRecentlyUsedCategories); - if (optimisticPolicyRecentlyUsedCategories.length) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport?.policyID}`, - value: optimisticPolicyRecentlyUsedCategories, - }); - } - } - - // Update recently used currencies if the currency is changed - if ('currency' in transactionChanges) { - const optimisticRecentlyUsedCurrencies = mergePolicyRecentlyUsedCurrencies(transactionChanges.currency, policyRecentlyUsedCurrencies ?? []); - if (optimisticRecentlyUsedCurrencies.length) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.RECENTLY_USED_CURRENCIES, - value: optimisticRecentlyUsedCurrencies, - }); - } - } - - // Update recently used categories if the tag is changed - const hasModifiedTag = 'tag' in transactionChanges; - if (hasModifiedTag) { - const optimisticPolicyRecentlyUsedTags = buildOptimisticPolicyRecentlyUsedTags({ - // TODO: Replace getPolicyTagsData (https://github.com/Expensify/App/issues/72721) and getPolicyRecentlyUsedTagsData (https://github.com/Expensify/App/issues/71491) with useOnyx hook - // eslint-disable-next-line @typescript-eslint/no-deprecated - policyTags: getPolicyTagsData(iouReport?.policyID), - policyRecentlyUsedTags, - transactionTags: transactionChanges.tag, - }); - if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport?.policyID}`, - value: optimisticPolicyRecentlyUsedTags, - }); - } - } - - if ('attendees' in transactionChanges) { - // Update violation limit, if we modify attendees. The given limit value is for a single attendee, if we have multiple attendees we should multiply limit by attendee count - const overLimitViolation = violations?.find((violation) => violation.name === 'overLimit'); - if (overLimitViolation) { - const limitForSingleAttendee = ViolationsUtils.getViolationAmountLimit(overLimitViolation); - if (limitForSingleAttendee * (transactionChanges?.attendees?.length ?? 1) > Math.abs(getAmount(transaction))) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, - value: violations?.filter((violation) => violation.name !== 'overLimit') ?? [], - }); - } - } + if (isScanning(transaction) && transactionThread?.parentReportActionID && ('amount' in transactionChanges || 'currency' in transactionChanges)) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.NVP_RECENT_ATTENDEES, - value: lodashUnionBy( - transactionChanges.attendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, email})), - recentAttendees, - (attendee) => attendee.email || attendee.displayName, - ).slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW), + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: {[transactionThread.parentReportActionID]: {originalMessage: {whisperedTo: []}}}, }); } - if (Array.isArray(apiParams?.attendees)) { - apiParams.attendees = JSON.stringify(apiParams?.attendees); - } - // Clear out the error fields and loading states on success successData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -4495,432 +3609,130 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U pendingFields: clearedPendingFields, isLoading: false, errorFields, - reportID: transaction?.reportID, }, }); - if (iouReport) { - // Reset the iouReport to its original state - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, - value: {...iouReport, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, - }); - } + // Reset the transaction thread to its original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: transactionThread, + }); - const hasModifiedCurrency = 'currency' in transactionChanges; - const hasModifiedComment = 'comment' in transactionChanges; - const hasModifiedReimbursable = 'reimbursable' in transactionChanges; - const hasModifiedTaxCode = 'taxCode' in transactionChanges; - const hasModifiedDate = 'date' in transactionChanges; - const hasModifiedAttendees = 'attendees' in transactionChanges; + return { + params: apiParams, + onyxData: {optimisticData, successData, failureData}, + }; +} - const isInvoice = isInvoiceReportReportUtils(iouReport); - if ( - transactionID && - policy && - isPaidGroupPolicy(policy) && - !isInvoice && - updatedTransaction && - (hasPendingWaypoints || - hasModifiedTag || - hasModifiedCategory || - hasModifiedComment || - hasModifiedMerchant || - hasModifiedDistanceRate || - hasModifiedDate || - hasModifiedCurrency || - hasModifiedAmount || - hasModifiedCreated || - hasModifiedReimbursable || - hasModifiedTaxCode || - hasModifiedAttendees) - ) { - const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; - // If the amount, currency or date have been modified, we remove the duplicate violations since they would be out of date as the transaction has changed - let optimisticViolations = - hasModifiedAmount || hasModifiedDate || hasModifiedCurrency - ? currentTransactionViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION) - : currentTransactionViolations; - optimisticViolations = - hasModifiedCategory && transactionChanges.category === '' - ? optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY) - : optimisticViolations; - if (hasPendingWaypoints) { - optimisticViolations = optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.NO_ROUTE); - } +type UpdateMoneyRequestDateParams = { + transactionID: string; + transactionThreadReport: OnyxEntry; + parentReport: OnyxEntry; + transactions: OnyxCollection; + transactionViolations: OnyxCollection; + value: string; + policy: OnyxEntry; + policyTags: OnyxEntry; + policyCategories: OnyxEntry; + currentUserAccountIDParam: number; + currentUserEmailParam: string; + isASAPSubmitBetaEnabled: boolean; + parentReportNextStep: OnyxEntry; +}; - const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( - updatedTransaction, - optimisticViolations, +/** Updates the created date of an expense */ +function updateMoneyRequestDate({ + transactionID, + transactionThreadReport, + parentReport, + transactions, + transactionViolations, + value, + policy, + policyTags, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + parentReportNextStep, +}: UpdateMoneyRequestDateParams) { + const transactionChanges: TransactionChanges = { + created: value, + }; + let data: UpdateMoneyRequestData; + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy); + } else { + data = getUpdateMoneyRequestParams({ + transactionID, + transactionThreadReport, + iouReport: parentReport, + transactionChanges, policy, - policyTagList ?? {}, - policyCategories ?? {}, - hasDependentTags(policy, policyTagList ?? {}), - isInvoice, - isSelfDM(iouReport), - iouReport, - isFromExpenseReport, - ); - optimisticData.push(violationsOnyxData); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, - value: currentTransactionViolations, + policyTagList: policyTags, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + iouReportNextStep: parentReportNextStep, }); - if (hash) { - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, - value: { - data: { - [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: violationsOnyxData.value, - }, - }, - }); - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, - value: { - data: { - [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: currentTransactionViolations, - }, - }, - }); - } - if ( - violationsOnyxData && - ((iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN) === CONST.REPORT.STATUS_NUM.OPEN || - (hasModifiedReimbursable && iouReport?.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED)) - ) { - const currentNextStep = iouReportNextStep ?? {}; - const shouldFixViolations = Array.isArray(violationsOnyxData.value) && violationsOnyxData.value.length > 0; - const moneyRequestReport = updatedMoneyRequestReport ?? iouReport ?? undefined; - const hasViolations = hasViolationsReportUtils(moneyRequestReport?.reportID, allTransactionViolations, currentUserAccountIDParam, currentUserEmailParam); - const optimisticNextStep = buildOptimisticNextStep({ - report: moneyRequestReport, - predictedNextStatus: iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN, - shouldFixViolations, - currentUserAccountIDParam, - currentUserEmailParam, - hasViolations, - isASAPSubmitBetaEnabled, - policy, - }); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport?.reportID}`, - // buildOptimisticNextStep is used in parallel - // eslint-disable-next-line @typescript-eslint/no-deprecated - value: buildNextStepNew({ - report: moneyRequestReport, - predictedNextStatus: iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN, - shouldFixViolations, - currentUserAccountIDParam, - currentUserEmailParam, - hasViolations, - isASAPSubmitBetaEnabled, - policy, - }), - }); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: { - nextStep: optimisticNextStep, - pendingFields: { - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${iouReport?.reportID}`, - value: currentNextStep, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: { - nextStep: iouReport?.nextStep ?? null, - pendingFields: { - nextStep: null, - }, - }, - }); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: { - pendingFields: { - nextStep: null, - }, - }, - }); - } + removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } + const {params, onyxData} = data; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); +} - // Reset the transaction thread to its original state - if (transactionThreadReport?.reportID) { - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, - value: transactionThreadReport, - }); +/** Updates the billable field of an expense */ +function updateMoneyRequestBillable({ + transactionID, + transactionThreadReport, + parentReport, + value, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + parentReportNextStep, +}: { + transactionID: string | undefined; + transactionThreadReport: OnyxEntry; + parentReport: OnyxEntry; + value: boolean; + policy: OnyxEntry; + policyTagList: OnyxEntry; + policyCategories: OnyxEntry; + currentUserAccountIDParam: number; + currentUserEmailParam: string; + isASAPSubmitBetaEnabled: boolean; + parentReportNextStep: OnyxEntry; +}) { + if (!transactionID || !transactionThreadReport?.reportID) { + return; } - - return { - params: apiParams, - onyxData: {optimisticData, successData, failureData}, + const transactionChanges: TransactionChanges = { + billable: value, }; + const {params, onyxData} = getUpdateMoneyRequestParams({ + transactionID, + transactionThreadReport, + iouReport: parentReport, + transactionChanges, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + iouReportNextStep: parentReportNextStep, + }); + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); } -/** - * @param transactionID - * @param transactionThreadReportID - * @param transactionChanges - * @param [transactionChanges.created] Present when updated the date field - * @param policy May be undefined, an empty object, or an object matching the Policy type (src/types/onyx/Policy.ts) - * @param [shouldBuildOptimisticModifiedExpenseReportAction=true] When true, build an optimistic MODIFIED_EXPENSE report action. - */ -function getUpdateTrackExpenseParams( - transactionID: string | undefined, - transactionThreadReportID: string | undefined, - transactionChanges: TransactionChanges, - policy: OnyxEntry, - shouldBuildOptimisticModifiedExpenseReportAction = true, -): UpdateMoneyRequestData< - typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT -> { - const optimisticData: Array> = []; - const successData: Array> = []; - const failureData: Array> = []; - - // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData - const pendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); - const clearedPendingFields = getClearedPendingFields(transactionChanges); - const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')])); - - // Step 2: Get all the collections being updated - const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; - const updatedTransaction = transaction - ? getUpdatedTransaction({ - transaction, - transactionChanges, - isFromExpenseReport: false, - policy, - }) - : null; - const transactionDetails = getTransactionDetails(updatedTransaction); - - if (transactionDetails?.waypoints) { - // This needs to be a JSON string since we're sending this to the MapBox API - transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints); - } - - const dataToIncludeInParams: Partial = Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => key in transactionChanges)); - - const apiParams: UpdateMoneyRequestParams = { - ...dataToIncludeInParams, - reportID: chatReport?.reportID, - transactionID, - }; - - const hasPendingWaypoints = 'waypoints' in transactionChanges; - const hasModifiedDistanceRate = 'customUnitRateID' in transactionChanges; - if (transaction && updatedTransaction && (hasPendingWaypoints || hasModifiedDistanceRate)) { - // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors - successData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - value: null, - }); - - // Revert the transaction's amount to the original value on failure. - // The IOU Report will be fully reverted in the failureData further below. - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - amount: transaction.amount, - modifiedAmount: transaction.modifiedAmount, - modifiedMerchant: transaction.modifiedMerchant, - }, - }); - } - - // Step 3: Build the modified expense report actions - // We don't create a modified report action if: - // - we're updating the waypoints - // - we're updating the distance rate while the waypoints are still pending - // - we're merging two expenses (server does not create MODIFIED_EXPENSE in this flow) - // In these cases, there isn't a valid optimistic mileage data we can use, - // and the report action is created on the server with the distance-related response from the MapBox API - const allowNegative = shouldEnableNegative(transactionThread ?? undefined); - const updatedReportAction = shouldBuildOptimisticModifiedExpenseReportAction - ? buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, false, policy, updatedTransaction, allowNegative) - : null; - if (!hasPendingWaypoints && !(hasModifiedDistanceRate && isFetchingWaypointsFromServer(transaction)) && updatedReportAction) { - apiParams.reportActionID = updatedReportAction.reportActionID; - - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, - value: { - [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction, - }, - }); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, - value: { - [updatedReportAction.reportActionID]: {pendingAction: null}, - }, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, - value: { - [updatedReportAction.reportActionID]: { - ...(updatedReportAction as OnyxTypes.ReportAction), - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), - }, - }, - }); - } - - // Step 4: Update the report preview message (and report header) so LHN amount tracked is correct. - // Optimistically modify the transaction and the transaction thread - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - ...updatedTransaction, - pendingFields, - errorFields: null, - }, - }); - - if (updatedReportAction) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, - value: { - lastActorAccountID: updatedReportAction.actorAccountID, - }, - }); - } - - if (isScanning(transaction) && transactionThread?.parentReportActionID && ('amount' in transactionChanges || 'currency' in transactionChanges)) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, - value: {[transactionThread.parentReportActionID]: {originalMessage: {whisperedTo: []}}}, - }); - } - - // Clear out the error fields and loading states on success - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - pendingFields: clearedPendingFields, - isLoading: false, - errorFields: null, - routes: null, - }, - }); - - // Clear out loading states, pending fields, and add the error fields - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - ...transaction, - pendingFields: clearedPendingFields, - isLoading: false, - errorFields, - }, - }); - - // Reset the transaction thread to its original state - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, - value: transactionThread, - }); - - return { - params: apiParams, - onyxData: {optimisticData, successData, failureData}, - }; -} - -type UpdateMoneyRequestDateParams = { - transactionID: string; - transactionThreadReport: OnyxEntry; - parentReport: OnyxEntry; - transactions: OnyxCollection; - transactionViolations: OnyxCollection; - value: string; - policy: OnyxEntry; - policyTags: OnyxEntry; - policyCategories: OnyxEntry; - currentUserAccountIDParam: number; - currentUserEmailParam: string; - isASAPSubmitBetaEnabled: boolean; - parentReportNextStep: OnyxEntry; -}; - -/** Updates the created date of an expense */ -function updateMoneyRequestDate({ - transactionID, - transactionThreadReport, - parentReport, - transactions, - transactionViolations, - value, - policy, - policyTags, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - parentReportNextStep, -}: UpdateMoneyRequestDateParams) { - const transactionChanges: TransactionChanges = { - created: value, - }; - let data: UpdateMoneyRequestData; - // eslint-disable-next-line @typescript-eslint/no-deprecated - if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) { - data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy); - } else { - data = getUpdateMoneyRequestParams({ - transactionID, - transactionThreadReport, - iouReport: parentReport, - transactionChanges, - policy, - policyTagList: policyTags, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - iouReportNextStep: parentReportNextStep, - }); - removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); - } - const {params, onyxData} = data; - API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); -} - -/** Updates the billable field of an expense */ -function updateMoneyRequestBillable({ +function updateMoneyRequestReimbursable({ transactionID, transactionThreadReport, parentReport, @@ -4949,7 +3761,7 @@ function updateMoneyRequestBillable({ return; } const transactionChanges: TransactionChanges = { - billable: value, + reimbursable: value, }; const {params, onyxData} = getUpdateMoneyRequestParams({ transactionID, @@ -4964,10 +3776,11 @@ function updateMoneyRequestBillable({ isASAPSubmitBetaEnabled, iouReportNextStep: parentReportNextStep, }); - API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); } -function updateMoneyRequestReimbursable({ +/** Updates the merchant field of an expense */ +function updateMoneyRequestMerchant({ transactionID, transactionThreadReport, parentReport, @@ -4980,10 +3793,10 @@ function updateMoneyRequestReimbursable({ isASAPSubmitBetaEnabled, parentReportNextStep, }: { - transactionID: string | undefined; + transactionID: string; transactionThreadReport: OnyxEntry; parentReport: OnyxEntry; - value: boolean; + value: string; policy: OnyxEntry; policyTagList: OnyxEntry; policyCategories: OnyxEntry; @@ -4992,56 +3805,8 @@ function updateMoneyRequestReimbursable({ isASAPSubmitBetaEnabled: boolean; parentReportNextStep: OnyxEntry; }) { - if (!transactionID || !transactionThreadReport?.reportID) { - return; - } const transactionChanges: TransactionChanges = { - reimbursable: value, - }; - const {params, onyxData} = getUpdateMoneyRequestParams({ - transactionID, - transactionThreadReport, - iouReport: parentReport, - transactionChanges, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - iouReportNextStep: parentReportNextStep, - }); - API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); -} - -/** Updates the merchant field of an expense */ -function updateMoneyRequestMerchant({ - transactionID, - transactionThreadReport, - parentReport, - value, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - parentReportNextStep, -}: { - transactionID: string; - transactionThreadReport: OnyxEntry; - parentReport: OnyxEntry; - value: string; - policy: OnyxEntry; - policyTagList: OnyxEntry; - policyCategories: OnyxEntry; - currentUserAccountIDParam: number; - currentUserEmailParam: string; - isASAPSubmitBetaEnabled: boolean; - parentReportNextStep: OnyxEntry; -}) { - const transactionChanges: TransactionChanges = { - merchant: value, + merchant: value, }; let data: UpdateMoneyRequestData; // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -5439,1206 +4204,173 @@ function updateMoneyRequestCategory({ API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); } -/** Updates the description of an expense */ -function updateMoneyRequestDescription({ - transactionID, - transactionThreadReport, - parentReport, - comment, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - parentReportNextStep, -}: { - transactionID: string; - transactionThreadReport: OnyxEntry; - parentReport: OnyxEntry; - comment: string; - policy: OnyxEntry; - policyTagList: OnyxEntry; - policyCategories: OnyxEntry; - currentUserAccountIDParam: number; - currentUserEmailParam: string; - isASAPSubmitBetaEnabled: boolean; - parentReportNextStep: OnyxEntry; -}) { - const parsedComment = getParsedComment(comment); - const transactionChanges: TransactionChanges = { - comment: parsedComment, - }; - let data: UpdateMoneyRequestData; - // eslint-disable-next-line @typescript-eslint/no-deprecated - if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) { - data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy); - } else { - data = getUpdateMoneyRequestParams({ - transactionID, - transactionThreadReport, - iouReport: parentReport, - transactionChanges, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - iouReportNextStep: parentReportNextStep, - }); - } - const {params, onyxData} = data; - params.description = parsedComment; - API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); -} - -/** Updates the distance rate of an expense */ -function updateMoneyRequestDistanceRate({ - transactionID, - transactionThreadReport, - parentReport, - rateID, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - updatedTaxAmount, - updatedTaxCode, - parentReportNextStep, -}: { - transactionID: string; - transactionThreadReport: OnyxEntry; - parentReport: OnyxEntry; - rateID: string; - policy: OnyxEntry; - policyTagList: OnyxEntry; - policyCategories: OnyxEntry; - currentUserAccountIDParam: number; - currentUserEmailParam: string; - isASAPSubmitBetaEnabled: boolean; - updatedTaxAmount?: number; - updatedTaxCode?: string; - parentReportNextStep: OnyxEntry; -}) { - const transactionChanges: TransactionChanges = { - customUnitRateID: rateID, - ...(typeof updatedTaxAmount === 'number' ? {taxAmount: updatedTaxAmount} : {}), - ...(updatedTaxCode ? {taxCode: updatedTaxCode} : {}), - }; - - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - if (transaction) { - const existingDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; - const newDistanceUnit = DistanceRequestUtils.getRateByCustomUnitRateID({customUnitRateID: rateID, policy})?.unit; - - // If the distanceUnit is set and the rate is changed to one that has a different unit, mark the merchant as modified to make the distance field pending - if (existingDistanceUnit && newDistanceUnit && newDistanceUnit !== existingDistanceUnit) { - transactionChanges.merchant = getMerchant(transaction); - } - } - - let data: UpdateMoneyRequestData; - // eslint-disable-next-line @typescript-eslint/no-deprecated - if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) { - data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy); - } else { - data = getUpdateMoneyRequestParams({ - transactionID, - transactionThreadReport, - iouReport: parentReport, - transactionChanges, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - iouReportNextStep: parentReportNextStep, - }); - } - const {params, onyxData} = data; - // `taxAmount` & `taxCode` only needs to be updated in the optimistic data, so we need to remove them from the params - const {taxAmount, taxCode, ...paramsWithoutTaxUpdated} = params; - API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE_RATE, paramsWithoutTaxUpdated, onyxData); -} - -const getConvertTrackedExpenseInformation = ( - transactionID: string | undefined, - actionableWhisperReportActionID: string | undefined, - moneyRequestReportID: string | undefined, - linkedTrackedExpenseReportAction: OnyxTypes.ReportAction, - linkedTrackedExpenseReportID: string, - transactionThreadReportID: string | undefined, - resolution: IOUAction, - isLinkedTrackedExpenseReportArchived: boolean | undefined, -) => { - const optimisticData: Array< - OnyxUpdate - > = []; - const successData: Array> = []; - const failureData: Array< - OnyxUpdate - > = []; - - // Delete the transaction from the track expense report - const { - optimisticData: deleteOptimisticData, - successData: deleteSuccessData, - failureData: deleteFailureData, - } = getDeleteTrackExpenseInformation( - allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${linkedTrackedExpenseReportID}`], - transactionID, - linkedTrackedExpenseReportAction, - isLinkedTrackedExpenseReportArchived, - false, - true, - actionableWhisperReportActionID, - resolution, - true, - ); - - optimisticData?.push(...deleteOptimisticData); - successData?.push(...deleteSuccessData); - failureData?.push(...deleteFailureData); - - // Build modified expense report action with the transaction changes - const modifiedExpenseReportAction = buildOptimisticMovedTransactionAction(transactionThreadReportID, linkedTrackedExpenseReportID ?? CONST.REPORT.UNREPORTED_REPORT_ID); - - optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: { - [modifiedExpenseReportAction.reportActionID]: modifiedExpenseReportAction, - }, - }); - successData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: { - [modifiedExpenseReportAction.reportActionID]: {pendingAction: null}, - }, - }); - failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: { - [modifiedExpenseReportAction.reportActionID]: { - ...modifiedExpenseReportAction, - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), - }, - }, - }); - - return {optimisticData, successData, failureData, modifiedExpenseReportActionID: modifiedExpenseReportAction.reportActionID}; -}; - -type GetConvertTrackedExpenseWorkspaceFailureDataParams = { - iouReportID: string; - iouCreatedReportActionID: string | undefined; - iouReportActionID: string; - chatReportID: string; - chatPreviewReportActionID: string; - transactionID: string; - linkedTrackedExpenseReportID: string; - linkedTrackedExpenseReportActionID: string; - transactionThreadReportID: string | undefined; - modifiedExpenseReportActionID: string; -}; - -function getConvertTrackedExpenseWorkspaceFailureData({ - iouReportID, - iouCreatedReportActionID, - iouReportActionID, - chatReportID, - chatPreviewReportActionID, - transactionID, - linkedTrackedExpenseReportID, - linkedTrackedExpenseReportActionID, - transactionThreadReportID, - modifiedExpenseReportActionID, -}: GetConvertTrackedExpenseWorkspaceFailureDataParams): Array> { - const additionalFailureData: Array> = []; - const previousIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; - const shouldClearOptimisticIOUReport = !previousIOUReport || previousIOUReport.pendingFields?.createChat === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; - - if (shouldClearOptimisticIOUReport) { - additionalFailureData.push( - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, - value: null, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - value: null, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${iouReportID}`, - value: null, - }, - ); - } else { - additionalFailureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, - value: previousIOUReport, - }); - } - - const previousReportPreviewAction = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`]?.[chatPreviewReportActionID]; - additionalFailureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, - value: { - [chatPreviewReportActionID]: previousReportPreviewAction ?? null, - }, - }); - - const previousIOUReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`]; - const previousIOUAction = previousIOUReportActions?.[iouReportActionID]; - additionalFailureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, - value: { - [iouReportActionID]: previousIOUAction ?? null, - ...(iouCreatedReportActionID ? {[iouCreatedReportActionID]: previousIOUReportActions?.[iouCreatedReportActionID] ?? null} : {}), - }, - }); - - additionalFailureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - pendingAction: null, - reportID: linkedTrackedExpenseReportID, - status: CONST.TRANSACTION.STATUS.POSTED, - }, - }); - - if (transactionThreadReportID) { - additionalFailureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: { - [modifiedExpenseReportActionID]: null, - }, - }); - } - - additionalFailureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${linkedTrackedExpenseReportID}`, - value: { - [linkedTrackedExpenseReportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), - pendingAction: null, - }, - }, - }); - - return additionalFailureData; -} - -type ConvertTrackedWorkspaceParams = { - category: string | undefined; - tag: string | undefined; - taxCode: string; - taxAmount: number; - billable: boolean | undefined; - policyID: string; - receipt: Receipt | undefined; - waypoints?: string; - customUnitID?: string; - customUnitRateID?: string; - reimbursable?: boolean; -}; - -type AddTrackedExpenseToPolicyParam = { - amount: number; - currency: string; - comment: string; - created: string; - merchant: string; - transactionID: string; - reimbursable: boolean; - actionableWhisperReportActionID: string | undefined; - moneyRequestReportID: string; - reportPreviewReportActionID: string; - modifiedExpenseReportActionID: string; - moneyRequestCreatedReportActionID: string | undefined; - moneyRequestPreviewReportActionID: string; - distance: number | undefined; - attendees: string | undefined; -} & ConvertTrackedWorkspaceParams; - -type ConvertTrackedExpenseToRequestParams = { - payerParams: { - accountID: number; - email: string; - }; - transactionParams: { - transactionID: string; - actionableWhisperReportActionID: string | undefined; - linkedTrackedExpenseReportAction: OnyxTypes.ReportAction; - linkedTrackedExpenseReportID: string; - amount: number; - currency: string; - comment: string; - merchant: string; - created: string; - attendees?: Attendee[]; - transactionThreadReportID?: string; - distance?: number; - isLinkedTrackedExpenseReportArchived: boolean | undefined; - waypoints?: string; - customUnitRateID?: string; - isDistance?: boolean; - }; - chatParams: { - reportID: string; - createdReportActionID: string | undefined; - reportPreviewReportActionID: string; - }; - iouParams: { - reportID: string; - createdReportActionID: string | undefined; - reportActionID: string; - }; - onyxData: OnyxData; - workspaceParams?: ConvertTrackedWorkspaceParams; -}; - -function addTrackedExpenseToPolicy(parameters: AddTrackedExpenseToPolicyParam, onyxData: OnyxData) { - API.write(WRITE_COMMANDS.ADD_TRACKED_EXPENSE_TO_POLICY, parameters, onyxData); -} - -function convertTrackedExpenseToRequest(convertTrackedExpenseParams: ConvertTrackedExpenseToRequestParams) { - const {payerParams, transactionParams, chatParams, iouParams, onyxData, workspaceParams} = convertTrackedExpenseParams; - const {accountID: payerAccountID, email: payerEmail} = payerParams; - const { - transactionID, - actionableWhisperReportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - amount, - distance, - currency, - comment, - merchant, - created, - attendees, - transactionThreadReportID, - isLinkedTrackedExpenseReportArchived, - waypoints, - customUnitRateID, - isDistance, - } = transactionParams; - const optimisticData: Array> = []; - const successData: Array> = []; - const failureData: Array> = []; - - optimisticData?.push(...(onyxData.optimisticData ?? [])); - successData?.push(...(onyxData.successData ?? [])); - failureData?.push(...(onyxData.failureData ?? [])); - - const convertTrackedExpenseInformation = getConvertTrackedExpenseInformation( - transactionID, - actionableWhisperReportActionID, - iouParams.reportID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - transactionThreadReportID, - CONST.IOU.ACTION.SUBMIT, - isLinkedTrackedExpenseReportArchived, - ); - optimisticData?.push(...(convertTrackedExpenseInformation.optimisticData ?? [])); - successData?.push(...(convertTrackedExpenseInformation.successData ?? [])); - failureData?.push(...(convertTrackedExpenseInformation.failureData ?? [])); - - if (transactionThreadReportID) { - const transactionThreadReport = getReportOrDraftReport(transactionThreadReportID); - - optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, - value: { - parentReportActionID: iouParams.reportActionID, - parentReportID: iouParams.reportID, - }, - }); - - failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, - value: { - parentReportActionID: transactionThreadReport?.parentReportActionID, - parentReportID: transactionThreadReport?.parentReportID, - }, - }); - } - - if (workspaceParams) { - const additionalFailureData = getConvertTrackedExpenseWorkspaceFailureData({ - iouReportID: iouParams.reportID, - iouCreatedReportActionID: iouParams.createdReportActionID, - iouReportActionID: iouParams.reportActionID, - chatReportID: chatParams.reportID, - chatPreviewReportActionID: chatParams.reportPreviewReportActionID, - transactionID, - linkedTrackedExpenseReportID, - linkedTrackedExpenseReportActionID: linkedTrackedExpenseReportAction.reportActionID, - transactionThreadReportID, - modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID, - }); - - // Removing the ghost IOU report on API failure which can cause unexpected errors. - failureData?.push(...additionalFailureData); - - const params = { - amount, - distance, - currency, - comment, - created, - merchant, - attendees: attendees ? JSON.stringify(attendees) : undefined, - reimbursable: true, - transactionID, - actionableWhisperReportActionID, - moneyRequestReportID: iouParams.reportID, - moneyRequestCreatedReportActionID: iouParams.createdReportActionID, - moneyRequestPreviewReportActionID: iouParams.reportActionID, - modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID, - reportPreviewReportActionID: chatParams.reportPreviewReportActionID, - ...workspaceParams, - }; - - addTrackedExpenseToPolicy(params, {optimisticData, successData, failureData}); - return; - } - - const parameters = { - attendees, - amount, - distance, - currency, - comment, - created, - merchant, - payerAccountID, - payerEmail, - chatReportID: chatParams.reportID, - transactionID, - actionableWhisperReportActionID, - createdChatReportActionID: chatParams.createdReportActionID, - moneyRequestReportID: iouParams.reportID, - moneyRequestCreatedReportActionID: iouParams.createdReportActionID, - moneyRequestPreviewReportActionID: iouParams.reportActionID, - transactionThreadReportID, - modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID, - reportPreviewReportActionID: chatParams.reportPreviewReportActionID, - isDistance, - customUnitRateID, - waypoints, - }; - API.write(WRITE_COMMANDS.CONVERT_TRACKED_EXPENSE_TO_REQUEST, parameters, {optimisticData, successData, failureData}); -} - -/** - * Move multiple tracked expenses from self-DM to an IOU report - */ -function convertBulkTrackedExpensesToIOU({ - transactionIDs, - iouReport, - chatReport, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam, - currentUserEmailParam, - transactionViolations, - policyRecentlyUsedCurrencies, - quickAction, - personalDetails, - betas, -}: { - transactionIDs: string[]; - iouReport: OnyxEntry; - chatReport: OnyxEntry; - isASAPSubmitBetaEnabled: boolean; - currentUserAccountIDParam: number; - currentUserEmailParam: string; - transactionViolations: OnyxCollection; - policyRecentlyUsedCurrencies: string[]; - quickAction: OnyxEntry; - personalDetails: OnyxEntry; - betas: OnyxEntry; -}) { - const iouReportID = iouReport?.reportID; - - if (!iouReport || !isMoneyRequestReportReportUtils(iouReport)) { - Log.warn('[convertBulkTrackedExpensesToIOU] Invalid IOU report', {iouReportID}); - return; - } - - if (!chatReport?.reportID) { - Log.warn('[convertBulkTrackedExpensesToIOU] No chat report found for IOU', {iouReportID}); - return; - } - - const participantAccountIDs = getReportRecipientAccountIDs(iouReport, userAccountID); - const payerAccountID = participantAccountIDs.at(0); - - if (!payerAccountID) { - Log.warn('[convertBulkTrackedExpensesToIOU] No payer found', {iouReportID, participantAccountIDs}); - return; - } - - const payerEmail = personalDetails?.[payerAccountID]?.login ?? ''; - const selfDMReportID = findSelfDMReportID(); - - if (!selfDMReportID) { - Log.warn('[convertBulkTrackedExpensesToIOU] Self DM not found'); - return; - } - - const selfDMReportActions = getAllReportActions(selfDMReportID); - - for (const transactionID of transactionIDs) { - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - if (!transaction) { - Log.warn('[convertBulkTrackedExpensesToIOU] Transaction not found', {transactionID}); - continue; - } - - const linkedTrackedExpenseReportAction = Object.values(selfDMReportActions).find((action) => { - if (!isMoneyRequestAction(action)) { - return false; - } - const originalMessage = getOriginalMessage(action); - return originalMessage?.IOUTransactionID === transactionID; - }); - - if (!linkedTrackedExpenseReportAction) { - Log.warn('[convertBulkTrackedExpensesToIOU] Tracked expense IOU action not found', {transactionID}); - continue; - } - - const actionableWhisperReportActionID = getTrackExpenseActionableWhisper(transactionID, selfDMReportID)?.reportActionID; - - const commentText = typeof transaction.comment === 'string' ? transaction.comment : (transaction.comment?.comment ?? ''); - const parsedComment = getParsedComment(Parser.htmlToMarkdown(commentText)); - - const attendees = transaction.comment?.attendees; - - const transactionThreadReportID = (linkedTrackedExpenseReportAction as OnyxTypes.ReportAction).childReportID; - - if (!transactionThreadReportID) { - Log.warn('[convertBulkTrackedExpensesToIOU] No transaction thread found for tracked expense, skipping', { - transactionID, - actionReportActionID: (linkedTrackedExpenseReportAction as OnyxTypes.ReportAction).reportActionID, - }); - continue; - } - - const participantParams = { - payeeAccountID: userAccountID, - payeeEmail: currentUserEmail, - participant: { - accountID: payerAccountID, - login: payerEmail, - }, - }; - - const transactionParams = { - amount: getAmount(transaction), - currency: getCurrency(transaction), - comment: parsedComment, - merchant: getMerchant(transaction), - created: transaction.created, - attendees, - actionableWhisperReportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID: selfDMReportID, - isLinkedTrackedExpenseReportArchived: false, - }; - - const { - payerAccountID: moneyRequestPayerAccountID, - payerEmail: moneyRequestPayerEmail, - iouReport: moneyRequestIOUReport, - chatReport: moneyRequestChatReport, - transaction: moneyRequestTransaction, - iouAction, - createdChatReportActionID, - createdIOUReportActionID, - reportPreviewAction, - transactionThreadReportID: moneyRequestTransactionThreadReportID, - onyxData, - } = getMoneyRequestInformation({ - parentChatReport: chatReport, - participantParams, - transactionParams, - moneyRequestReportID: iouReportID, - existingTransactionID: transactionID, - existingTransaction: transaction, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam, - currentUserEmailParam, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - personalDetails, - betas, - }); - - const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); - const transactionWaypoints = getWaypoints(transaction); - const sanitizedWaypointsForBulk = transactionWaypoints ? JSON.stringify(sanitizeRecentWaypoints(transactionWaypoints)) : undefined; - - const convertParams: ConvertTrackedExpenseToRequestParams = { - payerParams: { - accountID: moneyRequestPayerAccountID, - email: moneyRequestPayerEmail, - }, - transactionParams: { - amount: getAmount(transaction), - currency: getCurrency(transaction), - comment: parsedComment, - merchant: getMerchant(transaction), - created: transaction.created, - attendees, - transactionID: moneyRequestTransaction.transactionID, - actionableWhisperReportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID: selfDMReportID, - transactionThreadReportID: moneyRequestTransactionThreadReportID, - isLinkedTrackedExpenseReportArchived: false, - isDistance: isDistanceRequest, - customUnitRateID: isDistanceRequest ? getRateID(transaction) : undefined, - waypoints: isDistanceRequest ? sanitizedWaypointsForBulk : undefined, - distance: isDistanceRequest ? (transaction.comment?.customUnit?.quantity ?? undefined) : undefined, - }, - chatParams: { - reportID: moneyRequestChatReport.reportID, - createdReportActionID: createdChatReportActionID, - reportPreviewReportActionID: reportPreviewAction.reportActionID, - }, - iouParams: { - reportID: moneyRequestIOUReport.reportID, - createdReportActionID: createdIOUReportActionID, - reportActionID: iouAction.reportActionID, - }, - onyxData, - }; - - convertTrackedExpenseToRequest(convertParams); - } -} - -function categorizeTrackedExpense(trackedExpenseParams: TrackedExpenseParams) { - const {onyxData, reportInformation, transactionParams, policyParams, createdWorkspaceParams} = trackedExpenseParams; - const {optimisticData, successData, failureData} = onyxData ?? {}; - const {transactionID} = transactionParams; - const {isDraftPolicy} = policyParams; - const { - actionableWhisperReportActionID, - moneyRequestReportID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - transactionThreadReportID, - isLinkedTrackedExpenseReportArchived, - } = reportInformation; - const { - optimisticData: moveTransactionOptimisticData, - successData: moveTransactionSuccessData, - failureData: moveTransactionFailureData, - modifiedExpenseReportActionID, - } = getConvertTrackedExpenseInformation( - transactionID, - actionableWhisperReportActionID, - moneyRequestReportID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - transactionThreadReportID, - CONST.IOU.ACTION.CATEGORIZE, - isLinkedTrackedExpenseReportArchived, - ); - - optimisticData?.push(...moveTransactionOptimisticData); - successData?.push(...moveTransactionSuccessData); - failureData?.push(...moveTransactionFailureData); - - const parameters: CategorizeTrackedExpenseApiParams = { - ...{ - ...reportInformation, - linkedTrackedExpenseReportAction: undefined, - }, - ...policyParams, - ...transactionParams, - modifiedExpenseReportActionID, - policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, - policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, - adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, - adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, - engagementChoice: createdWorkspaceParams?.engagementChoice, - guidedSetupData: createdWorkspaceParams?.guidedSetupData, - description: transactionParams.comment, - customUnitID: createdWorkspaceParams?.customUnitID, - customUnitRateID: createdWorkspaceParams?.customUnitRateID ?? transactionParams.customUnitRateID, - attendees: transactionParams.attendees ? JSON.stringify(transactionParams.attendees) : undefined, - }; - - API.write(WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); - - // If a draft policy was used, then the CategorizeTrackedExpense command will create a real one - // so let's track that conversion here - if (isDraftPolicy) { - GoogleTagManager.publishEvent(CONST.ANALYTICS.EVENT.WORKSPACE_CREATED, userAccountID); - } -} - -function shareTrackedExpense(trackedExpenseParams: TrackedExpenseParams) { - const {onyxData: trackedExpenseOnyxData, reportInformation, transactionParams, policyParams, createdWorkspaceParams, accountantParams} = trackedExpenseParams; - - const policyID = policyParams?.policyID; - const chatReportID = reportInformation?.chatReportID; - const accountantEmail = addSMSDomainIfPhoneNumber(accountantParams?.accountant?.login); - const accountantAccountID = accountantParams?.accountant?.accountID; - - if (!policyID || !chatReportID || !accountantEmail || !accountantAccountID) { - return; - } - - const onyxData: OnyxData< - | BuildOnyxDataForTrackExpenseKeys - | BuildPolicyDataKeys - | typeof ONYXKEYS.NVP_RECENT_WAYPOINTS - | typeof ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE - | typeof ONYXKEYS.GPS_DRAFT_DETAILS - | typeof ONYXKEYS.SELF_DM_REPORT_ID - > = { - optimisticData: trackedExpenseOnyxData?.optimisticData ?? [], - successData: trackedExpenseOnyxData?.successData ?? [], - failureData: trackedExpenseOnyxData?.failureData ?? [], - }; - - const {transactionID} = transactionParams; - const { - actionableWhisperReportActionID, - moneyRequestPreviewReportActionID, - moneyRequestCreatedReportActionID, - reportPreviewReportActionID, - moneyRequestReportID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - transactionThreadReportID, - isLinkedTrackedExpenseReportArchived, - } = reportInformation; - - const convertTrackedExpenseInformation = getConvertTrackedExpenseInformation( - transactionID, - actionableWhisperReportActionID, - moneyRequestReportID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - transactionThreadReportID, - CONST.IOU.ACTION.SHARE, - isLinkedTrackedExpenseReportArchived, - ); - - onyxData.optimisticData?.push(...(convertTrackedExpenseInformation.optimisticData ?? [])); - onyxData.successData?.push(...(convertTrackedExpenseInformation.successData ?? [])); - onyxData.failureData?.push(...(convertTrackedExpenseInformation.failureData ?? [])); - - const policyEmployeeList = policyParams?.policy?.employeeList; - if (policyParams.policy && !policyEmployeeList?.[accountantEmail]) { - const policyMemberAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policyEmployeeList, false, false)); - const { - optimisticData: addAccountantToWorkspaceOptimisticData, - successData: addAccountantToWorkspaceSuccessData, - failureData: addAccountantToWorkspaceFailureData, - } = buildAddMembersToWorkspaceOnyxData({[accountantEmail]: accountantAccountID}, policyParams.policy, policyMemberAccountIDs, CONST.POLICY.ROLE.ADMIN, formatPhoneNumber); - onyxData.optimisticData?.push(...addAccountantToWorkspaceOptimisticData); - onyxData.successData?.push(...addAccountantToWorkspaceSuccessData); - onyxData.failureData?.push(...addAccountantToWorkspaceFailureData); - } else if (policyEmployeeList?.[accountantEmail].role !== CONST.POLICY.ROLE.ADMIN) { - const { - optimisticData: addAccountantToWorkspaceOptimisticData, - successData: addAccountantToWorkspaceSuccessData, - failureData: addAccountantToWorkspaceFailureData, - } = buildUpdateWorkspaceMembersRoleOnyxData(policyParams?.policy, [accountantEmail], [accountantAccountID], CONST.POLICY.ROLE.ADMIN); - onyxData.optimisticData?.push(...addAccountantToWorkspaceOptimisticData); - onyxData.successData?.push(...addAccountantToWorkspaceSuccessData); - onyxData.failureData?.push(...addAccountantToWorkspaceFailureData); - } - - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`]; - const chatReportParticipants = chatReport?.participants; - if (chatReport && !chatReportParticipants?.[accountantAccountID]) { - const { - optimisticData: inviteAccountantToRoomOptimisticData, - successData: inviteAccountantToRoomSuccessData, - failureData: inviteAccountantToRoomFailureData, - } = buildInviteToRoomOnyxData(chatReport, {[accountantEmail]: accountantAccountID}, formatPhoneNumber); - onyxData.optimisticData?.push(...inviteAccountantToRoomOptimisticData); - onyxData.successData?.push(...inviteAccountantToRoomSuccessData); - onyxData.failureData?.push(...inviteAccountantToRoomFailureData); - } - - const parameters: ShareTrackedExpenseParams = { - ...transactionParams, - policyID, - moneyRequestPreviewReportActionID, - moneyRequestReportID, - moneyRequestCreatedReportActionID, - actionableWhisperReportActionID, - modifiedExpenseReportActionID: convertTrackedExpenseInformation.modifiedExpenseReportActionID, - reportPreviewReportActionID, - policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID, - policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID, - adminsChatReportID: createdWorkspaceParams?.adminsChatReportID, - adminsCreatedReportActionID: createdWorkspaceParams?.adminsCreatedReportActionID, - engagementChoice: createdWorkspaceParams?.engagementChoice, - guidedSetupData: createdWorkspaceParams?.guidedSetupData, - policyName: createdWorkspaceParams?.policyName, - description: transactionParams.comment, - customUnitID: createdWorkspaceParams?.customUnitID, - customUnitRateID: createdWorkspaceParams?.customUnitRateID ?? transactionParams.customUnitRateID, - attendees: transactionParams.attendees ? JSON.stringify(transactionParams.attendees) : undefined, - accountantEmail, - }; - - API.write(WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, parameters, onyxData); -} - -/** - * Submit expense to another user - */ -function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report} { - const { - report, - existingIOUReport, - participantParams, - policyParams = {}, - transactionParams, - gpsPoint, - action, - shouldHandleNavigation = true, - backToReport, - shouldPlaySound = true, - optimisticChatReportID, - optimisticCreatedReportActionID, - optimisticIOUReportID, - optimisticReportPreviewActionID, - shouldGenerateTransactionThreadReport, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam, - currentUserEmailParam, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - existingTransactionDraft, - draftTransactionIDs = [], - isSelfTourViewed, - betas, - personalDetails, - } = requestMoneyInformation; - const {payeeAccountID} = participantParams; - const parsedComment = getParsedComment(transactionParams.comment ?? ''); - transactionParams.comment = parsedComment; - const { - amount, - distance, - currency, - merchant, - comment = '', - receipt, - category, - tag, - taxCode = '', - taxAmount = 0, - billable, - reimbursable, - created, - attendees, - actionableWhisperReportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - waypoints, - customUnitRateID, - isTestDrive, - isLinkedTrackedExpenseReportArchived, - type: transactionType, - count, - rate, - unit, - isFromGlobalCreate, - } = transactionParams; - - const testDriveCommentReportActionID = isTestDrive ? NumberUtils.rand64() : undefined; - - const sanitizedWaypoints = waypoints ? JSON.stringify(sanitizeRecentWaypoints(waypoints)) : undefined; - - // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function - const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); - const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; - const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; - const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseIOUUtils(action); - const existingTransactionID = existingTransactionDraft?.transactionID; - const existingTransaction = action === CONST.IOU.ACTION.SUBMIT ? existingTransactionDraft : allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]; - - const retryParams = { - ...requestMoneyInformation, - participantParams: { - ...requestMoneyInformation.participantParams, - participant: (({icons, ...rest}) => rest)(requestMoneyInformation.participantParams.participant), - }, - transactionParams: { - ...requestMoneyInformation.transactionParams, - receipt: undefined, - }, - }; - - const { - payerAccountID, - payerEmail, - iouReport, - chatReport, - transaction, - iouAction, - createdChatReportActionID, - createdIOUReportActionID, - reportPreviewAction, - transactionThreadReportID, - createdReportActionIDForThread, - onyxData, - } = getMoneyRequestInformation({ - parentChatReport: isMovingTransactionFromTrackExpense ? undefined : currentChatReport, - existingIOUReport, - participantParams, - policyParams, - transactionParams, - moneyRequestReportID, - existingTransactionID, - existingTransaction: isDistanceRequestTransactionUtils(existingTransaction) ? existingTransaction : undefined, - retryParams, - testDriveCommentReportActionID, - optimisticChatReportID, - optimisticCreatedReportActionID, - optimisticIOUReportID, - optimisticReportPreviewActionID, - shouldGenerateTransactionThreadReport, - action, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam, - currentUserEmailParam, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - betas, - personalDetails, - }); - const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport.reportID; - - if (shouldPlaySound) { - playSound(SOUNDS.DONE); - } - - switch (action) { - case CONST.IOU.ACTION.SUBMIT: { - if (!linkedTrackedExpenseReportAction || !linkedTrackedExpenseReportID) { - return {}; - } - const customUnitParams = isDistanceRequestTransactionUtils(transaction) - ? { - customUnitID: getDistanceRateCustomUnit(policyParams?.policy)?.customUnitID, - customUnitRateID, - } - : {}; - const workspaceParams = - isPolicyExpenseChatReportUtil(chatReport) && chatReport.policyID - ? { - receipt: isFileUploadable(receipt) ? receipt : undefined, - category, - tag, - taxCode, - taxAmount: Math.abs(taxAmount), - billable, - policyID: chatReport.policyID, - waypoints: sanitizedWaypoints, - reimbursable, - ...customUnitParams, - } - : undefined; - const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); - convertTrackedExpenseToRequest({ - payerParams: { - accountID: payerAccountID, - email: payerEmail, - }, - transactionParams: { - amount, - distance, - currency, - comment, - merchant, - created, - attendees, - transactionID: transaction.transactionID, - actionableWhisperReportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, - isLinkedTrackedExpenseReportArchived, - isDistance: isDistanceRequest, - customUnitRateID: isDistanceRequest ? customUnitRateID : undefined, - waypoints: isDistanceRequest ? sanitizedWaypoints : undefined, - }, - chatParams: { - reportID: chatReport.reportID, - createdReportActionID: createdChatReportActionID, - reportPreviewReportActionID: reportPreviewAction.reportActionID, - }, - iouParams: { - reportID: iouReport.reportID, - createdReportActionID: createdIOUReportActionID, - reportActionID: iouAction.reportActionID, - }, - onyxData, - workspaceParams, - }); - break; - } - default: { - // This is only required when inviting admins to test drive the app - const guidedSetupData: GuidedSetupData | undefined = isTestDrive - ? prepareOnboardingOnyxData({ - introSelected: {choice: CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER}, - engagementChoice: CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER, - onboardingMessage: getOnboardingMessages().onboardingMessages[CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER], - companySize: undefined, - isSelfTourViewed, - betas, - })?.guidedSetupData - : undefined; - - const parameters: RequestMoneyParams = { - debtorEmail: payerEmail, - debtorAccountID: payerAccountID, - amount, - currency, - comment, - created, - merchant, - iouReportID: iouReport.reportID, - chatReportID: chatReport.reportID, - transactionID: transaction.transactionID, - reportActionID: iouAction.reportActionID, - createdChatReportActionID, - createdIOUReportActionID, - reportPreviewReportActionID: reportPreviewAction.reportActionID, - receipt: isFileUploadable(receipt) ? receipt : undefined, - receiptState: receipt?.state, - category, - tag, - taxCode, - taxAmount, - billable, - // This needs to be a string of JSON because of limitations with the fetch() API and nested objects - receiptGpsPoints: gpsPoint ? JSON.stringify(gpsPoint) : undefined, - transactionThreadReportID, - createdReportActionIDForThread, - reimbursable, - description: parsedComment, - attendees: attendees ? JSON.stringify(attendees) : undefined, - isTestDrive, - guidedSetupData: guidedSetupData ? JSON.stringify(guidedSetupData) : undefined, - testDriveCommentReportActionID, - ...(transactionType === CONST.TRANSACTION.TYPE.TIME - ? { - type: transactionType, - count, - rate, - unit, - } - : {}), - }; - // eslint-disable-next-line rulesdir/no-multiple-api-calls - API.write(WRITE_COMMANDS.REQUEST_MONEY, parameters, onyxData); - } +/** Updates the description of an expense */ +function updateMoneyRequestDescription({ + transactionID, + transactionThreadReport, + parentReport, + comment, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + parentReportNextStep, +}: { + transactionID: string; + transactionThreadReport: OnyxEntry; + parentReport: OnyxEntry; + comment: string; + policy: OnyxEntry; + policyTagList: OnyxEntry; + policyCategories: OnyxEntry; + currentUserAccountIDParam: number; + currentUserEmailParam: string; + isASAPSubmitBetaEnabled: boolean; + parentReportNextStep: OnyxEntry; +}) { + const parsedComment = getParsedComment(comment); + const transactionChanges: TransactionChanges = { + comment: parsedComment, + }; + let data: UpdateMoneyRequestData; + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy); + } else { + data = getUpdateMoneyRequestParams({ + transactionID, + transactionThreadReport, + iouReport: parentReport, + transactionChanges, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + iouReportNextStep: parentReportNextStep, + }); } + const {params, onyxData} = data; + params.description = parsedComment; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); +} - if (shouldHandleNavigation) { - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => removeDraftTransactionsByIDs(draftTransactionIDs)); +/** Updates the distance rate of an expense */ +function updateMoneyRequestDistanceRate({ + transactionID, + transactionThreadReport, + parentReport, + rateID, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + updatedTaxAmount, + updatedTaxCode, + parentReportNextStep, +}: { + transactionID: string; + transactionThreadReport: OnyxEntry; + parentReport: OnyxEntry; + rateID: string; + policy: OnyxEntry; + policyTagList: OnyxEntry; + policyCategories: OnyxEntry; + currentUserAccountIDParam: number; + currentUserEmailParam: string; + isASAPSubmitBetaEnabled: boolean; + updatedTaxAmount?: number; + updatedTaxCode?: string; + parentReportNextStep: OnyxEntry; +}) { + const transactionChanges: TransactionChanges = { + customUnitRateID: rateID, + ...(typeof updatedTaxAmount === 'number' ? {taxAmount: updatedTaxAmount} : {}), + ...(updatedTaxCode ? {taxCode: updatedTaxCode} : {}), + }; - const trackReport = Navigation.getReportRouteByID(linkedTrackedExpenseReportAction?.childReportID); - if (trackReport?.key) { - Navigation.removeScreenByKey(trackReport.key); + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (transaction) { + const existingDistanceUnit = transaction?.comment?.customUnit?.distanceUnit; + const newDistanceUnit = DistanceRequestUtils.getRateByCustomUnitRateID({customUnitRateID: rateID, policy})?.unit; + + // If the distanceUnit is set and the rate is changed to one that has a different unit, mark the merchant as modified to make the distance field pending + if (existingDistanceUnit && newDistanceUnit && newDistanceUnit !== existingDistanceUnit) { + transactionChanges.merchant = getMerchant(transaction); } } - if (!requestMoneyInformation.isRetry) { - handleNavigateAfterExpenseCreate({ - activeReportID: backToReport ?? activeReportID, - transactionID: transaction.transactionID, - isFromGlobalCreate, - shouldHandleNavigation, + let data: UpdateMoneyRequestData; + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy); + } else { + data = getUpdateMoneyRequestParams({ + transactionID, + transactionThreadReport, + iouReport: parentReport, + transactionChanges, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + iouReportNextStep: parentReportNextStep, }); } - - if (activeReportID && !isMoneyRequestReport) { - Navigation.setNavigationActionToMicrotaskQueue(() => - setTimeout(() => { - notifyNewAction(activeReportID, reportPreviewAction, payeeAccountID === currentUserAccountIDParam); - }, CONST.TIMING.NOTIFY_NEW_ACTION_DELAY), - ); - } - - return {iouReport}; + const {params, onyxData} = data; + // `taxAmount` & `taxCode` only needs to be updated in the optimistic data, so we need to remove them from the params + const {taxAmount, taxCode, ...paramsWithoutTaxUpdated} = params; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE_RATE, paramsWithoutTaxUpdated, onyxData); } /** - * Track an expense + * Submit expense to another user */ -function trackExpense(params: CreateTrackExpenseParams) { +function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouReport?: OnyxTypes.Report} { const { report, - action, - isDraftPolicy, + existingIOUReport, participantParams, - policyParams: policyData = {}, - existingTransaction, - transactionParams: transactionData, - accountantParams, + policyParams = {}, + transactionParams, + gpsPoint, + action, shouldHandleNavigation = true, + backToReport, shouldPlaySound = true, + optimisticChatReportID, + optimisticCreatedReportActionID, + optimisticIOUReportID, + optimisticReportPreviewActionID, + shouldGenerateTransactionThreadReport, isASAPSubmitBetaEnabled, currentUserAccountIDParam, currentUserEmailParam, - introSelected, - activePolicyID, + transactionViolations, quickAction, - recentWaypoints = [], - betas, + policyRecentlyUsedCurrencies, + existingTransactionDraft, draftTransactionIDs = [], isSelfTourViewed, - } = params; - const {participant, payeeAccountID, payeeEmail} = participantParams; - const {policy, policyCategories, policyTagList} = policyData; - const parsedComment = getParsedComment(transactionData.comment ?? ''); - transactionData.comment = parsedComment; + betas, + personalDetails, + } = requestMoneyInformation; + const {payeeAccountID} = participantParams; + const parsedComment = getParsedComment(transactionParams.comment ?? ''); + transactionParams.comment = parsedComment; const { amount, + distance, currency, - created = '', - merchant = '', + merchant, comment = '', - distance, receipt, category, tag, @@ -6646,64 +4378,49 @@ function trackExpense(params: CreateTrackExpenseParams) { taxAmount = 0, billable, reimbursable, - gpsPoint, - validWaypoints, + created, + attendees, actionableWhisperReportActionID, linkedTrackedExpenseReportAction, linkedTrackedExpenseReportID, + waypoints, customUnitRateID, - attendees, - odometerStart, - odometerEnd, + isTestDrive, + isLinkedTrackedExpenseReportArchived, + type: transactionType, + count, + rate, + unit, isFromGlobalCreate, - gpsCoordinates, - } = transactionData; + } = transactionParams; + + const testDriveCommentReportActionID = isTestDrive ? NumberUtils.rand64() : undefined; + + const sanitizedWaypoints = waypoints ? JSON.stringify(sanitizeRecentWaypoints(waypoints)) : undefined; + + // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = isMoneyRequestReportReportUtils(report); const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseIOUUtils(action); + const existingTransactionID = existingTransactionDraft?.transactionID; + const existingTransaction = action === CONST.IOU.ACTION.SUBMIT ? existingTransactionDraft : allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]; - // Pass an open receipt so the distance expense will show a map with the route optimistically - const trackedReceipt = validWaypoints ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN, name: 'receipt-generic.png'} : receipt; - const sanitizedWaypoints = validWaypoints ? JSON.stringify(sanitizeRecentWaypoints(validWaypoints)) : undefined; - - const retryParams: CreateTrackExpenseParams = { - ...params, - report, - isDraftPolicy, - action, + const retryParams = { + ...requestMoneyInformation, participantParams: { - participant, - payeeAccountID, - payeeEmail, + ...requestMoneyInformation.participantParams, + participant: (({icons, ...rest}) => rest)(requestMoneyInformation.participantParams.participant), }, transactionParams: { - amount, - currency, - created, - merchant, - comment, - distance, + ...requestMoneyInformation.transactionParams, receipt: undefined, - category, - tag, - taxCode, - taxAmount, - billable, - reimbursable, - validWaypoints, - gpsPoint, - actionableWhisperReportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - customUnitRateID, }, - quickAction, - isSelfTourViewed, }; const { - createdWorkspaceParams, + payerAccountID, + payerEmail, iouReport, chatReport, transaction, @@ -6713,266 +4430,191 @@ function trackExpense(params: CreateTrackExpenseParams) { reportPreviewAction, transactionThreadReportID, createdReportActionIDForThread, - actionableWhisperReportActionIDParam, - optimisticReportID, - optimisticReportActionID, - onyxData: trackExpenseInformationOnyxData, - } = getTrackExpenseInformation({ - parentChatReport: currentChatReport, - moneyRequestReportID, - existingTransaction, - existingTransactionID: - isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && isMoneyRequestAction(linkedTrackedExpenseReportAction) - ? getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID - : undefined, - participantParams: { - participant, - payeeAccountID, - payeeEmail, - }, - transactionParams: { - comment, - amount, - distance, - currency, - created, - merchant, - receipt: trackedReceipt, - category, - tag, - taxCode, - taxAmount, - billable, - reimbursable, - linkedTrackedExpenseReportAction, - attendees, - odometerStart, - odometerEnd, - gpsCoordinates, - }, - policyParams: { - policy, - policyCategories, - policyTagList, - }, + onyxData, + } = getMoneyRequestInformation({ + parentChatReport: isMovingTransactionFromTrackExpense ? undefined : currentChatReport, + existingIOUReport, + participantParams, + policyParams, + transactionParams, + moneyRequestReportID, + existingTransactionID, + existingTransaction: isDistanceRequestTransactionUtils(existingTransaction) ? existingTransaction : undefined, retryParams, + testDriveCommentReportActionID, + optimisticChatReportID, + optimisticCreatedReportActionID, + optimisticIOUReportID, + optimisticReportPreviewActionID, + shouldGenerateTransactionThreadReport, + action, isASAPSubmitBetaEnabled, currentUserAccountIDParam, currentUserEmailParam, - introSelected, - activePolicyID, + transactionViolations, quickAction, + policyRecentlyUsedCurrencies, betas, - isSelfTourViewed, - }) ?? {}; - const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport?.reportID; - const onyxData: TrackedExpenseParams['onyxData'] = trackExpenseInformationOnyxData; - - const recentServerValidatedWaypoints = recentWaypoints.filter((item) => !item.pendingAction); - onyxData?.failureData?.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.NVP_RECENT_WAYPOINTS}`, - value: recentServerValidatedWaypoints, + personalDetails, }); + const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport.reportID; - const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); - - const isDistanceRequest = - isMapDistanceRequest(transaction) || isManualDistanceRequestTransactionUtils(transaction) || isOdometerDistanceRequestTransactionUtils(transaction) || isGPSDistanceRequest; - - if (isDistanceRequest) { - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - onyxData?.optimisticData?.push({ - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE, - value: transaction?.iouRequestType, - }); - } - - const mileageRate = isCustomUnitRateIDForP2P(transaction) ? undefined : customUnitRateID; if (shouldPlaySound) { playSound(SOUNDS.DONE); } switch (action) { - case CONST.IOU.ACTION.CATEGORIZE: { - if (!linkedTrackedExpenseReportAction || !linkedTrackedExpenseReportID) { - return; - } - const transactionParams: TrackedExpenseTransactionParams = { - transactionID: transaction?.transactionID, - amount, - currency, - comment, - distance, - merchant, - created, - taxCode, - taxAmount, - category, - tag, - billable, - reimbursable, - receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, - waypoints: sanitizedWaypoints, - customUnitRateID: mileageRate, - attendees, - }; - const policyParams: TrackedExpensePolicyParams = { - policyID: chatReport?.policyID, - policy, - isDraftPolicy, - }; - const reportInformation: TrackedExpenseReportInformation = { - moneyRequestPreviewReportActionID: iouAction?.reportActionID, - moneyRequestReportID: iouReport?.reportID, - moneyRequestCreatedReportActionID: createdIOUReportActionID, - actionableWhisperReportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - transactionThreadReportID, - reportPreviewReportActionID: reportPreviewAction?.reportActionID, - chatReportID: chatReport?.reportID, - isLinkedTrackedExpenseReportArchived: transactionData.isLinkedTrackedExpenseReportArchived, - }; - const trackedExpenseParams: TrackedExpenseParams = { - onyxData, - reportInformation, - transactionParams, - policyParams, - createdWorkspaceParams, - }; - - categorizeTrackedExpense(trackedExpenseParams); - break; - } - case CONST.IOU.ACTION.SHARE: { + case CONST.IOU.ACTION.SUBMIT: { if (!linkedTrackedExpenseReportAction || !linkedTrackedExpenseReportID) { - return; + return {}; } - const transactionParams: TrackedExpenseTransactionParams = { - transactionID: transaction?.transactionID, - amount, - currency, - comment, - distance, - merchant, - created, - taxCode: taxCode ?? '', - taxAmount: taxAmount ?? 0, - category, - tag, - billable, - reimbursable, - receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, - waypoints: sanitizedWaypoints, - customUnitRateID: mileageRate, - attendees, - }; - const policyParams: TrackedExpensePolicyParams = { - policyID: chatReport?.policyID, - policy, - }; - const reportInformation: TrackedExpenseReportInformation = { - moneyRequestPreviewReportActionID: iouAction?.reportActionID, - moneyRequestReportID: iouReport?.reportID, - moneyRequestCreatedReportActionID: createdIOUReportActionID, - actionableWhisperReportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID, - transactionThreadReportID, - reportPreviewReportActionID: reportPreviewAction?.reportActionID, - chatReportID: chatReport?.reportID, - isLinkedTrackedExpenseReportArchived: transactionData.isLinkedTrackedExpenseReportArchived, - }; - const trackedExpenseParams: TrackedExpenseParams = { + const customUnitParams = isDistanceRequestTransactionUtils(transaction) + ? { + customUnitID: getDistanceRateCustomUnit(policyParams?.policy)?.customUnitID, + customUnitRateID, + } + : {}; + const workspaceParams = + isPolicyExpenseChatReportUtil(chatReport) && chatReport.policyID + ? { + receipt: isFileUploadable(receipt) ? receipt : undefined, + category, + tag, + taxCode, + taxAmount: Math.abs(taxAmount), + billable, + policyID: chatReport.policyID, + waypoints: sanitizedWaypoints, + reimbursable, + ...customUnitParams, + } + : undefined; + const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); + convertTrackedExpenseToRequest({ + payerParams: { + accountID: payerAccountID, + email: payerEmail, + }, + transactionParams: { + amount, + distance, + currency, + comment, + merchant, + created, + attendees, + transactionID: transaction.transactionID, + actionableWhisperReportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID, + transactionThreadReportID: transactionThreadReportID ?? iouAction?.childReportID, + isLinkedTrackedExpenseReportArchived, + isDistance: isDistanceRequest, + customUnitRateID: isDistanceRequest ? customUnitRateID : undefined, + waypoints: isDistanceRequest ? sanitizedWaypoints : undefined, + }, + chatParams: { + reportID: chatReport.reportID, + createdReportActionID: createdChatReportActionID, + reportPreviewReportActionID: reportPreviewAction.reportActionID, + }, + iouParams: { + reportID: iouReport.reportID, + createdReportActionID: createdIOUReportActionID, + reportActionID: iouAction.reportActionID, + }, onyxData, - reportInformation, - transactionParams, - policyParams, - createdWorkspaceParams, - accountantParams, - }; - shareTrackedExpense(trackedExpenseParams); + workspaceParams, + }); break; } default: { - if (isGPSDistanceRequest) { - onyxData?.optimisticData?.push({ - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.GPS_DRAFT_DETAILS, - value: null, - }); - } + // This is only required when inviting admins to test drive the app + const guidedSetupData: GuidedSetupData | undefined = isTestDrive + ? prepareOnboardingOnyxData({ + introSelected: {choice: CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER}, + engagementChoice: CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER, + onboardingMessage: getOnboardingMessages().onboardingMessages[CONST.ONBOARDING_CHOICES.TEST_DRIVE_RECEIVER], + companySize: undefined, + isSelfTourViewed, + betas, + })?.guidedSetupData + : undefined; - const parameters: TrackExpenseParams = { + const parameters: RequestMoneyParams = { + debtorEmail: payerEmail, + debtorAccountID: payerAccountID, amount, - attendees: attendees ? JSON.stringify(attendees) : undefined, currency, comment, - distance: distance !== undefined ? roundToTwoDecimalPlaces(distance) : undefined, created, merchant, - iouReportID: iouReport?.reportID, - // If we are passing an optimisticReportID then we are creating a new chat (selfDM) and we don't have an *existing* chatReportID - chatReportID: optimisticReportID ? undefined : chatReport?.reportID, - transactionID: transaction?.transactionID, - reportActionID: iouAction?.reportActionID, + iouReportID: iouReport.reportID, + chatReportID: chatReport.reportID, + transactionID: transaction.transactionID, + reportActionID: iouAction.reportActionID, createdChatReportActionID, createdIOUReportActionID, - reportPreviewReportActionID: reportPreviewAction?.reportActionID, - optimisticReportID, - optimisticReportActionID, - // Tracked expenses in the CREATE flow are unreported and not tied to a policy - policyID: undefined, - receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, - receiptState: trackedReceipt?.state, - reimbursable, + reportPreviewReportActionID: reportPreviewAction.reportActionID, + receipt: isFileUploadable(receipt) ? receipt : undefined, + receiptState: receipt?.state, category, tag, taxCode, taxAmount, - taxPolicyID: policy?.id, billable, // This needs to be a string of JSON because of limitations with the fetch() API and nested objects receiptGpsPoints: gpsPoint ? JSON.stringify(gpsPoint) : undefined, transactionThreadReportID, createdReportActionIDForThread, - waypoints: sanitizedWaypoints, - customUnitRateID, + reimbursable, description: parsedComment, - gpsCoordinates, - isDistance: - isGPSDistanceRequest || - isMapDistanceRequest(transaction) || - isManualDistanceRequestTransactionUtils(transaction) || - isOdometerDistanceRequestTransactionUtils(transaction), - odometerStart, - odometerEnd, + attendees: attendees ? JSON.stringify(attendees) : undefined, + isTestDrive, + guidedSetupData: guidedSetupData ? JSON.stringify(guidedSetupData) : undefined, + testDriveCommentReportActionID, + ...(transactionType === CONST.TRANSACTION.TYPE.TIME + ? { + type: transactionType, + count, + rate, + unit, + } + : {}), }; - if (actionableWhisperReportActionIDParam) { - parameters.actionableWhisperReportActionID = actionableWhisperReportActionIDParam; - } - - API.write(WRITE_COMMANDS.TRACK_EXPENSE, parameters, onyxData); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + API.write(WRITE_COMMANDS.REQUEST_MONEY, parameters, onyxData); } } if (shouldHandleNavigation) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => removeDraftTransactionsByIDs(draftTransactionIDs)); + + const trackReport = Navigation.getReportRouteByID(linkedTrackedExpenseReportAction?.childReportID); + if (trackReport?.key) { + Navigation.removeScreenByKey(trackReport.key); + } } - if (!params.isRetry) { + if (!requestMoneyInformation.isRetry) { handleNavigateAfterExpenseCreate({ - activeReportID, - transactionID: transaction?.transactionID, + activeReportID: backToReport ?? activeReportID, + transactionID: transaction.transactionID, isFromGlobalCreate, shouldHandleNavigation, }); } - notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam); + if (activeReportID && !isMoneyRequestReport) { + Navigation.setNavigationActionToMicrotaskQueue(() => + setTimeout(() => { + notifyNewAction(activeReportID, reportPreviewAction, payeeAccountID === currentUserAccountIDParam); + }, CONST.TIMING.NOTIFY_NEW_ACTION_DELAY), + ); + } + + return {iouReport}; } function getOrCreateOptimisticSplitChatReport(existingSplitChatReportID: string | undefined, participants: Participant[], participantAccountIDs: number[], currentUserAccountID: number) { @@ -8112,42 +5754,6 @@ function getNavigationUrlOnMoneyRequestDelete( return undefined; } -/** - * Calculate the URL to navigate to after a track expense deletion - * @param chatReportID - The ID of the chat report containing the track expense - * @param transactionID - The ID of the track expense being deleted - * @param reportAction - The report action associated with the track expense - * @param isSingleTransactionView - Whether we're in single transaction view - * @returns The URL to navigate to - */ -function getNavigationUrlAfterTrackExpenseDelete( - chatReportID: string | undefined, - chatReport: OnyxEntry | undefined, - transactionID: string | undefined, - reportAction: OnyxTypes.ReportAction, - iouReport: OnyxEntry, - chatIOUReport: OnyxEntry, - isChatReportArchived: boolean | undefined, - isSingleTransactionView = false, -): Route | undefined { - if (!chatReportID || !transactionID) { - return undefined; - } - - // If not a self DM, handle it as a regular money request - if (!isSelfDM(chatReport)) { - return getNavigationUrlOnMoneyRequestDelete(transactionID, reportAction, iouReport, chatIOUReport, isChatReportArchived, isSingleTransactionView); - } - - // Only navigate if in single transaction view and the thread will be deleted - if (isSingleTransactionView && chatReport?.reportID) { - // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - return ROUTES.REPORT_WITH_ID.getRoute(chatReport.reportID); - } - - return undefined; -} - /** * * @param transactionID - The transactionID of IOU @@ -8768,75 +6374,6 @@ function deleteMoneyRequest({ return urlToNavigateBack; } -function deleteTrackExpense({ - chatReportID, - chatReport, - transactionID, - reportAction, - iouReport, - chatIOUReport, - transactions, - violations, - isSingleTransactionView = false, - isChatReportArchived, - isChatIOUReportArchived, - allTransactionViolationsParam, - currentUserAccountID, -}: DeleteTrackExpenseParams) { - if (!chatReportID || !transactionID) { - return; - } - - const urlToNavigateBack = getNavigationUrlAfterTrackExpenseDelete( - chatReportID, - chatReport, - transactionID, - reportAction, - iouReport, - chatIOUReport, - isSingleTransactionView, - isChatIOUReportArchived, - ); - - // STEP 1: Get all collections we're updating - if (!isSelfDM(chatReport)) { - deleteMoneyRequest({ - transactionID, - reportAction, - transactions, - violations, - iouReport, - chatReport: chatIOUReport, - isChatIOUReportArchived, - isSingleTransactionView, - allTransactionViolationsParam, - currentUserAccountID, - }); - return urlToNavigateBack; - } - - const whisperAction = getTrackExpenseActionableWhisper(transactionID, chatReportID); - const actionableWhisperReportActionID = whisperAction?.reportActionID; - const {parameters, optimisticData, successData, failureData} = getDeleteTrackExpenseInformation( - chatReport, - transactionID, - reportAction, - isChatReportArchived, - undefined, - undefined, - actionableWhisperReportActionID, - CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING, - false, - ); - - // STEP 6: Make the API request - API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); - clearPdfByOnyxKey(transactionID); - - // STEP 7: Navigate the user depending on which page they are on and which resources were deleted - return urlToNavigateBack; -} - type OptimisticHoldReportExpenseActionID = { optimisticReportActionID: string; oldReportActionID: string; @@ -13107,7 +10644,6 @@ export { createDistanceRequest, createDraftTransaction, deleteMoneyRequest, - deleteTrackExpense, detachReceipt, getIOURequestPolicyID, getReportOriginalCreationTimestamp, @@ -13116,7 +10652,6 @@ export { dismissModalAndOpenReportInInboxTab, navigateToStartStepIfScanFileCannotBeRead, completePaymentOnboarding, - convertBulkTrackedExpensesToIOU, payInvoice, payMoneyRequest, replaceReceipt, @@ -13154,7 +10689,6 @@ export { setMoneyRequestTaxRateValues, startMoneyRequest, submitReport, - trackExpense, unapproveExpenseReport, updateMoneyRequestAttendees, updateMoneyRequestAmountAndCurrency, @@ -13173,7 +10707,6 @@ export { shouldOptimisticallyUpdateSearch, getIOUReportActionToApproveOrPay, getNavigationUrlOnMoneyRequestDelete, - getNavigationUrlAfterTrackExpenseDelete, canSubmitReport, calculateDiffAmount, dismissRejectUseExplanation, @@ -13209,8 +10742,6 @@ export { buildMinimalTransactionForFormula, buildOnyxDataForMoneyRequest, createSplitsAndOnyxData, - getDeleteTrackExpenseInformation, - getTrackExpenseInformation, getMoneyRequestInformation, getOrCreateOptimisticSplitChatReport, }; @@ -13218,7 +10749,6 @@ export type { GPSPoint as GpsPoint, IOURequestType, StartSplitBilActionParams, - CreateTrackExpenseParams, RequestMoneyInformation, ReplaceReceipt, RequestMoneyParticipantParams, @@ -13232,3 +10762,5 @@ export type { BuildOnyxDataForMoneyRequestKeys, MoneyRequestInformation, }; +// eslint-disable-next-line import/no-cycle +export type {ConvertTrackedExpenseToRequestParams, CreateTrackExpenseParams, DeleteTrackExpenseParams, TrackExpenseInformation} from './TrackExpense'; diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 0159bcb3cd564..00dae0da71f6e 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -24,8 +24,9 @@ import {isDistanceRequest, isTransactionPendingDelete} from '@src/libs/Transacti import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {CardList, MergeTransaction, Policy, PolicyCategories, PolicyTagLists, Report, ReportNextStepDeprecated, Transaction, TransactionViolations} from '@src/types/onyx'; -import {getCleanUpTransactionThreadReportOnyxData, getDeleteTrackExpenseInformation, getUpdateMoneyRequestParams, getUpdateTrackExpenseParams} from './IOU'; +import {getCleanUpTransactionThreadReportOnyxData, getUpdateMoneyRequestParams, getUpdateTrackExpenseParams} from './IOU'; import type {UpdateMoneyRequestData, UpdateMoneyRequestDataKeys} from './IOU'; +import {getDeleteTrackExpenseInformation} from './IOU/TrackExpense'; /** * Setup merge transaction data for merging flow diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index da4bb7a036755..f5a0fef0ea9c0 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -108,7 +108,8 @@ import { } from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; import {isDemoTransaction} from '@libs/TransactionUtils'; -import {deleteTrackExpense, getNavigationUrlAfterTrackExpenseDelete, getNavigationUrlOnMoneyRequestDelete} from '@userActions/IOU'; +import {getNavigationUrlOnMoneyRequestDelete} from '@userActions/IOU'; +import {deleteTrackExpense, getNavigationUrlAfterTrackExpenseDelete} from '@userActions/IOU/TrackExpense'; import { clearAvatarErrors, clearPolicyRoomNameErrors, diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 02a178f3e86ef..8ec3f96ce6305 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -19,7 +19,8 @@ import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useThemeStyles from '@hooks/useThemeStyles'; import type {GpsPoint} from '@libs/actions/IOU'; -import {getIOURequestPolicyID, getMoneyRequestParticipantsFromReport, initMoneyRequest, requestMoney, trackExpense, updateLastLocationPermissionPrompt} from '@libs/actions/IOU'; +import {getIOURequestPolicyID, getMoneyRequestParticipantsFromReport, initMoneyRequest, requestMoney, updateLastLocationPermissionPrompt} from '@libs/actions/IOU'; +import {trackExpense} from '@libs/actions/IOU/TrackExpense'; import DateUtils from '@libs/DateUtils'; import {getFileName, readFileAsync} from '@libs/fileDownload/FileUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index 9aa51e01d860d..fe80a74a63fc2 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -18,7 +18,7 @@ import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; -import {deleteTrackExpense} from '@libs/actions/IOU'; +import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; import {deleteAppReport, deleteReportComment} from '@libs/actions/Report'; import calculateAnchorPosition from '@libs/calculateAnchorPosition'; import refocusComposerAfterPreventFirstResponder from '@libs/refocusComposerAfterPreventFirstResponder'; diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 8831be3e1ed13..c858fb201c0ff 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -36,11 +36,11 @@ import { setMoneyRequestParticipantsFromReport, setMoneyRequestTaxAmount, setMoneyRequestTaxRate, - trackExpense, updateMoneyRequestAmountAndCurrency, } from '@userActions/IOU'; import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; import {resetSplitShares, setDraftSplitTransaction, setSplitShares} from '@userActions/IOU/Split'; +import {trackExpense} from '@userActions/IOU/TrackExpense'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index f02a48fff78b5..448006238012d 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -92,13 +92,13 @@ import { setMoneyRequestReceipt, setMoneyRequestReimbursable, startMoneyRequest, - trackExpense as trackExpenseIOUActions, updateLastLocationPermissionPrompt, } from '@userActions/IOU'; import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpenseIOUActions} from '@userActions/IOU/PerDiem'; import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice'; import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; import {splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split'; +import {trackExpense as trackExpenseIOUActions} from '@userActions/IOU/TrackExpense'; import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy'; import {removeDraftTransaction, removeDraftTransactions, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 0e24d766b337e..eef3f8e2f17de 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -1,5 +1,6 @@ import type {KeysOfUnion, ValueOf} from 'type-fest'; -import type {CreateTrackExpenseParams, IOURequestType, ReplaceReceipt, RequestMoneyInformation, StartSplitBilActionParams} from '@libs/actions/IOU'; +import type {IOURequestType, ReplaceReceipt, RequestMoneyInformation, StartSplitBilActionParams} from '@libs/actions/IOU'; +import type {CreateTrackExpenseParams} from '@libs/actions/IOU/TrackExpense'; import type CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import type {FileObject} from '@src/types/utils/Attachment'; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 21805f5853915..88c392863e492 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -19,14 +19,11 @@ import { canIOUBePaid, canUnapproveIOU, completePaymentOnboarding, - convertBulkTrackedExpensesToIOU, createDistanceRequest, deleteMoneyRequest, - getDeleteTrackExpenseInformation, getIOUReportActionToApproveOrPay, getReportOriginalCreationTimestamp, getReportPreviewAction, - getTrackExpenseInformation, handleNavigateAfterExpenseCreate, initMoneyRequest, markRejectViolationAsResolved, @@ -38,7 +35,6 @@ import { setMoneyRequestCategory, shouldOptimisticallyUpdateSearch, submitReport, - trackExpense, updateMoneyRequestAmountAndCurrency, updateMoneyRequestAttendees, updateMoneyRequestCategory, @@ -47,6 +43,7 @@ import { } from '@libs/actions/IOU'; import {putOnHold} from '@libs/actions/IOU/Hold'; import {completeSplitBill, splitBill, startSplitBill, updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/Split'; +import {trackExpense} from '@libs/actions/IOU/TrackExpense'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {createWorkspace, deleteWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; import {addComment, createNewReport, deleteReport, notifyNewAction, openReport} from '@libs/actions/Report'; @@ -106,7 +103,6 @@ import * as InvoiceData from '../data/Invoice'; import currencyList from '../unit/currencyList.json'; import createPersonalDetails from '../utils/collections/personalDetails'; import createRandomPolicy, {createCategoryTaxExpenseRules} from '../utils/collections/policies'; -import createRandomPolicyCategories from '../utils/collections/policyCategory'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; @@ -488,1340 +484,6 @@ describe('actions/IOU', () => { }); }); - describe('trackExpense', () => { - it('category a distance expense of selfDM report', async () => { - /* - * This step simulates the following steps: - * - Go to self DM - * - Track a distance expense - * - Go to Troubleshoot > Clear cache and restart > Reset and refresh - * - Go to self DM - * - Click Categorize it (click Upgrade if there is no workspace) - * - Select category and submit the expense to the workspace - */ - - // Given a participant of the report - const participant = {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}; - - // Given valid waypoints of the transaction - const fakeWayPoints = { - waypoint0: { - keyForList: '88 Kearny Street_1735023533854', - lat: 37.7886378, - lng: -122.4033442, - address: '88 Kearny Street, San Francisco, CA, USA', - name: '88 Kearny Street', - }, - waypoint1: { - keyForList: 'Golden Gate Bridge Vista Point_1735023537514', - lat: 37.8077876, - lng: -122.4752007, - address: 'Golden Gate Bridge Vista Point, San Francisco, CA, USA', - name: 'Golden Gate Bridge Vista Point', - }, - }; - - // Given a selfDM report - const selfDMReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM); - - // Given a policyExpenseChat report - const policyExpenseChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - - // Given policy categories and a policy - const fakeCategories = createRandomPolicyCategories(3); - const fakePolicy = createRandomPolicy(1); - - // Given a transaction with a distance request type and valid waypoints - const fakeTransaction = { - ...createRandomTransaction(1), - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - comment: { - ...createRandomTransaction(1).comment, - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - customUnit: { - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - }, - waypoints: fakeWayPoints, - }, - }; - - // When the transaction is saved to draft before being submitted - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${fakeTransaction.transactionID}`, fakeTransaction); - mockFetch?.pause?.(); - - const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; - - // When the user submits the transaction to the selfDM report - trackExpense({ - report: selfDMReport, - isDraftPolicy: true, - action: CONST.IOU.ACTION.CREATE, - participantParams: { - payeeEmail: participant.login, - payeeAccountID: participant.accountID, - participant, - }, - transactionParams: { - amount: fakeTransaction.amount, - currency: fakeTransaction.currency, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: fakeTransaction.merchant, - billable: false, - validWaypoints: fakeWayPoints, - actionableWhisperReportActionID: fakeTransaction?.actionableWhisperReportActionID, - linkedTrackedExpenseReportAction: fakeTransaction?.linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID: fakeTransaction?.linkedTrackedExpenseReportID, - customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [fakeTransaction.transactionID], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - await mockFetch?.resume?.(); - - // Given transaction after tracked expense - const transaction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - Onyx.disconnect(connection); - const trackedExpenseTransaction = Object.values(transactions ?? {}).at(0); - - // Then the transaction must remain a distance request - const isDistanceRequest = isDistanceRequestUtil(trackedExpenseTransaction); - expect(isDistanceRequest).toBe(true); - resolve(trackedExpenseTransaction); - }, - }); - }); - - // Given all report actions of the selfDM report - const allReportActions = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (reportActions) => { - Onyx.disconnect(connection); - resolve(reportActions); - }, - }); - }); - - // Then the selfDM report should have an actionable track expense whisper action and an IOU action - const selfDMReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`]; - expect(Object.values(selfDMReportActions ?? {}).length).toBe(2); - - // When the cache is cleared before categorizing the tracked expense - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, { - iouRequestType: null, - }); - - // When the transaction is saved to draft by selecting a category in the selfDM report - const reportActionableTrackExpense = Object.values(selfDMReportActions ?? {}).find((reportAction) => isActionableTrackExpense(reportAction)); - createDraftTransactionAndNavigateToParticipantSelector({ - transactionID: transaction?.transactionID, - reportID: selfDMReport.reportID, - actionName: CONST.IOU.ACTION.CATEGORIZE, - reportActionID: reportActionableTrackExpense?.reportActionID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - allTransactionDrafts: {}, - activePolicy: undefined, - userBillingGraceEndPeriodCollection: undefined, - amountOwed: 0, - }); - await waitForBatchedUpdates(); - - // Then the transaction draft should be saved successfully - let allTransactionsDraft: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (val) => { - allTransactionsDraft = val; - }, - }); - const transactionDraft = allTransactionsDraft?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`]; - - // When the user confirms the category for the tracked expense - trackExpense({ - report: policyExpenseChat, - isDraftPolicy: false, - action: CONST.IOU.ACTION.CATEGORIZE, - participantParams: { - payeeEmail: participant.login, - payeeAccountID: participant.accountID, - participant: {...participant, isPolicyExpenseChat: true}, - }, - policyParams: { - policy: fakePolicy, - policyCategories: fakeCategories, - }, - transactionParams: { - amount: transactionDraft?.amount ?? fakeTransaction.amount, - currency: transactionDraft?.currency ?? fakeTransaction.currency, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: transactionDraft?.merchant ?? fakeTransaction.merchant, - category: Object.keys(fakeCategories).at(0) ?? '', - validWaypoints: Object.keys(transactionDraft?.comment?.waypoints ?? {}).length ? getValidWaypoints(transactionDraft?.comment?.waypoints, true) : undefined, - actionableWhisperReportActionID: transactionDraft?.actionableWhisperReportActionID, - linkedTrackedExpenseReportAction: transactionDraft?.linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID: transactionDraft?.linkedTrackedExpenseReportID, - customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - await mockFetch?.resume?.(); - - // Then the expense should be categorized successfully - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - Onyx.disconnect(connection); - const categorizedTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`]; - - // Then the transaction must remain a distance request, ensuring that the optimistic data is correctly built and the transaction type remains accurate. - const isDistanceRequest = isDistanceRequestUtil(categorizedTransaction); - expect(isDistanceRequest).toBe(true); - - // Then the transaction category must match the original category - expect(categorizedTransaction?.category).toBe(Object.keys(fakeCategories).at(0) ?? ''); - resolve(); - }, - }); - }); - - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, - callback: (quickAction) => { - Onyx.disconnect(connection); - resolve(); - - // Then the quickAction.action should be set to REQUEST_DISTANCE - expect(quickAction?.action).toBe(CONST.QUICK_ACTIONS.REQUEST_DISTANCE); - // Then the quickAction.chatReportID should be set to the given policyExpenseChat reportID - expect(quickAction?.chatReportID).toBe(policyExpenseChat.reportID); - }, - }); - }); - }); - - it('share with accountant', async () => { - const accountant: Required = {login: VIT_EMAIL, accountID: VIT_ACCOUNT_ID}; - const policy: Policy = {...createRandomPolicy(1), id: 'ABC'}; - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: '10', - }; - const policyExpenseChat: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), - reportID: '123', - policyID: policy.id, - type: CONST.REPORT.TYPE.CHAT, - isOwnPolicyExpenseChat: true, - }; - const transaction: Transaction = {...createRandomTransaction(1), transactionID: '555'}; - - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, transaction); - - const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; - - // Create a tracked expense - 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: transaction.amount, - currency: transaction.currency, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: transaction.merchant, - billable: false, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [transaction.transactionID], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - - const selfDMReportActionsOnyx = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, - waitForCollectionCallback: false, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); - expect(Object.values(selfDMReportActionsOnyx ?? {}).length).toBe(2); - - const linkedTrackedExpenseReportAction = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isMoneyRequestAction(reportAction)); - const reportActionableTrackExpense = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isActionableTrackExpense(reportAction)); - - mockFetch?.pause?.(); - - // Share the tracked expense with an accountant - 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: transaction.amount, - currency: transaction.currency, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: transaction.merchant, - billable: false, - actionableWhisperReportActionID: reportActionableTrackExpense?.reportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID: selfDMReport.reportID, - }, - accountantParams: { - accountant, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - - const policyExpenseChatOnyx = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, - waitForCollectionCallback: false, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); - const policyOnyx = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, - waitForCollectionCallback: false, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); - - await mockFetch?.resume?.(); - - // Accountant should be invited to the expense report - expect(policyExpenseChatOnyx?.participants?.[accountant.accountID]).toBeTruthy(); - - // Accountant should be added to the workspace as an admin - expect(policyOnyx?.employeeList?.[accountant.login].role).toBe(CONST.POLICY.ROLE.ADMIN); - }); - - it('share with accountant who is already a member', async () => { - const accountant: Required = {login: VIT_EMAIL, accountID: VIT_ACCOUNT_ID}; - const policy: Policy = {...createRandomPolicy(1), id: 'ABC', employeeList: {[accountant.login]: {email: accountant.login, role: CONST.POLICY.ROLE.USER}}}; - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: '10', - }; - const policyExpenseChat: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), - reportID: '123', - policyID: policy.id, - type: CONST.REPORT.TYPE.CHAT, - isOwnPolicyExpenseChat: true, - participants: {[accountant.accountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, - }; - const transaction: Transaction = {...createRandomTransaction(1), transactionID: '555'}; - - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, transaction); - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountant.accountID]: accountant}); - - const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; - - // Create a tracked expense - 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: transaction.amount, - currency: transaction.currency, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: transaction.merchant, - billable: false, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [transaction.transactionID], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - - const selfDMReportActionsOnyx = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, - waitForCollectionCallback: false, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); - expect(Object.values(selfDMReportActionsOnyx ?? {}).length).toBe(2); - - const linkedTrackedExpenseReportAction = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isMoneyRequestAction(reportAction)); - const reportActionableTrackExpense = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isActionableTrackExpense(reportAction)); - - mockFetch?.pause?.(); - - // Share the tracked expense with an accountant - 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: transaction.amount, - currency: transaction.currency, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: transaction.merchant, - billable: false, - actionableWhisperReportActionID: reportActionableTrackExpense?.reportActionID, - linkedTrackedExpenseReportAction, - linkedTrackedExpenseReportID: selfDMReport.reportID, - }, - accountantParams: { - accountant, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - - const policyExpenseChatOnyx = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, - waitForCollectionCallback: false, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); - const policyOnyx = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, - waitForCollectionCallback: false, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); - - await mockFetch?.resume?.(); - - // Accountant should be still a participant in the expense report - expect(policyExpenseChatOnyx?.participants?.[accountant.accountID]).toBeTruthy(); - - // Accountant role should change to admin - expect(policyOnyx?.employeeList?.[accountant.login].role).toBe(CONST.POLICY.ROLE.ADMIN); - }); - - /** - * Creates default trackExpense parameters - only override what's needed for each test - */ - function getDefaultTrackExpenseParams( - report: Report | undefined, - transactionOverrides: Partial[0]['transactionParams']> = {}, - ): Parameters[0] { - return { - report, - isDraftPolicy: false, - action: CONST.IOU.ACTION.CREATE, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {accountID: RORY_ACCOUNT_ID}, - }, - transactionParams: { - amount: 10000, - currency: 'USD', - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'Test Merchant', - billable: false, - ...transactionOverrides, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints: [], - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }; - } - - it('should create optimistic transaction with correct amount and currency', async () => { - // Given a selfDM report and transaction data - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-unit-1', - }; - const testAmount = 15000; // $150.00 - const testCurrency = 'USD'; - const testMerchant = 'Unit Test Merchant'; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with specific amount and currency - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: testAmount, currency: testCurrency, merchant: testMerchant})); - await waitForBatchedUpdates(); - - // Then transaction should be created with correct values - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransaction = Object.values(transactions ?? {}).at(0); - expect(createdTransaction).toBeTruthy(); - // Amount is stored as negative for track expenses - expect(Math.abs(createdTransaction?.amount ?? 0)).toBe(testAmount); - expect(createdTransaction?.currency).toBe(testCurrency); - expect(createdTransaction?.merchant).toBe(testMerchant); - }); - - it('should create actionable track expense whisper for selfDM reports', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-unit-2', - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called on selfDM - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 5000})); - await waitForBatchedUpdates(); - - // Then an actionable track expense whisper should be created - const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); - - const actionableWhisper = Object.values(reportActions ?? {}).find((action) => isActionableTrackExpense(action)); - expect(actionableWhisper).toBeTruthy(); - }); - - it('should set correct tax fields when tax parameters are provided', async () => { - // Given a selfDM report and transaction with tax - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-unit-3', - }; - const testTaxCode = 'TAX_CODE_1'; - const testTaxAmount = 500; // $5.00 tax - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with tax parameters - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {merchant: 'Tax Test Merchant', taxCode: testTaxCode, taxAmount: testTaxAmount})); - await waitForBatchedUpdates(); - - // Then transaction should have correct tax fields - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransaction = Object.values(transactions ?? {}).at(0); - expect(createdTransaction?.taxCode).toBe(testTaxCode); - expect(createdTransaction?.taxAmount).toBe(-testTaxAmount); - }); - - it('should set billable and reimbursable flags correctly', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-unit-4', - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with billable=true and reimbursable=true - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 7500, merchant: 'Billable Test', billable: true, reimbursable: true})); - await waitForBatchedUpdates(); - - // Then transaction should have correct billable and reimbursable flags - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransaction = Object.values(transactions ?? {}).at(0); - expect(createdTransaction?.billable).toBe(true); - expect(createdTransaction?.reimbursable).toBe(true); - }); - - it('should complete full track expense flow: create -> categorize -> submit to workspace', async () => { - // Given a selfDM report, policy, and expense chat - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-func-1', - }; - const policy = createRandomPolicy(1); - const policyExpenseChat: Report = { - ...createRandomReport(2, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), - reportID: 'expense-chat-func-1', - policyID: policy.id, - type: CONST.REPORT.TYPE.CHAT, - isOwnPolicyExpenseChat: true, - }; - const policyCategories = createRandomPolicyCategories(3); - const selectedCategory = Object.keys(policyCategories).at(0) ?? ''; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); - - // When trackExpense is called to create a tracked expense in selfDM - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 25000, merchant: 'Functional Test Restaurant'})); - await waitForBatchedUpdates(); - - // Then the initial expense should be created with report actions - const selfDMReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); - - expect(Object.values(selfDMReportActions ?? {}).length).toBe(2); - const moneyRequestAction = Object.values(selfDMReportActions ?? {}).find((action) => isMoneyRequestAction(action)); - const actionableWhisper = Object.values(selfDMReportActions ?? {}).find((action) => isActionableTrackExpense(action)); - expect(moneyRequestAction).toBeTruthy(); - expect(actionableWhisper).toBeTruthy(); - - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - const createdTransaction = Object.values(transactions ?? {}).at(0); - expect(createdTransaction).toBeTruthy(); - - // When a draft is created for categorization - createDraftTransactionAndNavigateToParticipantSelector({ - transactionID: createdTransaction?.transactionID, - reportID: selfDMReport.reportID, - actionName: CONST.IOU.ACTION.CATEGORIZE, - reportActionID: actionableWhisper?.reportActionID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - allTransactionDrafts: {}, - activePolicy: undefined, - userBillingGraceEndPeriodCollection: undefined, - amountOwed: 0, - }); - await waitForBatchedUpdates(); - - // Then the draft should be created - let transactionDrafts: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, - waitForCollectionCallback: true, - callback: (val) => { - transactionDrafts = val; - }, - }); - const draftTransaction = transactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${createdTransaction?.transactionID}`]; - expect(draftTransaction).toBeTruthy(); - - // When the expense is categorized and submitted to workspace - trackExpense({ - report: policyExpenseChat, - isDraftPolicy: false, - action: CONST.IOU.ACTION.CATEGORIZE, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, - }, - policyParams: { - policy, - policyCategories, - }, - transactionParams: { - amount: draftTransaction?.amount ?? 25000, - currency: draftTransaction?.currency ?? 'USD', - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: draftTransaction?.merchant ?? 'Functional Test Restaurant', - category: selectedCategory, - actionableWhisperReportActionID: draftTransaction?.actionableWhisperReportActionID, - linkedTrackedExpenseReportAction: moneyRequestAction, - linkedTrackedExpenseReportID: selfDMReport.reportID, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints: [], - draftTransactionIDs: [], - betas: [CONST.BETAS.ALL], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - - // Then the transaction should be categorized - let finalTransactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - finalTransactions = val; - }, - }); - const categorizedTransaction = finalTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${createdTransaction?.transactionID}`]; - expect(categorizedTransaction?.category).toBe(selectedCategory); - }); - - it('should handle expense with attendees correctly', async () => { - // Given a selfDM report with attendees data - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-func-2', - }; - const testAttendees = [ - {email: 'attendee1@test.com', displayName: 'Attendee One', avatarUrl: ''}, - {email: 'attendee2@test.com', displayName: 'Attendee Two', avatarUrl: ''}, - ]; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with attendees - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 30000, merchant: 'Team Lunch', attendees: testAttendees})); - await waitForBatchedUpdates(); - - // Then transaction should have attendees - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransaction = Object.values(transactions ?? {}).at(0); - expect(createdTransaction?.comment?.attendees).toHaveLength(2); - expect(createdTransaction?.comment?.attendees?.at(0)?.email).toBe('attendee1@test.com'); - }); - - it('should update quick action when tracking expense to policy expense chat', async () => { - // Given a policy expense chat - const policy = createRandomPolicy(1); - const policyExpenseChat: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), - reportID: 'expense-chat-func-2', - policyID: policy.id, - type: CONST.REPORT.TYPE.CHAT, - isOwnPolicyExpenseChat: true, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); - - // When trackExpense is called on policy expense chat - trackExpense({ - report: policyExpenseChat, - isDraftPolicy: false, - action: CONST.IOU.ACTION.CREATE, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, - }, - policyParams: { - policy, - }, - transactionParams: { - amount: 12000, - currency: 'USD', - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'Quick Action Test', - billable: false, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints: [], - betas: [CONST.BETAS.ALL], - draftTransactionIDs: [], - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - - // Then quick action should be updated - const quickAction = await getOnyxValue(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); - expect(quickAction).toBeTruthy(); - expect(quickAction?.chatReportID).toBe(policyExpenseChat.reportID); - }); - - it('should handle tracking expense without merchant gracefully', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-qa-1', - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called without merchant - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 5000, merchant: ''})); - await waitForBatchedUpdates(); - - // Then transaction should still be created - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - expect(Object.values(transactions ?? {}).length).toBeGreaterThan(0); - }); - - it('should handle zero amount expense', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-qa-2', - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with zero amount - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 0, merchant: 'Zero Amount Test'})); - await waitForBatchedUpdates(); - - // Then transaction should be created with zero amount - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransaction = Object.values(transactions ?? {}).at(0); - // trackExpense negates the amount, so 0 becomes -0, defaults to 1 to be able to use Math.abs - expect(createdTransaction).toBeTruthy(); - expect(Object.is(Math.abs(createdTransaction?.amount ?? 1), 0)).toBe(true); - }); - - it('should handle different currency codes correctly', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-qa-3', - }; - const testCurrency = 'EUR'; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with EUR currency - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 8500, currency: testCurrency, merchant: 'European Merchant'})); - await waitForBatchedUpdates(); - - // Then transaction should have correct currency - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransaction = Object.values(transactions ?? {}).at(0); - expect(createdTransaction?.currency).toBe(testCurrency); - }); - - it('should create optimistic selfDM report when none exists', async () => { - // Given no selfDM report exists - - // When trackExpense is called with undefined report - trackExpense(getDefaultTrackExpenseParams(undefined, {amount: 3000, merchant: 'Optimistic SelfDM Test'})); - await waitForBatchedUpdates(); - - // Then a selfDM report should be created optimistically - const reports = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (val) => { - Onyx.disconnect(connection); - resolve(val); - }, - }); - }); - - const selfDMReports = Object.values(reports ?? {}).filter((r) => r?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM); - expect(selfDMReports.length).toBeGreaterThan(0); - }); - - it('should handle API failure gracefully with failure data', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-qa-5', - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - mockFetch?.fail?.(); - - // When trackExpense is called and the API fails - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 5000, merchant: 'Failure Test'})); - await waitForBatchedUpdates(); - - // Then optimistic data should still be created initially - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - expect(Object.values(transactions ?? {}).length).toBeGreaterThan(0); - - mockFetch?.succeed?.(); - }); - - it('should handle category and tag together correctly', async () => { - // Given a selfDM report with category and tag - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-qa-6', - }; - const testCategory = 'Travel'; - const testTag = 'Business Trip'; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with category and tag - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 50000, merchant: 'Airline', category: testCategory, tag: testTag})); - await waitForBatchedUpdates(); - - // Then transaction should have correct category and tag - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransaction = Object.values(transactions ?? {}).at(0); - expect(createdTransaction?.category).toBe(testCategory); - expect(createdTransaction?.tag).toBe(testTag); - }); - - it('should handle very large expense amounts', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-qa-7', - }; - const largeAmount = 99999999; // Large amount in cents - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with very large amount - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: largeAmount, merchant: 'Large Purchase'})); - await waitForBatchedUpdates(); - - // Then transaction should handle large amount correctly - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransaction = Object.values(transactions ?? {}).at(0); - expect(Math.abs(createdTransaction?.amount ?? 0)).toBe(largeAmount); - }); - - it('should handle expense with special characters in merchant name', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-qa-8', - }; - const specialMerchant = "McDonald's & Café ñ 日本語"; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with special characters in merchant - trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 1500, merchant: specialMerchant})); - await waitForBatchedUpdates(); - - // Then transaction should preserve special characters - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransaction = Object.values(transactions ?? {}).at(0); - expect(createdTransaction?.merchant).toBe(specialMerchant); - }); - - it('should pass isSelfTourViewed true to trackExpense and create transaction successfully', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-tour-1', - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with isSelfTourViewed: true - trackExpense({ - ...getDefaultTrackExpenseParams(selfDMReport, {amount: 12000, merchant: 'Tour Viewed Merchant'}), - isSelfTourViewed: true, - }); - await waitForBatchedUpdates(); - - // Then the transaction should be created with correct values - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransactionResult = Object.values(transactions ?? {}).at(0); - expect(createdTransactionResult).toBeTruthy(); - expect(Math.abs(createdTransactionResult?.amount ?? 0)).toBe(12000); - expect(createdTransactionResult?.merchant).toBe('Tour Viewed Merchant'); - }); - - it('should pass isSelfTourViewed false to trackExpense and create transaction successfully', async () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-tour-2', - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - - // When trackExpense is called with isSelfTourViewed: false - trackExpense({ - ...getDefaultTrackExpenseParams(selfDMReport, {amount: 9000, merchant: 'Tour Not Viewed Merchant'}), - isSelfTourViewed: false, - }); - await waitForBatchedUpdates(); - - // Then the transaction should be created with correct values - let transactions: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (val) => { - transactions = val; - }, - }); - - const createdTransactionResult = Object.values(transactions ?? {}).at(0); - expect(createdTransactionResult).toBeTruthy(); - expect(Math.abs(createdTransactionResult?.amount ?? 0)).toBe(9000); - expect(createdTransactionResult?.merchant).toBe('Tour Not Viewed Merchant'); - }); - - it('should return valid track expense information from getTrackExpenseInformation with isSelfTourViewed true', () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-info-1', - }; - - // When getTrackExpenseInformation is called with isSelfTourViewed: true - const result = getTrackExpenseInformation({ - parentChatReport: selfDMReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {accountID: RORY_ACCOUNT_ID}, - }, - policyParams: { - policy: undefined, - policyCategories: undefined, - policyTagList: undefined, - }, - transactionParams: { - amount: 5000, - currency: 'USD', - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'Info Test Merchant', - comment: 'test comment', - receipt: undefined, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - isSelfTourViewed: true, - }); - - // Then the result should contain valid track expense data - expect(result).toBeDefined(); - expect(result.chatReport).toBeDefined(); - expect(result.transaction).toBeDefined(); - expect(result.iouAction).toBeDefined(); - expect(result.onyxData).toBeDefined(); - }); - - it('should return valid track expense information from getTrackExpenseInformation with isSelfTourViewed false', () => { - // Given a selfDM report - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: 'selfDM-info-2', - }; - - // When getTrackExpenseInformation is called with isSelfTourViewed: false - const result = getTrackExpenseInformation({ - parentChatReport: selfDMReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {accountID: RORY_ACCOUNT_ID}, - }, - policyParams: { - policy: undefined, - policyCategories: undefined, - policyTagList: undefined, - }, - transactionParams: { - amount: 7000, - currency: 'USD', - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'Info Test Merchant False', - comment: 'test comment false', - receipt: undefined, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - isSelfTourViewed: false, - }); - - // Then the result should contain valid track expense data - expect(result).toBeDefined(); - expect(result.chatReport).toBeDefined(); - expect(result.transaction).toBeDefined(); - expect(result.iouAction).toBeDefined(); - expect(result.onyxData).toBeDefined(); - }); - - it('should return valid track expense information for policy expense chat with both isSelfTourViewed values', () => { - // Given a policy expense chat report - const policy = createRandomPolicy(1); - const policyExpenseChat: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), - reportID: 'policy-chat-tour-test', - policyID: policy.id, - }; - - // When getTrackExpenseInformation is called with isSelfTourViewed: true - const resultWithTourViewed = getTrackExpenseInformation({ - parentChatReport: policyExpenseChat, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {accountID: RORY_ACCOUNT_ID, isPolicyExpenseChat: true}, - }, - policyParams: { - policy, - policyCategories: undefined, - policyTagList: undefined, - }, - transactionParams: { - amount: 3000, - currency: 'USD', - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'Policy Chat Merchant', - comment: '', - receipt: undefined, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - isSelfTourViewed: true, - }); - - // Then result should be valid - expect(resultWithTourViewed).toBeDefined(); - expect(resultWithTourViewed.chatReport).toBeDefined(); - expect(resultWithTourViewed.transaction).toBeDefined(); - - // When getTrackExpenseInformation is called with isSelfTourViewed: false - const resultWithoutTourViewed = getTrackExpenseInformation({ - parentChatReport: { - ...policyExpenseChat, - reportID: 'policy-chat-tour-test-2', - }, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {accountID: RORY_ACCOUNT_ID, isPolicyExpenseChat: true}, - }, - policyParams: { - policy, - policyCategories: undefined, - policyTagList: undefined, - }, - transactionParams: { - amount: 3000, - currency: 'USD', - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'Policy Chat Merchant', - comment: '', - receipt: undefined, - }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - isSelfTourViewed: false, - }); - - expect(resultWithoutTourViewed).toBeDefined(); - expect(resultWithoutTourViewed.chatReport).toBeDefined(); - expect(resultWithoutTourViewed.transaction).toBeDefined(); - }); - }); - describe('createDraftTransactionAndNavigateToParticipantSelector', () => { it('should clear existing draft transactions when allTransactionDrafts is provided', async () => { // Given existing draft transactions @@ -8165,459 +6827,154 @@ describe('actions/IOU', () => { }); }); - expect(report?.reportID).toBeFalsy(); - }); - - it('should delete the transaction thread regardless of whether there are visible comments in the thread.', async () => { - // Given initial environment is set up - await waitForBatchedUpdates(); - - // Given a transaction thread - thread = buildTransactionThread(createIOUAction, iouReport); - - expect(thread.participants).toEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); - - const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); - const userLogins = getLoginsByAccountIDs(participantAccountIDs); - jest.advanceTimersByTime(10); - openReport({ - reportID: thread.reportID, - introSelected: TEST_INTRO_SELECTED, - participantLoginList: userLogins, - newReportObject: thread, - parentReportActionID: createIOUAction?.reportActionID, - }); - await waitForBatchedUpdates(); - - Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, - callback: (val) => (reportActions = val), - }); - await waitForBatchedUpdates(); - - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - callback: (report) => { - Onyx.disconnect(connection); - expect(report).toBeTruthy(); - resolve(); - }, - }); - }); - - jest.advanceTimersByTime(10); - - // When a comment is added - addComment({ - report: thread, - notifyReportID: thread.reportID, - ancestors: [], - text: 'Testing a comment', - timezoneParam: CONST.DEFAULT_TIME_ZONE, - currentUserAccountID: RORY_ACCOUNT_ID, - }); - await waitForBatchedUpdates(); - - // Then comment details should match the expected report action - const resultAction = Object.values(reportActions ?? {}).find((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); - reportActionID = resultAction?.reportActionID; - expect(resultAction?.message).toEqual(REPORT_ACTION.message); - expect(resultAction?.person).toEqual(REPORT_ACTION.person); - - await waitForBatchedUpdates(); - - // Then the report should have 2 actions - expect(Object.values(reportActions ?? {}).length).toBe(2); - const resultActionAfter = reportActionID ? reportActions?.[reportActionID] : undefined; - expect(resultActionAfter?.pendingAction).toBeUndefined(); - - mockFetch?.pause?.(); - - const allReportActions = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (actions) => { - Onyx.disconnect(connection); - resolve(actions); - }, - }); - }); - - const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; - createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.reportActionID === createIOUAction?.reportActionID, - ); - - if (transaction && createIOUAction) { - // When deleting expense - deleteMoneyRequest({ - transactionID: transaction?.transactionID, - reportAction: createIOUAction, - transactions: {}, - violations: {}, - iouReport, - chatReport, - allTransactionViolationsParam: {}, - currentUserAccountID: TEST_USER_ACCOUNT_ID, - }); - } - await waitForBatchedUpdates(); - - // Then the transaction thread report should be ready to be deleted - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - waitForCollectionCallback: false, - callback: (report) => { - Onyx.disconnect(connection); - expect(report?.reportID).toBeFalsy(); - resolve(); - }, - }); - }); - - // When fetch resumes - // Then the transaction thread report should be deleted - mockFetch?.resume?.(); - await waitForBatchedUpdates(); - - // Then the transaction thread report should be deleted - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - waitForCollectionCallback: false, - callback: (report) => { - Onyx.disconnect(connection); - expect(report).toBeFalsy(); - resolve(); - }, - }); - }); - }); - - it('update the moneyRequestPreview to show [Deleted expense] when appropriate', async () => { - await waitForBatchedUpdates(); - - // Given a thread report - - jest.advanceTimersByTime(10); - thread = buildTransactionThread(createIOUAction, iouReport); - - expect(thread.participants).toStrictEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); - - Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, - callback: (val) => (reportActions = val), - }); - await waitForBatchedUpdates(); - - jest.advanceTimersByTime(10); - const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); - const userLogins = getLoginsByAccountIDs(participantAccountIDs); - openReport({ - reportID: thread.reportID, - introSelected: TEST_INTRO_SELECTED, - participantLoginList: userLogins, - newReportObject: thread, - parentReportActionID: createIOUAction?.reportActionID, - }); - - await waitForBatchedUpdates(); - - const allReportActions = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (actions) => { - Onyx.disconnect(connection); - resolve(actions); - }, - }); - }); - - const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; - createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - expect(createIOUAction?.childReportID).toBe(thread.reportID); - - await waitForBatchedUpdates(); - - // Given an added comment to the thread report - - jest.advanceTimersByTime(10); - - addComment({ - report: thread, - notifyReportID: thread.reportID, - ancestors: [], - text: 'Testing a comment', - timezoneParam: CONST.DEFAULT_TIME_ZONE, - currentUserAccountID: RORY_ACCOUNT_ID, - }); - await waitForBatchedUpdates(); - - // Fetch the updated IOU Action from Onyx due to addition of comment to transaction thread. - // This needs to be fetched as `deleteMoneyRequest` depends on `childVisibleActionCount` in `createIOUAction`. - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, - waitForCollectionCallback: false, - callback: (reportActionsForReport) => { - Onyx.disconnect(connection); - createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - resolve(); - }, - }); - }); - - let resultAction = Object.values(reportActions ?? {}).find((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); - reportActionID = resultAction?.reportActionID; - - expect(resultAction?.message).toEqual(REPORT_ACTION.message); - expect(resultAction?.person).toEqual(REPORT_ACTION.person); - expect(resultAction?.pendingAction).toBeUndefined(); + expect(report?.reportID).toBeFalsy(); + }); + it('should delete the transaction thread regardless of whether there are visible comments in the thread.', async () => { + // Given initial environment is set up await waitForBatchedUpdates(); - // Verify there are three actions (created + addcomment) and our optimistic comment has been removed - expect(Object.values(reportActions ?? {}).length).toBe(2); - - let resultActionAfterUpdate = reportActionID ? reportActions?.[reportActionID] : undefined; + // Given a transaction thread + thread = buildTransactionThread(createIOUAction, iouReport); - // Verify that our action is no longer in the loading state - expect(resultActionAfterUpdate?.pendingAction).toBeUndefined(); + expect(thread.participants).toEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); + const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); + const userLogins = getLoginsByAccountIDs(participantAccountIDs); + jest.advanceTimersByTime(10); + openReport({ + reportID: thread.reportID, + introSelected: TEST_INTRO_SELECTED, + participantLoginList: userLogins, + newReportObject: thread, + parentReportActionID: createIOUAction?.reportActionID, + }); await waitForBatchedUpdates(); - // Given an added comment to the IOU report - Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, callback: (val) => (reportActions = val), }); await waitForBatchedUpdates(); + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + callback: (report) => { + Onyx.disconnect(connection); + expect(report).toBeTruthy(); + resolve(); + }, + }); + }); + jest.advanceTimersByTime(10); - if (IOU_REPORT_ID) { - addComment({ - report: IOU_REPORT, - notifyReportID: IOU_REPORT_ID, - ancestors: [], - text: 'Testing a comment', - timezoneParam: CONST.DEFAULT_TIME_ZONE, - currentUserAccountID: RORY_ACCOUNT_ID, - }); - } + // When a comment is added + addComment({ + report: thread, + notifyReportID: thread.reportID, + ancestors: [], + text: 'Testing a comment', + timezoneParam: CONST.DEFAULT_TIME_ZONE, + currentUserAccountID: RORY_ACCOUNT_ID, + }); await waitForBatchedUpdates(); - resultAction = Object.values(reportActions ?? {}).find((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + // Then comment details should match the expected report action + const resultAction = Object.values(reportActions ?? {}).find((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); reportActionID = resultAction?.reportActionID; - expect(resultAction?.message).toEqual(REPORT_ACTION.message); expect(resultAction?.person).toEqual(REPORT_ACTION.person); - expect(resultAction?.pendingAction).toBeUndefined(); await waitForBatchedUpdates(); - // Verify there are three actions (created + iou + addcomment) and our optimistic comment has been removed - expect(Object.values(reportActions ?? {}).length).toBe(3); + // Then the report should have 2 actions + expect(Object.values(reportActions ?? {}).length).toBe(2); + const resultActionAfter = reportActionID ? reportActions?.[reportActionID] : undefined; + expect(resultActionAfter?.pendingAction).toBeUndefined(); - resultActionAfterUpdate = reportActionID ? reportActions?.[reportActionID] : undefined; + mockFetch?.pause?.(); - // Verify that our action is no longer in the loading state - expect(resultActionAfterUpdate?.pendingAction).toBeUndefined(); + const allReportActions = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + Onyx.disconnect(connection); + resolve(actions); + }, + }); + }); + + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; + createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( + (reportAction): reportAction is ReportAction => reportAction.reportActionID === createIOUAction?.reportActionID, + ); - mockFetch?.pause?.(); if (transaction && createIOUAction) { - // When we delete the expense + // When deleting expense deleteMoneyRequest({ - transactionID: transaction.transactionID, + transactionID: transaction?.transactionID, reportAction: createIOUAction, transactions: {}, violations: {}, iouReport, chatReport, - isChatIOUReportArchived: undefined, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, }); } await waitForBatchedUpdates(); - // Then we expect the moneyRequestPreview to show [Deleted expense] - + // Then the transaction thread report should be ready to be deleted await new Promise((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, waitForCollectionCallback: false, - callback: (reportActionsForReport) => { + callback: (report) => { Onyx.disconnect(connection); - createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - expect(getReportActionMessage(createIOUAction)?.isDeletedParentAction).toBeTruthy(); + expect(report?.reportID).toBeFalsy(); resolve(); }, }); }); - // When we resume fetch + // When fetch resumes + // Then the transaction thread report should be deleted mockFetch?.resume?.(); + await waitForBatchedUpdates(); - // Then we expect the moneyRequestPreview to show [Deleted expense] - + // Then the transaction thread report should be deleted await new Promise((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, waitForCollectionCallback: false, - callback: (reportActionsForReport) => { + callback: (report) => { Onyx.disconnect(connection); - createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - expect(getReportActionMessage(createIOUAction)?.isDeletedParentAction).toBeTruthy(); + expect(report).toBeFalsy(); resolve(); }, }); }); }); - it('update IOU report and reportPreview with new totals and messages if the IOU report is not deleted', async () => { - await waitForBatchedUpdates(); - Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - callback: (val) => (iouReport = val), - }); - await waitForBatchedUpdates(); - - // Given a second expense in addition to the first one - - jest.advanceTimersByTime(10); - const amount2 = 20000; - const comment2 = 'Send me money please 2'; - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: TEST_USER_LOGIN, - payeeAccountID: TEST_USER_ACCOUNT_ID, - participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, - }, - transactionParams: { - amount: amount2, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment: comment2, - }, - 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 we expect the IOU report and reportPreview to update with new totals - - expect(iouReport).toBeTruthy(); - expect(iouReport).toHaveProperty('reportID'); - expect(iouReport).toHaveProperty('chatReportID'); - expect(iouReport?.total).toBe(30000); - - const iouPreview = chatReport?.reportID && iouReport?.reportID ? getReportPreviewAction(chatReport.reportID, iouReport.reportID) : undefined; - expect(iouPreview).toBeTruthy(); - expect(getReportActionText(iouPreview)).toBe('rory@expensifail.com owes $300.00'); - - // When we delete the first expense - mockFetch?.pause?.(); - jest.advanceTimersByTime(10); - if (transaction && createIOUAction) { - deleteMoneyRequest({ - transactionID: transaction.transactionID, - reportAction: createIOUAction, - transactions: {}, - violations: {}, - iouReport, - chatReport, - isChatIOUReportArchived: undefined, - allTransactionViolationsParam: {}, - currentUserAccountID: TEST_USER_ACCOUNT_ID, - }); - } - await waitForBatchedUpdates(); - - // Then we expect the IOU report and reportPreview to update with new totals - - expect(iouReport).toBeTruthy(); - expect(iouReport).toHaveProperty('reportID'); - expect(iouReport).toHaveProperty('chatReportID'); - expect(iouReport?.total).toBe(20000); - - // When we resume - mockFetch?.resume?.(); - - // Then we expect the IOU report and reportPreview to update with new totals - expect(iouReport).toBeTruthy(); - expect(iouReport).toHaveProperty('reportID'); - expect(iouReport).toHaveProperty('chatReportID'); - expect(iouReport?.total).toBe(20000); - }); - - it('navigate the user correctly to the iou Report when appropriate', async () => { - // Given multiple expenses on an IOU report - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: TEST_USER_LOGIN, - payeeAccountID: TEST_USER_ACCOUNT_ID, - participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: '', - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - existingTransactionDraft: undefined, - draftTransactionIDs: [], - isSelfTourViewed: false, - quickAction: undefined, - betas: [CONST.BETAS.ALL], - personalDetails: {}, - }); + it('update the moneyRequestPreview to show [Deleted expense] when appropriate', async () => { await waitForBatchedUpdates(); // Given a thread report + jest.advanceTimersByTime(10); thread = buildTransactionThread(createIOUAction, iouReport); expect(thread.participants).toStrictEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, + callback: (val) => (reportActions = val), + }); + await waitForBatchedUpdates(); + jest.advanceTimersByTime(10); const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); const userLogins = getLoginsByAccountIDs(participantAccountIDs); @@ -8628,6 +6985,7 @@ describe('actions/IOU', () => { newReportObject: thread, parentReportActionID: createIOUAction?.reportActionID, }); + await waitForBatchedUpdates(); const allReportActions = await new Promise>((resolve) => { @@ -8647,89 +7005,152 @@ describe('actions/IOU', () => { ); expect(createIOUAction?.childReportID).toBe(thread.reportID); - // When we delete the expense, we should not delete the IOU report - mockFetch?.pause?.(); + await waitForBatchedUpdates(); - let navigateToAfterDelete; - if (transaction && createIOUAction) { - navigateToAfterDelete = deleteMoneyRequest({ - transactionID: transaction.transactionID, - reportAction: createIOUAction, - transactions: {}, - violations: {}, - iouReport, - chatReport, - isSingleTransactionView: true, - allTransactionViolationsParam: {}, - currentUserAccountID: TEST_USER_ACCOUNT_ID, - }); - } + // Given an added comment to the thread report - let allReports = await new Promise>((resolve) => { + jest.advanceTimersByTime(10); + + addComment({ + report: thread, + notifyReportID: thread.reportID, + ancestors: [], + text: 'Testing a comment', + timezoneParam: CONST.DEFAULT_TIME_ZONE, + currentUserAccountID: RORY_ACCOUNT_ID, + }); + await waitForBatchedUpdates(); + + // Fetch the updated IOU Action from Onyx due to addition of comment to transaction thread. + // This needs to be fetched as `deleteMoneyRequest` depends on `childVisibleActionCount` in `createIOUAction`. + await new Promise((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForReport) => { Onyx.disconnect(connection); - resolve(reports); + createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + resolve(); }, }); }); - iouReport = Object.values(allReports ?? {}).find((report) => isIOUReport(report)); - expect(iouReport).toBeTruthy(); - expect(iouReport).toHaveProperty('reportID'); - expect(iouReport).toHaveProperty('chatReportID'); + let resultAction = Object.values(reportActions ?? {}).find((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + reportActionID = resultAction?.reportActionID; - await mockFetch?.resume?.(); + expect(resultAction?.message).toEqual(REPORT_ACTION.message); + expect(resultAction?.person).toEqual(REPORT_ACTION.person); + expect(resultAction?.pendingAction).toBeUndefined(); - allReports = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - Onyx.disconnect(connection); - resolve(reports); - }, - }); + await waitForBatchedUpdates(); + + // Verify there are three actions (created + addcomment) and our optimistic comment has been removed + expect(Object.values(reportActions ?? {}).length).toBe(2); + + let resultActionAfterUpdate = reportActionID ? reportActions?.[reportActionID] : undefined; + + // Verify that our action is no longer in the loading state + expect(resultActionAfterUpdate?.pendingAction).toBeUndefined(); + + await waitForBatchedUpdates(); + + // Given an added comment to the IOU report + + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`, + callback: (val) => (reportActions = val), }); + await waitForBatchedUpdates(); - iouReport = Object.values(allReports ?? {}).find((report) => isIOUReport(report)); - expect(iouReport).toBeTruthy(); - expect(iouReport).toHaveProperty('reportID'); - expect(iouReport).toHaveProperty('chatReportID'); + jest.advanceTimersByTime(10); - // Then we expect to navigate to the iou report - expect(IOU_REPORT_ID).not.toBeUndefined(); if (IOU_REPORT_ID) { - expect(navigateToAfterDelete).toEqual(ROUTES.REPORT_WITH_ID.getRoute(IOU_REPORT_ID)); + addComment({ + report: IOU_REPORT, + notifyReportID: IOU_REPORT_ID, + ancestors: [], + text: 'Testing a comment', + timezoneParam: CONST.DEFAULT_TIME_ZONE, + currentUserAccountID: RORY_ACCOUNT_ID, + }); } - }); + await waitForBatchedUpdates(); - it('navigate the user correctly to the chat Report when appropriate', () => { - let navigateToAfterDelete; + resultAction = Object.values(reportActions ?? {}).find((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + reportActionID = resultAction?.reportActionID; + + expect(resultAction?.message).toEqual(REPORT_ACTION.message); + expect(resultAction?.person).toEqual(REPORT_ACTION.person); + expect(resultAction?.pendingAction).toBeUndefined(); + + await waitForBatchedUpdates(); + + // Verify there are three actions (created + iou + addcomment) and our optimistic comment has been removed + expect(Object.values(reportActions ?? {}).length).toBe(3); + + resultActionAfterUpdate = reportActionID ? reportActions?.[reportActionID] : undefined; + + // Verify that our action is no longer in the loading state + expect(resultActionAfterUpdate?.pendingAction).toBeUndefined(); + + mockFetch?.pause?.(); if (transaction && createIOUAction) { - // When we delete the expense and we should delete the IOU report - navigateToAfterDelete = deleteMoneyRequest({ + // When we delete the expense + deleteMoneyRequest({ transactionID: transaction.transactionID, reportAction: createIOUAction, transactions: {}, violations: {}, iouReport, chatReport, + isChatIOUReportArchived: undefined, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, }); } - // Then we expect to navigate to the chat report - expect(chatReport?.reportID).not.toBeUndefined(); + await waitForBatchedUpdates(); - if (chatReport?.reportID) { - expect(navigateToAfterDelete).toEqual(ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID)); - } + // Then we expect the moneyRequestPreview to show [Deleted expense] + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForReport) => { + Onyx.disconnect(connection); + createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(getReportActionMessage(createIOUAction)?.isDeletedParentAction).toBeTruthy(); + resolve(); + }, + }); + }); + + // When we resume fetch + mockFetch?.resume?.(); + + // Then we expect the moneyRequestPreview to show [Deleted expense] + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + waitForCollectionCallback: false, + callback: (reportActionsForReport) => { + Onyx.disconnect(connection); + createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(getReportActionMessage(createIOUAction)?.isDeletedParentAction).toBeTruthy(); + resolve(); + }, + }); + }); }); - it('update reportPreview with childVisibleActionCount if the IOU report is not deleted', async () => { + it('update IOU report and reportPreview with new totals and messages if the IOU report is not deleted', async () => { await waitForBatchedUpdates(); Onyx.connect({ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, @@ -8760,14 +7181,14 @@ describe('actions/IOU', () => { }, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, - transactionViolations: {}, currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, policyRecentlyUsedCurrencies: [], - quickAction: undefined, - isSelfTourViewed: false, existingTransactionDraft: undefined, draftTransactionIDs: [], + isSelfTourViewed: false, + quickAction: undefined, betas: [CONST.BETAS.ALL], personalDetails: {}, }); @@ -8775,99 +7196,16 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); - // Then we expect the IOU report and reportPreview to update with new totals - - expect(iouReport).toBeTruthy(); - expect(iouReport).toHaveProperty('reportID'); - expect(iouReport).toHaveProperty('chatReportID'); - expect(iouReport?.total).toBe(30000); - - await waitForBatchedUpdates(); - - // Given a transaction thread - thread = buildTransactionThread(createIOUAction, iouReport); - - expect(thread.participants).toEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); - - const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); - const userLogins = getLoginsByAccountIDs(participantAccountIDs); - jest.advanceTimersByTime(10); - openReport({ - reportID: thread.reportID, - introSelected: TEST_INTRO_SELECTED, - participantLoginList: userLogins, - newReportObject: thread, - parentReportActionID: createIOUAction?.reportActionID, - }); - await waitForBatchedUpdates(); - - Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, - callback: (val) => (reportActions = val), - }); - await waitForBatchedUpdates(); - - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - callback: (report) => { - Onyx.disconnect(connection); - expect(report).toBeTruthy(); - resolve(); - }, - }); - }); - - jest.advanceTimersByTime(10); - - // When a comment is added - let iouPreview = getReportPreviewAction(chatReport?.reportID, iouReport?.reportID); - const ancestors = []; - ancestors.push(...(iouReport && createIOUAction ? [{report: iouReport, reportAction: createIOUAction, shouldDisplayNewMarker: false}] : [])); - ancestors.push(...(chatReport && iouPreview ? [{report: chatReport, reportAction: iouPreview, shouldDisplayNewMarker: false}] : [])); - addComment({ - report: thread, - notifyReportID: thread.reportID, - ancestors, - text: 'Testing a comment', - timezoneParam: CONST.DEFAULT_TIME_ZONE, - currentUserAccountID: CARLOS_ACCOUNT_ID, - }); - await waitForBatchedUpdates(); - - // Then comment details should match the expected report action - const resultAction = Object.values(reportActions ?? {}).find((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); - reportActionID = resultAction?.reportActionID; - expect(resultAction?.message).toEqual(REPORT_ACTION.message); - expect(resultAction?.person).toEqual(REPORT_ACTION.person); - - await waitForBatchedUpdates(); - - // Then the childVisibleActionCount of createIOUAction and iouPreview should be increased by 1 - 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 expect the IOU report and reportPreview to update with new totals - const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; - createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( - (reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction) && reportAction.reportActionID === createIOUAction?.reportActionID, - ); - expect(createIOUAction).toBeTruthy(); - expect(createIOUAction?.childVisibleActionCount).toEqual(1); - expect(createIOUAction?.childCommenterCount).toEqual(1); + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); + expect(iouReport?.total).toBe(30000); - iouPreview = getReportPreviewAction(chatReport?.reportID, iouReport?.reportID); + const iouPreview = chatReport?.reportID && iouReport?.reportID ? getReportPreviewAction(chatReport.reportID, iouReport.reportID) : undefined; expect(iouPreview).toBeTruthy(); - expect(iouPreview?.childVisibleActionCount).toEqual(1); - expect(iouPreview?.childCommenterCount).toEqual(1); + expect(getReportActionText(iouPreview)).toBe('rory@expensifail.com owes $300.00'); // When we delete the first expense mockFetch?.pause?.(); @@ -8880,84 +7218,34 @@ describe('actions/IOU', () => { violations: {}, iouReport, chatReport, + isChatIOUReportArchived: undefined, allTransactionViolationsParam: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, }); } - await waitForBatchedUpdates(); - // Then we expect the reportPreview to update with new childVisibleActionCount + // Then we expect the IOU report and reportPreview to update with new totals - iouPreview = getReportPreviewAction(chatReport?.reportID, iouReport?.reportID); - expect(iouPreview).toBeTruthy(); - expect(iouPreview?.childVisibleActionCount).toEqual(0); - expect(iouPreview?.childCommenterCount).toEqual(0); + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); + expect(iouReport?.total).toBe(20000); // When we resume mockFetch?.resume?.(); - await waitForBatchedUpdates(); - // Then we expect the reportPreview to update with new childVisibleActionCount - iouPreview = getReportPreviewAction(chatReport?.reportID, iouReport?.reportID); - expect(iouPreview).toBeTruthy(); - expect(iouPreview?.childVisibleActionCount).toEqual(0); - expect(iouPreview?.childCommenterCount).toEqual(0); + // Then we expect the IOU report and reportPreview to update with new totals + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); + expect(iouReport?.total).toBe(20000); }); - }); - - describe('getDeleteTrackExpenseInformation', () => { - const amount = 10000; - const comment = 'Send me money please'; - let selfDMReport: Report; - let createIOUAction: OnyxEntry>; - let transaction: OnyxEntry; - let thread: OptimisticChatReport; - const TEST_USER_ACCOUNT_ID = 1; - const TEST_USER_LOGIN = 'test@test.com'; - let reportActionID; - const REPORT_ACTION: OnyxEntry = { - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - actorAccountID: TEST_USER_ACCOUNT_ID, - automatic: false, - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_3.png', - message: [{type: 'COMMENT', html: 'Testing a comment', text: 'Testing a comment', translationKey: ''}], - person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], - shouldShow: true, - created: DateUtils.getDBTime(), - reportActionID: '1', - originalMessage: { - html: '', - whisperedTo: [], - }, - }; - - let reportActions: OnyxCollection; - - beforeEach(async () => { - // Given mocks are cleared and helpers are set up - jest.clearAllMocks(); - PusherHelper.setup(); - - // 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, undefined); - await waitForBatchedUpdates(); - await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); - - selfDMReport = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: '10', - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); - const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; - // Create a tracked expense - trackExpense({ - report: selfDMReport, - isDraftPolicy: true, - action: CONST.IOU.ACTION.CREATE, + it('navigate the user correctly to the iou Report when appropriate', async () => { + // Given multiple expenses on an IOU report + requestMoney({ + report: chatReport, participantParams: { payeeEmail: TEST_USER_LOGIN, payeeAccountID: TEST_USER_ACCOUNT_ID, @@ -8965,97 +7253,202 @@ describe('actions/IOU', () => { }, transactionParams: { amount, - currency: 'USD', - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: comment, - billable: false, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, }, + shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - recentWaypoints, - betas: [CONST.BETAS.ALL], + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, draftTransactionIDs: [], isSelfTourViewed: false, + quickAction: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, }); await waitForBatchedUpdates(); - // When fetching all reports from Onyx - const allReports = await new Promise>((resolve) => { + // Given a thread report + jest.advanceTimersByTime(10); + thread = buildTransactionThread(createIOUAction, iouReport); + + expect(thread.participants).toStrictEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); + + jest.advanceTimersByTime(10); + const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); + const userLogins = getLoginsByAccountIDs(participantAccountIDs); + openReport({ + reportID: thread.reportID, + introSelected: TEST_INTRO_SELECTED, + participantLoginList: userLogins, + newReportObject: thread, + parentReportActionID: createIOUAction?.reportActionID, + }); + await waitForBatchedUpdates(); + + const allReportActions = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, - callback: (reports) => { + callback: (actions) => { Onyx.disconnect(connection); - resolve(reports); + resolve(actions); }, }); }); - // Then we should have exactly 2 reports - expect(Object.values(allReports ?? {}).length).toBe(2); + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; + createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(createIOUAction?.childReportID).toBe(thread.reportID); - // 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); - if (transactionThreadReport) { - thread = transactionThreadReport; - } - expect(thread).toBeTruthy(); - expect(thread).toHaveProperty('reportID'); - expect(thread?.parentReportID).toBe(selfDMReport.reportID); - expect(thread).toHaveProperty('parentReportActionID'); + // When we delete the expense, we should not delete the IOU report + mockFetch?.pause?.(); - await waitForBatchedUpdates(); + let navigateToAfterDelete; + if (transaction && createIOUAction) { + navigateToAfterDelete = deleteMoneyRequest({ + transactionID: transaction.transactionID, + reportAction: createIOUAction, + transactions: {}, + violations: {}, + iouReport, + chatReport, + isSingleTransactionView: true, + allTransactionViolationsParam: {}, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + }); + } - // When fetching all report actions from Onyx - const allReportActions = await new Promise>((resolve) => { + let allReports = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, - callback: (actions) => { + callback: (reports) => { Onyx.disconnect(connection); - resolve(actions); + resolve(reports); }, }); }); - // Then we should find an IOU action with specific properties - const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`]; - createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.reportActionID === thread?.parentReportActionID, - ); - expect(createIOUAction).toBeTruthy(); - expect(createIOUAction?.childReportID).toBe(thread?.reportID); + iouReport = Object.values(allReports ?? {}).find((report) => isIOUReport(report)); + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); - // When fetching all transactions from Onyx - const allTransactions = await new Promise>((resolve) => { + await mockFetch?.resume?.(); + + allReports = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, + key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, - callback: (transactions) => { + callback: (reports) => { Onyx.disconnect(connection); - resolve(transactions); + resolve(reports); + }, + }); + }); + + iouReport = Object.values(allReports ?? {}).find((report) => isIOUReport(report)); + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); + + // Then we expect to navigate to the iou report + expect(IOU_REPORT_ID).not.toBeUndefined(); + if (IOU_REPORT_ID) { + expect(navigateToAfterDelete).toEqual(ROUTES.REPORT_WITH_ID.getRoute(IOU_REPORT_ID)); + } + }); + + it('navigate the user correctly to the chat Report when appropriate', () => { + let navigateToAfterDelete; + if (transaction && createIOUAction) { + // When we delete the expense and we should delete the IOU report + navigateToAfterDelete = deleteMoneyRequest({ + transactionID: transaction.transactionID, + reportAction: createIOUAction, + transactions: {}, + violations: {}, + iouReport, + chatReport, + allTransactionViolationsParam: {}, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + }); + } + // Then we expect to navigate to the chat report + expect(chatReport?.reportID).not.toBeUndefined(); + + if (chatReport?.reportID) { + expect(navigateToAfterDelete).toEqual(ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID)); + } + }); + + it('update reportPreview with childVisibleActionCount if the IOU report is not deleted', async () => { + await waitForBatchedUpdates(); + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + callback: (val) => (iouReport = val), + }); + await waitForBatchedUpdates(); + + // Given a second expense in addition to the first one + + jest.advanceTimersByTime(10); + const amount2 = 20000; + const comment2 = 'Send me money please 2'; + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: TEST_USER_LOGIN, + payeeAccountID: TEST_USER_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount: amount2, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment: comment2, }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + betas: [CONST.BETAS.ALL], + personalDetails: {}, }); - }); + } - // Then we should find a specific transaction with relevant properties - 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); - }); + await waitForBatchedUpdates(); - afterEach(PusherHelper.teardown); + // Then we expect the IOU report and reportPreview to update with new totals + + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); + expect(iouReport?.total).toBe(30000); - it('should delete the transaction thread regardless of whether there are visible comments in the thread, if isMovingTransactionFromTrackExpense equals false.', async () => { - // Given initial environment is set up await waitForBatchedUpdates(); + // Given a transaction thread + thread = buildTransactionThread(createIOUAction, iouReport); + expect(thread.participants).toEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); @@ -9071,7 +7464,7 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread?.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, callback: (val) => (reportActions = val), }); await waitForBatchedUpdates(); @@ -9090,10 +7483,14 @@ describe('actions/IOU', () => { jest.advanceTimersByTime(10); // When a comment is added + let iouPreview = getReportPreviewAction(chatReport?.reportID, iouReport?.reportID); + const ancestors = []; + ancestors.push(...(iouReport && createIOUAction ? [{report: iouReport, reportAction: createIOUAction, shouldDisplayNewMarker: false}] : [])); + ancestors.push(...(chatReport && iouPreview ? [{report: chatReport, reportAction: iouPreview, shouldDisplayNewMarker: false}] : [])); addComment({ report: thread, notifyReportID: thread.reportID, - ancestors: [], + ancestors, text: 'Testing a comment', timezoneParam: CONST.DEFAULT_TIME_ZONE, currentUserAccountID: CARLOS_ACCOUNT_ID, @@ -9108,11 +7505,7 @@ describe('actions/IOU', () => { await waitForBatchedUpdates(); - // Then the report should have 2 actions - expect(Object.values(reportActions ?? {}).length).toBe(2); - const resultActionAfter = reportActionID ? reportActions?.[reportActionID] : undefined; - expect(resultActionAfter?.pendingAction).toBeUndefined(); - + // Then the childVisibleActionCount of createIOUAction and iouPreview should be increased by 1 const allReportActions = await new Promise>((resolve) => { const connection = Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, @@ -9124,87 +7517,54 @@ describe('actions/IOU', () => { }); }); - const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`]; + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.reportActionID === createIOUAction?.reportActionID, + (reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction) && reportAction.reportActionID === createIOUAction?.reportActionID, ); expect(createIOUAction).toBeTruthy(); + expect(createIOUAction?.childVisibleActionCount).toEqual(1); + expect(createIOUAction?.childCommenterCount).toEqual(1); - // When deleting expense - const {optimisticData, successData, shouldDeleteTransactionThread} = getDeleteTrackExpenseInformation( - selfDMReport, - transaction?.transactionID, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - createIOUAction!, - false, - undefined, - undefined, - ); - await waitForBatchedUpdates(); - - // Then the transaction thread report should be ready to be deleted - expect(shouldDeleteTransactionThread).toBe(true); - expect(optimisticData).toEqual( - expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, value: expect.objectContaining({reportID: null})})]), - ); - expect(optimisticData).toEqual(expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, value: null})])); - expect(successData).toEqual(expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, value: null})])); - }); - - it('should NOT delete the transaction thread regardless of whether there are no visible comments in the thread, if isMovingTransactionFromTrackExpense equals true.', async () => { - // Given initial environment is set up - await waitForBatchedUpdates(); - - expect(thread.participants).toEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); + iouPreview = getReportPreviewAction(chatReport?.reportID, iouReport?.reportID); + expect(iouPreview).toBeTruthy(); + expect(iouPreview?.childVisibleActionCount).toEqual(1); + expect(iouPreview?.childCommenterCount).toEqual(1); - const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); - const userLogins = getLoginsByAccountIDs(participantAccountIDs); + // When we delete the first expense + mockFetch?.pause?.(); jest.advanceTimersByTime(10); - openReport({ - reportID: thread.reportID, - introSelected: TEST_INTRO_SELECTED, - participantLoginList: userLogins, - newReportObject: thread, - parentReportActionID: createIOUAction?.reportActionID, - }); - await waitForBatchedUpdates(); + if (transaction && createIOUAction) { + deleteMoneyRequest({ + transactionID: transaction.transactionID, + reportAction: createIOUAction, + transactions: {}, + violations: {}, + iouReport, + chatReport, + allTransactionViolationsParam: {}, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + }); + } - Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread?.reportID}`, - callback: (val) => (reportActions = val), - }); await waitForBatchedUpdates(); - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - callback: (report) => { - Onyx.disconnect(connection); - expect(report).toBeTruthy(); - resolve(); - }, - }); - }); + // Then we expect the reportPreview to update with new childVisibleActionCount - // When deleting expense - const {optimisticData, successData, shouldDeleteTransactionThread} = getDeleteTrackExpenseInformation( - selfDMReport, - transaction?.transactionID, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - createIOUAction!, - false, - undefined, - true, - ); + iouPreview = getReportPreviewAction(chatReport?.reportID, iouReport?.reportID); + expect(iouPreview).toBeTruthy(); + expect(iouPreview?.childVisibleActionCount).toEqual(0); + expect(iouPreview?.childCommenterCount).toEqual(0); + + // When we resume + mockFetch?.resume?.(); await waitForBatchedUpdates(); - // Then the transaction thread report should be ready to be deleted - expect(shouldDeleteTransactionThread).toBe(false); - expect(optimisticData).not.toEqual( - expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, value: expect.objectContaining({reportID: null})})]), - ); - expect(optimisticData).not.toEqual(expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, value: null})])); - expect(successData).not.toEqual(expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, value: null})])); + // Then we expect the reportPreview to update with new childVisibleActionCount + iouPreview = getReportPreviewAction(chatReport?.reportID, iouReport?.reportID); + expect(iouPreview).toBeTruthy(); + expect(iouPreview?.childVisibleActionCount).toEqual(0); + expect(iouPreview?.childCommenterCount).toEqual(0); }); }); @@ -15083,254 +13443,6 @@ describe('actions/IOU', () => { }); }); - describe('convertBulkTrackedExpensesToIOU', () => { - it('should accept personalDetails as a required parameter', async () => { - const currentUserAccountID = 1; - const currentUserEmail = 'user@test.com'; - const payerAccountID = 2; - const payerEmail = 'payer@test.com'; - const selfDMReportID = 'selfDM123'; - const targetReportID = 'iouReport456'; - const chatReportID = 'chatReport789'; - const transactionID = 'transaction001'; - - // Setup personal details - const testPersonalDetails: PersonalDetailsList = { - [currentUserAccountID]: { - accountID: currentUserAccountID, - displayName: 'Current User', - login: currentUserEmail, - }, - [payerAccountID]: { - accountID: payerAccountID, - displayName: 'Payer User', - login: payerEmail, - }, - }; - - // Setup self DM report - const selfDMReport: Report = { - reportID: selfDMReportID, - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, - ownerAccountID: currentUserAccountID, - participants: { - [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - }; - - // Setup IOU report (target) - const iouReport: Report = { - reportID: targetReportID, - type: CONST.REPORT.TYPE.IOU, - chatReportID, - ownerAccountID: currentUserAccountID, - managerID: payerAccountID, - participants: { - [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - }; - - // Setup chat report - const chatReport: Report = { - reportID: chatReportID, - type: CONST.REPORT.TYPE.CHAT, - iouReportID: targetReportID, - ownerAccountID: currentUserAccountID, - participants: { - [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - }; - - // Setup transaction - const transaction: Transaction = { - transactionID, - reportID: selfDMReportID, - amount: 1000, - currency: 'USD', - merchant: 'Test Merchant', - created: DateUtils.getDBTime(), - comment: {comment: 'Test expense'}, - }; - - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, testPersonalDetails); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`, selfDMReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${targetReportID}`, iouReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); - await waitForBatchedUpdates(); - - // Call should not throw when personalDetails is provided - expect(() => { - convertBulkTrackedExpensesToIOU({ - transactionIDs: [transactionID], - iouReport, - chatReport, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: currentUserAccountID, - currentUserEmailParam: currentUserEmail, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - personalDetails: testPersonalDetails, - betas: [CONST.BETAS.ALL], - }); - }).not.toThrow(); - }); - - it('should use personalDetails to look up payer email', async () => { - const currentUserAccountID = 10; - const currentUserEmail = 'current@test.com'; - const payerAccountID = 20; - const payerEmail = 'payer@test.com'; - const targetReportID = 'iouReport200'; - const chatReportID = 'chatReport200'; - - // Personal details with payer information - const testPersonalDetails: PersonalDetailsList = { - [currentUserAccountID]: { - accountID: currentUserAccountID, - displayName: 'Current User', - login: currentUserEmail, - }, - [payerAccountID]: { - accountID: payerAccountID, - displayName: 'Payer From PersonalDetails', - login: payerEmail, - }, - }; - - // Setup IOU report - const iouReport: Report = { - reportID: targetReportID, - type: CONST.REPORT.TYPE.IOU, - chatReportID, - ownerAccountID: currentUserAccountID, - managerID: payerAccountID, - participants: { - [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - }; - - // Setup chat report - const chatReport: Report = { - reportID: chatReportID, - type: CONST.REPORT.TYPE.CHAT, - iouReportID: targetReportID, - participants: { - [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - }, - }; - - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, testPersonalDetails); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${targetReportID}`, iouReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport); - await waitForBatchedUpdates(); - - // The function should be able to look up payer email from personalDetails - // Even if no transactions are provided, it should not throw - expect(() => { - convertBulkTrackedExpensesToIOU({ - transactionIDs: [], - iouReport, - chatReport, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: currentUserAccountID, - currentUserEmailParam: currentUserEmail, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - personalDetails: testPersonalDetails, - betas: [CONST.BETAS.ALL], - }); - }).not.toThrow(); - }); - - it('should handle empty personalDetails gracefully', async () => { - const currentUserAccountID = 30; - const currentUserEmail = 'user30@test.com'; - const targetReportID = 'iouReport300'; - const chatReportID = 'chatReport300'; - - // Setup minimal IOU report - const iouReport: Report = { - reportID: targetReportID, - type: CONST.REPORT.TYPE.IOU, - chatReportID, - }; - - const chatReport: Report = { - reportID: chatReportID, - type: CONST.REPORT.TYPE.CHAT, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${targetReportID}`, iouReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport); - await waitForBatchedUpdates(); - - // Should not throw even with empty personalDetails - expect(() => { - convertBulkTrackedExpensesToIOU({ - transactionIDs: [], - iouReport, - chatReport, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: currentUserAccountID, - currentUserEmailParam: currentUserEmail, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - personalDetails: undefined, - betas: [CONST.BETAS.ALL], - }); - }).not.toThrow(); - }); - - it('should handle undefined personalDetails gracefully', async () => { - const currentUserAccountID = 40; - const currentUserEmail = 'user40@test.com'; - const targetReportID = 'iouReport400'; - const chatReportID = 'chatReport400'; - - // Setup minimal IOU report - const iouReport: Report = { - reportID: targetReportID, - type: CONST.REPORT.TYPE.IOU, - chatReportID, - }; - - const chatReport: Report = { - reportID: chatReportID, - type: CONST.REPORT.TYPE.CHAT, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${targetReportID}`, iouReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport); - await waitForBatchedUpdates(); - - // Should not throw even with undefined personalDetails - expect(() => { - convertBulkTrackedExpensesToIOU({ - transactionIDs: [], - iouReport, - chatReport, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: currentUserAccountID, - currentUserEmailParam: currentUserEmail, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - personalDetails: undefined, - betas: [CONST.BETAS.ALL], - }); - }).not.toThrow(); - }); - }); - describe('completePaymentOnboarding', () => { let completeOnboardingSpy: jest.SpyInstance; diff --git a/tests/actions/TrackExpenseTest.ts b/tests/actions/TrackExpenseTest.ts new file mode 100644 index 0000000000000..15b077bb88a78 --- /dev/null +++ b/tests/actions/TrackExpenseTest.ts @@ -0,0 +1,1743 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* 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 {convertBulkTrackedExpensesToIOU, getDeleteTrackExpenseInformation, trackExpense} from '@libs/actions/IOU/TrackExpense'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {addComment, openReport} from '@libs/actions/Report'; +import {subscribeToUserEvents} from '@libs/actions/User'; +import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; +// eslint-disable-next-line no-restricted-syntax +import type * as PolicyUtils from '@libs/PolicyUtils'; +import {getOriginalMessage, isActionableTrackExpense, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import type {OptimisticChatReport} from '@libs/ReportUtils'; +import {createDraftTransactionAndNavigateToParticipantSelector} from '@libs/ReportUtils'; +import {getValidWaypoints, isDistanceRequest as isDistanceRequestUtil} from '@libs/TransactionUtils'; +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 {IntroSelected, PersonalDetailsList, Policy, Report} from '@src/types/onyx'; +import type {Accountant} from '@src/types/onyx/IOU'; +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 currencyList from '../unit/currencyList.json'; +import createRandomPolicy from '../utils/collections/policies'; +import createRandomPolicyCategories from '../utils/collections/policyCategory'; +import {createRandomReport} from '../utils/collections/reports'; +import createRandomTransaction from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; +import PusherHelper from '../utils/PusherHelper'; +import type {MockFetch} from '../utils/TestHelper'; +import {getGlobalFetchMock, getOnyxData, setPersonalDetails, signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +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(() => '23423423'), + setNavigationActionToMicrotaskQueue: jest.fn(), + removeScreenByKey: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRouteWithoutParams: jest.fn(), + getActiveRoute: jest.fn(), + navigationRef: { + getRootState: jest.fn(), + }, +})); + +jest.mock('@react-navigation/native'); + +jest.mock('@src/libs/actions/Report', () => { + const originalModule = jest.requireActual('@src/libs/actions/Report'); + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); + +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +jest.mock('@hooks/useCardFeedsForDisplay', () => jest.fn(() => ({defaultCardFeed: null, cardFeedsByPolicy: {}}))); + +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 VIT_EMAIL = 'vit@expensifail.com'; +const VIT_ACCOUNT_ID = 4; + +const TEST_INTRO_SELECTED: IntroSelected = { + choice: CONST.ONBOARDING_CHOICES.SUBMIT, + isInviteOnboardingComplete: false, +}; + +OnyxUpdateManager(); + +describe('actions/IOU/TrackExpense', () => { + let mockFetch: MockFetch; + + 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('trackExpense', () => { + it('category a distance expense of selfDM report', async () => { + /* + * This step simulates the following steps: + * - Go to self DM + * - Track a distance expense + * - Go to Troubleshoot > Clear cache and restart > Reset and refresh + * - Go to self DM + * - Click Categorize it (click Upgrade if there is no workspace) + * - Select category and submit the expense to the workspace + */ + + // Given a participant of the report + const participant = {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}; + + // Given valid waypoints of the transaction + const fakeWayPoints = { + waypoint0: { + keyForList: '88 Kearny Street_1735023533854', + lat: 37.7886378, + lng: -122.4033442, + address: '88 Kearny Street, San Francisco, CA, USA', + name: '88 Kearny Street', + }, + waypoint1: { + keyForList: 'Golden Gate Bridge Vista Point_1735023537514', + lat: 37.8077876, + lng: -122.4752007, + address: 'Golden Gate Bridge Vista Point, San Francisco, CA, USA', + name: 'Golden Gate Bridge Vista Point', + }, + }; + + // Given a selfDM report + const selfDMReport = createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM); + + // Given a policyExpenseChat report + const policyExpenseChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + + // Given policy categories and a policy + const fakeCategories = createRandomPolicyCategories(3); + const fakePolicy = createRandomPolicy(1); + + // Given a transaction with a distance request type and valid waypoints + const fakeTransaction = { + ...createRandomTransaction(1), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + ...createRandomTransaction(1).comment, + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + }, + waypoints: fakeWayPoints, + }, + }; + + // When the transaction is saved to draft before being submitted + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${fakeTransaction.transactionID}`, fakeTransaction); + mockFetch?.pause?.(); + + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + + // When the user submits the transaction to the selfDM report + trackExpense({ + report: selfDMReport, + isDraftPolicy: true, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: participant.login, + payeeAccountID: participant.accountID, + participant, + }, + transactionParams: { + amount: fakeTransaction.amount, + currency: fakeTransaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: fakeTransaction.merchant, + billable: false, + validWaypoints: fakeWayPoints, + actionableWhisperReportActionID: fakeTransaction?.actionableWhisperReportActionID, + linkedTrackedExpenseReportAction: fakeTransaction?.linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: fakeTransaction?.linkedTrackedExpenseReportID, + customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + }); + await waitForBatchedUpdates(); + await mockFetch?.resume?.(); + + // Given transaction after tracked expense + const transaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + Onyx.disconnect(connection); + const trackedExpenseTransaction = Object.values(transactions ?? {}).at(0); + + // Then the transaction must remain a distance request + const isDistanceRequest = isDistanceRequestUtil(trackedExpenseTransaction); + expect(isDistanceRequest).toBe(true); + resolve(trackedExpenseTransaction); + }, + }); + }); + + // Given all report actions of the selfDM report + const allReportActions = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (reportActions) => { + Onyx.disconnect(connection); + resolve(reportActions); + }, + }); + }); + + // Then the selfDM report should have an actionable track expense whisper action and an IOU action + const selfDMReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`]; + expect(Object.values(selfDMReportActions ?? {}).length).toBe(2); + + // When the cache is cleared before categorizing the tracked expense + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, { + iouRequestType: null, + }); + + // When the transaction is saved to draft by selecting a category in the selfDM report + const reportActionableTrackExpense = Object.values(selfDMReportActions ?? {}).find((reportAction) => isActionableTrackExpense(reportAction)); + createDraftTransactionAndNavigateToParticipantSelector({ + transactionID: transaction?.transactionID, + reportID: selfDMReport.reportID, + actionName: CONST.IOU.ACTION.CATEGORIZE, + reportActionID: reportActionableTrackExpense?.reportActionID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + allTransactionDrafts: {}, + activePolicy: undefined, + userBillingGraceEndPeriodCollection: undefined, + amountOwed: 0, + }); + await waitForBatchedUpdates(); + + // Then the transaction draft should be saved successfully + let allTransactionsDraft: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + allTransactionsDraft = val; + }, + }); + const transactionDraft = allTransactionsDraft?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction?.transactionID}`]; + + // When the user confirms the category for the tracked expense + trackExpense({ + report: policyExpenseChat, + isDraftPolicy: false, + action: CONST.IOU.ACTION.CATEGORIZE, + participantParams: { + payeeEmail: participant.login, + payeeAccountID: participant.accountID, + participant: {...participant, isPolicyExpenseChat: true}, + }, + policyParams: { + policy: fakePolicy, + policyCategories: fakeCategories, + }, + transactionParams: { + amount: transactionDraft?.amount ?? fakeTransaction.amount, + currency: transactionDraft?.currency ?? fakeTransaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: transactionDraft?.merchant ?? fakeTransaction.merchant, + category: Object.keys(fakeCategories).at(0) ?? '', + validWaypoints: Object.keys(transactionDraft?.comment?.waypoints ?? {}).length ? getValidWaypoints(transactionDraft?.comment?.waypoints, true) : undefined, + actionableWhisperReportActionID: transactionDraft?.actionableWhisperReportActionID, + linkedTrackedExpenseReportAction: transactionDraft?.linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: transactionDraft?.linkedTrackedExpenseReportID, + customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + }); + await waitForBatchedUpdates(); + await mockFetch?.resume?.(); + + // Then the expense should be categorized successfully + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + Onyx.disconnect(connection); + const categorizedTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`]; + + // Then the transaction must remain a distance request, ensuring that the optimistic data is correctly built and the transaction type remains accurate. + const isDistanceRequest = isDistanceRequestUtil(categorizedTransaction); + expect(isDistanceRequest).toBe(true); + + // Then the transaction category must match the original category + expect(categorizedTransaction?.category).toBe(Object.keys(fakeCategories).at(0) ?? ''); + resolve(); + }, + }); + }); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + callback: (quickAction) => { + Onyx.disconnect(connection); + resolve(); + + // Then the quickAction.action should be set to REQUEST_DISTANCE + expect(quickAction?.action).toBe(CONST.QUICK_ACTIONS.REQUEST_DISTANCE); + // Then the quickAction.chatReportID should be set to the given policyExpenseChat reportID + expect(quickAction?.chatReportID).toBe(policyExpenseChat.reportID); + }, + }); + }); + }); + + it('share with accountant', async () => { + const accountant: Required = {login: VIT_EMAIL, accountID: VIT_ACCOUNT_ID}; + const policy: Policy = {...createRandomPolicy(1), id: 'ABC'}; + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: '10', + }; + const policyExpenseChat: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + reportID: '123', + policyID: policy.id, + type: CONST.REPORT.TYPE.CHAT, + isOwnPolicyExpenseChat: true, + }; + const transaction: Transaction = {...createRandomTransaction(1), transactionID: '555'}; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, transaction); + + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + + // Create a tracked expense + 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: transaction.amount, + currency: transaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: transaction.merchant, + billable: false, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + }); + await waitForBatchedUpdates(); + + const selfDMReportActionsOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + expect(Object.values(selfDMReportActionsOnyx ?? {}).length).toBe(2); + + const linkedTrackedExpenseReportAction = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isMoneyRequestAction(reportAction)); + const reportActionableTrackExpense = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isActionableTrackExpense(reportAction)); + + mockFetch?.pause?.(); + + // Share the tracked expense with an accountant + 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: transaction.amount, + currency: transaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: transaction.merchant, + billable: false, + actionableWhisperReportActionID: reportActionableTrackExpense?.reportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: selfDMReport.reportID, + }, + accountantParams: { + accountant, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + }); + await waitForBatchedUpdates(); + + const policyExpenseChatOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + const policyOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + await mockFetch?.resume?.(); + + // Accountant should be invited to the expense report + expect(policyExpenseChatOnyx?.participants?.[accountant.accountID]).toBeTruthy(); + + // Accountant should be added to the workspace as an admin + expect(policyOnyx?.employeeList?.[accountant.login].role).toBe(CONST.POLICY.ROLE.ADMIN); + }); + + it('share with accountant who is already a member', async () => { + const accountant: Required = {login: VIT_EMAIL, accountID: VIT_ACCOUNT_ID}; + const policy: Policy = {...createRandomPolicy(1), id: 'ABC', employeeList: {[accountant.login]: {email: accountant.login, role: CONST.POLICY.ROLE.USER}}}; + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: '10', + }; + const policyExpenseChat: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + reportID: '123', + policyID: policy.id, + type: CONST.REPORT.TYPE.CHAT, + isOwnPolicyExpenseChat: true, + participants: {[accountant.accountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, + }; + const transaction: Transaction = {...createRandomTransaction(1), transactionID: '555'}; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, transaction); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountant.accountID]: accountant}); + + const recentWaypoints = (await getOnyxValue(ONYXKEYS.NVP_RECENT_WAYPOINTS)) ?? []; + + // Create a tracked expense + 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: transaction.amount, + currency: transaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: transaction.merchant, + billable: false, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + }); + await waitForBatchedUpdates(); + + const selfDMReportActionsOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + expect(Object.values(selfDMReportActionsOnyx ?? {}).length).toBe(2); + + const linkedTrackedExpenseReportAction = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isMoneyRequestAction(reportAction)); + const reportActionableTrackExpense = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isActionableTrackExpense(reportAction)); + + mockFetch?.pause?.(); + + // Share the tracked expense with an accountant + 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: transaction.amount, + currency: transaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: transaction.merchant, + billable: false, + actionableWhisperReportActionID: reportActionableTrackExpense?.reportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: selfDMReport.reportID, + }, + accountantParams: { + accountant, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + }); + await waitForBatchedUpdates(); + + const policyExpenseChatOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + const policyOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + await mockFetch?.resume?.(); + + // Accountant should be still a participant in the expense report + expect(policyExpenseChatOnyx?.participants?.[accountant.accountID]).toBeTruthy(); + + // Accountant role should change to admin + expect(policyOnyx?.employeeList?.[accountant.login].role).toBe(CONST.POLICY.ROLE.ADMIN); + }); + + /** + * Creates default trackExpense parameters - only override what's needed for each test + */ + function getDefaultTrackExpenseParams( + report: Report | undefined, + transactionOverrides: Partial[0]['transactionParams']> = {}, + ): Parameters[0] { + return { + report, + isDraftPolicy: false, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount: 10000, + currency: 'USD', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Test Merchant', + billable: false, + ...transactionOverrides, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints: [], + betas: [CONST.BETAS.ALL], + }; + } + + it('should create optimistic transaction with correct amount and currency', async () => { + // Given a selfDM report and transaction data + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-unit-1', + }; + const testAmount = 15000; // $150.00 + const testCurrency = 'USD'; + const testMerchant = 'Unit Test Merchant'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with specific amount and currency + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: testAmount, currency: testCurrency, merchant: testMerchant})); + await waitForBatchedUpdates(); + + // Then transaction should be created with correct values + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction).toBeTruthy(); + // Amount is stored as negative for track expenses + expect(Math.abs(createdTransaction?.amount ?? 0)).toBe(testAmount); + expect(createdTransaction?.currency).toBe(testCurrency); + expect(createdTransaction?.merchant).toBe(testMerchant); + }); + + it('should create actionable track expense whisper for selfDM reports', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-unit-2', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called on selfDM + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 5000})); + await waitForBatchedUpdates(); + + // Then an actionable track expense whisper should be created + const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); + + const actionableWhisper = Object.values(reportActions ?? {}).find((action) => isActionableTrackExpense(action)); + expect(actionableWhisper).toBeTruthy(); + }); + + it('should set correct tax fields when tax parameters are provided', async () => { + // Given a selfDM report and transaction with tax + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-unit-3', + }; + const testTaxCode = 'TAX_CODE_1'; + const testTaxAmount = 500; // $5.00 tax + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with tax parameters + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {merchant: 'Tax Test Merchant', taxCode: testTaxCode, taxAmount: testTaxAmount})); + await waitForBatchedUpdates(); + + // Then transaction should have correct tax fields + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.taxCode).toBe(testTaxCode); + expect(createdTransaction?.taxAmount).toBe(-testTaxAmount); + }); + + it('should set billable and reimbursable flags correctly', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-unit-4', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with billable=true and reimbursable=true + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 7500, merchant: 'Billable Test', billable: true, reimbursable: true})); + await waitForBatchedUpdates(); + + // Then transaction should have correct billable and reimbursable flags + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.billable).toBe(true); + expect(createdTransaction?.reimbursable).toBe(true); + }); + + it('should complete full track expense flow: create -> categorize -> submit to workspace', async () => { + // Given a selfDM report, policy, and expense chat + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-func-1', + }; + const policy = createRandomPolicy(1); + const policyExpenseChat: Report = { + ...createRandomReport(2, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + reportID: 'expense-chat-func-1', + policyID: policy.id, + type: CONST.REPORT.TYPE.CHAT, + isOwnPolicyExpenseChat: true, + }; + const policyCategories = createRandomPolicyCategories(3); + const selectedCategory = Object.keys(policyCategories).at(0) ?? ''; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + // When trackExpense is called to create a tracked expense in selfDM + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 25000, merchant: 'Functional Test Restaurant'})); + await waitForBatchedUpdates(); + + // Then the initial expense should be created with report actions + const selfDMReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); + + expect(Object.values(selfDMReportActions ?? {}).length).toBe(2); + const moneyRequestAction = Object.values(selfDMReportActions ?? {}).find((action) => isMoneyRequestAction(action)); + const actionableWhisper = Object.values(selfDMReportActions ?? {}).find((action) => isActionableTrackExpense(action)); + expect(moneyRequestAction).toBeTruthy(); + expect(actionableWhisper).toBeTruthy(); + + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction).toBeTruthy(); + + // When a draft is created for categorization + createDraftTransactionAndNavigateToParticipantSelector({ + transactionID: createdTransaction?.transactionID, + reportID: selfDMReport.reportID, + actionName: CONST.IOU.ACTION.CATEGORIZE, + reportActionID: actionableWhisper?.reportActionID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + allTransactionDrafts: {}, + activePolicy: undefined, + userBillingGraceEndPeriodCollection: undefined, + amountOwed: 0, + }); + await waitForBatchedUpdates(); + + // Then the draft should be created + let transactionDrafts: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + transactionDrafts = val; + }, + }); + const draftTransaction = transactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${createdTransaction?.transactionID}`]; + expect(draftTransaction).toBeTruthy(); + + // When the expense is categorized and submitted to workspace + trackExpense({ + report: policyExpenseChat, + isDraftPolicy: false, + action: CONST.IOU.ACTION.CATEGORIZE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, + }, + policyParams: { + policy, + policyCategories, + }, + transactionParams: { + amount: draftTransaction?.amount ?? 25000, + currency: draftTransaction?.currency ?? 'USD', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: draftTransaction?.merchant ?? 'Functional Test Restaurant', + category: selectedCategory, + actionableWhisperReportActionID: draftTransaction?.actionableWhisperReportActionID, + linkedTrackedExpenseReportAction: moneyRequestAction, + linkedTrackedExpenseReportID: selfDMReport.reportID, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints: [], + betas: [CONST.BETAS.ALL], + }); + await waitForBatchedUpdates(); + + // Then the transaction should be categorized + let finalTransactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + finalTransactions = val; + }, + }); + const categorizedTransaction = finalTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${createdTransaction?.transactionID}`]; + expect(categorizedTransaction?.category).toBe(selectedCategory); + }); + + it('should handle expense with attendees correctly', async () => { + // Given a selfDM report with attendees data + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-func-2', + }; + const testAttendees = [ + {email: 'attendee1@test.com', displayName: 'Attendee One', avatarUrl: ''}, + {email: 'attendee2@test.com', displayName: 'Attendee Two', avatarUrl: ''}, + ]; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with attendees + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 30000, merchant: 'Team Lunch', attendees: testAttendees})); + await waitForBatchedUpdates(); + + // Then transaction should have attendees + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.comment?.attendees).toHaveLength(2); + expect(createdTransaction?.comment?.attendees?.at(0)?.email).toBe('attendee1@test.com'); + }); + + it('should update quick action when tracking expense to policy expense chat', async () => { + // Given a policy expense chat + const policy = createRandomPolicy(1); + const policyExpenseChat: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), + reportID: 'expense-chat-func-2', + policyID: policy.id, + type: CONST.REPORT.TYPE.CHAT, + isOwnPolicyExpenseChat: true, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + // When trackExpense is called on policy expense chat + trackExpense({ + report: policyExpenseChat, + isDraftPolicy: false, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, + }, + policyParams: { + policy, + }, + transactionParams: { + amount: 12000, + currency: 'USD', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'Quick Action Test', + billable: false, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints: [], + betas: [CONST.BETAS.ALL], + }); + await waitForBatchedUpdates(); + + // Then quick action should be updated + const quickAction = await getOnyxValue(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + expect(quickAction).toBeTruthy(); + expect(quickAction?.chatReportID).toBe(policyExpenseChat.reportID); + }); + + it('should handle tracking expense without merchant gracefully', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-1', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called without merchant + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 5000, merchant: ''})); + await waitForBatchedUpdates(); + + // Then transaction should still be created + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + expect(Object.values(transactions ?? {}).length).toBeGreaterThan(0); + }); + + it('should handle zero amount expense', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-2', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with zero amount + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 0, merchant: 'Zero Amount Test'})); + await waitForBatchedUpdates(); + + // Then transaction should be created with zero amount + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + // trackExpense negates the amount, so 0 becomes -0, defaults to 1 to be able to use Math.abs + expect(createdTransaction).toBeTruthy(); + expect(Object.is(Math.abs(createdTransaction?.amount ?? 1), 0)).toBe(true); + }); + + it('should handle different currency codes correctly', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-3', + }; + const testCurrency = 'EUR'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with EUR currency + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 8500, currency: testCurrency, merchant: 'European Merchant'})); + await waitForBatchedUpdates(); + + // Then transaction should have correct currency + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.currency).toBe(testCurrency); + }); + + it('should create optimistic selfDM report when none exists', async () => { + // Given no selfDM report exists + + // When trackExpense is called with undefined report + trackExpense(getDefaultTrackExpenseParams(undefined, {amount: 3000, merchant: 'Optimistic SelfDM Test'})); + await waitForBatchedUpdates(); + + // Then a selfDM report should be created optimistically + const reports = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (val) => { + Onyx.disconnect(connection); + resolve(val); + }, + }); + }); + + const selfDMReports = Object.values(reports ?? {}).filter((r) => r?.chatType === CONST.REPORT.CHAT_TYPE.SELF_DM); + expect(selfDMReports.length).toBeGreaterThan(0); + }); + + it('should handle API failure gracefully with failure data', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-5', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + mockFetch?.fail?.(); + + // When trackExpense is called and the API fails + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 5000, merchant: 'Failure Test'})); + await waitForBatchedUpdates(); + + // Then optimistic data should still be created initially + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + expect(Object.values(transactions ?? {}).length).toBeGreaterThan(0); + + mockFetch?.succeed?.(); + }); + + it('should handle category and tag together correctly', async () => { + // Given a selfDM report with category and tag + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-6', + }; + const testCategory = 'Travel'; + const testTag = 'Business Trip'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with category and tag + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 50000, merchant: 'Airline', category: testCategory, tag: testTag})); + await waitForBatchedUpdates(); + + // Then transaction should have correct category and tag + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.category).toBe(testCategory); + expect(createdTransaction?.tag).toBe(testTag); + }); + + it('should handle very large expense amounts', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-7', + }; + const largeAmount = 99999999; // Large amount in cents + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with very large amount + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: largeAmount, merchant: 'Large Purchase'})); + await waitForBatchedUpdates(); + + // Then transaction should handle large amount correctly + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(Math.abs(createdTransaction?.amount ?? 0)).toBe(largeAmount); + }); + + it('should handle expense with special characters in merchant name', async () => { + // Given a selfDM report + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: 'selfDM-qa-8', + }; + const specialMerchant = "McDonald's & Café ñ 日本語"; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + + // When trackExpense is called with special characters in merchant + trackExpense(getDefaultTrackExpenseParams(selfDMReport, {amount: 1500, merchant: specialMerchant})); + await waitForBatchedUpdates(); + + // Then transaction should preserve special characters + let transactions: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + transactions = val; + }, + }); + + const createdTransaction = Object.values(transactions ?? {}).at(0); + expect(createdTransaction?.merchant).toBe(specialMerchant); + }); + }); + + describe('getDeleteTrackExpenseInformation', () => { + const amount = 10000; + const comment = 'Send me money please'; + let selfDMReport: Report; + let createIOUAction: OnyxEntry>; + let transaction: OnyxEntry; + let thread: OptimisticChatReport; + const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@test.com'; + let reportActionID; + const REPORT_ACTION: OnyxEntry = { + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: TEST_USER_ACCOUNT_ID, + automatic: false, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_3.png', + message: [{type: 'COMMENT', html: 'Testing a comment', text: 'Testing a comment', translationKey: ''}], + person: [{type: 'TEXT', style: 'strong', text: 'Test User'}], + shouldShow: true, + created: DateUtils.getDBTime(), + reportActionID: '1', + originalMessage: { + html: '', + whisperedTo: [], + }, + }; + + let reportActions: OnyxCollection; + + beforeEach(async () => { + // Given mocks are cleared and helpers are set up + jest.clearAllMocks(); + PusherHelper.setup(); + + // 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); + await waitForBatchedUpdates(); + await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); + + selfDMReport = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: '10', + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + 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, + activePolicyID: undefined, + quickAction: undefined, + recentWaypoints, + betas: [CONST.BETAS.ALL], + }); + 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(2); + + // 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); + if (transactionThreadReport) { + thread = transactionThreadReport; + } + expect(thread).toBeTruthy(); + expect(thread).toHaveProperty('reportID'); + expect(thread?.parentReportID).toBe(selfDMReport.reportID); + expect(thread).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}`]; + createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( + (reportAction): reportAction is ReportAction => reportAction.reportActionID === thread?.parentReportActionID, + ); + expect(createIOUAction).toBeTruthy(); + expect(createIOUAction?.childReportID).toBe(thread?.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 + 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); + }); + + afterEach(PusherHelper.teardown); + + it('should delete the transaction thread regardless of whether there are visible comments in the thread, if isMovingTransactionFromTrackExpense equals false.', async () => { + // Given initial environment is set up + await waitForBatchedUpdates(); + + expect(thread.participants).toEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); + + const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); + const userLogins = getLoginsByAccountIDs(participantAccountIDs); + jest.advanceTimersByTime(10); + openReport({ + reportID: thread.reportID, + introSelected: TEST_INTRO_SELECTED, + participantLoginList: userLogins, + newReportObject: thread, + parentReportActionID: createIOUAction?.reportActionID, + }); + await waitForBatchedUpdates(); + + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread?.reportID}`, + callback: (val) => (reportActions = val), + }); + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + callback: (report) => { + Onyx.disconnect(connection); + expect(report).toBeTruthy(); + resolve(); + }, + }); + }); + + jest.advanceTimersByTime(10); + + // When a comment is added + addComment({ + report: thread, + notifyReportID: thread.reportID, + ancestors: [], + text: 'Testing a comment', + timezoneParam: CONST.DEFAULT_TIME_ZONE, + currentUserAccountID: CARLOS_ACCOUNT_ID, + }); + await waitForBatchedUpdates(); + + // Then comment details should match the expected report action + const resultAction = Object.values(reportActions ?? {}).find((reportAction) => reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + reportActionID = resultAction?.reportActionID; + expect(resultAction?.message).toEqual(REPORT_ACTION.message); + expect(resultAction?.person).toEqual(REPORT_ACTION.person); + + await waitForBatchedUpdates(); + + // Then the report should have 2 actions + expect(Object.values(reportActions ?? {}).length).toBe(2); + const resultActionAfter = reportActionID ? reportActions?.[reportActionID] : undefined; + expect(resultActionAfter?.pendingAction).toBeUndefined(); + + const allReportActions = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { + Onyx.disconnect(connection); + resolve(actions); + }, + }); + }); + + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`]; + createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( + (reportAction): reportAction is ReportAction => reportAction.reportActionID === createIOUAction?.reportActionID, + ); + expect(createIOUAction).toBeTruthy(); + + // When deleting expense + const {optimisticData, successData, shouldDeleteTransactionThread} = getDeleteTrackExpenseInformation( + selfDMReport, + transaction?.transactionID, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + createIOUAction!, + false, + undefined, + undefined, + ); + await waitForBatchedUpdates(); + + // Then the transaction thread report should be ready to be deleted + expect(shouldDeleteTransactionThread).toBe(true); + expect(optimisticData).toEqual( + expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, value: expect.objectContaining({reportID: null})})]), + ); + expect(optimisticData).toEqual(expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, value: null})])); + expect(successData).toEqual(expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, value: null})])); + }); + + it('should NOT delete the transaction thread regardless of whether there are no visible comments in the thread, if isMovingTransactionFromTrackExpense equals true.', async () => { + // Given initial environment is set up + await waitForBatchedUpdates(); + + expect(thread.participants).toEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); + + const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); + const userLogins = getLoginsByAccountIDs(participantAccountIDs); + jest.advanceTimersByTime(10); + openReport({ + reportID: thread.reportID, + introSelected: TEST_INTRO_SELECTED, + participantLoginList: userLogins, + newReportObject: thread, + parentReportActionID: createIOUAction?.reportActionID, + }); + await waitForBatchedUpdates(); + + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread?.reportID}`, + callback: (val) => (reportActions = val), + }); + await waitForBatchedUpdates(); + + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + callback: (report) => { + Onyx.disconnect(connection); + expect(report).toBeTruthy(); + resolve(); + }, + }); + }); + + // When deleting expense + const {optimisticData, successData, shouldDeleteTransactionThread} = getDeleteTrackExpenseInformation( + selfDMReport, + transaction?.transactionID, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + createIOUAction!, + false, + undefined, + true, + ); + await waitForBatchedUpdates(); + + // Then the transaction thread report should be ready to be deleted + expect(shouldDeleteTransactionThread).toBe(false); + expect(optimisticData).not.toEqual( + expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, value: expect.objectContaining({reportID: null})})]), + ); + expect(optimisticData).not.toEqual(expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, value: null})])); + expect(successData).not.toEqual(expect.arrayContaining([expect.objectContaining({key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, value: null})])); + }); + }); + + describe('convertBulkTrackedExpensesToIOU', () => { + it('should accept personalDetails as a required parameter', async () => { + const currentUserAccountID = 1; + const currentUserEmail = 'user@test.com'; + const payerAccountID = 2; + const payerEmail = 'payer@test.com'; + const selfDMReportID = 'selfDM123'; + const targetReportID = 'iouReport456'; + const chatReportID = 'chatReport789'; + const transactionID = 'transaction001'; + + // Setup personal details + const testPersonalDetails: PersonalDetailsList = { + [currentUserAccountID]: { + accountID: currentUserAccountID, + displayName: 'Current User', + login: currentUserEmail, + }, + [payerAccountID]: { + accountID: payerAccountID, + displayName: 'Payer User', + login: payerEmail, + }, + }; + + // Setup self DM report + const selfDMReport: Report = { + reportID: selfDMReportID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + ownerAccountID: currentUserAccountID, + participants: { + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + + // Setup IOU report (target) + const iouReport: Report = { + reportID: targetReportID, + type: CONST.REPORT.TYPE.IOU, + chatReportID, + ownerAccountID: currentUserAccountID, + managerID: payerAccountID, + participants: { + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + + // Setup chat report + const chatReport: Report = { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + iouReportID: targetReportID, + ownerAccountID: currentUserAccountID, + participants: { + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + + // Setup transaction + const transaction: Transaction = { + transactionID, + reportID: selfDMReportID, + amount: 1000, + currency: 'USD', + merchant: 'Test Merchant', + created: DateUtils.getDBTime(), + comment: {comment: 'Test expense'}, + }; + + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, testPersonalDetails); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`, selfDMReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${targetReportID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await waitForBatchedUpdates(); + + // Call should not throw when personalDetails is provided + expect(() => { + convertBulkTrackedExpensesToIOU({ + transactionIDs: [transactionID], + iouReport, + chatReport, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + personalDetails: testPersonalDetails, + betas: [CONST.BETAS.ALL], + }); + }).not.toThrow(); + }); + + it('should use personalDetails to look up payer email', async () => { + const currentUserAccountID = 10; + const currentUserEmail = 'current@test.com'; + const payerAccountID = 20; + const payerEmail = 'payer@test.com'; + const targetReportID = 'iouReport200'; + const chatReportID = 'chatReport200'; + + // Personal details with payer information + const testPersonalDetails: PersonalDetailsList = { + [currentUserAccountID]: { + accountID: currentUserAccountID, + displayName: 'Current User', + login: currentUserEmail, + }, + [payerAccountID]: { + accountID: payerAccountID, + displayName: 'Payer From PersonalDetails', + login: payerEmail, + }, + }; + + // Setup IOU report + const iouReport: Report = { + reportID: targetReportID, + type: CONST.REPORT.TYPE.IOU, + chatReportID, + ownerAccountID: currentUserAccountID, + managerID: payerAccountID, + participants: { + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + + // Setup chat report + const chatReport: Report = { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + iouReportID: targetReportID, + participants: { + [currentUserAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [payerAccountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, testPersonalDetails); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${targetReportID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport); + await waitForBatchedUpdates(); + + // The function should be able to look up payer email from personalDetails + // Even if no transactions are provided, it should not throw + expect(() => { + convertBulkTrackedExpensesToIOU({ + transactionIDs: [], + iouReport, + chatReport, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + personalDetails: testPersonalDetails, + betas: [CONST.BETAS.ALL], + }); + }).not.toThrow(); + }); + + it('should handle empty personalDetails gracefully', async () => { + const currentUserAccountID = 30; + const currentUserEmail = 'user30@test.com'; + const targetReportID = 'iouReport300'; + const chatReportID = 'chatReport300'; + + // Setup minimal IOU report + const iouReport: Report = { + reportID: targetReportID, + type: CONST.REPORT.TYPE.IOU, + chatReportID, + }; + + const chatReport: Report = { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${targetReportID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport); + await waitForBatchedUpdates(); + + // Should not throw even with empty personalDetails + expect(() => { + convertBulkTrackedExpensesToIOU({ + transactionIDs: [], + iouReport, + chatReport, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + personalDetails: undefined, + betas: [CONST.BETAS.ALL], + }); + }).not.toThrow(); + }); + + it('should handle undefined personalDetails gracefully', async () => { + const currentUserAccountID = 40; + const currentUserEmail = 'user40@test.com'; + const targetReportID = 'iouReport400'; + const chatReportID = 'chatReport400'; + + // Setup minimal IOU report + const iouReport: Report = { + reportID: targetReportID, + type: CONST.REPORT.TYPE.IOU, + chatReportID, + }; + + const chatReport: Report = { + reportID: chatReportID, + type: CONST.REPORT.TYPE.CHAT, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${targetReportID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport); + await waitForBatchedUpdates(); + + // Should not throw even with undefined personalDetails + expect(() => { + convertBulkTrackedExpensesToIOU({ + transactionIDs: [], + iouReport, + chatReport, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + personalDetails: undefined, + betas: [CONST.BETAS.ALL], + }); + }).not.toThrow(); + }); + }); +}); diff --git a/tests/ui/UnreadIndicatorsTest.tsx b/tests/ui/UnreadIndicatorsTest.tsx index 7e60f44ba8197..205bb96b71c01 100644 --- a/tests/ui/UnreadIndicatorsTest.tsx +++ b/tests/ui/UnreadIndicatorsTest.tsx @@ -10,7 +10,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import OnyxUtils from 'react-native-onyx/dist/OnyxUtils'; import {setSidebarLoaded} from '@libs/actions/App'; -import {trackExpense} from '@libs/actions/IOU'; +import {trackExpense} from '@libs/actions/IOU/TrackExpense'; import {addComment, deleteReportComment, markCommentAsUnread, readNewestAction} from '@libs/actions/Report'; import {subscribeToUserEvents} from '@libs/actions/User'; import {lastItem} from '@libs/CollectionUtils'; diff --git a/tests/unit/GoogleTagManagerTest.tsx b/tests/unit/GoogleTagManagerTest.tsx index 77ea0d1d1c8d2..bba4f93754299 100644 --- a/tests/unit/GoogleTagManagerTest.tsx +++ b/tests/unit/GoogleTagManagerTest.tsx @@ -2,7 +2,7 @@ import {NavigationContainer} from '@react-navigation/native'; import type * as NativeNavigation from '@react-navigation/native'; import {act, render} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; -import {trackExpense} from '@libs/actions/IOU'; +import {trackExpense} from '@libs/actions/IOU/TrackExpense'; import {addPaymentCard, addSubscriptionPaymentCard} from '@libs/actions/PaymentMethods'; import {createWorkspace} from '@libs/actions/Policy/Policy'; import GoogleTagManager from '@libs/GoogleTagManager';