diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 75acac40624ba..6f09a740db24f 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -2,6 +2,7 @@ import {useRoute} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {getArchiveReason} from '@selectors/Report'; +import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -45,6 +46,7 @@ import {setNameValuePair} from '@libs/actions/User'; import {isPersonalCard} from '@libs/CardUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getPlatform from '@libs/getPlatform'; +import {getExistingTransactionID} from '@libs/IOUUtils'; import Log from '@libs/Log'; import {getThreadReportIDsForTransactions, getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -269,6 +271,9 @@ function MoneyReportHeader({ ] as const); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${moneyRequestReport?.reportID}`); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); + const {translate, localeCompare} = useLocalize(); const encryptedAuthToken = session?.encryptedAuthToken ?? ''; @@ -707,6 +712,9 @@ function MoneyReportHeader({ const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; for (const item of transactionList) { + const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; + duplicateTransactionAction({ transaction: item, optimisticChatReportID, @@ -721,6 +729,8 @@ function MoneyReportHeader({ targetPolicy: defaultExpensePolicy ?? undefined, targetPolicyCategories: activePolicyCategories, targetReport: activePolicyExpenseChat, + existingTransactionDraft, + draftTransactionIDs, betas, personalDetails, recentWaypoints, @@ -731,7 +741,9 @@ function MoneyReportHeader({ activePolicyExpenseChat, activePolicyID, allPolicyCategories, + transactionDrafts, defaultExpensePolicy, + draftTransactionIDs, introSelected, isASAPSubmitBetaEnabled, quickAction, diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index b024785c54b66..ceb24605022c5 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -1,5 +1,6 @@ import {useRoute} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; import type {ReactNode} from 'react'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; @@ -29,6 +30,7 @@ import initSplitExpense from '@libs/actions/SplitExpenses'; import {setNameValuePair} from '@libs/actions/User'; import {isPersonalCard} from '@libs/CardUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getExistingTransactionID} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; @@ -163,6 +165,9 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const {removeTransaction} = useSearchActionsContext(); const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); + const {deleteTransactions} = useDeleteTransactions({report: parentReport, reportActions: parentReportAction ? [parentReportAction] : [], policy}); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -209,6 +214,9 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; for (const item of transactions) { + const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; + duplicateTransactionAction({ transaction: item, optimisticChatReportID, @@ -223,6 +231,8 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre targetPolicy: defaultExpensePolicy ?? undefined, targetPolicyCategories: activePolicyCategories, targetReport: activePolicyExpenseChat, + existingTransactionDraft, + draftTransactionIDs, betas, personalDetails, recentWaypoints, @@ -232,7 +242,9 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre [ activePolicyExpenseChat, allPolicyCategories, + transactionDrafts, defaultExpensePolicy, + draftTransactionIDs, isASAPSubmitBetaEnabled, introSelected, activePolicyID, diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index b889a27dff20e..23f5430595a25 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -3,13 +3,14 @@ import type {ValueOf} from 'type-fest'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {OnyxInputOrEntry, PersonalDetails, Policy, Report} from '@src/types/onyx'; +import type {OnyxInputOrEntry, PersonalDetails, Policy, Report, ReportAction} from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import SafeString from '@src/utils/SafeString'; import type {IOURequestType} from './actions/IOU'; import {getCurrencyUnit} from './CurrencyUtils'; import Navigation from './Navigation/Navigation'; import {isPaidGroupPolicy} from './PolicyUtils'; +import {getOriginalMessage, isMoneyRequestAction} from './ReportActionsUtils'; import {getReportTransactions} from './ReportUtils'; import {getCurrency, getTagArrayFromName} from './TransactionUtils'; @@ -356,6 +357,17 @@ function navigateToConfirmationPage( } } +/** + * Get the existing transaction ID from a linked tracked expense report action. + * This is used when moving a transaction from track expense to submit. + */ +function getExistingTransactionID(linkedTrackedExpenseReportAction: ReportAction | undefined): string | undefined { + if (!linkedTrackedExpenseReportAction || !isMoneyRequestAction(linkedTrackedExpenseReportAction)) { + return undefined; + } + return getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID; +} + function calculateDefaultReimbursable({ iouType, policy, @@ -380,6 +392,7 @@ export { calculateAmount, calculateSplitAmountFromPercentage, calculateSplitPercentagesFromAmounts, + getExistingTransactionID, insertTagIntoTransactionTagsString, isIOUReportPendingCurrencyConversion, isMovingTransactionFromTrackExpense, diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index afe50e78edc56..822b7c41ad138 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -502,6 +502,8 @@ type DuplicateExpenseTransactionParams = { targetPolicy?: OnyxEntry; targetPolicyCategories?: OnyxEntry; targetReport?: OnyxTypes.Report; + existingTransactionDraft: OnyxEntry; + draftTransactionIDs: string[]; betas: OnyxEntry; personalDetails: OnyxEntry; recentWaypoints: OnyxEntry; @@ -521,6 +523,8 @@ function duplicateExpenseTransaction({ targetPolicy, targetPolicyCategories, targetReport, + existingTransactionDraft, + draftTransactionIDs, betas, personalDetails, recentWaypoints, @@ -581,6 +585,8 @@ function duplicateExpenseTransaction({ transactionViolations: {}, policyRecentlyUsedCurrencies, quickAction, + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, betas, personalDetails, diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index f16e16b706a00..555252c958422 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -1,7 +1,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; -import {calculateDefaultReimbursable, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import {calculateDefaultReimbursable, getExistingTransactionID, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -58,6 +58,7 @@ type CreateTransactionParams = { policyParams?: {policy: OnyxEntry}; billable?: boolean; reimbursable?: boolean; + allTransactionDrafts: OnyxCollection; isSelfTourViewed: boolean; betas: OnyxEntry; personalDetails: OnyxEntry; @@ -105,6 +106,7 @@ type MoneyRequestStepScanParticipantsFlowParams = { shouldGenerateTransactionThreadReport: boolean; selfDMReport: OnyxEntry; isSelfTourViewed: boolean; + allTransactionDrafts: OnyxCollection; betas: OnyxEntry; recentWaypoints: OnyxEntry; }; @@ -172,11 +174,14 @@ function createTransaction({ policyParams, billable, reimbursable = true, + allTransactionDrafts, isSelfTourViewed, betas, personalDetails, recentWaypoints, }: CreateTransactionParams) { + const draftTransactionIDs = Object.keys(allTransactionDrafts ?? {}); + for (const [index, receiptFile] of files.entries()) { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); const receipt: Receipt = receiptFile.file ?? {}; @@ -212,6 +217,9 @@ function createTransaction({ betas, }); } else { + const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); + const existingTransactionDraft = existingTransactionID ? allTransactionDrafts?.[existingTransactionID] : undefined; + requestMoney({ report, betas, @@ -241,6 +249,8 @@ function createTransaction({ transactionViolations, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, personalDetails, }); @@ -296,6 +306,7 @@ function handleMoneyRequestStepScanParticipants({ locationPermissionGranted = false, selfDMReport, isSelfTourViewed, + allTransactionDrafts, betas, recentWaypoints, }: MoneyRequestStepScanParticipantsFlowParams) { @@ -399,6 +410,7 @@ function handleMoneyRequestStepScanParticipants({ billable: false, reimbursable: defaultReimbursable, isSelfTourViewed, + allTransactionDrafts, betas, personalDetails, recentWaypoints, @@ -425,6 +437,7 @@ function handleMoneyRequestStepScanParticipants({ participant, reimbursable: defaultReimbursable, isSelfTourViewed, + allTransactionDrafts, betas, personalDetails, recentWaypoints, @@ -451,6 +464,7 @@ function handleMoneyRequestStepScanParticipants({ participant, reimbursable: defaultReimbursable, isSelfTourViewed, + allTransactionDrafts, betas, personalDetails, recentWaypoints, diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index c568914379c2f..774bfbd4fb856 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -239,7 +239,7 @@ import type {GuidedSetupData} from '@userActions/Report'; import {buildInviteToRoomOnyxData, completeOnboarding, notifyNewAction, optimisticReportLastData} from '@userActions/Report'; import {clearAllRelatedReportActionErrors} from '@userActions/ReportActions'; import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeRecentWaypoints} from '@userActions/Transaction'; -import {removeDraftTransaction, removeDraftTransactions} from '@userActions/TransactionEdit'; +import {removeDraftTransaction, removeDraftTransactions, removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; import type {OnboardingCompanySize} from '@userActions/Welcome/OnboardingFlow'; import type {IOUAction, IOUActionParams, IOUType, OdometerImageType} from '@src/CONST'; @@ -573,6 +573,8 @@ type RequestMoneyInformation = { transactionViolations: OnyxCollection; quickAction: OnyxEntry; policyRecentlyUsedCurrencies: string[]; + existingTransactionDraft: OnyxEntry; + draftTransactionIDs: string[]; isSelfTourViewed: boolean; betas: OnyxEntry; personalDetails: OnyxEntry; @@ -6583,6 +6585,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep transactionViolations, quickAction, policyRecentlyUsedCurrencies, + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, betas, personalDetails, @@ -6628,14 +6632,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report; const moneyRequestReportID = isMoneyRequestReport ? report?.reportID : ''; const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseIOUUtils(action); - const existingTransactionID = - isMovingTransactionFromTrackExpense && linkedTrackedExpenseReportAction && isMoneyRequestAction(linkedTrackedExpenseReportAction) - ? getOriginalMessage(linkedTrackedExpenseReportAction)?.IOUTransactionID - : undefined; - const existingTransaction = - action === CONST.IOU.ACTION.SUBMIT - ? allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`] - : allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]; + const existingTransactionID = existingTransactionDraft?.transactionID; + const existingTransaction = action === CONST.IOU.ACTION.SUBMIT ? existingTransactionDraft : allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]; const retryParams = { ...requestMoneyInformation, @@ -6819,7 +6817,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep if (shouldHandleNavigation) { // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => removeDraftTransactions()); + InteractionManager.runAfterInteractions(() => removeDraftTransactionsByIDs(draftTransactionIDs)); const trackReport = Navigation.getReportRouteByID(linkedTrackedExpenseReportAction?.childReportID); if (trackReport?.key) { diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 65b61bd764891..57d3ccd8ca791 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -114,6 +114,21 @@ function removeDraftTransactions(shouldExcludeInitialTransaction = false, allTra Onyx.multiSet(draftTransactionsSet); } +function removeDraftTransactionsByIDs(transactionIDs: string[]) { + if (!transactionIDs.length) { + return; + } + + const draftTransactionsSet = transactionIDs.reduce( + (acc, transactionID) => { + acc[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`] = null; + return acc; + }, + {} as Record, + ); + Onyx.multiSet(draftTransactionsSet); +} + function replaceDefaultDraftTransaction(transaction: OnyxEntry) { if (!transaction) { return; @@ -176,6 +191,7 @@ export { removeDraftTransaction, removeTransactionReceipt, removeDraftTransactions, + removeDraftTransactionsByIDs, removeDraftSplitTransaction, replaceDefaultDraftTransaction, buildOptimisticTransactionAndCreateDraft, diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 801a5093003ce..47d353e6cb442 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -1,5 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -22,6 +23,7 @@ import {getIOURequestPolicyID, getMoneyRequestParticipantsFromReport, initMoneyR import DateUtils from '@libs/DateUtils'; import {getFileName, readFileAsync} from '@libs/fileDownload/FileUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; +import {getExistingTransactionID} from '@libs/IOUUtils'; import Log from '@libs/Log'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import Navigation from '@libs/Navigation/Navigation'; @@ -71,6 +73,9 @@ function SubmitDetailsPage({ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); + const [betas] = useOnyx(ONYXKEYS.BETAS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalPolicy = usePersonalPolicy(); @@ -169,6 +174,9 @@ function SubmitDetailsPage({ betas, }); } else { + const existingTransactionID = getExistingTransactionID(transaction.linkedTrackedExpenseReportAction); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; + requestMoney({ report, participantParams: {payeeEmail: currentUserPersonalDetails.login, payeeAccountID: currentUserPersonalDetails.accountID, participant}, @@ -201,6 +209,8 @@ function SubmitDetailsPage({ transactionViolations, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, betas, personalDetails, diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 10fc4bb28146b..3d1bf34fb753e 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -1,5 +1,6 @@ import {useFocusEffect} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputFocused'; @@ -21,7 +22,7 @@ import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import {setTransactionReport} from '@libs/actions/Transaction'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {calculateDefaultReimbursable, isMovingTransactionFromTrackExpense, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import {calculateDefaultReimbursable, getExistingTransactionID, isMovingTransactionFromTrackExpense, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getPolicyExpenseChat, getReportOrDraftReport, getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils'; @@ -129,6 +130,9 @@ function IOURequestStepAmount({ const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); + const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; @@ -245,6 +249,9 @@ function IOURequestStepAmount({ return; } if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { + const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; + requestMoney({ report, betas, @@ -269,6 +276,8 @@ function IOURequestStepAmount({ transactionViolations, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, personalDetails, }); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 844a353571dad..e18cb7f50242a 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -41,6 +41,7 @@ import getCurrentPosition from '@libs/getCurrentPosition'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; import { + getExistingTransactionID, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep, shouldShowReceiptEmptyState, @@ -578,6 +579,8 @@ function IOURequestStepConfirmation({ ); } + const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); + const existingTransactionDraft = transactions.find((tx) => tx.transactionID === existingTransactionID); let merchantToUse = isTestReceipt ? CONST.TEST_RECEIPT.MERCHANT : item.merchant; if (!isTestReceipt && isManualDistanceRequestTransactionUtils(item)) { const distance = item.comment?.customUnit?.quantity; @@ -657,6 +660,8 @@ function IOURequestStepConfirmation({ transactionViolations, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, + existingTransactionDraft, + draftTransactionIDs: transactionIDs, isSelfTourViewed, betas, personalDetails, @@ -665,6 +670,7 @@ function IOURequestStepConfirmation({ } }, [ + transactionIDs, transactions, receiptFiles, privateIsArchivedMap, diff --git a/src/pages/iou/request/step/IOURequestStepScan/useReceiptScan.ts b/src/pages/iou/request/step/IOURequestStepScan/useReceiptScan.ts index fe3bc75857acc..06555dcbae25e 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/useReceiptScan.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/useReceiptScan.ts @@ -25,6 +25,7 @@ import {setMoneyRequestReceipt} from '@userActions/IOU'; import {buildOptimisticTransactionAndCreateDraft, removeDraftTransactions, removeTransactionReceipt} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {validTransactionDraftsSelector} from '@src/selectors/TransactionDraft'; import type Transaction from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import type {ReceiptFile, UseReceiptScanParams} from './types'; @@ -80,6 +81,7 @@ function useReceiptScan({ const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [transactions, optimisticTransactions] = useOptimisticDraftTransactions(initialTransaction); const selfDMReport = useSelfDMReport(); + const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); const isEditing = action === CONST.IOU.ACTION.EDIT; const canUseMultiScan = isStartingScan && iouType !== CONST.IOU.TYPE.SPLIT; @@ -168,6 +170,7 @@ function useReceiptScan({ isSelfTourViewed, betas, recentWaypoints, + allTransactionDrafts, }); } diff --git a/src/selectors/TransactionDraft.ts b/src/selectors/TransactionDraft.ts new file mode 100644 index 0000000000000..92a274165eb09 --- /dev/null +++ b/src/selectors/TransactionDraft.ts @@ -0,0 +1,13 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import type Transaction from '@src/types/onyx/Transaction'; + +const validTransactionDraftsSelector = (drafts: OnyxCollection): Record => + Object.values(drafts ?? {}).reduce>((acc, draft) => { + if (draft) { + acc[draft.transactionID] = draft; + } + return acc; + }, {}); + +// eslint-disable-next-line import/prefer-default-export +export {validTransactionDraftsSelector}; diff --git a/tests/actions/IOU/IOUSettersTest.ts b/tests/actions/IOU/IOUSettersTest.ts new file mode 100644 index 0000000000000..52bf210b6be04 --- /dev/null +++ b/tests/actions/IOU/IOUSettersTest.ts @@ -0,0 +1,469 @@ +import Onyx from 'react-native-onyx'; +import { + addSubrate, + clearSubrates, + computePerDiemExpenseAmount, + removeSubrate, + setMoneyRequestAmount, + setMoneyRequestBillable, + setMoneyRequestCreated, + setMoneyRequestCurrency, + setMoneyRequestDescription, + setMoneyRequestMerchant, + setMoneyRequestReimbursable, + setMoneyRequestTag, + setMoneyRequestTaxAmount, + setMoneyRequestTaxRate, + updateSubrate, +} from '@libs/actions/IOU'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; +import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; +import createRandomTransaction from '../../utils/collections/transaction'; +import getOnyxValue from '../../utils/getOnyxValue'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const TRANSACTION_ID = 'test-txn-1'; + +describe('IOU setter functions', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + describe('setMoneyRequestAmount', () => { + it('should set amount and currency on a transaction draft', async () => { + setMoneyRequestAmount(TRANSACTION_ID, 5000, 'USD'); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.amount).toBe(5000); + expect(draft?.currency).toBe('USD'); + expect(draft?.shouldShowOriginalAmount).toBe(false); + }); + + it('should set shouldShowOriginalAmount when specified', async () => { + setMoneyRequestAmount(TRANSACTION_ID, 3000, 'EUR', true); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.amount).toBe(3000); + expect(draft?.currency).toBe('EUR'); + expect(draft?.shouldShowOriginalAmount).toBe(true); + }); + }); + + describe('setMoneyRequestBillable', () => { + it('should set billable to true on a transaction draft', async () => { + setMoneyRequestBillable(TRANSACTION_ID, true); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.billable).toBe(true); + }); + + it('should set billable to false on a transaction draft', async () => { + setMoneyRequestBillable(TRANSACTION_ID, false); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.billable).toBe(false); + }); + }); + + describe('setMoneyRequestCreated', () => { + it('should set created date on a transaction draft', async () => { + setMoneyRequestCreated(TRANSACTION_ID, '2024-01-15', true); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.created).toBe('2024-01-15'); + }); + + it('should set created date on a real transaction when isDraft is false', async () => { + const transaction = createRandomTransaction(1); + transaction.transactionID = TRANSACTION_ID; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + setMoneyRequestCreated(TRANSACTION_ID, '2024-06-01', false); + await waitForBatchedUpdates(); + + const updated = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`); + expect(updated?.created).toBe('2024-06-01'); + }); + }); + + describe('setMoneyRequestCurrency', () => { + it('should set currency on a transaction draft', async () => { + setMoneyRequestCurrency(TRANSACTION_ID, 'GBP'); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.currency).toBe('GBP'); + }); + + it('should set modifiedCurrency when isEditing is true', async () => { + setMoneyRequestCurrency(TRANSACTION_ID, 'JPY', true); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.modifiedCurrency).toBe('JPY'); + }); + }); + + describe('setMoneyRequestDescription', () => { + it('should set trimmed comment on a draft transaction', async () => { + setMoneyRequestDescription(TRANSACTION_ID, ' Test description ', true); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.comment?.comment).toBe('Test description'); + }); + + it('should set comment on a real transaction when isDraft is false', async () => { + const transaction = createRandomTransaction(1); + transaction.transactionID = TRANSACTION_ID; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + setMoneyRequestDescription(TRANSACTION_ID, 'Lunch with team', false); + await waitForBatchedUpdates(); + + const updated = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`); + expect(updated?.comment?.comment).toBe('Lunch with team'); + }); + }); + + describe('setMoneyRequestMerchant', () => { + it('should set merchant on a draft transaction', async () => { + setMoneyRequestMerchant(TRANSACTION_ID, 'Starbucks', true); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.merchant).toBe('Starbucks'); + }); + + it('should set merchant on a real transaction when isDraft is false', async () => { + const transaction = createRandomTransaction(1); + transaction.transactionID = TRANSACTION_ID; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + setMoneyRequestMerchant(TRANSACTION_ID, 'Amazon', false); + await waitForBatchedUpdates(); + + const updated = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`); + expect(updated?.merchant).toBe('Amazon'); + }); + }); + + describe('setMoneyRequestTag', () => { + it('should set tag on a transaction draft', async () => { + setMoneyRequestTag(TRANSACTION_ID, 'Engineering'); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.tag).toBe('Engineering'); + }); + }); + + describe('setMoneyRequestTaxAmount', () => { + it('should set taxAmount on a draft transaction by default', async () => { + setMoneyRequestTaxAmount(TRANSACTION_ID, 500); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.taxAmount).toBe(500); + }); + + it('should set taxAmount to null', async () => { + setMoneyRequestTaxAmount(TRANSACTION_ID, null); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.taxAmount ?? null).toBeNull(); + }); + + it('should set taxAmount on a real transaction when isDraft is false', async () => { + const transaction = createRandomTransaction(1); + transaction.transactionID = TRANSACTION_ID; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + setMoneyRequestTaxAmount(TRANSACTION_ID, 750, false); + await waitForBatchedUpdates(); + + const updated = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`); + expect(updated?.taxAmount).toBe(750); + }); + }); + + describe('setMoneyRequestTaxRate', () => { + it('should set taxCode on a draft transaction by default', async () => { + setMoneyRequestTaxRate(TRANSACTION_ID, 'TAX_10'); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.taxCode).toBe('TAX_10'); + }); + + it('should set taxCode to null', async () => { + setMoneyRequestTaxRate(TRANSACTION_ID, null); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.taxCode ?? null).toBeNull(); + }); + + it('should set taxCode on a real transaction when isDraft is false', async () => { + const transaction = createRandomTransaction(1); + transaction.transactionID = TRANSACTION_ID; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + setMoneyRequestTaxRate(TRANSACTION_ID, 'TAX_20', false); + await waitForBatchedUpdates(); + + const updated = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`); + expect(updated?.taxCode).toBe('TAX_20'); + }); + }); + + describe('setMoneyRequestReimbursable', () => { + it('should set reimbursable on a transaction draft', async () => { + setMoneyRequestReimbursable(TRANSACTION_ID, true); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.reimbursable).toBe(true); + }); + + it('should set reimbursable to false', async () => { + setMoneyRequestReimbursable(TRANSACTION_ID, false); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.reimbursable).toBe(false); + }); + }); +}); + +describe('computePerDiemExpenseAmount', () => { + it('should compute total amount from subRates', () => { + const customUnit: TransactionCustomUnit = { + subRates: [ + {id: '1', quantity: 2, name: 'Full Day', rate: 10000}, + {id: '2', quantity: 1, name: 'Half Day', rate: 5000}, + ], + }; + // 2 * 10000 + 1 * 5000 = 25000 + expect(computePerDiemExpenseAmount(customUnit)).toBe(25000); + }); + + it('should return 0 when subRates is empty', () => { + const customUnit: TransactionCustomUnit = { + subRates: [], + }; + expect(computePerDiemExpenseAmount(customUnit)).toBe(0); + }); + + it('should return 0 when subRates is undefined', () => { + const customUnit: TransactionCustomUnit = {}; + expect(computePerDiemExpenseAmount(customUnit)).toBe(0); + }); + + it('should handle single subRate', () => { + const customUnit: TransactionCustomUnit = { + subRates: [{id: '1', quantity: 3, name: 'Meals', rate: 2500}], + }; + expect(computePerDiemExpenseAmount(customUnit)).toBe(7500); + }); +}); + +describe('Subrate operations', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + function createTransactionWithSubrates(subRates: Array<{id: string; quantity: number; name: string; rate: number}>): Transaction { + const transaction = createRandomTransaction(1); + transaction.transactionID = TRANSACTION_ID; + transaction.comment = { + ...transaction.comment, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + subRates, + }, + }; + return transaction; + } + + describe('addSubrate', () => { + it('should add a subrate at the correct index', async () => { + const transaction = createTransactionWithSubrates([{id: '1', quantity: 1, name: 'Day 1', rate: 10000}]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + addSubrate(transaction, '1', 2, '2', 'Day 2', 5000); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + const subRates = draft?.comment?.customUnit?.subRates ?? []; + expect(subRates).toHaveLength(2); + expect(subRates.at(1)).toEqual(expect.objectContaining({id: '2', quantity: 2, name: 'Day 2', rate: 5000})); + }); + + it('should not add subrate when index is -1', async () => { + const transaction = createTransactionWithSubrates([{id: '1', quantity: 1, name: 'Day 1', rate: 10000}]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + addSubrate(transaction, '-1', 2, '2', 'Day 2', 5000); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + const subRates = draft?.comment?.customUnit?.subRates ?? []; + expect(subRates).toHaveLength(1); + }); + + it('should not add subrate when index does not match the length', async () => { + const transaction = createTransactionWithSubrates([{id: '1', quantity: 1, name: 'Day 1', rate: 10000}]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + // index 0 !== length 1, should not add + addSubrate(transaction, '0', 2, '2', 'Day 2', 5000); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + const subRates = draft?.comment?.customUnit?.subRates ?? []; + expect(subRates).toHaveLength(1); + }); + + it('should add first subrate when transaction has no existing subrates', async () => { + const transaction = createRandomTransaction(1); + transaction.transactionID = TRANSACTION_ID; + transaction.comment = {...transaction.comment, customUnit: {name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL}}; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + addSubrate(transaction, '0', 1, '1', 'Day 1', 10000); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + const subRates = draft?.comment?.customUnit?.subRates ?? []; + expect(subRates).toHaveLength(1); + expect(subRates.at(0)).toEqual(expect.objectContaining({id: '1', quantity: 1, name: 'Day 1', rate: 10000})); + }); + }); + + describe('removeSubrate', () => { + it('should remove a subrate at the specified index', async () => { + const transaction = createTransactionWithSubrates([ + {id: '1', quantity: 1, name: 'Day 1', rate: 10000}, + {id: '2', quantity: 2, name: 'Day 2', rate: 5000}, + ]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + removeSubrate(transaction, '0'); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + const subRates = draft?.comment?.customUnit?.subRates ?? []; + expect(subRates).toHaveLength(1); + expect(subRates.at(0)).toEqual(expect.objectContaining({id: '2'})); + }); + + it('should not remove subrate when index is -1', async () => { + const transaction = createTransactionWithSubrates([{id: '1', quantity: 1, name: 'Day 1', rate: 10000}]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + removeSubrate(transaction, '-1'); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + const subRates = draft?.comment?.customUnit?.subRates ?? []; + expect(subRates).toHaveLength(1); + }); + }); + + describe('updateSubrate', () => { + it('should update a subrate at the specified index', async () => { + const transaction = createTransactionWithSubrates([ + {id: '1', quantity: 1, name: 'Day 1', rate: 10000}, + {id: '2', quantity: 2, name: 'Day 2', rate: 5000}, + ]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + updateSubrate(transaction, '1', 3, '2', 'Day 2 Updated', 7500); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + const subRates = draft?.comment?.customUnit?.subRates ?? []; + expect(subRates).toHaveLength(2); + expect(subRates.at(1)).toEqual(expect.objectContaining({id: '2', quantity: 3, name: 'Day 2 Updated', rate: 7500})); + }); + + it('should not update when index is -1', async () => { + const transaction = createTransactionWithSubrates([{id: '1', quantity: 1, name: 'Day 1', rate: 10000}]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + updateSubrate(transaction, '-1', 3, '1', 'Updated', 7500); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + const subRates = draft?.comment?.customUnit?.subRates ?? []; + expect(subRates.at(0)).toEqual(expect.objectContaining({name: 'Day 1', rate: 10000})); + }); + + it('should not update when index is out of bounds', async () => { + const transaction = createTransactionWithSubrates([{id: '1', quantity: 1, name: 'Day 1', rate: 10000}]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + updateSubrate(transaction, '5', 3, '1', 'Updated', 7500); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + const subRates = draft?.comment?.customUnit?.subRates ?? []; + expect(subRates).toHaveLength(1); + expect(subRates.at(0)).toEqual(expect.objectContaining({name: 'Day 1'})); + }); + }); + + describe('clearSubrates', () => { + it('should clear all subrates on a transaction draft', async () => { + const transaction = createTransactionWithSubrates([ + {id: '1', quantity: 1, name: 'Day 1', rate: 10000}, + {id: '2', quantity: 2, name: 'Day 2', rate: 5000}, + ]); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + clearSubrates(TRANSACTION_ID); + await waitForBatchedUpdates(); + + const draft = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draft?.comment?.customUnit?.subRates).toEqual([]); + }); + }); +}); diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 1283720033631..9be0fdc46418d 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -91,6 +91,7 @@ describe('MoneyRequest', () => { files: [fakeReceiptFile], participant: {accountID: 222, login: 'test@test.com'}, quickAction: fakeQuickAction, + allTransactionDrafts: {}, selfDMReport, isSelfTourViewed: false, betas: [CONST.BETAS.ALL], @@ -254,6 +255,39 @@ describe('MoneyRequest', () => { ); }); + it('should pass existingTransactionDraft and draftTransactionIDs to requestMoney when allTransactionDrafts is provided', () => { + const draftTransaction = createRandomTransaction(99); + const linkedAction = { + reportActionID: 'action1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '', + originalMessage: { + IOUTransactionID: draftTransaction.transactionID, + IOUReportID: 'report456', + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + }; + const transactionWithLinkedAction = { + ...fakeTransaction, + linkedTrackedExpenseReportAction: linkedAction, + }; + + createTransaction({ + ...baseParams, + transactions: [transactionWithLinkedAction], + allTransactionDrafts: { + [draftTransaction.transactionID]: draftTransaction, + }, + }); + + expect(IOU.requestMoney).toHaveBeenCalledWith( + expect.objectContaining({ + existingTransactionDraft: draftTransaction, + draftTransactionIDs: [draftTransaction.transactionID], + }), + ); + }); + it('should pass billable and reimbursable flags to trackExpense', () => { createTransaction({ ...baseParams, @@ -272,6 +306,39 @@ describe('MoneyRequest', () => { ); }); + it('should pass undefined existingTransactionDraft when no matching draft exists', () => { + createTransaction({ + ...baseParams, + allTransactionDrafts: {}, + }); + + expect(IOU.requestMoney).toHaveBeenCalledWith( + expect.objectContaining({ + existingTransactionDraft: undefined, + draftTransactionIDs: [], + }), + ); + }); + + it('should compute draftTransactionIDs from allTransactionDrafts', () => { + const draft1 = createRandomTransaction(101); + const draft2 = createRandomTransaction(102); + + createTransaction({ + ...baseParams, + allTransactionDrafts: { + [draft1.transactionID]: draft1, + [draft2.transactionID]: draft2, + }, + }); + + expect(IOU.requestMoney).toHaveBeenCalledWith( + expect.objectContaining({ + draftTransactionIDs: expect.arrayContaining([draft1.transactionID, draft2.transactionID]), + }), + ); + }); + it('should pass gpsPoint to trackExpense when provided', () => { const gpsPoint = {lat: TEST_LATITUDE, long: TEST_LONGITUDE}; createTransaction({ @@ -344,6 +411,7 @@ describe('MoneyRequest', () => { isSelfTourViewed: false, betas: [], recentWaypoints: [] as RecentWaypoint[], + allTransactionDrafts: {}, }; beforeEach(async () => { diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 67fc6dc9a93f3..4436965423a0b 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -1725,6 +1725,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -1983,6 +1985,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -2214,6 +2218,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -2381,6 +2387,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -2899,6 +2907,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -2929,6 +2939,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -2959,6 +2971,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: RORY_ACCOUNT_ID, currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: true, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -3007,6 +3021,8 @@ describe('actions/IOU', () => { transactionViolations: {}, currentUserAccountIDParam: 123, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, currentUserEmailParam: 'existing@example.com', quickAction: undefined, @@ -3050,6 +3066,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -3118,6 +3136,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: currentUserPersonalDetails.accountID, currentUserEmailParam: currentUserPersonalDetails.login ?? '', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -3186,6 +3206,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: testPersonalDetails, betas: [CONST.BETAS.ALL], }); @@ -3257,6 +3279,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: testPersonalDetails, betas: [CONST.BETAS.ALL], }); @@ -3299,6 +3323,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, betas: [CONST.BETAS.ALL], }); @@ -3454,6 +3480,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, + existingTransactionDraft: transaction, + draftTransactionIDs: [], personalDetails: {}, betas: [CONST.BETAS.ALL], }); @@ -3519,6 +3547,8 @@ describe('actions/IOU', () => { quickAction: undefined, isSelfTourViewed: false, betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, }); return waitForBatchedUpdates(); @@ -3604,6 +3634,8 @@ describe('actions/IOU', () => { quickAction: undefined, isSelfTourViewed: false, betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, }); return waitForBatchedUpdates(); @@ -3680,6 +3712,8 @@ describe('actions/IOU', () => { quickAction: undefined, isSelfTourViewed: false, betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, }); return waitForBatchedUpdates(); @@ -3751,6 +3785,8 @@ describe('actions/IOU', () => { quickAction: undefined, isSelfTourViewed: false, betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, }); return waitForBatchedUpdates(); @@ -3823,6 +3859,8 @@ describe('actions/IOU', () => { quickAction: undefined, isSelfTourViewed: false, betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, }); return waitForBatchedUpdates(); @@ -6099,6 +6137,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -6358,6 +6398,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -6512,6 +6554,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -6868,6 +6912,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -6986,6 +7032,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -7235,6 +7283,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -7862,6 +7912,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -7940,6 +7992,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -8095,6 +8149,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, }); @@ -8813,6 +8869,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -8944,6 +9002,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: initialCurrencies, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -9021,6 +9081,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [], personalDetails: {}, }); @@ -9067,6 +9129,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [], personalDetails: {}, }); @@ -9274,6 +9338,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [], personalDetails: {}, }); @@ -9320,6 +9386,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [], personalDetails: {}, }); @@ -9496,6 +9564,8 @@ describe('actions/IOU', () => { currentUserEmailParam: RORY_EMAIL, transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -10027,6 +10097,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -10101,6 +10173,8 @@ describe('actions/IOU', () => { isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, }); @@ -11106,6 +11180,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -11205,6 +11281,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -11576,6 +11654,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -11739,6 +11819,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -11907,6 +11989,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], @@ -12085,6 +12169,8 @@ describe('actions/IOU', () => { currentUserEmailParam: RORY_EMAIL, transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index a331fc611eb66..d30633430e14a 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -26,7 +26,7 @@ import createRandomPolicy from '../../utils/collections/policies'; import createRandomPolicyCategories from '../../utils/collections/policyCategory'; import createRandomReportAction from '../../utils/collections/reportActions'; import {createRandomReport} from '../../utils/collections/reports'; -import createRandomTransaction, {createRandomDistanceRequestTransaction} from '../../utils/collections/transaction'; +import createRandomTransaction from '../../utils/collections/transaction'; import getOnyxValue from '../../utils/getOnyxValue'; import {getGlobalFetchMock, getOnyxData} from '../../utils/TestHelper'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; @@ -559,6 +559,84 @@ describe('actions/Duplicate', () => { }), ); }); + + it('should create an optimistic transaction thread report when transactionThreadReportID is provided', async () => { + // Given: Set up test data with main transaction and a duplicate, plus an IOU action for the main transaction + const reportID = 'report123'; + const chatReportID = 'chatReport123'; + const mainTransactionID = 'main123'; + const duplicate1ID = 'dup456'; + const duplicateTransactionIDs = [duplicate1ID]; + const optimisticTransactionThreadReportID = 'optimisticThread999'; + + const mainTransaction = createMockTransaction(mainTransactionID, reportID, 150); + const duplicateTransaction1 = createMockTransaction(duplicate1ID, reportID, 100); + const expenseReport: Report = { + ...createMockReport(reportID, 250), + chatReportID, + parentReportID: chatReportID, + }; + + const mainViolations = createMockViolations(); + const duplicate1Violations = createMockViolations(); + + // Create an IOU action for the main transaction so getIOUActionForReportID can find it + const mainIouAction = createMockIouAction(mainTransactionID, 'mainAction123', '', reportID); + const dupIouAction = createMockIouAction(duplicate1ID, 'action456', '', reportID); + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${mainTransactionID}`, mainTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${duplicate1ID}`, duplicateTransaction1); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, expenseReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${mainTransactionID}`, mainViolations); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${duplicate1ID}`, duplicate1Violations); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + mainAction123: mainIouAction, + action456: dupIouAction, + }); + await waitForBatchedUpdates(); + + const mergeParams = { + transactionID: mainTransactionID, + transactionIDList: duplicateTransactionIDs, + transactionThreadReportID: optimisticTransactionThreadReportID, + created: '2024-01-01 12:00:00', + merchant: 'Updated Merchant', + amount: 200, + currency: CONST.CURRENCY.EUR, + category: 'Travel', + comment: 'Updated comment', + billable: true, + reimbursable: false, + tag: 'UpdatedProject', + receiptID: 123, + reportID, + }; + + // When: Call mergeDuplicates with transactionThreadReportID + mergeDuplicates(mergeParams); + await waitForBatchedUpdates(); + + // Then: Verify the optimistic transaction thread report was created + const optimisticThreadReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThreadReportID}`); + expect(optimisticThreadReport).toBeTruthy(); + expect(optimisticThreadReport?.pendingFields?.createChat).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); + + // Then: Verify API was called with the transactionThreadReportID and createdReportActionIDForThread + expect(writeSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.MERGE_DUPLICATES, + expect.objectContaining({ + transactionThreadReportID: optimisticTransactionThreadReportID, + createdReportActionIDForThread: expect.any(String), + }), + expect.objectContaining({ + optimisticData: expect.arrayContaining([ + expect.objectContaining({ + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTransactionThreadReportID}`, + }), + ]), + }), + ); + }); }); describe('resolveDuplicates', () => { @@ -959,6 +1037,8 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: mockPersonalDetails, betas: [CONST.BETAS.ALL], recentWaypoints, @@ -1019,6 +1099,8 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: mockPersonalDetails, betas: [CONST.BETAS.ALL], recentWaypoints, @@ -1046,19 +1128,249 @@ describe('actions/Duplicate', () => { expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); }); - it('should create a duplicate distance expense with all fields duplicated', async () => { - const randomDistanceTransaction = createRandomDistanceRequestTransaction(1, true); + it('should create a duplicate expense successfully (previously with transaction drafts)', async () => { + const {waypoints, ...restOfComment} = mockTransaction.comment ?? {}; + const mockCashExpenseTransaction = { + ...mockTransaction, + amount: mockTransaction.amount * -1, + comment: { + ...restOfComment, + }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockCashExpenseTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: '', + targetPolicy: mockPolicy, + targetPolicyCategories: fakePolicyCategories, + targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + betas: [CONST.BETAS.ALL], + personalDetails: {}, + recentWaypoints: [], + }); + + await waitForBatchedUpdates(); + + let duplicatedTransaction: OnyxEntry; + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t); + }, + }); + + // Verify that a duplicated transaction was created + expect(duplicatedTransaction).toBeDefined(); + expect(duplicatedTransaction?.transactionID).toBeDefined(); + // The duplicated transaction should have a different transactionID than the original + expect(duplicatedTransaction?.transactionID).not.toBe(mockCashExpenseTransaction.transactionID); + }); + + it('should create a duplicate expense successfully (previously with undefined transaction drafts)', async () => { + const {waypoints, ...restOfComment} = mockTransaction.comment ?? {}; + const mockCashExpenseTransaction = { + ...mockTransaction, + amount: mockTransaction.amount * -1, + comment: { + ...restOfComment, + }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockCashExpenseTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + targetPolicy: mockPolicy, + targetPolicyCategories: fakePolicyCategories, + targetReport: policyExpenseChat, + isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + betas: [CONST.BETAS.ALL], + personalDetails: {}, + recentWaypoints: [], + }); + + await waitForBatchedUpdates(); + + let duplicatedTransaction: OnyxEntry; + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t); + }, + }); + + // Verify that a duplicated transaction was created + expect(duplicatedTransaction).toBeDefined(); + expect(duplicatedTransaction?.transactionID).toBeDefined(); + expect(duplicatedTransaction?.transactionID).not.toBe(mockCashExpenseTransaction.transactionID); + }); + + it('should create a duplicate time expense successfully (previously with transaction drafts)', async () => { + const transactionID = 'time-2'; + const HOURLY_RATE = 12.5; + const HOURS_WORKED = 8; + const AMOUNT_CENTS = Math.round(HOURS_WORKED * HOURLY_RATE * 100); + + const mockTimeExpenseTransaction = { + ...mockTransaction, + transactionID, + amount: AMOUNT_CENTS, + comment: { + type: 'time' as const, + units: { + unit: 'h' as const, + count: HOURS_WORKED, + rate: HOURLY_RATE, + }, + }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockTimeExpenseTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + targetPolicy: mockPolicy, + targetPolicyCategories: fakePolicyCategories, + targetReport: policyExpenseChat, + isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + betas: [CONST.BETAS.ALL], + personalDetails: {}, + recentWaypoints: [], + }); + + await waitForBatchedUpdates(); + + let duplicatedTransaction: OnyxEntry; + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (allTransactions) => { + const transactions = Object.values(allTransactions ?? {}).filter((t) => !!t); + expect(transactions).toHaveLength(1); + duplicatedTransaction = transactions.at(0); + }, + }); + + expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); + expect(duplicatedTransaction?.comment?.units?.count).toEqual(HOURS_WORKED); + expect(duplicatedTransaction?.comment?.units?.rate).toEqual(HOURLY_RATE); + expect(duplicatedTransaction?.comment?.units?.unit).toBe('h'); + expect(duplicatedTransaction?.comment?.type).toBe('time'); + expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); + }); + + it('should return early when transaction is undefined', async () => { + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: undefined, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: '', + targetPolicy: mockPolicy, + targetPolicyCategories: fakePolicyCategories, + targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + betas: [CONST.BETAS.ALL], + personalDetails: {}, + recentWaypoints: [], + }); + + await waitForBatchedUpdates(); + + // Verify API was NOT called since transaction is undefined + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('should call trackExpense when no targetPolicy is provided', async () => { + const {waypoints, ...restOfComment} = mockTransaction.comment ?? {}; + const mockCashExpenseTransaction = { + ...mockTransaction, + amount: mockTransaction.amount * -1, + comment: { + ...restOfComment, + }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockCashExpenseTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: '', + targetPolicy: undefined, + targetPolicyCategories: undefined, + targetReport: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + betas: [CONST.BETAS.ALL], + personalDetails: {}, + recentWaypoints: [], + }); + + await waitForBatchedUpdates(); - const DISTANCE_MI = 11.23; + // Verify API was called with TRACK_EXPENSE (trackExpense path, not requestMoney) + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.TRACK_EXPENSE, expect.objectContaining({}), expect.objectContaining({})); + }); + it('should call createDistanceRequest for distance transactions', async () => { const mockDistanceTransaction = { - ...randomDistanceTransaction, - amount: randomDistanceTransaction.amount * -1, + ...mockTransaction, + amount: mockTransaction.amount * -1, comment: { - ...randomDistanceTransaction.comment, + type: 'customUnit' as const, customUnit: { - ...randomDistanceTransaction.comment?.customUnit, - quantity: DISTANCE_MI, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, }, }, }; @@ -1079,6 +1391,8 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: mockPersonalDetails, betas: [CONST.BETAS.ALL], recentWaypoints, @@ -1086,24 +1400,58 @@ describe('actions/Duplicate', () => { await waitForBatchedUpdates(); - let duplicatedTransaction: OnyxEntry; + // Verify API was called with CREATE_DISTANCE_REQUEST + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, expect.objectContaining({}), expect.objectContaining({})); + }); - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, - callback: (allTransactions) => { - duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t); + it('should call submitPerDiemExpense for per diem transactions', async () => { + const mockPerDiemTransaction = { + ...mockTransaction, + amount: mockTransaction.amount * -1, + comment: { + type: 'customUnit' as const, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: 'unit-123', + customUnitRateID: 'rate-456', + subRates: [{id: 'subrate-1', quantity: 1, name: 'Full Day', rate: 100, currency: 'USD'}], + attributes: { + dates: { + start: '2024-01-01', + end: '2024-01-02', + }, + }, + }, }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockPerDiemTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + activePolicyID: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: '', + targetPolicy: mockPolicy, + targetPolicyCategories: fakePolicyCategories, + targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], + betas: [CONST.BETAS.ALL], + personalDetails: {}, + recentWaypoints: [], }); - if (!duplicatedTransaction) { - return; - } + await waitForBatchedUpdates(); - expect(duplicatedTransaction?.transactionID).not.toBe(mockDistanceTransaction.transactionID); - expect(duplicatedTransaction?.comment?.customUnit?.name).toEqual(CONST.CUSTOM_UNITS.NAME_DISTANCE); - expect(duplicatedTransaction?.comment?.customUnit?.distanceUnit).toEqual(mockDistanceTransaction.comment?.customUnit?.distanceUnit); - expect(duplicatedTransaction?.comment?.customUnit?.quantity).toEqual(DISTANCE_MI); + // Verify API was called with CREATE_PER_DIEM_REQUEST + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST, expect.objectContaining({}), expect.objectContaining({})); }); it('should not pass linkedTrackedExpenseReportAction.childReportID as transactionThreadReportID to the API', async () => { @@ -1156,6 +1504,8 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, recentWaypoints, @@ -1202,6 +1552,8 @@ describe('actions/Duplicate', () => { targetPolicy: undefined, targetPolicyCategories: undefined, targetReport: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, recentWaypoints, @@ -1259,6 +1611,8 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, recentWaypoints, diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 97a0802f2c44c..f5ac55c29c034 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -1661,6 +1661,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, }); await waitForBatchedUpdates(); @@ -1863,6 +1865,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, betas: [CONST.BETAS.ALL], personalDetails: {}, @@ -2026,6 +2030,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, betas: [CONST.BETAS.ALL], personalDetails: {}, @@ -2194,6 +2200,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, betas: [CONST.BETAS.ALL], personalDetails: {}, @@ -2372,6 +2380,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, betas: [CONST.BETAS.ALL], personalDetails: {}, diff --git a/tests/actions/TransactionEditTest.ts b/tests/actions/TransactionEditTest.ts index 3a33ca18e075e..6bc6b69b1aa9b 100644 --- a/tests/actions/TransactionEditTest.ts +++ b/tests/actions/TransactionEditTest.ts @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; -import {createBackupTransaction, restoreOriginalTransactionFromBackup} from '@libs/actions/TransactionEdit'; +import {createBackupTransaction, removeDraftTransactionsByIDs, restoreOriginalTransactionFromBackup} from '@libs/actions/TransactionEdit'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -130,4 +130,55 @@ describe('actions/TransactionEdit', () => { }); }); }); + + describe('removeDraftTransactionsByIDs', () => { + it('should remove draft transactions for the given IDs', async () => { + const transaction1 = createRandomTransaction(1); + const transaction2 = createRandomTransaction(2); + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction1.transactionID}`, transaction1); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction2.transactionID}`, transaction2); + await waitForBatchedUpdates(); + + removeDraftTransactionsByIDs([transaction1.transactionID, transaction2.transactionID]); + await waitForBatchedUpdates(); + + const draft1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction1.transactionID}`); + const draft2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction2.transactionID}`); + + expect(draft1).toBeUndefined(); + expect(draft2).toBeUndefined(); + }); + + it('should only remove specified draft transactions and leave others intact', async () => { + const transaction1 = createRandomTransaction(1); + const transaction2 = createRandomTransaction(2); + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction1.transactionID}`, transaction1); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction2.transactionID}`, transaction2); + await waitForBatchedUpdates(); + + removeDraftTransactionsByIDs([transaction1.transactionID]); + await waitForBatchedUpdates(); + + const draft1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction1.transactionID}`); + const draft2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction2.transactionID}`); + + expect(draft1).toBeUndefined(); + expect(draft2).toBeDefined(); + }); + + it('should do nothing when given an empty array', async () => { + const transaction1 = createRandomTransaction(1); + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction1.transactionID}`, transaction1); + await waitForBatchedUpdates(); + + removeDraftTransactionsByIDs([]); + await waitForBatchedUpdates(); + + const draft1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction1.transactionID}`); + expect(draft1).toBeDefined(); + }); + }); }); diff --git a/tests/ui/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx new file mode 100644 index 0000000000000..f04962f8add85 --- /dev/null +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -0,0 +1,249 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import IOURequestStepAmount from '@pages/iou/request/step/IOURequestStepAmount'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Report, Transaction} from '@src/types/onyx'; +import * as IOU from '../../src/libs/actions/IOU'; +import createRandomTransaction from '../utils/collections/transaction'; +import {signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +// Mock LocaleContextProvider to avoid dynamic import issues with emojis/IntlStore +jest.mock('@components/LocaleContextProvider', () => { + const React2 = require('react'); + + const defaultContextValue = { + translate: (path: string) => path, + numberFormat: (number: number) => String(number), + getLocalDateFromDatetime: () => new Date(), + datetimeToRelative: () => '', + datetimeToCalendarTime: () => '', + formatPhoneNumber: (phone: string) => phone, + toLocaleDigit: (digit: string) => digit, + toLocaleOrdinal: (number: number) => String(number), + fromLocaleDigit: (localeDigit: string) => localeDigit, + localeCompare: (a: string, b: string) => a.localeCompare(b), + formatTravelDate: () => '', + preferredLocale: 'en', + }; + + const LocaleContext = React2.createContext(defaultContextValue); + + return { + LocaleContext, + LocaleContextProvider: ({children}: {children: React.ReactNode}) => React2.createElement(LocaleContext.Provider, {value: defaultContextValue}, children), + }; +}); + +jest.mock('@libs/actions/IOU', () => { + const actual = jest.requireActual('@libs/actions/IOU'); + return { + ...actual, + requestMoney: jest.fn(() => ({iouReport: undefined})), + trackExpense: jest.fn(), + }; +}); + +jest.mock('@libs/actions/IOU/SendMoney', () => ({ + sendMoneyElsewhere: jest.fn(), + sendMoneyWithWallet: jest.fn(), +})); + +jest.mock('@components/ProductTrainingContext', () => ({ + useProductTrainingContext: () => [false], +})); +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@libs/Navigation/navigationRef', () => ({ + getCurrentRoute: jest.fn(() => ({ + name: 'Money_Request_Step_Amount', + params: {}, + })), + getState: jest.fn(() => ({})), +})); + +jest.mock('@libs/Navigation/Navigation', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({ + name: 'Money_Request_Step_Amount', + params: {}, + })), + getState: jest.fn(() => ({})), + }; + return { + navigate: jest.fn(), + goBack: jest.fn(), + dismissModalWithReport: jest.fn(), + navigationRef: mockRef, + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), + getReportRouteByID: jest.fn(() => undefined), + removeScreenByKey: jest.fn(), + getActiveRouteWithoutParams: jest.fn(() => ''), + }; +}); + +jest.mock('@react-navigation/native', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({ + name: 'Money_Request_Step_Amount', + params: {}, + })), + getState: jest.fn(() => ({})), + }; + return { + createNavigationContainerRef: jest.fn(() => mockRef), + useIsFocused: () => true, + useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + }; +}); + +const ACCOUNT_ID = 1; +const ACCOUNT_LOGIN = 'test@user.com'; +const REPORT_ID = 'report-1'; +const TRANSACTION_ID = 'txn-1'; +const PARTICIPANT_ACCOUNT_ID = 2; + +function createTestReport(): Report { + return { + reportID: REPORT_ID, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + ownerAccountID: ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isPinned: false, + lastVisibleActionCreated: '', + lastReadTime: '', + participants: { + [ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: CONST.REPORT.ROLE.MEMBER}, + [PARTICIPANT_ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: CONST.REPORT.ROLE.MEMBER}, + }, + }; +} + +// Helper to create route params for IOURequestStepAmount +function createRouteParams(overrides = {}) { + return { + key: 'StepAmount-test', + name: SCREENS.MONEY_REQUEST.STEP_AMOUNT, + params: { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + ...overrides, + }, + }; +} + +describe('IOURequestStepAmount - draft transactions coverage', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('should initialize transactionDrafts and draftTransactionIDs on render', async () => { + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + + const transaction = createRandomTransaction(1); + transaction.transactionID = TRANSACTION_ID; + transaction.reportID = REPORT_ID; + + const report = createTestReport(); + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + }); + + render( + + + + + , + ); + + await waitForBatchedUpdatesWithAct(); + + // Component rendered successfully, covering lines 133-134 + // (useOptimisticDraftTransactions call and draftTransactionIDs map) + expect(screen.getByTestId('moneyRequestAmountInput')).toBeTruthy(); + }); + + it('should pass existingTransactionDraft and draftTransactionIDs to requestMoney when skip confirmation is enabled for SUBMIT', async () => { + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + + const draftTransaction = createRandomTransaction(99); + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + }; + + const report = createTestReport(); + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction.transactionID}`, draftTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${TRANSACTION_ID}`, true); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [PARTICIPANT_ACCOUNT_ID]: { + accountID: PARTICIPANT_ACCOUNT_ID, + login: 'participant@test.com', + displayName: 'Test Participant', + }, + }); + }); + + render( + + + + + , + ); + + await waitForBatchedUpdatesWithAct(); + + // Trigger form submission by pressing the "Next" button + const nextButton = screen.getByTestId('next-button'); + fireEvent.press(nextButton); + await waitForBatchedUpdatesWithAct(); + + // Verify requestMoney was called with draftTransactionIDs + expect(IOU.requestMoney).toHaveBeenCalledWith( + expect.objectContaining({ + draftTransactionIDs: expect.any(Array), + }), + ); + }); +}); diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index eb62175968d53..81066b2fc6458 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -789,3 +789,35 @@ describe('canApproveIOU', () => { expect(canApproveIOU(report, policy, reportMetadata)).toBe(false); }); }); + +describe('getExistingTransactionID', () => { + test('should return undefined when linkedTrackedExpenseReportAction is undefined', () => { + expect(IOUUtils.getExistingTransactionID(undefined)).toBeUndefined(); + }); + + test('should return undefined when reportAction is not a money request action', () => { + const nonMoneyRequestAction = { + reportActionID: 'action1', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '', + message: [], + } as unknown as Parameters[0]; + + expect(IOUUtils.getExistingTransactionID(nonMoneyRequestAction)).toBeUndefined(); + }); + + test('should return IOUTransactionID from a valid money request action', () => { + const moneyRequestAction = { + reportActionID: 'action1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '', + originalMessage: { + IOUTransactionID: 'txn123', + IOUReportID: 'report456', + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + } as unknown as Parameters[0]; + + expect(IOUUtils.getExistingTransactionID(moneyRequestAction)).toBe('txn123'); + }); +});