From bf908ec5752865fcaa0a9e3191ebda4214106c41 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 20 Jan 2026 15:56:29 +0700 Subject: [PATCH 01/13] refactor duplicateExpenseTransaction and handleFileRetry --- src/components/DotIndicatorMessage.tsx | 5 +- src/components/MoneyReportHeader.tsx | 14 +- src/components/MoneyRequestHeader.tsx | 14 +- .../handleFileRetry.ts | 11 +- .../index.android.ts | 11 +- src/libs/ReceiptUploadRetryHandler/index.ts | 11 +- src/libs/actions/IOU/Duplicate.ts | 5 +- src/libs/actions/IOU/index.ts | 5 +- tests/actions/IOUTest/DuplicateTest.ts | 174 ++++++++++++++++++ 9 files changed, 240 insertions(+), 10 deletions(-) diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 233048215395b..2968f69797709 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -4,6 +4,7 @@ import type {ReactElement} from 'react'; import React, {useState} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -12,6 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isReceiptError, isTranslationKeyError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import handleRetryPress from '@libs/ReceiptUploadRetryHandler'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import ConfirmModal from './ConfirmModal'; @@ -50,6 +52,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissErr const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator']); const [shouldShowErrorModal, setShouldShowErrorModal] = useState(false); + const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); if (Object.keys(messages).length === 0) { return null; @@ -72,7 +75,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissErr } if (href.endsWith('retry')) { - handleRetryPress(receiptError, dismissError, setShouldShowErrorModal); + handleRetryPress(receiptError, dismissError, setShouldShowErrorModal, allTransactionDrafts); } else if (href.endsWith('download')) { fileDownload(translate, receiptError.source, receiptError.filename).finally(() => dismissError()); } diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 45362c5568d79..d36d85d658f46 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -508,6 +508,7 @@ function MoneyReportHeader({ const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyRequestReport?.reportID); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES, {canBeMissing: true}); + const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); const shouldShowLoadingBar = useLoadingBarVisibility(); const kycWallRef = useContext(KYCWallContext); @@ -652,10 +653,21 @@ function MoneyReportHeader({ targetPolicy: defaultExpensePolicy ?? undefined, targetPolicyCategories: activePolicyCategories, targetReport: activePolicyExpenseChat, + allTransactionDrafts, }); } }, - [activePolicyExpenseChat, activePolicyID, allPolicyCategories, defaultExpensePolicy, introSelected, isASAPSubmitBetaEnabled, quickAction, policyRecentlyUsedCurrencies], + [ + activePolicyExpenseChat, + activePolicyID, + allPolicyCategories, + allTransactionDrafts, + defaultExpensePolicy, + introSelected, + isASAPSubmitBetaEnabled, + quickAction, + policyRecentlyUsedCurrencies, + ], ); const getStatusIcon: (src: IconAsset) => React.ReactNode = (src) => ( diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index e656f28c963fe..6ca3986980ffc 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -170,6 +170,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const {wideRHPRouteKeys} = useContext(WideRHPContext); const [network] = useOnyx(ONYXKEYS.NETWORK, {canBeMissing: true}); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, {canBeMissing: true}); + const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); const markAsCash = useCallback(() => { markAsCashAction(transaction?.transactionID, reportID, transactionViolations); @@ -199,10 +200,21 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre targetPolicy: defaultExpensePolicy ?? undefined, targetPolicyCategories: activePolicyCategories, targetReport: activePolicyExpenseChat, + allTransactionDrafts, }); } }, - [activePolicyExpenseChat, allPolicyCategories, defaultExpensePolicy, isASAPSubmitBetaEnabled, introSelected, activePolicyID, quickAction, policyRecentlyUsedCurrencies], + [ + activePolicyExpenseChat, + allPolicyCategories, + allTransactionDrafts, + defaultExpensePolicy, + isASAPSubmitBetaEnabled, + introSelected, + activePolicyID, + quickAction, + policyRecentlyUsedCurrencies, + ], ); const getStatusIcon: (src: IconAsset) => ReactNode = (src) => ( diff --git a/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts b/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts index 4bf3a4777edfd..02e760e751d78 100644 --- a/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts +++ b/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts @@ -1,8 +1,16 @@ +import type {OnyxCollection} from 'react-native-onyx'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; import type {ReceiptError} from '@src/types/onyx/Transaction'; -export default function handleFileRetry(message: ReceiptError, file: File, dismissError: () => void, setShouldShowErrorModal: (value: boolean) => void) { +export default function handleFileRetry( + message: ReceiptError, + file: File, + dismissError: () => void, + setShouldShowErrorModal: (value: boolean) => void, + allTransactionDrafts: OnyxCollection, +) { const retryParams: IOU.ReplaceReceipt | IOU.StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation = typeof message.retryParams === 'string' ? (JSON.parse(message.retryParams) as IOU.ReplaceReceipt | IOU.StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation) @@ -39,6 +47,7 @@ export default function handleFileRetry(message: ReceiptError, file: File, dismi requestMoneyParams.transactionParams.receipt = file; requestMoneyParams.isRetry = true; requestMoneyParams.shouldPlaySound = false; + requestMoneyParams.allTransactionDrafts = allTransactionDrafts; IOU.requestMoney(requestMoneyParams); break; } diff --git a/src/libs/ReceiptUploadRetryHandler/index.android.ts b/src/libs/ReceiptUploadRetryHandler/index.android.ts index ffaf6f3ce34c0..aff9a4fe1c8b4 100644 --- a/src/libs/ReceiptUploadRetryHandler/index.android.ts +++ b/src/libs/ReceiptUploadRetryHandler/index.android.ts @@ -1,8 +1,15 @@ import RNFS from 'react-native-fs'; +import type {OnyxCollection} from 'react-native-onyx'; +import type * as OnyxTypes from '@src/types/onyx'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import handleFileRetry from './handleFileRetry'; -export default function handleRetryPress(message: ReceiptError, dismissError: () => void, setShouldShowErrorModal: (value: boolean) => void) { +export default function handleRetryPress( + message: ReceiptError, + dismissError: () => void, + setShouldShowErrorModal: (value: boolean) => void, + allTransactionDrafts: OnyxCollection, +) { if (!message.source) { return; } @@ -13,7 +20,7 @@ export default function handleRetryPress(message: ReceiptError, dismissError: () const file = new File([fileContent], message.filename, {type: 'image/jpeg'}); file.uri = message.source; file.source = message.source; - handleFileRetry(message, file, dismissError, setShouldShowErrorModal); + handleFileRetry(message, file, dismissError, setShouldShowErrorModal, allTransactionDrafts); }) .catch(() => { setShouldShowErrorModal(true); diff --git a/src/libs/ReceiptUploadRetryHandler/index.ts b/src/libs/ReceiptUploadRetryHandler/index.ts index 7a517fd5ef1cd..1d1e873b24578 100644 --- a/src/libs/ReceiptUploadRetryHandler/index.ts +++ b/src/libs/ReceiptUploadRetryHandler/index.ts @@ -1,7 +1,14 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import type * as OnyxTypes from '@src/types/onyx'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import handleFileRetry from './handleFileRetry'; -export default function handleRetryPress(message: ReceiptError, dismissError: () => void, setShouldShowErrorModal: (value: boolean) => void) { +export default function handleRetryPress( + message: ReceiptError, + dismissError: () => void, + setShouldShowErrorModal: (value: boolean) => void, + allTransactionDrafts: OnyxCollection, +) { if (!message.source) { return; } @@ -12,7 +19,7 @@ export default function handleRetryPress(message: ReceiptError, dismissError: () const reconstructedFile = new File([blob], message.filename); reconstructedFile.uri = message.source; reconstructedFile.source = message.source; - handleFileRetry(message, reconstructedFile, dismissError, setShouldShowErrorModal); + handleFileRetry(message, reconstructedFile, dismissError, setShouldShowErrorModal, allTransactionDrafts); }) .catch(() => { setShouldShowErrorModal(true); diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index da8822844c319..1605c395c1d97 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -1,5 +1,5 @@ import {format} from 'date-fns'; -import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {PartialDeep} from 'type-fest'; import * as API from '@libs/API'; @@ -462,6 +462,7 @@ type DuplicateExpenseTransactionParams = { targetPolicy?: OnyxEntry; targetPolicyCategories?: OnyxEntry; targetReport?: OnyxTypes.Report; + allTransactionDrafts: OnyxCollection; }; function duplicateExpenseTransaction({ @@ -476,6 +477,7 @@ function duplicateExpenseTransaction({ targetPolicy, targetPolicyCategories, targetReport, + allTransactionDrafts, }: DuplicateExpenseTransactionParams) { if (!transaction) { return; @@ -527,6 +529,7 @@ function duplicateExpenseTransaction({ transactionViolations: {}, policyRecentlyUsedCurrencies, quickAction, + allTransactionDrafts, }; // If no workspace is provided the expense should be unreported diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 76be6b2098862..12eea9105d325 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -542,6 +542,7 @@ type RequestMoneyInformation = { transactionViolations: OnyxCollection; quickAction: OnyxEntry; policyRecentlyUsedCurrencies: string[]; + allTransactionDrafts?: OnyxCollection; }; type MoneyRequestInformationParams = { @@ -6104,6 +6105,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep transactionViolations, quickAction, policyRecentlyUsedCurrencies, + // TODO: Remove the usages of allTransactionDrafts in the follow-up PR + allTransactionDrafts: allTransactionDraftsParam = allTransactionDrafts, } = requestMoneyInformation; const {payeeAccountID} = participantParams; const parsedComment = getParsedComment(transactionParams.comment ?? ''); @@ -6151,7 +6154,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep : undefined; const existingTransaction = action === CONST.IOU.ACTION.SUBMIT - ? allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`] + ? allTransactionDraftsParam[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`] : allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${existingTransactionID}`]; const retryParams = { diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 5f60632f0d6a2..6be2cd5e9bbf8 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -704,6 +704,7 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, + allTransactionDrafts: {}, }); await waitForBatchedUpdates(); @@ -759,6 +760,7 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, + allTransactionDrafts: {}, }); await waitForBatchedUpdates(); @@ -782,6 +784,178 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.comment?.type).toBe('time'); expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); }); + + it('should create a duplicate expense with allTransactionDrafts containing existing drafts', async () => { + const {waypoints, ...restOfComment} = mockTransaction.comment ?? {}; + const mockCashExpenseTransaction = { + ...mockTransaction, + amount: mockTransaction.amount * -1, + comment: { + ...restOfComment, + }, + }; + + // Create some mock transaction drafts + const draftTransaction1 = createRandomTransaction(100); + const draftTransaction2 = createRandomTransaction(200); + const allTransactionDrafts = { + [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction1.transactionID}`]: draftTransaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction2.transactionID}`]: draftTransaction2, + }; + + 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, + allTransactionDrafts, + }); + + 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); + // The duplicated transaction should not be one of the draft transactions + expect(duplicatedTransaction?.transactionID).not.toBe(draftTransaction1.transactionID); + expect(duplicatedTransaction?.transactionID).not.toBe(draftTransaction2.transactionID); + }); + + it('should create a duplicate expense with allTransactionDrafts as undefined', 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, + allTransactionDrafts: undefined, + }); + + 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 even with undefined allTransactionDrafts + expect(duplicatedTransaction).toBeDefined(); + expect(duplicatedTransaction?.transactionID).toBeDefined(); + expect(duplicatedTransaction?.transactionID).not.toBe(mockCashExpenseTransaction.transactionID); + }); + + it('should create a duplicate time expense with allTransactionDrafts containing existing 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, + }, + }, + }; + + // Create some mock transaction drafts + const draftTransaction1 = createRandomTransaction(300); + const draftTransaction2 = createRandomTransaction(400); + const allTransactionDrafts = { + [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction1.transactionID}`]: draftTransaction1, + [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction2.transactionID}`]: draftTransaction2, + }; + + 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, + allTransactionDrafts, + }); + + 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(); + // The duplicated transaction should not be one of the draft transactions + expect(duplicatedTransaction?.transactionID).not.toBe(draftTransaction1.transactionID); + expect(duplicatedTransaction?.transactionID).not.toBe(draftTransaction2.transactionID); + }); }); describe('resolveDuplicate', () => { From bb745299698609556c1065680d04ba32aed1d706 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 20 Jan 2026 16:06:40 +0700 Subject: [PATCH 02/13] update removeDraftTransactions --- src/libs/actions/IOU/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 12eea9105d325..5a8cce7ffe285 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -6332,7 +6332,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep if (shouldHandleNavigation) { // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => removeDraftTransactions()); + InteractionManager.runAfterInteractions(() => removeDraftTransactions(undefined, allTransactionDraftsParam)); if (!requestMoneyInformation.isRetry) { dismissModalAndOpenReportInInboxTab(backToReport ?? activeReportID); } From 4d660bdd519117b940b639fa776747a9894c150a Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 26 Jan 2026 16:38:28 +0700 Subject: [PATCH 03/13] lint fix --- tests/actions/IOUTest/DuplicateTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index f4c798188ea00..bb66814ef8f8b 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -22,7 +22,7 @@ import currencyList from '../../unit/currencyList.json'; import createRandomPolicy from '../../utils/collections/policies'; import createRandomPolicyCategories from '../../utils/collections/policyCategory'; import {createRandomReport} from '../../utils/collections/reports'; -import createRandomTransaction, {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'; From c4addc1c85972e438a3f3eca183bd918d093e97a Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 27 Jan 2026 23:13:22 +0700 Subject: [PATCH 04/13] update requestMoney --- src/components/DotIndicatorMessage.tsx | 5 +- src/components/MoneyReportHeader.tsx | 13 +++- src/components/MoneyRequestHeader.tsx | 13 +++- src/hooks/useTransactionDrafts.ts | 19 ++++++ src/libs/IOUUtils.ts | 15 ++++- .../handleFileRetry.ts | 11 +--- .../index.android.ts | 11 +--- src/libs/ReceiptUploadRetryHandler/index.ts | 11 +--- src/libs/actions/IOU/Duplicate.ts | 9 ++- src/libs/actions/IOU/MoneyRequest.ts | 19 +++++- src/libs/actions/IOU/index.ts | 21 +++---- src/libs/actions/TransactionEdit.ts | 16 +++++ src/pages/Share/SubmitDetailsPage.tsx | 10 +++- .../iou/request/step/IOURequestStepAmount.tsx | 9 ++- .../step/IOURequestStepConfirmation.tsx | 10 ++++ tests/actions/IOU/MoneyRequestTest.ts | 1 + tests/actions/IOUTest.ts | 60 +++++++++++++++++++ tests/actions/IOUTest/DuplicateTest.ts | 45 +++++--------- tests/actions/IOUTest/SplitTest.ts | 8 +++ 19 files changed, 217 insertions(+), 89 deletions(-) create mode 100644 src/hooks/useTransactionDrafts.ts diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 7372a7d60b4a0..233048215395b 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -6,14 +6,12 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isReceiptError, isTranslationKeyError} from '@libs/ErrorUtils'; import fileDownload from '@libs/fileDownload'; import handleRetryPress from '@libs/ReceiptUploadRetryHandler'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import ConfirmModal from './ConfirmModal'; @@ -52,7 +50,6 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissErr const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator']); const [shouldShowErrorModal, setShouldShowErrorModal] = useState(false); - const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); if (Object.keys(messages).length === 0) { return null; @@ -75,7 +72,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissErr } if (href.endsWith('retry')) { - handleRetryPress(receiptError, dismissError, setShouldShowErrorModal, allTransactionDrafts); + handleRetryPress(receiptError, dismissError, setShouldShowErrorModal); } else if (href.endsWith('download')) { fileDownload(translate, receiptError.source, receiptError.filename).finally(() => dismissError()); } diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index bfe9324cc69a0..00ff64288e0fc 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -32,6 +32,7 @@ import useStrictPolicyRules from '@hooks/useStrictPolicyRules'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; +import useTransactionDrafts from '@hooks/useTransactionDrafts'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; @@ -43,6 +44,7 @@ import {getExportTemplates, queueExportSearchWithTemplate, search} from '@libs/a import {setNameValuePair} from '@libs/actions/User'; 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'; @@ -255,6 +257,7 @@ function MoneyReportHeader({ ] as const); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE, {canBeMissing: true}); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${moneyRequestReport?.reportID}`, {canBeMissing: true}); + const {allTransactionDrafts, draftTransactionIDs} = useTransactionDrafts(); const {translate, localeCompare} = useLocalize(); const exportTemplates = useMemo( @@ -514,7 +517,6 @@ function MoneyReportHeader({ const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyRequestReport?.reportID); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES, {canBeMissing: true}); - const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); const shouldShowLoadingBar = useLoadingBarVisibility(); const kycWallRef = useContext(KYCWallContext); @@ -647,6 +649,9 @@ function MoneyReportHeader({ const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; for (const item of transactionList) { + const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); + const existingTransactionDraft = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`]; + duplicateTransactionAction({ transaction: item, optimisticChatReportID, @@ -661,7 +666,8 @@ function MoneyReportHeader({ targetPolicy: defaultExpensePolicy ?? undefined, targetPolicyCategories: activePolicyCategories, targetReport: activePolicyExpenseChat, - allTransactionDrafts, + existingTransactionDraft, + draftTransactionIDs, }); } }, @@ -669,13 +675,14 @@ function MoneyReportHeader({ activePolicyExpenseChat, activePolicyID, allPolicyCategories, + allTransactionDrafts, defaultExpensePolicy, + draftTransactionIDs, introSelected, isASAPSubmitBetaEnabled, quickAction, policyRecentlyUsedCurrencies, policy?.id, - allTransactionDrafts, isSelfTourViewed, ], ); diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 27a1a7c1d2f0f..68ee07b0dcd49 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -20,12 +20,14 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; +import useTransactionDrafts from '@hooks/useTransactionDrafts'; import useTransactionViolations from '@hooks/useTransactionViolations'; import {deleteTrackExpense, initSplitExpense, markRejectViolationAsResolved} from '@libs/actions/IOU'; import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {setNameValuePair} from '@libs/actions/User'; 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'; @@ -148,6 +150,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false}); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); + const {allTransactionDrafts, draftTransactionIDs} = useTransactionDrafts(); const {deleteTransactions} = useDeleteTransactions({report: parentReport, reportActions: parentReportAction ? [parentReportAction] : [], policy}); const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -174,7 +177,6 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const {wideRHPRouteKeys} = useContext(WideRHPContext); const [network] = useOnyx(ONYXKEYS.NETWORK, {canBeMissing: true}); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, {canBeMissing: true}); - const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {canBeMissing: true, selector: hasSeenTourSelector}); const markAsCash = useCallback(() => { @@ -193,6 +195,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 = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`]; + duplicateTransactionAction({ transaction: item, optimisticChatReportID, @@ -207,21 +212,23 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre targetPolicy: defaultExpensePolicy ?? undefined, targetPolicyCategories: activePolicyCategories, targetReport: activePolicyExpenseChat, - allTransactionDrafts, + existingTransactionDraft, + draftTransactionIDs, }); } }, [ activePolicyExpenseChat, allPolicyCategories, + allTransactionDrafts, defaultExpensePolicy, + draftTransactionIDs, isASAPSubmitBetaEnabled, introSelected, activePolicyID, quickAction, policyRecentlyUsedCurrencies, policy?.id, - allTransactionDrafts, isSelfTourViewed, ], ); diff --git a/src/hooks/useTransactionDrafts.ts b/src/hooks/useTransactionDrafts.ts new file mode 100644 index 0000000000000..24e924b7ea2d5 --- /dev/null +++ b/src/hooks/useTransactionDrafts.ts @@ -0,0 +1,19 @@ +import {useMemo} from 'react'; +import useOnyx from '@hooks/useOnyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function useTransactionDrafts() { + const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); + + const draftTransactionIDs = useMemo( + () => + Object.values(allTransactionDrafts ?? {}) + .filter((transaction): transaction is NonNullable => !!transaction) + .map((transaction) => transaction.transactionID), + [allTransactionDrafts], + ); + + return {allTransactionDrafts, draftTransactionIDs}; +} + +export default useTransactionDrafts; diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index c40a3045397fd..04805b7210806 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -2,7 +2,7 @@ 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} from '@src/types/onyx/IOU'; import SafeString from '@src/utils/SafeString'; import type {IOURequestType} from './actions/IOU'; @@ -10,6 +10,7 @@ import {getCurrencyUnit} from './CurrencyUtils'; import Navigation from './Navigation/Navigation'; import Performance from './Performance'; import {isPaidGroupPolicy} from './PolicyUtils'; +import {getOriginalMessage, isMoneyRequestAction} from './ReportActionsUtils'; import {getReportTransactions} from './ReportUtils'; import {getCurrency, getTagArrayFromName} from './TransactionUtils'; @@ -357,10 +358,22 @@ 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; +} + export { calculateAmount, calculateSplitAmountFromPercentage, calculateSplitPercentagesFromAmounts, + getExistingTransactionID, insertTagIntoTransactionTagsString, isIOUReportPendingCurrencyConversion, isMovingTransactionFromTrackExpense, diff --git a/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts b/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts index 4102f29153798..a8e7dae2cc1b7 100644 --- a/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts +++ b/src/libs/ReceiptUploadRetryHandler/handleFileRetry.ts @@ -1,17 +1,9 @@ -import type {OnyxCollection} from 'react-native-onyx'; import * as IOU from '@userActions/IOU'; import {startSplitBill} from '@userActions/IOU/Split'; import CONST from '@src/CONST'; -import type * as OnyxTypes from '@src/types/onyx'; import type {ReceiptError} from '@src/types/onyx/Transaction'; -export default function handleFileRetry( - message: ReceiptError, - file: File, - dismissError: () => void, - setShouldShowErrorModal: (value: boolean) => void, - allTransactionDrafts: OnyxCollection, -) { +export default function handleFileRetry(message: ReceiptError, file: File, dismissError: () => void, setShouldShowErrorModal: (value: boolean) => void) { const retryParams: IOU.ReplaceReceipt | IOU.StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation = typeof message.retryParams === 'string' ? (JSON.parse(message.retryParams) as IOU.ReplaceReceipt | IOU.StartSplitBilActionParams | IOU.CreateTrackExpenseParams | IOU.RequestMoneyInformation) @@ -48,7 +40,6 @@ export default function handleFileRetry( requestMoneyParams.transactionParams.receipt = file; requestMoneyParams.isRetry = true; requestMoneyParams.shouldPlaySound = false; - requestMoneyParams.allTransactionDrafts = allTransactionDrafts; IOU.requestMoney(requestMoneyParams); break; } diff --git a/src/libs/ReceiptUploadRetryHandler/index.android.ts b/src/libs/ReceiptUploadRetryHandler/index.android.ts index aff9a4fe1c8b4..ffaf6f3ce34c0 100644 --- a/src/libs/ReceiptUploadRetryHandler/index.android.ts +++ b/src/libs/ReceiptUploadRetryHandler/index.android.ts @@ -1,15 +1,8 @@ import RNFS from 'react-native-fs'; -import type {OnyxCollection} from 'react-native-onyx'; -import type * as OnyxTypes from '@src/types/onyx'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import handleFileRetry from './handleFileRetry'; -export default function handleRetryPress( - message: ReceiptError, - dismissError: () => void, - setShouldShowErrorModal: (value: boolean) => void, - allTransactionDrafts: OnyxCollection, -) { +export default function handleRetryPress(message: ReceiptError, dismissError: () => void, setShouldShowErrorModal: (value: boolean) => void) { if (!message.source) { return; } @@ -20,7 +13,7 @@ export default function handleRetryPress( const file = new File([fileContent], message.filename, {type: 'image/jpeg'}); file.uri = message.source; file.source = message.source; - handleFileRetry(message, file, dismissError, setShouldShowErrorModal, allTransactionDrafts); + handleFileRetry(message, file, dismissError, setShouldShowErrorModal); }) .catch(() => { setShouldShowErrorModal(true); diff --git a/src/libs/ReceiptUploadRetryHandler/index.ts b/src/libs/ReceiptUploadRetryHandler/index.ts index 1d1e873b24578..7a517fd5ef1cd 100644 --- a/src/libs/ReceiptUploadRetryHandler/index.ts +++ b/src/libs/ReceiptUploadRetryHandler/index.ts @@ -1,14 +1,7 @@ -import type {OnyxCollection} from 'react-native-onyx'; -import type * as OnyxTypes from '@src/types/onyx'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import handleFileRetry from './handleFileRetry'; -export default function handleRetryPress( - message: ReceiptError, - dismissError: () => void, - setShouldShowErrorModal: (value: boolean) => void, - allTransactionDrafts: OnyxCollection, -) { +export default function handleRetryPress(message: ReceiptError, dismissError: () => void, setShouldShowErrorModal: (value: boolean) => void) { if (!message.source) { return; } @@ -19,7 +12,7 @@ export default function handleRetryPress( const reconstructedFile = new File([blob], message.filename); reconstructedFile.uri = message.source; reconstructedFile.source = message.source; - handleFileRetry(message, reconstructedFile, dismissError, setShouldShowErrorModal, allTransactionDrafts); + handleFileRetry(message, reconstructedFile, dismissError, setShouldShowErrorModal); }) .catch(() => { setShouldShowErrorModal(true); diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 2c5e0c80520e0..454a5d6d0ba53 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -466,7 +466,8 @@ type DuplicateExpenseTransactionParams = { targetPolicy?: OnyxEntry; targetPolicyCategories?: OnyxEntry; targetReport?: OnyxTypes.Report; - allTransactionDrafts: OnyxCollection; + existingTransactionDraft: OnyxEntry; + draftTransactionIDs: string[]; }; function duplicateExpenseTransaction({ @@ -483,7 +484,8 @@ function duplicateExpenseTransaction({ targetPolicy, targetPolicyCategories, targetReport, - allTransactionDrafts, + existingTransactionDraft, + draftTransactionIDs, }: DuplicateExpenseTransactionParams) { if (!transaction) { return; @@ -535,7 +537,8 @@ function duplicateExpenseTransaction({ transactionViolations: {}, policyRecentlyUsedCurrencies, quickAction, - allTransactionDrafts, + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, }; diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 289a76e6468d6..7e699c9a19e75 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 {navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import {getExistingTransactionID, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getManagerMcTestParticipant, getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; @@ -14,6 +14,7 @@ import {setTransactionReport} from '@userActions/Transaction'; import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {IntroSelected, LastSelectedDistanceRates, PersonalDetailsList, Policy, QuickAction, Report, Transaction, TransactionViolation} from '@src/types/onyx'; @@ -56,6 +57,7 @@ type CreateTransactionParams = { policyParams?: {policy: OnyxEntry}; billable?: boolean; reimbursable?: boolean; + allTransactionDrafts: OnyxCollection; isSelfTourViewed: boolean; }; @@ -98,6 +100,7 @@ type MoneyRequestStepScanParticipantsFlowParams = { locationPermissionGranted?: boolean; shouldGenerateTransactionThreadReport: boolean; isSelfTourViewed: boolean; + allTransactionDrafts?: OnyxCollection; }; type MoneyRequestStepDistanceNavigationParams = { @@ -154,8 +157,13 @@ function createTransaction({ policyParams, billable, reimbursable = true, + allTransactionDrafts, isSelfTourViewed, }: CreateTransactionParams) { + const draftTransactionIDs = Object.values(allTransactionDrafts ?? {}) + .filter((transaction): transaction is NonNullable => !!transaction) + .map((transaction) => transaction.transactionID); + for (const [index, receiptFile] of files.entries()) { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); const receipt: Receipt = receiptFile.file ?? {}; @@ -189,6 +197,9 @@ function createTransaction({ quickAction, }); } else { + const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); + const existingTransactionDraft = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`]; + requestMoney({ report, participantParams: { @@ -217,6 +228,8 @@ function createTransaction({ transactionViolations, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, }); } @@ -269,6 +282,7 @@ function handleMoneyRequestStepScanParticipants({ isTestTransaction = false, locationPermissionGranted = false, isSelfTourViewed, + allTransactionDrafts, }: MoneyRequestStepScanParticipantsFlowParams) { if (backTo) { Navigation.goBack(backTo); @@ -363,6 +377,7 @@ function handleMoneyRequestStepScanParticipants({ billable: false, reimbursable: true, isSelfTourViewed, + allTransactionDrafts, }); }, (errorData) => { @@ -385,6 +400,7 @@ function handleMoneyRequestStepScanParticipants({ files, participant, isSelfTourViewed, + allTransactionDrafts, }); }, ); @@ -407,6 +423,7 @@ function handleMoneyRequestStepScanParticipants({ files, participant, isSelfTourViewed, + allTransactionDrafts, }); return; } diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 7cdb3342007e2..367c1bc35a7d5 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -230,7 +230,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 {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} from '@src/CONST'; @@ -538,7 +538,8 @@ type RequestMoneyInformation = { transactionViolations: OnyxCollection; quickAction: OnyxEntry; policyRecentlyUsedCurrencies: string[]; - allTransactionDrafts?: OnyxCollection; + existingTransactionDraft: OnyxEntry; + draftTransactionIDs: string[]; isSelfTourViewed: boolean; }; @@ -6132,8 +6133,8 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep transactionViolations, quickAction, policyRecentlyUsedCurrencies, - // TODO: Remove the usages of allTransactionDrafts in the follow-up PR - allTransactionDrafts: allTransactionDraftsParam = allTransactionDrafts, + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, } = requestMoneyInformation; const {payeeAccountID} = participantParams; @@ -6176,14 +6177,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 - ? allTransactionDraftsParam[`${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, @@ -6361,7 +6356,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep if (shouldHandleNavigation) { // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => removeDraftTransactions(undefined, allTransactionDraftsParam)); + InteractionManager.runAfterInteractions(() => removeDraftTransactionsByIDs(draftTransactionIDs)); if (!requestMoneyInformation.isRetry) { dismissModalAndOpenReportInInboxTab(backToReport ?? activeReportID); } diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index e0204436e42b3..e9dbe731f0c29 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; @@ -175,6 +190,7 @@ export { removeDraftTransaction, removeTransactionReceipt, removeDraftTransactions, + removeDraftTransactionsByIDs, removeDraftSplitTransaction, replaceDefaultDraftTransaction, buildOptimisticTransactionAndCreateDraft, diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 37198a5b38dcf..3004ff7ad1c3f 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -1,7 +1,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; import reportsSelector from '@selectors/Attributes'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -17,11 +17,13 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionDrafts from '@hooks/useTransactionDrafts'; import type {GpsPoint} from '@libs/actions/IOU'; import {getIOURequestPolicyID, getMoneyRequestParticipantsFromReport, initMoneyRequest, requestMoney, trackExpense, updateLastLocationPermissionPrompt} from '@libs/actions/IOU'; 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,7 @@ function SubmitDetailsPage({ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {canBeMissing: true, selector: hasSeenTourSelector}); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES, {canBeMissing: true}); + const {allTransactionDrafts, draftTransactionIDs} = useTransactionDrafts(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalPolicy = usePersonalPolicy(); @@ -162,6 +165,9 @@ function SubmitDetailsPage({ quickAction, }); } else { + const existingTransactionID = getExistingTransactionID(transaction.linkedTrackedExpenseReportAction); + const existingTransactionDraft = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`]; + requestMoney({ report, participantParams: {payeeEmail: currentUserPersonalDetails.login, payeeAccountID: currentUserPersonalDetails.accountID, participant}, @@ -194,6 +200,8 @@ function SubmitDetailsPage({ transactionViolations, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, }); } diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 2bfb58537af29..6401580adb58e 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -16,10 +16,11 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; +import useTransactionDrafts from '@hooks/useTransactionDrafts'; import {setTransactionReport} from '@libs/actions/Transaction'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {isMovingTransactionFromTrackExpense, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import {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'; @@ -125,6 +126,7 @@ function IOURequestStepAmount({ const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); + const {allTransactionDrafts, draftTransactionIDs} = useTransactionDrafts(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; @@ -231,6 +233,9 @@ function IOURequestStepAmount({ return; } if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { + const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); + const existingTransactionDraft = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`]; + requestMoney({ report, participantParams: { @@ -253,6 +258,8 @@ function IOURequestStepAmount({ transactionViolations, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, }); return; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 98d5460cdb7b7..a78ad54539078 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -31,6 +31,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionDrafts from '@hooks/useTransactionDrafts'; import {completeTestDriveTask} from '@libs/actions/Task'; import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; @@ -40,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, @@ -133,6 +135,7 @@ function IOURequestStepConfirmation({ selector: transactionDraftValuesSelector, canBeMissing: true, }); + const {allTransactionDrafts, draftTransactionIDs} = useTransactionDrafts(); const transactions = useMemo(() => { const allTransactions = optimisticTransactions && optimisticTransactions.length > 1 ? optimisticTransactions : [initialTransaction]; return allTransactions.filter((transaction): transaction is Transaction => !!transaction); @@ -580,6 +583,9 @@ function IOURequestStepConfirmation({ ); } + const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); + const existingTransactionDraft = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`]; + const {iouReport} = requestMoneyIOUActions({ report, existingIOUReport, @@ -637,12 +643,16 @@ function IOURequestStepConfirmation({ transactionViolations, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, + existingTransactionDraft, + draftTransactionIDs, isSelfTourViewed, }); existingIOUReport = iouReport; } }, [ + allTransactionDrafts, + draftTransactionIDs, transactions, receiptFiles, privateIsArchivedMap, diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index f44a8207d13a0..17e8a398722d7 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -88,6 +88,7 @@ describe('MoneyRequest', () => { files: [fakeReceiptFile], participant: {accountID: 222, login: 'test@test.com'}, quickAction: fakeQuickAction, + allTransactionDrafts: {}, isSelfTourViewed: false, }; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 37db6c9566c2c..3206dd25504e5 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -1129,6 +1129,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -1385,6 +1387,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -1614,6 +1618,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -1779,6 +1785,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -2289,6 +2297,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -2317,6 +2327,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -2345,6 +2357,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: RORY_ACCOUNT_ID, currentUserEmailParam: RORY_EMAIL, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: true, quickAction: undefined, }); @@ -2391,6 +2405,8 @@ describe('actions/IOU', () => { transactionViolations: {}, currentUserAccountIDParam: 123, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, currentUserEmailParam: 'existing@example.com', quickAction: undefined, @@ -2432,6 +2448,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: 123, currentUserEmailParam: 'existing@example.com', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -2498,6 +2516,8 @@ describe('actions/IOU', () => { currentUserAccountIDParam: currentUserPersonalDetails.accountID, currentUserEmailParam: currentUserPersonalDetails.login ?? '', policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -4238,6 +4258,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -4486,6 +4508,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -4629,6 +4653,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -4947,6 +4973,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -5055,6 +5083,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -5302,6 +5332,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -5881,6 +5913,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -5956,6 +5990,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -6362,6 +6398,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -6490,6 +6528,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: initialCurrencies, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -6561,6 +6601,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -6758,6 +6800,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -6928,6 +6972,8 @@ describe('actions/IOU', () => { currentUserEmailParam: RORY_EMAIL, transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -7574,6 +7620,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -8426,6 +8474,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -8522,6 +8572,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -9433,6 +9485,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -9592,6 +9646,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -9756,6 +9812,8 @@ describe('actions/IOU', () => { currentUserEmailParam: 'existing@example.com', transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); @@ -9930,6 +9988,8 @@ describe('actions/IOU', () => { currentUserEmailParam: RORY_EMAIL, transactionViolations: {}, policyRecentlyUsedCurrencies: [], + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, quickAction: undefined, }); diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 3b4931dc4fc38..9718ccffaf7af 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -706,7 +706,8 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, - allTransactionDrafts: {}, + existingTransactionDraft: undefined, + draftTransactionIDs: [], }); await waitForBatchedUpdates(); @@ -764,7 +765,8 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, - allTransactionDrafts: {}, + existingTransactionDraft: undefined, + draftTransactionIDs: [], }); await waitForBatchedUpdates(); @@ -789,7 +791,7 @@ describe('actions/Duplicate', () => { expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); }); - it('should create a duplicate expense with allTransactionDrafts containing existing drafts', async () => { + it('should create a duplicate expense successfully (previously with transaction drafts)', async () => { const {waypoints, ...restOfComment} = mockTransaction.comment ?? {}; const mockCashExpenseTransaction = { ...mockTransaction, @@ -799,14 +801,6 @@ describe('actions/Duplicate', () => { }, }; - // Create some mock transaction drafts - const draftTransaction1 = createRandomTransaction(100); - const draftTransaction2 = createRandomTransaction(200); - const allTransactionDrafts = { - [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction1.transactionID}`]: draftTransaction1, - [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction2.transactionID}`]: draftTransaction2, - }; - await Onyx.clear(); duplicateExpenseTransaction({ @@ -823,7 +817,8 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, - allTransactionDrafts, + existingTransactionDraft: undefined, + draftTransactionIDs: [], }); await waitForBatchedUpdates(); @@ -843,12 +838,9 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.transactionID).toBeDefined(); // The duplicated transaction should have a different transactionID than the original expect(duplicatedTransaction?.transactionID).not.toBe(mockCashExpenseTransaction.transactionID); - // The duplicated transaction should not be one of the draft transactions - expect(duplicatedTransaction?.transactionID).not.toBe(draftTransaction1.transactionID); - expect(duplicatedTransaction?.transactionID).not.toBe(draftTransaction2.transactionID); }); - it('should create a duplicate expense with allTransactionDrafts as undefined', async () => { + it('should create a duplicate expense successfully (previously with undefined transaction drafts)', async () => { const {waypoints, ...restOfComment} = mockTransaction.comment ?? {}; const mockCashExpenseTransaction = { ...mockTransaction, @@ -872,8 +864,9 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, - allTransactionDrafts: undefined, isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], }); await waitForBatchedUpdates(); @@ -888,13 +881,13 @@ describe('actions/Duplicate', () => { }, }); - // Verify that a duplicated transaction was created even with undefined allTransactionDrafts + // 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 with allTransactionDrafts containing existing drafts', async () => { + 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; @@ -914,14 +907,6 @@ describe('actions/Duplicate', () => { }, }; - // Create some mock transaction drafts - const draftTransaction1 = createRandomTransaction(300); - const draftTransaction2 = createRandomTransaction(400); - const allTransactionDrafts = { - [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction1.transactionID}`]: draftTransaction1, - [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction2.transactionID}`]: draftTransaction2, - }; - await Onyx.clear(); duplicateExpenseTransaction({ @@ -936,8 +921,9 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, - allTransactionDrafts, isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], }); await waitForBatchedUpdates(); @@ -960,9 +946,6 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.comment?.units?.unit).toBe('h'); expect(duplicatedTransaction?.comment?.type).toBe('time'); expect(isTimeRequest(duplicatedTransaction)).toBeTruthy(); - // The duplicated transaction should not be one of the draft transactions - expect(duplicatedTransaction?.transactionID).not.toBe(draftTransaction1.transactionID); - expect(duplicatedTransaction?.transactionID).not.toBe(draftTransaction2.transactionID); }); }); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 05fbfa59fae24..c3fba683ec07d 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -1626,6 +1626,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, }); await waitForBatchedUpdates(); @@ -1785,6 +1787,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, }); await waitForBatchedUpdates(); @@ -1949,6 +1953,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, }); await waitForBatchedUpdates(); @@ -2123,6 +2129,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionViolations: {}, policyRecentlyUsedCurrencies: [], quickAction: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], isSelfTourViewed: false, }); await waitForBatchedUpdates(); From 593a1d24a90a8535ea0bdd3c502ca5a1e7151827 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Wed, 28 Jan 2026 10:58:53 +0700 Subject: [PATCH 05/13] lint fix --- src/libs/actions/IOU/Duplicate.ts | 2 +- src/pages/Share/SubmitDetailsPage.tsx | 2 +- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index c05c5ef8bf735..7ee7023f305bc 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -1,5 +1,5 @@ import {format} from 'date-fns'; -import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {PartialDeep} from 'type-fest'; import * as API from '@libs/API'; diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 2791bbca2bf68..6e3823958f3a4 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -1,7 +1,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; import reportsSelector from '@selectors/Attributes'; import {hasSeenTourSelector} from '@selectors/Onboarding'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index e40ea330f806b..c886ecbe68d6e 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -574,7 +574,7 @@ function IOURequestStepConfirmation({ } const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); - const existingTransactionDraft = transactions.find((transaction) => transaction.transactionID === existingTransactionID); + const existingTransactionDraft = transactions.find((tx) => tx.transactionID === existingTransactionID); const {iouReport} = requestMoneyIOUActions({ report, From 179ecb2e873b4ca2e82a433d198cb39a4c167615 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 12 Feb 2026 17:37:42 +0700 Subject: [PATCH 06/13] add tests --- tests/actions/IOU/MoneyRequestTest.ts | 84 +++++++++++++++++++++++++++ tests/actions/IOUTest.ts | 4 +- tests/actions/TransactionEditTest.ts | 53 ++++++++++++++++- tests/unit/IOUUtilsTest.ts | 32 ++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 801d96aa446c4..b267618338ad6 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -249,6 +249,90 @@ 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: { + [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction.transactionID}`]: draftTransaction, + }, + }); + + expect(IOU.requestMoney).toHaveBeenCalledWith( + expect.objectContaining({ + existingTransactionDraft: draftTransaction, + draftTransactionIDs: [draftTransaction.transactionID], + }), + ); + }); + + 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: { + [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draft1.transactionID}`]: draft1, + [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draft2.transactionID}`]: draft2, + }, + }); + + expect(IOU.requestMoney).toHaveBeenCalledWith( + expect.objectContaining({ + draftTransactionIDs: expect.arrayContaining([draft1.transactionID, draft2.transactionID]), + }), + ); + }); + + it('should filter out null entries from allTransactionDrafts when computing draftTransactionIDs', () => { + const draft1 = createRandomTransaction(101); + + createTransaction({ + ...baseParams, + allTransactionDrafts: { + [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draft1.transactionID}`]: draft1, + [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}nullEntry`]: undefined, + }, + }); + + expect(IOU.requestMoney).toHaveBeenCalledWith( + expect.objectContaining({ + draftTransactionIDs: [draft1.transactionID], + }), + ); + }); }); describe('handleMoneyRequestStepScanParticipants', () => { diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index d44539666baf2..e359773321046 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -3480,7 +3480,7 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, - existingTransactionDraft: undefined, + existingTransactionDraft: transaction, draftTransactionIDs: [], personalDetails: {}, betas: [CONST.BETAS.ALL], @@ -8131,6 +8131,8 @@ describe('actions/IOU', () => { policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], 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/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'); + }); +}); From fa6a592d6b0ef9569a5ad2890dbc38392cbd18dd Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 13 Feb 2026 10:45:12 +0700 Subject: [PATCH 07/13] ts fix --- tests/actions/IOUTest/DuplicateTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index f14159bddcdda..f95962764c59e 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1252,6 +1252,8 @@ describe('actions/Duplicate', () => { targetPolicy: mockPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, }); From b4be10b2722b5a9378c8515b2c4cf2ddaad2b033 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 13 Feb 2026 13:08:20 +0700 Subject: [PATCH 08/13] add test --- tests/actions/IOUTest/DuplicateTest.ts | 234 +++++++++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index f95962764c59e..2952756b3d9bc 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -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', () => { @@ -1202,6 +1280,162 @@ describe('actions/Duplicate', () => { 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: {}, + }); + + 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: {}, + }); + + await waitForBatchedUpdates(); + + // 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 = { + ...mockTransaction, + amount: mockTransaction.amount * -1, + comment: { + type: 'customUnit' as const, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + }, + }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockDistanceTransaction, + 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: {}, + }); + + await waitForBatchedUpdates(); + + // Verify API was called with CREATE_DISTANCE_REQUEST + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, expect.objectContaining({}), expect.objectContaining({})); + }); + + 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: {}, + }); + + await waitForBatchedUpdates(); + + // 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 () => { // Given a transaction with linkedTrackedExpenseReportAction set // This simulates a split expense that was removed from a report, where the From c6f2422dfc7287b7069b98104e0cf4f34df0905c Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Sun, 22 Feb 2026 12:25:28 +0700 Subject: [PATCH 09/13] add tests --- tests/actions/IOUTest.ts | 2 + tests/actions/IOUTest/DuplicateTest.ts | 10 + tests/actions/IOUTest/SplitTest.ts | 2 + tests/ui/IOURequestStepAmountDraftTest.tsx | 249 +++++++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 tests/ui/IOURequestStepAmountDraftTest.tsx diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index ffe7bc6f4dfbc..ac11f5a534691 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -10141,6 +10141,8 @@ describe('actions/IOU', () => { isSelfTourViewed: false, quickAction: undefined, betas: [CONST.BETAS.ALL], + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, }); diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 42c7068700584..d538c9d404f0b 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1151,6 +1151,7 @@ describe('actions/Duplicate', () => { draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, + recentWaypoints: [], }); await waitForBatchedUpdates(); @@ -1201,6 +1202,7 @@ describe('actions/Duplicate', () => { draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, + recentWaypoints: [], }); await waitForBatchedUpdates(); @@ -1260,6 +1262,7 @@ describe('actions/Duplicate', () => { draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, + recentWaypoints: [], }); await waitForBatchedUpdates(); @@ -1305,6 +1308,7 @@ describe('actions/Duplicate', () => { draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, + recentWaypoints: [], }); await waitForBatchedUpdates(); @@ -1343,6 +1347,7 @@ describe('actions/Duplicate', () => { draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, + recentWaypoints: [], }); await waitForBatchedUpdates(); @@ -1433,6 +1438,7 @@ describe('actions/Duplicate', () => { draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, + recentWaypoints: [], }); await waitForBatchedUpdates(); @@ -1539,6 +1545,8 @@ describe('actions/Duplicate', () => { targetPolicy: undefined, targetPolicyCategories: undefined, targetReport: undefined, + existingTransactionDraft: undefined, + draftTransactionIDs: [], betas: [CONST.BETAS.ALL], personalDetails: {}, recentWaypoints, @@ -1596,6 +1604,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 523734a766bc3..09ed0835b9777 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -1657,6 +1657,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { policyRecentlyUsedCurrencies: [], quickAction: undefined, isSelfTourViewed: false, + existingTransactionDraft: undefined, + draftTransactionIDs: [], personalDetails: {}, }); await waitForBatchedUpdates(); 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), + }), + ); + }); +}); From 4b2570873813dd6c869ab2c58503e5ce23fbf4fa Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Mon, 23 Feb 2026 09:21:56 +0700 Subject: [PATCH 10/13] add tests --- tests/actions/IOU/IOUSettersTest.ts | 469 ++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 tests/actions/IOU/IOUSettersTest.ts 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([]); + }); + }); +}); From a3cab5378aa13f4f1ccb9ef4799cf45e6ef40ba3 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 24 Feb 2026 09:51:45 +0700 Subject: [PATCH 11/13] refactor useOptimisticDraftTransactions --- src/components/MoneyReportHeader.tsx | 8 ++++---- src/components/MoneyRequestHeader.tsx | 8 ++++---- src/pages/Share/SubmitDetailsPage.tsx | 8 ++++---- src/pages/iou/request/step/IOURequestStepAmount.tsx | 8 ++++---- src/selectors/TransactionDraft.ts | 13 +++++++++++++ 5 files changed, 29 insertions(+), 16 deletions(-) create mode 100644 src/selectors/TransactionDraft.ts diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 64c8c5daf16b8..c22cf40f3f7d8 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'; @@ -18,7 +19,6 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; import useParticipantsInvoiceReport from '@hooks/useParticipantsInvoiceReport'; import usePaymentAnimations from '@hooks/usePaymentAnimations'; import usePaymentOptions from '@hooks/usePaymentOptions'; @@ -269,8 +269,8 @@ function MoneyReportHeader({ ] as const); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${moneyRequestReport?.reportID}`); - const [transactionDrafts] = useOptimisticDraftTransactions(undefined); - const draftTransactionIDs = transactionDrafts?.map((item) => item.transactionID); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); const {translate, localeCompare} = useLocalize(); @@ -705,7 +705,7 @@ function MoneyReportHeader({ for (const item of transactionList) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); - const existingTransactionDraft = transactionDrafts?.find((t) => t.transactionID === existingTransactionID); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; duplicateTransactionAction({ transaction: item, diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index eeb2b968e46d7..429dffe72f378 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'; @@ -15,7 +16,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -156,8 +156,8 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [transactionDrafts] = useOptimisticDraftTransactions(undefined); - const draftTransactionIDs = transactionDrafts?.map((item) => item.transactionID); + 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(); @@ -206,7 +206,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre for (const item of transactions) { const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction); - const existingTransactionDraft = transactionDrafts?.find((t) => t.transactionID === existingTransactionID); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; duplicateTransactionAction({ transaction: item, diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 32ea99fc4eb55..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'; @@ -11,7 +12,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; @@ -73,8 +73,8 @@ 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] = useOptimisticDraftTransactions(undefined); - const draftTransactionIDs = transactionDrafts?.map((item) => item.transactionID); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); const [betas] = useOnyx(ONYXKEYS.BETAS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -175,7 +175,7 @@ function SubmitDetailsPage({ }); } else { const existingTransactionID = getExistingTransactionID(transaction.linkedTrackedExpenseReportAction); - const existingTransactionDraft = transactionDrafts?.find((t) => t.transactionID === existingTransactionID); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; requestMoney({ report, diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index fc4c77dde5159..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'; @@ -10,7 +11,6 @@ import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useOptimisticDraftTransactions from '@hooks/useOptimisticDraftTransactions'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -130,8 +130,8 @@ function IOURequestStepAmount({ const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const [transactionDrafts] = useOptimisticDraftTransactions(undefined); - const draftTransactionIDs = transactionDrafts?.map((item) => item.transactionID); + const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector}); + const draftTransactionIDs = Object.keys(transactionDrafts ?? {}); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; @@ -250,7 +250,7 @@ function IOURequestStepAmount({ } if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); - const existingTransactionDraft = transactionDrafts?.find((t) => t.transactionID === existingTransactionID); + const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined; requestMoney({ report, 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}; From 2bd12607db23cee360476d240a30184cb034fa43 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 26 Feb 2026 10:27:32 +0700 Subject: [PATCH 12/13] refactor --- src/libs/actions/IOU/MoneyRequest.ts | 8 +++--- .../step/IOURequestStepScan/useReceiptScan.ts | 3 +++ tests/actions/IOU/MoneyRequestTest.ts | 25 +++---------------- 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 1b413e78eb840..e8eca70fa3c63 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -107,7 +107,7 @@ type MoneyRequestStepScanParticipantsFlowParams = { shouldGenerateTransactionThreadReport: boolean; selfDMReport: OnyxEntry; isSelfTourViewed: boolean; - allTransactionDrafts?: OnyxCollection; + allTransactionDrafts: OnyxCollection; betas: OnyxEntry; recentWaypoints: OnyxEntry; }; @@ -181,9 +181,7 @@ function createTransaction({ personalDetails, recentWaypoints, }: CreateTransactionParams) { - const draftTransactionIDs = Object.values(allTransactionDrafts ?? {}) - .filter((transaction): transaction is NonNullable => !!transaction) - .map((transaction) => transaction.transactionID); + const draftTransactionIDs = Object.keys(allTransactionDrafts ?? {}); for (const [index, receiptFile] of files.entries()) { const transaction = transactions.find((item) => item.transactionID === receiptFile.transactionID); @@ -221,7 +219,7 @@ function createTransaction({ }); } else { const existingTransactionID = getExistingTransactionID(transaction?.linkedTrackedExpenseReportAction); - const existingTransactionDraft = allTransactionDrafts?.[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${existingTransactionID}`]; + const existingTransactionDraft = existingTransactionID ? allTransactionDrafts?.[existingTransactionID] : undefined; requestMoney({ report, 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/tests/actions/IOU/MoneyRequestTest.ts b/tests/actions/IOU/MoneyRequestTest.ts index 373fddecbdeae..9be0fdc46418d 100644 --- a/tests/actions/IOU/MoneyRequestTest.ts +++ b/tests/actions/IOU/MoneyRequestTest.ts @@ -276,7 +276,7 @@ describe('MoneyRequest', () => { ...baseParams, transactions: [transactionWithLinkedAction], allTransactionDrafts: { - [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draftTransaction.transactionID}`]: draftTransaction, + [draftTransaction.transactionID]: draftTransaction, }, }); @@ -327,8 +327,8 @@ describe('MoneyRequest', () => { createTransaction({ ...baseParams, allTransactionDrafts: { - [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draft1.transactionID}`]: draft1, - [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draft2.transactionID}`]: draft2, + [draft1.transactionID]: draft1, + [draft2.transactionID]: draft2, }, }); @@ -339,24 +339,6 @@ describe('MoneyRequest', () => { ); }); - it('should filter out null entries from allTransactionDrafts when computing draftTransactionIDs', () => { - const draft1 = createRandomTransaction(101); - - createTransaction({ - ...baseParams, - allTransactionDrafts: { - [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${draft1.transactionID}`]: draft1, - [`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}nullEntry`]: undefined, - }, - }); - - expect(IOU.requestMoney).toHaveBeenCalledWith( - expect.objectContaining({ - draftTransactionIDs: [draft1.transactionID], - }), - ); - }); - it('should pass gpsPoint to trackExpense when provided', () => { const gpsPoint = {lat: TEST_LATITUDE, long: TEST_LONGITUDE}; createTransaction({ @@ -429,6 +411,7 @@ describe('MoneyRequest', () => { isSelfTourViewed: false, betas: [], recentWaypoints: [] as RecentWaypoint[], + allTransactionDrafts: {}, }; beforeEach(async () => { From 1b74c12c938d1f1ebdaf543926a3c7c835ffafbb Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 26 Feb 2026 10:29:48 +0700 Subject: [PATCH 13/13] lint fix --- src/libs/actions/IOU/MoneyRequest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index e8eca70fa3c63..555252c958422 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -15,7 +15,6 @@ import {setTransactionReport} from '@userActions/Transaction'; import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {Beta, IntroSelected, LastSelectedDistanceRates, PersonalDetailsList, Policy, QuickAction, RecentWaypoint, Report, Transaction, TransactionViolation} from '@src/types/onyx';