From cd4f8b5841eaf7349abd5934f6dbfa25226f04d3 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 15 Jan 2026 16:03:58 +0700 Subject: [PATCH 01/11] move split actions to new file --- src/hooks/useDeleteTransactions.ts | 3 +- .../handleFileRetry.ts | 10 +- src/libs/actions/IOU/Split.ts | 1549 +++ src/libs/actions/IOU/index.ts | 1687 +-- src/pages/iou/SplitBillDetailsPage.tsx | 3 +- src/pages/iou/SplitExpensePage.tsx | 2 +- .../step/IOURequestStepConfirmation.tsx | 4 +- .../step/IOURequestStepScan/index.native.tsx | 2 +- .../request/step/IOURequestStepScan/index.tsx | 2 +- tests/actions/IOUTest.ts | 10661 +++++++--------- tests/actions/IOUTest/SplitTest.ts | 2157 ++++ 11 files changed, 8139 insertions(+), 7941 deletions(-) create mode 100644 src/libs/actions/IOU/Split.ts create mode 100644 tests/actions/IOUTest/SplitTest.ts diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index 24f7dd9e6fd02..38ec654495c90 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -1,7 +1,8 @@ import {useCallback} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; -import {deleteMoneyRequest, getIOURequestPolicyID, initSplitExpenseItemData, updateSplitTransactions} from '@libs/actions/IOU'; +import {deleteMoneyRequest, getIOURequestPolicyID, initSplitExpenseItemData} from '@libs/actions/IOU'; import {getIOUActionForTransactions} from '@libs/actions/IOU/Duplicate'; +import {updateSplitTransactions} from '@libs/actions/IOU/Split'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getChildTransactions, getOriginalTransactionWithSplitInfo} from '@libs/TransactionUtils'; diff --git a/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts b/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts index 4bf3a4777edfd..7ce351ae4daa2 100644 --- a/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts +++ b/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts @@ -1,11 +1,13 @@ import * as IOU from '@userActions/IOU'; +import {startSplitBill} from '@userActions/IOU/Split'; +import type {StartSplitBilActionParams} from '@userActions/IOU/Split'; import CONST from '@src/CONST'; import type {ReceiptError} from '@src/types/onyx/Transaction'; export default function handleFileRetry(message: ReceiptError, file: File, dismissError: () => void, setShouldShowErrorModal: (value: boolean) => void) { - const retryParams: IOU.ReplaceReceipt | IOU.StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation = + const retryParams: IOU.ReplaceReceipt | StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation = typeof message.retryParams === 'string' - ? (JSON.parse(message.retryParams) as IOU.ReplaceReceipt | IOU.StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation) + ? (JSON.parse(message.retryParams) as IOU.ReplaceReceipt | StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation) : message.retryParams; switch (message.action) { @@ -18,10 +20,10 @@ export default function handleFileRetry(message: ReceiptError, file: File, dismi } case CONST.IOU.ACTION_PARAMS.START_SPLIT_BILL: { dismissError(); - const startSplitBillParams = {...retryParams} as IOU.StartSplitBilActionParams; + const startSplitBillParams = {...retryParams} as StartSplitBilActionParams; startSplitBillParams.receipt = file; startSplitBillParams.shouldPlaySound = false; - IOU.startSplitBill(startSplitBillParams); + startSplitBill(startSplitBillParams); break; } case CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE: { diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts new file mode 100644 index 0000000000000..fc07a35840440 --- /dev/null +++ b/src/libs/actions/IOU/Split.ts @@ -0,0 +1,1549 @@ +import {InteractionManager} from 'react-native'; +import type {OnyxCollection, OnyxEntry, OnyxKey, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {SearchContextProps} from '@components/Search/types'; +import * as API from '@libs/API'; +import type {CompleteSplitBillParams, RevertSplitTransactionParams, SplitBillParams, SplitTransactionParams, SplitTransactionSplitsParam, StartSplitBillParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import DateUtils from '@libs/DateUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import {calculateAmount as calculateIOUAmount, updateIOUOwnerAndTotal} from '@libs/IOUUtils'; +import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import * as NumberUtils from '@libs/NumberUtils'; +import Parser from '@libs/Parser'; +import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; +import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; +import {getAllReportActions, getOriginalMessage, getReportAction, getReportActionHtml, getReportActionText, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import { + buildOptimisticChatReport, + buildOptimisticCreatedReportAction, + buildOptimisticExpenseReport, + buildOptimisticIOUReport, + buildOptimisticIOUReportAction, + buildOptimisticMoneyRequestEntities, + buildOptimisticReportPreview, + generateReportID, + getChatByParticipants, + getParsedComment, + getReportOrDraftReport, + getTransactionDetails, + hasViolations as hasViolationsReportUtils, + isArchivedReport, + isPolicyExpenseChat as isPolicyExpenseChatReportUtil, + shouldCreateNewMoneyRequestReport as shouldCreateNewMoneyRequestReportReportUtils, + updateReportPreview, +} from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; +import {buildOptimisticTransaction, getChildTransactions, isOnHold, isPerDiemRequest as isPerDiemRequestTransactionUtils} from '@libs/TransactionUtils'; +import {buildOptimisticPolicyRecentlyUsedTags, getPolicyTagsData} from '@userActions/Policy/Tag'; +import {notifyNewAction} from '@userActions/Report'; +import {removeDraftSplitTransaction, removeDraftTransaction} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Attendee, Participant, Split, SplitExpense} from '@src/types/onyx/IOU'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; +import type RecentlyUsedTags from '@src/types/onyx/RecentlyUsedTags'; +import type ReportAction from '@src/types/onyx/ReportAction'; +import type {OnyxData} from '@src/types/onyx/Request'; +import type {SplitShares, TransactionChanges} from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import { + buildMinimalTransactionForFormula, + buildOnyxDataForMoneyRequest, + createSplitsAndOnyxData, + dismissModalAndOpenReportInInboxTab, + getAllPersonalDetails, + getAllReports, + getAllTransactions, + getDeleteTrackExpenseInformation, + getMoneyRequestInformation, + getMoneyRequestParticipantsFromReport, + getOrCreateOptimisticSplitChatReport, + getReceiptError, + getReportPreviewAction, + getUpdateMoneyRequestParams, + mergePolicyRecentlyUsedCategories, + mergePolicyRecentlyUsedCurrencies, +} from './index'; +import type {MoneyRequestInformationParams, OneOnOneIOUReport, StartSplitBilActionParams} from './index'; + +type IOURequestType = ValueOf; + +type SplitBillActionsParams = { + participants: Participant[]; + currentUserLogin: string; + currentUserAccountID: number; + amount: number; + comment: string; + currency: string; + merchant: string; + created: string; + category?: string; + tag?: string; + billable?: boolean; + reimbursable?: boolean; + iouRequestType?: IOURequestType; + existingSplitChatReportID?: string; + splitShares?: SplitShares; + taxCode?: string; + taxAmount?: number; + isRetry?: boolean; + policyRecentlyUsedCategories?: OnyxEntry; + policyRecentlyUsedTags: OnyxEntry; + isASAPSubmitBetaEnabled: boolean; + transactionViolations: OnyxCollection; + quickAction: OnyxEntry; + policyRecentlyUsedCurrencies: string[]; +}; + +type UpdateSplitTransactionsParams = { + allTransactionsList: OnyxCollection; + allReportsList: OnyxCollection; + allReportNameValuePairsList: OnyxCollection; + transactionData: { + reportID: string; + originalTransactionID: string; + splitExpenses: SplitExpense[]; + splitExpensesTotal?: number; + }; + searchContext?: Partial; + policyCategories: OnyxTypes.PolicyCategories | undefined; + policy: OnyxTypes.Policy | undefined; + policyRecentlyUsedCategories: OnyxTypes.RecentlyUsedCategories | undefined; + iouReport: OnyxEntry; + firstIOU: OnyxEntry | undefined; + isASAPSubmitBetaEnabled: boolean; + currentUserPersonalDetails: CurrentUserPersonalDetails; + transactionViolations: OnyxCollection; + quickAction: OnyxEntry; + policyRecentlyUsedCurrencies: string[]; +}; + +/** Used for editing a split expense while it's still scanning or when SmartScan fails, it completes a split expense started by startSplitBill above. + * + * @param chatReportID - The group chat or workspace reportID + * @param reportAction - The split action that lives in the chatReport above + * @param updatedTransaction - The updated **draft** split transaction + * @param sessionAccountID - accountID of the current user + * @param sessionEmail - email of the current user + */ +function completeSplitBill( + chatReportID: string, + reportAction: OnyxEntry, + updatedTransaction: OnyxEntry, + sessionAccountID: number, + isASAPSubmitBetaEnabled: boolean, + quickAction: OnyxEntry, + transactionViolations: OnyxCollection, + sessionEmail?: string, +) { + if (!reportAction) { + return; + } + + const parsedComment = getParsedComment(Parser.htmlToMarkdown(updatedTransaction?.comment?.comment ?? '')); + if (updatedTransaction?.comment) { + // eslint-disable-next-line no-param-reassign + updatedTransaction.comment.comment = parsedComment; + } + const currentUserEmailForIOUSplit = addSMSDomainIfPhoneNumber(sessionEmail); + const transactionID = updatedTransaction?.transactionID; + const unmodifiedTransaction = getAllTransactions()[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + + // Save optimistic updated transaction and action + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + receipt: { + state: CONST.IOU.RECEIPT_STATE.OPEN, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: { + [reportAction.reportActionID]: { + lastModified: DateUtils.getDBTime(), + originalMessage: { + whisperedTo: [], + }, + }, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {pendingAction: null}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, + value: {pendingAction: null}, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...unmodifiedTransaction, + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: { + [reportAction.reportActionID]: { + ...reportAction, + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ]; + + const splitParticipants: Split[] = updatedTransaction?.comment?.splits ?? []; + const amount = updatedTransaction?.modifiedAmount; + const currency = updatedTransaction?.modifiedCurrency; + + // Exclude the current user when calculating the split amount, `calculateAmount` takes it into account + const splitAmount = calculateIOUAmount(splitParticipants.length - 1, amount ?? 0, currency ?? '', false); + const splitTaxAmount = calculateIOUAmount(splitParticipants.length - 1, updatedTransaction?.taxAmount ?? 0, currency ?? '', false); + + const splits: Split[] = [{email: currentUserEmailForIOUSplit}]; + for (const participant of splitParticipants) { + // Skip creating the transaction for the current user + if (participant.email === currentUserEmailForIOUSplit) { + continue; + } + const isPolicyExpenseChat = !!participant.policyID; + + if (!isPolicyExpenseChat) { + // In case this is still the optimistic accountID saved in the splits array, return early as we cannot know + // if there is an existing chat between the split creator and this participant + // Instead, we will rely on Auth generating the report IDs and the user won't see any optimistic chats or reports created + const participantPersonalDetails: OnyxTypes.PersonalDetails | null = getAllPersonalDetails()[participant?.accountID ?? CONST.DEFAULT_NUMBER_ID]; + if (!participantPersonalDetails || participantPersonalDetails.isOptimisticPersonalDetail) { + splits.push({ + email: participant.email, + }); + continue; + } + } + + let oneOnOneChatReport: OnyxEntry; + let isNewOneOnOneChatReport = false; + if (isPolicyExpenseChat) { + // The expense chat reportID is saved in the splits array when starting a split expense with a workspace + oneOnOneChatReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]; + } else { + const existingChatReport = getChatByParticipants(participant.accountID ? [participant.accountID, sessionAccountID] : []); + isNewOneOnOneChatReport = !existingChatReport; + oneOnOneChatReport = + existingChatReport ?? + buildOptimisticChatReport({ + participantList: participant.accountID ? [participant.accountID, sessionAccountID] : [], + }); + } + + let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport?.iouReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] : null; + const shouldCreateNewOneOnOneIOUReport = shouldCreateNewMoneyRequestReportReportUtils(oneOnOneIOUReport, oneOnOneChatReport, false); + + // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation + const optimisticTransactionID = NumberUtils.rand64(); + const optimisticExpenseReportID = generateReportID(); + + if (!oneOnOneIOUReport || shouldCreateNewOneOnOneIOUReport) { + const reportTransactions = buildMinimalTransactionForFormula( + optimisticTransactionID, + optimisticExpenseReportID, + updatedTransaction?.modifiedCreated, + splitAmount, + currency ?? '', + updatedTransaction?.modifiedMerchant, + ); + + oneOnOneIOUReport = isPolicyExpenseChat + ? buildOptimisticExpenseReport( + oneOnOneChatReport?.reportID, + participant.policyID, + sessionAccountID, + splitAmount, + currency ?? '', + undefined, + undefined, + optimisticExpenseReportID, + reportTransactions, + ) + : buildOptimisticIOUReport(sessionAccountID, participant.accountID ?? CONST.DEFAULT_NUMBER_ID, splitAmount, oneOnOneChatReport?.reportID, currency ?? ''); + } else if (isPolicyExpenseChat) { + if (typeof oneOnOneIOUReport?.total === 'number') { + // Because of the Expense reports are stored as negative values, we subtract the total from the amount + oneOnOneIOUReport.total -= splitAmount; + } + } else { + oneOnOneIOUReport = updateIOUOwnerAndTotal(oneOnOneIOUReport, sessionAccountID, splitAmount, currency ?? ''); + } + + const oneOnOneTransaction = buildOptimisticTransaction({ + existingTransactionID: optimisticTransactionID, + originalTransactionID: transactionID, + transactionParams: { + amount: isPolicyExpenseChat ? -splitAmount : splitAmount, + currency: currency ?? '', + reportID: oneOnOneIOUReport?.reportID, + comment: parsedComment, + created: updatedTransaction?.modifiedCreated, + merchant: updatedTransaction?.modifiedMerchant, + receipt: {...updatedTransaction?.receipt, state: CONST.IOU.RECEIPT_STATE.OPEN}, + category: updatedTransaction?.category, + tag: updatedTransaction?.tag, + taxCode: updatedTransaction?.taxCode, + taxAmount: isPolicyExpenseChat ? -splitTaxAmount : splitAmount, + billable: updatedTransaction?.billable, + reimbursable: updatedTransaction?.reimbursable, + source: CONST.IOU.TYPE.SPLIT, + filename: updatedTransaction?.receipt?.filename, + }, + }); + oneOnOneIOUReport.transactionCount = (oneOnOneIOUReport.transactionCount ?? 0) + 1; + + const [oneOnOneCreatedActionForChat, oneOnOneCreatedActionForIOU, oneOnOneIOUAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = + buildOptimisticMoneyRequestEntities({ + iouReport: oneOnOneIOUReport, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: splitAmount, + currency: currency ?? '', + comment: parsedComment, + payeeEmail: currentUserEmailForIOUSplit, + participants: [participant], + transactionID: oneOnOneTransaction.transactionID, + }); + + let oneOnOneReportPreviewAction = getReportPreviewAction(oneOnOneChatReport?.reportID, oneOnOneIOUReport?.reportID); + if (oneOnOneReportPreviewAction) { + oneOnOneReportPreviewAction = updateReportPreview(oneOnOneIOUReport, oneOnOneReportPreviewAction); + } else { + oneOnOneReportPreviewAction = buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction); + } + const hasViolations = hasViolationsReportUtils(oneOnOneIOUReport.reportID, transactionViolations, sessionAccountID, sessionEmail ?? ''); + + const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest({ + isNewChatReport: isNewOneOnOneChatReport, + isOneOnOneSplit: true, + shouldCreateNewMoneyRequestReport: shouldCreateNewOneOnOneIOUReport, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam: sessionAccountID, + currentUserEmailParam: sessionEmail ?? '', + hasViolations, + optimisticParams: { + chat: { + report: oneOnOneChatReport, + createdAction: oneOnOneCreatedActionForChat, + reportPreviewAction: oneOnOneReportPreviewAction, + }, + iou: { + report: oneOnOneIOUReport, + createdAction: oneOnOneCreatedActionForIOU, + action: oneOnOneIOUAction, + }, + transactionParams: { + transaction: oneOnOneTransaction, + transactionThreadReport: optimisticTransactionThread, + transactionThreadCreatedReportAction: optimisticCreatedActionForTransactionThread, + }, + policyRecentlyUsed: {}, + }, + quickAction, + }); + + splits.push({ + email: participant.email, + accountID: participant.accountID, + policyID: participant.policyID, + iouReportID: oneOnOneIOUReport?.reportID, + chatReportID: oneOnOneChatReport?.reportID, + transactionID: oneOnOneTransaction.transactionID, + reportActionID: oneOnOneIOUAction.reportActionID, + createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID, + createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID, + reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID, + }); + + optimisticData.push(...oneOnOneOptimisticData); + successData.push(...oneOnOneSuccessData); + failureData.push(...oneOnOneFailureData); + } + + const { + amount: transactionAmount, + currency: transactionCurrency, + created: transactionCreated, + merchant: transactionMerchant, + comment: transactionComment, + category: transactionCategory, + tag: transactionTag, + taxCode: transactionTaxCode, + taxAmount: transactionTaxAmount, + billable: transactionBillable, + reimbursable: transactionReimbursable, + } = getTransactionDetails(updatedTransaction) ?? {}; + + const parameters: CompleteSplitBillParams = { + transactionID, + amount: transactionAmount, + currency: transactionCurrency, + created: transactionCreated, + merchant: transactionMerchant, + comment: transactionComment, + category: transactionCategory, + tag: transactionTag, + splits: JSON.stringify(splits), + taxCode: transactionTaxCode, + taxAmount: transactionTaxAmount, + billable: transactionBillable, + reimbursable: transactionReimbursable, + description: parsedComment, + }; + + playSound(SOUNDS.DONE); + API.write(WRITE_COMMANDS.COMPLETE_SPLIT_BILL, parameters, {optimisticData, successData, failureData}); + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); + dismissModalAndOpenReportInInboxTab(chatReportID); + notifyNewAction(chatReportID, sessionAccountID); +} + +/** + * @param amount - always in smallest currency unit + * @param existingSplitChatReportID - Either a group DM or a expense chat + */ +function splitBill({ + participants, + currentUserLogin, + currentUserAccountID, + amount, + comment, + currency, + merchant, + created, + category = '', + tag = '', + billable = false, + reimbursable = false, + iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, + existingSplitChatReportID, + splitShares = {}, + taxCode = '', + taxAmount = 0, + policyRecentlyUsedCategories, + isASAPSubmitBetaEnabled, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + policyRecentlyUsedTags, +}: SplitBillActionsParams) { + const parsedComment = getParsedComment(comment); + const {splitData, splits, onyxData} = createSplitsAndOnyxData({ + participants, + currentUserLogin, + currentUserAccountID, + existingSplitChatReportID, + transactionParams: { + amount, + comment: parsedComment, + currency, + merchant, + created, + category, + tag, + splitShares, + billable, + reimbursable, + iouRequestType, + taxCode, + taxAmount, + }, + policyRecentlyUsedCategories, + policyRecentlyUsedTags, + isASAPSubmitBetaEnabled, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + }); + + const parameters: SplitBillParams = { + reportID: splitData.chatReportID, + amount, + splits: JSON.stringify(splits), + currency, + comment: parsedComment, + category, + merchant, + created, + tag, + billable, + reimbursable, + transactionID: splitData.transactionID, + reportActionID: splitData.reportActionID, + createdReportActionID: splitData.createdReportActionID, + policyID: splitData.policyID, + chatType: splitData.chatType, + taxCode, + taxAmount, + description: parsedComment, + }; + + playSound(SOUNDS.DONE); + API.write(WRITE_COMMANDS.SPLIT_BILL, parameters, onyxData); + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); + + dismissModalAndOpenReportInInboxTab(existingSplitChatReportID); + + notifyNewAction(splitData.chatReportID, currentUserAccountID); +} + +/** + * @param amount - always in the smallest currency unit + */ +function splitBillAndOpenReport({ + participants, + currentUserLogin, + currentUserAccountID, + amount, + comment, + currency, + merchant, + created, + category = '', + tag = '', + billable = false, + reimbursable = false, + iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, + splitShares = {}, + taxCode = '', + taxAmount = 0, + existingSplitChatReportID, + policyRecentlyUsedCategories, + policyRecentlyUsedTags, + isASAPSubmitBetaEnabled, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, +}: SplitBillActionsParams) { + const parsedComment = getParsedComment(comment); + const {splitData, splits, onyxData} = createSplitsAndOnyxData({ + participants, + currentUserLogin, + currentUserAccountID, + existingSplitChatReportID, + isASAPSubmitBetaEnabled, + transactionParams: { + amount, + comment: parsedComment, + currency, + merchant, + created, + category, + tag, + splitShares, + billable, + reimbursable, + iouRequestType, + taxCode, + taxAmount, + }, + policyRecentlyUsedCategories, + policyRecentlyUsedTags, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + }); + + const parameters: SplitBillParams = { + reportID: splitData.chatReportID, + amount, + splits: JSON.stringify(splits), + currency, + merchant, + created, + comment: parsedComment, + category, + tag, + billable, + reimbursable, + transactionID: splitData.transactionID, + reportActionID: splitData.reportActionID, + createdReportActionID: splitData.createdReportActionID, + policyID: splitData.policyID, + chatType: splitData.chatType, + taxCode, + taxAmount, + description: parsedComment, + }; + + playSound(SOUNDS.DONE); + API.write(WRITE_COMMANDS.SPLIT_BILL_AND_OPEN_REPORT, parameters, onyxData); + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); + + dismissModalAndOpenReportInInboxTab(splitData.chatReportID); + notifyNewAction(splitData.chatReportID, currentUserAccountID); +} + +/** Used exclusively for starting a split expense request that contains a receipt, the split request will be completed once the receipt is scanned + * or user enters details manually. + * + * @param existingSplitChatReportID - Either a group DM or a expense chat + */ +function startSplitBill({ + participants, + currentUserLogin, + currentUserAccountID, + comment, + receipt, + existingSplitChatReportID, + billable = false, + reimbursable = false, + category = '', + tag = '', + currency, + taxCode = '', + taxAmount = 0, + shouldPlaySound = true, + policyRecentlyUsedCategories, + policyRecentlyUsedTags, + quickAction, + policyRecentlyUsedCurrencies, +}: StartSplitBilActionParams) { + const currentUserEmailForIOUSplit = addSMSDomainIfPhoneNumber(currentUserLogin); + const participantAccountIDs = participants.map((participant) => Number(participant.accountID)); + const {splitChatReport, existingSplitChatReport} = getOrCreateOptimisticSplitChatReport(existingSplitChatReportID, participants, participantAccountIDs, currentUserAccountID); + const isOwnPolicyExpenseChat = !!splitChatReport.isOwnPolicyExpenseChat; + const parsedComment = getParsedComment(comment); + + // ReportID is -2 (aka "deleted") on the group transaction + const splitTransaction = buildOptimisticTransaction({ + transactionParams: { + amount: 0, + currency, + reportID: CONST.REPORT.SPLIT_REPORT_ID, + comment: parsedComment, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + receipt, + category, + tag, + taxCode, + taxAmount, + billable, + reimbursable, + }, + }); + + const filename = splitTransaction.receipt?.filename; + + // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat + const splitChatCreatedReportAction = buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + const splitIOUReportAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.SPLIT, + amount: 0, + currency: CONST.CURRENCY.USD, + comment: parsedComment, + participants, + transactionID: splitTransaction.transactionID, + isOwnPolicyExpenseChat, + }); + + splitChatReport.lastReadTime = DateUtils.getDBTime(); + splitChatReport.lastMessageText = getReportActionText(splitIOUReportAction); + splitChatReport.lastMessageHtml = getReportActionHtml(splitIOUReportAction); + + // If we have an existing splitChatReport (group chat or workspace) use it's pending fields, otherwise indicate that we are adding a chat + if (!existingSplitChatReport) { + splitChatReport.pendingFields = { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + } + + const optimisticData: OnyxUpdate[] = [ + { + // Use set for new reports because it doesn't exist yet, is faster, + // and we need the data to be available when we navigate to the chat page + onyxMethod: existingSplitChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: splitChatReport, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: { + action: CONST.QUICK_ACTIONS.SPLIT_SCAN, + chatReportID: splitChatReport.reportID, + isFirstQuickAction: isEmptyObject(quickAction), + }, + }, + existingSplitChatReport + ? { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitIOUReportAction.reportActionID]: splitIOUReportAction as OnyxTypes.ReportAction, + }, + } + : { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitChatCreatedReportAction.reportActionID]: splitChatCreatedReportAction, + [splitIOUReportAction.reportActionID]: splitIOUReportAction as OnyxTypes.ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: splitTransaction, + }, + ]; + + if (!existingSplitChatReport) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${splitChatReport.reportID}`, + value: { + isOptimisticReport: true, + }, + }); + } + + const successData: Array< + OnyxUpdate + > = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + ...(existingSplitChatReport ? {} : {[splitChatCreatedReportAction.reportActionID]: {pendingAction: null}}), + [splitIOUReportAction.reportActionID]: {pendingAction: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: {pendingAction: null}, + }, + ]; + + if (!existingSplitChatReport) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${splitChatReport.reportID}`, + value: { + isOptimisticReport: false, + }, + }); + } + + const redundantParticipants: Record = {}; + if (!existingSplitChatReport) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: {pendingFields: {createChat: null}, participants: redundantParticipants}, + }); + } + + const failureData: Array< + OnyxUpdate + > = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: { + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: quickAction ?? null, + }, + ]; + + const retryParams = { + participants: participants.map(({icons, ...rest}) => rest), + currentUserLogin, + currentUserAccountID, + comment, + receipt, + existingSplitChatReportID, + billable, + reimbursable, + category, + tag, + currency, + taxCode, + taxAmount, + quickAction, + policyRecentlyUsedCurrencies, + policyRecentlyUsedTags, + }; + + if (existingSplitChatReport) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitIOUReportAction.reportActionID]: { + errors: getReceiptError(receipt, filename, undefined, undefined, CONST.IOU.ACTION_PARAMS.START_SPLIT_BILL, retryParams), + }, + }, + }); + } else { + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, + value: { + errorFields: { + createChat: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, + value: { + [splitChatCreatedReportAction.reportActionID]: { + errors: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), + }, + [splitIOUReportAction.reportActionID]: { + errors: getReceiptError(receipt, filename, undefined, undefined, CONST.IOU.ACTION_PARAMS.START_SPLIT_BILL, retryParams), + }, + }, + }, + ); + } + + const splits: Split[] = [{email: currentUserEmailForIOUSplit, accountID: currentUserAccountID}]; + + for (const participant of participants) { + // Disabling this line since participant.login can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const email = participant.isOwnPolicyExpenseChat ? '' : addSMSDomainIfPhoneNumber(participant.login || participant.text || '').toLowerCase(); + const accountID = participant.isOwnPolicyExpenseChat ? 0 : Number(participant.accountID); + if (email === currentUserEmailForIOUSplit) { + continue; + } + + // When splitting with a expense chat, we only need to supply the policyID and the workspace reportID as it's needed so we can update the report preview + if (participant.isOwnPolicyExpenseChat) { + splits.push({ + policyID: participant.policyID, + chatReportID: splitChatReport.reportID, + }); + continue; + } + + const participantPersonalDetails = getAllPersonalDetails()[participant?.accountID ?? CONST.DEFAULT_NUMBER_ID]; + if (!participantPersonalDetails) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [accountID]: { + accountID, + // Disabling this line since participant.displayName can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayName: formatPhoneNumber(participant.displayName || email), + // Disabling this line since participant.login can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + login: participant.login || participant.text, + isOptimisticPersonalDetail: true, + }, + }, + }); + // BE will send different participants. We clear the optimistic ones to avoid duplicated entries + redundantParticipants[accountID] = null; + } + + splits.push({ + email, + accountID, + }); + } + + for (const participant of participants) { + const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(participant); + if (!isPolicyExpenseChat) { + continue; + } + const optimisticPolicyRecentlyUsedCategories = mergePolicyRecentlyUsedCategories(category, policyRecentlyUsedCategories); + const optimisticPolicyRecentlyUsedTags = buildOptimisticPolicyRecentlyUsedTags({ + policyTags: getPolicyTagsData(participant.policyID), + policyRecentlyUsedTags, + transactionTags: tag, + }); + const optimisticRecentlyUsedCurrencies = mergePolicyRecentlyUsedCurrencies(currency, policyRecentlyUsedCurrencies); + + if (optimisticPolicyRecentlyUsedCategories.length > 0) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${participant.policyID}`, + value: optimisticPolicyRecentlyUsedCategories, + }); + } + + if (optimisticRecentlyUsedCurrencies.length > 0) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.RECENTLY_USED_CURRENCIES, + value: optimisticRecentlyUsedCurrencies, + }); + } + + if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${participant.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + } + + // Save the new splits array into the transaction's comment in case the user calls CompleteSplitBill while offline + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, + value: { + comment: { + splits, + }, + }, + }); + + const parameters: StartSplitBillParams = { + chatReportID: splitChatReport.reportID, + reportActionID: splitIOUReportAction.reportActionID, + transactionID: splitTransaction.transactionID, + splits: JSON.stringify(splits), + receipt, + comment: parsedComment, + category, + tag, + currency, + isFromGroupDM: !existingSplitChatReport, + billable, + reimbursable, + ...(existingSplitChatReport ? {} : {createdReportActionID: splitChatCreatedReportAction.reportActionID}), + chatType: splitChatReport?.chatType, + taxCode, + taxAmount, + description: parsedComment, + }; + if (shouldPlaySound) { + playSound(SOUNDS.DONE); + } + + API.write(WRITE_COMMANDS.START_SPLIT_BILL, parameters, {optimisticData, successData, failureData}); + + Navigation.dismissModalWithReport({reportID: splitChatReport.reportID}); + notifyNewAction(splitChatReport.reportID, currentUserAccountID); + + // Return the split transactionID for testing purpose + return {splitTransactionID: splitTransaction.transactionID}; +} + +function updateSplitTransactions({ + allTransactionsList, + allReportsList, + allReportNameValuePairsList, + transactionData, + searchContext, + policyCategories, + policy, + policyRecentlyUsedCategories, + iouReport, + firstIOU, + isASAPSubmitBetaEnabled, + currentUserPersonalDetails, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, +}: UpdateSplitTransactionsParams) { + const transactionReport = getReportOrDraftReport(transactionData?.reportID); + const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); + const expenseReport = transactionReport?.type === CONST.REPORT.TYPE.EXPENSE ? transactionReport : parentTransactionReport; + + const originalTransactionID = transactionData?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + const originalTransaction = allTransactionsList?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`]; + const originalTransactionDetails = getTransactionDetails(originalTransaction); + + const policyTags = getPolicyTagsData(expenseReport?.policyID); + const participants = getMoneyRequestParticipantsFromReport(expenseReport, currentUserPersonalDetails.accountID); + const splitExpenses = transactionData?.splitExpenses ?? []; + + // List of all child transactions that have been created after split + const originalChildTransactions = getChildTransactions(allTransactionsList, allReportsList, originalTransactionID); + const processedChildTransactionIDs: string[] = []; + + const splitExpensesTotal = transactionData?.splitExpensesTotal ?? 0; + + const isCreationOfSplits = originalChildTransactions.length === 0; + const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); + const isReverseSplitOperation = splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft; + + let changesInReportTotal = 0; + // Validate custom unit rate before proceeding with split + const customUnitRateID = originalTransaction?.comment?.customUnit?.customUnitRateID; + const isPerDiem = isPerDiemRequestTransactionUtils(originalTransaction); + + if (customUnitRateID && policy && !isPerDiem) { + const customUnitRate = getDistanceRateCustomUnitRate(policy, customUnitRateID); + + // If the rate doesn't exist or is disabled, show an error and return early + if (!customUnitRate || !customUnitRate.enabled) { + // Show error to user + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.invalidRate'), + }); + return; + } + } + + const splits: SplitTransactionSplitsParam = + splitExpenses.map((split) => { + const currentDescription = getParsedComment(Parser.htmlToMarkdown(split.description ?? '')); + changesInReportTotal += split.amount; + return { + amount: split.amount, + category: split.category ?? '', + tag: split.tags?.[0] ?? '', + created: split.created, + merchant: split?.merchant ?? '', + transactionID: split.transactionID, + comment: { + comment: currentDescription, + }, + reimbursable: split?.reimbursable, + }; + }) ?? []; + changesInReportTotal -= splitExpensesTotal; + + const successData = [] as OnyxUpdate[]; + const failureData = [] as OnyxUpdate[]; + const optimisticData = [] as OnyxUpdate[]; + + // The split transactions can be in different reports, so we need to calculate the total for each report. + const reportTotals = new Map(); + const expenseReportID = expenseReport?.reportID; + + if (expenseReportID) { + const expenseReportKey = `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`; + const expenseReportTotal = allReportsList?.[expenseReportKey]?.total ?? expenseReport?.total ?? 0; + reportTotals.set(expenseReportID, expenseReportTotal - changesInReportTotal); + } + + for (const expense of splitExpenses) { + const splitExpenseReportID = expense.reportID; + if (!splitExpenseReportID || reportTotals.has(splitExpenseReportID)) { + continue; + } + + const splitExpenseReport = allReportsList?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpenseReportID}`]; + reportTotals.set(splitExpenseReportID, splitExpenseReport?.total ?? 0); + } + + for (const [index, splitExpense] of splitExpenses.entries()) { + const existingTransactionID = isReverseSplitOperation ? originalTransactionID : splitExpense.transactionID; + const splitTransaction = allTransactionsList?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]; + if (splitTransaction) { + processedChildTransactionIDs.push(splitTransaction.transactionID); + } + + const splitReportActions = getAllReportActions(isReverseSplitOperation ? expenseReport?.reportID : splitTransaction?.reportID); + const currentReportAction = Object.values(splitReportActions).find((action) => { + const transactionID = isMoneyRequestAction(action) ? (getOriginalMessage(action)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; + return transactionID === existingTransactionID; + }); + + const requestMoneyInformation = { + participantParams: { + participant: participants.at(0) ?? ({} as Participant), + payeeEmail: currentUserPersonalDetails?.login ?? '', + payeeAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, + }, + policyParams: { + policy, + policyCategories, + policyTags, + }, + transactionParams: { + amount: Math.abs(originalTransaction?.amount ?? 0), + modifiedAmount: splitExpense.amount ?? 0, + currency: originalTransactionDetails?.currency ?? CONST.CURRENCY.USD, + created: splitExpense.created, + merchant: splitExpense.merchant ?? '', + comment: splitExpense.description, + category: splitExpense.category, + tag: splitExpense.tags?.[0], + originalTransactionID, + attendees: originalTransactionDetails?.attendees, + source: CONST.IOU.TYPE.SPLIT, + linkedTrackedExpenseReportAction: currentReportAction, + pendingAction: splitTransaction ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + pendingFields: splitTransaction ? splitTransaction.pendingFields : undefined, + reimbursable: originalTransactionDetails?.reimbursable, + taxCode: originalTransactionDetails?.taxCode, + taxAmount: calculateIOUAmount(splitExpenses.length - 1, originalTransactionDetails?.taxAmount ?? 0, originalTransactionDetails?.currency ?? CONST.CURRENCY.USD, false), + billable: originalTransactionDetails?.billable, + }, + parentChatReport: getReportOrDraftReport(getReportOrDraftReport(expenseReport?.chatReportID)?.parentReportID), + existingTransaction: originalTransaction, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam: currentUserPersonalDetails?.accountID, + currentUserEmailParam: currentUserPersonalDetails?.login ?? '', + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + } as MoneyRequestInformationParams; + + if (isReverseSplitOperation) { + requestMoneyInformation.transactionParams = { + amount: splitExpense.amount ?? 0, + currency: originalTransactionDetails?.currency ?? CONST.CURRENCY.USD, + created: splitExpense.created, + merchant: splitExpense.merchant ?? '', + comment: splitExpense.description, + category: splitExpense.category, + tag: splitExpense.tags?.[0], + attendees: originalTransactionDetails?.attendees as Attendee[], + linkedTrackedExpenseReportAction: currentReportAction, + taxCode: originalTransactionDetails?.taxCode, + taxAmount: calculateIOUAmount(splitExpenses.length - 1, originalTransactionDetails?.taxAmount ?? 0, originalTransactionDetails?.currency ?? CONST.CURRENCY.USD, false), + billable: originalTransactionDetails?.billable, + }; + requestMoneyInformation.existingTransaction = undefined; + } + + const {participantParams, policyParams, transactionParams, parentChatReport, existingTransaction} = requestMoneyInformation; + const parsedComment = getParsedComment(Parser.htmlToMarkdown(transactionParams.comment ?? '')); + transactionParams.comment = parsedComment; + + const {transactionThreadReportID, createdReportActionIDForThread, onyxData, iouAction} = getMoneyRequestInformation({ + participantParams, + parentChatReport, + policyParams, + transactionParams, + moneyRequestReportID: splitExpense?.reportID, + existingTransaction, + existingTransactionID, + newReportTotal: reportTotals.get(splitExpense?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)) ?? 0, + newNonReimbursableTotal: (transactionReport?.nonReimbursableTotal ?? 0) - changesInReportTotal, + isSplitExpense: true, + currentReportActionID: currentReportAction?.reportActionID, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam: currentUserPersonalDetails?.accountID, + currentUserEmailParam: currentUserPersonalDetails?.login ?? '', + transactionViolations, + quickAction, + shouldGenerateTransactionThreadReport: !isReverseSplitOperation, + policyRecentlyUsedCurrencies, + }); + + let updateMoneyRequestParamsOnyxData: OnyxData = {}; + const currentSplit = splits.at(index); + + // For existing split transactions, update the field change messages + // For new transactions, skip this step + if (splitTransaction) { + const existing = getTransactionDetails(splitTransaction); + const transactionChanges = { + ...currentSplit, + comment: currentSplit?.comment?.comment, + } as TransactionChanges; + + if (currentSplit) { + currentSplit.reimbursable = splitTransaction.reimbursable; + currentSplit.billable = splitTransaction.billable; + } + + for (const key of Object.keys(transactionChanges)) { + const newValue = transactionChanges[key as keyof typeof transactionChanges]; + const oldValue = existing?.[key as keyof typeof existing]; + if (newValue === oldValue) { + delete transactionChanges[key as keyof typeof transactionChanges]; + // Ensure we pass the currency to getUpdateMoneyRequestParams as well, so the amount message is created correctly + } else if (key === 'amount') { + transactionChanges.currency = originalTransactionDetails?.currency; + } + } + + if (isReverseSplitOperation) { + delete transactionChanges.transactionID; + } + + if (Object.keys(transactionChanges).length > 0) { + const transactionThreadReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${isReverseSplitOperation ? splitExpense?.reportID : transactionThreadReportID}`]; + const transactionIOUReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpense?.reportID ?? transactionThreadReport?.parentReportID}`]; + const {onyxData: moneyRequestParamsOnyxData, params} = getUpdateMoneyRequestParams({ + transactionID: existingTransactionID, + transactionThreadReport, + iouReport: transactionIOUReport, + transactionChanges, + policy, + policyTagList: policyTags ?? null, + policyCategories: policyCategories ?? null, + newTransactionReportID: splitExpense?.reportID, + policyRecentlyUsedCategories, + currentUserAccountIDParam: currentUserPersonalDetails?.accountID, + currentUserEmailParam: currentUserPersonalDetails?.login ?? '', + isASAPSubmitBetaEnabled, + }); + if (currentSplit) { + currentSplit.modifiedExpenseReportActionID = params.reportActionID; + } + updateMoneyRequestParamsOnyxData = moneyRequestParamsOnyxData; + } + // For new split transactions, set the reportID once the transaction and associated report are created + } else if (currentSplit) { + currentSplit.reportID = splitExpense?.reportID; + } + + if (currentSplit) { + currentSplit.transactionThreadReportID = transactionThreadReportID; + currentSplit.createdReportActionIDForThread = createdReportActionIDForThread; + currentSplit.splitReportActionID = iouAction.reportActionID; + } + + optimisticData.push(...(onyxData.optimisticData ?? []), ...(updateMoneyRequestParamsOnyxData.optimisticData ?? [])); + successData.push(...(onyxData.successData ?? []), ...(updateMoneyRequestParamsOnyxData.successData ?? [])); + failureData.push(...(onyxData.failureData ?? []), ...(updateMoneyRequestParamsOnyxData.failureData ?? [])); + } + + // All transactions that were deleted in the split list will be marked as deleted in onyx + const undeletedTransactions = originalChildTransactions.filter( + (currentTransaction) => !processedChildTransactionIDs.includes(currentTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID), + ); + + for (const undeletedTransaction of undeletedTransactions) { + const splitTransaction = allTransactionsList?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${undeletedTransaction?.transactionID}`]; + const splitReportActions = getAllReportActions(splitTransaction?.reportID); + const reportNameValuePairs = allReportNameValuePairsList?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${splitTransaction?.reportID}`]; + const isReportArchived = isArchivedReport(reportNameValuePairs); + const currentReportAction = Object.values(splitReportActions).find((action) => { + const transactionID = isMoneyRequestAction(action) ? (getOriginalMessage(action)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; + return transactionID === undeletedTransaction?.transactionID; + }) as ReportAction; + + const { + optimisticData: deleteExpenseOptimisticData, + failureData: deleteExpenseFailureData, + successData: deleteExpenseSuccessData, + } = getDeleteTrackExpenseInformation( + splitTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + undeletedTransaction?.transactionID, + currentReportAction, + undefined, + undefined, + undefined, + undefined, + undefined, + isReportArchived, + ); + + optimisticData.push(...(deleteExpenseOptimisticData ?? [])); + successData.push(...(deleteExpenseSuccessData ?? [])); + failureData.push(...(deleteExpenseFailureData ?? [])); + } + + if (!isReverseSplitOperation) { + // Use SET to update originalTransaction more quickly in Onyx as compared to MERGE to prevent UI inconsistency + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, + value: { + ...originalTransaction, + reportID: CONST.REPORT.SPLIT_REPORT_ID, + }, + }); + + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, + value: originalTransaction, + }); + + if (firstIOU) { + const updatedReportAction = { + [firstIOU.reportActionID]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + previousMessage: firstIOU.message, + message: [ + { + type: 'COMMENT', + html: '', + text: '', + isEdited: true, + isDeletedParentAction: true, + }, + ], + originalMessage: { + IOUTransactionID: null, + }, + errors: null, + }, + }; + const transactionThread = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${firstIOU.childReportID}`] ?? null; + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${firstIOU?.childReportID}`, + value: null, + }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: updatedReportAction, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + value: { + [firstIOU.reportActionID]: { + ...firstIOU, + pendingAction: null, + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${firstIOU?.childReportID}`, + value: transactionThread, + }); + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${searchContext?.currentSearchHash}`, + value: { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`]: null, + }, + }, + }); + + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${searchContext?.currentSearchHash}`, + value: { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`]: originalTransaction, + }, + }, + }); + } else { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, + value: { + errors: null, + }, + }); + } + + if (isReverseSplitOperation) { + const parameters = { + ...splits.at(0), + comment: splits.at(0)?.comment?.comment, + } as RevertSplitTransactionParams; + API.write(WRITE_COMMANDS.REVERT_SPLIT_TRANSACTION, parameters, {optimisticData, successData, failureData}); + } else { + // Prepare splitApiParams for the Transaction_Split API call which requires a specific format for the splits + // The format is: splits[0][amount], splits[0][category], splits[0][tag] etc. + const splitApiParams = {} as Record; + for (const [i, split] of splits.entries()) { + for (const [key, value] of Object.entries(split)) { + splitApiParams[`splits[${i}][${key}]`] = value !== null && typeof value === 'object' ? JSON.stringify(value) : value; + } + } + if (isCreationOfSplits) { + const isTransactionOnHold = isOnHold(originalTransaction); + + if (isTransactionOnHold) { + const holdReportActionIDs: string[] = []; + const holdReportActionCommentIDs: string[] = []; + const transactionReportActions = getAllReportActions(firstIOU?.childReportID); + const holdReportAction = getReportAction(firstIOU?.childReportID, `${originalTransaction?.comment?.hold ?? ''}`); + + const holdReportActionComment = holdReportAction + ? Object.values(transactionReportActions ?? {}).find( + (action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && action?.timestamp === holdReportAction.timestamp, + ) + : undefined; + + if (holdReportAction && holdReportActionComment) { + // Loop through all split expenses and add optimistic hold report actions for each split + for (const [index, splitExpense] of splits.entries()) { + const splitReportID = splitExpense?.transactionThreadReportID; + if (!splitReportID) { + continue; + } + + // Generate new IDs and timestamps for each split + const newHoldReportActionID = NumberUtils.rand64(); + const newHoldReportActionCommentID = NumberUtils.rand64(); + const timestamp = DateUtils.getDBTime(); + const reportActionTimestamp = DateUtils.addMillisecondsFromDateTime(timestamp, 1); + + // Store IDs for API parameters + holdReportActionIDs[index] = newHoldReportActionID; + holdReportActionCommentIDs[index] = newHoldReportActionCommentID; + + // Create new optimistic hold report action with new ID and timestamp, keeping other information + const newHoldReportAction = { + ...holdReportAction, + reportActionID: newHoldReportActionID, + created: timestamp, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + + // Create new optimistic hold report action comment with new ID and timestamp, keeping other information + const newHoldReportActionComment = { + ...holdReportActionComment, + reportActionID: newHoldReportActionCommentID, + created: reportActionTimestamp, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + + // Add to optimisticData for this split's reportActions + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitReportID}`, + value: { + [newHoldReportActionID]: newHoldReportAction, + [newHoldReportActionCommentID]: newHoldReportActionComment, + }, + }); + + // Add successData to clear pendingAction after API call succeeds + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitReportID}`, + value: { + [newHoldReportActionID]: {pendingAction: null}, + [newHoldReportActionCommentID]: {pendingAction: null}, + }, + }); + + // Add failureData to remove optimistic hold report actions if the request fails + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitReportID}`, + value: { + [newHoldReportActionID]: null, + [newHoldReportActionCommentID]: null, + }, + }); + } + + // Add hold report action IDs to API parameters + for (const [i, holdReportActionID] of holdReportActionIDs.entries()) { + if (holdReportActionID) { + splitApiParams[`splits[${i}][holdReportActionID]`] = holdReportActionID; + } + } + for (const [i, holdReportActionCommentID] of holdReportActionCommentIDs.entries()) { + if (holdReportActionCommentID) { + splitApiParams[`splits[${i}][holdReportActionCommentID]`] = holdReportActionCommentID; + } + } + } + } + } + + const splitParameters: SplitTransactionParams = { + ...splitApiParams, + transactionID: originalTransactionID, + }; + + if (isCreationOfSplits) { + // eslint-disable-next-line rulesdir/no-multiple-api-calls + API.write(WRITE_COMMANDS.SPLIT_TRANSACTION, splitParameters, {optimisticData, successData, failureData}); + } else { + // eslint-disable-next-line rulesdir/no-multiple-api-calls + API.write(WRITE_COMMANDS.UPDATE_SPLIT_TRANSACTION, splitParameters, {optimisticData, successData, failureData}); + } + } + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => removeDraftSplitTransaction(originalTransactionID)); +} + +function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransactionsParams) { + updateSplitTransactions(params); + const transactionReport = getReportOrDraftReport(params.transactionData?.reportID); + const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); + const expenseReport = transactionReport?.type === CONST.REPORT.TYPE.EXPENSE ? transactionReport : parentTransactionReport; + const isSearchPageTopmostFullScreenRoute = isSearchTopmostFullScreenRoute(); + const transactionThreadReportID = params.firstIOU?.childReportID; + const transactionThreadReportScreen = Navigation.getReportRouteByID(transactionThreadReportID); + + // Reset selected transactions in search after saving split expenses + const searchFullScreenRoutes = navigationRef.getRootState()?.routes.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + const lastRoute = searchFullScreenRoutes?.state?.routes?.at(-1); + const isUserOnSearchPage = isSearchTopmostFullScreenRoute() && lastRoute?.name === SCREENS.SEARCH.ROOT; + if (isUserOnSearchPage) { + params?.searchContext?.clearSelectedTransactions?.(undefined, true); + } else { + params?.searchContext?.clearSelectedTransactions?.(true); + } + + if (isSearchPageTopmostFullScreenRoute || !transactionReport?.parentReportID) { + Navigation.dismissModal(); + + // After the modal is dismissed, remove the transaction thread report screen + // to avoid navigating back to a report removed by the split transaction. + requestAnimationFrame(() => { + if (!transactionThreadReportScreen?.key) { + return; + } + + Navigation.removeScreenByKey(transactionThreadReportScreen.key); + }); + + return; + } + Navigation.dismissModalWithReport({reportID: expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)}); + + // After the modal is dismissed, remove the transaction thread report screen + // to avoid navigating back to a report removed by the split transaction. + requestAnimationFrame(() => { + if (!transactionThreadReportScreen?.key) { + return; + } + + Navigation.removeScreenByKey(transactionThreadReportScreen.key); + }); +} + +export {completeSplitBill, splitBill, splitBillAndOpenReport, startSplitBill, updateSplitTransactions, updateSplitTransactionsFromSplitExpensesFlow}; + +export type {SplitBillActionsParams, UpdateSplitTransactionsParams}; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 2890f59799a14..b8eb56334b2e8 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import {eachDayOfInterval, format} from 'date-fns'; +import {format} from 'date-fns'; import {fastMerge} from 'expensify-common'; import cloneDeep from 'lodash/cloneDeep'; // eslint-disable-next-line you-dont-need-lodash-underscore/union-by @@ -10,14 +10,13 @@ import Onyx from 'react-native-onyx'; import type {SetRequired, ValueOf} from 'type-fest'; import ReceiptGeneric from '@assets/images/receipt-generic.png'; import type {PaymentMethod} from '@components/KYCWall/types'; -import type {SearchContextProps, SearchQueryJSON} from '@components/Search/types'; +import type {SearchQueryJSON} from '@components/Search/types'; import * as API from '@libs/API'; import type { AddReportApproverParams, ApproveMoneyRequestParams, AssignReportToMeParams, CategorizeTrackedExpenseParams as CategorizeTrackedExpenseApiParams, - CompleteSplitBillParams, CreateDistanceRequestParams, CreatePerDiemRequestParams, CreateWorkspaceParams, @@ -31,13 +30,8 @@ import type { ReplaceReceiptParams, RequestMoneyParams, RetractReportParams, - RevertSplitTransactionParams, SetNameValuePairParams, ShareTrackedExpenseParams, - SplitBillParams, - SplitTransactionParams, - SplitTransactionSplitsParam, - StartSplitBillParams, SubmitReportParams, TrackExpenseParams, UnapproveExpenseReportParams, @@ -52,7 +46,6 @@ import {readFileAsync} from '@libs/fileDownload/FileUtils'; import type {MinimalTransaction} from '@libs/Formula'; import GoogleTagManager from '@libs/GoogleTagManager'; import { - calculateAmount as calculateIOUAmount, formatCurrentUserToAttendee, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep, @@ -79,7 +72,6 @@ import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import { arePaymentsEnabled, getDistanceRateCustomUnit, - getDistanceRateCustomUnitRate, getMemberAccountIDsForWorkspace, getPerDiemCustomUnit, getPerDiemRateCustomUnitRate, @@ -233,7 +225,7 @@ import type {GuidedSetupData} from '@userActions/Report'; import {buildInviteToRoomOnyxData, completeOnboarding, getCurrentUserAccountID, notifyNewAction, optimisticReportLastData} from '@userActions/Report'; import {clearAllRelatedReportActionErrors} from '@userActions/ReportActions'; import {sanitizeRecentWaypoints} from '@userActions/Transaction'; -import {removeDraftSplitTransaction, removeDraftTransaction, removeDraftTransactions} from '@userActions/TransactionEdit'; +import {removeDraftTransaction, removeDraftTransactions} from '@userActions/TransactionEdit'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; import type {OnboardingCompanySize} from '@userActions/Welcome/OnboardingFlow'; import type {IOUAction, IOUActionParams, IOUType} from '@src/CONST'; @@ -763,29 +755,6 @@ type StartSplitBilActionParams = { policyRecentlyUsedCurrencies: string[]; }; -type UpdateSplitTransactionsParams = { - allTransactionsList: OnyxCollection; - allReportsList: OnyxCollection; - allReportNameValuePairsList: OnyxCollection; - transactionData: { - reportID: string; - originalTransactionID: string; - splitExpenses: SplitExpense[]; - splitExpensesTotal?: number; - }; - searchContext?: Partial; - policyCategories: OnyxTypes.PolicyCategories | undefined; - policy: OnyxTypes.Policy | undefined; - policyRecentlyUsedCategories: OnyxTypes.RecentlyUsedCategories | undefined; - iouReport: OnyxEntry; - firstIOU: OnyxEntry | undefined; - isASAPSubmitBetaEnabled: boolean; - currentUserPersonalDetails: CurrentUserPersonalDetails; - transactionViolations: OnyxCollection; - quickAction: OnyxEntry; - policyRecentlyUsedCurrencies: string[]; -}; - type ReplaceReceipt = { transactionID: string; file?: File; @@ -7243,997 +7212,125 @@ function createSplitsAndOnyxData({ }; } -type SplitBillActionsParams = { - participants: Participant[]; - currentUserLogin: string; - currentUserAccountID: number; - amount: number; - comment: string; - currency: string; - merchant: string; - created: string; - category?: string; - tag?: string; - billable?: boolean; - reimbursable?: boolean; - iouRequestType?: IOURequestType; - existingSplitChatReportID?: string; - splitShares?: SplitShares; - taxCode?: string; - taxAmount?: number; - isRetry?: boolean; - policyRecentlyUsedCategories?: OnyxEntry; - policyRecentlyUsedTags: OnyxEntry; - isASAPSubmitBetaEnabled: boolean; - transactionViolations: OnyxCollection; - quickAction: OnyxEntry; - policyRecentlyUsedCurrencies: string[]; -}; - -/** - * @param amount - always in smallest currency unit - * @param existingSplitChatReportID - Either a group DM or a expense chat - */ -function splitBill({ - participants, - currentUserLogin, - currentUserAccountID, - amount, - comment, - currency, - merchant, - created, - category = '', - tag = '', - billable = false, - reimbursable = false, - iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, - existingSplitChatReportID, - splitShares = {}, - taxCode = '', - taxAmount = 0, - policyRecentlyUsedCategories, - isASAPSubmitBetaEnabled, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - policyRecentlyUsedTags, -}: SplitBillActionsParams) { - const parsedComment = getParsedComment(comment); - const {splitData, splits, onyxData} = createSplitsAndOnyxData({ - participants, - currentUserLogin, - currentUserAccountID, - existingSplitChatReportID, - transactionParams: { - amount, - comment: parsedComment, - currency, - merchant, - created, - category, - tag, - splitShares, - billable, - reimbursable, - iouRequestType, - taxCode, - taxAmount, - }, - policyRecentlyUsedCategories, - policyRecentlyUsedTags, - isASAPSubmitBetaEnabled, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - }); - - const parameters: SplitBillParams = { - reportID: splitData.chatReportID, - amount, - splits: JSON.stringify(splits), - currency, - comment: parsedComment, - category, - merchant, - created, - tag, - billable, - reimbursable, - transactionID: splitData.transactionID, - reportActionID: splitData.reportActionID, - createdReportActionID: splitData.createdReportActionID, - policyID: splitData.policyID, - chatType: splitData.chatType, - taxCode, - taxAmount, - description: parsedComment, - }; +function setDraftSplitTransaction( + transactionID: string | undefined, + splitTransactionDraft: OnyxEntry, + transactionChanges: TransactionChanges = {}, + policy?: OnyxEntry, +) { + if (!transactionID) { + return undefined; + } + let draftSplitTransaction = splitTransactionDraft; - playSound(SOUNDS.DONE); - API.write(WRITE_COMMANDS.SPLIT_BILL, parameters, onyxData); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); + if (!draftSplitTransaction) { + draftSplitTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + } - dismissModalAndOpenReportInInboxTab(existingSplitChatReportID); + const updatedTransaction = draftSplitTransaction + ? getUpdatedTransaction({ + transaction: draftSplitTransaction, + transactionChanges, + isFromExpenseReport: false, + shouldUpdateReceiptState: false, + policy, + }) + : null; - notifyNewAction(splitData.chatReportID, currentUserAccountID); + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, updatedTransaction); } -/** - * @param amount - always in the smallest currency unit - */ -function splitBillAndOpenReport({ - participants, - currentUserLogin, - currentUserAccountID, - amount, - comment, - currency, - merchant, - created, - category = '', - tag = '', - billable = false, - reimbursable = false, - iouRequestType = CONST.IOU.REQUEST_TYPE.MANUAL, - splitShares = {}, - taxCode = '', - taxAmount = 0, - existingSplitChatReportID, - policyRecentlyUsedCategories, - policyRecentlyUsedTags, - isASAPSubmitBetaEnabled, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, -}: SplitBillActionsParams) { - const parsedComment = getParsedComment(comment); - const {splitData, splits, onyxData} = createSplitsAndOnyxData({ +/** Requests money based on a distance (e.g. mileage from a map) */ +function createDistanceRequest(distanceRequestInformation: CreateDistanceRequestInformation) { + const { + report, participants, - currentUserLogin, - currentUserAccountID, - existingSplitChatReportID, + currentUserLogin = '', + currentUserAccountID = -1, + iouType = CONST.IOU.TYPE.SUBMIT, + existingTransaction, + transactionParams, + policyParams = {}, + backToReport, isASAPSubmitBetaEnabled, - transactionParams: { - amount, - comment: parsedComment, - currency, - merchant, - created, - category, - tag, - splitShares, - billable, - reimbursable, - iouRequestType, - taxCode, - taxAmount, - }, - policyRecentlyUsedCategories, - policyRecentlyUsedTags, transactionViolations, quickAction, policyRecentlyUsedCurrencies, - }); - - const parameters: SplitBillParams = { - reportID: splitData.chatReportID, + } = distanceRequestInformation; + const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; + const parsedComment = getParsedComment(transactionParams.comment); + transactionParams.comment = parsedComment; + const { amount, - splits: JSON.stringify(splits), + comment, + distance, currency, - merchant, created, - comment: parsedComment, category, tag, + taxAmount, + taxCode, + merchant, billable, reimbursable, - transactionID: splitData.transactionID, - reportActionID: splitData.reportActionID, - createdReportActionID: splitData.createdReportActionID, - policyID: splitData.policyID, - chatType: splitData.chatType, - taxCode, - taxAmount, - description: parsedComment, - }; + validWaypoints, + customUnitRateID = '', + splitShares = {}, + attendees, + receipt, + odometerStart, + odometerEnd, + } = transactionParams; - playSound(SOUNDS.DONE); - API.write(WRITE_COMMANDS.SPLIT_BILL_AND_OPEN_REPORT, parameters, onyxData); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); + // If the report is an 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 isManualDistanceRequest = isEmptyObject(validWaypoints); - dismissModalAndOpenReportInInboxTab(splitData.chatReportID); - notifyNewAction(splitData.chatReportID, currentUserAccountID); -} + const optimisticReceipt: Receipt | undefined = !isManualDistanceRequest + ? { + source: ReceiptGeneric as ReceiptSource, + state: CONST.IOU.RECEIPT_STATE.OPEN, + } + : receipt; -/** Used exclusively for starting a split expense request that contains a receipt, the split request will be completed once the receipt is scanned - * or user enters details manually. - * - * @param existingSplitChatReportID - Either a group DM or a expense chat - */ -function startSplitBill({ - participants, - currentUserLogin, - currentUserAccountID, - comment, - receipt, - existingSplitChatReportID, - billable = false, - reimbursable = false, - category = '', - tag = '', - currency, - taxCode = '', - taxAmount = 0, - shouldPlaySound = true, - policyRecentlyUsedCategories, - policyRecentlyUsedTags, - quickAction, - policyRecentlyUsedCurrencies, -}: StartSplitBilActionParams) { - const currentUserEmailForIOUSplit = addSMSDomainIfPhoneNumber(currentUserLogin); - const participantAccountIDs = participants.map((participant) => Number(participant.accountID)); - const {splitChatReport, existingSplitChatReport} = getOrCreateOptimisticSplitChatReport(existingSplitChatReportID, participants, participantAccountIDs, currentUserAccountID); - const isOwnPolicyExpenseChat = !!splitChatReport.isOwnPolicyExpenseChat; - const parsedComment = getParsedComment(comment); - - // ReportID is -2 (aka "deleted") on the group transaction - const splitTransaction = buildOptimisticTransaction({ - transactionParams: { - amount: 0, - currency, - reportID: CONST.REPORT.SPLIT_REPORT_ID, - comment: parsedComment, - merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - receipt, - category, - tag, - taxCode, - taxAmount, - billable, - reimbursable, - }, - }); - - const filename = splitTransaction.receipt?.filename; - - // Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat - const splitChatCreatedReportAction = buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); - const splitIOUReportAction = buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.SPLIT, - amount: 0, - currency: CONST.CURRENCY.USD, - comment: parsedComment, - participants, - transactionID: splitTransaction.transactionID, - isOwnPolicyExpenseChat, - }); - - splitChatReport.lastReadTime = DateUtils.getDBTime(); - splitChatReport.lastMessageText = getReportActionText(splitIOUReportAction); - splitChatReport.lastMessageHtml = getReportActionHtml(splitIOUReportAction); - - // If we have an existing splitChatReport (group chat or workspace) use it's pending fields, otherwise indicate that we are adding a chat - if (!existingSplitChatReport) { - splitChatReport.pendingFields = { - createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }; - } - - const optimisticData: OnyxUpdate[] = [ - { - // Use set for new reports because it doesn't exist yet, is faster, - // and we need the data to be available when we navigate to the chat page - onyxMethod: existingSplitChatReport ? Onyx.METHOD.MERGE : Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, - value: splitChatReport, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, - value: { - action: CONST.QUICK_ACTIONS.SPLIT_SCAN, - chatReportID: splitChatReport.reportID, - isFirstQuickAction: isEmptyObject(quickAction), - }, - }, - existingSplitChatReport - ? { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, - value: { - [splitIOUReportAction.reportActionID]: splitIOUReportAction as OnyxTypes.ReportAction, - }, - } - : { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, - value: { - [splitChatCreatedReportAction.reportActionID]: splitChatCreatedReportAction, - [splitIOUReportAction.reportActionID]: splitIOUReportAction as OnyxTypes.ReportAction, - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, - value: splitTransaction, - }, - ]; - - if (!existingSplitChatReport) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${splitChatReport.reportID}`, - value: { - isOptimisticReport: true, - }, - }); - } - - const successData: Array< - OnyxUpdate - > = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, - value: { - ...(existingSplitChatReport ? {} : {[splitChatCreatedReportAction.reportActionID]: {pendingAction: null}}), - [splitIOUReportAction.reportActionID]: {pendingAction: null}, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, - value: {pendingAction: null}, - }, - ]; - - if (!existingSplitChatReport) { - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${splitChatReport.reportID}`, - value: { - isOptimisticReport: false, - }, - }); - } - - const redundantParticipants: Record = {}; - if (!existingSplitChatReport) { - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, - value: {pendingFields: {createChat: null}, participants: redundantParticipants}, - }); - } - - const failureData: Array< - OnyxUpdate - > = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, - value: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), - }, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, - value: quickAction ?? null, - }, - ]; - - const retryParams = { - participants: participants.map(({icons, ...rest}) => rest), - currentUserLogin, - currentUserAccountID, - comment, - receipt, - existingSplitChatReportID, - billable, - reimbursable, - category, - tag, - currency, - taxCode, - taxAmount, - quickAction, - policyRecentlyUsedCurrencies, - policyRecentlyUsedTags, - }; - - if (existingSplitChatReport) { - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, - value: { - [splitIOUReportAction.reportActionID]: { - errors: getReceiptError(receipt, filename, undefined, undefined, CONST.IOU.ACTION_PARAMS.START_SPLIT_BILL, retryParams), - }, - }, - }); - } else { - failureData.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${splitChatReport.reportID}`, - value: { - errorFields: { - createChat: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, - value: { - [splitChatCreatedReportAction.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('report.genericCreateReportFailureMessage'), - }, - [splitIOUReportAction.reportActionID]: { - errors: getReceiptError(receipt, filename, undefined, undefined, CONST.IOU.ACTION_PARAMS.START_SPLIT_BILL, retryParams), - }, - }, - }, - ); - } - - const splits: Split[] = [{email: currentUserEmailForIOUSplit, accountID: currentUserAccountID}]; - - for (const participant of participants) { - // Disabling this line since participant.login can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const email = participant.isOwnPolicyExpenseChat ? '' : addSMSDomainIfPhoneNumber(participant.login || participant.text || '').toLowerCase(); - const accountID = participant.isOwnPolicyExpenseChat ? 0 : Number(participant.accountID); - if (email === currentUserEmailForIOUSplit) { - continue; - } - - // When splitting with a expense chat, we only need to supply the policyID and the workspace reportID as it's needed so we can update the report preview - if (participant.isOwnPolicyExpenseChat) { - splits.push({ - policyID: participant.policyID, - chatReportID: splitChatReport.reportID, - }); - continue; - } - - const participantPersonalDetails = allPersonalDetails[participant?.accountID ?? CONST.DEFAULT_NUMBER_ID]; - if (!participantPersonalDetails) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - value: { - [accountID]: { - accountID, - // Disabling this line since participant.displayName can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - displayName: formatPhoneNumber(participant.displayName || email), - // Disabling this line since participant.login can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - login: participant.login || participant.text, - isOptimisticPersonalDetail: true, - }, - }, - }); - // BE will send different participants. We clear the optimistic ones to avoid duplicated entries - redundantParticipants[accountID] = null; - } - - splits.push({ - email, - accountID, - }); - } - - for (const participant of participants) { - const isPolicyExpenseChat = isPolicyExpenseChatReportUtil(participant); - if (!isPolicyExpenseChat) { - continue; - } - const optimisticPolicyRecentlyUsedCategories = mergePolicyRecentlyUsedCategories(category, policyRecentlyUsedCategories); - const optimisticPolicyRecentlyUsedTags = buildOptimisticPolicyRecentlyUsedTags({ - policyTags: getPolicyTagsData(participant.policyID), - policyRecentlyUsedTags, - transactionTags: tag, - }); - const optimisticRecentlyUsedCurrencies = mergePolicyRecentlyUsedCurrencies(currency, policyRecentlyUsedCurrencies); - - if (optimisticPolicyRecentlyUsedCategories.length > 0) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${participant.policyID}`, - value: optimisticPolicyRecentlyUsedCategories, - }); - } - - if (optimisticRecentlyUsedCurrencies.length > 0) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.RECENTLY_USED_CURRENCIES, - value: optimisticRecentlyUsedCurrencies, - }); - } - - if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${participant.policyID}`, - value: optimisticPolicyRecentlyUsedTags, - }); - } - } - - // Save the new splits array into the transaction's comment in case the user calls CompleteSplitBill while offline - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransaction.transactionID}`, - value: { - comment: { - splits, - }, - }, - }); - - const parameters: StartSplitBillParams = { - chatReportID: splitChatReport.reportID, - reportActionID: splitIOUReportAction.reportActionID, - transactionID: splitTransaction.transactionID, - splits: JSON.stringify(splits), - receipt, - comment: parsedComment, - category, - tag, - currency, - isFromGroupDM: !existingSplitChatReport, - billable, - reimbursable, - ...(existingSplitChatReport ? {} : {createdReportActionID: splitChatCreatedReportAction.reportActionID}), - chatType: splitChatReport?.chatType, - taxCode, - taxAmount, - description: parsedComment, - }; - if (shouldPlaySound) { - playSound(SOUNDS.DONE); - } - - API.write(WRITE_COMMANDS.START_SPLIT_BILL, parameters, {optimisticData, successData, failureData}); - - Navigation.dismissModalWithReport({reportID: splitChatReport.reportID}); - notifyNewAction(splitChatReport.reportID, currentUserAccountID); - - // Return the split transactionID for testing purpose - return {splitTransactionID: splitTransaction.transactionID}; -} - -/** Used for editing a split expense while it's still scanning or when SmartScan fails, it completes a split expense started by startSplitBill above. - * - * @param chatReportID - The group chat or workspace reportID - * @param reportAction - The split action that lives in the chatReport above - * @param updatedTransaction - The updated **draft** split transaction - * @param sessionAccountID - accountID of the current user - * @param sessionEmail - email of the current user - */ -function completeSplitBill( - chatReportID: string, - reportAction: OnyxEntry, - updatedTransaction: OnyxEntry, - sessionAccountID: number, - isASAPSubmitBetaEnabled: boolean, - quickAction: OnyxEntry, - transactionViolations: OnyxCollection, - sessionEmail?: string, -) { - if (!reportAction) { - return; - } - - const parsedComment = getParsedComment(Parser.htmlToMarkdown(updatedTransaction?.comment?.comment ?? '')); - if (updatedTransaction?.comment) { - // eslint-disable-next-line no-param-reassign - updatedTransaction.comment.comment = parsedComment; - } - const currentUserEmailForIOUSplit = addSMSDomainIfPhoneNumber(sessionEmail); - const transactionID = updatedTransaction?.transactionID; - const unmodifiedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - - // Save optimistic updated transaction and action - const optimisticData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - ...updatedTransaction, - receipt: { - state: CONST.IOU.RECEIPT_STATE.OPEN, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, - value: { - [reportAction.reportActionID]: { - lastModified: DateUtils.getDBTime(), - originalMessage: { - whisperedTo: [], - }, - }, - }, - }, - ]; - - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: {pendingAction: null}, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - value: {pendingAction: null}, - }, - ]; - - const failureData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - ...unmodifiedTransaction, - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, - value: { - [reportAction.reportActionID]: { - ...reportAction, - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), - }, - }, - }, - ]; - - const splitParticipants: Split[] = updatedTransaction?.comment?.splits ?? []; - const amount = updatedTransaction?.modifiedAmount; - const currency = updatedTransaction?.modifiedCurrency; - - // Exclude the current user when calculating the split amount, `calculateAmount` takes it into account - const splitAmount = calculateIOUAmount(splitParticipants.length - 1, amount ?? 0, currency ?? '', false); - const splitTaxAmount = calculateIOUAmount(splitParticipants.length - 1, updatedTransaction?.taxAmount ?? 0, currency ?? '', false); - - const splits: Split[] = [{email: currentUserEmailForIOUSplit}]; - for (const participant of splitParticipants) { - // Skip creating the transaction for the current user - if (participant.email === currentUserEmailForIOUSplit) { - continue; - } - const isPolicyExpenseChat = !!participant.policyID; - - if (!isPolicyExpenseChat) { - // In case this is still the optimistic accountID saved in the splits array, return early as we cannot know - // if there is an existing chat between the split creator and this participant - // Instead, we will rely on Auth generating the report IDs and the user won't see any optimistic chats or reports created - const participantPersonalDetails: OnyxTypes.PersonalDetails | null = allPersonalDetails[participant?.accountID ?? CONST.DEFAULT_NUMBER_ID]; - if (!participantPersonalDetails || participantPersonalDetails.isOptimisticPersonalDetail) { - splits.push({ - email: participant.email, - }); - continue; - } - } - - let oneOnOneChatReport: OnyxEntry; - let isNewOneOnOneChatReport = false; - if (isPolicyExpenseChat) { - // The expense chat reportID is saved in the splits array when starting a split expense with a workspace - oneOnOneChatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]; - } else { - const existingChatReport = getChatByParticipants(participant.accountID ? [participant.accountID, sessionAccountID] : []); - isNewOneOnOneChatReport = !existingChatReport; - oneOnOneChatReport = - existingChatReport ?? - buildOptimisticChatReport({ - participantList: participant.accountID ? [participant.accountID, sessionAccountID] : [], - }); - } - - let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport?.iouReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] : null; - const shouldCreateNewOneOnOneIOUReport = shouldCreateNewMoneyRequestReportReportUtils(oneOnOneIOUReport, oneOnOneChatReport, false); - - // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation - const optimisticTransactionID = NumberUtils.rand64(); - const optimisticExpenseReportID = generateReportID(); - - if (!oneOnOneIOUReport || shouldCreateNewOneOnOneIOUReport) { - const reportTransactions = buildMinimalTransactionForFormula( - optimisticTransactionID, - optimisticExpenseReportID, - updatedTransaction?.modifiedCreated, - splitAmount, - currency ?? '', - updatedTransaction?.modifiedMerchant, - ); - - oneOnOneIOUReport = isPolicyExpenseChat - ? buildOptimisticExpenseReport( - oneOnOneChatReport?.reportID, - participant.policyID, - sessionAccountID, - splitAmount, - currency ?? '', - undefined, - undefined, - optimisticExpenseReportID, - reportTransactions, - ) - : buildOptimisticIOUReport(sessionAccountID, participant.accountID ?? CONST.DEFAULT_NUMBER_ID, splitAmount, oneOnOneChatReport?.reportID, currency ?? ''); - } else if (isPolicyExpenseChat) { - if (typeof oneOnOneIOUReport?.total === 'number') { - // Because of the Expense reports are stored as negative values, we subtract the total from the amount - oneOnOneIOUReport.total -= splitAmount; - } - } else { - oneOnOneIOUReport = updateIOUOwnerAndTotal(oneOnOneIOUReport, sessionAccountID, splitAmount, currency ?? ''); - } - - const oneOnOneTransaction = buildOptimisticTransaction({ - existingTransactionID: optimisticTransactionID, - originalTransactionID: transactionID, - transactionParams: { - amount: isPolicyExpenseChat ? -splitAmount : splitAmount, - currency: currency ?? '', - reportID: oneOnOneIOUReport?.reportID, - comment: parsedComment, - created: updatedTransaction?.modifiedCreated, - merchant: updatedTransaction?.modifiedMerchant, - receipt: {...updatedTransaction?.receipt, state: CONST.IOU.RECEIPT_STATE.OPEN}, - category: updatedTransaction?.category, - tag: updatedTransaction?.tag, - taxCode: updatedTransaction?.taxCode, - taxAmount: isPolicyExpenseChat ? -splitTaxAmount : splitAmount, - billable: updatedTransaction?.billable, - reimbursable: updatedTransaction?.reimbursable, - source: CONST.IOU.TYPE.SPLIT, - filename: updatedTransaction?.receipt?.filename, - }, - }); - oneOnOneIOUReport.transactionCount = (oneOnOneIOUReport.transactionCount ?? 0) + 1; - - const [oneOnOneCreatedActionForChat, oneOnOneCreatedActionForIOU, oneOnOneIOUAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = - buildOptimisticMoneyRequestEntities({ - iouReport: oneOnOneIOUReport, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: splitAmount, - currency: currency ?? '', - comment: parsedComment, - payeeEmail: currentUserEmailForIOUSplit, - participants: [participant], - transactionID: oneOnOneTransaction.transactionID, - }); - - let oneOnOneReportPreviewAction = getReportPreviewAction(oneOnOneChatReport?.reportID, oneOnOneIOUReport?.reportID); - if (oneOnOneReportPreviewAction) { - oneOnOneReportPreviewAction = updateReportPreview(oneOnOneIOUReport, oneOnOneReportPreviewAction); - } else { - oneOnOneReportPreviewAction = buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction); - } - const hasViolations = hasViolationsReportUtils(oneOnOneIOUReport.reportID, transactionViolations, sessionAccountID, sessionEmail ?? ''); - - const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest({ - isNewChatReport: isNewOneOnOneChatReport, - isOneOnOneSplit: true, - shouldCreateNewMoneyRequestReport: shouldCreateNewOneOnOneIOUReport, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam: sessionAccountID, - currentUserEmailParam: sessionEmail ?? '', - hasViolations, - optimisticParams: { - chat: { - report: oneOnOneChatReport, - createdAction: oneOnOneCreatedActionForChat, - reportPreviewAction: oneOnOneReportPreviewAction, - }, - iou: { - report: oneOnOneIOUReport, - createdAction: oneOnOneCreatedActionForIOU, - action: oneOnOneIOUAction, - }, - transactionParams: { - transaction: oneOnOneTransaction, - transactionThreadReport: optimisticTransactionThread, - transactionThreadCreatedReportAction: optimisticCreatedActionForTransactionThread, - }, - policyRecentlyUsed: {}, - }, - quickAction, - }); - - splits.push({ - email: participant.email, - accountID: participant.accountID, - policyID: participant.policyID, - iouReportID: oneOnOneIOUReport?.reportID, - chatReportID: oneOnOneChatReport?.reportID, - transactionID: oneOnOneTransaction.transactionID, - reportActionID: oneOnOneIOUAction.reportActionID, - createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID, - createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID, - reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID, - transactionThreadReportID: optimisticTransactionThread.reportID, - createdReportActionIDForThread: optimisticCreatedActionForTransactionThread?.reportActionID, - }); - - optimisticData.push(...oneOnOneOptimisticData); - successData.push(...oneOnOneSuccessData); - failureData.push(...oneOnOneFailureData); - } - - const { - amount: transactionAmount, - currency: transactionCurrency, - created: transactionCreated, - merchant: transactionMerchant, - comment: transactionComment, - category: transactionCategory, - tag: transactionTag, - taxCode: transactionTaxCode, - taxAmount: transactionTaxAmount, - billable: transactionBillable, - reimbursable: transactionReimbursable, - } = getTransactionDetails(updatedTransaction) ?? {}; - - const parameters: CompleteSplitBillParams = { - transactionID, - amount: transactionAmount, - currency: transactionCurrency, - created: transactionCreated, - merchant: transactionMerchant, - comment: transactionComment, - category: transactionCategory, - tag: transactionTag, - splits: JSON.stringify(splits), - taxCode: transactionTaxCode, - taxAmount: transactionTaxAmount, - billable: transactionBillable, - reimbursable: transactionReimbursable, - description: parsedComment, - }; - - playSound(SOUNDS.DONE); - API.write(WRITE_COMMANDS.COMPLETE_SPLIT_BILL, parameters, {optimisticData, successData, failureData}); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - dismissModalAndOpenReportInInboxTab(chatReportID); - notifyNewAction(chatReportID, sessionAccountID); -} - -function setDraftSplitTransaction( - transactionID: string | undefined, - splitTransactionDraft: OnyxEntry, - transactionChanges: TransactionChanges = {}, - policy?: OnyxEntry, -) { - if (!transactionID) { - return undefined; - } - let draftSplitTransaction = splitTransactionDraft; - - if (!draftSplitTransaction) { - draftSplitTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - } - - const updatedTransaction = draftSplitTransaction - ? getUpdatedTransaction({ - transaction: draftSplitTransaction, - transactionChanges, - isFromExpenseReport: false, - shouldUpdateReceiptState: false, - policy, - }) - : null; - - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, updatedTransaction); -} - -/** Requests money based on a distance (e.g. mileage from a map) */ -function createDistanceRequest(distanceRequestInformation: CreateDistanceRequestInformation) { - const { - report, - participants, - currentUserLogin = '', - currentUserAccountID = -1, - iouType = CONST.IOU.TYPE.SUBMIT, - existingTransaction, - transactionParams, - policyParams = {}, - backToReport, - isASAPSubmitBetaEnabled, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - } = distanceRequestInformation; - const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; - const parsedComment = getParsedComment(transactionParams.comment); - transactionParams.comment = parsedComment; - const { - amount, - comment, - distance, - currency, - created, - category, - tag, - taxAmount, - taxCode, - merchant, - billable, - reimbursable, - validWaypoints, - customUnitRateID = '', - splitShares = {}, - attendees, - receipt, - odometerStart, - odometerEnd, - } = transactionParams; - - // If the report is an 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 isManualDistanceRequest = isEmptyObject(validWaypoints); - - const optimisticReceipt: Receipt | undefined = !isManualDistanceRequest - ? { - source: ReceiptGeneric as ReceiptSource, - state: CONST.IOU.RECEIPT_STATE.OPEN, - } - : receipt; - - let parameters: CreateDistanceRequestParams; - let onyxData: OnyxData; - const sanitizedWaypoints = !isManualDistanceRequest ? sanitizeRecentWaypoints(validWaypoints) : null; - if (iouType === CONST.IOU.TYPE.SPLIT) { - const { - splitData, - splits, - onyxData: splitOnyxData, - } = createSplitsAndOnyxData({ - participants, - currentUserLogin: currentUserLogin ?? '', - currentUserAccountID, - existingSplitChatReportID: report?.reportID, - transactionParams: { - amount, - comment, - currency, - merchant, - created, - category: category ?? '', - tag: tag ?? '', - splitShares, - billable, - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - taxCode, - taxAmount, - attendees, - }, - policyRecentlyUsedCategories, - policyRecentlyUsedTags, - isASAPSubmitBetaEnabled, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - }); - onyxData = splitOnyxData; + let parameters: CreateDistanceRequestParams; + let onyxData: OnyxData; + const sanitizedWaypoints = !isManualDistanceRequest ? sanitizeRecentWaypoints(validWaypoints) : null; + if (iouType === CONST.IOU.TYPE.SPLIT) { + const { + splitData, + splits, + onyxData: splitOnyxData, + } = createSplitsAndOnyxData({ + participants, + currentUserLogin: currentUserLogin ?? '', + currentUserAccountID, + existingSplitChatReportID: report?.reportID, + transactionParams: { + amount, + comment, + currency, + merchant, + created, + category: category ?? '', + tag: tag ?? '', + splitShares, + billable, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + taxCode, + taxAmount, + attendees, + }, + policyRecentlyUsedCategories, + policyRecentlyUsedTags, + isASAPSubmitBetaEnabled, + transactionViolations, + quickAction, + policyRecentlyUsedCurrencies, + }); + onyxData = splitOnyxData; // Splits don't use the IOU report param. The split transaction isn't linked to a report shown in the UI, it's linked to a special default reportID of -2. // Therefore, any params related to the IOU report are irrelevant and omitted below. @@ -13368,580 +12465,6 @@ function clearSplitTransactionDraftErrors(transactionID: string | undefined) { }); } -function updateSplitTransactions({ - allTransactionsList, - allReportsList, - allReportNameValuePairsList, - transactionData, - searchContext, - policyCategories, - policy, - policyRecentlyUsedCategories, - iouReport, - firstIOU, - isASAPSubmitBetaEnabled, - currentUserPersonalDetails, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, -}: UpdateSplitTransactionsParams) { - const transactionReport = getReportOrDraftReport(transactionData?.reportID); - const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); - const expenseReport = transactionReport?.type === CONST.REPORT.TYPE.EXPENSE ? transactionReport : parentTransactionReport; - - const originalTransactionID = transactionData?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; - const originalTransaction = allTransactionsList?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`]; - const originalTransactionDetails = getTransactionDetails(originalTransaction); - - const policyTags = getPolicyTagsData(expenseReport?.policyID); - const participants = getMoneyRequestParticipantsFromReport(expenseReport, currentUserPersonalDetails.accountID); - const splitExpenses = transactionData?.splitExpenses ?? []; - - // List of all child transactions that have been created after split - const originalChildTransactions = getChildTransactions(allTransactionsList, allReportsList, originalTransactionID); - const processedChildTransactionIDs: string[] = []; - - const splitExpensesTotal = transactionData?.splitExpensesTotal ?? 0; - - const isCreationOfSplits = originalChildTransactions.length === 0; - const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); - const isReverseSplitOperation = splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft; - - let changesInReportTotal = 0; - // Validate custom unit rate before proceeding with split - const customUnitRateID = originalTransaction?.comment?.customUnit?.customUnitRateID; - const isPerDiem = isPerDiemRequestTransactionUtils(originalTransaction); - - if (customUnitRateID && policy && !isPerDiem) { - const customUnitRate = getDistanceRateCustomUnitRate(policy, customUnitRateID); - - // If the rate doesn't exist or is disabled, show an error and return early - if (!customUnitRate || !customUnitRate.enabled) { - // Show error to user - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.invalidRate'), - }); - return; - } - } - - const splits: SplitTransactionSplitsParam = - splitExpenses.map((split) => { - const currentDescription = getParsedComment(Parser.htmlToMarkdown(split.description ?? '')); - changesInReportTotal += split.amount; - return { - amount: split.amount, - category: split.category ?? '', - tag: split.tags?.[0] ?? '', - created: split.created, - merchant: split?.merchant ?? '', - transactionID: split.transactionID, - comment: { - comment: currentDescription, - }, - reimbursable: split?.reimbursable, - }; - }) ?? []; - changesInReportTotal -= splitExpensesTotal; - - const successData = [] as OnyxUpdate[]; - const failureData = [] as OnyxUpdate[]; - const optimisticData = [] as OnyxUpdate[]; - - // The split transactions can be in different reports, so we need to calculate the total for each report. - const reportTotals = new Map(); - const expenseReportID = expenseReport?.reportID; - - if (expenseReportID) { - const expenseReportKey = `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`; - const expenseReportTotal = allReportsList?.[expenseReportKey]?.total ?? expenseReport?.total ?? 0; - reportTotals.set(expenseReportID, expenseReportTotal - changesInReportTotal); - } - - for (const expense of splitExpenses) { - const splitExpenseReportID = expense.reportID; - if (!splitExpenseReportID || reportTotals.has(splitExpenseReportID)) { - continue; - } - - const splitExpenseReport = allReportsList?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpenseReportID}`]; - reportTotals.set(splitExpenseReportID, splitExpenseReport?.total ?? 0); - } - - for (const [index, splitExpense] of splitExpenses.entries()) { - const existingTransactionID = isReverseSplitOperation ? originalTransactionID : splitExpense.transactionID; - const splitTransaction = allTransactionsList?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]; - if (splitTransaction) { - processedChildTransactionIDs.push(splitTransaction.transactionID); - } - - const splitReportActions = getAllReportActions(isReverseSplitOperation ? expenseReport?.reportID : splitTransaction?.reportID); - const currentReportAction = Object.values(splitReportActions).find((action) => { - const transactionID = isMoneyRequestAction(action) ? (getOriginalMessage(action)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; - return transactionID === existingTransactionID; - }); - - const requestMoneyInformation = { - participantParams: { - participant: participants.at(0) ?? ({} as Participant), - payeeEmail: currentUserPersonalDetails?.login ?? '', - payeeAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, - }, - policyParams: { - policy, - policyCategories, - policyTags, - }, - transactionParams: { - amount: Math.abs(originalTransaction?.amount ?? 0), - modifiedAmount: splitExpense.amount ?? 0, - currency: originalTransactionDetails?.currency ?? CONST.CURRENCY.USD, - created: splitExpense.created, - merchant: splitExpense.merchant ?? '', - comment: splitExpense.description, - category: splitExpense.category, - tag: splitExpense.tags?.[0], - originalTransactionID, - attendees: originalTransactionDetails?.attendees, - source: CONST.IOU.TYPE.SPLIT, - linkedTrackedExpenseReportAction: currentReportAction, - pendingAction: splitTransaction ? null : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - pendingFields: splitTransaction ? splitTransaction.pendingFields : undefined, - reimbursable: originalTransactionDetails?.reimbursable, - taxCode: originalTransactionDetails?.taxCode, - taxAmount: calculateIOUAmount(splitExpenses.length - 1, originalTransactionDetails?.taxAmount ?? 0, originalTransactionDetails?.currency ?? CONST.CURRENCY.USD, false), - billable: originalTransactionDetails?.billable, - }, - parentChatReport: getReportOrDraftReport(getReportOrDraftReport(expenseReport?.chatReportID)?.parentReportID), - existingTransaction: originalTransaction, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam: currentUserPersonalDetails?.accountID, - currentUserEmailParam: currentUserPersonalDetails?.login ?? '', - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - } as MoneyRequestInformationParams; - - if (isReverseSplitOperation) { - requestMoneyInformation.transactionParams = { - amount: splitExpense.amount ?? 0, - currency: originalTransactionDetails?.currency ?? CONST.CURRENCY.USD, - created: splitExpense.created, - merchant: splitExpense.merchant ?? '', - comment: splitExpense.description, - category: splitExpense.category, - tag: splitExpense.tags?.[0], - attendees: originalTransactionDetails?.attendees as Attendee[], - linkedTrackedExpenseReportAction: currentReportAction, - taxCode: originalTransactionDetails?.taxCode, - taxAmount: calculateIOUAmount(splitExpenses.length - 1, originalTransactionDetails?.taxAmount ?? 0, originalTransactionDetails?.currency ?? CONST.CURRENCY.USD, false), - billable: originalTransactionDetails?.billable, - }; - requestMoneyInformation.existingTransaction = undefined; - } - - const {participantParams, policyParams, transactionParams, parentChatReport, existingTransaction} = requestMoneyInformation; - const parsedComment = getParsedComment(Parser.htmlToMarkdown(transactionParams.comment ?? '')); - transactionParams.comment = parsedComment; - - const {transactionThreadReportID, createdReportActionIDForThread, onyxData, iouAction} = getMoneyRequestInformation({ - participantParams, - parentChatReport, - policyParams, - transactionParams, - moneyRequestReportID: splitExpense?.reportID, - existingTransaction, - existingTransactionID, - newReportTotal: reportTotals.get(splitExpense?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)) ?? 0, - newNonReimbursableTotal: (transactionReport?.nonReimbursableTotal ?? 0) - changesInReportTotal, - isSplitExpense: true, - currentReportActionID: currentReportAction?.reportActionID, - isASAPSubmitBetaEnabled, - currentUserAccountIDParam: currentUserPersonalDetails?.accountID, - currentUserEmailParam: currentUserPersonalDetails?.login ?? '', - transactionViolations, - quickAction, - shouldGenerateTransactionThreadReport: !isReverseSplitOperation, - policyRecentlyUsedCurrencies, - }); - - let updateMoneyRequestParamsOnyxData: OnyxData = {}; - const currentSplit = splits.at(index); - - // For existing split transactions, update the field change messages - // For new transactions, skip this step - if (splitTransaction) { - const existing = getTransactionDetails(splitTransaction); - const transactionChanges = { - ...currentSplit, - comment: currentSplit?.comment?.comment, - } as TransactionChanges; - - if (currentSplit) { - currentSplit.reimbursable = splitTransaction.reimbursable; - currentSplit.billable = splitTransaction.billable; - } - - for (const key of Object.keys(transactionChanges)) { - const newValue = transactionChanges[key as keyof typeof transactionChanges]; - const oldValue = existing?.[key as keyof typeof existing]; - if (newValue === oldValue) { - delete transactionChanges[key as keyof typeof transactionChanges]; - // Ensure we pass the currency to getUpdateMoneyRequestParams as well, so the amount message is created correctly - } else if (key === 'amount') { - transactionChanges.currency = originalTransactionDetails?.currency; - } - } - - if (isReverseSplitOperation) { - delete transactionChanges.transactionID; - } - - if (Object.keys(transactionChanges).length > 0) { - const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${isReverseSplitOperation ? splitExpense?.reportID : transactionThreadReportID}`]; - const transactionIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpense?.reportID ?? transactionThreadReport?.parentReportID}`]; - const {onyxData: moneyRequestParamsOnyxData, params} = getUpdateMoneyRequestParams({ - transactionID: existingTransactionID, - transactionThreadReport, - iouReport: transactionIOUReport, - transactionChanges, - policy, - policyTagList: policyTags ?? null, - policyCategories: policyCategories ?? null, - newTransactionReportID: splitExpense?.reportID, - policyRecentlyUsedCategories, - currentUserAccountIDParam: currentUserPersonalDetails?.accountID, - currentUserEmailParam: currentUserPersonalDetails?.login ?? '', - isASAPSubmitBetaEnabled, - }); - if (currentSplit) { - currentSplit.modifiedExpenseReportActionID = params.reportActionID; - } - updateMoneyRequestParamsOnyxData = moneyRequestParamsOnyxData; - } - // For new split transactions, set the reportID once the transaction and associated report are created - } else if (currentSplit) { - currentSplit.reportID = splitExpense?.reportID; - } - - if (currentSplit) { - currentSplit.transactionThreadReportID = transactionThreadReportID; - currentSplit.createdReportActionIDForThread = createdReportActionIDForThread; - currentSplit.splitReportActionID = iouAction.reportActionID; - } - - optimisticData.push(...(onyxData.optimisticData ?? []), ...(updateMoneyRequestParamsOnyxData.optimisticData ?? [])); - successData.push(...(onyxData.successData ?? []), ...(updateMoneyRequestParamsOnyxData.successData ?? [])); - failureData.push(...(onyxData.failureData ?? []), ...(updateMoneyRequestParamsOnyxData.failureData ?? [])); - } - - // All transactions that were deleted in the split list will be marked as deleted in onyx - const undeletedTransactions = originalChildTransactions.filter( - (currentTransaction) => !processedChildTransactionIDs.includes(currentTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID), - ); - - for (const undeletedTransaction of undeletedTransactions) { - const splitTransaction = allTransactionsList?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${undeletedTransaction?.transactionID}`]; - const splitReportActions = getAllReportActions(splitTransaction?.reportID); - const reportNameValuePairs = allReportNameValuePairsList?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${splitTransaction?.reportID}`]; - const isReportArchived = isArchivedReport(reportNameValuePairs); - const currentReportAction = Object.values(splitReportActions).find((action) => { - const transactionID = isMoneyRequestAction(action) ? (getOriginalMessage(action)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; - return transactionID === undeletedTransaction?.transactionID; - }) as ReportAction; - - const { - optimisticData: deleteExpenseOptimisticData, - failureData: deleteExpenseFailureData, - successData: deleteExpenseSuccessData, - } = getDeleteTrackExpenseInformation( - splitTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - undeletedTransaction?.transactionID, - currentReportAction, - undefined, - undefined, - undefined, - undefined, - undefined, - isReportArchived, - ); - - optimisticData.push(...(deleteExpenseOptimisticData ?? [])); - successData.push(...(deleteExpenseSuccessData ?? [])); - failureData.push(...(deleteExpenseFailureData ?? [])); - } - - if (!isReverseSplitOperation) { - // Use SET to update originalTransaction more quickly in Onyx as compared to MERGE to prevent UI inconsistency - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, - value: { - ...originalTransaction, - reportID: CONST.REPORT.SPLIT_REPORT_ID, - }, - }); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, - value: originalTransaction, - }); - - if (firstIOU) { - const updatedReportAction = { - [firstIOU.reportActionID]: { - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - previousMessage: firstIOU.message, - message: [ - { - type: 'COMMENT', - html: '', - text: '', - isEdited: true, - isDeletedParentAction: true, - }, - ], - originalMessage: { - IOUTransactionID: null, - }, - errors: null, - }, - }; - const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${firstIOU.childReportID}`] ?? null; - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${firstIOU?.childReportID}`, - value: null, - }); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, - value: updatedReportAction, - }); - - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, - value: { - [firstIOU.reportActionID]: { - ...firstIOU, - pendingAction: null, - }, - }, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${firstIOU?.childReportID}`, - value: transactionThread, - }); - } - - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${searchContext?.currentSearchHash}`, - value: { - data: { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`]: null, - }, - }, - }); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${searchContext?.currentSearchHash}`, - value: { - data: { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`]: originalTransaction, - }, - }, - }); - } else { - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, - value: { - errors: null, - }, - }); - } - - if (isReverseSplitOperation) { - const parameters = { - ...splits.at(0), - comment: splits.at(0)?.comment?.comment, - } as RevertSplitTransactionParams; - API.write(WRITE_COMMANDS.REVERT_SPLIT_TRANSACTION, parameters, {optimisticData, successData, failureData}); - } else { - // Prepare splitApiParams for the Transaction_Split API call which requires a specific format for the splits - // The format is: splits[0][amount], splits[0][category], splits[0][tag] etc. - const splitApiParams = {} as Record; - for (const [i, split] of splits.entries()) { - for (const [key, value] of Object.entries(split)) { - splitApiParams[`splits[${i}][${key}]`] = value !== null && typeof value === 'object' ? JSON.stringify(value) : value; - } - } - if (isCreationOfSplits) { - const isTransactionOnHold = isOnHold(originalTransaction); - - if (isTransactionOnHold) { - const holdReportActionIDs: string[] = []; - const holdReportActionCommentIDs: string[] = []; - const transactionReportActions = getAllReportActions(firstIOU?.childReportID); - const holdReportAction = getReportAction(firstIOU?.childReportID, `${originalTransaction?.comment?.hold ?? ''}`); - - const holdReportActionComment = holdReportAction - ? Object.values(transactionReportActions ?? {}).find( - (action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT && action?.timestamp === holdReportAction.timestamp, - ) - : undefined; - - if (holdReportAction && holdReportActionComment) { - // Loop through all split expenses and add optimistic hold report actions for each split - for (const [index, splitExpense] of splits.entries()) { - const splitReportID = splitExpense?.transactionThreadReportID; - if (!splitReportID) { - continue; - } - - // Generate new IDs and timestamps for each split - const newHoldReportActionID = NumberUtils.rand64(); - const newHoldReportActionCommentID = NumberUtils.rand64(); - const timestamp = DateUtils.getDBTime(); - const reportActionTimestamp = DateUtils.addMillisecondsFromDateTime(timestamp, 1); - - // Store IDs for API parameters - holdReportActionIDs[index] = newHoldReportActionID; - holdReportActionCommentIDs[index] = newHoldReportActionCommentID; - - // Create new optimistic hold report action with new ID and timestamp, keeping other information - const newHoldReportAction = { - ...holdReportAction, - reportActionID: newHoldReportActionID, - created: timestamp, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }; - - // Create new optimistic hold report action comment with new ID and timestamp, keeping other information - const newHoldReportActionComment = { - ...holdReportActionComment, - reportActionID: newHoldReportActionCommentID, - created: reportActionTimestamp, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }; - - // Add to optimisticData for this split's reportActions - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitReportID}`, - value: { - [newHoldReportActionID]: newHoldReportAction, - [newHoldReportActionCommentID]: newHoldReportActionComment, - }, - }); - - // Add successData to clear pendingAction after API call succeeds - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitReportID}`, - value: { - [newHoldReportActionID]: {pendingAction: null}, - [newHoldReportActionCommentID]: {pendingAction: null}, - }, - }); - - // Add failureData to remove optimistic hold report actions if the request fails - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitReportID}`, - value: { - [newHoldReportActionID]: null, - [newHoldReportActionCommentID]: null, - }, - }); - } - - // Add hold report action IDs to API parameters - for (const [i, holdReportActionID] of holdReportActionIDs.entries()) { - if (holdReportActionID) { - splitApiParams[`splits[${i}][holdReportActionID]`] = holdReportActionID; - } - } - for (const [i, holdReportActionCommentID] of holdReportActionCommentIDs.entries()) { - if (holdReportActionCommentID) { - splitApiParams[`splits[${i}][holdReportActionCommentID]`] = holdReportActionCommentID; - } - } - } - } - } - - const splitParameters: SplitTransactionParams = { - ...splitApiParams, - transactionID: originalTransactionID, - }; - - if (isCreationOfSplits) { - // eslint-disable-next-line rulesdir/no-multiple-api-calls - API.write(WRITE_COMMANDS.SPLIT_TRANSACTION, splitParameters, {optimisticData, successData, failureData}); - } else { - // eslint-disable-next-line rulesdir/no-multiple-api-calls - API.write(WRITE_COMMANDS.UPDATE_SPLIT_TRANSACTION, splitParameters, {optimisticData, successData, failureData}); - } - } - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => removeDraftSplitTransaction(originalTransactionID)); -} - -function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransactionsParams) { - updateSplitTransactions(params); - const transactionReport = getReportOrDraftReport(params.transactionData?.reportID); - const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); - const expenseReport = transactionReport?.type === CONST.REPORT.TYPE.EXPENSE ? transactionReport : parentTransactionReport; - const isSearchPageTopmostFullScreenRoute = isSearchTopmostFullScreenRoute(); - const transactionThreadReportID = params.firstIOU?.childReportID; - const transactionThreadReportScreen = Navigation.getReportRouteByID(transactionThreadReportID); - - // Reset selected transactions in search after saving split expenses - const searchFullScreenRoutes = navigationRef.getRootState()?.routes.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); - const lastRoute = searchFullScreenRoutes?.state?.routes?.at(-1); - const isUserOnSearchPage = isSearchTopmostFullScreenRoute() && lastRoute?.name === SCREENS.SEARCH.ROOT; - if (isUserOnSearchPage) { - params?.searchContext?.clearSelectedTransactions?.(undefined, true); - } else { - params?.searchContext?.clearSelectedTransactions?.(true); - } - - if (isSearchPageTopmostFullScreenRoute || !transactionReport?.parentReportID) { - Navigation.dismissModal(); - - // After the modal is dismissed, remove the transaction thread report screen - // to avoid navigating back to a report removed by the split transaction. - requestAnimationFrame(() => { - if (!transactionThreadReportScreen?.key) { - return; - } - - Navigation.removeScreenByKey(transactionThreadReportScreen.key); - }); - - return; - } - Navigation.dismissModalWithReport({reportID: expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)}); - - // After the modal is dismissed, remove the transaction thread report screen - // to avoid navigating back to a report removed by the split transaction. - requestAnimationFrame(() => { - if (!transactionThreadReportScreen?.key) { - return; - } - - Navigation.removeScreenByKey(transactionThreadReportScreen.key); - }); -} - function assignReportToMe( report: OnyxTypes.Report, accountID: number, @@ -14181,7 +12704,6 @@ export { canCancelPayment, cleanUpMoneyRequest, clearMoneyRequest, - completeSplitBill, createDistanceRequest, createDraftTransaction, deleteMoneyRequest, @@ -14234,10 +12756,7 @@ export { setMoneyRequestTaxAmount, setMoneyRequestTaxRate, setSplitShares, - splitBill, - splitBillAndOpenReport, startMoneyRequest, - startSplitBill, submitReport, trackExpense, unapproveExpenseReport, @@ -14275,8 +12794,6 @@ export { evenlyDistributeSplitExpenseAmounts, resetSplitExpensesByDateRange, updateSplitExpenseAmountField, - updateSplitTransactions, - updateSplitTransactionsFromSplitExpensesFlow, initDraftSplitExpenseDataForEdit, removeSplitExpenseField, updateSplitExpenseField, diff --git a/src/pages/iou/SplitBillDetailsPage.tsx b/src/pages/iou/SplitBillDetailsPage.tsx index 0f41962fde962..05ec74643e411 100644 --- a/src/pages/iou/SplitBillDetailsPage.tsx +++ b/src/pages/iou/SplitBillDetailsPage.tsx @@ -14,7 +14,8 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {completeSplitBill, setDraftSplitTransaction} from '@libs/actions/IOU'; +import {setDraftSplitTransaction} from '@libs/actions/IOU'; +import {completeSplitBill} from '@libs/actions/IOU/Split'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index b1c36fe24f97b..05be412fce414 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -32,9 +32,9 @@ import { initDraftSplitExpenseDataForEdit, initSplitExpenseItemData, updateSplitExpenseAmountField, - updateSplitTransactionsFromSplitExpensesFlow, } from '@libs/actions/IOU'; import {getIOUActionForTransactions} from '@libs/actions/IOU/Duplicate'; +import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/Split'; import {convertToBackendAmount, convertToDisplayString} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 5e4be0eba26c9..870c04bb04f14 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -82,16 +82,14 @@ import { setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt, setMoneyRequestReimbursable, - splitBill, - splitBillAndOpenReport, startMoneyRequest, - startSplitBill, submitPerDiemExpense as submitPerDiemExpenseIOUActions, trackExpense as trackExpenseIOUActions, updateLastLocationPermissionPrompt, } from '@userActions/IOU'; import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice'; import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; +import {splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split'; import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy'; import {removeDraftTransaction, removeDraftTransactions, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 60bb28a873ca9..95c3e0c6fe96f 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -64,11 +64,11 @@ import { setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt, - startSplitBill, trackExpense, updateLastLocationPermissionPrompt, } from '@userActions/IOU'; import type {GpsPoint} from '@userActions/IOU'; +import {startSplitBill} from '@userActions/IOU/Split'; import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index b544b57b54ca2..de4521d17decc 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -63,10 +63,10 @@ import { setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt, setMultipleMoneyRequestParticipantsFromReport, - startSplitBill, trackExpense, updateLastLocationPermissionPrompt, } from '@userActions/IOU'; +import {startSplitBill} from '@userActions/IOU/Split'; import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index b5e80b9efb93a..aaad67034b0e7 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -2,7 +2,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import {renderHook, waitFor} from '@testing-library/react-native'; import {format} from 'date-fns'; -import {deepEqual} from 'fast-equals'; import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry, OnyxInputValue, OnyxMultiSetInput} from 'react-native-onyx'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -19,7 +18,6 @@ import { cancelPayment, canIOUBePaid, canUnapproveIOU, - completeSplitBill, createDistanceRequest, deleteMoneyRequest, evenlyDistributeSplitExpenseAmounts, @@ -38,8 +36,6 @@ import { setDraftSplitTransaction, setMoneyRequestCategory, shouldOptimisticallyUpdateSearch, - splitBill, - startSplitBill, submitPerDiemExpense, submitReport, trackExpense, @@ -48,7 +44,6 @@ import { updateMoneyRequestCategory, updateMoneyRequestTag, updateSplitExpenseAmountField, - updateSplitTransactionsFromSplitExpensesFlow, } from '@libs/actions/IOU'; import {putOnHold} from '@libs/actions/IOU/Hold'; import {getSendInvoiceInformation} from '@libs/actions/IOU/SendInvoice'; @@ -2538,182 +2533,39 @@ describe('actions/IOU', () => { }); }); - describe('split expense', () => { - it('creates and updates new chats and IOUs as needed', () => { - jest.setTimeout(10 * 1000); - /* - * Given that: - * - Rory and Carlos have chatted before - * - Rory and Jules have chatted before and have an active IOU report - * - Rory and Vit have never chatted together before - * - There is no existing group chat with the four of them - */ - const amount = 400; - const comment = 'Yes, I am splitting a bill for $4 USD'; - const merchant = 'Yema Kitchen'; - let carlosChatReport: OnyxEntry = { - reportID: rand64(), - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }; - const carlosCreatedAction: OnyxEntry = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: DateUtils.getDBTime(), - reportID: carlosChatReport.reportID, - }; - const julesIOUReportID = rand64(); - let julesChatReport: OnyxEntry = { - reportID: rand64(), - type: CONST.REPORT.TYPE.CHAT, - iouReportID: julesIOUReportID, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [JULES_ACCOUNT_ID]: JULES_PARTICIPANT}, - }; - const julesChatCreatedAction: OnyxEntry = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: DateUtils.getDBTime(), - reportID: julesChatReport.reportID, - }; - const julesCreatedAction: OnyxEntry = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, - created: DateUtils.getDBTime(), - reportID: julesIOUReportID, - }; - jest.advanceTimersByTime(200); - const julesExistingTransaction: OnyxEntry = { - transactionID: rand64(), - amount: 1000, - comment: { - comment: 'This is an existing transaction', - attendees: [{email: 'text@expensify.com', displayName: 'Test User', avatarUrl: ''}], + describe('payMoneyRequestElsewhere', () => { + it('clears outstanding IOUReport', () => { + const amount = 10000; + const comment = 'Giv money plz'; + let chatReport: OnyxEntry; + let iouReport: OnyxEntry; + let createIOUAction: OnyxEntry>; + let payIOUAction: OnyxEntry; + let transaction: OnyxEntry; + requestMoney({ + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, }, - created: DateUtils.getDBTime(), - currency: '', - merchant: '', - reportID: '', - }; - let julesIOUReport: OnyxEntry = { - reportID: julesIOUReportID, - chatReportID: julesChatReport.reportID, - type: CONST.REPORT.TYPE.IOU, - ownerAccountID: RORY_ACCOUNT_ID, - managerID: JULES_ACCOUNT_ID, - currency: CONST.CURRENCY.USD, - total: julesExistingTransaction?.amount, - }; - const julesExistingIOUAction: OnyxEntry = { - reportActionID: rand64(), - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - actorAccountID: RORY_ACCOUNT_ID, - created: DateUtils.getDBTime(), - originalMessage: { - IOUReportID: julesIOUReportID, - IOUTransactionID: julesExistingTransaction?.transactionID, - amount: julesExistingTransaction?.amount ?? 0, + transactionParams: { + amount, + attendees: [], currency: CONST.CURRENCY.USD, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - participantAccountIDs: [RORY_ACCOUNT_ID, JULES_ACCOUNT_ID], + created: '', + merchant: '', + comment, }, - reportID: julesIOUReportID, - }; - - let carlosIOUReport: OnyxEntry; - let carlosIOUAction: OnyxEntry>; - let carlosIOUCreatedAction: OnyxEntry; - let carlosTransaction: OnyxEntry; - - let julesIOUAction: OnyxEntry>; - let julesIOUCreatedAction: OnyxEntry; - let julesTransaction: OnyxEntry; - - let vitChatReport: OnyxEntry; - let vitIOUReport: OnyxEntry; - let vitCreatedAction: OnyxEntry; - let vitIOUAction: OnyxEntry>; - let vitTransaction: OnyxEntry; - - let groupChat: OnyxEntry; - let groupCreatedAction: OnyxEntry; - let groupIOUAction: OnyxEntry>; - let groupTransaction: OnyxEntry; - - const reportCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT, [carlosChatReport, julesChatReport, julesIOUReport], (item) => item.reportID); - - const carlosActionsCollectionDataSet = toCollectionDataSet( - `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, - [ - { - [carlosCreatedAction.reportActionID]: carlosCreatedAction, - }, - ], - (item) => item[carlosCreatedAction.reportActionID].reportID, - ); - - const julesActionsCollectionDataSet = toCollectionDataSet( - `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, - [ - { - [julesCreatedAction.reportActionID]: julesCreatedAction, - [julesExistingIOUAction.reportActionID]: julesExistingIOUAction, - }, - ], - (item) => item[julesCreatedAction.reportActionID].reportID, - ); - - const julesCreatedActionsCollectionDataSet = toCollectionDataSet( - `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, - [ - { - [julesChatCreatedAction.reportActionID]: julesChatCreatedAction, - }, - ], - (item) => item[julesChatCreatedAction.reportActionID].reportID, - ); - - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - return Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { - ...reportCollectionDataSet, - }) - .then(() => - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { - ...carlosActionsCollectionDataSet, - ...julesCreatedActionsCollectionDataSet, - ...julesActionsCollectionDataSet, - }), - ) - .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${julesExistingTransaction?.transactionID}`, julesExistingTransaction)) - .then(() => { - // When we split a bill offline - mockFetch?.pause?.(); - splitBill( - // TODO: Migrate after the backend accepts accountIDs - { - participants: [ - [CARLOS_EMAIL, String(CARLOS_ACCOUNT_ID)], - [JULES_EMAIL, String(JULES_ACCOUNT_ID)], - [VIT_EMAIL, String(VIT_ACCOUNT_ID)], - ].map(([email, accountID]) => ({login: email, accountID: Number(accountID)})), - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - amount, - comment, - currency: CONST.CURRENCY.USD, - merchant, - created: '', - tag: '', - existingSplitChatReportID: '', - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - }, - ); - return waitForBatchedUpdates(); - }) + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + return waitForBatchedUpdates() .then( () => new Promise((resolve) => { @@ -2723,70 +2575,24 @@ describe('actions/IOU', () => { callback: (allReports) => { Onyx.disconnect(connection); - // There should now be 10 reports - expect(Object.values(allReports ?? {}).length).toBe(10); - - // 1. The chat report with Rory + Carlos - carlosChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === carlosChatReport?.reportID); - expect(isEmptyObject(carlosChatReport)).toBe(false); - expect(carlosChatReport?.pendingFields).toBeFalsy(); - - // 2. The IOU report with Rory + Carlos (new) - carlosIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU && report.managerID === CARLOS_ACCOUNT_ID); - expect(isEmptyObject(carlosIOUReport)).toBe(false); - expect(carlosIOUReport?.total).toBe(amount / 4); - - // 3. The chat report with Rory + Jules - julesChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === julesChatReport?.reportID); - expect(isEmptyObject(julesChatReport)).toBe(false); - expect(julesChatReport?.pendingFields).toBeFalsy(); - - // 4. The IOU report with Rory + Jules - julesIOUReport = Object.values(allReports ?? {}).find((report) => report?.reportID === julesIOUReport?.reportID); - expect(isEmptyObject(julesIOUReport)).toBe(false); - expect(julesChatReport?.pendingFields).toBeFalsy(); - expect(julesIOUReport?.total).toBe((julesExistingTransaction?.amount ?? 0) + amount / 4); - - // 5. The chat report with Rory + Vit (new) - vitChatReport = Object.values(allReports ?? {}).find( - (report) => - report?.type === CONST.REPORT.TYPE.CHAT && - deepEqual(report.participants, {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [VIT_ACCOUNT_ID]: VIT_PARTICIPANT}), - ); - expect(isEmptyObject(vitChatReport)).toBe(false); - expect(vitChatReport?.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); - - // 6. The IOU report with Rory + Vit (new) - vitIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU && report.managerID === VIT_ACCOUNT_ID); - expect(isEmptyObject(vitIOUReport)).toBe(false); - expect(vitIOUReport?.total).toBe(amount / 4); - - // 7. The group chat with everyone - groupChat = Object.values(allReports ?? {}).find( - (report) => - report?.type === CONST.REPORT.TYPE.CHAT && - deepEqual(report.participants, { - [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT, - [JULES_ACCOUNT_ID]: JULES_PARTICIPANT, - [VIT_ACCOUNT_ID]: VIT_PARTICIPANT, - [RORY_ACCOUNT_ID]: RORY_PARTICIPANT, - }), - ); - expect(isEmptyObject(groupChat)).toBe(false); - expect(groupChat?.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); - - // The 1:1 chat reports and the IOU reports should be linked together - expect(carlosChatReport?.iouReportID).toBe(carlosIOUReport?.reportID); - expect(carlosIOUReport?.chatReportID).toBe(carlosChatReport?.reportID); - for (const participant of Object.values(carlosIOUReport?.participants ?? {})) { - expect(participant.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); - } + expect(Object.values(allReports ?? {}).length).toBe(3); - expect(julesChatReport?.iouReportID).toBe(julesIOUReport?.reportID); - expect(julesIOUReport?.chatReportID).toBe(julesChatReport?.reportID); + const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); + chatReport = chatReports.at(0); + expect(chatReport).toBeTruthy(); + expect(chatReport).toHaveProperty('reportID'); + expect(chatReport).toHaveProperty('iouReportID'); + + iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); + + expect(chatReport?.iouReportID).toBe(iouReport?.reportID); + expect(iouReport?.chatReportID).toBe(chatReport?.reportID); - expect(vitChatReport?.iouReportID).toBe(vitIOUReport?.reportID); - expect(vitIOUReport?.chatReportID).toBe(vitChatReport?.reportID); + expect(chatReport?.pendingFields).toBeFalsy(); + expect(iouReport?.pendingFields).toBeFalsy(); resolve(); }, @@ -2802,81 +2608,13 @@ describe('actions/IOU', () => { callback: (allReportActions) => { Onyx.disconnect(connection); - // There should be reportActions on all 7 chat reports + 3 IOU reports in each 1:1 chat - expect(Object.values(allReportActions ?? {}).length).toBe(10); - - const carlosReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${carlosChatReport?.iouReportID}`]; - const julesReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesChatReport?.iouReportID}`]; - const vitReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${vitChatReport?.iouReportID}`]; - const groupReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChat?.reportID}`]; + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; - // Carlos DM should have two reportActions – the existing CREATED action and a pending IOU action - expect(Object.values(carlosReportActions ?? {}).length).toBe(2); - carlosIOUCreatedAction = Object.values(carlosReportActions ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, - ); - carlosIOUAction = Object.values(carlosReportActions ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const carlosOriginalMessage = carlosIOUAction ? getOriginalMessage(carlosIOUAction) : undefined; - - expect(carlosIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(carlosOriginalMessage?.IOUReportID).toBe(carlosIOUReport?.reportID); - expect(carlosOriginalMessage?.amount).toBe(amount / 4); - expect(carlosOriginalMessage?.comment).toBe(comment); - expect(carlosOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - expect(Date.parse(carlosIOUCreatedAction?.created ?? '')).toBeLessThan(Date.parse(carlosIOUAction?.created ?? '')); - - // Jules DM should have three reportActions, the existing CREATED action, the existing IOU action, and a new pending IOU action - expect(Object.values(julesReportActions ?? {}).length).toBe(3); - expect(julesReportActions?.[julesCreatedAction.reportActionID]).toStrictEqual(julesCreatedAction); - julesIOUCreatedAction = Object.values(julesReportActions ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, - ); - julesIOUAction = Object.values(julesReportActions ?? {}).find( - (reportAction): reportAction is ReportAction => - reportAction.reportActionID !== julesCreatedAction.reportActionID && reportAction.reportActionID !== julesExistingIOUAction.reportActionID, - ); - const julesOriginalMessage = julesIOUAction ? getOriginalMessage(julesIOUAction) : undefined; - - expect(julesIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(julesOriginalMessage?.IOUReportID).toBe(julesIOUReport?.reportID); - expect(julesOriginalMessage?.amount).toBe(amount / 4); - expect(julesOriginalMessage?.comment).toBe(comment); - expect(julesOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - expect(Date.parse(julesIOUCreatedAction?.created ?? '')).toBeLessThan(Date.parse(julesIOUAction?.created ?? '')); - - // Vit DM should have two reportActions – a pending CREATED action and a pending IOU action - expect(Object.values(vitReportActions ?? {}).length).toBe(2); - vitCreatedAction = Object.values(vitReportActions ?? {}).find( - (reportAction): reportAction is ReportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, - ); - vitIOUAction = Object.values(vitReportActions ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const vitOriginalMessage = vitIOUAction ? getOriginalMessage(vitIOUAction) : undefined; - - expect(vitCreatedAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(vitIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(vitOriginalMessage?.IOUReportID).toBe(vitIOUReport?.reportID); - expect(vitOriginalMessage?.amount).toBe(amount / 4); - expect(vitOriginalMessage?.comment).toBe(comment); - expect(vitOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); - expect(Date.parse(vitCreatedAction?.created ?? '')).toBeLessThan(Date.parse(vitIOUAction?.created ?? '')); - - // Group chat should have two reportActions – a pending CREATED action and a pending IOU action w/ type SPLIT - expect(Object.values(groupReportActions ?? {}).length).toBe(2); - groupCreatedAction = Object.values(groupReportActions ?? {}).find((reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - groupIOUAction = Object.values(groupReportActions ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), + createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( + (reportAction): reportAction is ReportAction => isMoneyRequestAction(reportAction), ); - const groupOriginalMessage = groupIOUAction ? getOriginalMessage(groupIOUAction) : undefined; - - expect(groupCreatedAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(groupIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(groupOriginalMessage).not.toHaveProperty('IOUReportID'); - expect(groupOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.SPLIT); - expect(Date.parse(groupCreatedAction?.created ?? '')).toBeLessThanOrEqual(Date.parse(groupIOUAction?.created ?? '')); + expect(createIOUAction).toBeTruthy(); + expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUReportID).toBe(iouReport?.reportID); resolve(); }, @@ -2891,118 +2629,91 @@ describe('actions/IOU', () => { waitForCollectionCallback: true, callback: (allTransactions) => { Onyx.disconnect(connection); - - /* There should be 5 transactions - * – one existing one with Jules - * - one for each of the three IOU reports - * - one on the group chat w/ deleted report - */ - expect(Object.values(allTransactions ?? {}).length).toBe(5); - expect(allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${julesExistingTransaction?.transactionID}`]).toBeTruthy(); - - carlosTransaction = Object.values(allTransactions ?? {}).find( - (transaction) => carlosIOUAction && transaction?.transactionID === getOriginalMessage(carlosIOUAction)?.IOUTransactionID, - ); - julesTransaction = Object.values(allTransactions ?? {}).find( - (transaction) => julesIOUAction && transaction?.transactionID === getOriginalMessage(julesIOUAction)?.IOUTransactionID, - ); - vitTransaction = Object.values(allTransactions ?? {}).find( - (transaction) => vitIOUAction && transaction?.transactionID === getOriginalMessage(vitIOUAction)?.IOUTransactionID, - ); - groupTransaction = Object.values(allTransactions ?? {}).find((transaction) => transaction?.reportID === CONST.REPORT.SPLIT_REPORT_ID); - - expect(carlosTransaction?.reportID).toBe(carlosIOUReport?.reportID); - expect(julesTransaction?.reportID).toBe(julesIOUReport?.reportID); - expect(vitTransaction?.reportID).toBe(vitIOUReport?.reportID); - expect(groupTransaction).toBeTruthy(); - - expect(carlosTransaction?.amount).toBe(amount / 4); - expect(julesTransaction?.amount).toBe(amount / 4); - expect(vitTransaction?.amount).toBe(amount / 4); - expect(groupTransaction?.amount).toBe(amount); - - expect(carlosTransaction?.comment?.comment).toBe(comment); - expect(julesTransaction?.comment?.comment).toBe(comment); - expect(vitTransaction?.comment?.comment).toBe(comment); - expect(groupTransaction?.comment?.comment).toBe(comment); - - expect(carlosTransaction?.merchant).toBe(merchant); - expect(julesTransaction?.merchant).toBe(merchant); - expect(vitTransaction?.merchant).toBe(merchant); - expect(groupTransaction?.merchant).toBe(merchant); - - expect(carlosTransaction?.comment?.source).toBe(CONST.IOU.TYPE.SPLIT); - expect(julesTransaction?.comment?.source).toBe(CONST.IOU.TYPE.SPLIT); - expect(vitTransaction?.comment?.source).toBe(CONST.IOU.TYPE.SPLIT); - - expect(carlosTransaction?.comment?.originalTransactionID).toBe(groupTransaction?.transactionID); - expect(julesTransaction?.comment?.originalTransactionID).toBe(groupTransaction?.transactionID); - expect(vitTransaction?.comment?.originalTransactionID).toBe(groupTransaction?.transactionID); - - expect(carlosTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(julesTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(vitTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - expect(groupTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - + expect(Object.values(allTransactions ?? {}).length).toBe(1); + transaction = Object.values(allTransactions ?? {}).find((t) => t); + expect(transaction).toBeTruthy(); + expect(transaction?.amount).toBe(amount); + expect(transaction?.reportID).toBe(iouReport?.reportID); + expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUTransactionID).toBe(transaction?.transactionID); resolve(); }, }); }), ) + .then(() => { + mockFetch?.pause?.(); + if (chatReport && iouReport) { + payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, undefined, undefined); + } + return waitForBatchedUpdates(); + }) .then( () => new Promise((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - waitForCollectionCallback: false, - callback: (allPersonalDetails) => { + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { Onyx.disconnect(connection); - expect(allPersonalDetails).toMatchObject({ - [VIT_ACCOUNT_ID]: { - accountID: VIT_ACCOUNT_ID, - displayName: VIT_EMAIL, - login: VIT_EMAIL, - }, - }); + + expect(Object.values(allReports ?? {}).length).toBe(3); + + chatReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.CHAT); + iouReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.IOU); + + expect(chatReport?.iouReportID).toBeFalsy(); + + // expect(iouReport.status).toBe(CONST.REPORT.STATUS_NUM.REIMBURSED); + // expect(iouReport.stateNum).toBe(CONST.REPORT.STATE_NUM.APPROVED); + resolve(); }, }); }), ) - .then(mockFetch?.resume) - .then(waitForNetworkPromises) .then( () => new Promise((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, - callback: (allReports) => { + callback: (allReportActions) => { Onyx.disconnect(connection); - for (const report of Object.values(allReports ?? {})) { - if (!report?.pendingFields) { - continue; - } - for (const pendingField of Object.values(report?.pendingFields)) { - expect(pendingField).toBeFalsy(); - } - } + + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`]; + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); + + payIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( + (reportAction) => isMoneyRequestAction(reportAction) && getOriginalMessage(reportAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY, + ); + expect(payIOUAction).toBeTruthy(); + expect(payIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + resolve(); }, }); }), ) + .then(mockFetch?.resume) .then( () => new Promise((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, - callback: (allReportActions) => { + callback: (allReports) => { Onyx.disconnect(connection); - for (const reportAction of Object.values(allReportActions ?? {})) { - expect(reportAction?.pendingAction).toBeFalsy(); - } + + expect(Object.values(allReports ?? {}).length).toBe(3); + + chatReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.CHAT); + iouReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.IOU); + + expect(chatReport?.iouReportID).toBeFalsy(); + + // expect(iouReport.status).toBe(CONST.REPORT.STATUS_NUM.REIMBURSED); + // expect(iouReport.stateNum).toBe(CONST.REPORT.STATE_NUM.APPROVED); + resolve(); }, }); @@ -3012,835 +2723,890 @@ describe('actions/IOU', () => { () => new Promise((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, - callback: (allTransactions) => { + callback: (allReportActions) => { Onyx.disconnect(connection); - for (const transaction of Object.values(allTransactions ?? {})) { - expect(transaction?.pendingAction).toBeFalsy(); - } + + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`]; + expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); + + payIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( + (reportAction) => isMoneyRequestAction(reportAction) && getOriginalMessage(reportAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY, + ); resolve(); + + expect(payIOUAction).toBeTruthy(); + expect(payIOUAction?.pendingAction).toBeFalsy(); }, }); }), ); }); + }); - it('should update split chat report lastVisibleActionCreated to the report preview action', async () => { - // Given a expense chat with no expenses - const workspaceReportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, {reportID: workspaceReportID, isOwnPolicyExpenseChat: true}); - - // When the user split bill on the workspace - splitBill({ - participants: [{reportID: workspaceReportID}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: workspaceReportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - }); + describe('pay expense report via ACH', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; - await waitForBatchedUpdates(); - - // Then the expense chat lastVisibleActionCreated should be updated to the report preview action created - const reportPreviewAction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceReportID}`, - callback: (reportActions) => { - Onyx.disconnect(connection); - resolve(Object.values(reportActions ?? {}).find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); - }, - }); - }); - - await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, - callback: (report) => { - Onyx.disconnect(connection); - expect(report?.lastVisibleActionCreated).toBe(reportPreviewAction?.created); - resolve(report); - }, - }); - }); - }); - - it('correctly sets quickAction', async () => { - // Given a expense chat with no expenses - const workspaceReportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, {reportID: workspaceReportID, isOwnPolicyExpenseChat: true}); - - splitBill({ - participants: [{reportID: workspaceReportID}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: workspaceReportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - }); - - await waitForBatchedUpdates(); - - expect(await getOnyxValue(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE)).toHaveProperty('isFirstQuickAction', true); - - splitBill({ - participants: [{reportID: workspaceReportID}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: workspaceReportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: {action: CONST.QUICK_ACTIONS.SEND_MONEY, chatReportID: '456'}, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - }); - await waitForBatchedUpdates(); - - expect(await getOnyxValue(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE)).toMatchObject({ - action: CONST.QUICK_ACTIONS.SPLIT_MANUAL, - isFirstQuickAction: false, - }); + afterEach(() => { + mockFetch?.resume?.(); }); - it('merges policyRecentlyUsedCurrencies when splitting a bill', async () => { - const initialCurrencies = [CONST.CURRENCY.USD]; - await Onyx.set(ONYXKEYS.RECENTLY_USED_CURRENCIES, initialCurrencies); + it('updates the expense request and expense report when paid while offline', () => { + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; - splitBill({ - participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.EUR, - merchant: 'test', - created: '', - existingSplitChatReportID: '', - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: initialCurrencies, - policyRecentlyUsedTags: undefined, - }); + mockFetch?.pause?.(); + Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + return waitForBatchedUpdates() + .then(() => { + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - await waitForBatchedUpdates(); + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - const recentlyUsedCurrencies = await getOnyxValue(ONYXKEYS.RECENTLY_USED_CURRENCIES); - expect(recentlyUsedCurrencies).toEqual([CONST.CURRENCY.EUR, ...initialCurrencies]); + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport && expenseReport) { + payMoneyRequest(CONST.IOU.PAYMENT_TYPE.VBBA, chatReport, expenseReport, undefined, undefined); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allActions) => { + Onyx.disconnect(connection); + expect(Object.values(allActions ?? {})).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.arrayContaining([ + expect.objectContaining({ + html: `paid $${amount / 100}.00 with Expensify`, + text: `paid $${amount / 100}.00 with Expensify`, + }), + ]), + originalMessage: expect.objectContaining({ + amount, + paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, + type: 'pay', + }), + }), + ]), + ); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + const updatedIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); + const updatedChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === expenseReport?.chatReportID); + expect(updatedIOUReport).toEqual( + expect.objectContaining({ + lastMessageHtml: `paid $${amount / 100}.00 with Expensify`, + lastMessageText: `paid $${amount / 100}.00 with Expensify`, + statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + }), + ); + expect(updatedChatReport).toEqual( + expect.objectContaining({ + lastMessageHtml: `paid $${amount / 100}.00 with Expensify`, + lastMessageText: `paid $${amount / 100}.00 with Expensify`, + }), + ); + resolve(); + }, + }); + }), + ); }); - it('should update split chat report lastVisibleActionCreated to the latest IOU action when split bill in a DM', async () => { - // Given a DM chat with no expenses - const reportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { - reportID, - type: CONST.REPORT.TYPE.CHAT, - participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, - }); - - // When the user split bill twice on the DM - splitBill({ - participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: reportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - }); + it('shows an error when paying results in an error', () => { + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; - await waitForBatchedUpdates(); + Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + return waitForBatchedUpdates() + .then(() => { + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - splitBill({ - participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 200, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: reportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - }); + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); + + resolve(); + }, + }); + }), + ) + .then(() => { + mockFetch?.fail?.(); + if (chatReport && expenseReport) { + payMoneyRequest('ACH', chatReport, expenseReport, undefined, undefined); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allActions) => { + Onyx.disconnect(connection); + const erroredAction = Object.values(allActions ?? {}).find((action) => !isEmptyObject(action?.errors)); + expect(Object.values(erroredAction?.errors ?? {}).at(0)).toEqual(translateLocal('iou.error.other')); + resolve(); + }, + }); + }), + ); + }); + }); + + describe('payMoneyRequest', () => { + it('should apply optimistic data correctly', async () => { + // Given an outstanding IOU report + const chatReport = { + ...createRandomReport(0, undefined), + lastReadTime: DateUtils.getDBTime(), + lastVisibleActionCreated: DateUtils.getDBTime(), + }; + const iouReport = { + ...createRandomReport(1, undefined), + chatType: undefined, + type: CONST.REPORT.TYPE.IOU, + total: 10, + }; + mockFetch?.pause?.(); + + jest.advanceTimersByTime(10); + + // When paying the IOU report + payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, undefined, undefined); await waitForBatchedUpdates(); - // Then the DM lastVisibleActionCreated should be updated to the second IOU action created - const iouAction = await new Promise>((resolve) => { + // Then the optimistic data should be applied correctly + const payReportAction = await new Promise((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, callback: (reportActions) => { Onyx.disconnect(connection); - resolve(Object.values(reportActions ?? {}).find((action) => isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU) && getOriginalMessage(action)?.amount === 200)); + resolve(Object.values(reportActions ?? {}).pop()); }, }); }); - const report = await new Promise>((resolve) => { + await new Promise((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - callback: (reportVal) => { + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + callback: (report) => { Onyx.disconnect(connection); - resolve(reportVal); + expect(report?.lastVisibleActionCreated).toBe(chatReport.lastVisibleActionCreated); + expect(report?.hasOutstandingChildRequest).toBe(false); + expect(report?.iouReportID).toBeUndefined(); + expect(new Date(report?.lastReadTime ?? '').getTime()).toBeGreaterThan(new Date(chatReport?.lastReadTime ?? '').getTime()); + expect(report?.lastMessageText).toBe(getReportActionText(payReportAction)); + expect(report?.lastMessageHtml).toBe(getReportActionHtml(payReportAction)); + resolve(); }, }); }); - expect(report?.lastVisibleActionCreated).toBe(iouAction?.created); - }); - - it('optimistic transaction should be merged with the draft transaction if it is a distance request', async () => { - // Given a workspace expense chat and a draft split transaction - const workspaceReportID = '1'; - const transactionAmount = 100; - const draftTransaction = { - amount: transactionAmount, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - splitShares: { - [workspaceReportID]: {amount: 100}, - }, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, {reportID: workspaceReportID, isOwnPolicyExpenseChat: true}); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, draftTransaction); - - // When doing a distance split expense - splitBill({ - participants: [{reportID: workspaceReportID}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - existingSplitChatReportID: workspaceReportID, - ...draftTransaction, - comment: '', - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - }); - await waitForBatchedUpdates(); - - const optimisticTransaction = await new Promise>((resolve) => { + await new Promise((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + callback: (report) => { Onyx.disconnect(connection); - resolve(Object.values(transactions ?? {}).find((transaction) => transaction?.amount === -(transactionAmount / 2))); + expect(report?.hasOutstandingChildRequest).toBe(false); + expect(report?.statusNum).toBe(CONST.REPORT.STATUS_NUM.REIMBURSED); + expect(report?.lastVisibleActionCreated).toBe(payReportAction?.created); + expect(report?.lastMessageText).toBe(getReportActionText(payReportAction)); + expect(report?.lastMessageHtml).toBe(getReportActionHtml(payReportAction)); + expect(report?.pendingFields).toEqual({ + preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }); + resolve(); }, }); }); - // Then the data from the transaction draft should be merged into the optimistic transaction - expect(optimisticTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE); + mockFetch?.resume?.(); }); - it("should update the notification preference of the report to ALWAYS if it's previously hidden", async () => { - // Given a group chat with hidden notification preference - const reportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { - reportID, - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.GROUP, - participants: { - [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, - [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + it('calls notifyNewAction for the top most report', () => { + // Given two expenses in an iou report where one of them held + const iouReport = buildOptimisticIOUReport(1, 2, 100, '1', 'USD'); + const transaction1 = buildOptimisticTransaction({ + transactionParams: { + amount: 100, + currency: 'USD', + reportID: iouReport.reportID, }, }); - - // When the user split bill on the group chat - splitBill({ - participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '', - amount: 100, - currency: CONST.CURRENCY.USD, - merchant: 'test', - created: '', - existingSplitChatReportID: reportID, - isASAPSubmitBetaEnabled: false, - transactionViolations: {}, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, + const transaction2 = buildOptimisticTransaction({ + transactionParams: { + amount: 100, + currency: 'USD', + reportID: iouReport.reportID, + }, }); + const transactionCollectionDataSet: TransactionCollectionDataSet = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, + }; + const iouActions: ReportAction[] = []; + for (const transaction of [transaction1, transaction2]) { + iouActions.push( + buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: transaction.amount, + currency: transaction.currency, + comment: '', + participants: [], + transactionID: transaction.transactionID, + }), + ); + } + const actions: OnyxInputValue = {}; + for (const iouAction of iouActions) { + actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouAction.reportActionID}`] = iouAction; + } + const actionCollectionDataSet: ReportActionsCollectionDataSet = {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`]: actions}; - await waitForBatchedUpdates(); - - // Then the DM notification preference should be updated to ALWAYS - const report = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - callback: (reportVal) => { - Onyx.disconnect(connection); - resolve(reportVal); - }, + return waitForBatchedUpdates() + .then(() => Onyx.multiSet({...transactionCollectionDataSet, ...actionCollectionDataSet})) + .then(() => { + putOnHold(transaction1.transactionID, 'comment', iouReport.reportID); + return waitForBatchedUpdates(); + }) + .then(() => { + // When partially paying an iou report from the chat report via the report preview + payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, {reportID: topMostReportID}, iouReport, undefined, undefined, undefined, false); + return waitForBatchedUpdates(); + }) + .then(() => { + // Then notifyNewAction should be called on the top most report. + expect(notifyNewAction).toHaveBeenCalledWith(topMostReportID, expect.anything()); }); - }); - expect(report?.participants?.[RORY_ACCOUNT_ID].notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS); }); - it('should update the policyRecentlyUsedTags when tag is provided', async () => { - // Given a policy recently used tags - const policyID = 'A'; - const transactionTag = 'new tag'; - const tagName = 'Tag'; - const policyRecentlyUsedTags: OnyxEntry = { - [tagName]: ['old tag'], - }; - - const policyExpenseChat = { - reportID: '2', - policyID, - isPolicyExpenseChat: true, - isOwnPolicyExpenseChat: true, - }; + it('new expense report should be a draft report when paying partially and the approval is disabled', async () => { + const adminAccountID = 1; + const employeeAccountID = 3; + const adminEmail = 'admin@test.com'; + const employeeEmail = 'employee@test.com'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { - [tagName]: {name: tagName}, + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [adminAccountID]: { + accountID: adminAccountID, + login: adminEmail, + displayName: 'Admin User', + }, + [employeeAccountID]: { + accountID: employeeAccountID, + login: employeeEmail, + displayName: 'Employee User', + }, }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, policyRecentlyUsedTags); - // When doing a split bill - splitBill({ - participants: [{isPolicyExpenseChat: true, policyID}], - existingSplitChatReportID: policyExpenseChat.reportID, - currentUserLogin: currentUserPersonalDetails.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - amount: 1, - created: '', - comment: '', - merchant: '', - transactionViolations: undefined, - category: undefined, - tag: transactionTag, - currency: CONST.CURRENCY.USD, - taxCode: '', - taxAmount: 0, - isASAPSubmitBetaEnabled: false, - policyRecentlyUsedTags, - quickAction: {}, - policyRecentlyUsedCurrencies: [], - }); - - waitForBatchedUpdates(); - - // Then the transaction tag should be added to the recently used tags collection - const newPolicyRecentlyUsedTags: RecentlyUsedTags = await new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, - callback: (recentlyUsedTags) => { - resolve(recentlyUsedTags ?? {}); - Onyx.disconnect(connection); + // Create policy with no approval required + const policy = { + id: '1', + name: 'Test Policy', + role: CONST.POLICY.ROLE.ADMIN, + owner: adminEmail, + outputCurrency: CONST.CURRENCY.USD, + isPolicyExpenseChatEnabled: true, + type: CONST.POLICY.TYPE.CORPORATE, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + employeeList: { + [employeeEmail]: { + email: employeeEmail, + role: CONST.POLICY.ROLE.USER, + submitsTo: adminEmail, + }, + [adminEmail]: { + email: adminEmail, + role: CONST.POLICY.ROLE.ADMIN, + submitsTo: '', + forwardsTo: '', }, - }); - }); - expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); - expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); - }); - - it('the description should not be parsed again after completing the scan split bill without changing the description', async () => { - const reportID = '1'; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { - reportID, - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.GROUP, - participants: { - [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, - [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, }, - }); - - // Start a scan split bill - const {splitTransactionID} = startSplitBill({ - participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], - currentUserLogin: RORY_EMAIL, - currentUserAccountID: RORY_ACCOUNT_ID, - comment: '# test', - currency: CONST.CURRENCY.USD, - existingSplitChatReportID: reportID, - receipt: {}, - category: undefined, - tag: undefined, - taxCode: '', - taxAmount: 0, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - policyRecentlyUsedTags: undefined, - }); - - await waitForBatchedUpdates(); - - let splitTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID}`); + }; - // Then the description should be parsed correctly - expect(splitTransaction?.comment?.comment).toBe('

test

'); + // Create expense report + const expenseReport = { + reportID: '123', + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: employeeAccountID, + managerID: adminAccountID, + policyID: policy.id, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + total: 1000, + currency: 'USD', + parentReportID: '456', + chatReportID: '456', + }; - const updatedSplitTransaction = splitTransaction - ? { - ...splitTransaction, - amount: 100, - } - : undefined; + const chatReport = { + reportID: '456', + isOwnPolicyExpenseChat: true, + ownerAccountID: employeeAccountID, + iouReportID: expenseReport.reportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + }; - const reportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); - const iouAction = Object.values(reportActions ?? {}).find((action) => isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU)); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); - expect(iouAction).toBeTruthy(); + const newExpenseReportID = payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, expenseReport, undefined, undefined, undefined, false, undefined, policy); + await waitForBatchedUpdates(); + const newExpenseReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newExpenseReportID}`); + expect(newExpenseReport?.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); + expect(newExpenseReport?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); + }); + }); - // Complete this split bill without changing the description - completeSplitBill(reportID, iouAction, updatedSplitTransaction, RORY_ACCOUNT_ID, false, undefined, {}, RORY_EMAIL); + describe('a expense chat with a cancelled payment', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; - await waitForBatchedUpdates(); + afterEach(() => { + mockFetch?.resume?.(); + }); - splitTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID}`); + it("has an iouReportID of the cancelled payment's expense report", () => { + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; - // Then the description should be the same since it was not changed - expect(splitTransaction?.comment?.comment).toBe('

test

'); + // Given a signed in account, which owns a workspace, and has a policy expense chat + Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + return waitForBatchedUpdates() + .then(() => { + // Which owns a workspace + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + return waitForBatchedUpdates(); + }) + .then(() => + getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }), + ) + .then(() => { + if (chatReport) { + // When an IOU expense is submitted to that policy expense chat + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + } + return waitForBatchedUpdates(); + }) + .then(() => + // And given an expense report has now been created which holds the IOU + getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); + }, + }), + ) + .then(() => { + // When the expense report is paid elsewhere (but really, any payment option would work) + if (chatReport && expenseReport) { + payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, expenseReport, undefined, undefined); + } + return waitForBatchedUpdates(); + }) + .then(() => { + if (chatReport && expenseReport) { + // And when the payment is cancelled + cancelPayment(expenseReport, chatReport, {} as Policy, true, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true); + } + return waitForBatchedUpdates(); + }) + .then(() => + getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + const chatReportData = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`]; + // Then the policy expense chat report has the iouReportID of the IOU expense report + expect(chatReportData?.iouReportID).toBe(expenseReport?.reportID); + }, + }), + ); }); }); - describe('startSplitBill', () => { - it('should update the policyRecentlyUsedTags when tag is provided', async () => { - // Given a policy recently used tags - const policyID = 'A'; - const transactionTag = 'new tag'; - const tagName = 'Tag'; - const policyRecentlyUsedTags: OnyxEntry = { - [tagName]: ['old tag'], - }; + describe('deleteMoneyRequest', () => { + const amount = 10000; + const comment = 'Send me money please'; + let chatReport: OnyxEntry; + let iouReport: OnyxEntry; + let createIOUAction: OnyxEntry>; + let transaction: OnyxEntry; + let thread: OptimisticChatReport; + const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@test.com'; + let IOU_REPORT_ID: string | undefined; + let IOU_REPORT: OnyxEntry; + 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: [], + }, + }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { - [tagName]: {name: tagName}, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, policyRecentlyUsedTags); + let reportActions: OnyxCollection; - // When doing a split bill with a receipt - startSplitBill({ - participants: [{isPolicyExpenseChat: true, policyID}], - currentUserLogin: currentUserPersonalDetails.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - comment: '', - receipt: {}, - category: undefined, - tag: transactionTag, - currency: CONST.CURRENCY.USD, - taxCode: '', - taxAmount: 0, - policyRecentlyUsedTags, - quickAction: {}, - policyRecentlyUsedCurrencies: [], - }); + beforeEach(async () => { + // Given mocks are cleared and helpers are set up + jest.clearAllMocks(); + PusherHelper.setup(); - waitForBatchedUpdates(); + // Given a test user is signed in with Onyx setup and some initial data + await signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); + subscribeToUserEvents(); + await waitForBatchedUpdates(); + await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); - // Then the transaction tag should be added to the recently used tags collection - const newPolicyRecentlyUsedTags: RecentlyUsedTags = await new Promise((resolve) => { - const connection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, - callback: (recentlyUsedTags) => { - resolve(recentlyUsedTags ?? {}); - Onyx.disconnect(connection); - }, - }); - }); - expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); - expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); - }); - }); - - describe('updateSplitTransactionsFromSplitExpensesFlow', () => { - it('should delete the original transaction thread report', async () => { - const expenseReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction: Transaction = { - amount: 100, - currency: 'USD', - transactionID: '1', - reportID: expenseReport.reportID, - created: DateUtils.getDBTime(), - merchant: 'test', - }; - const transactionThread: Report = { - ...createRandomReport(2, undefined), - }; - const iouAction: ReportAction = { - ...buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: transaction.amount, - currency: transaction.currency, - comment: '', - participants: [], - transactionID: transaction.transactionID, - iouReportID: expenseReport.reportID, - }), - childReportID: transactionThread.reportID, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { - [iouAction.reportActionID]: iouAction, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - const draftTransaction: OnyxEntry = { - ...transaction, - comment: { - originalTransactionID: transaction.transactionID, - }, - }; - - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - let allReportNameValuePairs: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - waitForCollectionCallback: true, - callback: (value) => { - allReportNameValuePairs = value; - }, - }); - - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + // When a submit IOU expense is made + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: TEST_USER_LOGIN, + payeeAccountID: TEST_USER_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, }, - searchContext: { - currentSearchHash: -2, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: '', + comment, }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: iouAction, + shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, }); - await waitForBatchedUpdates(); - const originalTransactionThread = await new Promise>((resolve) => { + // When fetching all reports from Onyx + const allReports = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${iouAction.childReportID}`, - callback: (val) => { + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (reports) => { Onyx.disconnect(connection); - resolve(val); + resolve(reports); }, }); }); - expect(originalTransactionThread).toBe(undefined); - }); - it('should remove the original transaction from the search snapshot data', async () => { - // Given a single expense - const expenseReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - }; - const transaction: Transaction = { - amount: 100, - currency: 'USD', - transactionID: '1', - reportID: expenseReport.reportID, - created: DateUtils.getDBTime(), - merchant: 'test', - }; - const transactionThread: Report = { - ...createRandomReport(2, undefined), - }; - const iouAction: ReportAction = { - ...buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: transaction.amount, - currency: transaction.currency, - comment: '', - participants: [], - transactionID: transaction.transactionID, - iouReportID: expenseReport.reportID, - }), - childReportID: transactionThread.reportID, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { - [iouAction.reportActionID]: iouAction, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - const draftTransaction: OnyxEntry = { - ...transaction, - comment: { - originalTransactionID: transaction.transactionID, - }, - }; + // Then we should have exactly 3 reports + expect(Object.values(allReports ?? {}).length).toBe(3); - // When splitting the expense - const hash = 1; + // Then one of them should be a chat report with relevant properties + chatReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT); + expect(chatReport).toBeTruthy(); + expect(chatReport).toHaveProperty('reportID'); + expect(chatReport).toHaveProperty('iouReportID'); - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - let allReportNameValuePairs: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - waitForCollectionCallback: true, - callback: (value) => { - allReportNameValuePairs = value; - }, - }); - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: hash, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: undefined, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); + // Then one of them should be an IOU report with relevant properties + iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); + + // Then their IDs should reference each other + expect(chatReport?.iouReportID).toBe(iouReport?.reportID); + expect(iouReport?.chatReportID).toBe(chatReport?.reportID); + + // Storing IOU Report ID for further reference + IOU_REPORT_ID = chatReport?.iouReportID; + IOU_REPORT = iouReport; await waitForBatchedUpdates(); - // Then the original expense/transaction should be removed from the search snapshot data - const searchSnapshot = await new Promise>((resolve) => { + // When fetching all report actions from Onyx + const allReportActions = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, - callback: (val) => { + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { Onyx.disconnect(connection); - resolve(val); + resolve(actions); }, }); }); - expect(searchSnapshot?.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]).toBe(undefined); - }); - - it('should add split transactions optimistically on search snapshot when current search filter is on unapprovedCash', async () => { - const chatReport: Report = createRandomReport(7, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - // Given a single expense - const expenseReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - chatReportID: chatReport.reportID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - }; - const transaction: Transaction = { - amount: 100, - currency: 'USD', - transactionID: '1', - reportID: expenseReport.reportID, - created: DateUtils.getDBTime(), - merchant: 'test', - }; - const transactionThread: Report = { - ...createRandomReport(2, undefined), - }; - const iouAction: ReportAction = { - ...buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: transaction.amount, - currency: transaction.currency, - comment: '', - participants: [], - transactionID: transaction.transactionID, - iouReportID: expenseReport.reportID, - }), - childReportID: transactionThread.reportID, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { - [iouAction.reportActionID]: iouAction, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); - const splitTransactionID1 = '34'; - const splitTransactionID2 = '35'; - const draftTransaction: OnyxEntry = { - ...transaction, - comment: { - originalTransactionID: transaction.transactionID, - splitExpenses: [ - {amount: transaction.amount / 2, transactionID: splitTransactionID1, created: ''}, - {amount: transaction.amount / 2, transactionID: splitTransactionID2, created: ''}, - ], - }, - }; - // When splitting the expense - const hash = 1; + // Then we should find an IOU action with specific properties + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; + createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(createIOUAction).toBeTruthy(); + expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUReportID).toBe(iouReport?.reportID); - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - let allReportNameValuePairs: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - waitForCollectionCallback: true, - callback: (value) => { - allReportNameValuePairs = value; - }, + // 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); + }, + }); }); - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: hash, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: undefined, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, + // 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(iouReport?.reportID); + expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUTransactionID).toBe(transaction?.transactionID); + }); + + afterEach(PusherHelper.teardown); + + it('delete an expense (IOU Action and transaction) successfully', async () => { + // Given the fetch operations are paused and an expense is initiated + mockFetch?.pause?.(); + + if (transaction && createIOUAction) { + // When the expense is deleted + deleteMoneyRequest({ + transactionID: transaction?.transactionID, + reportAction: createIOUAction, + transactions: {}, + violations: {}, + iouReport, + chatReport, + isChatIOUReportArchived: true, + allTransactionViolationsParam: {}, + }); + } + await waitForBatchedUpdates(); + + // Then we check if the IOU report action is removed from the report actions collection + let reportActionsForReport = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + waitForCollectionCallback: false, + callback: (actionsForReport) => { + Onyx.disconnect(connection); + resolve(actionsForReport); + }, + }); + }); + + createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + // Then the IOU Action should be truthy for offline support. + expect(createIOUAction).toBeTruthy(); + + // Then we check if the transaction is removed from the transactions collection + const t = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, + waitForCollectionCallback: false, + callback: (transactionResult) => { + Onyx.disconnect(connection); + resolve(transactionResult); + }, + }); }); + expect(t).toBeTruthy(); + expect(t?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + // Given fetch operations are resumed + mockFetch?.resume?.(); await waitForBatchedUpdates(); - // Then the split expenses/transactions should be added on the search snapshot data - const searchSnapshot = await new Promise>((resolve) => { + // Then we recheck the IOU report action from the report actions collection + reportActionsForReport = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`, - callback: (val) => { + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, + waitForCollectionCallback: false, + callback: (actionsForReport) => { Onyx.disconnect(connection); - resolve(val); + resolve(actionsForReport); + }, + }); + }); + + createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(createIOUAction).toBeFalsy(); + + // Then we recheck the transaction from the transactions collection + const tr = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, + waitForCollectionCallback: false, + callback: (transactionResult) => { + Onyx.disconnect(connection); + resolve(transactionResult); }, }); }); - expect(searchSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID1}`]).toBeDefined(); - expect(searchSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID2}`]).toBeDefined(); + + expect(tr).toBeFalsy(); }); - }); - describe('payMoneyRequestElsewhere', () => { - it('clears outstanding IOUReport', () => { - const amount = 10000; - const comment = 'Giv money plz'; - let chatReport: OnyxEntry; - let iouReport: OnyxEntry; - let createIOUAction: OnyxEntry>; - let payIOUAction: OnyxEntry; - let transaction: OnyxEntry; + it('delete the IOU report when there are no expenses left in the IOU report', async () => { + // Given an IOU report and a paused fetch state + mockFetch?.pause?.(); + + if (transaction && createIOUAction) { + // When the IOU expense is deleted + deleteMoneyRequest({ + transactionID: transaction?.transactionID, + reportAction: createIOUAction, + transactions: {}, + violations: {}, + iouReport, + chatReport, + isChatIOUReportArchived: true, + allTransactionViolationsParam: {}, + }); + } + await waitForBatchedUpdates(); + + let report = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + waitForCollectionCallback: false, + callback: (res) => { + Onyx.disconnect(connection); + resolve(res); + }, + }); + }); + + // Then the report should be truthy for offline support + expect(report).toBeTruthy(); + + // Given the resumed fetch state + mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + report = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + waitForCollectionCallback: false, + callback: (res) => { + Onyx.disconnect(connection); + resolve(res); + }, + }); + }); + + // Then the report should be falsy so that there is no trace of the expense. + expect(report).toBeFalsy(); + }); + + it('does not delete the IOU report when there are expenses left in the IOU report', async () => { + // Given multiple expenses on an IOU report requestMoney({ - report: {reportID: ''}, + report: chatReport, participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + payeeEmail: TEST_USER_LOGIN, + payeeAccountID: TEST_USER_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID}, }, transactionParams: { amount, @@ -3858,859 +3624,398 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], quickAction: undefined, }); - return waitForBatchedUpdates() - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expect(Object.values(allReports ?? {}).length).toBe(3); + await waitForBatchedUpdates(); - const chatReports = Object.values(allReports ?? {}).filter((report) => report?.type === CONST.REPORT.TYPE.CHAT); - chatReport = chatReports.at(0); - expect(chatReport).toBeTruthy(); - expect(chatReport).toHaveProperty('reportID'); - expect(chatReport).toHaveProperty('iouReportID'); + // When we attempt to delete an expense from the IOU report + mockFetch?.pause?.(); + if (transaction && createIOUAction) { + deleteMoneyRequest({ + transactionID: transaction?.transactionID, + reportAction: createIOUAction, + transactions: {}, + violations: {}, + iouReport, + chatReport, + allTransactionViolationsParam: {}, + }); + } + await waitForBatchedUpdates(); - iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - expect(iouReport).toBeTruthy(); - expect(iouReport).toHaveProperty('reportID'); - expect(iouReport).toHaveProperty('chatReportID'); + // Then expect that the IOU report still exists + let allReports = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (reports) => { + Onyx.disconnect(connection); + resolve(reports); + }, + }); + }); - expect(chatReport?.iouReportID).toBe(iouReport?.reportID); - expect(iouReport?.chatReportID).toBe(chatReport?.reportID); + await waitForBatchedUpdates(); - expect(chatReport?.pendingFields).toBeFalsy(); - expect(iouReport?.pendingFields).toBeFalsy(); + iouReport = Object.values(allReports ?? {}).find((report) => isIOUReport(report)); + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connection); + // Given the resumed fetch state + await mockFetch?.resume?.(); - const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; + allReports = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (reports) => { + Onyx.disconnect(connection); + resolve(reports); + }, + }); + }); + // Then expect that the IOU report still exists + iouReport = Object.values(allReports ?? {}).find((report) => isIOUReport(report)); + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); + }); - createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( - (reportAction): reportAction is ReportAction => isMoneyRequestAction(reportAction), - ); - expect(createIOUAction).toBeTruthy(); - expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUReportID).toBe(iouReport?.reportID); + it('delete the transaction thread if there are no visible comments in the thread', async () => { + // Given all promises are resolved + await waitForBatchedUpdates(); + jest.advanceTimersByTime(10); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - Onyx.disconnect(connection); - expect(Object.values(allTransactions ?? {}).length).toBe(1); - transaction = Object.values(allTransactions ?? {}).find((t) => t); - expect(transaction).toBeTruthy(); - expect(transaction?.amount).toBe(amount); - expect(transaction?.reportID).toBe(iouReport?.reportID); - expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUTransactionID).toBe(transaction?.transactionID); - resolve(); - }, - }); - }), - ) - .then(() => { - mockFetch?.pause?.(); - if (chatReport && iouReport) { - payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, undefined, undefined); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); + // Given a transaction thread + thread = buildTransactionThread(createIOUAction, iouReport); - expect(Object.values(allReports ?? {}).length).toBe(3); + expect(thread.participants).toStrictEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); - chatReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.CHAT); - iouReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.IOU); + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, + callback: (val) => (reportActions = val), + }); - expect(chatReport?.iouReportID).toBeFalsy(); + await waitForBatchedUpdates(); - // expect(iouReport.status).toBe(CONST.REPORT.STATUS_NUM.REIMBURSED); - // expect(iouReport.stateNum).toBe(CONST.REPORT.STATE_NUM.APPROVED); + jest.advanceTimersByTime(10); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connection); + // Given User logins from the participant accounts + const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); + const userLogins = getLoginsByAccountIDs(participantAccountIDs); - const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`]; - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); + // When Opening a thread report with the given details + openReport(thread.reportID, '', userLogins, thread, createIOUAction?.reportActionID); + await waitForBatchedUpdates(); - payIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( - (reportAction) => isMoneyRequestAction(reportAction) && getOriginalMessage(reportAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY, - ); - expect(payIOUAction).toBeTruthy(); - expect(payIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + // Then The iou action has the transaction report id as a child report ID + 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); - resolve(); - }, - }); - }), - ) - .then(mockFetch?.resume) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); + await waitForBatchedUpdates(); - expect(Object.values(allReports ?? {}).length).toBe(3); + // Given Fetch is paused and timers have advanced + mockFetch?.pause?.(); + jest.advanceTimersByTime(10); - chatReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.CHAT); - iouReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.IOU); + if (transaction && createIOUAction) { + // When Deleting an expense + deleteMoneyRequest({ + transactionID: transaction?.transactionID, + reportAction: createIOUAction, + transactions: {}, + violations: {}, + iouReport, + chatReport, + allTransactionViolationsParam: {}, + }); + } + await waitForBatchedUpdates(); - expect(chatReport?.iouReportID).toBeFalsy(); + // Then The report for the given thread ID does not exist + let report = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + waitForCollectionCallback: false, + callback: (reportData) => { + Onyx.disconnect(connection); + resolve(reportData); + }, + }); + }); - // expect(iouReport.status).toBe(CONST.REPORT.STATUS_NUM.REIMBURSED); - // expect(iouReport.stateNum).toBe(CONST.REPORT.STATE_NUM.APPROVED); + expect(report?.reportID).toBeFalsy(); + mockFetch?.resume?.(); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connection); + // Then After resuming fetch, the report for the given thread ID still does not exist + report = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + waitForCollectionCallback: false, + callback: (reportData) => { + Onyx.disconnect(connection); + resolve(reportData); + }, + }); + }); - const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`]; - expect(Object.values(reportActionsForIOUReport ?? {}).length).toBe(3); + expect(report?.reportID).toBeFalsy(); + }); - payIOUAction = Object.values(reportActionsForIOUReport ?? {}).find( - (reportAction) => isMoneyRequestAction(reportAction) && getOriginalMessage(reportAction)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY, - ); - resolve(); + it('delete the transaction thread if there are only changelogs (i.e. MODIFIED_EXPENSE actions) in the thread', async () => { + // Given all promises are resolved + await waitForBatchedUpdates(); + jest.advanceTimersByTime(10); - expect(payIOUAction).toBeTruthy(); - expect(payIOUAction?.pendingAction).toBeFalsy(); - }, - }); - }), - ); - }); - }); + // Given a transaction thread + thread = buildTransactionThread(createIOUAction, iouReport); - describe('pay expense report via ACH', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, + callback: (val) => (reportActions = val), + }); - afterEach(() => { - mockFetch?.resume?.(); - }); + await waitForBatchedUpdates(); - it('updates the expense request and expense report when paid while offline', () => { - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; + jest.advanceTimersByTime(10); - mockFetch?.pause?.(); - Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); - return waitForBatchedUpdates() - .then(() => { - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - - resolve(); - }, - }); - }), - ) - .then(() => { - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - - resolve(); - }, - }); - }), - ) - .then(() => { - if (chatReport && expenseReport) { - payMoneyRequest(CONST.IOU.PAYMENT_TYPE.VBBA, chatReport, expenseReport, undefined, undefined); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allActions) => { - Onyx.disconnect(connection); - expect(Object.values(allActions ?? {})).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: expect.arrayContaining([ - expect.objectContaining({ - html: `paid $${amount / 100}.00 with Expensify`, - text: `paid $${amount / 100}.00 with Expensify`, - }), - ]), - originalMessage: expect.objectContaining({ - amount, - paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, - type: 'pay', - }), - }), - ]), - ); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - const updatedIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - const updatedChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === expenseReport?.chatReportID); - expect(updatedIOUReport).toEqual( - expect.objectContaining({ - lastMessageHtml: `paid $${amount / 100}.00 with Expensify`, - lastMessageText: `paid $${amount / 100}.00 with Expensify`, - statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - }), - ); - expect(updatedChatReport).toEqual( - expect.objectContaining({ - lastMessageHtml: `paid $${amount / 100}.00 with Expensify`, - lastMessageText: `paid $${amount / 100}.00 with Expensify`, - }), - ); - resolve(); - }, - }); - }), - ); - }); - - it('shows an error when paying results in an error', () => { - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - - Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); - return waitForBatchedUpdates() - .then(() => { - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - - resolve(); - }, - }); - }), - ) - .then(() => { - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - - resolve(); - }, - }); - }), - ) - .then(() => { - mockFetch?.fail?.(); - if (chatReport && expenseReport) { - payMoneyRequest('ACH', chatReport, expenseReport, undefined, undefined); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allActions) => { - Onyx.disconnect(connection); - const erroredAction = Object.values(allActions ?? {}).find((action) => !isEmptyObject(action?.errors)); - expect(Object.values(erroredAction?.errors ?? {}).at(0)).toEqual(translateLocal('iou.error.other')); - resolve(); - }, - }); - }), - ); - }); - }); - - describe('payMoneyRequest', () => { - it('should apply optimistic data correctly', async () => { - // Given an outstanding IOU report - const chatReport = { - ...createRandomReport(0, undefined), - lastReadTime: DateUtils.getDBTime(), - lastVisibleActionCreated: DateUtils.getDBTime(), - }; - const iouReport = { - ...createRandomReport(1, undefined), - chatType: undefined, - type: CONST.REPORT.TYPE.IOU, - total: 10, - }; - mockFetch?.pause?.(); - - jest.advanceTimersByTime(10); - - // When paying the IOU report - payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport, undefined, undefined); + // Given User logins from the participant accounts + const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); + const userLogins = getLoginsByAccountIDs(participantAccountIDs); + // When Opening a thread report with the given details + openReport(thread.reportID, '', userLogins, thread, createIOUAction?.reportActionID); await waitForBatchedUpdates(); - // Then the optimistic data should be applied correctly - const payReportAction = await new Promise((resolve) => { + // Then The iou action has the transaction report id as a child report ID + const allReportActions = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, - callback: (reportActions) => { + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (actions) => { Onyx.disconnect(connection); - resolve(Object.values(reportActions ?? {}).pop()); + resolve(actions); }, }); }); + const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; + createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, - callback: (report) => { - Onyx.disconnect(connection); - expect(report?.lastVisibleActionCreated).toBe(chatReport.lastVisibleActionCreated); - expect(report?.hasOutstandingChildRequest).toBe(false); - expect(report?.iouReportID).toBeUndefined(); - expect(new Date(report?.lastReadTime ?? '').getTime()).toBeGreaterThan(new Date(chatReport?.lastReadTime ?? '').getTime()); - expect(report?.lastMessageText).toBe(getReportActionText(payReportAction)); - expect(report?.lastMessageHtml).toBe(getReportActionHtml(payReportAction)); - resolve(); - }, - }); - }); + expect(createIOUAction?.childReportID).toBe(thread.reportID); - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, - callback: (report) => { - Onyx.disconnect(connection); - expect(report?.hasOutstandingChildRequest).toBe(false); - expect(report?.statusNum).toBe(CONST.REPORT.STATUS_NUM.REIMBURSED); - expect(report?.lastVisibleActionCreated).toBe(payReportAction?.created); - expect(report?.lastMessageText).toBe(getReportActionText(payReportAction)); - expect(report?.lastMessageHtml).toBe(getReportActionHtml(payReportAction)); - expect(report?.pendingFields).toEqual({ - preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }); - resolve(); + await waitForBatchedUpdates(); + + jest.advanceTimersByTime(10); + if (transaction && createIOUAction) { + updateMoneyRequestAmountAndCurrency({ + transactionID: transaction.transactionID, + transactions: {}, + transactionThreadReport: thread, + parentReport: iouReport, + transactionViolations: {}, + amount: 20000, + currency: CONST.CURRENCY.USD, + taxAmount: 0, + taxCode: '', + policy: { + id: '123', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + name: '', + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, }, + policyTagList: {}, + policyCategories: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + policyRecentlyUsedCurrencies: [], }); - }); + } + await waitForBatchedUpdates(); - mockFetch?.resume?.(); - }); + // Verify there are two actions (created + changelog) + expect(Object.values(reportActions ?? {}).length).toBe(2); - it('calls notifyNewAction for the top most report', () => { - // Given two expenses in an iou report where one of them held - const iouReport = buildOptimisticIOUReport(1, 2, 100, '1', 'USD'); - const transaction1 = buildOptimisticTransaction({ - transactionParams: { - amount: 100, - currency: 'USD', - reportID: iouReport.reportID, - }, - }); - const transaction2 = buildOptimisticTransaction({ - transactionParams: { - amount: 100, - currency: 'USD', - reportID: iouReport.reportID, - }, + // Fetch the updated IOU Action from Onyx + 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(); + }, + }); }); - const transactionCollectionDataSet: TransactionCollectionDataSet = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`]: transaction1, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`]: transaction2, - }; - const iouActions: ReportAction[] = []; - for (const transaction of [transaction1, transaction2]) { - iouActions.push( - buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - amount: transaction.amount, - currency: transaction.currency, - comment: '', - participants: [], - transactionID: transaction.transactionID, - }), - ); - } - const actions: OnyxInputValue = {}; - for (const iouAction of iouActions) { - actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouAction.reportActionID}`] = iouAction; + + if (transaction && createIOUAction) { + // When Deleting an expense + deleteMoneyRequest({ + transactionID: transaction?.transactionID, + reportAction: createIOUAction, + transactions: {}, + violations: {}, + iouReport, + chatReport, + allTransactionViolationsParam: {}, + }); } - const actionCollectionDataSet: ReportActionsCollectionDataSet = {[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`]: actions}; + await waitForBatchedUpdates(); - return waitForBatchedUpdates() - .then(() => Onyx.multiSet({...transactionCollectionDataSet, ...actionCollectionDataSet})) - .then(() => { - putOnHold(transaction1.transactionID, 'comment', iouReport.reportID); - return waitForBatchedUpdates(); - }) - .then(() => { - // When partially paying an iou report from the chat report via the report preview - payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, {reportID: topMostReportID}, iouReport, undefined, undefined, undefined, false); - return waitForBatchedUpdates(); - }) - .then(() => { - // Then notifyNewAction should be called on the top most report. - expect(notifyNewAction).toHaveBeenCalledWith(topMostReportID, expect.anything()); + // Then, the report for the given thread ID does not exist + const report = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + waitForCollectionCallback: false, + callback: (reportData) => { + Onyx.disconnect(connection); + resolve(reportData); + }, }); + }); + + expect(report?.reportID).toBeFalsy(); }); - it('new expense report should be a draft report when paying partially and the approval is disabled', async () => { - const adminAccountID = 1; - const employeeAccountID = 3; - const adminEmail = 'admin@test.com'; - const employeeEmail = 'employee@test.com'; + it('does not delete the transaction thread if there are visible comments in the thread', async () => { + // Given initial environment is set up + await waitForBatchedUpdates(); - await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { - [adminAccountID]: { - accountID: adminAccountID, - login: adminEmail, - displayName: 'Admin User', - }, - [employeeAccountID]: { - accountID: employeeAccountID, - login: employeeEmail, - displayName: 'Employee User', - }, + // 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(thread.reportID, '', userLogins, thread, createIOUAction?.reportActionID); + await waitForBatchedUpdates(); + + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, + callback: (val) => (reportActions = val), }); + await waitForBatchedUpdates(); - // Create policy with no approval required - const policy = { - id: '1', - name: 'Test Policy', - role: CONST.POLICY.ROLE.ADMIN, - owner: adminEmail, - outputCurrency: CONST.CURRENCY.USD, - isPolicyExpenseChatEnabled: true, - type: CONST.POLICY.TYPE.CORPORATE, - approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, - employeeList: { - [employeeEmail]: { - email: employeeEmail, - role: CONST.POLICY.ROLE.USER, - submitsTo: adminEmail, - }, - [adminEmail]: { - email: adminEmail, - role: CONST.POLICY.ROLE.ADMIN, - submitsTo: '', - forwardsTo: '', + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + callback: (report) => { + Onyx.disconnect(connection); + expect(report).toBeTruthy(); + resolve(); }, - }, - }; + }); + }); - // Create expense report - const expenseReport = { - reportID: '123', - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: employeeAccountID, - managerID: adminAccountID, - policyID: policy.id, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - total: 1000, - currency: 'USD', - parentReportID: '456', - chatReportID: '456', - }; + jest.advanceTimersByTime(10); - const chatReport = { - reportID: '456', - isOwnPolicyExpenseChat: true, - ownerAccountID: employeeAccountID, - iouReportID: expenseReport.reportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.CHAT, - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - }; + // When a comment is added + addComment(thread, thread.reportID, [], 'Testing a comment', CONST.DEFAULT_TIME_ZONE); + await waitForBatchedUpdates(); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); + // 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); - const newExpenseReportID = payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, expenseReport, undefined, undefined, undefined, false, undefined, policy); await waitForBatchedUpdates(); - const newExpenseReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${newExpenseReportID}`); - expect(newExpenseReport?.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); - expect(newExpenseReport?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); - }); - }); - describe('a expense chat with a cancelled payment', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; + // Then the report should have 2 actions + expect(Object.values(reportActions ?? {}).length).toBe(2); + const resultActionAfter = reportActionID ? reportActions?.[reportActionID] : undefined; + expect(resultActionAfter?.pendingAction).toBeUndefined(); - afterEach(() => { - mockFetch?.resume?.(); - }); + mockFetch?.pause?.(); - it("has an iouReportID of the cancelled payment's expense report", () => { - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - - // Given a signed in account, which owns a workspace, and has a policy expense chat - Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); - return waitForBatchedUpdates() - .then(() => { - // Which owns a workspace - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); - return waitForBatchedUpdates(); - }) - .then(() => - getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - }, - }), - ) - .then(() => { - if (chatReport) { - // When an IOU expense is submitted to that policy expense chat - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - } - return waitForBatchedUpdates(); - }) - .then(() => - // And given an expense report has now been created which holds the IOU - getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - }, - }), - ) - .then(() => { - // When the expense report is paid elsewhere (but really, any payment option would work) - if (chatReport && expenseReport) { - payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, expenseReport, undefined, undefined); - } - return waitForBatchedUpdates(); - }) - .then(() => { - if (chatReport && expenseReport) { - // And when the payment is cancelled - cancelPayment(expenseReport, chatReport, {} as Policy, true, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true); - } - return waitForBatchedUpdates(); - }) - .then(() => - getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - const chatReportData = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`]; - // Then the policy expense chat report has the iouReportID of the IOU expense report - expect(chatReportData?.iouReportID).toBe(expenseReport?.reportID); - }, - }), - ); - }); - }); - - describe('deleteMoneyRequest', () => { - const amount = 10000; - const comment = 'Send me money please'; - let chatReport: OnyxEntry; - let iouReport: OnyxEntry; - let createIOUAction: OnyxEntry>; - let transaction: OnyxEntry; - let thread: OptimisticChatReport; - const TEST_USER_ACCOUNT_ID = 1; - const TEST_USER_LOGIN = 'test@test.com'; - let IOU_REPORT_ID: string | undefined; - let IOU_REPORT: OnyxEntry; - 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(); + if (transaction && createIOUAction) { + // When deleting expense + deleteMoneyRequest({ + transactionID: transaction?.transactionID, + reportAction: createIOUAction, + transactions: {}, + violations: {}, + iouReport, + chatReport, + allTransactionViolationsParam: {}, + }); + } await waitForBatchedUpdates(); - await setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); - // When a submit IOU expense is made - 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: [], - quickAction: undefined, + // Then the transaction thread report should still exist + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + waitForCollectionCallback: false, + callback: (report) => { + Onyx.disconnect(connection); + expect(report).toBeTruthy(); + resolve(); + }, + }); }); - await waitForBatchedUpdates(); - // When fetching all reports from Onyx - const allReports = await new Promise>((resolve) => { + // When fetch resumes + // Then the transaction thread report should still exist + mockFetch?.resume?.(); + await new Promise((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { + key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, + callback: (report) => { Onyx.disconnect(connection); - resolve(reports); + expect(report).toBeTruthy(); + resolve(); }, }); }); + }); - // Then we should have exactly 3 reports - expect(Object.values(allReports ?? {}).length).toBe(3); + it('update the moneyRequestPreview to show [Deleted expense] when appropriate', async () => { + await waitForBatchedUpdates(); - // Then one of them should be a chat report with relevant properties - chatReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.CHAT); - expect(chatReport).toBeTruthy(); - expect(chatReport).toHaveProperty('reportID'); - expect(chatReport).toHaveProperty('iouReportID'); + // Given a thread report - // Then one of them should be an IOU report with relevant properties - iouReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - expect(iouReport).toBeTruthy(); - expect(iouReport).toHaveProperty('reportID'); - expect(iouReport).toHaveProperty('chatReportID'); + jest.advanceTimersByTime(10); + thread = buildTransactionThread(createIOUAction, iouReport); - // Then their IDs should reference each other - expect(chatReport?.iouReportID).toBe(iouReport?.reportID); - expect(iouReport?.chatReportID).toBe(chatReport?.reportID); + expect(thread.participants).toStrictEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); - // Storing IOU Report ID for further reference - IOU_REPORT_ID = chatReport?.iouReportID; - IOU_REPORT = iouReport; + 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(thread.reportID, '', userLogins, thread, createIOUAction?.reportActionID); await waitForBatchedUpdates(); - // When fetching all report actions from Onyx const allReportActions = await new Promise>((resolve) => { const connection = Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, @@ -4722,177 +4027,229 @@ describe('actions/IOU', () => { }); }); - // Then we should find an IOU action with specific properties const reportActionsForIOUReport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.iouReportID}`]; createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find((reportAction): reportAction is ReportAction => isMoneyRequestAction(reportAction), ); - expect(createIOUAction).toBeTruthy(); - expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUReportID).toBe(iouReport?.reportID); + 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); - }, - }); - }); + await waitForBatchedUpdates(); - // 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(iouReport?.reportID); - expect(createIOUAction && getOriginalMessage(createIOUAction)?.IOUTransactionID).toBe(transaction?.transactionID); - }); + // Given an added comment to the thread report - afterEach(PusherHelper.teardown); + jest.advanceTimersByTime(10); - it('delete an expense (IOU Action and transaction) successfully', async () => { - // Given the fetch operations are paused and an expense is initiated - mockFetch?.pause?.(); + addComment(thread, thread.reportID, [], 'Testing a comment', CONST.DEFAULT_TIME_ZONE); + 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(); + + 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(); + + jest.advanceTimersByTime(10); + + if (IOU_REPORT_ID) { + addComment(IOU_REPORT, IOU_REPORT_ID, [], 'Testing a comment', CONST.DEFAULT_TIME_ZONE); + } + await waitForBatchedUpdates(); + + 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 the expense is deleted + // When we delete the expense deleteMoneyRequest({ - transactionID: transaction?.transactionID, + transactionID: transaction.transactionID, reportAction: createIOUAction, transactions: {}, violations: {}, iouReport, chatReport, - isChatIOUReportArchived: true, + isChatIOUReportArchived: undefined, allTransactionViolationsParam: {}, }); } await waitForBatchedUpdates(); - // Then we check if the IOU report action is removed from the report actions collection - let reportActionsForReport = await new Promise>((resolve) => { + // 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: (actionsForReport) => { + callback: (reportActionsForReport) => { Onyx.disconnect(connection); - resolve(actionsForReport); + createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(getReportActionMessage(createIOUAction)?.isDeletedParentAction).toBeTruthy(); + resolve(); }, }); }); - createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - // Then the IOU Action should be truthy for offline support. - expect(createIOUAction).toBeTruthy(); + // When we resume fetch + mockFetch?.resume?.(); - // Then we check if the transaction is removed from the transactions collection - const t = await new Promise>((resolve) => { + // Then we expect the moneyRequestPreview to show [Deleted expense] + + await new Promise((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, waitForCollectionCallback: false, - callback: (transactionResult) => { + callback: (reportActionsForReport) => { Onyx.disconnect(connection); - resolve(transactionResult); + createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + expect(getReportActionMessage(createIOUAction)?.isDeletedParentAction).toBeTruthy(); + resolve(); }, }); }); + }); - expect(t).toBeTruthy(); - expect(t?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - - // Given fetch operations are resumed - mockFetch?.resume?.(); + it('update IOU report and reportPreview with new totals and messages if the IOU report is not deleted', async () => { await waitForBatchedUpdates(); - - // Then we recheck the IOU report action from the report actions collection - reportActionsForReport = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, - waitForCollectionCallback: false, - callback: (actionsForReport) => { - Onyx.disconnect(connection); - resolve(actionsForReport); - }, - }); + Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + callback: (val) => (iouReport = val), }); + await waitForBatchedUpdates(); - createIOUAction = Object.values(reportActionsForReport ?? {}).find((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - expect(createIOUAction).toBeFalsy(); + // Given a second expense in addition to the first one - // Then we recheck the transaction from the transactions collection - const tr = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction?.transactionID}`, - waitForCollectionCallback: false, - callback: (transactionResult) => { - Onyx.disconnect(connection); - resolve(transactionResult); + 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: [], + quickAction: undefined, }); - }); + } - expect(tr).toBeFalsy(); - }); + await waitForBatchedUpdates(); - it('delete the IOU report when there are no expenses left in the IOU report', async () => { - // Given an IOU report and a paused fetch state - mockFetch?.pause?.(); + // 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) { - // When the IOU expense is deleted deleteMoneyRequest({ - transactionID: transaction?.transactionID, + transactionID: transaction.transactionID, reportAction: createIOUAction, transactions: {}, violations: {}, iouReport, chatReport, - isChatIOUReportArchived: true, + isChatIOUReportArchived: undefined, allTransactionViolationsParam: {}, }); } await waitForBatchedUpdates(); - let report = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - waitForCollectionCallback: false, - callback: (res) => { - Onyx.disconnect(connection); - resolve(res); - }, - }); - }); + // Then we expect the IOU report and reportPreview to update with new totals - // Then the report should be truthy for offline support - expect(report).toBeTruthy(); + expect(iouReport).toBeTruthy(); + expect(iouReport).toHaveProperty('reportID'); + expect(iouReport).toHaveProperty('chatReportID'); + expect(iouReport?.total).toBe(20000); - // Given the resumed fetch state + // When we resume mockFetch?.resume?.(); - await waitForBatchedUpdates(); - - report = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - waitForCollectionCallback: false, - callback: (res) => { - Onyx.disconnect(connection); - resolve(res); - }, - }); - }); - // Then the report should be falsy so that there is no trace of the expense. - expect(report).toBeFalsy(); + // 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('does not delete the IOU report when there are expenses left in the IOU report', async () => { + it('navigate the user correctly to the iou Report when appropriate', async () => { // Given multiple expenses on an IOU report requestMoney({ report: chatReport, @@ -4917,25 +4274,54 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], quickAction: undefined, }); + 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}}); + jest.advanceTimersByTime(10); + const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); + const userLogins = getLoginsByAccountIDs(participantAccountIDs); + openReport(thread.reportID, '', userLogins, thread, createIOUAction?.reportActionID); await waitForBatchedUpdates(); - // When we attempt to delete an expense from the IOU report - mockFetch?.pause?.(); - if (transaction && createIOUAction) { - deleteMoneyRequest({ - transactionID: transaction?.transactionID, + 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); + + // When we delete the expense, we should not delete the IOU report + mockFetch?.pause?.(); + + let navigateToAfterDelete; + if (transaction && createIOUAction) { + navigateToAfterDelete = deleteMoneyRequest({ + transactionID: transaction.transactionID, reportAction: createIOUAction, transactions: {}, violations: {}, iouReport, chatReport, + isSingleTransactionView: true, allTransactionViolationsParam: {}, }); } - await waitForBatchedUpdates(); - // Then expect that the IOU report still exists let allReports = await new Promise>((resolve) => { const connection = Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, @@ -4947,14 +4333,11 @@ describe('actions/IOU', () => { }); }); - await waitForBatchedUpdates(); - iouReport = Object.values(allReports ?? {}).find((report) => isIOUReport(report)); expect(iouReport).toBeTruthy(); expect(iouReport).toHaveProperty('reportID'); expect(iouReport).toHaveProperty('chatReportID'); - // Given the resumed fetch state await mockFetch?.resume?.(); allReports = await new Promise>((resolve) => { @@ -4967,67 +4350,25 @@ describe('actions/IOU', () => { }, }); }); - // Then expect that the IOU report still exists + iouReport = Object.values(allReports ?? {}).find((report) => isIOUReport(report)); expect(iouReport).toBeTruthy(); expect(iouReport).toHaveProperty('reportID'); expect(iouReport).toHaveProperty('chatReportID'); - }); - - it('delete the transaction thread if there are no visible comments in the thread', async () => { - // Given all promises are resolved - await waitForBatchedUpdates(); - jest.advanceTimersByTime(10); - - // Given a transaction thread - 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); - - // Given User logins from the participant accounts - const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); - const userLogins = getLoginsByAccountIDs(participantAccountIDs); - - // When Opening a thread report with the given details - openReport(thread.reportID, '', userLogins, thread, createIOUAction?.reportActionID); - await waitForBatchedUpdates(); - - // Then The iou action has the transaction report id as a child report ID - 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 Fetch is paused and timers have advanced - mockFetch?.pause?.(); - 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)); + } + }); + it('navigate the user correctly to the chat Report when appropriate', () => { + let navigateToAfterDelete; if (transaction && createIOUAction) { - // When Deleting an expense - deleteMoneyRequest({ - transactionID: transaction?.transactionID, + // When we delete the expense and we should delete the IOU report + navigateToAfterDelete = deleteMoneyRequest({ + transactionID: transaction.transactionID, reportAction: createIOUAction, transactions: {}, violations: {}, @@ -5036,3648 +4377,2496 @@ describe('actions/IOU', () => { allTransactionViolationsParam: {}, }); } - await waitForBatchedUpdates(); - - // Then The report for the given thread ID does not exist - let report = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - waitForCollectionCallback: false, - callback: (reportData) => { - Onyx.disconnect(connection); - resolve(reportData); - }, - }); - }); - - expect(report?.reportID).toBeFalsy(); - mockFetch?.resume?.(); - - // Then After resuming fetch, the report for the given thread ID still does not exist - report = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - waitForCollectionCallback: false, - callback: (reportData) => { - Onyx.disconnect(connection); - resolve(reportData); - }, - }); - }); + // Then we expect to navigate to the chat report + expect(chatReport?.reportID).not.toBeUndefined(); - expect(report?.reportID).toBeFalsy(); + if (chatReport?.reportID) { + expect(navigateToAfterDelete).toEqual(ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID)); + } }); + }); - it('delete the transaction thread if there are only changelogs (i.e. MODIFIED_EXPENSE actions) in the thread', async () => { - // Given all promises are resolved - await waitForBatchedUpdates(); - jest.advanceTimersByTime(10); + describe('bulk deleteMoneyRequest', () => { + it('update IOU report total properly for bulk deletion of expenses', async () => { + const expenseReport: Report = { + ...createRandomReport(11, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + total: 30, + currency: CONST.CURRENCY.USD, + unheldTotal: 20, + unheldNonReimbursableTotal: 20, + }; + const transaction1: Transaction = { + ...createRandomTransaction(1), + amount: 10, + comment: {hold: '123'}, + currency: CONST.CURRENCY.USD, + reportID: expenseReport.reportID, + reimbursable: true, + }; + const moneyRequestAction1: ReportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: '1', + originalMessage: { + IOUReportID: expenseReport.reportID, + amount: transaction1.amount, + currency: transaction1.currency, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + message: undefined, + previousMessage: undefined, + }; + const transaction2: Transaction = {...createRandomTransaction(2), amount: 10, currency: CONST.CURRENCY.USD, reportID: expenseReport.reportID, reimbursable: false}; + const moneyRequestAction2: ReportAction = { + ...createRandomReportAction(2), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: '2', + originalMessage: { + IOUReportID: expenseReport.reportID, + amount: transaction2.amount, + currency: transaction2.currency, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + message: undefined, + previousMessage: undefined, + }; + const transaction3: Transaction = {...createRandomTransaction(3), amount: 10, currency: CONST.CURRENCY.USD, reportID: expenseReport.reportID, reimbursable: false}; - // Given a transaction thread - thread = buildTransactionThread(createIOUAction, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, transaction1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`, transaction2); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction3.transactionID}`, transaction3); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, - callback: (val) => (reportActions = val), + const selectedTransactionIDs = [transaction1.transactionID, transaction2.transactionID]; + deleteMoneyRequest({ + transactionID: transaction1.transactionID, + reportAction: moneyRequestAction1, + transactions: {}, + violations: {}, + iouReport: expenseReport, + chatReport: expenseReport, + transactionIDsPendingDeletion: [], + selectedTransactionIDs, + allTransactionViolationsParam: {}, + }); + deleteMoneyRequest({ + transactionID: transaction2.transactionID, + reportAction: moneyRequestAction2, + transactions: {}, + violations: {}, + iouReport: expenseReport, + chatReport: expenseReport, + transactionIDsPendingDeletion: [transaction1.transactionID], + selectedTransactionIDs, + allTransactionViolationsParam: {}, }); await waitForBatchedUpdates(); - jest.advanceTimersByTime(10); - - // Given User logins from the participant accounts - const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); - const userLogins = getLoginsByAccountIDs(participantAccountIDs); - - // When Opening a thread report with the given details - openReport(thread.reportID, '', userLogins, thread, createIOUAction?.reportActionID); - await waitForBatchedUpdates(); - - // Then The iou action has the transaction report id as a child report ID - const allReportActions = await new Promise>((resolve) => { + const report = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (actions) => { + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, + callback: (val) => { Onyx.disconnect(connection); - resolve(actions); + resolve(val); }, }); }); - 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); + expect(report?.total).toBe(10); + expect(report?.unheldTotal).toBe(10); + expect(report?.unheldNonReimbursableTotal).toBe(10); + }); + }); - await waitForBatchedUpdates(); + describe('deleteMoneyRequest with allTransactionViolationsParam', () => { + it('should pass transaction violations to hasOutstandingChildRequest correctly', async () => { + // Given an expense report with a transaction + const expenseReport: Report = { + ...createRandomReport(20, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + total: 100, + currency: CONST.CURRENCY.USD, + }; - jest.advanceTimersByTime(10); - if (transaction && createIOUAction) { - updateMoneyRequestAmountAndCurrency({ - transactionID: transaction.transactionID, - transactions: {}, - transactionThreadReport: thread, - parentReport: iouReport, - transactionViolations: {}, - amount: 20000, - currency: CONST.CURRENCY.USD, - taxAmount: 0, - taxCode: '', - policy: { - id: '123', - role: CONST.POLICY.ROLE.USER, - type: CONST.POLICY.TYPE.TEAM, - name: '', - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }, - policyTagList: {}, - policyCategories: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - policyRecentlyUsedCurrencies: [], - }); - } - await waitForBatchedUpdates(); + const transaction1: Transaction = { + ...createRandomTransaction(20), + amount: 100, + currency: CONST.CURRENCY.USD, + reportID: expenseReport.reportID, + reimbursable: true, + }; - // Verify there are two actions (created + changelog) - expect(Object.values(reportActions ?? {}).length).toBe(2); + const moneyRequestAction1: ReportAction = { + ...createRandomReportAction(20), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: '20', + originalMessage: { + IOUReportID: expenseReport.reportID, + amount: transaction1.amount, + currency: transaction1.currency, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + message: undefined, + previousMessage: undefined, + }; - // Fetch the updated IOU Action from Onyx - 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(); + // When we set up the transaction and report in Onyx + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, transaction1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + + // And we call deleteMoneyRequest with transaction violations + const transactionViolations = { + [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction1.transactionID}`]: [ + { + name: CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE, + type: CONST.VIOLATION_TYPES.VIOLATION, }, - }); + ], + }; + + deleteMoneyRequest({ + transactionID: transaction1.transactionID, + reportAction: moneyRequestAction1, + transactions: {}, + violations: {}, + iouReport: expenseReport, + chatReport: expenseReport, + allTransactionViolationsParam: transactionViolations, }); - if (transaction && createIOUAction) { - // When Deleting an expense - deleteMoneyRequest({ - transactionID: transaction?.transactionID, - reportAction: createIOUAction, - transactions: {}, - violations: {}, - iouReport, - chatReport, - allTransactionViolationsParam: {}, - }); - } await waitForBatchedUpdates(); - // Then, the report for the given thread ID does not exist - const report = await new Promise>((resolve) => { + // Then the transaction should be deleted + const deletedTransaction = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - waitForCollectionCallback: false, - callback: (reportData) => { + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, + callback: (val) => { Onyx.disconnect(connection); - resolve(reportData); + resolve(val); }, }); }); - expect(report?.reportID).toBeFalsy(); + expect(deletedTransaction).toBeUndefined(); }); - it('does not delete the transaction thread if there are visible comments in the thread', async () => { - // Given initial environment is set up - await waitForBatchedUpdates(); - - // Given a transaction thread - thread = buildTransactionThread(createIOUAction, iouReport); + it('should handle empty transaction violations correctly', async () => { + // Given an expense report with a transaction + const expenseReport: Report = { + ...createRandomReport(21, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + total: 50, + currency: CONST.CURRENCY.USD, + }; - expect(thread.participants).toEqual({[CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, role: CONST.REPORT.ROLE.ADMIN}}); + const transaction1: Transaction = { + ...createRandomTransaction(21), + amount: 50, + currency: CONST.CURRENCY.USD, + reportID: expenseReport.reportID, + reimbursable: true, + }; - const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); - const userLogins = getLoginsByAccountIDs(participantAccountIDs); - jest.advanceTimersByTime(10); - openReport(thread.reportID, '', userLogins, thread, createIOUAction?.reportActionID); - await waitForBatchedUpdates(); + const moneyRequestAction1: ReportAction = { + ...createRandomReportAction(21), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: '21', + originalMessage: { + IOUReportID: expenseReport.reportID, + amount: transaction1.amount, + currency: transaction1.currency, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + message: undefined, + previousMessage: undefined, + }; - Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${thread.reportID}`, - callback: (val) => (reportActions = val), - }); - await waitForBatchedUpdates(); + // When we set up the transaction and report in Onyx + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, transaction1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - callback: (report) => { - Onyx.disconnect(connection); - expect(report).toBeTruthy(); - resolve(); - }, - }); + // And we call deleteMoneyRequest with empty transaction violations + deleteMoneyRequest({ + transactionID: transaction1.transactionID, + reportAction: moneyRequestAction1, + transactions: {}, + violations: {}, + iouReport: expenseReport, + chatReport: expenseReport, + allTransactionViolationsParam: {}, }); - jest.advanceTimersByTime(10); - - // When a comment is added - addComment(thread, thread.reportID, [], 'Testing a comment', CONST.DEFAULT_TIME_ZONE); - 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?.(); - - if (transaction && createIOUAction) { - // When deleting expense - deleteMoneyRequest({ - transactionID: transaction?.transactionID, - reportAction: createIOUAction, - transactions: {}, - violations: {}, - iouReport, - chatReport, - allTransactionViolationsParam: {}, - }); - } await waitForBatchedUpdates(); - // Then the transaction thread report should still exist - await new Promise((resolve) => { + // Then the transaction should be deleted + const deletedTransaction = await new Promise>((resolve) => { const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - waitForCollectionCallback: false, - callback: (report) => { + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, + callback: (val) => { Onyx.disconnect(connection); - expect(report).toBeTruthy(); - resolve(); + resolve(val); }, }); }); - // When fetch resumes - // Then the transaction thread report should still exist - mockFetch?.resume?.(); - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${thread.reportID}`, - callback: (report) => { - Onyx.disconnect(connection); - expect(report).toBeTruthy(); - resolve(); - }, - }); - }); + expect(deletedTransaction).toBeUndefined(); }); + }); - it('update the moneyRequestPreview to show [Deleted expense] when appropriate', async () => { - await waitForBatchedUpdates(); - - // Given a thread report + describe('submitReport', () => { + it('correctly submits a report', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + return waitForBatchedUpdates() + .then(() => { + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); - 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(thread.reportID, '', userLogins, thread, 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); + // Change the approval mode for the policy since default is Submit and Close + setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - await waitForBatchedUpdates(); + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + Onyx.merge(`report_${expenseReport?.reportID}`, { + statusNum: 0, + stateNum: 0, + }); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - // Given an added comment to the thread report + // Verify report is a draft + expect(expenseReport?.stateNum).toBe(0); + expect(expenseReport?.statusNum).toBe(0); + resolve(); + }, + }); + }), + ) + .then(async () => { + if (expenseReport) { + const nextStep = await getOnyxValue(`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`); + submitReport(expenseReport, {} as Policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, nextStep); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + // Report was submitted correctly + expect(expenseReport?.stateNum).toBe(1); + expect(expenseReport?.statusNum).toBe(1); + resolve(); + }, + }); + }), + ); + }); + it('merges policyRecentlyUsedCurrencies into recently used currencies', () => { + const amount = 10000; + const comment = 'Test expense'; + const merchant = 'Test Merchant'; + const initialCurrencies = [CONST.CURRENCY.USD, CONST.CURRENCY.EUR]; + let chatReport: OnyxEntry; - jest.advanceTimersByTime(10); + return waitForBatchedUpdates() + .then(() => { + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); - addComment(thread, thread.reportID, [], 'Testing a comment', CONST.DEFAULT_TIME_ZONE); - await waitForBatchedUpdates(); + setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - // 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(); - }, + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.GBP, + created: '', + merchant, + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: initialCurrencies, + quickAction: undefined, + }); + } + return waitForBatchedUpdates(); + }) + .then(async () => { + const recentlyUsedCurrencies = await getOnyxValue(ONYXKEYS.RECENTLY_USED_CURRENCIES); + expect(recentlyUsedCurrencies).toEqual([CONST.CURRENCY.GBP, ...initialCurrencies]); }); - }); - - 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(); - - 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 + }); + it('correctly submits a report with Submit and Close approval mode', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let policy: OnyxEntry; - Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${IOU_REPORT_ID}`, - callback: (val) => (reportActions = val), - }); - await waitForBatchedUpdates(); - - jest.advanceTimersByTime(10); - - if (IOU_REPORT_ID) { - addComment(IOU_REPORT, IOU_REPORT_ID, [], 'Testing a comment', CONST.DEFAULT_TIME_ZONE); - } - await waitForBatchedUpdates(); - - 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; + return waitForBatchedUpdates() + .then(() => { + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID: undefined, + engagementChoice: CONST.ONBOARDING_CHOICES.CHAT_SPLIT, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + reimbursable: true, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (allPolicies) => { + Onyx.disconnect(connection); + policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.name === "Carlos's Workspace"); + expect(policy).toBeTruthy(); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - // Verify that our action is no longer in the loading state - expect(resultActionAfterUpdate?.pendingAction).toBeUndefined(); + Onyx.merge(`report_${expenseReport?.reportID}`, { + statusNum: 0, + stateNum: 0, + }); + resolve(); - mockFetch?.pause?.(); - if (transaction && createIOUAction) { - // When we delete the expense - deleteMoneyRequest({ - transactionID: transaction.transactionID, - reportAction: createIOUAction, - transactions: {}, - violations: {}, - iouReport, - chatReport, - isChatIOUReportArchived: undefined, - allTransactionViolationsParam: {}, - }); - } - await waitForBatchedUpdates(); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(true); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - // Then we expect the moneyRequestPreview to show [Deleted expense] + resolve(); + // Verify report is a draft + expect(expenseReport?.stateNum).toBe(0); + expect(expenseReport?.statusNum).toBe(0); - 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(); - }, - }); - }); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(false); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); + }, + }); + }), + ) + .then(async () => { + if (expenseReport) { + const nextStep = await getOnyxValue(`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`); + submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, nextStep); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - // When we resume fetch - mockFetch?.resume?.(); + resolve(); + // Report is closed since the default policy settings is Submit and Close + expect(expenseReport?.stateNum).toBe(2); + expect(expenseReport?.statusNum).toBe(2); - // Then we expect the moneyRequestPreview to show [Deleted expense] + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(true); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); + }, + }); + }), + ) + .then(() => { + if (policy) { + const reportToArchive = []; + if (expenseReport) { + reportToArchive.push(expenseReport); + } + if (chatReport) { + reportToArchive.push(chatReport); + } + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + deleteWorkspace({ + policyID: policy.id, + personalPolicyID: undefined, + activePolicyID: undefined, + policyName: policy.name, + lastAccessedWorkspacePolicyID: undefined, + policyCardFeeds: undefined, + reportsToArchive: reportToArchive, + transactionViolations: undefined, + reimbursementAccountError: undefined, + bankAccountList: {}, + lastUsedPaymentMethods: undefined, + localeCompare, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - 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(); - }, - }); - }); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(false); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); + resolve(); + }, + }); + }), + ); }); + it('correctly implements error handling', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let policy: OnyxEntry; - 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: [], - quickAction: undefined, - }); - } - - 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: {}, - }); - } - 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: [], - quickAction: undefined, - }); - 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}}); - - jest.advanceTimersByTime(10); - const participantAccountIDs = Object.keys(thread.participants ?? {}).map(Number); - const userLogins = getLoginsByAccountIDs(participantAccountIDs); - openReport(thread.reportID, '', userLogins, thread, 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); - - // When we delete the expense, we should not delete the IOU report - mockFetch?.pause?.(); - - let navigateToAfterDelete; - if (transaction && createIOUAction) { - navigateToAfterDelete = deleteMoneyRequest({ - transactionID: transaction.transactionID, - reportAction: createIOUAction, - transactions: {}, - violations: {}, - iouReport, - chatReport, - isSingleTransactionView: true, - allTransactionViolationsParam: {}, - }); - } - - let allReports = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - Onyx.disconnect(connection); - resolve(reports); - }, - }); - }); - - iouReport = Object.values(allReports ?? {}).find((report) => isIOUReport(report)); - expect(iouReport).toBeTruthy(); - expect(iouReport).toHaveProperty('reportID'); - expect(iouReport).toHaveProperty('chatReportID'); - - await mockFetch?.resume?.(); - - allReports = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (reports) => { - Onyx.disconnect(connection); - 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: {}, - }); - } - // 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)); - } - }); - }); - - describe('bulk deleteMoneyRequest', () => { - it('update IOU report total properly for bulk deletion of expenses', async () => { - const expenseReport: Report = { - ...createRandomReport(11, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - total: 30, - currency: CONST.CURRENCY.USD, - unheldTotal: 20, - unheldNonReimbursableTotal: 20, - }; - const transaction1: Transaction = { - ...createRandomTransaction(1), - amount: 10, - comment: {hold: '123'}, - currency: CONST.CURRENCY.USD, - reportID: expenseReport.reportID, - reimbursable: true, - }; - const moneyRequestAction1: ReportAction = { - ...createRandomReportAction(1), - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - childReportID: '1', - originalMessage: { - IOUReportID: expenseReport.reportID, - amount: transaction1.amount, - currency: transaction1.currency, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - }, - message: undefined, - previousMessage: undefined, - }; - const transaction2: Transaction = {...createRandomTransaction(2), amount: 10, currency: CONST.CURRENCY.USD, reportID: expenseReport.reportID, reimbursable: false}; - const moneyRequestAction2: ReportAction = { - ...createRandomReportAction(2), - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - childReportID: '2', - originalMessage: { - IOUReportID: expenseReport.reportID, - amount: transaction2.amount, - currency: transaction2.currency, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - }, - message: undefined, - previousMessage: undefined, - }; - const transaction3: Transaction = {...createRandomTransaction(3), amount: 10, currency: CONST.CURRENCY.USD, reportID: expenseReport.reportID, reimbursable: false}; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, transaction1); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction2.transactionID}`, transaction2); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction3.transactionID}`, transaction3); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - - const selectedTransactionIDs = [transaction1.transactionID, transaction2.transactionID]; - deleteMoneyRequest({ - transactionID: transaction1.transactionID, - reportAction: moneyRequestAction1, - transactions: {}, - violations: {}, - iouReport: expenseReport, - chatReport: expenseReport, - transactionIDsPendingDeletion: [], - selectedTransactionIDs, - allTransactionViolationsParam: {}, - }); - deleteMoneyRequest({ - transactionID: transaction2.transactionID, - reportAction: moneyRequestAction2, - transactions: {}, - violations: {}, - iouReport: expenseReport, - chatReport: expenseReport, - transactionIDsPendingDeletion: [transaction1.transactionID], - selectedTransactionIDs, - allTransactionViolationsParam: {}, - }); - - await waitForBatchedUpdates(); - - const report = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, - callback: (val) => { - Onyx.disconnect(connection); - resolve(val); - }, - }); - }); - - expect(report?.total).toBe(10); - expect(report?.unheldTotal).toBe(10); - expect(report?.unheldNonReimbursableTotal).toBe(10); - }); - }); - - describe('deleteMoneyRequest with allTransactionViolationsParam', () => { - it('should pass transaction violations to hasOutstandingChildRequest correctly', async () => { - // Given an expense report with a transaction - const expenseReport: Report = { - ...createRandomReport(20, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - total: 100, - currency: CONST.CURRENCY.USD, - }; - - const transaction1: Transaction = { - ...createRandomTransaction(20), - amount: 100, - currency: CONST.CURRENCY.USD, - reportID: expenseReport.reportID, - reimbursable: true, - }; - - const moneyRequestAction1: ReportAction = { - ...createRandomReportAction(20), - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - childReportID: '20', - originalMessage: { - IOUReportID: expenseReport.reportID, - amount: transaction1.amount, - currency: transaction1.currency, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - }, - message: undefined, - previousMessage: undefined, - }; - - // When we set up the transaction and report in Onyx - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, transaction1); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - - // And we call deleteMoneyRequest with transaction violations - const transactionViolations = { - [`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction1.transactionID}`]: [ - { - name: CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE, - type: CONST.VIOLATION_TYPES.VIOLATION, - }, - ], - }; - - deleteMoneyRequest({ - transactionID: transaction1.transactionID, - reportAction: moneyRequestAction1, - transactions: {}, - violations: {}, - iouReport: expenseReport, - chatReport: expenseReport, - allTransactionViolationsParam: transactionViolations, - }); - - await waitForBatchedUpdates(); - - // Then the transaction should be deleted - const deletedTransaction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, - callback: (val) => { - Onyx.disconnect(connection); - resolve(val); - }, - }); - }); - - expect(deletedTransaction).toBeUndefined(); - }); - - it('should handle empty transaction violations correctly', async () => { - // Given an expense report with a transaction - const expenseReport: Report = { - ...createRandomReport(21, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - total: 50, - currency: CONST.CURRENCY.USD, - }; - - const transaction1: Transaction = { - ...createRandomTransaction(21), - amount: 50, - currency: CONST.CURRENCY.USD, - reportID: expenseReport.reportID, - reimbursable: true, - }; - - const moneyRequestAction1: ReportAction = { - ...createRandomReportAction(21), - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - childReportID: '21', - originalMessage: { - IOUReportID: expenseReport.reportID, - amount: transaction1.amount, - currency: transaction1.currency, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - }, - message: undefined, - previousMessage: undefined, - }; - - // When we set up the transaction and report in Onyx - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, transaction1); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); - - // And we call deleteMoneyRequest with empty transaction violations - deleteMoneyRequest({ - transactionID: transaction1.transactionID, - reportAction: moneyRequestAction1, - transactions: {}, - violations: {}, - iouReport: expenseReport, - chatReport: expenseReport, - allTransactionViolationsParam: {}, - }); - - await waitForBatchedUpdates(); - - // Then the transaction should be deleted - const deletedTransaction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction1.transactionID}`, - callback: (val) => { - Onyx.disconnect(connection); - resolve(val); - }, - }); - }); - - expect(deletedTransaction).toBeUndefined(); - }); - }); - - describe('submitReport', () => { - it('correctly submits a report', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - return waitForBatchedUpdates() - .then(() => { - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); - - // Change the approval mode for the policy since default is Submit and Close - setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - - resolve(); - }, - }); - }), - ) - .then(() => { - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - Onyx.merge(`report_${expenseReport?.reportID}`, { - statusNum: 0, - stateNum: 0, - }); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - - // Verify report is a draft - expect(expenseReport?.stateNum).toBe(0); - expect(expenseReport?.statusNum).toBe(0); - resolve(); - }, - }); - }), - ) - .then(async () => { - if (expenseReport) { - const nextStep = await getOnyxValue(`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`); - submitReport(expenseReport, {} as Policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, nextStep); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - // Report was submitted correctly - expect(expenseReport?.stateNum).toBe(1); - expect(expenseReport?.statusNum).toBe(1); - resolve(); - }, - }); - }), - ); - }); - it('merges policyRecentlyUsedCurrencies into recently used currencies', () => { - const amount = 10000; - const comment = 'Test expense'; - const merchant = 'Test Merchant'; - const initialCurrencies = [CONST.CURRENCY.USD, CONST.CURRENCY.EUR]; - let chatReport: OnyxEntry; - - return waitForBatchedUpdates() - .then(() => { - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); - - setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - - resolve(); - }, - }); - }), - ) - .then(() => { - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.GBP, - created: '', - merchant, - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: initialCurrencies, - quickAction: undefined, - }); - } - return waitForBatchedUpdates(); - }) - .then(async () => { - const recentlyUsedCurrencies = await getOnyxValue(ONYXKEYS.RECENTLY_USED_CURRENCIES); - expect(recentlyUsedCurrencies).toEqual([CONST.CURRENCY.GBP, ...initialCurrencies]); - }); - }); - it('correctly submits a report with Submit and Close approval mode', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let policy: OnyxEntry; - - return waitForBatchedUpdates() - .then(() => { - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - policyID: undefined, - engagementChoice: CONST.ONBOARDING_CHOICES.CHAT_SPLIT, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - resolve(); - }, - }); - }), - ) - .then(() => { - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - reimbursable: true, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (allPolicies) => { - Onyx.disconnect(connection); - policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.name === "Carlos's Workspace"); - expect(policy).toBeTruthy(); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - - Onyx.merge(`report_${expenseReport?.reportID}`, { - statusNum: 0, - stateNum: 0, - }); - resolve(); - - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(true); - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - - resolve(); - // Verify report is a draft - expect(expenseReport?.stateNum).toBe(0); - expect(expenseReport?.statusNum).toBe(0); - - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(false); - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); - }, - }); - }), - ) - .then(async () => { - if (expenseReport) { - const nextStep = await getOnyxValue(`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`); - submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, nextStep); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - - resolve(); - // Report is closed since the default policy settings is Submit and Close - expect(expenseReport?.stateNum).toBe(2); - expect(expenseReport?.statusNum).toBe(2); - - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(true); - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); - }, - }); - }), - ) - .then(() => { - if (policy) { - const reportToArchive = []; - if (expenseReport) { - reportToArchive.push(expenseReport); - } - if (chatReport) { - reportToArchive.push(chatReport); - } - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - deleteWorkspace({ - policyID: policy.id, - personalPolicyID: undefined, - activePolicyID: undefined, - policyName: policy.name, - lastAccessedWorkspacePolicyID: undefined, - policyCardFeeds: undefined, - reportsToArchive: reportToArchive, - transactionViolations: undefined, - reimbursementAccountError: undefined, - bankAccountList: {}, - lastUsedPaymentMethods: undefined, - localeCompare, - }); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(false); - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); - resolve(); - }, - }); - }), - ); - }); - it('correctly implements error handling', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let policy: OnyxEntry; - - return waitForBatchedUpdates() - .then(() => { - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - policyID: undefined, - engagementChoice: CONST.ONBOARDING_CHOICES.CHAT_SPLIT, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - resolve(); - }, - }); - }), - ) - .then(() => { - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - reimbursable: true, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (allPolicies) => { - Onyx.disconnect(connection); - policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.name === "Carlos's Workspace"); - expect(policy).toBeTruthy(); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - - Onyx.merge(`report_${expenseReport?.reportID}`, { - statusNum: 0, - stateNum: 0, - }); - - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(true); - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - - // Verify report is a draft - expect(expenseReport?.stateNum).toBe(0); - expect(expenseReport?.statusNum).toBe(0); - resolve(); - }, - }); - }), - ) - .then(async () => { - mockFetch?.fail?.(); - if (expenseReport) { - const nextStep = await getOnyxValue(`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`); - submitReport(expenseReport, {} as Policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, nextStep); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - - // Report was submitted with some fail - expect(expenseReport?.stateNum).toBe(0); - expect(expenseReport?.statusNum).toBe(0); - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(false); - expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); - resolve(); - }, - }); - }), - ); - }); - - it('should not set stateNum, statusNum, or nextStep optimistically when submitting with Dynamic External Workflow policy', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let policy: OnyxEntry; - let nextStepBeforeSubmit: Report['nextStep']; - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: 'Test Workspace with Dynamic External Workflow', - policyID, - introSelected: undefined, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); - return waitForBatchedUpdates() - .then(() => { - setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (allPolicies) => { - Onyx.disconnect(connection); - policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.id === policyID); - expect(policy).toBeTruthy(); - expect(policy?.approvalMode).toBe(CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - chatReport = Object.values(allReports ?? {}).find( - (report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && report.policyID === policyID, - ); - resolve(); - }, - }); - }), - ) - .then(() => { - if (chatReport) { - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - reimbursable: true, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - Onyx.merge(`report_${expenseReport?.reportID}`, { - statusNum: 0, - stateNum: 0, - }); - resolve(); - }, - }); - }), - ) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - - expect(expenseReport?.stateNum).toBe(0); - expect(expenseReport?.statusNum).toBe(0); - nextStepBeforeSubmit = expenseReport?.nextStep; - resolve(); - }, - }); - }), - ) - .then(() => { - if (expenseReport) { - submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, undefined); - } - return waitForBatchedUpdates(); - }) - .then( - () => - new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connection); - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - - expect(expenseReport?.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); - expect(expenseReport?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); - expect(expenseReport?.nextStep).toEqual(nextStepBeforeSubmit); - expect(expenseReport?.pendingFields?.nextStep).toBeUndefined(); - - resolve(); - }, - }); - }), - ); - }); - }); - - describe('canIOUBePaid', () => { - it('For invoices from archived workspaces', async () => { - const {policy, convertedInvoiceChat: chatReport}: InvoiceTestData = InvoiceData; - - const chatReportRNVP: ReportNameValuePairs = {private_isArchived: DateUtils.getDBTime()}; - - const invoiceReceiver = chatReport?.invoiceReceiver as {type: string; policyID: string; accountID: number}; - - const iouReport = {...createRandomReport(1, undefined), type: CONST.REPORT.TYPE.INVOICE, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiver.policyID}`, {id: invoiceReceiver.policyID, role: CONST.POLICY.ROLE.ADMIN}); - - expect(canIOUBePaid(iouReport, chatReport, policy, {}, [], true)).toBe(true); - expect(canIOUBePaid(iouReport, chatReport, policy, {}, [], false)).toBe(true); - - // When the invoice is archived - expect(canIOUBePaid(iouReport, chatReport, policy, {}, [], true, chatReportRNVP)).toBe(false); - expect(canIOUBePaid(iouReport, chatReport, policy, {}, [], false, chatReportRNVP)).toBe(false); - }); - }); - - describe('setMoneyRequestCategory', () => { - it('should set the associated tax for the category based on the tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount: 0, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - }); - - describe('should not change the tax', () => { - it('if the transaction type is distance', async () => { - // Given a policy with tax expense rules associated with category and a distance transaction - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting the money request category - setMoneyRequestCategory(transactionID, category, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - }); - - it('should clear the tax when the policyID is empty', async () => { - // Given a transaction with a tax - const transactionID = '1'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - }); - - // When setting the money request category without a policyID - setMoneyRequestCategory(transactionID, '', undefined); - await waitForBatchedUpdates(); - - // Then the transaction tax should be cleared - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(''); - expect(transaction?.taxAmount).toBeUndefined(); - resolve(); - }, - }); - }); - }); - }); - - describe('updateMoneyRequestCategory', () => { - it('should update the tax when there are tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const policyID = '2'; - const transactionThreadReportID = '3'; - const transactionThreadReport = {reportID: transactionThreadReportID}; - const category = 'Advertising'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - taxCode, - taxAmount: 0, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); - - // When updating a money request category - updateMoneyRequestCategory({ - transactionID, - transactionThreadReport, - parentReport: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - }); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - - // But the original message should only contains the old and new category data - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - callback: (reportActions) => { - Onyx.disconnect(connection); - const reportAction = Object.values(reportActions ?? {}).at(0); - if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE)) { - const originalMessage = getOriginalMessage(reportAction); - expect(originalMessage?.oldCategory).toBe(''); - expect(originalMessage?.category).toBe(category); - expect(originalMessage?.oldTaxRate).toBeUndefined(); - expect(originalMessage?.oldTaxAmount).toBeUndefined(); - resolve(); - } - }, - }); - }); - }); - - describe('should not update the tax', () => { - it('if the transaction type is distance', async () => { - // Given a policy with tax expense rules associated with category and a distance transaction - const transactionID = '1'; - const policyID = '2'; - const category = 'Advertising'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - taxCode, - taxAmount, - amount: 100, - comment: { - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - customUnit: { - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - }, - }, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When updating a money request category - updateMoneyRequestCategory({ - transactionID, - transactionThreadReport: {reportID: '3'}, - parentReport: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - }); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const policyID = '2'; - const category = 'Advertising'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {amount: 100}); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When updating the money request category - updateMoneyRequestCategory({ - transactionID, - transactionThreadReport: {reportID: '3'}, - parentReport: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - }); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBeUndefined(); - expect(transaction?.taxAmount).toBeUndefined(); - resolve(); - }, - }); - }); - }); - }); - - it('should remove all existing category violations when the transaction Category is unset', async () => { - const transactionID = '1'; - const policyID = '2'; - const transactionThreadReportID = '3'; - const transactionThreadReport = {reportID: transactionThreadReportID}; - const category = ''; - const fakePolicy: Policy = { - ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), - requiresCategory: true, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - amount: 100, - transactionID, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [ - { - type: CONST.VIOLATION_TYPES.VIOLATION, - name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, - data: {}, - showInReview: true, - }, - ]); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); - - // When updating a money request category - updateMoneyRequestCategory({ - transactionID, - transactionThreadReport, - parentReport: undefined, - category, - policy: fakePolicy, - policyTagList: undefined, - policyCategories: undefined, - policyRecentlyUsedCategories: [], - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - }); - - await waitForBatchedUpdates(); - - // Any existing category violations will be removed, leaving only the MISSING_CATEGORY violation in the end - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, - callback: (transactionViolations) => { - Onyx.disconnect(connection); - expect(transactionViolations).toHaveLength(1); - expect(transactionViolations?.at(0)?.name).toEqual(CONST.VIOLATIONS.MISSING_CATEGORY); - resolve(); - }, - }); - }); - }); - }); - - describe('setDraftSplitTransaction', () => { - it('should set the associated tax for the category based on the tax expense rules', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - const draftTransaction: Transaction = { - ...createRandomTransaction(1), - taxCode, - taxAmount: 0, - amount: 100, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting a category of a draft split transaction - setDraftSplitTransaction(transactionID, draftTransaction, {category}, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount should be updated based on the expense rules - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(ruleTaxCode); - expect(transaction?.taxAmount).toBe(5); - resolve(); - }, - }); - }); - }); - - describe('should not change the tax', () => { - it('if there are no tax expense rules', async () => { - // Given a policy without tax expense rules - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const taxCode = 'id_TAX_EXEMPT'; - const taxAmount = 0; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {}, - }; - const draftTransaction: Transaction = { - ...createRandomTransaction(1), - taxCode, - taxAmount, - amount: 100, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting a category of a draft split transaction - setDraftSplitTransaction(transactionID, draftTransaction, {category}, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBe(taxCode); - expect(transaction?.taxAmount).toBe(taxAmount); - resolve(); - }, - }); - }); - }); - - it('if we are not updating category', async () => { - // Given a policy with tax expense rules associated with category - const transactionID = '1'; - const category = 'Advertising'; - const policyID = '2'; - const ruleTaxCode = 'id_TAX_RATE_1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, - }; - const draftTransaction: Transaction = { - ...createRandomTransaction(1), - amount: 100, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - - // When setting a draft split transaction without category update - setDraftSplitTransaction(transactionID, draftTransaction, {}, fakePolicy); - - await waitForBatchedUpdates(); - - // Then the transaction tax rate and amount shouldn't be updated - await new Promise((resolve) => { - const connection = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, - callback: (transaction) => { - Onyx.disconnect(connection); - expect(transaction?.taxCode).toBeUndefined(); - expect(transaction?.taxAmount).toBeUndefined(); - resolve(); - }, - }); - }); - }); - }); - }); - - describe('should have valid parameters', () => { - let writeSpy: jest.SpyInstance; - const isValid = (value: unknown) => !value || typeof value !== 'object' || value instanceof Blob; - - beforeEach(() => { - // eslint-disable-next-line rulesdir/no-multiple-api-calls - writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - }); - - afterEach(() => { - writeSpy.mockRestore(); - }); - - test.each([ - [WRITE_COMMANDS.REQUEST_MONEY, CONST.IOU.ACTION.CREATE], - [WRITE_COMMANDS.CONVERT_TRACKED_EXPENSE_TO_REQUEST, CONST.IOU.ACTION.SUBMIT], - ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { - // When an expense is created - requestMoney({ - action, - report: {reportID: ''}, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount: 10000, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'KFC', - comment: '', - linkedTrackedExpenseReportAction: { - reportActionID: '', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - created: '2024-10-30', - }, - actionableWhisperReportActionID: '1', - linkedTrackedExpenseReportID: '1', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - - await waitForBatchedUpdates(); - - // Then the correct API request should be made - expect(writeSpy).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const [command, params] = writeSpy.mock.calls.at(0); - expect(command).toBe(expectedCommand); - - // And the parameters should be supported by XMLHttpRequest - for (const value of Object.values(params as Record)) { - expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); - } - }); - - test.each([ - [WRITE_COMMANDS.TRACK_EXPENSE, CONST.IOU.ACTION.CREATE], - [WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, CONST.IOU.ACTION.CATEGORIZE], - [WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, CONST.IOU.ACTION.SHARE], - ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { - // When a track expense is created - trackExpense({ - report: {reportID: '123', policyID: 'A'}, - isDraftPolicy: false, - action, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount: 10000, - currency: CONST.CURRENCY.USD, - created: '2024-10-30', - merchant: 'KFC', - receipt: {}, - actionableWhisperReportActionID: '1', - linkedTrackedExpenseReportAction: { - reportActionID: '', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - created: '2024-10-30', - }, - linkedTrackedExpenseReportID: '1', - }, - accountantParams: action === CONST.IOU.ACTION.SHARE ? {accountant: {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL}} : undefined, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - }); - - await waitForBatchedUpdates(); - - // Then the correct API request should be made - expect(writeSpy).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const [command, params] = writeSpy.mock.calls.at(0); - expect(command).toBe(expectedCommand); - - if (expectedCommand === WRITE_COMMANDS.SHARE_TRACKED_EXPENSE) { - expect(params).toHaveProperty('policyName'); - } - - // And the parameters should be supported by XMLHttpRequest - for (const value of Object.values(params as Record)) { - expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); - } - }); - }); - - describe('canApproveIOU', () => { - it('should return false if we have only pending card transactions', async () => { - const policyID = '2'; - const reportID = '1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - }; - const fakeReport: Report = { - ...createRandomReport(Number(reportID), undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID, - }; - const fakeTransaction1: Transaction = { - ...createRandomTransaction(0), - reportID, - bank: CONST.EXPENSIFY_CARD.BANK, - status: CONST.TRANSACTION.STATUS.PENDING, - }; - const fakeTransaction2: Transaction = { - ...createRandomTransaction(1), - reportID, - bank: CONST.EXPENSIFY_CARD.BANK, - status: CONST.TRANSACTION.STATUS.PENDING, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction1.transactionID}`, fakeTransaction1); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction2.transactionID}`, fakeTransaction2); - - await waitForBatchedUpdates(); - - expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); - // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data - const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); - await waitForBatchedUpdatesWithAct(); - expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeFalsy(); - }); - it('should return false if we have only scanning transactions', async () => { - const policyID = '2'; - const reportID = '1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - }; - const fakeReport: Report = { - ...createRandomReport(Number(reportID), undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction1: Transaction = { - ...createRandomTransaction(0), - reportID, - amount: 0, - modifiedAmount: 0, - receipt: { - source: 'test', - state: CONST.IOU.RECEIPT_STATE.SCANNING, - }, - merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - modifiedMerchant: undefined, - }; - const fakeTransaction2: Transaction = { - ...createRandomTransaction(1), - reportID, - amount: 0, - modifiedAmount: 0, - receipt: { - source: 'test', - state: CONST.IOU.RECEIPT_STATE.SCANNING, - }, - merchant: '', - modifiedMerchant: undefined, - }; - - await Onyx.set(ONYXKEYS.COLLECTION.REPORT, { - [`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`]: fakeReport, - }); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction1.transactionID}`, fakeTransaction1); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction2.transactionID}`, fakeTransaction2); - - await waitForBatchedUpdates(); - - expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); - // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data - const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); - await waitForBatchedUpdatesWithAct(); - expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeFalsy(); - }); - it('should return false if all transactions are pending card or scanning transaction', async () => { - const policyID = '2'; - const reportID = '1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - }; - const fakeReport: Report = { - ...createRandomReport(Number(reportID), undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction1: Transaction = { - ...createRandomTransaction(0), - reportID, - bank: CONST.EXPENSIFY_CARD.BANK, - status: CONST.TRANSACTION.STATUS.PENDING, - }; - const fakeTransaction2: Transaction = { - ...createRandomTransaction(1), - reportID, - amount: 0, - modifiedAmount: 0, - receipt: { - source: 'test', - state: CONST.IOU.RECEIPT_STATE.SCANNING, - }, - merchant: '', - modifiedMerchant: undefined, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction1.transactionID}`, fakeTransaction1); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction2.transactionID}`, fakeTransaction2); - - await waitForBatchedUpdates(); - - expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); - // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data - const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); - await waitForBatchedUpdatesWithAct(); - expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeFalsy(); - }); - it('should return true if at least one transaction is not pending card or scanning transaction', async () => { - const policyID = '2'; - const reportID = '1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - }; - const fakeReport: Report = { - ...createRandomReport(Number(reportID), undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID, - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction1: Transaction = { - ...createRandomTransaction(0), - reportID, - bank: CONST.EXPENSIFY_CARD.BANK, - status: CONST.TRANSACTION.STATUS.PENDING, - }; - const fakeTransaction2: Transaction = { - ...createRandomTransaction(1), - reportID, - amount: 0, - receipt: { - source: 'test', - state: CONST.IOU.RECEIPT_STATE.SCANNING, - }, - merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - modifiedMerchant: undefined, - }; - const fakeTransaction3: Transaction = { - ...createRandomTransaction(2), - reportID, - amount: 100, - status: CONST.TRANSACTION.STATUS.POSTED, - }; + return waitForBatchedUpdates() + .then(() => { + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID: undefined, + engagementChoice: CONST.ONBOARDING_CHOICES.CHAT_SPLIT, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + reimbursable: true, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (allPolicies) => { + Onyx.disconnect(connection); + policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.name === "Carlos's Workspace"); + expect(policy).toBeTruthy(); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction1.transactionID}`, fakeTransaction1); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction2.transactionID}`, fakeTransaction2); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction3.transactionID}`, fakeTransaction3); + Onyx.merge(`report_${expenseReport?.reportID}`, { + statusNum: 0, + stateNum: 0, + }); - await waitForBatchedUpdates(); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(true); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - expect(canApproveIOU(fakeReport, fakePolicy)).toBeTruthy(); - // Then should return true when passing transactions directly as the third parameter instead of relying on Onyx data - const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); - await waitForBatchedUpdatesWithAct(); - expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeTruthy(); + // Verify report is a draft + expect(expenseReport?.stateNum).toBe(0); + expect(expenseReport?.statusNum).toBe(0); + resolve(); + }, + }); + }), + ) + .then(async () => { + mockFetch?.fail?.(); + if (expenseReport) { + const nextStep = await getOnyxValue(`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`); + submitReport(expenseReport, {} as Policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, nextStep); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + + // Report was submitted with some fail + expect(expenseReport?.stateNum).toBe(0); + expect(expenseReport?.statusNum).toBe(0); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], true)).toBe(false); + expect(canIOUBePaid(expenseReport, chatReport, policy, {}, [], false)).toBe(false); + resolve(); + }, + }); + }), + ); }); - it('should return false if the report is closed', async () => { - // Given a closed report, a policy, and a transaction - const policyID = '2'; - const reportID = '1'; - const fakePolicy: Policy = { - ...createRandomPolicy(Number(policyID)), - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - }; - const fakeReport: Report = { - ...createRandomReport(Number(reportID), undefined), - type: CONST.REPORT.TYPE.EXPENSE, + it('should not set stateNum, statusNum, or nextStep optimistically when submitting with Dynamic External Workflow policy', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let policy: OnyxEntry; + let nextStepBeforeSubmit: Report['nextStep']; + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: 'Test Workspace with Dynamic External Workflow', policyID, - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.CLOSED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - }; - Onyx.multiSet({ - [ONYXKEYS.COLLECTION.REPORT]: fakeReport, - [ONYXKEYS.COLLECTION.TRANSACTION]: fakeTransaction, + introSelected: undefined, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, }); - await waitForBatchedUpdates(); - // Then, canApproveIOU should return false since the report is closed - expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); - // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data - expect(canApproveIOU(fakeReport, fakePolicy, [fakeTransaction])).toBeFalsy(); - }); - }); + return waitForBatchedUpdates() + .then(() => { + setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (allPolicies) => { + Onyx.disconnect(connection); + policy = Object.values(allPolicies ?? {}).find((p): p is OnyxEntry => p?.id === policyID); + expect(policy).toBeTruthy(); + expect(policy?.approvalMode).toBe(CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + chatReport = Object.values(allReports ?? {}).find( + (report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && report.policyID === policyID, + ); + resolve(); + }, + }); + }), + ) + .then(() => { + if (chatReport) { + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + reimbursable: true, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); + Onyx.merge(`report_${expenseReport?.reportID}`, { + statusNum: 0, + stateNum: 0, + }); + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - describe('canUnapproveIOU', () => { - it('should return false if the report is waiting for a bank account', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: 'A', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - isWaitingOnBankAccount: true, - managerID: RORY_ACCOUNT_ID, - }; - expect(canUnapproveIOU(fakeReport, undefined)).toBeFalsy(); - }); - }); + expect(expenseReport?.stateNum).toBe(0); + expect(expenseReport?.statusNum).toBe(0); + nextStepBeforeSubmit = expenseReport?.nextStep; + resolve(); + }, + }); + }), + ) + .then(() => { + if (expenseReport) { + submitReport(expenseReport, policy, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true, true, undefined); + } + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE && report?.policyID === policyID); - describe('canCancelPayment', () => { - it('should return true if the report is waiting for a bank account', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: 'A', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - isWaitingOnBankAccount: true, - managerID: RORY_ACCOUNT_ID, - }; - expect(canCancelPayment(fakeReport, {accountID: RORY_ACCOUNT_ID}, undefined)).toBeTruthy(); + expect(expenseReport?.stateNum).toBe(CONST.REPORT.STATE_NUM.OPEN); + expect(expenseReport?.statusNum).toBe(CONST.REPORT.STATUS_NUM.OPEN); + expect(expenseReport?.nextStep).toEqual(nextStepBeforeSubmit); + expect(expenseReport?.pendingFields?.nextStep).toBeUndefined(); + + resolve(); + }, + }); + }), + ); }); }); describe('canIOUBePaid', () => { - it('should return false if the report has negative total and onlyShowPayElsewhere is false', async () => { - const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - const fakePolicy: Policy = { - ...createRandomPolicy(Number('AA')), - id: 'AA', - type: CONST.POLICY.TYPE.TEAM, - approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, - reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - role: CONST.POLICY.ROLE.ADMIN, - }; + it('For invoices from archived workspaces', async () => { + const {policy, convertedInvoiceChat: chatReport}: InvoiceTestData = InvoiceData; - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: 'AA', - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, - ownerAccountID: CARLOS_ACCOUNT_ID, - managerID: RORY_ACCOUNT_ID, - isWaitingOnBankAccount: false, - total: 100, // positive amount in the DB means negative amount in the UI - }; + const chatReportRNVP: ReportNameValuePairs = {private_isArchived: DateUtils.getDBTime()}; - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + const invoiceReceiver = chatReport?.invoiceReceiver as {type: string; policyID: string; accountID: number}; - expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, [], false)).toBeFalsy(); - expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, [], true)).toBeTruthy(); + const iouReport = {...createRandomReport(1, undefined), type: CONST.REPORT.TYPE.INVOICE, statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED}; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiver.policyID}`, {id: invoiceReceiver.policyID, role: CONST.POLICY.ROLE.ADMIN}); + + expect(canIOUBePaid(iouReport, chatReport, policy, {}, [], true)).toBe(true); + expect(canIOUBePaid(iouReport, chatReport, policy, {}, [], false)).toBe(true); + + // When the invoice is archived + expect(canIOUBePaid(iouReport, chatReport, policy, {}, [], true, chatReportRNVP)).toBe(false); + expect(canIOUBePaid(iouReport, chatReport, policy, {}, [], false, chatReportRNVP)).toBe(false); }); }); - describe('calculateDiffAmount', () => { - it('should return 0 if iouReport is undefined', () => { - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - reportID: '1', - amount: 100, - currency: 'USD', + describe('setMoneyRequestCategory', () => { + it('should set the associated tax for the category based on the tax expense rules', async () => { + // Given a policy with tax expense rules associated with category + const transactionID = '1'; + const category = 'Advertising'; + const policyID = '2'; + const taxCode = 'id_TAX_EXEMPT'; + const ruleTaxCode = 'id_TAX_RATE_1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + taxCode, + taxAmount: 0, + amount: 100, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - expect(calculateDiffAmount(undefined, fakeTransaction, fakeTransaction)).toBe(0); + // When setting the money request category + setMoneyRequestCategory(transactionID, category, fakePolicy); + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount should be updated based on the expense rules + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(ruleTaxCode); + expect(transaction?.taxAmount).toBe(5); + resolve(); + }, + }); + }); }); - it('should return 0 when the currency and amount of the transactions are the same', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - reportID: fakeReport.reportID, - amount: 100, - currency: 'USD', - }; + describe('should not change the tax', () => { + it('if the transaction type is distance', async () => { + // Given a policy with tax expense rules associated with category and a distance transaction + const transactionID = '1'; + const category = 'Advertising'; + const policyID = '2'; + const taxCode = 'id_TAX_EXEMPT'; + const ruleTaxCode = 'id_TAX_RATE_1'; + const taxAmount = 0; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + taxCode, + taxAmount, + amount: 100, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - expect(calculateDiffAmount(fakeReport, fakeTransaction, fakeTransaction)).toBe(0); + // When setting the money request category + setMoneyRequestCategory(transactionID, category, fakePolicy); + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount shouldn't be updated + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(taxCode); + expect(transaction?.taxAmount).toBe(taxAmount); + resolve(); + }, + }); + }); + }); + + it('if there are no tax expense rules', async () => { + // Given a policy without tax expense rules + const transactionID = '1'; + const category = 'Advertising'; + const policyID = '2'; + const taxCode = 'id_TAX_EXEMPT'; + const taxAmount = 0; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + taxCode, + taxAmount, + amount: 100, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + + // When setting the money request category + setMoneyRequestCategory(transactionID, category, fakePolicy); + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount shouldn't be updated + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(taxCode); + expect(transaction?.taxAmount).toBe(taxAmount); + resolve(); + }, + }); + }); + }); }); - it('should return the difference between the updated amount and the current amount when the currency of the updated and current transactions have the same currency', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, - currency: 'USD', - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), + it('should clear the tax when the policyID is empty', async () => { + // Given a transaction with a tax + const transactionID = '1'; + const taxCode = 'id_TAX_EXEMPT'; + const taxAmount = 0; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { + taxCode, + taxAmount, amount: 100, - currency: 'USD', - }; - const updatedTransaction = { - ...fakeTransaction, - amount: 200, - currency: 'USD', - }; + }); - expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBe(-100); + // When setting the money request category without a policyID + setMoneyRequestCategory(transactionID, '', undefined); + await waitForBatchedUpdates(); + + // Then the transaction tax should be cleared + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(''); + expect(transaction?.taxAmount).toBeUndefined(); + resolve(); + }, + }); + }); }); + }); - it('should return null when the currency of the updated and current transactions have different values', () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, - }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - amount: 100, - currency: 'USD', - }; - const updatedTransaction = { - ...fakeTransaction, - amount: 200, - currency: 'EUR', + describe('updateMoneyRequestCategory', () => { + it('should update the tax when there are tax expense rules', async () => { + // Given a policy with tax expense rules associated with category + const transactionID = '1'; + const policyID = '2'; + const transactionThreadReportID = '3'; + const transactionThreadReport = {reportID: transactionThreadReportID}; + const category = 'Advertising'; + const taxCode = 'id_TAX_EXEMPT'; + const ruleTaxCode = 'id_TAX_RATE_1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + taxCode, + taxAmount: 0, + amount: 100, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); - expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBeNull(); - }); - }); + // When updating a money request category + updateMoneyRequestCategory({ + transactionID, + transactionThreadReport, + parentReport: undefined, + category, + policy: fakePolicy, + policyTagList: undefined, + policyCategories: undefined, + policyRecentlyUsedCategories: [], + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + }); - describe('initMoneyRequest', () => { - const fakeReport: Report = { - ...createRandomReport(0, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - managerID: CARLOS_ACCOUNT_ID, - }; - const fakePolicy: Policy = { - ...createRandomPolicy(1), - type: CONST.POLICY.TYPE.TEAM, - outputCurrency: 'USD', - }; + await waitForBatchedUpdates(); - const fakeParentReport: Report = { - ...createRandomReport(1, undefined), - reportID: fakeReport.reportID, - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - managerID: CARLOS_ACCOUNT_ID, - }; - const fakePersonalPolicy: Pick = { - id: '2', - autoReporting: true, - type: CONST.POLICY.TYPE.PERSONAL, - outputCurrency: 'NZD', - }; - const transactionResult: Transaction = { - amount: 0, - comment: { - attendees: [ - { - email: currentUserPersonalDetails.email ?? '', - login: currentUserPersonalDetails.login, - accountID: 3, - text: currentUserPersonalDetails.login, - selected: true, - reportID: '0', - avatarUrl: SafeString(currentUserPersonalDetails.avatar) ?? '', - displayName: currentUserPersonalDetails.displayName ?? '', + // Then the transaction tax rate and amount should be updated based on the expense rules + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(ruleTaxCode); + expect(transaction?.taxAmount).toBe(5); + resolve(); }, - ], - }, - created: '2025-04-01', - currency: 'USD', - iouRequestType: 'manual', - reportID: fakeReport.reportID, - transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, - isFromGlobalCreate: true, - merchant: '(none)', - }; + }); + }); - const currentDate = '2025-04-01'; - beforeEach(async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, null); - await Onyx.merge(`${ONYXKEYS.CURRENT_DATE}`, currentDate); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - return waitForBatchedUpdates(); + // But the original message should only contains the old and new category data + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + const reportAction = Object.values(reportActions ?? {}).at(0); + if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE)) { + const originalMessage = getOriginalMessage(reportAction); + expect(originalMessage?.oldCategory).toBe(''); + expect(originalMessage?.category).toBe(category); + expect(originalMessage?.oldTaxRate).toBeUndefined(); + expect(originalMessage?.oldTaxAmount).toBeUndefined(); + resolve(); + } + }, + }); + }); }); - it('should merge transaction draft onyx value', async () => { - await waitForBatchedUpdates() - .then(() => { - initMoneyRequest({ - reportID: fakeReport.reportID, - policy: fakePolicy, - personalPolicy: fakePersonalPolicy, - isFromGlobalCreate: true, - newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - report: fakeReport, - parentReport: fakeParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies: false, - }); - }) - .then(async () => { - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); + describe('should not update the tax', () => { + it('if the transaction type is distance', async () => { + // Given a policy with tax expense rules associated with category and a distance transaction + const transactionID = '1'; + const policyID = '2'; + const category = 'Advertising'; + const taxCode = 'id_TAX_EXEMPT'; + const taxAmount = 0; + const ruleTaxCode = 'id_TAX_RATE_1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + taxCode, + taxAmount, + amount: 100, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + }, + }, }); - }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - it('should modify transaction draft when currentIouRequestType is different', async () => { - await waitForBatchedUpdates() - .then(() => { - return initMoneyRequest({ - reportID: fakeReport.reportID, - policy: fakePolicy, - personalPolicy: fakePersonalPolicy, - isFromGlobalCreate: true, - currentIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - report: fakeReport, - parentReport: fakeParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies: false, - }); - }) - .then(async () => { - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual({ - ...transactionResult, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - }); + // When updating a money request category + updateMoneyRequestCategory({ + transactionID, + transactionThreadReport: {reportID: '3'}, + parentReport: undefined, + category, + policy: fakePolicy, + policyTagList: undefined, + policyCategories: undefined, + policyRecentlyUsedCategories: [], + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, }); - }); - it('should return personal currency when policy is missing', async () => { - await waitForBatchedUpdates() - .then(() => { - return initMoneyRequest({ - reportID: fakeReport.reportID, - personalPolicy: fakePersonalPolicy, - isFromGlobalCreate: true, - newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - report: fakeReport, - parentReport: fakeParentReport, - currentDate, - currentUserPersonalDetails, - hasOnlyPersonalPolicies: false, + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount shouldn't be updated + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(taxCode); + expect(transaction?.taxAmount).toBe(taxAmount); + resolve(); + }, }); - }) - .then(async () => { - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual({ - ...transactionResult, - currency: fakePersonalPolicy.outputCurrency, + }); + }); + + it('if there are no tax expense rules', async () => { + // Given a policy without tax expense rules + const transactionID = '1'; + const policyID = '2'; + const category = 'Advertising'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {}, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {amount: 100}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + + // When updating the money request category + updateMoneyRequestCategory({ + transactionID, + transactionThreadReport: {reportID: '3'}, + parentReport: undefined, + category, + policy: fakePolicy, + policyTagList: undefined, + policyCategories: undefined, + policyRecentlyUsedCategories: [], + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + }); + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount shouldn't be updated + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBeUndefined(); + expect(transaction?.taxAmount).toBeUndefined(); + resolve(); + }, }); }); + }); }); - }); - - describe('updateMoneyRequestAmountAndCurrency', () => { - it('update the amount of the money request successfully', async () => { - const initialCurrencies = [CONST.CURRENCY.EUR, CONST.CURRENCY.GBP]; - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, + it('should remove all existing category violations when the transaction Category is unset', async () => { + const transactionID = '1'; + const policyID = '2'; + const transactionThreadReportID = '3'; + const transactionThreadReport = {reportID: transactionThreadReportID}; + const category = ''; + const fakePolicy: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + requiresCategory: true, }; - const fakeTransaction: Transaction = { - ...createRandomTransaction(1), - reportID: fakeReport.reportID, + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { amount: 100, - currency: CONST.CURRENCY.USD, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); - - mockFetch?.pause?.(); + transactionID, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [ + { + type: CONST.VIOLATION_TYPES.VIOLATION, + name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, + data: {}, + showInReview: true, + }, + ]); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThreadReport); - updateMoneyRequestAmountAndCurrency({ - transactionID: fakeTransaction.transactionID, - transactionThreadReport: fakeReport, + // When updating a money request category + updateMoneyRequestCategory({ + transactionID, + transactionThreadReport, parentReport: undefined, - amount: 20000, - currency: CONST.CURRENCY.USD, - taxAmount: 0, - taxCode: '', - policy: { - id: '123', - role: CONST.POLICY.ROLE.USER, - type: CONST.POLICY.TYPE.TEAM, - name: '', - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }, - policyTagList: {}, - policyCategories: {}, - transactions: {}, - transactionViolations: {}, + category, + policy: fakePolicy, + policyTagList: undefined, + policyCategories: undefined, + policyRecentlyUsedCategories: [], currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', isASAPSubmitBetaEnabled: false, - policyRecentlyUsedCurrencies: initialCurrencies, }); await waitForBatchedUpdates(); - mockFetch?.succeed?.(); - await mockFetch?.resume?.(); - const updatedTransaction = await new Promise>((resolve) => { + // Any existing category violations will be removed, leaving only the MISSING_CATEGORY violation in the end + await new Promise((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + callback: (transactionViolations) => { Onyx.disconnect(connection); - const newTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`]; - resolve(newTransaction); + expect(transactionViolations).toHaveLength(1); + expect(transactionViolations?.at(0)?.name).toEqual(CONST.VIOLATIONS.MISSING_CATEGORY); + resolve(); }, }); }); - expect(updatedTransaction?.modifiedAmount).toBe(20000); - - const recentlyUsedCurrencies = await getOnyxValue(ONYXKEYS.RECENTLY_USED_CURRENCIES); - expect(recentlyUsedCurrencies).toEqual([CONST.CURRENCY.USD, ...initialCurrencies]); }); + }); - it('update the amount of the money request failed', async () => { - const fakeReport: Report = { - ...createRandomReport(1, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - policyID: '1', - stateNum: CONST.REPORT.STATE_NUM.APPROVED, - statusNum: CONST.REPORT.STATUS_NUM.APPROVED, - managerID: RORY_ACCOUNT_ID, + describe('setDraftSplitTransaction', () => { + it('should set the associated tax for the category based on the tax expense rules', async () => { + // Given a policy with tax expense rules associated with category + const transactionID = '1'; + const category = 'Advertising'; + const policyID = '2'; + const taxCode = 'id_TAX_EXEMPT'; + const ruleTaxCode = 'id_TAX_RATE_1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, }; - const fakeTransaction: Transaction = { + const draftTransaction: Transaction = { ...createRandomTransaction(1), - reportID: fakeReport.reportID, + taxCode, + taxAmount: 0, amount: 100, - currency: 'USD', }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); - - mockFetch?.pause?.(); - - updateMoneyRequestAmountAndCurrency({ - transactionID: fakeTransaction.transactionID, - transactionThreadReport: fakeReport, - parentReport: undefined, - amount: 20000, - currency: CONST.CURRENCY.USD, - taxAmount: 0, - taxCode: '', - policy: { - id: '123', - role: CONST.POLICY.ROLE.USER, - type: CONST.POLICY.TYPE.TEAM, - name: '', - owner: '', - outputCurrency: '', - isPolicyExpenseChatEnabled: false, - }, - policyTagList: {}, - policyCategories: {}, - transactions: {}, - transactionViolations: {}, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, - policyRecentlyUsedCurrencies: [], - }); + // When setting a category of a draft split transaction + setDraftSplitTransaction(transactionID, draftTransaction, {category}, fakePolicy); await waitForBatchedUpdates(); - mockFetch?.fail?.(); - await mockFetch?.resume?.(); - const updatedTransaction = await new Promise>((resolve) => { + // Then the transaction tax rate and amount should be updated based on the expense rules + await new Promise((resolve) => { const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { Onyx.disconnect(connection); - const newTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`]; - resolve(newTransaction); + expect(transaction?.taxCode).toBe(ruleTaxCode); + expect(transaction?.taxAmount).toBe(5); + resolve(); }, }); }); - expect(updatedTransaction?.modifiedAmount).toBe(0); + }); + + describe('should not change the tax', () => { + it('if there are no tax expense rules', async () => { + // Given a policy without tax expense rules + const transactionID = '1'; + const category = 'Advertising'; + const policyID = '2'; + const taxCode = 'id_TAX_EXEMPT'; + const taxAmount = 0; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {}, + }; + const draftTransaction: Transaction = { + ...createRandomTransaction(1), + taxCode, + taxAmount, + amount: 100, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + + // When setting a category of a draft split transaction + setDraftSplitTransaction(transactionID, draftTransaction, {category}, fakePolicy); + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount shouldn't be updated + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBe(taxCode); + expect(transaction?.taxAmount).toBe(taxAmount); + resolve(); + }, + }); + }); + }); + + it('if we are not updating category', async () => { + // Given a policy with tax expense rules associated with category + const transactionID = '1'; + const category = 'Advertising'; + const policyID = '2'; + const ruleTaxCode = 'id_TAX_RATE_1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, ruleTaxCode)}, + }; + const draftTransaction: Transaction = { + ...createRandomTransaction(1), + amount: 100, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, fakePolicy); + + // When setting a draft split transaction without category update + setDraftSplitTransaction(transactionID, draftTransaction, {}, fakePolicy); + + await waitForBatchedUpdates(); + + // Then the transaction tax rate and amount shouldn't be updated + await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, + callback: (transaction) => { + Onyx.disconnect(connection); + expect(transaction?.taxCode).toBeUndefined(); + expect(transaction?.taxAmount).toBeUndefined(); + resolve(); + }, + }); + }); + }); }); }); - describe('updateMoneyRequestAmountAndCurrency', () => { - it('removes AUTO_REPORTED_REJECTED_EXPENSE violation when the submitter edits the expense', async () => { - const transactionID = 'txn1'; - const transactionThreadReportID = 'thread1'; - const expenseReportID = 'report1'; - const policyID = '42'; - const TEST_USER_ACCOUNT_ID = 1; - const TEST_USER_LOGIN = 'test@test.com'; - - const expenseReport: Report = { - ...createRandomReport(1, undefined), - reportID: expenseReportID, - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: TEST_USER_ACCOUNT_ID, - policyID, - }; - - const transactionThread: Report = { - ...createRandomReport(2, undefined), - reportID: transactionThreadReportID, - parentReportID: expenseReportID, - parentReportActionID: 'parentAction', - type: CONST.REPORT.TYPE.CHAT, - }; + describe('should have valid parameters', () => { + let writeSpy: jest.SpyInstance; + const isValid = (value: unknown) => !value || typeof value !== 'object' || value instanceof Blob; - const transaction: Transaction = { - ...createRandomTransaction(3), - transactionID, - reportID: expenseReportID, - amount: 10000, - currency: CONST.CURRENCY.USD, - }; + beforeEach(() => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls + writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + }); - const policy: Policy = { - ...createRandomPolicy(Number(policyID)), - id: policyID, - type: CONST.POLICY.TYPE.CORPORATE, - }; + afterEach(() => { + writeSpy.mockRestore(); + }); - await Onyx.set(ONYXKEYS.SESSION, {accountID: TEST_USER_ACCOUNT_ID, email: TEST_USER_LOGIN}); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThread); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [ - { - name: CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE, - type: CONST.VIOLATION_TYPES.WARNING, + test.each([ + [WRITE_COMMANDS.REQUEST_MONEY, CONST.IOU.ACTION.CREATE], + [WRITE_COMMANDS.CONVERT_TRACKED_EXPENSE_TO_REQUEST, CONST.IOU.ACTION.SUBMIT], + ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { + // When an expense is created + requestMoney({ + action, + report: {reportID: ''}, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, }, - ]); - await waitForBatchedUpdates(); - - updateMoneyRequestAmountAndCurrency({ - transactionID, - transactionThreadReport: transactionThread, - parentReport: expenseReport, - amount: 20000, - currency: CONST.CURRENCY.USD, - taxAmount: 0, - taxCode: '', - policy, - policyTagList: {}, - policyCategories: {}, - transactions: {}, - transactionViolations: {}, + transactionParams: { + amount: 10000, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'KFC', + comment: '', + linkedTrackedExpenseReportAction: { + reportActionID: '', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-10-30', + }, + actionableWhisperReportActionID: '1', + linkedTrackedExpenseReportID: '1', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', - isASAPSubmitBetaEnabled: false, + transactionViolations: {}, policyRecentlyUsedCurrencies: [], + quickAction: undefined, }); await waitForBatchedUpdates(); - const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - expect(updatedViolations).toEqual([]); - }); - }); - - describe('cancelPayment', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; + // Then the correct API request should be made + expect(writeSpy).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const [command, params] = writeSpy.mock.calls.at(0); + expect(command).toBe(expectedCommand); - afterEach(() => { - mockFetch?.resume?.(); + // And the parameters should be supported by XMLHttpRequest + for (const value of Object.values(params as Record)) { + expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); + } }); - it('pendingAction is not null after canceling the payment failed', async () => { - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - - // Given a signed in account, which owns a workspace, and has a policy expense chat - Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); - // Which owns a workspace - await waitForBatchedUpdates(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); - await waitForBatchedUpdates(); - - // Get the policy expense chat report - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + test.each([ + [WRITE_COMMANDS.TRACK_EXPENSE, CONST.IOU.ACTION.CREATE], + [WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE, CONST.IOU.ACTION.CATEGORIZE], + [WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, CONST.IOU.ACTION.SHARE], + ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { + // When a track expense is created + trackExpense({ + report: {reportID: '123', policyID: 'A'}, + isDraftPolicy: false, + action, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount: 10000, + currency: CONST.CURRENCY.USD, + created: '2024-10-30', + merchant: 'KFC', + receipt: {}, + actionableWhisperReportActionID: '1', + linkedTrackedExpenseReportAction: { + reportActionID: '', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-10-30', + }, + linkedTrackedExpenseReportID: '1', }, + accountantParams: action === CONST.IOU.ACTION.SHARE ? {accountant: {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL}} : undefined, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, }); - if (chatReport) { - // When an IOU expense is submitted to that policy expense chat - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - } await waitForBatchedUpdates(); - // And given an expense report has now been created which holds the IOU - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - }, - }); + // Then the correct API request should be made + expect(writeSpy).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const [command, params] = writeSpy.mock.calls.at(0); + expect(command).toBe(expectedCommand); - if (chatReport && expenseReport) { - mockFetch?.pause?.(); - // And when the payment is cancelled - cancelPayment(expenseReport, chatReport, {} as Policy, true, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true); + if (expectedCommand === WRITE_COMMANDS.SHARE_TRACKED_EXPENSE) { + expect(params).toHaveProperty('policyName'); } - await waitForBatchedUpdates(); - mockFetch?.fail?.(); + // And the parameters should be supported by XMLHttpRequest + for (const value of Object.values(params as Record)) { + expect(Array.isArray(value) ? value.every(isValid) : isValid(value)).toBe(true); + } + }); + }); + + describe('canApproveIOU', () => { + it('should return false if we have only pending card transactions', async () => { + const policyID = '2'; + const reportID = '1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; + const fakeReport: Report = { + ...createRandomReport(Number(reportID), undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + }; + const fakeTransaction1: Transaction = { + ...createRandomTransaction(0), + reportID, + bank: CONST.EXPENSIFY_CARD.BANK, + status: CONST.TRANSACTION.STATUS.PENDING, + }; + const fakeTransaction2: Transaction = { + ...createRandomTransaction(1), + reportID, + bank: CONST.EXPENSIFY_CARD.BANK, + status: CONST.TRANSACTION.STATUS.PENDING, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction1.transactionID}`, fakeTransaction1); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction2.transactionID}`, fakeTransaction2); - await mockFetch?.resume?.(); + await waitForBatchedUpdates(); - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - callback: (allReportActions) => { - const action = Object.values(allReportActions ?? {}).find((a) => a?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED); - expect(action?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); + // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data + const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdatesWithAct(); + expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeFalsy(); + }); + it('should return false if we have only scanning transactions', async () => { + const policyID = '2'; + const reportID = '1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; + const fakeReport: Report = { + ...createRandomReport(Number(reportID), undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction1: Transaction = { + ...createRandomTransaction(0), + reportID, + amount: 0, + modifiedAmount: 0, + receipt: { + source: 'test', + state: CONST.IOU.RECEIPT_STATE.SCANNING, + }, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + modifiedMerchant: undefined, + }; + const fakeTransaction2: Transaction = { + ...createRandomTransaction(1), + reportID, + amount: 0, + modifiedAmount: 0, + receipt: { + source: 'test', + state: CONST.IOU.RECEIPT_STATE.SCANNING, }, + merchant: '', + modifiedMerchant: undefined, + }; + + await Onyx.set(ONYXKEYS.COLLECTION.REPORT, { + [`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`]: fakeReport, }); - }); - }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction1.transactionID}`, fakeTransaction1); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction2.transactionID}`, fakeTransaction2); - describe('payMoneyRequest', () => { - const amount = 10000; - const comment = '💸💸💸💸'; - const merchant = 'NASDAQ'; + await waitForBatchedUpdates(); - afterEach(() => { - mockFetch?.resume?.(); + expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); + // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data + const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdatesWithAct(); + expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeFalsy(); }); + it('should return false if all transactions are pending card or scanning transaction', async () => { + const policyID = '2'; + const reportID = '1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; + const fakeReport: Report = { + ...createRandomReport(Number(reportID), undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction1: Transaction = { + ...createRandomTransaction(0), + reportID, + bank: CONST.EXPENSIFY_CARD.BANK, + status: CONST.TRANSACTION.STATUS.PENDING, + }; + const fakeTransaction2: Transaction = { + ...createRandomTransaction(1), + reportID, + amount: 0, + modifiedAmount: 0, + receipt: { + source: 'test', + state: CONST.IOU.RECEIPT_STATE.SCANNING, + }, + merchant: '', + modifiedMerchant: undefined, + }; - it('pendingAction is not null after paying the money request', async () => { - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction1.transactionID}`, fakeTransaction1); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction2.transactionID}`, fakeTransaction2); - // Given a signed in account, which owns a workspace, and has a policy expense chat - Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); - // Which owns a workspace - await waitForBatchedUpdates(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - activePolicyID: '123', - }); await waitForBatchedUpdates(); - // Get the policy expense chat report - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); + // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data + const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdatesWithAct(); + expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeFalsy(); + }); + it('should return true if at least one transaction is not pending card or scanning transaction', async () => { + const policyID = '2'; + const reportID = '1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; + const fakeReport: Report = { + ...createRandomReport(Number(reportID), undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction1: Transaction = { + ...createRandomTransaction(0), + reportID, + bank: CONST.EXPENSIFY_CARD.BANK, + status: CONST.TRANSACTION.STATUS.PENDING, + }; + const fakeTransaction2: Transaction = { + ...createRandomTransaction(1), + reportID, + amount: 0, + receipt: { + source: 'test', + state: CONST.IOU.RECEIPT_STATE.SCANNING, }, - }); + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + modifiedMerchant: undefined, + }; + const fakeTransaction3: Transaction = { + ...createRandomTransaction(2), + reportID, + amount: 100, + status: CONST.TRANSACTION.STATUS.POSTED, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction1.transactionID}`, fakeTransaction1); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction2.transactionID}`, fakeTransaction2); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction3.transactionID}`, fakeTransaction3); - if (chatReport) { - // When an IOU expense is submitted to that policy expense chat - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant, - comment, - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - } await waitForBatchedUpdates(); - // And given an expense report has now been created which holds the IOU - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); - }, + expect(canApproveIOU(fakeReport, fakePolicy)).toBeTruthy(); + // Then should return true when passing transactions directly as the third parameter instead of relying on Onyx data + const {result} = renderHook(() => useReportWithTransactionsAndViolations(reportID), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdatesWithAct(); + expect(canApproveIOU(result.current.at(0) as Report, fakePolicy, result.current.at(1) as Transaction[])).toBeTruthy(); + }); + + it('should return false if the report is closed', async () => { + // Given a closed report, a policy, and a transaction + const policyID = '2'; + const reportID = '1'; + const fakePolicy: Policy = { + ...createRandomPolicy(Number(policyID)), + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; + const fakeReport: Report = { + ...createRandomReport(Number(reportID), undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.CLOSED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + }; + Onyx.multiSet({ + [ONYXKEYS.COLLECTION.REPORT]: fakeReport, + [ONYXKEYS.COLLECTION.TRANSACTION]: fakeTransaction, }); - - // When the expense report is paid elsewhere (but really, any payment option would work) - if (chatReport && expenseReport) { - mockFetch?.pause?.(); - payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, expenseReport, undefined, undefined); - } await waitForBatchedUpdates(); + // Then, canApproveIOU should return false since the report is closed + expect(canApproveIOU(fakeReport, fakePolicy)).toBeFalsy(); + // Then should return false when passing transactions directly as the third parameter instead of relying on Onyx data + expect(canApproveIOU(fakeReport, fakePolicy, [fakeTransaction])).toBeFalsy(); + }); + }); - mockFetch?.fail?.(); - - await mockFetch?.resume?.(); + describe('canUnapproveIOU', () => { + it('should return false if the report is waiting for a bank account', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'A', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + isWaitingOnBankAccount: true, + managerID: RORY_ACCOUNT_ID, + }; + expect(canUnapproveIOU(fakeReport, undefined)).toBeFalsy(); + }); + }); - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - callback: (allReportActions) => { - const action = Object.values(allReportActions ?? {}).find((a) => { - const originalMessage = isMoneyRequestAction(a) ? getOriginalMessage(a) : undefined; - return originalMessage?.type === 'pay'; - }); - expect(action?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); - }, - }); + describe('canCancelPayment', () => { + it('should return true if the report is waiting for a bank account', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'A', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + isWaitingOnBankAccount: true, + managerID: RORY_ACCOUNT_ID, + }; + expect(canCancelPayment(fakeReport, {accountID: RORY_ACCOUNT_ID}, undefined)).toBeTruthy(); }); }); - describe('initSplitExpense', () => { - it('should initialize split expense with correct transaction details', async () => { - const transaction: Transaction = { - transactionID: '123', - amount: 100, - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - splitExpenses: [], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - category: 'Food', - tag: 'lunch', - created: DateUtils.getDBTime(), - reportID: '456', + describe('canIOUBePaid', () => { + it('should return false if the report has negative total and onlyShowPayElsewhere is false', async () => { + const policyChat = createRandomReport(1, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + const fakePolicy: Policy = { + ...createRandomPolicy(Number('AA')), + id: 'AA', + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + role: CONST.POLICY.ROLE.ADMIN, }; - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, - }); + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: 'AA', + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: CARLOS_ACCOUNT_ID, + managerID: RORY_ACCOUNT_ID, + isWaitingOnBankAccount: false, + total: 100, // positive amount in the DB means negative amount in the UI + }; - initSplitExpense(allTransactions, allReports, transaction); - await waitForBatchedUpdates(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`); + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, [], false)).toBeFalsy(); + expect(canIOUBePaid(fakeReport, policyChat, fakePolicy, {}, [], true)).toBeTruthy(); + }); + }); - expect(draftTransaction).toBeTruthy(); + describe('calculateDiffAmount', () => { + it('should return 0 if iouReport is undefined', () => { + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + reportID: '1', + amount: 100, + currency: 'USD', + }; - const splitExpenses = draftTransaction?.comment?.splitExpenses; - expect(splitExpenses).toHaveLength(2); - expect(draftTransaction?.amount).toBe(100); - expect(draftTransaction?.currency).toBe('USD'); - expect(draftTransaction?.merchant).toBe('Test Merchant'); + expect(calculateDiffAmount(undefined, fakeTransaction, fakeTransaction)).toBe(0); + }); - expect(splitExpenses?.[0].amount).toBe(50); - expect(splitExpenses?.[0].description).toBe('Test comment'); - expect(splitExpenses?.[0].category).toBe('Food'); - expect(splitExpenses?.[0].tags).toEqual(['lunch']); + it('should return 0 when the currency and amount of the transactions are the same', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + reportID: fakeReport.reportID, + amount: 100, + currency: 'USD', + }; - expect(splitExpenses?.[1].amount).toBe(50); - expect(splitExpenses?.[1].description).toBe('Test comment'); - expect(splitExpenses?.[1].category).toBe('Food'); - expect(splitExpenses?.[1].tags).toEqual(['lunch']); + expect(calculateDiffAmount(fakeReport, fakeTransaction, fakeTransaction)).toBe(0); }); - it('should not initialize split expense for null transaction', async () => { - const transaction: Transaction | undefined = undefined; - - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, - }); - initSplitExpense(allTransactions, allReports, transaction); - await waitForBatchedUpdates(); + it('should return the difference between the updated amount and the current amount when the currency of the updated and current transactions have the same currency', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + currency: 'USD', + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + amount: 100, + currency: 'USD', + }; + const updatedTransaction = { + ...fakeTransaction, + amount: 200, + currency: 'USD', + }; - expect(transaction).toBeFalsy(); + expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBe(-100); }); - it('should initialize split expense with correct VND currency amounts', async () => { - const transaction: Transaction = { - transactionID: '123', - amount: 1700, - currency: 'VND', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - splitExpenses: [], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - category: 'Food', - tag: 'lunch', - created: DateUtils.getDBTime(), - reportID: '456', + it('should return null when the currency of the updated and current transactions have different values', () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + amount: 100, + currency: 'USD', + }; + const updatedTransaction = { + ...fakeTransaction, + amount: 200, + currency: 'EUR', }; - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, - }); + expect(calculateDiffAmount(fakeReport, updatedTransaction, fakeTransaction)).toBeNull(); + }); + }); + + describe('initMoneyRequest', () => { + const fakeReport: Report = { + ...createRandomReport(0, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + managerID: CARLOS_ACCOUNT_ID, + }; + const fakePolicy: Policy = { + ...createRandomPolicy(1), + type: CONST.POLICY.TYPE.TEAM, + outputCurrency: 'USD', + }; - initSplitExpense(allTransactions, allReports, transaction); - await waitForBatchedUpdates(); + const fakeParentReport: Report = { + ...createRandomReport(1, undefined), + reportID: fakeReport.reportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + managerID: CARLOS_ACCOUNT_ID, + }; + const fakePersonalPolicy: Pick = { + id: '2', + autoReporting: true, + type: CONST.POLICY.TYPE.PERSONAL, + outputCurrency: 'NZD', + }; + const transactionResult: Transaction = { + amount: 0, + comment: { + attendees: [ + { + email: currentUserPersonalDetails.email ?? '', + login: currentUserPersonalDetails.login, + accountID: 3, + text: currentUserPersonalDetails.login, + selected: true, + reportID: '0', + avatarUrl: SafeString(currentUserPersonalDetails.avatar) ?? '', + displayName: currentUserPersonalDetails.displayName ?? '', + }, + ], + }, + created: '2025-04-01', + currency: 'USD', + iouRequestType: 'manual', + reportID: fakeReport.reportID, + transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + isFromGlobalCreate: true, + merchant: '(none)', + }; - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`); + const currentDate = '2025-04-01'; + beforeEach(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, null); + await Onyx.merge(`${ONYXKEYS.CURRENT_DATE}`, currentDate); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${fakeReport.reportID}`, fakeReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + return waitForBatchedUpdates(); + }); - expect(draftTransaction).toBeTruthy(); + it('should merge transaction draft onyx value', async () => { + await waitForBatchedUpdates() + .then(() => { + initMoneyRequest({ + reportID: fakeReport.reportID, + policy: fakePolicy, + personalPolicy: fakePersonalPolicy, + isFromGlobalCreate: true, + newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + report: fakeReport, + parentReport: fakeParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies: false, + }); + }) + .then(async () => { + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual(transactionResult); + }); + }); - const splitExpenses = draftTransaction?.comment?.splitExpenses; - expect(splitExpenses).toHaveLength(2); - expect(draftTransaction?.amount).toBe(1700); - expect(draftTransaction?.currency).toBe('VND'); - expect((splitExpenses?.[0]?.amount ?? 0) + (splitExpenses?.[1]?.amount ?? 0)).toBe(1700); - expect(splitExpenses?.[0]?.amount).toBe(900); - expect(splitExpenses?.[1]?.amount).toBe(800); + it('should modify transaction draft when currentIouRequestType is different', async () => { + await waitForBatchedUpdates() + .then(() => { + return initMoneyRequest({ + reportID: fakeReport.reportID, + policy: fakePolicy, + personalPolicy: fakePersonalPolicy, + isFromGlobalCreate: true, + currentIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + newIouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + report: fakeReport, + parentReport: fakeParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies: false, + }); + }) + .then(async () => { + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual({ + ...transactionResult, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + }); + }); + }); + it('should return personal currency when policy is missing', async () => { + await waitForBatchedUpdates() + .then(() => { + return initMoneyRequest({ + reportID: fakeReport.reportID, + personalPolicy: fakePersonalPolicy, + isFromGlobalCreate: true, + newIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + report: fakeReport, + parentReport: fakeParentReport, + currentDate, + currentUserPersonalDetails, + hasOnlyPersonalPolicies: false, + }); + }) + .then(async () => { + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`)).toStrictEqual({ + ...transactionResult, + currency: fakePersonalPolicy.outputCurrency, + }); + }); }); }); - describe('addSplitExpenseField', () => { - it('should add new split expense field to draft transaction', async () => { - const transaction: Transaction = { - transactionID: '123', - amount: 100, - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - splitExpenses: [], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - category: 'Food', - tag: 'lunch', - created: DateUtils.getDBTime(), - reportID: '456', - }; + describe('updateMoneyRequestAmountAndCurrency', () => { + it('update the amount of the money request successfully', async () => { + const initialCurrencies = [CONST.CURRENCY.EUR, CONST.CURRENCY.GBP]; - const draftTransaction: Transaction = { - transactionID: '123', + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + reportID: fakeReport.reportID, amount: 100, - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - splitExpenses: [ - { - transactionID: '789', - amount: 50, - description: 'Test comment', - category: 'Food', - tags: ['lunch'], - created: DateUtils.getDBTime(), - }, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - category: 'Food', - tag: 'lunch', - created: DateUtils.getDBTime(), - reportID: '456', + currency: CONST.CURRENCY.USD, }; - addSplitExpenseField(transaction, draftTransaction); - await waitForBatchedUpdates(); - - const updatedDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`); - expect(updatedDraftTransaction).toBeTruthy(); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); - const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; - expect(splitExpenses).toHaveLength(2); - expect(splitExpenses?.[1].amount).toBe(0); - expect(splitExpenses?.[1].description).toBe('Test comment'); - expect(splitExpenses?.[1].category).toBe('Food'); - expect(splitExpenses?.[1].tags).toEqual(['lunch']); - }); + mockFetch?.pause?.(); - it('should preserve reimbursable field when adding new split to card transaction', async () => { - // Setup: Card transaction (reimbursable: false) - const cardTransaction: Transaction = { - transactionID: '123', - amount: 100, - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Card transaction', - splitExpenses: [], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + updateMoneyRequestAmountAndCurrency({ + transactionID: fakeTransaction.transactionID, + transactionThreadReport: fakeReport, + parentReport: undefined, + amount: 20000, + currency: CONST.CURRENCY.USD, + taxAmount: 0, + taxCode: '', + policy: { + id: '123', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + name: '', + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, }, - category: 'Food', - tag: 'lunch', - created: DateUtils.getDBTime(), - reportID: '456', - reimbursable: false, // Card transaction - not reimbursable - }; + policyTagList: {}, + policyCategories: {}, + transactions: {}, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + policyRecentlyUsedCurrencies: initialCurrencies, + }); + + await waitForBatchedUpdates(); + mockFetch?.succeed?.(); + await mockFetch?.resume?.(); + + const updatedTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + Onyx.disconnect(connection); + const newTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`]; + resolve(newTransaction); + }, + }); + }); + expect(updatedTransaction?.modifiedAmount).toBe(20000); - const draftTransaction: Transaction = { - transactionID: '123', + const recentlyUsedCurrencies = await getOnyxValue(ONYXKEYS.RECENTLY_USED_CURRENCIES); + expect(recentlyUsedCurrencies).toEqual([CONST.CURRENCY.USD, ...initialCurrencies]); + }); + + it('update the amount of the money request failed', async () => { + const fakeReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + managerID: RORY_ACCOUNT_ID, + }; + const fakeTransaction: Transaction = { + ...createRandomTransaction(1), + reportID: fakeReport.reportID, amount: 100, currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Card transaction', - splitExpenses: [ - { - transactionID: '789', - amount: 50, - description: 'Card transaction', - category: 'Food', - tags: ['lunch'], - created: DateUtils.getDBTime(), - reimbursable: false, // Existing split - not reimbursable - }, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - category: 'Food', - tag: 'lunch', - created: DateUtils.getDBTime(), - reportID: '456', - reimbursable: false, }; - // Action: Add a new split expense field - addSplitExpenseField(cardTransaction, draftTransaction); - await waitForBatchedUpdates(); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`, fakeTransaction); - const updatedDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${cardTransaction.transactionID}`); - expect(updatedDraftTransaction).toBeTruthy(); + mockFetch?.pause?.(); - const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; - expect(splitExpenses).toHaveLength(2); + updateMoneyRequestAmountAndCurrency({ + transactionID: fakeTransaction.transactionID, + transactionThreadReport: fakeReport, + parentReport: undefined, + amount: 20000, + currency: CONST.CURRENCY.USD, + taxAmount: 0, + taxCode: '', + policy: { + id: '123', + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.TEAM, + name: '', + owner: '', + outputCurrency: '', + isPolicyExpenseChatEnabled: false, + }, + policyTagList: {}, + policyCategories: {}, + transactions: {}, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + policyRecentlyUsedCurrencies: [], + }); - // Verify: The new split should have reimbursable: false (not counted as out-of-pocket) - expect(splitExpenses?.[1].reimbursable).toBe(false); - expect(splitExpenses?.[1].amount).toBe(0); - expect(splitExpenses?.[1].description).toBe('Card transaction'); - expect(splitExpenses?.[1].category).toBe('Food'); - expect(splitExpenses?.[1].tags).toEqual(['lunch']); + await waitForBatchedUpdates(); + mockFetch?.fail?.(); + await mockFetch?.resume?.(); - // Verify: The existing split should still have reimbursable: false - expect(splitExpenses?.[0].reimbursable).toBe(false); + const updatedTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + Onyx.disconnect(connection); + const newTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${fakeTransaction.transactionID}`]; + resolve(newTransaction); + }, + }); + }); + expect(updatedTransaction?.modifiedAmount).toBe(0); }); }); - describe('evenlyDistributeSplitExpenseAmounts', () => { - it('distributes evenly across 3 splits with remainder on last split', async () => { - const originalTransactionID = 'orig-last'; - const draftTransaction: Transaction = { - transactionID: 'draft-2', - amount: 100, // in cents = $1.00 - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - originalTransactionID, - splitExpenses: [ - {transactionID: 'x', amount: 0, description: 'X', created: DateUtils.getDBTime()}, - {transactionID: 'y', amount: 0, description: 'Y', created: DateUtils.getDBTime()}, - {transactionID: 'z', amount: 0, description: 'Z', created: DateUtils.getDBTime()}, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - created: DateUtils.getDBTime(), - reportID: 'rep-2', + describe('updateMoneyRequestAmountAndCurrency', () => { + it('removes AUTO_REPORTED_REJECTED_EXPENSE violation when the submitter edits the expense', async () => { + const transactionID = 'txn1'; + const transactionThreadReportID = 'thread1'; + const expenseReportID = 'report1'; + const policyID = '42'; + const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@test.com'; + + const expenseReport: Report = { + ...createRandomReport(1, undefined), + reportID: expenseReportID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: TEST_USER_ACCOUNT_ID, + policyID, }; - evenlyDistributeSplitExpenseAmounts(draftTransaction); - await waitForBatchedUpdates(); + const transactionThread: Report = { + ...createRandomReport(2, undefined), + reportID: transactionThreadReportID, + parentReportID: expenseReportID, + parentReportActionID: 'parentAction', + type: CONST.REPORT.TYPE.CHAT, + }; - const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); - expect(updatedDraft).toBeTruthy(); - const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([33, 33, 34]); - }); + const transaction: Transaction = { + ...createRandomTransaction(3), + transactionID, + reportID: expenseReportID, + amount: 10000, + currency: CONST.CURRENCY.USD, + }; - it('assigns full amount when there is only one split', async () => { - const originalTransactionID = 'orig-single'; - const draftTransaction: Transaction = { - transactionID: 'draft-3', - amount: 1000, // in cents = $10.00 - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - originalTransactionID, - splitExpenses: [{transactionID: 'only', amount: 0, description: 'Only', created: DateUtils.getDBTime()}], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - created: DateUtils.getDBTime(), - reportID: 'rep-3', + const policy: Policy = { + ...createRandomPolicy(Number(policyID)), + id: policyID, + type: CONST.POLICY.TYPE.CORPORATE, }; - evenlyDistributeSplitExpenseAmounts(draftTransaction); + await Onyx.set(ONYXKEYS.SESSION, {accountID: TEST_USER_ACCOUNT_ID, email: TEST_USER_LOGIN}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, transactionThread); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [ + { + name: CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE, + type: CONST.VIOLATION_TYPES.WARNING, + }, + ]); await waitForBatchedUpdates(); - const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); - const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([1000]); + updateMoneyRequestAmountAndCurrency({ + transactionID, + transactionThreadReport: transactionThread, + parentReport: expenseReport, + amount: 20000, + currency: CONST.CURRENCY.USD, + taxAmount: 0, + taxCode: '', + policy, + policyTagList: {}, + policyCategories: {}, + transactions: {}, + transactionViolations: {}, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + policyRecentlyUsedCurrencies: [], + }); + + await waitForBatchedUpdates(); + + const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + expect(updatedViolations).toEqual([]); }); + }); - it('evenly distributes equal split with no remainder (4-way $1.00 -> 25¢ each)', async () => { - const originalTransactionID = 'orig-equal-4'; - const draftTransaction: Transaction = { - transactionID: 'draft-4', - amount: 100, // in cents = $1.00 - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - originalTransactionID, - splitExpenses: [ - {transactionID: '1', amount: 0, description: '1', created: DateUtils.getDBTime()}, - {transactionID: '2', amount: 0, description: '2', created: DateUtils.getDBTime()}, - {transactionID: '3', amount: 0, description: '3', created: DateUtils.getDBTime()}, - {transactionID: '4', amount: 0, description: '4', created: DateUtils.getDBTime()}, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + describe('cancelPayment', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; + + afterEach(() => { + mockFetch?.resume?.(); + }); + + it('pendingAction is not null after canceling the payment failed', async () => { + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + + // Given a signed in account, which owns a workspace, and has a policy expense chat + Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + // Which owns a workspace + await waitForBatchedUpdates(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + await waitForBatchedUpdates(); + + // Get the policy expense chat report + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); }, - created: DateUtils.getDBTime(), - reportID: 'rep-4', - }; + }); - evenlyDistributeSplitExpenseAmounts(draftTransaction); + if (chatReport) { + // When an IOU expense is submitted to that policy expense chat + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + } await waitForBatchedUpdates(); - const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); - const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([25, 25, 25, 25]); - }); - - it('2-way split equal (even cents) -> 50¢ / 50¢', async () => { - const originalTransactionID = 'orig-2-equal'; - const draftTransaction: Transaction = { - transactionID: 'draft-5', - amount: 100, // in cents = $1.00 - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - originalTransactionID, - splitExpenses: [ - {transactionID: 'a', amount: 0, description: 'A', created: DateUtils.getDBTime()}, - {transactionID: 'b', amount: 0, description: 'B', created: DateUtils.getDBTime()}, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + // And given an expense report has now been created which holds the IOU + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); }, - created: DateUtils.getDBTime(), - reportID: 'rep-5', - }; + }); - evenlyDistributeSplitExpenseAmounts(draftTransaction); + if (chatReport && expenseReport) { + mockFetch?.pause?.(); + // And when the payment is cancelled + cancelPayment(expenseReport, chatReport, {} as Policy, true, CARLOS_ACCOUNT_ID, CARLOS_EMAIL, true); + } await waitForBatchedUpdates(); - const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); - const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([50, 50]); - }); + mockFetch?.fail?.(); - it('2-way split with remainder (odd cents) -> 50¢ / 51¢', async () => { - const originalTransactionID = 'orig-2-rem'; - const draftTransaction: Transaction = { - transactionID: 'draft-6', - amount: 101, // in cents = $1.01 - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - originalTransactionID, - splitExpenses: [ - {transactionID: 'a', amount: 0, description: 'A', created: DateUtils.getDBTime()}, - {transactionID: 'b', amount: 0, description: 'B', created: DateUtils.getDBTime()}, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + await mockFetch?.resume?.(); + + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + callback: (allReportActions) => { + const action = Object.values(allReportActions ?? {}).find((a) => a?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DEQUEUED); + expect(action?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); }, - created: DateUtils.getDBTime(), - reportID: 'rep-6', - }; + }); + }); + }); - evenlyDistributeSplitExpenseAmounts(draftTransaction); - await waitForBatchedUpdates(); + describe('payMoneyRequest', () => { + const amount = 10000; + const comment = '💸💸💸💸'; + const merchant = 'NASDAQ'; - const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); - const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([50, 51]); + afterEach(() => { + mockFetch?.resume?.(); }); - it('3-way split of $1001 with remainder -> [$333.66, $333.66, $333.68]', async () => { - const originalTransactionID = 'orig-1001-3-last'; - const draftTransaction: Transaction = { - transactionID: 'draft-7', - amount: 100100, // in cents = $1001.00 - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Test comment', - originalTransactionID, - splitExpenses: [ - {transactionID: 'p', amount: 0, description: 'P', created: DateUtils.getDBTime()}, - {transactionID: 'q', amount: 0, description: 'Q', created: DateUtils.getDBTime()}, - {transactionID: 'r', amount: 0, description: 'R', created: DateUtils.getDBTime()}, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - created: DateUtils.getDBTime(), - reportID: 'rep-7', - }; + it('pendingAction is not null after paying the money request', async () => { + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; - evenlyDistributeSplitExpenseAmounts(draftTransaction); + // Given a signed in account, which owns a workspace, and has a policy expense chat + Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + // Which owns a workspace + await waitForBatchedUpdates(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + activePolicyID: '123', + }); await waitForBatchedUpdates(); - const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); - const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([33366, 33366, 33368]); - expect(amounts.reduce((a, b) => a + b, 0)).toBe(100100); - }); - - it('preserves negative sign and evenly distributes with remainder on last for 3-way split', async () => { - const originalTransactionID = 'orig-neg-3'; - const draftTransaction: Transaction = { - transactionID: 'draft-neg-3', - amount: -100, // in cents = -$1.00 - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Negative amount test', - originalTransactionID, - splitExpenses: [ - {transactionID: 'n1', amount: 0, description: 'N1', created: DateUtils.getDBTime()}, - {transactionID: 'n2', amount: 0, description: 'N2', created: DateUtils.getDBTime()}, - {transactionID: 'n3', amount: 0, description: 'N3', created: DateUtils.getDBTime()}, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + // Get the policy expense chat report + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); }, - created: DateUtils.getDBTime(), - reportID: 'rep-neg-3', - }; + }); - evenlyDistributeSplitExpenseAmounts(draftTransaction); + if (chatReport) { + // When an IOU expense is submitted to that policy expense chat + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant, + comment, + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + } await waitForBatchedUpdates(); - const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); - const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([-33, -33, -34]); - expect(amounts.reduce((a, b) => a + b, 0)).toBe(-100); - }); - - it('preserves negative sign for 2-way odd-cent split -> [-$0.51, -$0.50]', async () => { - const originalTransactionID = 'orig-neg-2'; - const draftTransaction: Transaction = { - transactionID: 'draft-neg-2', - amount: -101, // in cents = -$1.01 - currency: 'USD', - merchant: 'Test Merchant', - comment: { - comment: 'Negative amount test 2-way', - originalTransactionID, - splitExpenses: [ - {transactionID: 'nA', amount: 0, description: 'NA', created: DateUtils.getDBTime()}, - {transactionID: 'nB', amount: 0, description: 'NB', created: DateUtils.getDBTime()}, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + // And given an expense report has now been created which holds the IOU + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU); }, - created: DateUtils.getDBTime(), - reportID: 'rep-neg-2', - }; + }); - evenlyDistributeSplitExpenseAmounts(draftTransaction); + // When the expense report is paid elsewhere (but really, any payment option would work) + if (chatReport && expenseReport) { + mockFetch?.pause?.(); + payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, expenseReport, undefined, undefined); + } await waitForBatchedUpdates(); - const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); - const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([-50, -51]); - expect(amounts.reduce((a, b) => a + b, 0)).toBe(-101); + mockFetch?.fail?.(); + + await mockFetch?.resume?.(); + + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + callback: (allReportActions) => { + const action = Object.values(allReportActions ?? {}).find((a) => { + const originalMessage = isMoneyRequestAction(a) ? getOriginalMessage(a) : undefined; + return originalMessage?.type === 'pay'; + }); + expect(action?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + }, + }); }); }); - describe('updateSplitExpenseAmountField', () => { - it('should update amount expense field to draft transaction', async () => { - const originalTransactionID = '123'; - const currentTransactionID = '789'; - const draftTransaction: Transaction = { - transactionID: '234', + describe('initSplitExpense', () => { + it('should initialize split expense with correct transaction details', async () => { + const transaction: Transaction = { + transactionID: '123', amount: 100, currency: 'USD', merchant: 'Test Merchant', comment: { comment: 'Test comment', - originalTransactionID, - splitExpenses: [ - { - transactionID: currentTransactionID, - amount: 50, - description: 'Test comment', - category: 'Food', - tags: ['lunch'], - created: DateUtils.getDBTime(), - }, - ], + splitExpenses: [], attendees: [], type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, }, @@ -8687,1024 +6876,808 @@ describe('actions/IOU', () => { reportID: '456', }; - updateSplitExpenseAmountField(draftTransaction, currentTransactionID, 20); - await waitForBatchedUpdates(); - - const updatedDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); - expect(updatedDraftTransaction).toBeTruthy(); - - const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; - expect(splitExpenses?.[0].amount).toBe(20); - }); - }); - - describe('replaceReceipt', () => { - it('should replace the receipt of the transaction', async () => { - const transactionID = '123'; - const file = new File([new Blob(['test'])], 'test.jpg', {type: 'image/jpeg'}); - file.source = 'test'; - const source = 'test'; - - const transaction = { - transactionID, - receipt: { - source: 'test1', + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; }, - }; - - // Given a transaction with a receipt - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); - await waitForBatchedUpdates(); - - // Given a snapshot of the transaction - await Onyx.set(`${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`, { - // @ts-expect-error: Allow partial record in snapshot update - data: { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; }, }); - await waitForBatchedUpdates(); - // When the receipt is replaced - replaceReceipt({transactionID, file, source, transactionPolicy: undefined}); + initSplitExpense(allTransactions, allReports, transaction); await waitForBatchedUpdates(); - // Then the transaction should have the new receipt source - const updatedTransaction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - Onyx.disconnect(connection); - const newTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - resolve(newTransaction); - }, - }); - }); - expect(updatedTransaction?.receipt?.source).toBe(source); - - // Then the snapshot should have the new receipt source - const updatedSnapshot = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.SNAPSHOT, - waitForCollectionCallback: true, - callback: (snapshots) => { - Onyx.disconnect(connection); - const newSnapshot = snapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`]; - resolve(newSnapshot); - }, - }); - }); + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`); - expect(updatedSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.receipt?.source).toBe(source); - }); + expect(draftTransaction).toBeTruthy(); - it('should add receipt if it does not exist', async () => { - const transactionID = '123'; - const file = new File([new Blob(['test'])], 'test.jpg', {type: 'image/jpeg'}); - file.source = 'test'; - const source = 'test'; + const splitExpenses = draftTransaction?.comment?.splitExpenses; + expect(splitExpenses).toHaveLength(2); + expect(draftTransaction?.amount).toBe(100); + expect(draftTransaction?.currency).toBe('USD'); + expect(draftTransaction?.merchant).toBe('Test Merchant'); - const transaction = { - transactionID, - }; + expect(splitExpenses?.[0].amount).toBe(50); + expect(splitExpenses?.[0].description).toBe('Test comment'); + expect(splitExpenses?.[0].category).toBe('Food'); + expect(splitExpenses?.[0].tags).toEqual(['lunch']); - // Given a transaction without a receipt - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); - await waitForBatchedUpdates(); + expect(splitExpenses?.[1].amount).toBe(50); + expect(splitExpenses?.[1].description).toBe('Test comment'); + expect(splitExpenses?.[1].category).toBe('Food'); + expect(splitExpenses?.[1].tags).toEqual(['lunch']); + }); + it('should not initialize split expense for null transaction', async () => { + const transaction: Transaction | undefined = undefined; - // Given a snapshot of the transaction - await Onyx.set(`${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`, { - // @ts-expect-error: Allow partial record in snapshot update - data: { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; }, }); - await waitForBatchedUpdates(); - - // When the receipt is replaced - replaceReceipt({transactionID, file, source, transactionPolicy: undefined}); - await waitForBatchedUpdates(); - - // Then the transaction should have the new receipt source - const updatedTransaction = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - Onyx.disconnect(connection); - const newTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - resolve(newTransaction); - }, - }); - }); - expect(updatedTransaction?.receipt?.source).toBe(source); - - // Then the snapshot should have the new receipt source - const updatedSnapshot = await new Promise>((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.COLLECTION.SNAPSHOT, - waitForCollectionCallback: true, - callback: (snapshots) => { - Onyx.disconnect(connection); - const newSnapshot = snapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`]; - resolve(newSnapshot); - }, - }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, }); - expect(updatedSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.receipt?.source).toBe(source); - }); - }); - - describe('changeTransactionsReport', () => { - it('should set the correct optimistic onyx data for reporting a tracked expense', async () => { - let personalDetailsList: OnyxEntry; - let expenseReport: OnyxEntry; - let transaction: OnyxEntry; - let allTransactions: OnyxCollection = {}; - - // Given a signed in account, which owns a workspace, and has a policy expense chat - Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); - const creatorPersonalDetails = personalDetailsList?.[CARLOS_ACCOUNT_ID] ?? {accountID: CARLOS_ACCOUNT_ID}; - - const policyID = generatePolicyID(); - const mockPolicy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, "Carlos's Workspace"), - id: policyID, - outputCurrency: CONST.CURRENCY.USD, - owner: CARLOS_EMAIL, - ownerAccountID: CARLOS_ACCOUNT_ID, - pendingAction: undefined, - }; - + initSplitExpense(allTransactions, allReports, transaction); await waitForBatchedUpdates(); - createNewReport(creatorPersonalDetails, true, false, mockPolicy); - // Create a tracked expense - const selfDMReport: Report = { - ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), - reportID: '10', - }; - - const amount = 100; + expect(transaction).toBeFalsy(); + }); - trackExpense({ - report: selfDMReport, - isDraftPolicy: true, - action: CONST.IOU.ACTION.CREATE, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {accountID: RORY_ACCOUNT_ID}, - }, - transactionParams: { - amount, - currency: CONST.CURRENCY.USD, - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - merchant: 'merchant', - billable: false, - reimbursable: false, + it('should initialize split expense with correct VND currency amounts', async () => { + const transaction: Transaction = { + transactionID: '123', + amount: 1700, + currency: 'VND', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + splitExpenses: [], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, }, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - introSelected: undefined, - activePolicyID: undefined, - quickAction: undefined, - }); + category: 'Food', + tag: 'lunch', + created: DateUtils.getDBTime(), + reportID: '456', + }; + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; await getOnyxData({ key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, - callback: (transactions) => { - transaction = Object.values(transactions ?? {}).find((t) => !!t); - allTransactions = transactions; + callback: (value) => { + allTransactions = value; }, }); - await getOnyxData({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.EXPENSE); + callback: (value) => { + allReports = value; }, }); - let iouReportActionOnSelfDMReport: OnyxEntry; - let trackExpenseActionableWhisper: OnyxEntry; + initSplitExpense(allTransactions, allReports, transaction); + await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - iouReportActionOnSelfDMReport = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`] ?? {}).find( - (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU, - ); - trackExpenseActionableWhisper = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport?.reportID}`] ?? {}).find( - (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, - ); + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`); + + expect(draftTransaction).toBeTruthy(); + + const splitExpenses = draftTransaction?.comment?.splitExpenses; + expect(splitExpenses).toHaveLength(2); + expect(draftTransaction?.amount).toBe(1700); + expect(draftTransaction?.currency).toBe('VND'); + expect((splitExpenses?.[0]?.amount ?? 0) + (splitExpenses?.[1]?.amount ?? 0)).toBe(1700); + expect(splitExpenses?.[0]?.amount).toBe(900); + expect(splitExpenses?.[1]?.amount).toBe(800); + }); + }); + + describe('addSplitExpenseField', () => { + it('should add new split expense field to draft transaction', async () => { + const transaction: Transaction = { + transactionID: '123', + amount: 100, + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + splitExpenses: [], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, }, - }); + category: 'Food', + tag: 'lunch', + created: DateUtils.getDBTime(), + reportID: '456', + }; - expect(isMoneyRequestAction(iouReportActionOnSelfDMReport) ? getOriginalMessage(iouReportActionOnSelfDMReport)?.IOUTransactionID : undefined).toBe(transaction?.transactionID); - expect(trackExpenseActionableWhisper).toBeDefined(); + const draftTransaction: Transaction = { + transactionID: '123', + amount: 100, + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + splitExpenses: [ + { + transactionID: '789', + amount: 50, + description: 'Test comment', + category: 'Food', + tags: ['lunch'], + created: DateUtils.getDBTime(), + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + category: 'Food', + tag: 'lunch', + created: DateUtils.getDBTime(), + reportID: '456', + }; - if (!transaction || !expenseReport) { - return; - } + addSplitExpenseField(transaction, draftTransaction); + await waitForBatchedUpdates(); - const {result} = renderHook(() => { - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`, {canBeMissing: true}); - return {report}; - }); + const updatedDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`); + expect(updatedDraftTransaction).toBeTruthy(); - await waitFor(() => { - expect(result.current.report).toBeDefined(); - }); + const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; + expect(splitExpenses).toHaveLength(2); + expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].description).toBe('Test comment'); + expect(splitExpenses?.[1].category).toBe('Food'); + expect(splitExpenses?.[1].tags).toEqual(['lunch']); + }); - changeTransactionsReport({ - transactionIDs: [transaction?.transactionID], - isASAPSubmitBetaEnabled: false, - accountID: CARLOS_ACCOUNT_ID, - email: CARLOS_EMAIL, - newReport: result.current.report, - allTransactionsCollection: allTransactions, - }); + it('should preserve reimbursable field when adding new split to card transaction', async () => { + // Setup: Card transaction (reimbursable: false) + const cardTransaction: Transaction = { + transactionID: '123', + amount: 100, + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Card transaction', + splitExpenses: [], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + category: 'Food', + tag: 'lunch', + created: DateUtils.getDBTime(), + reportID: '456', + reimbursable: false, // Card transaction - not reimbursable + }; - let updatedTransaction: OnyxEntry; - let updatedIOUReportActionOnSelfDMReport: OnyxEntry; - let updatedTrackExpenseActionableWhisper: OnyxEntry; - let updatedExpenseReport: OnyxEntry; + const draftTransaction: Transaction = { + transactionID: '123', + amount: 100, + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Card transaction', + splitExpenses: [ + { + transactionID: '789', + amount: 50, + description: 'Card transaction', + category: 'Food', + tags: ['lunch'], + created: DateUtils.getDBTime(), + reimbursable: false, // Existing split - not reimbursable + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + category: 'Food', + tag: 'lunch', + created: DateUtils.getDBTime(), + reportID: '456', + reimbursable: false, + }; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (transactions) => { - updatedTransaction = Object.values(transactions ?? {}).find((t) => t?.transactionID === transaction?.transactionID); + // Action: Add a new split expense field + addSplitExpenseField(cardTransaction, draftTransaction); + await waitForBatchedUpdates(); + + const updatedDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${cardTransaction.transactionID}`); + expect(updatedDraftTransaction).toBeTruthy(); + + const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; + expect(splitExpenses).toHaveLength(2); + + // Verify: The new split should have reimbursable: false (not counted as out-of-pocket) + expect(splitExpenses?.[1].reimbursable).toBe(false); + expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].description).toBe('Card transaction'); + expect(splitExpenses?.[1].category).toBe('Food'); + expect(splitExpenses?.[1].tags).toEqual(['lunch']); + + // Verify: The existing split should still have reimbursable: false + expect(splitExpenses?.[0].reimbursable).toBe(false); + }); + }); + + describe('evenlyDistributeSplitExpenseAmounts', () => { + it('distributes evenly across 3 splits with remainder on last split', async () => { + const originalTransactionID = 'orig-last'; + const draftTransaction: Transaction = { + transactionID: 'draft-2', + amount: 100, // in cents = $1.00 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + originalTransactionID, + splitExpenses: [ + {transactionID: 'x', amount: 0, description: 'X', created: DateUtils.getDBTime()}, + {transactionID: 'y', amount: 0, description: 'Y', created: DateUtils.getDBTime()}, + {transactionID: 'z', amount: 0, description: 'Z', created: DateUtils.getDBTime()}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: 'rep-2', + }; + + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); + + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + expect(updatedDraft).toBeTruthy(); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([33, 33, 34]); + }); + + it('assigns full amount when there is only one split', async () => { + const originalTransactionID = 'orig-single'; + const draftTransaction: Transaction = { + transactionID: 'draft-3', + amount: 1000, // in cents = $10.00 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + originalTransactionID, + splitExpenses: [{transactionID: 'only', amount: 0, description: 'Only', created: DateUtils.getDBTime()}], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, }, - }); + created: DateUtils.getDBTime(), + reportID: 'rep-3', + }; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - updatedIOUReportActionOnSelfDMReport = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`] ?? {}).find( - (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU, - ); - updatedTrackExpenseActionableWhisper = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport?.reportID}`] ?? {}).find( - (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, - ); - }, - }); + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - updatedExpenseReport = Object.values(allReports ?? {}).find((r) => r?.reportID === expenseReport?.reportID); + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([1000]); + }); + + it('evenly distributes equal split with no remainder (4-way $1.00 -> 25¢ each)', async () => { + const originalTransactionID = 'orig-equal-4'; + const draftTransaction: Transaction = { + transactionID: 'draft-4', + amount: 100, // in cents = $1.00 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + originalTransactionID, + splitExpenses: [ + {transactionID: '1', amount: 0, description: '1', created: DateUtils.getDBTime()}, + {transactionID: '2', amount: 0, description: '2', created: DateUtils.getDBTime()}, + {transactionID: '3', amount: 0, description: '3', created: DateUtils.getDBTime()}, + {transactionID: '4', amount: 0, description: '4', created: DateUtils.getDBTime()}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, }, - }); + created: DateUtils.getDBTime(), + reportID: 'rep-4', + }; - expect(updatedTransaction?.reportID).toBe(expenseReport?.reportID); - expect(isMoneyRequestAction(updatedIOUReportActionOnSelfDMReport) ? getOriginalMessage(updatedIOUReportActionOnSelfDMReport)?.IOUTransactionID : undefined).toBe(undefined); - expect(updatedTrackExpenseActionableWhisper).toBe(undefined); - expect(updatedExpenseReport?.nonReimbursableTotal).toBe(-amount); - expect(updatedExpenseReport?.total).toBe(-amount); - expect(updatedExpenseReport?.unheldNonReimbursableTotal).toBe(-amount); + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); + + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([25, 25, 25, 25]); }); - describe('updateSplitTransactionsFromSplitExpensesFlow', () => { - it("should update split transaction's description correctly ", async () => { - const amount = 10000; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let originalTransactionID; - - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); + it('2-way split equal (even cents) -> 50¢ / 50¢', async () => { + const originalTransactionID = 'orig-2-equal'; + const draftTransaction: Transaction = { + transactionID: 'draft-5', + amount: 100, // in cents = $1.00 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + originalTransactionID, + splitExpenses: [ + {transactionID: 'a', amount: 0, description: 'A', created: DateUtils.getDBTime()}, + {transactionID: 'b', amount: 0, description: 'B', created: DateUtils.getDBTime()}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: 'rep-5', + }; - // Change the approval mode for the policy since default is Submit and Close - setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); - await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - }, - }); - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'NASDAQ', - comment: '*hey* `hey`', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - }, - }); - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; - originalTransactionID = originalMessage?.IOUTransactionID; - }, - }); + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); - const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); - const draftTransaction: Transaction = { - reportID: originalTransaction?.reportID ?? '456', - transactionID: originalTransaction?.transactionID ?? '234', - amount, - created: originalTransaction?.created ?? DateUtils.getDBTime(), - currency: CONST.CURRENCY.USD, - merchant: originalTransaction?.merchant ?? '', - comment: { - originalTransactionID, - comment: originalTransaction?.comment?.comment ?? '', - splitExpenses: [ - { - transactionID: '235', - amount: amount / 2, - description: 'hey
hey', - created: DateUtils.getDBTime(), - }, - { - transactionID: '234', - amount: amount / 2, - description: '*hey1* `hey`', - created: DateUtils.getDBTime(), - }, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - }; + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([50, 50]); + }); - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - let allReportNameValuePairs: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - waitForCollectionCallback: true, - callback: (value) => { - allReportNameValuePairs = value; - }, - }); + it('2-way split with remainder (odd cents) -> 50¢ / 51¢', async () => { + const originalTransactionID = 'orig-2-rem'; + const draftTransaction: Transaction = { + transactionID: 'draft-6', + amount: 101, // in cents = $1.01 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + originalTransactionID, + splitExpenses: [ + {transactionID: 'a', amount: 0, description: 'A', created: DateUtils.getDBTime()}, + {transactionID: 'b', amount: 0, description: 'B', created: DateUtils.getDBTime()}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: 'rep-6', + }; - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: -2, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: undefined, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - await waitForBatchedUpdates(); + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); - const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); - expect(split1?.comment?.comment).toBe('hey
hey'); - const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}234`); - expect(split2?.comment?.comment).toBe('hey1 hey'); - }); - - it("should not create new expense report if the admin split the employee's expense", async () => { - const amount = 10000; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let originalTransactionID; - - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: RORY_EMAIL, - makeMeAdmin: true, - policyName: "Rory's Workspace", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - }); + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([50, 51]); + }); - // Change the approval mode for the policy since default is Submit and Close - setWorkspaceApprovalMode(policyID, RORY_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); - await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - }, - }); - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: CARLOS_EMAIL, - payeeAccountID: CARLOS_ACCOUNT_ID, - participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'NASDAQ', - comment: '*hey* `hey`', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - }, - }); - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; - originalTransactionID = originalMessage?.IOUTransactionID; - }, - }); + it('3-way split of $1001 with remainder -> [$333.66, $333.66, $333.68]', async () => { + const originalTransactionID = 'orig-1001-3-last'; + const draftTransaction: Transaction = { + transactionID: 'draft-7', + amount: 100100, // in cents = $1001.00 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + originalTransactionID, + splitExpenses: [ + {transactionID: 'p', amount: 0, description: 'P', created: DateUtils.getDBTime()}, + {transactionID: 'q', amount: 0, description: 'Q', created: DateUtils.getDBTime()}, + {transactionID: 'r', amount: 0, description: 'R', created: DateUtils.getDBTime()}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: 'rep-7', + }; - const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); - const draftTransaction: Transaction = { - reportID: originalTransaction?.reportID ?? '456', - transactionID: originalTransaction?.transactionID ?? '234', - amount, - created: originalTransaction?.created ?? DateUtils.getDBTime(), - currency: CONST.CURRENCY.USD, - merchant: originalTransaction?.merchant ?? '', - comment: { - originalTransactionID, - comment: originalTransaction?.comment?.comment ?? '', - splitExpenses: [ - { - transactionID: '235', - amount: amount / 2, - description: 'hey
hey', - created: DateUtils.getDBTime(), - }, - { - transactionID: '234', - amount: amount / 2, - description: '*hey1* `hey`', - created: DateUtils.getDBTime(), - }, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - }; + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - let allReportNameValuePairs: OnyxCollection; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - waitForCollectionCallback: true, - callback: (value) => { - allReportNameValuePairs = value; - }, - }); + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([33366, 33366, 33368]); + expect(amounts.reduce((a, b) => a + b, 0)).toBe(100100); + }); - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: -2, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: undefined, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - await waitForBatchedUpdates(); + it('preserves negative sign and evenly distributes with remainder on last for 3-way split', async () => { + const originalTransactionID = 'orig-neg-3'; + const draftTransaction: Transaction = { + transactionID: 'draft-neg-3', + amount: -100, // in cents = -$1.00 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Negative amount test', + originalTransactionID, + splitExpenses: [ + {transactionID: 'n1', amount: 0, description: 'N1', created: DateUtils.getDBTime()}, + {transactionID: 'n2', amount: 0, description: 'N2', created: DateUtils.getDBTime()}, + {transactionID: 'n3', amount: 0, description: 'N3', created: DateUtils.getDBTime()}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: 'rep-neg-3', + }; - const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); - expect(split1?.reportID).toBe(expenseReport?.reportID); - const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}234`); - expect(split2?.reportID).toBe(expenseReport?.reportID); - }); - - it('should use splitExpensesTotal in calculation when editing splits', async () => { - // The fix ensures we rely on splitExpensesTotal rather than potentially incorrect backend reportTotal - // This prevents scenarios where backend sends wrong total (e.g., -$2 instead of -$10) - // from causing incorrect report totals (e.g., $24 instead of correct -$10) - - const amount = -10000; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let originalTransactionID; - - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); - setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); - await waitForBatchedUpdates(); + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([-33, -33, -34]); + expect(amounts.reduce((a, b) => a + b, 0)).toBe(-100); + }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - }, - }); + it('preserves negative sign for 2-way odd-cent split -> [-$0.51, -$0.50]', async () => { + const originalTransactionID = 'orig-neg-2'; + const draftTransaction: Transaction = { + transactionID: 'draft-neg-2', + amount: -101, // in cents = -$1.01 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Negative amount test 2-way', + originalTransactionID, + splitExpenses: [ + {transactionID: 'nA', amount: 0, description: 'NA', created: DateUtils.getDBTime()}, + {transactionID: 'nB', amount: 0, description: 'NB', created: DateUtils.getDBTime()}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: 'rep-neg-2', + }; - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test Merchant', - comment: 'Test expense', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: 123, - currentUserEmailParam: 'existing@example.com', - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - await waitForBatchedUpdates(); + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); - }, - }); + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([-50, -51]); + expect(amounts.reduce((a, b) => a + b, 0)).toBe(-101); + }); + }); - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; - originalTransactionID = originalMessage?.IOUTransactionID; - }, - }); + describe('updateSplitExpenseAmountField', () => { + it('should update amount expense field to draft transaction', async () => { + const originalTransactionID = '123'; + const currentTransactionID = '789'; + const draftTransaction: Transaction = { + transactionID: '234', + amount: 100, + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Test comment', + originalTransactionID, + splitExpenses: [ + { + transactionID: currentTransactionID, + amount: 50, + description: 'Test comment', + category: 'Food', + tags: ['lunch'], + created: DateUtils.getDBTime(), + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + category: 'Food', + tag: 'lunch', + created: DateUtils.getDBTime(), + reportID: '456', + }; - const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + updateSplitExpenseAmountField(draftTransaction, currentTransactionID, 20); + await waitForBatchedUpdates(); - // Set up split expenses with explicit splitExpensesTotal - // Using negative amounts to get positive transaction amounts (expense reports store as negative) - const splitExpensesTotal = -8000; // -$80 total for splits - const draftTransaction: Transaction = { - reportID: originalTransaction?.reportID ?? '456', - transactionID: originalTransaction?.transactionID ?? '234', - amount, - created: originalTransaction?.created ?? DateUtils.getDBTime(), - currency: CONST.CURRENCY.USD, - merchant: originalTransaction?.merchant ?? '', - comment: { - originalTransactionID, - comment: originalTransaction?.comment?.comment ?? '', - splitExpenses: [ - { - transactionID: '235', - amount: -5000, - description: 'Split 1', - created: DateUtils.getDBTime(), - }, - { - transactionID: '236', - amount: -3000, - description: 'Split 2', - created: DateUtils.getDBTime(), - }, - ], - splitExpensesTotal, - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - }; + const updatedDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + expect(updatedDraftTransaction).toBeTruthy(); - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - let allReportNameValuePairs: OnyxCollection; + const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; + expect(splitExpenses?.[0].amount).toBe(20); + }); + }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - waitForCollectionCallback: true, - callback: (value) => { - allReportNameValuePairs = value; - }, - }); + describe('replaceReceipt', () => { + it('should replace the receipt of the transaction', async () => { + const transactionID = '123'; + const file = new File([new Blob(['test'])], 'test.jpg', {type: 'image/jpeg'}); + file.source = 'test'; + const source = 'test'; - // it should use splitExpensesTotal in its calculation - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: -2, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU: undefined, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); - await waitForBatchedUpdates(); + const transaction = { + transactionID, + receipt: { + source: 'test1', + }, + }; - const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); - const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}236`); - - expect(split1).toBeDefined(); - expect(split2).toBeDefined(); - }); - - it('should create hold report actions for split transactions when original transaction is on hold', async () => { - // Given an expense that is on hold - const amount = 10000; - let expenseReport: OnyxEntry; - let chatReport: OnyxEntry; - let originalTransactionID: string | undefined; - let transactionThreadReportID: string | undefined; - - const policyID = generatePolicyID(); - createWorkspace({ - policyOwnerEmail: CARLOS_EMAIL, - makeMeAdmin: true, - policyName: "Carlos's Workspace for Hold Test", - policyID, - introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, - currentUserAccountIDParam: CARLOS_ACCOUNT_ID, - currentUserEmailParam: CARLOS_EMAIL, - }); + // Given a transaction with a receipt + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await waitForBatchedUpdates(); - // Change the approval mode for the policy since default is Submit and Close - setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); - await waitForBatchedUpdates(); + // Given a snapshot of the transaction + await Onyx.set(`${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`, { + // @ts-expect-error: Allow partial record in snapshot update + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }, + }); + await waitForBatchedUpdates(); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); - }, - }); + // When the receipt is replaced + replaceReceipt({transactionID, file, source, transactionPolicy: undefined}); + await waitForBatchedUpdates(); - // Create the initial expense - requestMoney({ - report: chatReport, - participantParams: { - payeeEmail: RORY_EMAIL, - payeeAccountID: RORY_ACCOUNT_ID, - participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, - }, - transactionParams: { - amount, - attendees: [], - currency: CONST.CURRENCY.USD, - created: '', - merchant: 'Test Merchant', - comment: 'Original expense', - }, - shouldGenerateTransactionThreadReport: true, - isASAPSubmitBetaEnabled: false, - currentUserAccountIDParam: RORY_ACCOUNT_ID, - currentUserEmailParam: RORY_EMAIL, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, + // Then the transaction should have the new receipt source + const updatedTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + Onyx.disconnect(connection); + const newTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + resolve(newTransaction); + }, }); - await waitForBatchedUpdates(); + }); + expect(updatedTransaction?.receipt?.source).toBe(source); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, + // Then the snapshot should have the new receipt source + const updatedSnapshot = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.SNAPSHOT, waitForCollectionCallback: true, - callback: (allReports) => { - expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + callback: (snapshots) => { + Onyx.disconnect(connection); + const newSnapshot = snapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`]; + resolve(newSnapshot); }, }); + }); - // Get the original transaction ID and transaction thread report ID - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - const iouAction = iouActions?.at(0); - const originalMessage = isMoneyRequestAction(iouAction) ? getOriginalMessage(iouAction) : undefined; - originalTransactionID = originalMessage?.IOUTransactionID; - transactionThreadReportID = iouAction?.childReportID; - }, - }); + expect(updatedSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.receipt?.source).toBe(source); + }); - // Put the expense on hold - if (originalTransactionID && transactionThreadReportID) { - putOnHold(originalTransactionID, 'Test hold reason', transactionThreadReportID); - } - await waitForBatchedUpdates(); + it('should add receipt if it does not exist', async () => { + const transactionID = '123'; + const file = new File([new Blob(['test'])], 'test.jpg', {type: 'image/jpeg'}); + file.source = 'test'; + const source = 'test'; - // Verify the transaction is on hold - const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); - expect(originalTransaction?.comment?.hold).toBeDefined(); + const transaction = { + transactionID, + }; - // Get the first IOU action for the split flow - let firstIOU: ReportAction | undefined; - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - firstIOU = iouActions?.at(0); - }, - }); + // Given a transaction without a receipt + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await waitForBatchedUpdates(); - // Create the draft transaction with split expenses - const draftTransaction: Transaction = { - reportID: originalTransaction?.reportID ?? '456', - transactionID: originalTransaction?.transactionID ?? '234', - amount, - created: originalTransaction?.created ?? DateUtils.getDBTime(), - currency: CONST.CURRENCY.USD, - merchant: originalTransaction?.merchant ?? '', - comment: { - originalTransactionID, - comment: originalTransaction?.comment?.comment ?? '', - hold: originalTransaction?.comment?.hold, - splitExpenses: [ - { - transactionID: 'split-held-tx-1', - amount: amount / 2, - description: 'Split 1', - created: DateUtils.getDBTime(), - }, - { - transactionID: 'split-held-tx-2', - amount: amount / 2, - description: 'Split 2', - created: DateUtils.getDBTime(), - }, - ], - attendees: [], - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - }, - }; + // Given a snapshot of the transaction + await Onyx.set(`${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`, { + // @ts-expect-error: Allow partial record in snapshot update + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }, + }); + await waitForBatchedUpdates(); - let allTransactions: OnyxCollection; - let allReports: OnyxCollection; - let allReportNameValuePairs: OnyxCollection; + // When the receipt is replaced + replaceReceipt({transactionID, file, source, transactionPolicy: undefined}); + await waitForBatchedUpdates(); - await getOnyxData({ + // Then the transaction should have the new receipt source + const updatedTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, - callback: (value) => { - allTransactions = value; - }, - }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; + callback: (transactions) => { + Onyx.disconnect(connection); + const newTransaction = transactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + resolve(newTransaction); }, }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + }); + expect(updatedTransaction?.receipt?.source).toBe(source); + + // Then the snapshot should have the new receipt source + const updatedSnapshot = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.SNAPSHOT, waitForCollectionCallback: true, - callback: (value) => { - allReportNameValuePairs = value; + callback: (snapshots) => { + Onyx.disconnect(connection); + const newSnapshot = snapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`]; + resolve(newSnapshot); }, }); + }); - // When splitting the held expense - updateSplitTransactionsFromSplitExpensesFlow({ - allTransactionsList: allTransactions, - allReportsList: allReports, - allReportNameValuePairsList: allReportNameValuePairs, - transactionData: { - reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), - originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), - splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], - splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, - }, - searchContext: { - currentSearchHash: -2, - }, - policyCategories: undefined, - policy: undefined, - policyRecentlyUsedCategories: [], - iouReport: expenseReport, - firstIOU, - isASAPSubmitBetaEnabled: false, - currentUserPersonalDetails, - transactionViolations: {}, - policyRecentlyUsedCurrencies: [], - quickAction: undefined, - }); + expect(updatedSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]?.receipt?.source).toBe(source); + }); + }); - await waitForBatchedUpdates(); + describe('changeTransactionsReport', () => { + it('should set the correct optimistic onyx data for reporting a tracked expense', async () => { + let personalDetailsList: OnyxEntry; + let expenseReport: OnyxEntry; + let transaction: OnyxEntry; + let allTransactions: OnyxCollection = {}; - // Then verify the split transactions were created - const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}split-held-tx-1`); - const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}split-held-tx-2`); + // Given a signed in account, which owns a workspace, and has a policy expense chat + Onyx.set(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + const creatorPersonalDetails = personalDetailsList?.[CARLOS_ACCOUNT_ID] ?? {accountID: CARLOS_ACCOUNT_ID}; - expect(split1).toBeDefined(); - expect(split2).toBeDefined(); + const policyID = generatePolicyID(); + const mockPolicy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM, "Carlos's Workspace"), + id: policyID, + outputCurrency: CONST.CURRENCY.USD, + owner: CARLOS_EMAIL, + ownerAccountID: CARLOS_ACCOUNT_ID, + pendingAction: undefined, + }; - // Find the transaction thread reports for each split by looking at the IOU actions - let split1ThreadReportID: string | undefined; - let split2ThreadReportID: string | undefined; + await waitForBatchedUpdates(); - await getOnyxData({ - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, - callback: (allReportsAction) => { - const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => - isMoneyRequestAction(reportAction), - ); - for (const action of iouActions) { - const message = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; - if (message?.IOUTransactionID === 'split-held-tx-1') { - split1ThreadReportID = action.childReportID; - } else if (message?.IOUTransactionID === 'split-held-tx-2') { - split2ThreadReportID = action.childReportID; - } - } - }, - }); + createNewReport(creatorPersonalDetails, true, false, mockPolicy); + // Create a tracked expense + const selfDMReport: Report = { + ...createRandomReport(1, CONST.REPORT.CHAT_TYPE.SELF_DM), + reportID: '10', + }; - // Verify that split transaction thread IDs exist - expect(split1ThreadReportID).toBeDefined(); - expect(split2ThreadReportID).toBeDefined(); - - // Verify each split transaction thread has hold report actions - // When splitting a held expense, new hold report actions should be created for each split - if (split1ThreadReportID) { - const split1ReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${split1ThreadReportID}`); - const split1HoldActions = Object.values(split1ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD); - const split1CommentActions = Object.values(split1ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); - - // Should have at least one HOLD action and one ADD_COMMENT action (the hold comment) - // The hold actions are created optimistically with pendingAction: ADD, but this - // may be cleared to null after the API call succeeds - expect(split1HoldActions.length).toBeGreaterThanOrEqual(1); - expect(split1CommentActions.length).toBeGreaterThanOrEqual(1); - } + const amount = 100; + + trackExpense({ + report: selfDMReport, + isDraftPolicy: true, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount, + currency: CONST.CURRENCY.USD, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: 'merchant', + billable: false, + reimbursable: false, + }, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + transaction = Object.values(transactions ?? {}).find((t) => !!t); + allTransactions = transactions; + }, + }); - if (split2ThreadReportID) { - const split2ReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${split2ThreadReportID}`); - const split2HoldActions = Object.values(split2ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD); - const split2CommentActions = Object.values(split2ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((r) => r?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); - // Should have at least one HOLD action and one ADD_COMMENT action (the hold comment) - expect(split2HoldActions.length).toBeGreaterThanOrEqual(1); - expect(split2CommentActions.length).toBeGreaterThanOrEqual(1); - } + let iouReportActionOnSelfDMReport: OnyxEntry; + let trackExpenseActionableWhisper: OnyxEntry; + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (allReportActions) => { + iouReportActionOnSelfDMReport = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`] ?? {}).find( + (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU, + ); + trackExpenseActionableWhisper = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport?.reportID}`] ?? {}).find( + (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, + ); + }, + }); + + expect(isMoneyRequestAction(iouReportActionOnSelfDMReport) ? getOriginalMessage(iouReportActionOnSelfDMReport)?.IOUTransactionID : undefined).toBe(transaction?.transactionID); + expect(trackExpenseActionableWhisper).toBeDefined(); + + if (!transaction || !expenseReport) { + return; + } + + const {result} = renderHook(() => { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`, {canBeMissing: true}); + return {report}; + }); + + await waitFor(() => { + expect(result.current.report).toBeDefined(); + }); + + changeTransactionsReport({ + transactionIDs: [transaction?.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: CARLOS_ACCOUNT_ID, + email: CARLOS_EMAIL, + newReport: result.current.report, + allTransactionsCollection: allTransactions, + }); + + let updatedTransaction: OnyxEntry; + let updatedIOUReportActionOnSelfDMReport: OnyxEntry; + let updatedTrackExpenseActionableWhisper: OnyxEntry; + let updatedExpenseReport: OnyxEntry; + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + updatedTransaction = Object.values(transactions ?? {}).find((t) => t?.transactionID === transaction?.transactionID); + }, + }); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (allReportActions) => { + updatedIOUReportActionOnSelfDMReport = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`] ?? {}).find( + (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU, + ); + updatedTrackExpenseActionableWhisper = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport?.reportID}`] ?? {}).find( + (r) => r?.actionName === CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER, + ); + }, + }); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + updatedExpenseReport = Object.values(allReports ?? {}).find((r) => r?.reportID === expenseReport?.reportID); + }, }); + + expect(updatedTransaction?.reportID).toBe(expenseReport?.reportID); + expect(isMoneyRequestAction(updatedIOUReportActionOnSelfDMReport) ? getOriginalMessage(updatedIOUReportActionOnSelfDMReport)?.IOUTransactionID : undefined).toBe(undefined); + expect(updatedTrackExpenseActionableWhisper).toBe(undefined); + expect(updatedExpenseReport?.nonReimbursableTotal).toBe(-amount); + expect(updatedExpenseReport?.total).toBe(-amount); + expect(updatedExpenseReport?.unheldNonReimbursableTotal).toBe(-amount); }); }); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts new file mode 100644 index 0000000000000..26958cebde551 --- /dev/null +++ b/tests/actions/IOUTest/SplitTest.ts @@ -0,0 +1,2157 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import {deepEqual} from 'fast-equals'; +import Onyx from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {requestMoney} from '@libs/actions/IOU'; +import {putOnHold} from '@libs/actions/IOU/Hold'; +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {createWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; +import {rand64} from '@libs/NumberUtils'; +import {getOriginalMessage, isActionOfType, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {buildOptimisticIOUReportAction} from '@libs/ReportUtils'; +import {completeSplitBill, splitBill, startSplitBill, updateSplitTransactionsFromSplitExpensesFlow} from '@userActions/IOU/Split'; +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import DateUtils from '@src/libs/DateUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {RecentlyUsedTags, Report, ReportNameValuePairs, SearchResults} from '@src/types/onyx'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; +import type {Participant} from '@src/types/onyx/Report'; +import type ReportAction from '@src/types/onyx/ReportAction'; +import type Transaction from '@src/types/onyx/Transaction'; +import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import currencyList from '../../unit/currencyList.json'; +import createPersonalDetails from '../../utils/collections/personalDetails'; +import {createRandomReport} from '../../utils/collections/reports'; +import getOnyxValue from '../../utils/getOnyxValue'; +import type {MockFetch} from '../../utils/TestHelper'; +import {getGlobalFetchMock, getOnyxData} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; +import waitForNetworkPromises from '../../utils/waitForNetworkPromises'; + +jest.mock('@src/libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: 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', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const originalModule = jest.requireActual('@src/libs/actions/Report'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...originalModule, + notifyNewAction: jest.fn(), + }; +}); + +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); + +const unapprovedCashHash = 71801560; +jest.mock('@src/libs/SearchQueryUtils', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@src/libs/SearchQueryUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + getCurrentSearchQueryJSON: jest.fn().mockImplementation(() => ({ + hash: unapprovedCashHash, + query: 'test', + type: 'expense', + status: ['drafts', 'outstanding'], + filters: {operator: 'eq', left: 'reimbursable', right: 'yes'}, + flatFilters: [{key: 'reimbursable', filters: [{operator: 'eq', value: 'yes'}]}], + inputQuery: '', + recentSearchHash: 89, + similarSearchHash: 1832274510, + sortBy: 'tag', + sortOrder: 'asc', + })), + buildCannedSearchQuery: jest.fn(), + }; +}); + +// Test user constants +const CARLOS_EMAIL = 'cmartins@expensifail.com'; +const CARLOS_ACCOUNT_ID = 1; +const CARLOS_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; +const JULES_EMAIL = 'jules@expensifail.com'; +const JULES_ACCOUNT_ID = 2; +const JULES_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; +const RORY_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'admin'}; +const VIT_EMAIL = 'vit@expensifail.com'; +const VIT_ACCOUNT_ID = 4; +const VIT_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; + +const currentUserPersonalDetails: CurrentUserPersonalDetails = { + ...createPersonalDetails(RORY_ACCOUNT_ID), + login: RORY_EMAIL, + email: RORY_EMAIL, + displayName: RORY_EMAIL, + avatar: 'https://example.com/avatar.jpg', +}; + +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('split expense', () => { + it('creates and updates new chats and IOUs as needed', () => { + jest.setTimeout(10 * 1000); + /* + * Given that: + * - Rory and Carlos have chatted before + * - Rory and Jules have chatted before and have an active IOU report + * - Rory and Vit have never chatted together before + * - There is no existing group chat with the four of them + */ + const amount = 400; + const comment = 'Yes, I am splitting a bill for $4 USD'; + const merchant = 'Yema Kitchen'; + let carlosChatReport: OnyxEntry = { + reportID: rand64(), + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }; + const carlosCreatedAction: OnyxEntry = { + reportActionID: rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + reportID: carlosChatReport.reportID, + }; + const julesIOUReportID = rand64(); + let julesChatReport: OnyxEntry = { + reportID: rand64(), + type: CONST.REPORT.TYPE.CHAT, + iouReportID: julesIOUReportID, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [JULES_ACCOUNT_ID]: JULES_PARTICIPANT}, + }; + const julesChatCreatedAction: OnyxEntry = { + reportActionID: rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + reportID: julesChatReport.reportID, + }; + const julesCreatedAction: OnyxEntry = { + reportActionID: rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + created: DateUtils.getDBTime(), + reportID: julesIOUReportID, + }; + jest.advanceTimersByTime(200); + const julesExistingTransaction: OnyxEntry = { + transactionID: rand64(), + amount: 1000, + comment: { + comment: 'This is an existing transaction', + attendees: [{email: 'text@expensify.com', displayName: 'Test User', avatarUrl: ''}], + }, + created: DateUtils.getDBTime(), + currency: '', + merchant: '', + reportID: '', + }; + let julesIOUReport: OnyxEntry = { + reportID: julesIOUReportID, + chatReportID: julesChatReport.reportID, + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: RORY_ACCOUNT_ID, + managerID: JULES_ACCOUNT_ID, + currency: CONST.CURRENCY.USD, + total: julesExistingTransaction?.amount, + }; + const julesExistingIOUAction: OnyxEntry = { + reportActionID: rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: RORY_ACCOUNT_ID, + created: DateUtils.getDBTime(), + originalMessage: { + IOUReportID: julesIOUReportID, + IOUTransactionID: julesExistingTransaction?.transactionID, + amount: julesExistingTransaction?.amount ?? 0, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + participantAccountIDs: [RORY_ACCOUNT_ID, JULES_ACCOUNT_ID], + }, + reportID: julesIOUReportID, + }; + + let carlosIOUReport: OnyxEntry; + let carlosIOUAction: OnyxEntry>; + let carlosIOUCreatedAction: OnyxEntry; + let carlosTransaction: OnyxEntry; + + let julesIOUAction: OnyxEntry>; + let julesIOUCreatedAction: OnyxEntry; + let julesTransaction: OnyxEntry; + + let vitChatReport: OnyxEntry; + let vitIOUReport: OnyxEntry; + let vitCreatedAction: OnyxEntry; + let vitIOUAction: OnyxEntry>; + let vitTransaction: OnyxEntry; + + let groupChat: OnyxEntry; + let groupCreatedAction: OnyxEntry; + let groupIOUAction: OnyxEntry>; + let groupTransaction: OnyxEntry; + + const reportCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT, [carlosChatReport, julesChatReport, julesIOUReport], (item) => item.reportID); + + const carlosActionsCollectionDataSet = toCollectionDataSet( + `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, + [ + { + [carlosCreatedAction.reportActionID]: carlosCreatedAction, + }, + ], + (item) => item[carlosCreatedAction.reportActionID].reportID, + ); + + const julesActionsCollectionDataSet = toCollectionDataSet( + `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, + [ + { + [julesCreatedAction.reportActionID]: julesCreatedAction, + [julesExistingIOUAction.reportActionID]: julesExistingIOUAction, + }, + ], + (item) => item[julesCreatedAction.reportActionID].reportID, + ); + + const julesCreatedActionsCollectionDataSet = toCollectionDataSet( + `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, + [ + { + [julesChatCreatedAction.reportActionID]: julesChatCreatedAction, + }, + ], + (item) => item[julesChatCreatedAction.reportActionID].reportID, + ); + + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + return Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, { + ...reportCollectionDataSet, + }) + .then(() => + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + ...carlosActionsCollectionDataSet, + ...julesCreatedActionsCollectionDataSet, + ...julesActionsCollectionDataSet, + }), + ) + .then(() => Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${julesExistingTransaction?.transactionID}`, julesExistingTransaction)) + .then(() => { + // When we split a bill offline + mockFetch?.pause?.(); + splitBill( + // TODO: Migrate after the backend accepts accountIDs + { + participants: [ + [CARLOS_EMAIL, String(CARLOS_ACCOUNT_ID)], + [JULES_EMAIL, String(JULES_ACCOUNT_ID)], + [VIT_EMAIL, String(VIT_ACCOUNT_ID)], + ].map(([email, accountID]) => ({login: email, accountID: Number(accountID)})), + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + amount, + comment, + currency: CONST.CURRENCY.USD, + merchant, + created: '', + tag: '', + existingSplitChatReportID: '', + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + policyRecentlyUsedTags: undefined, + }, + ); + return waitForBatchedUpdates(); + }) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + + // There should now be 10 reports + expect(Object.values(allReports ?? {}).length).toBe(10); + + // 1. The chat report with Rory + Carlos + carlosChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === carlosChatReport?.reportID); + expect(isEmptyObject(carlosChatReport)).toBe(false); + expect(carlosChatReport?.pendingFields).toBeFalsy(); + + // 2. The IOU report with Rory + Carlos (new) + carlosIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU && report.managerID === CARLOS_ACCOUNT_ID); + expect(isEmptyObject(carlosIOUReport)).toBe(false); + expect(carlosIOUReport?.total).toBe(amount / 4); + + // 3. The chat report with Rory + Jules + julesChatReport = Object.values(allReports ?? {}).find((report) => report?.reportID === julesChatReport?.reportID); + expect(isEmptyObject(julesChatReport)).toBe(false); + expect(julesChatReport?.pendingFields).toBeFalsy(); + + // 4. The IOU report with Rory + Jules + julesIOUReport = Object.values(allReports ?? {}).find((report) => report?.reportID === julesIOUReport?.reportID); + expect(isEmptyObject(julesIOUReport)).toBe(false); + expect(julesChatReport?.pendingFields).toBeFalsy(); + expect(julesIOUReport?.total).toBe((julesExistingTransaction?.amount ?? 0) + amount / 4); + + // 5. The chat report with Rory + Vit (new) + vitChatReport = Object.values(allReports ?? {}).find( + (report) => + report?.type === CONST.REPORT.TYPE.CHAT && deepEqual(report.participants, {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [VIT_ACCOUNT_ID]: VIT_PARTICIPANT}), + ); + expect(isEmptyObject(vitChatReport)).toBe(false); + expect(vitChatReport?.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + + // 6. The IOU report with Rory + Vit (new) + vitIOUReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.IOU && report.managerID === VIT_ACCOUNT_ID); + expect(isEmptyObject(vitIOUReport)).toBe(false); + expect(vitIOUReport?.total).toBe(amount / 4); + + // 7. The group chat with everyone + groupChat = Object.values(allReports ?? {}).find( + (report) => + report?.type === CONST.REPORT.TYPE.CHAT && + deepEqual(report.participants, { + [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT, + [JULES_ACCOUNT_ID]: JULES_PARTICIPANT, + [VIT_ACCOUNT_ID]: VIT_PARTICIPANT, + [RORY_ACCOUNT_ID]: RORY_PARTICIPANT, + }), + ); + expect(isEmptyObject(groupChat)).toBe(false); + expect(groupChat?.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); + + // The 1:1 chat reports and the IOU reports should be linked together + expect(carlosChatReport?.iouReportID).toBe(carlosIOUReport?.reportID); + expect(carlosIOUReport?.chatReportID).toBe(carlosChatReport?.reportID); + for (const participant of Object.values(carlosIOUReport?.participants ?? {})) { + expect(participant.notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + } + + expect(julesChatReport?.iouReportID).toBe(julesIOUReport?.reportID); + expect(julesIOUReport?.chatReportID).toBe(julesChatReport?.reportID); + + expect(vitChatReport?.iouReportID).toBe(vitIOUReport?.reportID); + expect(vitIOUReport?.chatReportID).toBe(vitChatReport?.reportID); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (allReportActions) => { + Onyx.disconnect(connection); + + // There should be reportActions on all 7 chat reports + 3 IOU reports in each 1:1 chat + expect(Object.values(allReportActions ?? {}).length).toBe(10); + + const carlosReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${carlosChatReport?.iouReportID}`]; + const julesReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${julesChatReport?.iouReportID}`]; + const vitReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${vitChatReport?.iouReportID}`]; + const groupReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChat?.reportID}`]; + + // Carlos DM should have two reportActions – the existing CREATED action and a pending IOU action + expect(Object.values(carlosReportActions ?? {}).length).toBe(2); + carlosIOUCreatedAction = Object.values(carlosReportActions ?? {}).find( + (reportAction): reportAction is ReportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, + ); + carlosIOUAction = Object.values(carlosReportActions ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const carlosOriginalMessage = carlosIOUAction ? getOriginalMessage(carlosIOUAction) : undefined; + + expect(carlosIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(carlosOriginalMessage?.IOUReportID).toBe(carlosIOUReport?.reportID); + expect(carlosOriginalMessage?.amount).toBe(amount / 4); + expect(carlosOriginalMessage?.comment).toBe(comment); + expect(carlosOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + expect(Date.parse(carlosIOUCreatedAction?.created ?? '')).toBeLessThan(Date.parse(carlosIOUAction?.created ?? '')); + + // Jules DM should have three reportActions, the existing CREATED action, the existing IOU action, and a new pending IOU action + expect(Object.values(julesReportActions ?? {}).length).toBe(3); + expect(julesReportActions?.[julesCreatedAction.reportActionID]).toStrictEqual(julesCreatedAction); + julesIOUCreatedAction = Object.values(julesReportActions ?? {}).find( + (reportAction): reportAction is ReportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, + ); + julesIOUAction = Object.values(julesReportActions ?? {}).find( + (reportAction): reportAction is ReportAction => + reportAction.reportActionID !== julesCreatedAction.reportActionID && reportAction.reportActionID !== julesExistingIOUAction.reportActionID, + ); + const julesOriginalMessage = julesIOUAction ? getOriginalMessage(julesIOUAction) : undefined; + + expect(julesIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(julesOriginalMessage?.IOUReportID).toBe(julesIOUReport?.reportID); + expect(julesOriginalMessage?.amount).toBe(amount / 4); + expect(julesOriginalMessage?.comment).toBe(comment); + expect(julesOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + expect(Date.parse(julesIOUCreatedAction?.created ?? '')).toBeLessThan(Date.parse(julesIOUAction?.created ?? '')); + + // Vit DM should have two reportActions – a pending CREATED action and a pending IOU action + expect(Object.values(vitReportActions ?? {}).length).toBe(2); + vitCreatedAction = Object.values(vitReportActions ?? {}).find( + (reportAction): reportAction is ReportAction => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED, + ); + vitIOUAction = Object.values(vitReportActions ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const vitOriginalMessage = vitIOUAction ? getOriginalMessage(vitIOUAction) : undefined; + + expect(vitCreatedAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(vitIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(vitOriginalMessage?.IOUReportID).toBe(vitIOUReport?.reportID); + expect(vitOriginalMessage?.amount).toBe(amount / 4); + expect(vitOriginalMessage?.comment).toBe(comment); + expect(vitOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.CREATE); + expect(Date.parse(vitCreatedAction?.created ?? '')).toBeLessThan(Date.parse(vitIOUAction?.created ?? '')); + + // Group chat should have two reportActions – a pending CREATED action and a pending IOU action w/ type SPLIT + expect(Object.values(groupReportActions ?? {}).length).toBe(2); + groupCreatedAction = Object.values(groupReportActions ?? {}).find((reportAction) => reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + groupIOUAction = Object.values(groupReportActions ?? {}).find((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const groupOriginalMessage = groupIOUAction ? getOriginalMessage(groupIOUAction) : undefined; + + expect(groupCreatedAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(groupIOUAction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(groupOriginalMessage).not.toHaveProperty('IOUReportID'); + expect(groupOriginalMessage?.type).toBe(CONST.IOU.REPORT_ACTION_TYPE.SPLIT); + expect(Date.parse(groupCreatedAction?.created ?? '')).toBeLessThanOrEqual(Date.parse(groupIOUAction?.created ?? '')); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connection); + + /* There should be 5 transactions + * – one existing one with Jules + * - one for each of the three IOU reports + * - one on the group chat w/ deleted report + */ + expect(Object.values(allTransactions ?? {}).length).toBe(5); + expect(allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${julesExistingTransaction?.transactionID}`]).toBeTruthy(); + + carlosTransaction = Object.values(allTransactions ?? {}).find( + (transaction) => carlosIOUAction && transaction?.transactionID === getOriginalMessage(carlosIOUAction)?.IOUTransactionID, + ); + julesTransaction = Object.values(allTransactions ?? {}).find( + (transaction) => julesIOUAction && transaction?.transactionID === getOriginalMessage(julesIOUAction)?.IOUTransactionID, + ); + vitTransaction = Object.values(allTransactions ?? {}).find( + (transaction) => vitIOUAction && transaction?.transactionID === getOriginalMessage(vitIOUAction)?.IOUTransactionID, + ); + groupTransaction = Object.values(allTransactions ?? {}).find((transaction) => transaction?.reportID === CONST.REPORT.SPLIT_REPORT_ID); + + expect(carlosTransaction?.reportID).toBe(carlosIOUReport?.reportID); + expect(julesTransaction?.reportID).toBe(julesIOUReport?.reportID); + expect(vitTransaction?.reportID).toBe(vitIOUReport?.reportID); + expect(groupTransaction).toBeTruthy(); + + expect(carlosTransaction?.amount).toBe(amount / 4); + expect(julesTransaction?.amount).toBe(amount / 4); + expect(vitTransaction?.amount).toBe(amount / 4); + expect(groupTransaction?.amount).toBe(amount); + + expect(carlosTransaction?.comment?.comment).toBe(comment); + expect(julesTransaction?.comment?.comment).toBe(comment); + expect(vitTransaction?.comment?.comment).toBe(comment); + expect(groupTransaction?.comment?.comment).toBe(comment); + + expect(carlosTransaction?.merchant).toBe(merchant); + expect(julesTransaction?.merchant).toBe(merchant); + expect(vitTransaction?.merchant).toBe(merchant); + expect(groupTransaction?.merchant).toBe(merchant); + + expect(carlosTransaction?.comment?.source).toBe(CONST.IOU.TYPE.SPLIT); + expect(julesTransaction?.comment?.source).toBe(CONST.IOU.TYPE.SPLIT); + expect(vitTransaction?.comment?.source).toBe(CONST.IOU.TYPE.SPLIT); + + expect(carlosTransaction?.comment?.originalTransactionID).toBe(groupTransaction?.transactionID); + expect(julesTransaction?.comment?.originalTransactionID).toBe(groupTransaction?.transactionID); + expect(vitTransaction?.comment?.originalTransactionID).toBe(groupTransaction?.transactionID); + + expect(carlosTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(julesTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(vitTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + expect(groupTransaction?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + waitForCollectionCallback: false, + callback: (allPersonalDetails) => { + Onyx.disconnect(connection); + expect(allPersonalDetails).toMatchObject({ + [VIT_ACCOUNT_ID]: { + accountID: VIT_ACCOUNT_ID, + displayName: VIT_EMAIL, + login: VIT_EMAIL, + }, + }); + resolve(); + }, + }); + }), + ) + .then(mockFetch?.resume) + .then(waitForNetworkPromises) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connection); + for (const report of Object.values(allReports ?? {})) { + if (!report?.pendingFields) { + continue; + } + for (const pendingField of Object.values(report?.pendingFields)) { + expect(pendingField).toBeFalsy(); + } + } + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (allReportActions) => { + Onyx.disconnect(connection); + for (const reportAction of Object.values(allReportActions ?? {})) { + expect(reportAction?.pendingAction).toBeFalsy(); + } + resolve(); + }, + }); + }), + ) + .then( + () => + new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + Onyx.disconnect(connection); + for (const transaction of Object.values(allTransactions ?? {})) { + expect(transaction?.pendingAction).toBeFalsy(); + } + resolve(); + }, + }); + }), + ); + }); + + it('should update split chat report lastVisibleActionCreated to the report preview action', async () => { + // Given a expense chat with no expenses + const workspaceReportID = '1'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, {reportID: workspaceReportID, isOwnPolicyExpenseChat: true}); + + // When the user split bill on the workspace + splitBill({ + participants: [{reportID: workspaceReportID}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + comment: '', + amount: 100, + currency: CONST.CURRENCY.USD, + merchant: 'test', + created: '', + existingSplitChatReportID: workspaceReportID, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + policyRecentlyUsedTags: undefined, + }); + + await waitForBatchedUpdates(); + + // Then the expense chat lastVisibleActionCreated should be updated to the report preview action created + const reportPreviewAction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceReportID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + resolve(Object.values(reportActions ?? {}).find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); + }, + }); + }); + + await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, + callback: (report) => { + Onyx.disconnect(connection); + expect(report?.lastVisibleActionCreated).toBe(reportPreviewAction?.created); + resolve(report); + }, + }); + }); + }); + + it('correctly sets quickAction', async () => { + // Given a expense chat with no expenses + const workspaceReportID = '1'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, {reportID: workspaceReportID, isOwnPolicyExpenseChat: true}); + + splitBill({ + participants: [{reportID: workspaceReportID}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + comment: '', + amount: 100, + currency: CONST.CURRENCY.USD, + merchant: 'test', + created: '', + existingSplitChatReportID: workspaceReportID, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + policyRecentlyUsedTags: undefined, + }); + + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE)).toHaveProperty('isFirstQuickAction', true); + + splitBill({ + participants: [{reportID: workspaceReportID}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + comment: '', + amount: 100, + currency: CONST.CURRENCY.USD, + merchant: 'test', + created: '', + existingSplitChatReportID: workspaceReportID, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: {action: CONST.QUICK_ACTIONS.SEND_MONEY, chatReportID: '456'}, + policyRecentlyUsedCurrencies: [], + policyRecentlyUsedTags: undefined, + }); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE)).toMatchObject({ + action: CONST.QUICK_ACTIONS.SPLIT_MANUAL, + isFirstQuickAction: false, + }); + }); + + it('merges policyRecentlyUsedCurrencies when splitting a bill', async () => { + const initialCurrencies = [CONST.CURRENCY.USD]; + await Onyx.set(ONYXKEYS.RECENTLY_USED_CURRENCIES, initialCurrencies); + + splitBill({ + participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + comment: '', + amount: 100, + currency: CONST.CURRENCY.EUR, + merchant: 'test', + created: '', + existingSplitChatReportID: '', + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: undefined, + policyRecentlyUsedCurrencies: initialCurrencies, + policyRecentlyUsedTags: undefined, + }); + + await waitForBatchedUpdates(); + + const recentlyUsedCurrencies = await getOnyxValue(ONYXKEYS.RECENTLY_USED_CURRENCIES); + expect(recentlyUsedCurrencies).toEqual([CONST.CURRENCY.EUR, ...initialCurrencies]); + }); + + it('should update split chat report lastVisibleActionCreated to the latest IOU action when split bill in a DM', async () => { + // Given a DM chat with no expenses + const reportID = '1'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + reportID, + type: CONST.REPORT.TYPE.CHAT, + participants: {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [CARLOS_ACCOUNT_ID]: CARLOS_PARTICIPANT}, + }); + + // When the user split bill twice on the DM + splitBill({ + participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + comment: '', + amount: 100, + currency: CONST.CURRENCY.USD, + merchant: 'test', + created: '', + existingSplitChatReportID: reportID, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + policyRecentlyUsedTags: undefined, + }); + + await waitForBatchedUpdates(); + + splitBill({ + participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + comment: '', + amount: 200, + currency: CONST.CURRENCY.USD, + merchant: 'test', + created: '', + existingSplitChatReportID: reportID, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + policyRecentlyUsedTags: undefined, + }); + + await waitForBatchedUpdates(); + + // Then the DM lastVisibleActionCreated should be updated to the second IOU action created + const iouAction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + callback: (reportActions) => { + Onyx.disconnect(connection); + resolve(Object.values(reportActions ?? {}).find((action) => isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.IOU) && getOriginalMessage(action)?.amount === 200)); + }, + }); + }); + + const report = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + callback: (reportVal) => { + Onyx.disconnect(connection); + resolve(reportVal); + }, + }); + }); + expect(report?.lastVisibleActionCreated).toBe(iouAction?.created); + }); + + it('optimistic transaction should be merged with the draft transaction if it is a distance request', async () => { + // Given a workspace expense chat and a draft split transaction + const workspaceReportID = '1'; + const transactionAmount = 100; + const draftTransaction = { + amount: transactionAmount, + currency: CONST.CURRENCY.USD, + merchant: 'test', + created: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + splitShares: { + [workspaceReportID]: {amount: 100}, + }, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceReportID}`, {reportID: workspaceReportID, isOwnPolicyExpenseChat: true}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, draftTransaction); + + // When doing a distance split expense + splitBill({ + participants: [{reportID: workspaceReportID}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + existingSplitChatReportID: workspaceReportID, + ...draftTransaction, + comment: '', + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + policyRecentlyUsedTags: undefined, + }); + + await waitForBatchedUpdates(); + + const optimisticTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + Onyx.disconnect(connection); + resolve(Object.values(transactions ?? {}).find((transaction) => transaction?.amount === -(transactionAmount / 2))); + }, + }); + }); + + // Then the data from the transaction draft should be merged into the optimistic transaction + expect(optimisticTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE); + }); + + it("should update the notification preference of the report to ALWAYS if it's previously hidden", async () => { + // Given a group chat with hidden notification preference + const reportID = '1'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + reportID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + participants: { + [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}, + }, + }); + + // When the user split bill on the group chat + splitBill({ + participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + comment: '', + amount: 100, + currency: CONST.CURRENCY.USD, + merchant: 'test', + created: '', + existingSplitChatReportID: reportID, + isASAPSubmitBetaEnabled: false, + transactionViolations: {}, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + policyRecentlyUsedTags: undefined, + }); + + await waitForBatchedUpdates(); + + // Then the DM notification preference should be updated to ALWAYS + const report = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + callback: (reportVal) => { + Onyx.disconnect(connection); + resolve(reportVal); + }, + }); + }); + expect(report?.participants?.[RORY_ACCOUNT_ID].notificationPreference).toBe(CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS); + }); + + it('should update the policyRecentlyUsedTags when tag is provided', async () => { + // Given a policy recently used tags + const policyID = 'A'; + const transactionTag = 'new tag'; + const tagName = 'Tag'; + const policyRecentlyUsedTags: OnyxEntry = { + [tagName]: ['old tag'], + }; + + const policyExpenseChat = { + reportID: '2', + policyID, + isPolicyExpenseChat: true, + isOwnPolicyExpenseChat: true, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [tagName]: {name: tagName}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, policyRecentlyUsedTags); + + // When doing a split bill + splitBill({ + participants: [{isPolicyExpenseChat: true, policyID}], + existingSplitChatReportID: policyExpenseChat.reportID, + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + amount: 1, + created: '', + comment: '', + merchant: '', + transactionViolations: undefined, + category: undefined, + tag: transactionTag, + currency: CONST.CURRENCY.USD, + taxCode: '', + taxAmount: 0, + isASAPSubmitBetaEnabled: false, + policyRecentlyUsedTags, + quickAction: {}, + policyRecentlyUsedCurrencies: [], + }); + + waitForBatchedUpdates(); + + // Then the transaction tag should be added to the recently used tags collection + const newPolicyRecentlyUsedTags: RecentlyUsedTags = await new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, + callback: (recentlyUsedTags) => { + resolve(recentlyUsedTags ?? {}); + Onyx.disconnect(connection); + }, + }); + }); + expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); + expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); + }); + + it('the description should not be parsed again after completing the scan split bill without changing the description', async () => { + const reportID = '1'; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, { + reportID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + participants: { + [RORY_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + [CARLOS_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }); + + // Start a scan split bill + const {splitTransactionID} = startSplitBill({ + participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], + currentUserLogin: RORY_EMAIL, + currentUserAccountID: RORY_ACCOUNT_ID, + comment: '# test', + currency: CONST.CURRENCY.USD, + existingSplitChatReportID: reportID, + receipt: {}, + category: undefined, + tag: undefined, + taxCode: '', + taxAmount: 0, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + policyRecentlyUsedTags: undefined, + }); + + await waitForBatchedUpdates(); + + let splitTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID}`); + + // Then the description should be parsed correctly + expect(splitTransaction?.comment?.comment).toBe('

test

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

test

'); + }); +}); + +describe('startSplitBill', () => { + it('should update the policyRecentlyUsedTags when tag is provided', async () => { + // Given a policy recently used tags + const policyID = 'A'; + const transactionTag = 'new tag'; + const tagName = 'Tag'; + const policyRecentlyUsedTags: OnyxEntry = { + [tagName]: ['old tag'], + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { + [tagName]: {name: tagName}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, policyRecentlyUsedTags); + + // When doing a split bill with a receipt + startSplitBill({ + participants: [{isPolicyExpenseChat: true, policyID}], + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + comment: '', + receipt: {}, + category: undefined, + tag: transactionTag, + currency: CONST.CURRENCY.USD, + taxCode: '', + taxAmount: 0, + policyRecentlyUsedTags, + quickAction: {}, + policyRecentlyUsedCurrencies: [], + }); + + waitForBatchedUpdates(); + + // Then the transaction tag should be added to the recently used tags collection + const newPolicyRecentlyUsedTags: RecentlyUsedTags = await new Promise((resolve) => { + const connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, + callback: (recentlyUsedTags) => { + resolve(recentlyUsedTags ?? {}); + Onyx.disconnect(connection); + }, + }); + }); + expect(newPolicyRecentlyUsedTags[tagName].length).toBe(2); + expect(newPolicyRecentlyUsedTags[tagName].at(0)).toBe(transactionTag); + }); +}); + +describe('updateSplitTransactionsFromSplitExpensesFlow', () => { + it('should delete the original transaction thread report', async () => { + const expenseReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction: Transaction = { + amount: 100, + currency: 'USD', + transactionID: '1', + reportID: expenseReport.reportID, + created: DateUtils.getDBTime(), + merchant: 'test', + }; + const transactionThread: Report = { + ...createRandomReport(2, undefined), + }; + const iouAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: transaction.amount, + currency: transaction.currency, + comment: '', + participants: [], + transactionID: transaction.transactionID, + iouReportID: expenseReport.reportID, + }), + childReportID: transactionThread.reportID, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { + [iouAction.reportActionID]: iouAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + const draftTransaction: OnyxEntry = { + ...transaction, + comment: { + originalTransactionID: transaction.transactionID, + }, + }; + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + searchContext: { + currentSearchHash: -2, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: iouAction, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + + await waitForBatchedUpdates(); + + const originalTransactionThread = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${iouAction.childReportID}`, + callback: (val) => { + Onyx.disconnect(connection); + resolve(val); + }, + }); + }); + expect(originalTransactionThread).toBe(undefined); + }); + + it('should remove the original transaction from the search snapshot data', async () => { + // Given a single expense + const expenseReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction: Transaction = { + amount: 100, + currency: 'USD', + transactionID: '1', + reportID: expenseReport.reportID, + created: DateUtils.getDBTime(), + merchant: 'test', + }; + const transactionThread: Report = { + ...createRandomReport(2, undefined), + }; + const iouAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: transaction.amount, + currency: transaction.currency, + comment: '', + participants: [], + transactionID: transaction.transactionID, + iouReportID: expenseReport.reportID, + }), + childReportID: transactionThread.reportID, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { + [iouAction.reportActionID]: iouAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + const draftTransaction: OnyxEntry = { + ...transaction, + comment: { + originalTransactionID: transaction.transactionID, + }, + }; + + // When splitting the expense + const hash = 1; + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + searchContext: { + currentSearchHash: hash, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + + await waitForBatchedUpdates(); + + // Then the original expense/transaction should be removed from the search snapshot data + const searchSnapshot = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, + callback: (val) => { + Onyx.disconnect(connection); + resolve(val); + }, + }); + }); + expect(searchSnapshot?.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]).toBe(undefined); + }); + + it('should add split transactions optimistically on search snapshot when current search filter is on unapprovedCash', async () => { + const chatReport: Report = createRandomReport(7, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + // Given a single expense + const expenseReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + chatReportID: chatReport.reportID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + const transaction: Transaction = { + amount: 100, + currency: 'USD', + transactionID: '1', + reportID: expenseReport.reportID, + created: DateUtils.getDBTime(), + merchant: 'test', + }; + const transactionThread: Report = { + ...createRandomReport(2, undefined), + }; + const iouAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: transaction.amount, + currency: transaction.currency, + comment: '', + participants: [], + transactionID: transaction.transactionID, + iouReportID: expenseReport.reportID, + }), + childReportID: transactionThread.reportID, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { + [iouAction.reportActionID]: iouAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + const splitTransactionID1 = '34'; + const splitTransactionID2 = '35'; + const draftTransaction: OnyxEntry = { + ...transaction, + comment: { + originalTransactionID: transaction.transactionID, + splitExpenses: [ + {amount: transaction.amount / 2, transactionID: splitTransactionID1, created: ''}, + {amount: transaction.amount / 2, transactionID: splitTransactionID2, created: ''}, + ], + }, + }; + + // When splitting the expense + const hash = 1; + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + searchContext: { + currentSearchHash: hash, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + + await waitForBatchedUpdates(); + + // Then the split expenses/transactions should be added on the search snapshot data + const searchSnapshot = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`, + callback: (val) => { + Onyx.disconnect(connection); + resolve(val); + }, + }); + }); + expect(searchSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID1}`]).toBeDefined(); + expect(searchSnapshot?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID2}`]).toBeDefined(); + }); +}); + +describe('updateSplitTransactionsFromSplitExpensesFlow', () => { + it("should update split transaction's description correctly ", async () => { + const amount = 10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID; + + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + + // Change the approval mode for the policy since default is Submit and Close + setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); + await waitForBatchedUpdates(); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'NASDAQ', + comment: '*hey* `hey`', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + await waitForBatchedUpdates(); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + }, + }); + + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + const draftTransaction: Transaction = { + reportID: originalTransaction?.reportID ?? '456', + transactionID: originalTransaction?.transactionID ?? '234', + amount, + created: originalTransaction?.created ?? DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + merchant: originalTransaction?.merchant ?? '', + comment: { + originalTransactionID, + comment: originalTransaction?.comment?.comment ?? '', + splitExpenses: [ + { + transactionID: '235', + amount: amount / 2, + description: 'hey
hey', + created: DateUtils.getDBTime(), + }, + { + transactionID: '234', + amount: amount / 2, + description: '*hey1* `hey`', + created: DateUtils.getDBTime(), + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + }; + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + searchContext: { + currentSearchHash: -2, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + await waitForBatchedUpdates(); + + const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); + expect(split1?.comment?.comment).toBe('hey
hey'); + const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}234`); + expect(split2?.comment?.comment).toBe('hey1 hey'); + }); + + it("should not create new expense report if the admin split the employee's expense", async () => { + const amount = 10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID; + + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: RORY_EMAIL, + makeMeAdmin: true, + policyName: "Rory's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + }); + + // Change the approval mode for the policy since default is Submit and Close + setWorkspaceApprovalMode(policyID, RORY_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); + await waitForBatchedUpdates(); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: CARLOS_EMAIL, + payeeAccountID: CARLOS_ACCOUNT_ID, + participant: {login: RORY_EMAIL, accountID: RORY_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'NASDAQ', + comment: '*hey* `hey`', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + await waitForBatchedUpdates(); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + }, + }); + + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + const draftTransaction: Transaction = { + reportID: originalTransaction?.reportID ?? '456', + transactionID: originalTransaction?.transactionID ?? '234', + amount, + created: originalTransaction?.created ?? DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + merchant: originalTransaction?.merchant ?? '', + comment: { + originalTransactionID, + comment: originalTransaction?.comment?.comment ?? '', + splitExpenses: [ + { + transactionID: '235', + amount: amount / 2, + description: 'hey
hey', + created: DateUtils.getDBTime(), + }, + { + transactionID: '234', + amount: amount / 2, + description: '*hey1* `hey`', + created: DateUtils.getDBTime(), + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + }; + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + searchContext: { + currentSearchHash: -2, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + await waitForBatchedUpdates(); + + const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); + expect(split1?.reportID).toBe(expenseReport?.reportID); + const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}234`); + expect(split2?.reportID).toBe(expenseReport?.reportID); + }); + + it('should use splitExpensesTotal in calculation when editing splits', async () => { + // The fix ensures we rely on splitExpensesTotal rather than potentially incorrect backend reportTotal + // This prevents scenarios where backend sends wrong total (e.g., -$2 instead of -$10) + // from causing incorrect report totals (e.g., $24 instead of correct -$10) + + const amount = -10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID; + + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + + setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test Merchant', + comment: 'Test expense', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + }, + }); + + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + + // Set up split expenses with explicit splitExpensesTotal + // Using negative amounts to get positive transaction amounts (expense reports store as negative) + const splitExpensesTotal = -8000; // -$80 total for splits + const draftTransaction: Transaction = { + reportID: originalTransaction?.reportID ?? '456', + transactionID: originalTransaction?.transactionID ?? '234', + amount, + created: originalTransaction?.created ?? DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + merchant: originalTransaction?.merchant ?? '', + comment: { + originalTransactionID, + comment: originalTransaction?.comment?.comment ?? '', + splitExpenses: [ + { + transactionID: '235', + amount: -5000, + description: 'Split 1', + created: DateUtils.getDBTime(), + }, + { + transactionID: '236', + amount: -3000, + description: 'Split 2', + created: DateUtils.getDBTime(), + }, + ], + splitExpensesTotal, + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + }; + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + // it should use splitExpensesTotal in its calculation + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + searchContext: { + currentSearchHash: -2, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + await waitForBatchedUpdates(); + + const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}235`); + const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}236`); + + expect(split1).toBeDefined(); + expect(split2).toBeDefined(); + }); + + it('should create hold report actions for split transactions when original transaction is on hold', async () => { + // Given an expense that is on hold + const amount = 10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID: string | undefined; + let transactionThreadReportID: string | undefined; + + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace for Hold Test", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + }); + + // Change the approval mode for the policy since default is Submit and Close + setWorkspaceApprovalMode(policyID, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + + // Create the initial expense + requestMoney({ + report: chatReport, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'Test Merchant', + comment: 'Original expense', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + + // Get the original transaction ID and transaction thread report ID + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const iouAction = iouActions?.at(0); + const originalMessage = isMoneyRequestAction(iouAction) ? getOriginalMessage(iouAction) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + transactionThreadReportID = iouAction?.childReportID; + }, + }); + + // Put the expense on hold + if (originalTransactionID && transactionThreadReportID) { + putOnHold(originalTransactionID, 'Test hold reason', transactionThreadReportID); + } + await waitForBatchedUpdates(); + + // Verify the transaction is on hold + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + expect(originalTransaction?.comment?.hold).toBeDefined(); + + // Get the first IOU action for the split flow + let firstIOU: ReportAction | undefined; + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + firstIOU = iouActions?.at(0); + }, + }); + + // Create the draft transaction with split expenses + const draftTransaction: Transaction = { + reportID: originalTransaction?.reportID ?? '456', + transactionID: originalTransaction?.transactionID ?? '234', + amount, + created: originalTransaction?.created ?? DateUtils.getDBTime(), + currency: CONST.CURRENCY.USD, + merchant: originalTransaction?.merchant ?? '', + comment: { + originalTransactionID, + comment: originalTransaction?.comment?.comment ?? '', + hold: originalTransaction?.comment?.hold, + splitExpenses: [ + { + transactionID: 'split-held-tx-1', + amount: amount / 2, + description: 'Split 1', + created: DateUtils.getDBTime(), + }, + { + transactionID: 'split-held-tx-2', + amount: amount / 2, + description: 'Split 2', + created: DateUtils.getDBTime(), + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + }; + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + + // When splitting the held expense + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: draftTransaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), + originalTransactionID: draftTransaction?.comment?.originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: draftTransaction?.comment?.splitExpenses ?? [], + splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal, + }, + searchContext: { + currentSearchHash: -2, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + }); + + await waitForBatchedUpdates(); + + // Then verify the split transactions were created + const split1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}split-held-tx-1`); + const split2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}split-held-tx-2`); + + expect(split1).toBeDefined(); + expect(split2).toBeDefined(); + + // Find the transaction thread reports for each split by looking at the IOU actions + let split1ThreadReportID: string | undefined; + let split2ThreadReportID: string | undefined; + + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + callback: (allReportsAction) => { + const iouActions = Object.values(allReportsAction ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + for (const action of iouActions) { + const message = isMoneyRequestAction(action) ? getOriginalMessage(action) : undefined; + if (message?.IOUTransactionID === 'split-held-tx-1') { + split1ThreadReportID = action.childReportID; + } else if (message?.IOUTransactionID === 'split-held-tx-2') { + split2ThreadReportID = action.childReportID; + } + } + }, + }); + + // Verify that split transaction thread IDs exist + expect(split1ThreadReportID).toBeDefined(); + expect(split2ThreadReportID).toBeDefined(); + + // Verify each split transaction thread has hold report actions + // When splitting a held expense, new hold report actions should be created for each split + if (split1ThreadReportID) { + const split1ReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${split1ThreadReportID}`); + const split1HoldActions = Object.values(split1ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD); + const split1CommentActions = Object.values(split1ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + + // Should have at least one HOLD action and one ADD_COMMENT action (the hold comment) + // The hold actions are created optimistically with pendingAction: ADD, but this + // may be cleared to null after the API call succeeds + expect(split1HoldActions.length).toBeGreaterThanOrEqual(1); + expect(split1CommentActions.length).toBeGreaterThanOrEqual(1); + } + + if (split2ThreadReportID) { + const split2ReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${split2ThreadReportID}`); + const split2HoldActions = Object.values(split2ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD); + const split2CommentActions = Object.values(split2ReportActions ?? {}).filter((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + + // Should have at least one HOLD action and one ADD_COMMENT action (the hold comment) + expect(split2HoldActions.length).toBeGreaterThanOrEqual(1); + expect(split2CommentActions.length).toBeGreaterThanOrEqual(1); + } + }); +}); From 0b2e5bf64f34f158f2f7b71556795d71f0bbb29e Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 15 Jan 2026 16:09:13 +0700 Subject: [PATCH 02/11] fix UTs --- src/libs/actions/IOU/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index b8eb56334b2e8..2943d27ce7f89 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -46,6 +46,7 @@ import {readFileAsync} from '@libs/fileDownload/FileUtils'; import type {MinimalTransaction} from '@libs/Formula'; import GoogleTagManager from '@libs/GoogleTagManager'; import { + calculateAmount as calculateIOUAmount, formatCurrentUserToAttendee, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep, @@ -12821,6 +12822,12 @@ export { getSearchOnyxUpdate, setMoneyRequestTimeRate, setMoneyRequestTimeCount, + buildMinimalTransactionForFormula, + buildOnyxDataForMoneyRequest, + createSplitsAndOnyxData, + getDeleteTrackExpenseInformation, + getMoneyRequestInformation, + getOrCreateOptimisticSplitChatReport, }; export type { GPSPoint as GpsPoint, @@ -12833,4 +12840,6 @@ export type { PerDiemExpenseTransactionParams, UpdateMoneyRequestData, BasePolicyParams, + MoneyRequestInformationParams, + OneOnOneIOUReport, }; From ba9705a23a0d7ac9c4fe229e15ba233f9de89a5b Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 15 Jan 2026 16:28:27 +0700 Subject: [PATCH 03/11] fix UTs --- .../ReceiptUploadRetryHandler/handleFileRetry.ts | 7 +++---- src/libs/actions/IOU/index.ts | 2 +- tests/actions/IOUTest.ts | 5 ----- .../IOURequestStepConfirmationPageTest.tsx | 12 +++++++++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts b/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts index 7ce351ae4daa2..a8e7dae2cc1b7 100644 --- a/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts +++ b/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts @@ -1,13 +1,12 @@ import * as IOU from '@userActions/IOU'; import {startSplitBill} from '@userActions/IOU/Split'; -import type {StartSplitBilActionParams} from '@userActions/IOU/Split'; import CONST from '@src/CONST'; import type {ReceiptError} from '@src/types/onyx/Transaction'; export default function handleFileRetry(message: ReceiptError, file: File, dismissError: () => void, setShouldShowErrorModal: (value: boolean) => void) { - const retryParams: IOU.ReplaceReceipt | StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation = + const retryParams: IOU.ReplaceReceipt | IOU.StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation = typeof message.retryParams === 'string' - ? (JSON.parse(message.retryParams) as IOU.ReplaceReceipt | StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation) + ? (JSON.parse(message.retryParams) as IOU.ReplaceReceipt | IOU.StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation) : message.retryParams; switch (message.action) { @@ -20,7 +19,7 @@ export default function handleFileRetry(message: ReceiptError, file: File, dismi } case CONST.IOU.ACTION_PARAMS.START_SPLIT_BILL: { dismissError(); - const startSplitBillParams = {...retryParams} as StartSplitBilActionParams; + const startSplitBillParams = {...retryParams} as IOU.StartSplitBilActionParams; startSplitBillParams.receipt = file; startSplitBillParams.shouldPlaySound = false; startSplitBill(startSplitBillParams); diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 2943d27ce7f89..2158f698a4d35 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import {format} from 'date-fns'; +import {eachDayOfInterval, format} from 'date-fns'; import {fastMerge} from 'expensify-common'; import cloneDeep from 'lodash/cloneDeep'; // eslint-disable-next-line you-dont-need-lodash-underscore/union-by diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index aaad67034b0e7..30d3ffc5a276c 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -79,7 +79,6 @@ import type ReportAction from '@src/types/onyx/ReportAction'; import type {ReportActions, ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; import type Transaction from '@src/types/onyx/Transaction'; import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction'; -import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import SafeString from '@src/utils/SafeString'; import {changeTransactionsReport} from '../../src/libs/actions/Transaction'; @@ -166,15 +165,11 @@ jest.mock('@libs/PolicyUtils', () => ({ const CARLOS_EMAIL = 'cmartins@expensifail.com'; const CARLOS_ACCOUNT_ID = 1; const CARLOS_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; -const JULES_EMAIL = 'jules@expensifail.com'; -const JULES_ACCOUNT_ID = 2; -const JULES_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; const RORY_EMAIL = 'rory@expensifail.com'; const RORY_ACCOUNT_ID = 3; const RORY_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'admin'}; const VIT_EMAIL = 'vit@expensifail.com'; const VIT_ACCOUNT_ID = 4; -const VIT_PARTICIPANT: Participant = {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: 'member'}; OnyxUpdateManager(); describe('actions/IOU', () => { diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index c108ccb80360b..6de40eed2ae19 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -16,6 +16,7 @@ import * as IOU from '../../../src/libs/actions/IOU'; import createRandomPolicy from '../../utils/collections/policies'; import {signInWithTestUser, translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; +import { startSplitBill } from '@libs/actions/IOU/Split'; jest.mock('@rnmapbox/maps', () => { return { @@ -29,10 +30,14 @@ jest.mock('@libs/actions/IOU', () => { return { ...actualNav, startMoneyRequest: jest.fn(), - startSplitBill: jest.fn(), requestMoney: jest.fn(() => ({iouReport: undefined})), }; }); +jest.mock('@libs/actions/IOU/Split', () => { + return { + startSplitBill: jest.fn(), + }; +}); jest.mock('@components/ProductTrainingContext', () => ({ useProductTrainingContext: () => [false], })); @@ -57,6 +62,7 @@ jest.mock('@libs/Navigation/Navigation', () => { return { navigate: jest.fn(), goBack: jest.fn(), + dismissModalWithReport: jest.fn(), navigationRef: mockRef, }; }); @@ -291,7 +297,7 @@ describe('IOURequestStepConfirmationPageTest', () => { , ); fireEvent.press(await screen.findByText(translateLocal('iou.splitExpense'))); - expect(IOU.startSplitBill).toHaveBeenCalledTimes(1); + expect(startSplitBill).toHaveBeenCalledTimes(1); }); it('should create a split expense for each scanned receipt', async () => { @@ -340,7 +346,7 @@ describe('IOURequestStepConfirmationPageTest', () => { , ); fireEvent.press(await screen.findByText(translateLocal('iou.createExpenses', 2))); - expect(IOU.startSplitBill).toHaveBeenCalledTimes(2); + expect(startSplitBill).toHaveBeenCalledTimes(2); }); describe('Tax Calculation Tests', () => { From 79a7d9be7e2bf0de2a1e58a1c846d4242786772b Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 15 Jan 2026 17:12:33 +0700 Subject: [PATCH 04/11] prettier --- tests/ui/components/IOURequestStepConfirmationPageTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 6de40eed2ae19..ae14327eea21a 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -6,6 +6,7 @@ import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersona import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {startSplitBill} from '@libs/actions/IOU/Split'; import IOURequestStepConfirmationWithWritableReportOrNotFound from '@pages/iou/request/step/IOURequestStepConfirmation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -16,7 +17,6 @@ import * as IOU from '../../../src/libs/actions/IOU'; import createRandomPolicy from '../../utils/collections/policies'; import {signInWithTestUser, translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; -import { startSplitBill } from '@libs/actions/IOU/Split'; jest.mock('@rnmapbox/maps', () => { return { From 3422efcdfd2bf62d5313904d932579f58fe5845c Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 16 Jan 2026 15:42:25 +0700 Subject: [PATCH 05/11] chore --- src/libs/actions/IOU/Split.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index c2d403962991b..525001d1ecbc2 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -215,6 +215,10 @@ function splitBill({ notifyNewAction(splitData.chatReportID, currentUserAccountID); } +const allPersonalDetails = getAllPersonalDetails(); +const allTransactions = getAllTransactions(); +const allReports = getAllReports(); + /** * @param amount - always in the smallest currency unit */ From ba1f443bd665b05fd52ab837c9b145d8189c3ae5 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 16 Jan 2026 15:58:49 +0700 Subject: [PATCH 06/11] fix UT --- tests/actions/IOUTest/SplitTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 26958cebde551..b9a744e0c666b 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -35,6 +35,7 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ navigate: jest.fn(), dismissModal: jest.fn(), dismissModalWithReport: jest.fn(), + dismissToSuperWideRHP: jest.fn(), goBack: jest.fn(), getTopmostReportId: jest.fn(() => '23423423'), setNavigationActionToMicrotaskQueue: jest.fn(), From d6bca7a947dbb42c2beafacc0d30696962901599 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 16 Jan 2026 16:16:36 +0700 Subject: [PATCH 07/11] chore --- src/libs/actions/IOU/Split.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 525001d1ecbc2..b39d87e20a812 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -215,9 +215,6 @@ function splitBill({ notifyNewAction(splitData.chatReportID, currentUserAccountID); } -const allPersonalDetails = getAllPersonalDetails(); -const allTransactions = getAllTransactions(); -const allReports = getAllReports(); /** * @param amount - always in the smallest currency unit @@ -560,7 +557,7 @@ function startSplitBill({ continue; } - const participantPersonalDetails = allPersonalDetails[participant?.accountID ?? CONST.DEFAULT_NUMBER_ID]; + const participantPersonalDetails = getAllPersonalDetails()[participant?.accountID ?? CONST.DEFAULT_NUMBER_ID]; if (!participantPersonalDetails) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -698,7 +695,7 @@ function completeSplitBill( } const currentUserEmailForIOUSplit = addSMSDomainIfPhoneNumber(sessionEmail); const transactionID = updatedTransaction?.transactionID; - const unmodifiedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const unmodifiedTransaction = getAllTransactions()[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; // Save optimistic updated transaction and action const optimisticData: OnyxUpdate[] = [ @@ -780,7 +777,7 @@ function completeSplitBill( // In case this is still the optimistic accountID saved in the splits array, return early as we cannot know // if there is an existing chat between the split creator and this participant // Instead, we will rely on Auth generating the report IDs and the user won't see any optimistic chats or reports created - const participantPersonalDetails: OnyxTypes.PersonalDetails | null = allPersonalDetails[participant?.accountID ?? CONST.DEFAULT_NUMBER_ID]; + const participantPersonalDetails: OnyxTypes.PersonalDetails | null = getAllPersonalDetails()[participant?.accountID ?? CONST.DEFAULT_NUMBER_ID]; if (!participantPersonalDetails || participantPersonalDetails.isOptimisticPersonalDetail) { splits.push({ email: participant.email, @@ -793,7 +790,7 @@ function completeSplitBill( let isNewOneOnOneChatReport = false; if (isPolicyExpenseChat) { // The expense chat reportID is saved in the splits array when starting a split expense with a workspace - oneOnOneChatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]; + oneOnOneChatReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]; } else { const existingChatReport = getChatByParticipants(participant.accountID ? [participant.accountID, sessionAccountID] : []); isNewOneOnOneChatReport = !existingChatReport; @@ -804,7 +801,7 @@ function completeSplitBill( }); } - let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport?.iouReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] : null; + let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport?.iouReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] : null; const shouldCreateNewOneOnOneIOUReport = shouldCreateNewMoneyRequestReportReportUtils(oneOnOneIOUReport, oneOnOneChatReport, false); // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation @@ -1204,8 +1201,8 @@ function updateSplitTransactions({ } if (Object.keys(transactionChanges).length > 0) { - const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${isReverseSplitOperation ? splitExpense?.reportID : transactionThreadReportID}`]; - const transactionIOUReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpense?.reportID ?? transactionThreadReport?.parentReportID}`]; + const transactionThreadReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${isReverseSplitOperation ? splitExpense?.reportID : transactionThreadReportID}`]; + const transactionIOUReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${splitExpense?.reportID ?? transactionThreadReport?.parentReportID}`]; const {onyxData: moneyRequestParamsOnyxData, params} = getUpdateMoneyRequestParams({ transactionID: existingTransactionID, transactionThreadReport, @@ -1315,7 +1312,7 @@ function updateSplitTransactions({ errors: null, }, }; - const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${firstIOU.childReportID}`] ?? null; + const transactionThread = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${firstIOU.childReportID}`] ?? null; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${firstIOU?.childReportID}`, From 7af8b25fea924ac0ead190bd62a3e09badba5901 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 16 Jan 2026 16:40:51 +0700 Subject: [PATCH 08/11] chore --- src/libs/actions/IOU/Split.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index b39d87e20a812..7a7f97fa8c0db 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -215,7 +215,6 @@ function splitBill({ notifyNewAction(splitData.chatReportID, currentUserAccountID); } - /** * @param amount - always in the smallest currency unit */ From 8fc833357ecbee46dd9259110d797993fa5919ea Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 21 Jan 2026 19:11:40 +0700 Subject: [PATCH 09/11] type fix --- src/libs/actions/IOU/MoneyRequest.ts | 2 +- tests/actions/IOU/MoneyRequestTest.ts | 9 ++++++++- tests/actions/IOUTest/SplitTest.ts | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 12249e8a3ef85..233f421b5e111 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -33,9 +33,9 @@ import { setMoneyRequestParticipantsFromReport, setMoneyRequestPendingFields, setMultipleMoneyRequestParticipantsFromReport, - startSplitBill, trackExpense, } from './index'; +import {startSplitBill} from './Split'; type CreateTransactionParams = { transactions: Transaction[]; diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 9ec270de68557..315263c1ae2b7 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -2,6 +2,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {MoneyRequestStepScanParticipantsFlowParams} from '@libs/actions/IOU/MoneyRequest'; import {createTransaction, handleMoneyRequestStepDistanceNavigation, handleMoneyRequestStepScanParticipants} from '@libs/actions/IOU/MoneyRequest'; +import {startSplitBill} from '@libs/actions/IOU/Split'; import getCurrentPosition from '@libs/getCurrentPosition'; import {GeolocationErrorCode} from '@libs/getCurrentPosition/getCurrentPosition.types'; import Navigation from '@libs/Navigation/Navigation'; @@ -31,6 +32,12 @@ jest.mock('@libs/actions/IOU', () => { }; }); +jest.mock('@libs/actions/IOU/Split', () => { + return { + startSplitBill: jest.fn(), + }; +}); + jest.mock('@src/libs/Navigation/Navigation', () => ({ navigate: jest.fn(), goBack: jest.fn(), @@ -360,7 +367,7 @@ describe('MoneyRequest', () => { await waitForBatchedUpdates(); - expect(IOU.startSplitBill).toHaveBeenCalledWith({ + expect(startSplitBill).toHaveBeenCalledWith({ participants: [ expect.objectContaining({ accountID: 0, diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 02d248491207f..951804863c4f9 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -16,6 +16,7 @@ import IntlStore from '@src/languages/IntlStore'; import DateUtils from '@src/libs/DateUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {RecentlyUsedTags, Report, ReportNameValuePairs, SearchResults} from '@src/types/onyx'; +import type {SplitExpense} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {Participant} from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; From 3b80d9ccf7f716719326dbc2eefd205b51a7517d Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 22 Jan 2026 23:19:48 +0700 Subject: [PATCH 10/11] fix conflict --- src/libs/actions/IOU/Split.ts | 31 ++++++++++------------------ tests/actions/IOUTest/SplitTest.ts | 33 +++++++----------------------- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 77b8384c755f1..e95036ee241ae 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -96,7 +96,6 @@ type UpdateSplitTransactionsParams = { transactionViolations: OnyxCollection; quickAction: OnyxEntry; policyRecentlyUsedCurrencies: string[]; - allBetas: OnyxEntry; iouReportNextStep: OnyxEntry; }; @@ -125,7 +124,6 @@ type SplitBillActionsParams = { transactionViolations: OnyxCollection; quickAction: OnyxEntry; policyRecentlyUsedCurrencies: string[]; - allBetas: OnyxEntry; }; /** @@ -155,7 +153,6 @@ function splitBill({ transactionViolations, quickAction, policyRecentlyUsedCurrencies, - allBetas, policyRecentlyUsedTags, }: SplitBillActionsParams) { const parsedComment = getParsedComment(comment); @@ -185,7 +182,6 @@ function splitBill({ transactionViolations, quickAction, policyRecentlyUsedCurrencies, - allBetas, }); const parameters: SplitBillParams = { @@ -247,7 +243,6 @@ function splitBillAndOpenReport({ transactionViolations, quickAction, policyRecentlyUsedCurrencies, - allBetas, }: SplitBillActionsParams) { const parsedComment = getParsedComment(comment); const {splitData, splits, onyxData} = createSplitsAndOnyxData({ @@ -276,7 +271,6 @@ function splitBillAndOpenReport({ transactionViolations, quickAction, policyRecentlyUsedCurrencies, - allBetas, }); const parameters: SplitBillParams = { @@ -688,7 +682,6 @@ function completeSplitBill( isASAPSubmitBetaEnabled: boolean, quickAction: OnyxEntry, transactionViolations: OnyxCollection, - allBetas: OnyxEntry, sessionEmail?: string, ) { if (!reportAction) { @@ -809,7 +802,7 @@ function completeSplitBill( } let oneOnOneIOUReport: OneOnOneIOUReport = oneOnOneChatReport?.iouReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`] : null; - const shouldCreateNewOneOnOneIOUReport = shouldCreateNewMoneyRequestReportReportUtils(oneOnOneIOUReport, oneOnOneChatReport, false, allBetas); + const shouldCreateNewOneOnOneIOUReport = shouldCreateNewMoneyRequestReportReportUtils(oneOnOneIOUReport, oneOnOneChatReport, false); // Generate IDs upfront so we can pass them to buildOptimisticExpenseReport for formula computation const optimisticTransactionID = NumberUtils.rand64(); @@ -826,16 +819,17 @@ function completeSplitBill( ); oneOnOneIOUReport = isPolicyExpenseChat - ? buildOptimisticExpenseReport({ - chatReportID: oneOnOneChatReport?.reportID, - policyID: participant.policyID, - payeeAccountID: sessionAccountID, - total: splitAmount, - currency: currency ?? '', - allBetas, - optimisticIOUReportID: optimisticExpenseReportID, + ? buildOptimisticExpenseReport( + oneOnOneChatReport?.reportID, + participant.policyID, + sessionAccountID, + splitAmount, + currency ?? '', + undefined, + undefined, + optimisticExpenseReportID, reportTransactions, - }) + ) : buildOptimisticIOUReport(sessionAccountID, participant.accountID ?? CONST.DEFAULT_NUMBER_ID, splitAmount, oneOnOneChatReport?.reportID, currency ?? ''); } else if (isPolicyExpenseChat) { if (typeof oneOnOneIOUReport?.total === 'number') { @@ -993,7 +987,6 @@ function updateSplitTransactions({ transactionViolations, quickAction, policyRecentlyUsedCurrencies, - allBetas, iouReportNextStep, }: UpdateSplitTransactionsParams) { const transactionReport = getReportOrDraftReport(transactionData?.reportID); @@ -1134,7 +1127,6 @@ function updateSplitTransactions({ transactionViolations, quickAction, policyRecentlyUsedCurrencies, - allBetas, } as MoneyRequestInformationParams; if (isReverseSplitOperation) { @@ -1178,7 +1170,6 @@ function updateSplitTransactions({ quickAction, shouldGenerateTransactionThreadReport: !isReverseSplitOperation, policyRecentlyUsedCurrencies, - allBetas, }); let updateMoneyRequestParamsOnyxData: OnyxData = {}; diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 84ada699202c2..d156b368effdf 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -308,7 +308,6 @@ describe('split expense', () => { transactionViolations: {}, quickAction: undefined, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], policyRecentlyUsedTags: undefined, }, ); @@ -350,7 +349,8 @@ describe('split expense', () => { // 5. The chat report with Rory + Vit (new) vitChatReport = Object.values(allReports ?? {}).find( (report) => - report?.type === CONST.REPORT.TYPE.CHAT && deepEqual(report.participants, {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [VIT_ACCOUNT_ID]: VIT_PARTICIPANT}), + report?.type === CONST.REPORT.TYPE.CHAT && + deepEqual(report.participants, {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [VIT_ACCOUNT_ID]: VIT_PARTICIPANT}), ); expect(isEmptyObject(vitChatReport)).toBe(false); expect(vitChatReport?.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); @@ -645,7 +645,6 @@ describe('split expense', () => { transactionViolations: {}, quickAction: undefined, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], policyRecentlyUsedTags: undefined, }); @@ -693,7 +692,6 @@ describe('split expense', () => { transactionViolations: {}, quickAction: undefined, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], policyRecentlyUsedTags: undefined, }); @@ -715,7 +713,6 @@ describe('split expense', () => { transactionViolations: {}, quickAction: {action: CONST.QUICK_ACTIONS.SEND_MONEY, chatReportID: '456'}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], policyRecentlyUsedTags: undefined, }); await waitForBatchedUpdates(); @@ -744,7 +741,6 @@ describe('split expense', () => { transactionViolations: {}, quickAction: undefined, policyRecentlyUsedCurrencies: initialCurrencies, - allBetas: [CONST.BETAS.ALL], policyRecentlyUsedTags: undefined, }); @@ -778,7 +774,6 @@ describe('split expense', () => { transactionViolations: {}, quickAction: undefined, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], policyRecentlyUsedTags: undefined, }); @@ -798,7 +793,6 @@ describe('split expense', () => { transactionViolations: {}, quickAction: undefined, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], policyRecentlyUsedTags: undefined, }); @@ -857,7 +851,6 @@ describe('split expense', () => { transactionViolations: {}, quickAction: undefined, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], policyRecentlyUsedTags: undefined, }); @@ -906,7 +899,6 @@ describe('split expense', () => { transactionViolations: {}, quickAction: undefined, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], policyRecentlyUsedTags: undefined, }); @@ -967,7 +959,6 @@ describe('split expense', () => { policyRecentlyUsedTags, quickAction: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], }); waitForBatchedUpdates(); @@ -1036,7 +1027,7 @@ describe('split expense', () => { expect(iouAction).toBeTruthy(); // Complete this split bill without changing the description - completeSplitBill(reportID, iouAction, updatedSplitTransaction, RORY_ACCOUNT_ID, false, undefined, {}, [CONST.BETAS.ALL], RORY_EMAIL); + completeSplitBill(reportID, iouAction, updatedSplitTransaction, RORY_ACCOUNT_ID, false, undefined, {}, RORY_EMAIL); await waitForBatchedUpdates(); @@ -1168,7 +1159,6 @@ describe('split expense', () => { policyRecentlyUsedCurrencies: [], quickAction: undefined, iouReportNextStep: undefined, - allBetas: [CONST.BETAS.ALL], }); await waitForBatchedUpdates(); @@ -1183,7 +1173,9 @@ describe('split expense', () => { waitForCollectionCallback: true, callback: (transactions) => { Onyx.disconnect(connection); - const splits = Object.values(transactions ?? {}).filter((t) => t?.transactionID !== originalTransactionID && t?.comment?.originalTransactionID === originalTransactionID); + const splits = Object.values(transactions ?? {}).filter( + (t) => t?.transactionID !== originalTransactionID && t?.comment?.originalTransactionID === originalTransactionID, + ); resolve(splits as Transaction[]); }, }); @@ -1335,7 +1327,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currentUserPersonalDetails, transactionViolations: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], quickAction: undefined, iouReportNextStep: undefined, }); @@ -1445,7 +1436,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currentUserPersonalDetails, transactionViolations: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], quickAction: undefined, iouReportNextStep: undefined, }); @@ -1568,7 +1558,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currentUserPersonalDetails, transactionViolations: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], quickAction: undefined, iouReportNextStep: undefined, }); @@ -1639,7 +1628,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], quickAction: undefined, }); await waitForBatchedUpdates(); @@ -1739,7 +1727,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currentUserPersonalDetails, transactionViolations: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], quickAction: undefined, iouReportNextStep: undefined, }); @@ -1799,7 +1786,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], quickAction: undefined, }); await waitForBatchedUpdates(); @@ -1899,7 +1885,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currentUserPersonalDetails, transactionViolations: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], quickAction: undefined, iouReportNextStep: undefined, }); @@ -1964,7 +1949,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], quickAction: undefined, }); await waitForBatchedUpdates(); @@ -2073,7 +2057,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { currentUserPersonalDetails, transactionViolations: {}, policyRecentlyUsedCurrencies: [], - allBetas: [CONST.BETAS.ALL], quickAction: undefined, iouReportNextStep: undefined, }); @@ -2140,7 +2123,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, - allBetas: [CONST.BETAS.ALL], }); await waitForBatchedUpdates(); @@ -2271,7 +2253,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, - allBetas: [CONST.BETAS.ALL], iouReportNextStep: undefined, }); @@ -2334,4 +2315,4 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { expect(split2CommentActions.length).toBeGreaterThanOrEqual(1); } }); -}); +}); \ No newline at end of file From d0f2b1cc5c246d2b41048d6c282c4e6d048dbe3c Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 22 Jan 2026 23:23:10 +0700 Subject: [PATCH 11/11] run prettier --- tests/actions/IOUTest/SplitTest.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index d156b368effdf..951804863c4f9 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -349,8 +349,7 @@ describe('split expense', () => { // 5. The chat report with Rory + Vit (new) vitChatReport = Object.values(allReports ?? {}).find( (report) => - report?.type === CONST.REPORT.TYPE.CHAT && - deepEqual(report.participants, {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [VIT_ACCOUNT_ID]: VIT_PARTICIPANT}), + report?.type === CONST.REPORT.TYPE.CHAT && deepEqual(report.participants, {[RORY_ACCOUNT_ID]: RORY_PARTICIPANT, [VIT_ACCOUNT_ID]: VIT_PARTICIPANT}), ); expect(isEmptyObject(vitChatReport)).toBe(false); expect(vitChatReport?.pendingFields).toStrictEqual({createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); @@ -1173,9 +1172,7 @@ describe('split expense', () => { waitForCollectionCallback: true, callback: (transactions) => { Onyx.disconnect(connection); - const splits = Object.values(transactions ?? {}).filter( - (t) => t?.transactionID !== originalTransactionID && t?.comment?.originalTransactionID === originalTransactionID, - ); + const splits = Object.values(transactions ?? {}).filter((t) => t?.transactionID !== originalTransactionID && t?.comment?.originalTransactionID === originalTransactionID); resolve(splits as Transaction[]); }, }); @@ -2315,4 +2312,4 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { expect(split2CommentActions.length).toBeGreaterThanOrEqual(1); } }); -}); \ No newline at end of file +});