From 48f6c1199af659680beaf22192e90bfe0af228bb Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 2 Mar 2026 20:38:42 +0530 Subject: [PATCH 01/26] Implement core "Duplicate report" action logic. Signed-off-by: krishna2323 --- src/components/MoneyReportHeader.tsx | 36 ++++-- src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/libs/actions/IOU/Duplicate.ts | 136 +++++++++++++++++++- tests/actions/IOUTest/DuplicateTest.ts | 166 ++++++++++++++++++++++++- 5 files changed, 326 insertions(+), 14 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index cb27a42652c77..d565feeb4181f 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -35,7 +35,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import useTransactionViolations from '@hooks/useTransactionViolations'; -import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; +import {duplicateReport as duplicateReportAction, duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; import {openOldDotLink} from '@libs/actions/Link'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -61,7 +61,7 @@ import { } from '@libs/NextStepUtils'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {selectPaymentType} from '@libs/PaymentUtils'; -import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; +import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow, isPolicyMember} from '@libs/PolicyUtils'; import {getIOUActionForReportID, getOriginalMessage, getReportAction, hasPendingDEWApprove, hasPendingDEWSubmit, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {getAllExpensesToHoldIfApplicable, getReportPrimaryAction, isMarkAsResolvedAction} from '@libs/ReportPrimaryActionUtils'; import {getSecondaryExportReportActions, getSecondaryReportActions} from '@libs/ReportSecondaryActionUtils'; @@ -213,7 +213,8 @@ function MoneyReportHeader({ | PlatformStackRouteProp | PlatformStackRouteProp >(); - const {login: currentUserLogin, accountID, email} = useCurrentUserPersonalDetails(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {login: currentUserLogin, accountID, email} = currentUserPersonalDetails; const personalDetails = usePersonalDetails(); const defaultExpensePolicy = useDefaultExpensePolicy(); const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id); @@ -1465,11 +1466,28 @@ function MoneyReportHeader({ icon: expensifyIcons.ReportCopy, value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DUPLICATE_REPORT, - // To be implemented in https://github.com/Expensify/App/issues/82153 - // onSelected: () => { - // }, - // Remove after implementation - shouldShow: false, + onSelected: () => { + const sourcePolicy = policy; + const targetPolicy = sourcePolicy && isPolicyMember(sourcePolicy, currentUserLogin) ? sourcePolicy : defaultExpensePolicy; + const targetPolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicy?.id}`] ?? {}; + + duplicateReportAction({ + sourceReportTransactions: transactions, + sourceReportName: moneyRequestReport?.reportName ?? '', + targetPolicy: targetPolicy ?? undefined, + targetPolicyCategories, + ownerPersonalDetails: currentUserPersonalDetails, + isASAPSubmitBetaEnabled, + betas, + personalDetails, + quickAction, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + draftTransactionIDs, + isSelfTourViewed, + transactionViolations: allTransactionViolations, + translate, + }); + }, }, [CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE]: { text: translate('iou.changeWorkspace'), @@ -1764,7 +1782,7 @@ function MoneyReportHeader({ } return option; }); - }, [originalSelectedTransactionsOptions, showDeleteModal, dismissedRejectUseExplanation]); + }, [originalSelectedTransactionsOptions, showDeleteModal, dismissedRejectUseExplanation, isDelegateAccessRestricted, showDelegateNoAccessModal]); const shouldShowSelectedTransactionsButton = !!selectedTransactionsOptions.length && !transactionThreadReportID; diff --git a/src/languages/en.ts b/src/languages/en.ts index 2c7b215126acc..426ea366cb7bc 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -552,6 +552,7 @@ const translations = { duplicated: 'Duplicated', duplicateExpense: 'Duplicate expense', duplicateReport: 'Duplicate report', + copyOfReportName: (reportName: string) => `Copy of ${reportName}`, exchangeRate: 'Exchange rate', reimbursableTotal: 'Reimbursable total', nonReimbursableTotal: 'Non-reimbursable total', diff --git a/src/languages/es.ts b/src/languages/es.ts index a4c45e1aebc34..c505df25df13e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -397,6 +397,7 @@ const translations: TranslationDeepObject = { duplicated: 'Duplicado', duplicateExpense: 'Duplicar gasto', duplicateReport: 'Duplicar informe', + copyOfReportName: (reportName: string) => `Copia de ${reportName}`, exchangeRate: 'Tipo de cambio', reimbursableTotal: 'Total reembolsable', nonReimbursableTotal: 'Total no reembolsable', diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 822b7c41ad138..6098dc81584f7 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -1,12 +1,14 @@ 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 type {LocalizedTranslate} from '@components/LocaleContextProvider'; import * as API from '@libs/API'; import type {MergeDuplicatesParams, ResolveDuplicatesParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; import * as NumberUtils from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import {getIOUActionForReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -18,11 +20,14 @@ import { buildTransactionThread, getTransactionDetails, } from '@libs/ReportUtils'; -import {getRequestType, getTransactionType} from '@libs/TransactionUtils'; +import {getRequestType, getTransactionType, isFromCreditCardImport, isPartialTransaction, isScanning} from '@libs/TransactionUtils'; +import {createNewReport, updateReportName} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type {CreateDistanceRequestInformation, CreateTrackExpenseParams, PerDiemExpenseInformation, RequestMoneyInformation} from '.'; import { @@ -669,5 +674,128 @@ function duplicateExpenseTransaction({ } } -export {getIOUActionForTransactions, mergeDuplicates, resolveDuplicates, duplicateExpenseTransaction}; -export type {DuplicateExpenseTransactionParams}; +type DuplicateReportParams = { + sourceReportTransactions: OnyxTypes.Transaction[]; + sourceReportName: string; + targetPolicy: OnyxEntry; + targetPolicyCategories: OnyxEntry; + ownerPersonalDetails: CurrentUserPersonalDetails; + isASAPSubmitBetaEnabled: boolean; + betas: OnyxEntry; + personalDetails: OnyxEntry; + quickAction: OnyxEntry; + policyRecentlyUsedCurrencies: string[]; + draftTransactionIDs: string[]; + isSelfTourViewed: boolean; + transactionViolations: OnyxCollection; + translate: LocalizedTranslate; +}; + +function duplicateReport({ + sourceReportTransactions, + sourceReportName, + targetPolicy, + targetPolicyCategories, + ownerPersonalDetails, + isASAPSubmitBetaEnabled, + betas, + personalDetails, + quickAction, + policyRecentlyUsedCurrencies, + draftTransactionIDs, + isSelfTourViewed, + transactionViolations, + translate, +}: DuplicateReportParams) { + const newReport = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas); + + const newReportName = translate('common.copyOfReportName', sourceReportName); + updateReportName(newReport.reportID, newReportName, newReport.reportName ?? ''); + + const eligibleTransactions = sourceReportTransactions.filter((transaction) => { + if (isFromCreditCardImport(transaction)) { + return false; + } + if (transaction.accountant) { + return false; + } + if (isPartialTransaction(transaction) || isScanning(transaction)) { + return false; + } + return true; + }); + + const userAccountID = getUserAccountID(); + const currentUserEmailValue = getCurrentUserEmail(); + const parentChatReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${newReport.chatReportID}`]; + const participants = getMoneyRequestParticipantsFromReport(parentChatReport, userAccountID); + + const policyParams = targetPolicy + ? { + policy: targetPolicy, + // eslint-disable-next-line @typescript-eslint/no-deprecated + policyTagList: getPolicyTagsData(targetPolicy.id) ?? {}, + policyCategories: targetPolicyCategories ?? {}, + } + : undefined; + + for (const transaction of eligibleTransactions) { + const transactionDetails = getTransactionDetails(transaction); + if (!transactionDetails) { + continue; + } + + const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; + + const params: RequestMoneyInformation = { + report: parentChatReport, + existingIOUReport: newReport as OnyxEntry, + participantParams: { + payeeAccountID: userAccountID, + payeeEmail: currentUserEmailValue, + participant: participants.at(0) ?? {}, + }, + policyParams, + gpsPoint: undefined, + action: CONST.IOU.ACTION.CREATE, + transactionParams: { + ...transactionWithoutLinkedAction, + ...transactionDetails, + attendees: transactionDetails.attendees as Attendee[] | undefined, + comment: Parser.htmlToMarkdown(transactionDetails.comment ?? ''), + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + customUnitRateID: transaction.comment?.customUnit?.customUnitRateID, + merchant: transaction.modifiedMerchant ? transaction.modifiedMerchant : (transaction.merchant ?? ''), + modifiedAmount: undefined, + originalTransactionID: undefined, + receipt: undefined, + source: undefined, + waypoints: transactionDetails.waypoints as WaypointCollection | undefined, + type: transaction.comment?.type, + count: transaction.comment?.units?.count, + rate: transaction.comment?.units?.rate, + unit: transaction.comment?.units?.unit, + }, + shouldHandleNavigation: false, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam: userAccountID, + currentUserEmailParam: currentUserEmailValue, + transactionViolations: transactionViolations ?? {}, + quickAction, + policyRecentlyUsedCurrencies, + existingTransactionDraft: undefined, + draftTransactionIDs, + isSelfTourViewed, + betas, + personalDetails, + }; + + requestMoney(params); + } + + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(newReport.reportID)); +} + +export {getIOUActionForTransactions, mergeDuplicates, resolveDuplicates, duplicateExpenseTransaction, duplicateReport}; +export type {DuplicateExpenseTransactionParams, DuplicateReportParams}; diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index d30633430e14a..bbcbda5530a5c 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -3,10 +3,12 @@ import type {OnyxEntry, OnyxInputValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {getReportPreviewAction} from '@libs/actions/IOU'; -import {duplicateExpenseTransaction, mergeDuplicates, resolveDuplicates} from '@libs/actions/IOU/Duplicate'; +import {duplicateExpenseTransaction, duplicateReport, mergeDuplicates, resolveDuplicates} from '@libs/actions/IOU/Duplicate'; +import type {DuplicateReportParams} from '@libs/actions/IOU/Duplicate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {addComment, openReport} from '@libs/actions/Report'; import {WRITE_COMMANDS} from '@libs/API/types'; +import Navigation from '@libs/Navigation/Navigation'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, getReportAction} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReport, buildOptimisticIOUReportAction, buildTransactionThread} from '@libs/ReportUtils'; @@ -1714,4 +1716,166 @@ describe('actions/Duplicate', () => { }); }); }); + + describe('duplicateReport', () => { + let writeSpy: jest.SpyInstance; + + const mockPolicy = createRandomPolicy(1); + const mockPolicyCategories = createRandomPolicyCategories(3); + const mockPersonalDetails = { + [RORY_ACCOUNT_ID]: { + accountID: RORY_ACCOUNT_ID, + login: RORY_EMAIL, + displayName: 'Rory', + }, + }; + const mockOwnerPersonalDetails = { + accountID: RORY_ACCOUNT_ID, + login: RORY_EMAIL, + displayName: 'Rory', + }; + + const mockTranslate = ((path: string, ...args: string[]) => { + if (path === 'common.copyOfReportName') { + return `Copy of ${args.at(0)}`; + } + return path; + }) as DuplicateReportParams['translate']; + + const createCashTransaction = (id: string, overrides: Partial = {}): Transaction => ({ + ...createRandomTransaction(Number(id)), + transactionID: id, + amount: -500, + merchant: 'Test Merchant', + modifiedMerchant: '', + currency: CONST.CURRENCY.USD, + cardNumber: '', + cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, + managedCard: false, + bank: '', + receipt: {}, + ...overrides, + }); + + const getDefaultParams = (sourceTransactions: Transaction[]): DuplicateReportParams => ({ + sourceReportTransactions: sourceTransactions, + sourceReportName: 'Original Report', + targetPolicy: mockPolicy, + targetPolicyCategories: mockPolicyCategories, + ownerPersonalDetails: mockOwnerPersonalDetails, + isASAPSubmitBetaEnabled: false, + betas: [CONST.BETAS.ALL], + personalDetails: mockPersonalDetails, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + draftTransactionIDs: [], + isSelfTourViewed: false, + transactionViolations: {}, + translate: mockTranslate, + }); + + const countWriteCommandCalls = (command: string) => writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === command).length; + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = getGlobalFetchMock(); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + writeSpy = jest.spyOn(API, 'write').mockImplementation((command, params, options) => { + if (options?.optimisticData) { + for (const update of options.optimisticData) { + if (update.onyxMethod === Onyx.METHOD.MERGE) { + Onyx.merge(update.key, update.value); + } else if (update.onyxMethod === Onyx.METHOD.SET) { + Onyx.set(update.key, update.value); + } + } + } + return Promise.resolve(); + }); + return Onyx.clear(); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('should create a new report and duplicate all eligible transactions', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2', {merchant: 'Coffee Shop'}); + + duplicateReport(getDefaultParams([tx1, tx2])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.SET_REPORT_NAME)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); + + const setReportNameCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.SET_REPORT_NAME) as unknown[] | undefined; + expect(setReportNameCall?.at(1)).toEqual(expect.objectContaining({reportName: 'Copy of Original Report'})); + + expect(Navigation.navigate).toHaveBeenCalled(); + }); + + it('should filter out credit card import transactions', async () => { + const cashTx = createCashTransaction('cash1'); + const cardTx = createCashTransaction('card1', { + transactionType: CONST.SEARCH.TRANSACTION_TYPE.CARD, + }); + const cashTx2 = createCashTransaction('cash2'); + + duplicateReport(getDefaultParams([cashTx, cardTx, cashTx2])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); + }); + + it('should filter out accountant (Expensiworks) transactions', async () => { + const normalTx = createCashTransaction('normal1'); + const accountantTx = createCashTransaction('acct1', { + accountant: {accountID: 999, login: 'accountant@test.com'}, + }); + + duplicateReport(getDefaultParams([normalTx, accountantTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + }); + + it('should filter out scanning transactions', async () => { + const normalTx = createCashTransaction('normal1'); + const scanningTx = createCashTransaction('scan1', { + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + receipt: { + source: 'receipt.jpg', + state: CONST.IOU.RECEIPT_STATE.SCANNING, + }, + }); + + duplicateReport(getDefaultParams([normalTx, scanningTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + }); + + it('should still create the report when all transactions are ineligible', async () => { + const cardTx = createCashTransaction('card1', { + transactionType: CONST.SEARCH.TRANSACTION_TYPE.CARD, + }); + const accountantTx = createCashTransaction('acct1', { + accountant: {accountID: 999, login: 'accountant@test.com'}, + }); + + duplicateReport(getDefaultParams([cardTx, accountantTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.SET_REPORT_NAME)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(0); + + expect(Navigation.navigate).toHaveBeenCalled(); + }); + }); }); From c2805479cfe028b628bfa320dcaeb7cafef437e3 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 6 Mar 2026 02:30:51 +0530 Subject: [PATCH 02/26] Implement duplicate report action with proper transaction routing - Pass reportName to CREATE_APP_REPORT instead of separate SET_REPORT_NAME call - Route distance/per-diem transactions through specialized creation flows - Exclude pending-deletion expenses from duplication - Add translations for report copy name (en, es) - Add tests for duplicateReport covering filtering and transaction type routing Made-with: Cursor --- src/components/MoneyReportHeader.tsx | 4 +- .../API/parameters/CreateAppReportParams.ts | 1 + src/libs/actions/IOU/Duplicate.ts | 63 ++++++++++++++++--- src/libs/actions/Report/index.ts | 8 +++ tests/actions/IOUTest/DuplicateTest.ts | 53 ++++++++++++++-- 5 files changed, 116 insertions(+), 13 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ea2f603606e5a..a323169441126 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1563,10 +1563,11 @@ function MoneyReportHeader({ const targetPolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicy?.id}`] ?? {}; duplicateReportAction({ - sourceReportTransactions: transactions, + sourceReportTransactions: nonPendingDeleteTransactions, sourceReportName: moneyRequestReport?.reportName ?? '', targetPolicy: targetPolicy ?? undefined, targetPolicyCategories, + targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicy?.id}`] ?? {}, ownerPersonalDetails: currentUserPersonalDetails, isASAPSubmitBetaEnabled, betas, @@ -1577,6 +1578,7 @@ function MoneyReportHeader({ isSelfTourViewed, transactionViolations: allTransactionViolations, translate, + recentWaypoints: recentWaypoints ?? [], }); }, }, diff --git a/src/libs/API/parameters/CreateAppReportParams.ts b/src/libs/API/parameters/CreateAppReportParams.ts index de9e533b86d28..b5776ca3a4018 100644 --- a/src/libs/API/parameters/CreateAppReportParams.ts +++ b/src/libs/API/parameters/CreateAppReportParams.ts @@ -6,5 +6,6 @@ type CreateAppReportParams = { reportPreviewReportActionID: string; ownerEmail?: string; shouldDismissEmptyReportsConfirmation?: boolean; + reportName?: string; }; export default CreateAppReportParams; diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 288a01ab647db..9c00d047fef0d 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -20,8 +20,8 @@ import { buildTransactionThread, getTransactionDetails, } from '@libs/ReportUtils'; -import {getRequestType, getTransactionType, isFromCreditCardImport, isPartialTransaction, isScanning, isDistanceRequest, isExpenseSplit} from '@libs/TransactionUtils'; -import {createNewReport, updateReportName} from '@userActions/Report'; +import {getRequestType, getTransactionType, isDistanceRequest, isExpenseSplit, isFromCreditCardImport, isPartialTransaction, isScanning} from '@libs/TransactionUtils'; +import {createNewReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -692,6 +692,7 @@ type DuplicateReportParams = { sourceReportName: string; targetPolicy: OnyxEntry; targetPolicyCategories: OnyxEntry; + targetPolicyTags: OnyxEntry; ownerPersonalDetails: CurrentUserPersonalDetails; isASAPSubmitBetaEnabled: boolean; betas: OnyxEntry; @@ -702,6 +703,7 @@ type DuplicateReportParams = { isSelfTourViewed: boolean; transactionViolations: OnyxCollection; translate: LocalizedTranslate; + recentWaypoints: OnyxEntry; }; function duplicateReport({ @@ -709,6 +711,7 @@ function duplicateReport({ sourceReportName, targetPolicy, targetPolicyCategories, + targetPolicyTags, ownerPersonalDetails, isASAPSubmitBetaEnabled, betas, @@ -719,11 +722,10 @@ function duplicateReport({ isSelfTourViewed, transactionViolations, translate, + recentWaypoints, }: DuplicateReportParams) { - const newReport = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas); - const newReportName = translate('common.copyOfReportName', sourceReportName); - updateReportName(newReport.reportID, newReportName, newReport.reportName ?? ''); + const newReport = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas, false, undefined, newReportName); const eligibleTransactions = sourceReportTransactions.filter((transaction) => { if (isFromCreditCardImport(transaction)) { @@ -746,8 +748,7 @@ function duplicateReport({ const policyParams = targetPolicy ? { policy: targetPolicy, - // eslint-disable-next-line @typescript-eslint/no-deprecated - policyTagList: getPolicyTagsData(targetPolicy.id) ?? {}, + policyTagList: targetPolicyTags, policyCategories: targetPolicyCategories ?? {}, } : undefined; @@ -804,7 +805,53 @@ function duplicateReport({ personalDetails, }; - requestMoney(params); + const transactionType = getTransactionType(transaction); + + switch (transactionType) { + case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: { + const distanceParams: CreateDistanceRequestInformation = { + ...params, + participants, + existingTransaction: { + ...(params.transactionParams ?? {}), + comment: transaction.comment, + iouRequestType: getRequestType(transaction), + modifiedCreated: '', + reportID: '1', + transactionID: '1', + }, + transactionParams: { + ...(params.transactionParams ?? {}), + comment: Parser.htmlToMarkdown(transactionDetails.comment ?? ''), + validWaypoints: transactionDetails.waypoints as WaypointCollection | undefined, + }, + policyRecentlyUsedCurrencies, + quickAction, + customUnitPolicyID: targetPolicy?.id ?? '', + personalDetails, + recentWaypoints, + }; + createDistanceRequest(distanceParams); + break; + } + case CONST.SEARCH.TRANSACTION_TYPE.PER_DIEM: { + const perDiemParams: PerDiemExpenseInformation = { + ...params, + transactionParams: { + ...(params.transactionParams ?? {}), + comment: transactionDetails.comment ?? '', + customUnit: transaction?.comment?.customUnit ?? {}, + }, + hasViolations: false, + customUnitPolicyID: targetPolicy?.id ?? '', + }; + submitPerDiemExpense(perDiemParams); + break; + } + default: + requestMoney(params); + break; + } } Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(newReport.reportID)); diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 9a80202ec08cf..d6b8a3aca07f3 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -3280,12 +3280,17 @@ function buildNewReportOptimisticData( hasViolationsParam: boolean, isASAPSubmitBetaEnabled: boolean, betas: OnyxEntry, + reportName?: string, ) { const {accountID, login, email} = ownerPersonalDetails; const timeOfCreation = DateUtils.getDBTime(); const parentReport = getPolicyExpenseChat(accountID, policy?.id); const optimisticReportData = buildOptimisticEmptyReport(reportID, accountID, parentReport, reportPreviewReportActionID, policy, timeOfCreation, betas); + if (reportName) { + optimisticReportData.reportName = reportName; + } + const optimisticNextStep = buildOptimisticNextStep({ report: optimisticReportData, predictedNextStatus: CONST.REPORT.STATUS_NUM.OPEN, @@ -3511,6 +3516,7 @@ function createNewReport( betas: OnyxEntry, shouldNotifyNewAction = false, shouldDismissEmptyReportsConfirmation?: boolean, + reportName?: string, ) { const optimisticReportID = generateReportID(); const reportActionID = rand64(); @@ -3525,6 +3531,7 @@ function createNewReport( hasViolationsParam, isASAPSubmitBetaEnabled, betas, + reportName, ); if (shouldDismissEmptyReportsConfirmation) { @@ -3541,6 +3548,7 @@ function createNewReport( reportPreviewReportActionID, ownerEmail: ownerPersonalDetails.login, ...(shouldDismissEmptyReportsConfirmation ? {shouldDismissEmptyReportsConfirmation} : {}), + ...(reportName ? {reportName} : {}), }, {optimisticData, successData, failureData}, ); diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index d56ed47126088..9fb801ead006a 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1782,6 +1782,7 @@ describe('actions/Duplicate', () => { sourceReportName: 'Original Report', targetPolicy: mockPolicy, targetPolicyCategories: mockPolicyCategories, + targetPolicyTags: {}, ownerPersonalDetails: mockOwnerPersonalDetails, isASAPSubmitBetaEnabled: false, betas: [CONST.BETAS.ALL], @@ -1792,6 +1793,7 @@ describe('actions/Duplicate', () => { isSelfTourViewed: false, transactionViolations: {}, translate: mockTranslate, + recentWaypoints: [], }); const countWriteCommandCalls = (command: string) => writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === command).length; @@ -1827,11 +1829,10 @@ describe('actions/Duplicate', () => { await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); - expect(countWriteCommandCalls(WRITE_COMMANDS.SET_REPORT_NAME)).toBe(1); expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); - const setReportNameCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.SET_REPORT_NAME) as unknown[] | undefined; - expect(setReportNameCall?.at(1)).toEqual(expect.objectContaining({reportName: 'Copy of Original Report'})); + const createReportCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_APP_REPORT) as unknown[] | undefined; + expect(createReportCall?.at(1)).toEqual(expect.objectContaining({reportName: 'Copy of Original Report'})); expect(Navigation.navigate).toHaveBeenCalled(); }); @@ -1880,6 +1881,51 @@ describe('actions/Duplicate', () => { expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); }); + it('should route distance transactions through createDistanceRequest', async () => { + const cashTx = createCashTransaction('cash1'); + const distanceTx = createCashTransaction('dist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + }, + }, + }); + + duplicateReport(getDefaultParams([cashTx, distanceTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)).toBe(1); + }); + + it('should route per diem transactions through submitPerDiemExpense', async () => { + const cashTx = createCashTransaction('cash1'); + const perDiemTx = createCashTransaction('pd1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: 'unit1', + customUnitRateID: 'rate1', + subRates: [{id: 'sub1', quantity: 1, name: 'Full Day', rate: 100}], + attributes: {dates: {start: '2024-01-01', end: '2024-01-02'}}, + }, + }, + }); + + duplicateReport(getDefaultParams([cashTx, perDiemTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(1); + }); + it('should still create the report when all transactions are ineligible', async () => { const cardTx = createCashTransaction('card1', { transactionType: CONST.SEARCH.TRANSACTION_TYPE.CARD, @@ -1892,7 +1938,6 @@ describe('actions/Duplicate', () => { await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); - expect(countWriteCommandCalls(WRITE_COMMANDS.SET_REPORT_NAME)).toBe(1); expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(0); expect(Navigation.navigate).toHaveBeenCalled(); From 7bafa0f4f48361aa541ce9f2f0b738cd3dfbca2d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 6 Mar 2026 12:50:20 +0530 Subject: [PATCH 03/26] fix ESLint. Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 9c00d047fef0d..d979933905a34 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -827,7 +827,7 @@ function duplicateReport({ }, policyRecentlyUsedCurrencies, quickAction, - customUnitPolicyID: targetPolicy?.id ?? '', + customUnitPolicyID: targetPolicy?.id, personalDetails, recentWaypoints, }; @@ -843,7 +843,7 @@ function duplicateReport({ customUnit: transaction?.comment?.customUnit ?? {}, }, hasViolations: false, - customUnitPolicyID: targetPolicy?.id ?? '', + customUnitPolicyID: targetPolicy?.id, }; submitPerDiemExpense(perDiemParams); break; From 5a451417c4d3bab036bc81793402143778021187 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 6 Mar 2026 21:03:59 +0530 Subject: [PATCH 04/26] Fix duplicate report preview, remove navigation, and suppress multiple sounds Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 21 ++++++++++++---- src/libs/actions/IOU/index.ts | 22 ++++++++++++++--- src/libs/actions/Report/index.ts | 2 +- tests/actions/IOUTest/DuplicateTest.ts | 34 ++++++++++++++++++++++---- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index d979933905a34..491e5073eaa5d 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -8,7 +8,7 @@ import type {MergeDuplicatesParams, ResolveDuplicatesParams} from '@libs/API/par import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; -import Navigation from '@libs/Navigation/Navigation'; +import {updateIOUOwnerAndTotal} from '@libs/IOUUtils'; import * as NumberUtils from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import {getIOUActionForReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -20,11 +20,11 @@ import { buildTransactionThread, getTransactionDetails, } from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import {getRequestType, getTransactionType, isDistanceRequest, isExpenseSplit, isFromCreditCardImport, isPartialTransaction, isScanning} from '@libs/TransactionUtils'; import {createNewReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; @@ -725,7 +725,7 @@ function duplicateReport({ recentWaypoints, }: DuplicateReportParams) { const newReportName = translate('common.copyOfReportName', sourceReportName); - const newReport = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas, false, undefined, newReportName); + const {reportPreviewReportActionID, ...newReport} = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas, false, undefined, newReportName); const eligibleTransactions = sourceReportTransactions.filter((transaction) => { if (isFromCreditCardImport(transaction)) { @@ -743,6 +743,11 @@ function duplicateReport({ const userAccountID = getUserAccountID(); const currentUserEmailValue = getCurrentUserEmail(); const parentChatReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${newReport.chatReportID}`]; + + if (!parentChatReport) { + return; + } + const participants = getMoneyRequestParticipantsFromReport(parentChatReport, userAccountID); const policyParams = targetPolicy @@ -753,6 +758,8 @@ function duplicateReport({ } : undefined; + let currentIOUReport = newReport as OnyxEntry; + for (const transaction of eligibleTransactions) { const transactionDetails = getTransactionDetails(transaction); if (!transactionDetails) { @@ -763,7 +770,8 @@ function duplicateReport({ const params: RequestMoneyInformation = { report: parentChatReport, - existingIOUReport: newReport as OnyxEntry, + existingIOUReport: currentIOUReport, + optimisticReportPreviewActionID: reportPreviewReportActionID, participantParams: { payeeAccountID: userAccountID, payeeEmail: currentUserEmailValue, @@ -791,6 +799,7 @@ function duplicateReport({ unit: transaction.comment?.units?.unit, }, shouldHandleNavigation: false, + shouldPlaySound: false, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled, currentUserAccountIDParam: userAccountID, @@ -852,9 +861,11 @@ function duplicateReport({ requestMoney(params); break; } + + currentIOUReport = updateIOUOwnerAndTotal(currentIOUReport, userAccountID, transactionDetails.amount ?? 0, transactionDetails.currency ?? ''); } - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(newReport.reportID)); + playSound(SOUNDS.DONE); } export {getIOUActionForTransactions, mergeDuplicates, resolveDuplicates, duplicateExpenseTransaction, duplicateReport}; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 7babf90eeba93..947d3b721b422 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -533,6 +533,8 @@ type PerDiemExpenseInformation = { betas: OnyxEntry; customUnitPolicyID?: string; shouldHandleNavigation?: boolean; + shouldPlaySound?: boolean; + optimisticReportPreviewActionID?: string; }; type PerDiemExpenseInformationParams = { @@ -549,6 +551,7 @@ type PerDiemExpenseInformationParams = { quickAction: OnyxEntry; policyRecentlyUsedCurrencies: string[]; betas: OnyxEntry; + optimisticReportPreviewActionID?: string; }; type RequestMoneyInformation = { @@ -686,8 +689,10 @@ type CreateDistanceRequestInformation = { recentWaypoints: OnyxEntry; customUnitPolicyID?: string; shouldHandleNavigation?: boolean; + shouldPlaySound?: boolean; personalDetails: OnyxEntry; betas: OnyxEntry; + optimisticReportPreviewActionID?: string; }; type CreateSplitsTransactionParams = Omit & { @@ -3822,6 +3827,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI quickAction, policyRecentlyUsedCurrencies, betas, + optimisticReportPreviewActionID, } = perDiemExpenseInformation; const {payeeAccountID = userAccountID, payeeEmail = currentUserEmail, participant} = participantParams; const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; @@ -3965,7 +3971,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI if (reportPreviewAction) { reportPreviewAction = updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); } else { - reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction); + reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction, undefined, optimisticReportPreviewActionID); chatReport.lastVisibleActionCreated = reportPreviewAction.created; // Generated ReportPreview action is a parent report action of the iou report. @@ -6886,6 +6892,8 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf betas, customUnitPolicyID, shouldHandleNavigation = true, + shouldPlaySound: shouldPlaySoundParam = true, + optimisticReportPreviewActionID, } = submitPerDiemExpenseInformation; const {payeeAccountID} = participantParams; const {currency, comment = '', category, tag, created, customUnit, attendees, isFromGlobalCreate} = transactionParams; @@ -6933,6 +6941,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf quickAction, policyRecentlyUsedCurrencies, betas, + optimisticReportPreviewActionID, }); const activeReportID = isMoneyRequestReport && Navigation.getTopmostReportId() === report?.reportID ? report?.reportID : chatReport.reportID; @@ -6972,7 +6981,9 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf customUnitPolicyID, }; - playSound(SOUNDS.DONE); + if (shouldPlaySoundParam) { + playSound(SOUNDS.DONE); + } API.write(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST, parameters, onyxData); // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -8334,8 +8345,10 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest recentWaypoints = [], customUnitPolicyID, shouldHandleNavigation = true, + shouldPlaySound: shouldPlaySoundParam = true, personalDetails, betas, + optimisticReportPreviewActionID, } = distanceRequestInformation; const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; const parsedComment = getParsedComment(transactionParams.comment); @@ -8498,6 +8511,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest policyRecentlyUsedCurrencies, personalDetails, betas, + optimisticReportPreviewActionID, }); onyxData = moneyRequestOnyxData; @@ -8565,7 +8579,9 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest value: recentServerValidatedWaypoints, }); - playSound(SOUNDS.DONE); + if (shouldPlaySoundParam) { + playSound(SOUNDS.DONE); + } API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData); // eslint-disable-next-line @typescript-eslint/no-deprecated diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index d6b8a3aca07f3..093d48835cfa6 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -3556,7 +3556,7 @@ function createNewReport( notifyNewAction(parentReportID, reportPreviewAction, true); } - return optimisticReportData; + return {...optimisticReportData, reportPreviewReportActionID}; } /** diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 9fb801ead006a..ac5670d007275 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1777,7 +1777,7 @@ describe('actions/Duplicate', () => { ...overrides, }); - const getDefaultParams = (sourceTransactions: Transaction[]): DuplicateReportParams => ({ + const getDefaultParams = (sourceTransactions: Transaction[], overrides: Partial = {}): DuplicateReportParams => ({ sourceReportTransactions: sourceTransactions, sourceReportName: 'Original Report', targetPolicy: mockPolicy, @@ -1794,11 +1794,14 @@ describe('actions/Duplicate', () => { transactionViolations: {}, translate: mockTranslate, recentWaypoints: [], + ...overrides, }); const countWriteCommandCalls = (command: string) => writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === command).length; - beforeEach(() => { + const POLICY_EXPENSE_CHAT_REPORT_ID = 'policyExpenseChatReport'; + + beforeEach(async () => { jest.clearAllMocks(); global.fetch = getGlobalFetchMock(); // eslint-disable-next-line rulesdir/no-multiple-api-calls @@ -1814,7 +1817,15 @@ describe('actions/Duplicate', () => { } return Promise.resolve(); }); - return Onyx.clear(); + await Onyx.clear(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${POLICY_EXPENSE_CHAT_REPORT_ID}`, { + reportID: POLICY_EXPENSE_CHAT_REPORT_ID, + policyID: mockPolicy.id, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + ownerAccountID: RORY_ACCOUNT_ID, + type: CONST.REPORT.TYPE.CHAT, + }); + await waitForBatchedUpdates(); }); afterEach(() => { @@ -1834,7 +1845,7 @@ describe('actions/Duplicate', () => { const createReportCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_APP_REPORT) as unknown[] | undefined; expect(createReportCall?.at(1)).toEqual(expect.objectContaining({reportName: 'Copy of Original Report'})); - expect(Navigation.navigate).toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); }); it('should filter out credit card import transactions', async () => { @@ -1926,6 +1937,19 @@ describe('actions/Duplicate', () => { expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(1); }); + it('should not duplicate expenses when no parent chat report exists', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2'); + + duplicateReport(getDefaultParams([tx1, tx2], {targetPolicy: undefined})); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(0); + + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + it('should still create the report when all transactions are ineligible', async () => { const cardTx = createCashTransaction('card1', { transactionType: CONST.SEARCH.TRANSACTION_TYPE.CARD, @@ -1940,7 +1964,7 @@ describe('actions/Duplicate', () => { expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(0); - expect(Navigation.navigate).toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); }); }); }); From bdd2c7d0e5bb8353cc9567270a3cf4b9bdb30045 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 6 Mar 2026 21:38:07 +0530 Subject: [PATCH 05/26] Rename DUPLICATE to DUPLICATE_EXPENSE and add duplicate report UI feedback. Signed-off-by: krishna2323 --- src/CONST/index.ts | 2 +- src/components/MoneyReportHeader.tsx | 17 +++++++++++++---- src/components/MoneyRequestHeader.tsx | 4 ++-- src/libs/ReportSecondaryActionUtils.ts | 2 +- tests/unit/ReportSecondaryActionUtilsTest.ts | 10 +++++----- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8d6d2aff5ef33..d9458f143f51d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1221,7 +1221,7 @@ const CONST = { EXPORT: 'export', PAY: 'pay', MERGE: 'merge', - DUPLICATE: 'duplicate', + DUPLICATE_EXPENSE: 'duplicateExpense', DUPLICATE_REPORT: 'duplicateReport', }, PRIMARY_ACTIONS: { diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index a323169441126..f69be6cc18a5f 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -385,6 +385,7 @@ function MoneyReportHeader({ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(); + const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [paymentType, setPaymentType] = useState(); const [requestType, setRequestType] = useState(); @@ -1517,11 +1518,11 @@ function MoneyReportHeader({ setupMergeTransactionDataAndNavigate(currentTransaction.transactionID, [currentTransaction], localeCompare); }, }, - [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE]: { + [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE]: { text: isDuplicateActive ? translate('common.duplicateExpense') : translate('common.duplicated'), icon: isDuplicateActive ? expensifyIcons.ExpenseCopy : expensifyIcons.Checkmark, iconFill: isDuplicateActive ? undefined : theme.icon, - value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE, + value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE, onSelected: () => { if (hasCustomUnitOutOfPolicyViolation) { setRateErrorModalVisible(true); @@ -1553,11 +1554,19 @@ function MoneyReportHeader({ activePolicyExpenseChat?.iouReportID === moneyRequestReport?.reportID, }, [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT]: { - text: translate('common.duplicateReport'), - icon: expensifyIcons.ReportCopy, + text: isDuplicateReportActive ? translate('common.duplicateReport') : translate('common.duplicated'), + icon: isDuplicateReportActive ? expensifyIcons.ReportCopy : expensifyIcons.Checkmark, + iconFill: isDuplicateReportActive ? undefined : theme.icon, value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DUPLICATE_REPORT, + shouldCloseModalOnSelect: false, onSelected: () => { + if (!isDuplicateReportActive) { + return; + } + + temporarilyDisableDuplicateReportAction(); + const sourcePolicy = policy; const targetPolicy = sourcePolicy && isPolicyMember(sourcePolicy, currentUserLogin) ? sourcePolicy : defaultExpensePolicy; const targetPolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicy?.id}`] ?? {}; diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index cf1dca3ca4a04..0d7c449ee47d9 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -495,11 +495,11 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre setupMergeTransactionDataAndNavigate(transaction.transactionID, [transaction], localeCompare, [], false, isOnSearch); }, }, - [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE]: { + [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.DUPLICATE]: { text: isDuplicateActive ? translate('common.duplicateExpense') : translate('common.duplicated'), icon: isDuplicateActive ? expensifyIcons.ExpenseCopy : expensifyIcons.Checkmark, iconFill: isDuplicateActive ? undefined : theme.icon, - value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE, + value: CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.DUPLICATE, onSelected: () => { if (hasCustomUnitOutOfPolicyViolation) { showConfirmModal({ diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index fb115669cb11e..721b8b638ad8b 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -945,7 +945,7 @@ function getSecondaryReportActions({ } if (isDuplicateAction(report, reportTransactions)) { - options.push(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE); + options.push(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE); } if (isDuplicateReportAction(report)) { diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 2a03ce4c6df2b..2a8005bd9194f 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -2172,7 +2172,7 @@ describe('getSecondaryAction', () => { policy, reportActions, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(true); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(true); }); it('does not include DUPLICATE option if there are no transactions', async () => { @@ -2202,7 +2202,7 @@ describe('getSecondaryAction', () => { originalTransaction: {} as Transaction, policy, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(false); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); it('does not include DUPLICATE option for expense report with multiple transactions', () => { @@ -2262,7 +2262,7 @@ describe('getSecondaryAction', () => { policy, reportActions, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(false); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); it('does not include DUPLICATE option for card transaction', async () => { @@ -2302,7 +2302,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(false); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); it('does not include DUPLICATE option for expenses from other users', () => { @@ -2347,7 +2347,7 @@ describe('getSecondaryAction', () => { policy, reportActions, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(false); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); }); From 5715b18d41a952d09c6a6ae300bf40328c243d7c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 6 Mar 2026 22:35:13 +0530 Subject: [PATCH 06/26] Return iouReport from createDistanceRequest and submitPerDiemExpense for accurate running totals. Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 21 +++-- src/libs/actions/IOU/index.ts | 6 ++ tests/actions/IOUTest/DuplicateTest.ts | 106 +++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 491e5073eaa5d..e6e8cad1fd0b0 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -8,7 +8,6 @@ import type {MergeDuplicatesParams, ResolveDuplicatesParams} from '@libs/API/par import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; -import {updateIOUOwnerAndTotal} from '@libs/IOUUtils'; import * as NumberUtils from '@libs/NumberUtils'; import Parser from '@libs/Parser'; import {getIOUActionForReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -840,7 +839,10 @@ function duplicateReport({ personalDetails, recentWaypoints, }; - createDistanceRequest(distanceParams); + const distanceResult = createDistanceRequest(distanceParams); + if (distanceResult?.iouReport) { + currentIOUReport = distanceResult.iouReport; + } break; } case CONST.SEARCH.TRANSACTION_TYPE.PER_DIEM: { @@ -854,15 +856,20 @@ function duplicateReport({ hasViolations: false, customUnitPolicyID: targetPolicy?.id, }; - submitPerDiemExpense(perDiemParams); + const perDiemResult = submitPerDiemExpense(perDiemParams); + if (perDiemResult?.iouReport) { + currentIOUReport = perDiemResult.iouReport; + } break; } - default: - requestMoney(params); + default: { + const result = requestMoney(params); + if (result?.iouReport) { + currentIOUReport = result.iouReport; + } break; + } } - - currentIOUReport = updateIOUOwnerAndTotal(currentIOUReport, userAccountID, transactionDetails.amount ?? 0, transactionDetails.currency ?? ''); } playSound(SOUNDS.DONE); diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index c099d58348228..8ef364335aba4 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -6996,6 +6996,8 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf if (activeReportID) { notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam); } + + return {iouReport}; } type PerDiemExpenseInformationForSelfDM = { @@ -8401,6 +8403,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest let parameters: CreateDistanceRequestParams; let onyxData: OnyxData; + let distanceIouReport: OnyxInputValue = null; const sanitizedWaypoints = !isManualDistanceRequest ? sanitizeRecentWaypoints(validWaypoints) : null; if (iouType === CONST.IOU.TYPE.SPLIT) { const { @@ -8524,6 +8527,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest }); onyxData = moneyRequestOnyxData; + distanceIouReport = iouReport; const isGPSDistanceRequest = transaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS; @@ -8604,6 +8608,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest if (!isMoneyRequestReport) { notifyNewAction(activeReportID, undefined, true); } + + return {iouReport: distanceIouReport}; } type UpdateMoneyRequestAmountAndCurrencyParams = { diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 8f314dc23a833..aa0a7ae0be424 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1978,5 +1978,111 @@ describe('actions/Duplicate', () => { expect(Navigation.navigate).not.toHaveBeenCalled(); }); + + it('should set duplicated transaction dates to today', async () => { + const oldDate = '2023-06-15'; + const tx = createCashTransaction('tx1', {created: oldDate}); + + duplicateReport(getDefaultParams([tx])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + + const today = new Date().toISOString().slice(0, 10); + expect(requestMoneyCall?.at(1)).toEqual(expect.objectContaining({created: today})); + }); + + it('should clear receipt data from duplicated transactions', async () => { + const txWithReceipt = createCashTransaction('tx1', { + receipt: {source: 'https://example.com/receipt.jpg', state: CONST.IOU.RECEIPT_STATE.OPEN}, + }); + + duplicateReport(getDefaultParams([txWithReceipt])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + expect(requestMoneyCall?.at(1)).toEqual(expect.objectContaining({receipt: undefined})); + }); + + it('should use modifiedMerchant when available', async () => { + const tx = createCashTransaction('tx1', { + merchant: 'Original Merchant', + modifiedMerchant: 'Modified Merchant', + }); + + duplicateReport(getDefaultParams([tx])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + expect(requestMoneyCall?.at(1)).toEqual(expect.objectContaining({merchant: 'Modified Merchant'})); + }); + + it('should pass the same reportPreviewReportActionID to all expense calls', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2'); + const tx3 = createCashTransaction('tx3'); + + duplicateReport(getDefaultParams([tx1, tx2, tx3])); + await waitForBatchedUpdates(); + + const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record][]; + expect(requestMoneyCalls).toHaveLength(3); + + const firstPreviewID = requestMoneyCalls.at(0)?.at(1)?.reportPreviewReportActionID; + expect(firstPreviewID).toBeDefined(); + for (const call of requestMoneyCalls) { + expect(call.at(1)?.reportPreviewReportActionID).toBe(firstPreviewID); + } + }); + + it('should target the same chat report for all expense calls', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2'); + + duplicateReport(getDefaultParams([tx1, tx2])); + await waitForBatchedUpdates(); + + const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record][]; + expect(requestMoneyCalls).toHaveLength(2); + + const firstChatReportID = requestMoneyCalls.at(0)?.at(1)?.chatReportID; + const secondChatReportID = requestMoneyCalls.at(1)?.at(1)?.chatReportID; + expect(firstChatReportID).toBeDefined(); + expect(firstChatReportID).toBe(secondChatReportID); + }); + + it('should filter out partial (incomplete) transactions', async () => { + const normalTx = createCashTransaction('normal1'); + const partialTx = createCashTransaction('partial1', { + amount: 0, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + }); + + duplicateReport(getDefaultParams([normalTx, partialTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + }); + + it('should handle mixed eligible and ineligible transactions correctly', async () => { + const cashTx = createCashTransaction('cash1'); + const cardTx = createCashTransaction('card1', {transactionType: CONST.SEARCH.TRANSACTION_TYPE.CARD}); + const accountantTx = createCashTransaction('acct1', {accountant: {accountID: 999, login: 'a@test.com'}}); + const cashTx2 = createCashTransaction('cash2'); + const scanningTx = createCashTransaction('scan1', { + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + receipt: {source: 'r.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, + }); + + duplicateReport(getDefaultParams([cashTx, cardTx, accountantTx, cashTx2, scanningTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); + }); }); }); From 67c0c1d32083bf0e7eb209e0dfdacb18f06172cd Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sat, 7 Mar 2026 02:49:25 +0530 Subject: [PATCH 07/26] address codex review. Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index e6e8cad1fd0b0..58ae540816895 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -767,6 +767,9 @@ function duplicateReport({ const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; + // Strip waypoints for split distance expenses to preserve the split's amount and distance. + const waypoints = !isExpenseSplit(transaction) ? (transactionDetails.waypoints as WaypointCollection | undefined) : undefined; + const params: RequestMoneyInformation = { report: parentChatReport, existingIOUReport: currentIOUReport, @@ -791,7 +794,7 @@ function duplicateReport({ originalTransactionID: undefined, receipt: undefined, source: undefined, - waypoints: transactionDetails.waypoints as WaypointCollection | undefined, + waypoints, type: transaction.comment?.type, count: transaction.comment?.units?.count, rate: transaction.comment?.units?.rate, @@ -813,6 +816,10 @@ function duplicateReport({ personalDetails, }; + if (isExpenseSplit(transaction) && isDistanceRequest(transaction)) { + params.transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; + } + const transactionType = getTransactionType(transaction); switch (transactionType) { @@ -831,7 +838,7 @@ function duplicateReport({ transactionParams: { ...(params.transactionParams ?? {}), comment: Parser.htmlToMarkdown(transactionDetails.comment ?? ''), - validWaypoints: transactionDetails.waypoints as WaypointCollection | undefined, + validWaypoints: waypoints, }, policyRecentlyUsedCurrencies, quickAction, From 596552baabbb2ac929dff218ce1ff8ac610850fe Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sat, 7 Mar 2026 02:51:09 +0530 Subject: [PATCH 08/26] add more tests. Signed-off-by: krishna2323 --- tests/actions/IOUTest/DuplicateTest.ts | 111 +++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index aa0a7ae0be424..b70f52e6edaf7 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -2084,5 +2084,116 @@ describe('actions/Duplicate', () => { expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); }); + + it('should strip waypoints and use stored distance for split distance expenses', async () => { + const splitDistanceTx = createCashTransaction('splitDist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + originalTransactionID: 'origTx1', + source: 'split', + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 42, + }, + }, + }); + + duplicateReport(getDefaultParams([splitDistanceTx])); + await waitForBatchedUpdates(); + + const distanceCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_DISTANCE_REQUEST) as [string, Record] | undefined; + expect(distanceCall).toBeDefined(); + expect(distanceCall?.at(1)).toEqual(expect.objectContaining({waypoints: 'null'})); + }); + + it('should preserve waypoints for non-split distance expenses', async () => { + const distanceTx = createCashTransaction('dist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + }, + waypoints: { + waypoint0: {lat: 37.7749, lng: -122.4194, address: 'San Francisco'}, + waypoint1: {lat: 34.0522, lng: -118.2437, address: 'Los Angeles'}, + }, + }, + }); + + duplicateReport(getDefaultParams([distanceTx])); + await waitForBatchedUpdates(); + + const distanceCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_DISTANCE_REQUEST) as [string, Record] | undefined; + expect(distanceCall).toBeDefined(); + + const waypoints = distanceCall?.at(1)?.waypoints; + expect(waypoints).toBeDefined(); + expect(waypoints).not.toBe('null'); + }); + + it('should correctly route a report with mixed cash, distance, and per diem transactions', async () => { + const cashTx = createCashTransaction('cash1'); + const distanceTx = createCashTransaction('dist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + }, + }, + }); + const perDiemTx = createCashTransaction('pd1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: 'unit1', + customUnitRateID: 'rate1', + subRates: [{id: 'sub1', quantity: 1, name: 'Full Day', rate: 100}], + attributes: {dates: {start: '2024-01-01', end: '2024-01-02'}}, + }, + }, + }); + const cashTx2 = createCashTransaction('cash2'); + + duplicateReport(getDefaultParams([cashTx, distanceTx, perDiemTx, cashTx2])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(1); + }); + + it('should preserve transaction fields like category, tag, and currency', async () => { + const tx = createCashTransaction('tx1', { + category: 'Travel', + tag: 'Business', + currency: 'EUR', + amount: -1500, + billable: true, + }); + + duplicateReport(getDefaultParams([tx])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + expect(requestMoneyCall?.at(1)).toEqual( + expect.objectContaining({ + category: 'Travel', + tag: 'Business', + currency: 'EUR', + amount: 1500, + billable: true, + }), + ); + }); }); }); From b2a8595bfbb76fc6c877e4c2bdf14649c73f1b2e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 11 Mar 2026 03:54:36 +0530 Subject: [PATCH 09/26] fix tests. Signed-off-by: krishna2323 --- tests/actions/IOUTest/DuplicateTest.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index b70f52e6edaf7..e33b33e89eb66 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -2028,13 +2028,13 @@ describe('actions/Duplicate', () => { duplicateReport(getDefaultParams([tx1, tx2, tx3])); await waitForBatchedUpdates(); - const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record][]; + const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as Array<[string, Record]>; expect(requestMoneyCalls).toHaveLength(3); - const firstPreviewID = requestMoneyCalls.at(0)?.at(1)?.reportPreviewReportActionID; + const firstPreviewID = requestMoneyCalls.at(0)?.[1]?.reportPreviewReportActionID; expect(firstPreviewID).toBeDefined(); for (const call of requestMoneyCalls) { - expect(call.at(1)?.reportPreviewReportActionID).toBe(firstPreviewID); + expect(call[1].reportPreviewReportActionID).toBe(firstPreviewID); } }); @@ -2045,11 +2045,11 @@ describe('actions/Duplicate', () => { duplicateReport(getDefaultParams([tx1, tx2])); await waitForBatchedUpdates(); - const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record][]; + const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as Array<[string, Record]>; expect(requestMoneyCalls).toHaveLength(2); - const firstChatReportID = requestMoneyCalls.at(0)?.at(1)?.chatReportID; - const secondChatReportID = requestMoneyCalls.at(1)?.at(1)?.chatReportID; + const firstChatReportID = requestMoneyCalls.at(0)?.[1]?.chatReportID; + const secondChatReportID = requestMoneyCalls.at(1)?.[1]?.chatReportID; expect(firstChatReportID).toBeDefined(); expect(firstChatReportID).toBe(secondChatReportID); }); @@ -2130,7 +2130,7 @@ describe('actions/Duplicate', () => { const distanceCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_DISTANCE_REQUEST) as [string, Record] | undefined; expect(distanceCall).toBeDefined(); - const waypoints = distanceCall?.at(1)?.waypoints; + const waypoints = distanceCall?.[1]?.waypoints; expect(waypoints).toBeDefined(); expect(waypoints).not.toBe('null'); }); From 271364723a44b9ac1fd7dd7b677fc075216bd751 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 12 Mar 2026 13:33:07 +0530 Subject: [PATCH 10/26] fix split distance transaction case. Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 25a24fa6adc24..06edc9b1cebeb 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -832,7 +832,11 @@ function duplicateReport({ participants, existingTransaction: { ...(params.transactionParams ?? {}), - comment: transaction.comment, + comment: { + ...transaction.comment, + originalTransactionID: undefined, + source: undefined, + }, iouRequestType: getRequestType(transaction), modifiedCreated: '', reportID: '1', From 213517b0a2af99c2d15c939682964465c35b5832 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 12 Mar 2026 13:35:21 +0530 Subject: [PATCH 11/26] minor fix. Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 06edc9b1cebeb..12c5d9abde72e 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -792,6 +792,7 @@ function duplicateReport({ comment: Parser.htmlToMarkdown(transactionDetails.comment ?? ''), created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), customUnitRateID: transaction.comment?.customUnit?.customUnitRateID, + isTestDrive: transaction.receipt?.isTestDriveReceipt, merchant: transaction.modifiedMerchant ? transaction.modifiedMerchant : (transaction.merchant ?? ''), modifiedAmount: undefined, originalTransactionID: undefined, From 80a7aa7ba496cf9747461bc250c5d7dff0d3f574 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 12 Mar 2026 13:41:27 +0530 Subject: [PATCH 12/26] add more tests. Signed-off-by: krishna2323 --- tests/actions/IOUTest/DuplicateTest.ts | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index e33b33e89eb66..4a3b257242689 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -2195,5 +2195,75 @@ describe('actions/Duplicate', () => { }), ); }); + + it('should strip originalTransactionID and source when duplicating split distance expenses', async () => { + const splitDistanceTx = createCashTransaction('splitDist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + originalTransactionID: 'origParent123', + source: 'split', + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 10, + }, + }, + }); + + duplicateReport(getDefaultParams([splitDistanceTx])); + await waitForBatchedUpdates(); + + const distanceCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_DISTANCE_REQUEST) as [string, Record] | undefined; + expect(distanceCall).toBeDefined(); + + const allTransactions = await getOnyxValue(ONYXKEYS.COLLECTION.TRANSACTION); + const duplicatedTransactions = (Object.values(allTransactions ?? {}) as Array).filter( + (tx): tx is Transaction => !!tx && tx.transactionID !== splitDistanceTx.transactionID, + ); + expect(duplicatedTransactions.length).toBeGreaterThan(0); + for (const tx of duplicatedTransactions) { + expect(tx.comment?.originalTransactionID).toBeFalsy(); + expect(tx.comment?.source).not.toBe('split'); + } + }); + + it('should clear modifiedAmount from duplicated transactions', async () => { + const tx = createCashTransaction('tx1', { + modifiedAmount: 999, + }); + + duplicateReport(getDefaultParams([tx])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + expect(requestMoneyCall?.[1]?.modifiedAmount).toBeUndefined(); + }); + + it('should not create any expense calls for an empty transactions array', async () => { + duplicateReport(getDefaultParams([])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(0); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)).toBe(0); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(0); + }); + + it('should pass shouldPlaySound false to individual expense calls', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2'); + + duplicateReport(getDefaultParams([tx1, tx2])); + await waitForBatchedUpdates(); + + const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as Array<[string, Record]>; + expect(requestMoneyCalls).toHaveLength(2); + + for (const call of requestMoneyCalls) { + expect(call[1]).not.toEqual(expect.objectContaining({shouldPlaySound: true})); + } + }); }); }); From 4d21ec24245e34e815681a15855c16aefbbcd3cd Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 12 Mar 2026 14:57:04 +0530 Subject: [PATCH 13/26] fix: pass existingIOUReport through distance and per-diem expense creation paths Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 8 ++++++++ src/libs/actions/IOU/PerDiem.ts | 11 +++++++++-- src/libs/actions/IOU/index.ts | 3 +++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 12c5d9abde72e..b6225b1ed09db 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -560,11 +560,15 @@ function duplicateExpenseTransaction({ transactionParams: { ...transactionWithoutLinkedAction, ...transactionDetails, + actionableWhisperReportActionID: undefined, attendees: transactionDetails?.attendees as Attendee[] | undefined, comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), customUnitRateID: transaction?.comment?.customUnit?.customUnitRateID, + isFromGlobalCreate: undefined, + isLinkedTrackedExpenseReportArchived: undefined, isTestDrive: transaction?.receipt?.isTestDriveReceipt, + linkedTrackedExpenseReportID: undefined, merchant: transaction?.modifiedMerchant ? transaction.modifiedMerchant : (transaction?.merchant ?? ''), modifiedAmount: undefined, originalTransactionID: undefined, @@ -788,11 +792,15 @@ function duplicateReport({ transactionParams: { ...transactionWithoutLinkedAction, ...transactionDetails, + actionableWhisperReportActionID: undefined, attendees: transactionDetails.attendees as Attendee[] | undefined, comment: Parser.htmlToMarkdown(transactionDetails.comment ?? ''), created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), customUnitRateID: transaction.comment?.customUnit?.customUnitRateID, + isFromGlobalCreate: undefined, + isLinkedTrackedExpenseReportArchived: undefined, isTestDrive: transaction.receipt?.isTestDriveReceipt, + linkedTrackedExpenseReportID: undefined, merchant: transaction.modifiedMerchant ? transaction.modifiedMerchant : (transaction.merchant ?? ''), modifiedAmount: undefined, originalTransactionID: undefined, diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index e58a8c5766351..3eb3d4983d251 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -235,6 +235,7 @@ type PerDiemExpenseInformation = { policyParams?: BasePolicyParams; recentlyUsedParams?: RecentlyUsedParams; transactionParams: PerDiemExpenseTransactionParams; + existingIOUReport?: OnyxEntry; isASAPSubmitBetaEnabled: boolean; currentUserAccountIDParam: number; currentUserEmailParam: string; @@ -255,6 +256,7 @@ type PerDiemExpenseInformationParams = { participantParams: RequestMoneyParticipantParams; policyParams?: BasePolicyParams; recentlyUsedParams?: RecentlyUsedParams; + existingIOUReport?: OnyxEntry; moneyRequestReportID?: string; isASAPSubmitBetaEnabled: boolean; currentUserAccountIDParam: number; @@ -302,6 +304,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI participantParams, policyParams = {}, recentlyUsedParams = {}, + existingIOUReport: existingIOUReportParam, moneyRequestReportID = '', isASAPSubmitBetaEnabled, currentUserAccountIDParam, @@ -352,10 +355,12 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI }); } - // STEP 2: Get the Expense/IOU report. If the moneyRequestReportID has been provided, we want to add the transaction to this specific report. + // STEP 2: Get the Expense/IOU report. If the existingIOUReport or moneyRequestReportID has been provided, we want to add the transaction to this specific report. // If no such reportID has been provided, let's use the chatReport.iouReportID property. In case that is not present, build a new optimistic Expense/IOU report. let iouReport: OnyxInputValue = null; - if (moneyRequestReportID) { + if (existingIOUReportParam) { + iouReport = existingIOUReportParam; + } else if (moneyRequestReportID) { iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`] ?? null; } else { iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; @@ -885,6 +890,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf policyParams = {}, recentlyUsedParams = {}, transactionParams, + existingIOUReport, isASAPSubmitBetaEnabled, currentUserAccountIDParam, currentUserEmailParam, @@ -936,6 +942,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf policyParams, recentlyUsedParams, transactionParams, + existingIOUReport, moneyRequestReportID, isASAPSubmitBetaEnabled, currentUserAccountIDParam, diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 219bce09644c4..12259b43bc09c 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -631,6 +631,7 @@ type CreateDistanceRequestInformation = { currentUserLogin?: string; currentUserAccountID?: number; iouType?: ValueOf; + existingIOUReport?: OnyxEntry; existingTransaction?: OnyxEntry; transactionParams: DistanceRequestTransactionParams; policyParams?: BasePolicyParams; @@ -7557,6 +7558,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest currentUserLogin = '', currentUserAccountID = -1, iouType = CONST.IOU.TYPE.SUBMIT, + existingIOUReport, existingTransaction, transactionParams, policyParams = {}, @@ -7695,6 +7697,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest onyxData: moneyRequestOnyxData, } = getMoneyRequestInformation({ parentChatReport: currentChatReport, + existingIOUReport, existingTransaction, moneyRequestReportID, participantParams: { From f6a99b2d7e42a364bd4b25ad4360d944268fa23c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 15 Mar 2026 17:54:52 +0530 Subject: [PATCH 14/26] fix prettier. Signed-off-by: krishna2323 --- src/components/MoneyReportHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index a4a60e345e4b9..2014a19f7e8c3 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -66,7 +66,7 @@ import { } from '@libs/NextStepUtils'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; -import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow, sortPoliciesByName, isPolicyMember} from '@libs/PolicyUtils'; +import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow, isPolicyMember, sortPoliciesByName} from '@libs/PolicyUtils'; import { getIOUActionForReportID, getOriginalMessage, From efc1b0d2377d4f8b49fa61fa45551eb78c866a87 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 15 Mar 2026 18:14:41 +0530 Subject: [PATCH 15/26] refactor: extract shared helpers from duplicateExpenseTransaction and duplicateReport. Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 325 ++++++++++++++---------------- 1 file changed, 149 insertions(+), 176 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index b6225b1ed09db..f80f94a8533ef 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -25,7 +25,7 @@ import {createNewReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import type {Attendee} from '@src/types/onyx/IOU'; +import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type {CreateDistanceRequestInformation, CreateTrackExpenseParams, RequestMoneyInformation} from '.'; @@ -482,6 +482,122 @@ function resolveDuplicates(params: MergeDuplicatesParams) { API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData}); } +/** + * Builds the transactionParams object and computes waypoints used when duplicating a transaction. + * Shared between duplicateExpenseTransaction and duplicateReport. + */ +function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType) { + const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; + const waypoints = !isExpenseSplit(transaction) ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; + + const transactionParams = { + ...transactionWithoutLinkedAction, + ...transactionDetails, + actionableWhisperReportActionID: undefined, + attendees: transactionDetails?.attendees as Attendee[] | undefined, + comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + customUnitRateID: transaction.comment?.customUnit?.customUnitRateID, + isFromGlobalCreate: undefined, + isLinkedTrackedExpenseReportArchived: undefined, + isTestDrive: transaction.receipt?.isTestDriveReceipt, + linkedTrackedExpenseReportID: undefined, + merchant: transaction.modifiedMerchant ? transaction.modifiedMerchant : (transaction.merchant ?? ''), + modifiedAmount: undefined, + originalTransactionID: undefined, + receipt: undefined, + source: undefined, + waypoints, + type: transaction.comment?.type, + count: transaction.comment?.units?.count, + rate: transaction.comment?.units?.rate, + unit: transaction.comment?.units?.unit, + }; + + if (isExpenseSplit(transaction) && isDistanceRequest(transaction)) { + transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; + } + + return {transactionParams, waypoints}; +} + +/** + * Routes a duplicate expense to the correct creation function based on transaction type. + * Shared between duplicateExpenseTransaction and duplicateReport. + */ +function createExpenseByType({ + transactionType, + params, + transaction, + transactionDetails, + waypoints, + participants, + policyRecentlyUsedCurrencies, + quickAction, + customUnitPolicyID, + personalDetails, + recentWaypoints, +}: { + transactionType: string; + params: RequestMoneyInformation; + transaction: OnyxTypes.Transaction; + transactionDetails: ReturnType; + waypoints: WaypointCollection | undefined; + participants: Participant[]; + policyRecentlyUsedCurrencies: string[]; + quickAction: OnyxEntry; + customUnitPolicyID?: string; + personalDetails: OnyxEntry; + recentWaypoints: OnyxEntry; +}) { + switch (transactionType) { + case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: { + const distanceParams: CreateDistanceRequestInformation = { + ...params, + participants, + existingTransaction: { + ...(params.transactionParams ?? {}), + comment: { + ...transaction.comment, + originalTransactionID: undefined, + source: undefined, + }, + iouRequestType: getRequestType(transaction), + modifiedCreated: '', + reportID: '1', + transactionID: '1', + }, + transactionParams: { + ...(params.transactionParams ?? {}), + comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), + validWaypoints: waypoints, + }, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + quickAction, + customUnitPolicyID, + personalDetails, + recentWaypoints, + }; + return createDistanceRequest(distanceParams); + } + case CONST.SEARCH.TRANSACTION_TYPE.PER_DIEM: { + const perDiemParams: PerDiemExpenseInformation = { + ...params, + transactionParams: { + ...(params.transactionParams ?? {}), + comment: transactionDetails?.comment ?? '', + customUnit: transaction.comment?.customUnit ?? {}, + }, + hasViolations: false, + customUnitPolicyID, + }; + return submitPerDiemExpense(perDiemParams); + } + default: + return requestMoney(params); + } +} + type DuplicateExpenseTransactionParams = { transaction: OnyxEntry; optimisticChatReportID: string; @@ -534,15 +650,7 @@ function duplicateExpenseTransaction({ const participants = getMoneyRequestParticipantsFromReport(targetReport, userAccountID); const transactionDetails = getTransactionDetails(transaction); - - // Exclude linkedTrackedExpenseReportAction from the original transaction to avoid reportID collisions - // when duplicating split expenses that were removed from a report. linkedTrackedExpenseReportAction.childReportID - // gets used as existingTransactionThreadReportID in getMoneyRequestInformation, which would cause the backend - // to try to create a transaction thread report with an ID that already exists. - const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; - - // We remove waypoints for split distance expenses in order to preserve the split's amount and distance. - const waypoints = !isExpenseSplit(transaction) ? (transactionDetails?.waypoints as WaypointCollection) : undefined; + const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails); const params: RequestMoneyInformation = { report: targetReport, @@ -557,29 +665,7 @@ function duplicateExpenseTransaction({ }, gpsPoint: undefined, action: CONST.IOU.ACTION.CREATE, - transactionParams: { - ...transactionWithoutLinkedAction, - ...transactionDetails, - actionableWhisperReportActionID: undefined, - attendees: transactionDetails?.attendees as Attendee[] | undefined, - comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - customUnitRateID: transaction?.comment?.customUnit?.customUnitRateID, - isFromGlobalCreate: undefined, - isLinkedTrackedExpenseReportArchived: undefined, - isTestDrive: transaction?.receipt?.isTestDriveReceipt, - linkedTrackedExpenseReportID: undefined, - merchant: transaction?.modifiedMerchant ? transaction.modifiedMerchant : (transaction?.merchant ?? ''), - modifiedAmount: undefined, - originalTransactionID: undefined, - receipt: undefined, - source: undefined, - waypoints, - type: transaction?.comment?.type, - count: transaction?.comment?.units?.count, - rate: transaction?.comment?.units?.rate, - unit: transaction?.comment?.units?.unit, - }, + transactionParams, shouldHandleNavigation: false, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled, @@ -595,11 +681,6 @@ function duplicateExpenseTransaction({ personalDetails, }; - // Since we remove waypoints for split distance expenses, we need to re-add the distance param here - if (isExpenseSplit(transaction) && isDistanceRequest(transaction)) { - params.transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; - } - // If no workspace is provided the expense should be unreported if (!targetPolicy) { const trackExpenseParams: CreateTrackExpenseParams = { @@ -643,54 +724,19 @@ function duplicateExpenseTransaction({ policyCategories: targetPolicyCategories ?? {}, }; - const transactionType = getTransactionType(transaction); - - switch (transactionType) { - case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: { - const distanceParams: CreateDistanceRequestInformation = { - ...params, - participants, - existingTransaction: { - ...(params.transactionParams ?? {}), - comment: { - ...transaction.comment, - originalTransactionID: undefined, - source: undefined, - }, - iouRequestType: getRequestType(transaction), - modifiedCreated: '', - reportID: '1', - transactionID: '1', - }, - transactionParams: { - ...(params.transactionParams ?? {}), - comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), - validWaypoints: waypoints, - }, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - quickAction, - customUnitPolicyID, - personalDetails, - recentWaypoints, - }; - return createDistanceRequest(distanceParams); - } - case CONST.SEARCH.TRANSACTION_TYPE.PER_DIEM: { - const perDiemParams: PerDiemExpenseInformation = { - ...params, - transactionParams: { - ...(params.transactionParams ?? {}), - comment: transactionDetails?.comment ?? '', - customUnit: transaction?.comment?.customUnit ?? {}, - }, - hasViolations: false, - customUnitPolicyID, - }; - return submitPerDiemExpense(perDiemParams); - } - default: - return requestMoney(params); - } + return createExpenseByType({ + transactionType: getTransactionType(transaction), + params, + transaction, + transactionDetails, + waypoints, + participants, + policyRecentlyUsedCurrencies, + quickAction, + customUnitPolicyID, + personalDetails, + recentWaypoints, + }); } type DuplicateReportParams = { @@ -772,10 +818,7 @@ function duplicateReport({ continue; } - const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; - - // Strip waypoints for split distance expenses to preserve the split's amount and distance. - const waypoints = !isExpenseSplit(transaction) ? (transactionDetails.waypoints as WaypointCollection | undefined) : undefined; + const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails); const params: RequestMoneyInformation = { report: parentChatReport, @@ -789,29 +832,7 @@ function duplicateReport({ policyParams, gpsPoint: undefined, action: CONST.IOU.ACTION.CREATE, - transactionParams: { - ...transactionWithoutLinkedAction, - ...transactionDetails, - actionableWhisperReportActionID: undefined, - attendees: transactionDetails.attendees as Attendee[] | undefined, - comment: Parser.htmlToMarkdown(transactionDetails.comment ?? ''), - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - customUnitRateID: transaction.comment?.customUnit?.customUnitRateID, - isFromGlobalCreate: undefined, - isLinkedTrackedExpenseReportArchived: undefined, - isTestDrive: transaction.receipt?.isTestDriveReceipt, - linkedTrackedExpenseReportID: undefined, - merchant: transaction.modifiedMerchant ? transaction.modifiedMerchant : (transaction.merchant ?? ''), - modifiedAmount: undefined, - originalTransactionID: undefined, - receipt: undefined, - source: undefined, - waypoints, - type: transaction.comment?.type, - count: transaction.comment?.units?.count, - rate: transaction.comment?.units?.rate, - unit: transaction.comment?.units?.unit, - }, + transactionParams, shouldHandleNavigation: false, shouldPlaySound: false, shouldGenerateTransactionThreadReport: true, @@ -828,70 +849,22 @@ function duplicateReport({ personalDetails, }; - if (isExpenseSplit(transaction) && isDistanceRequest(transaction)) { - params.transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; - } + const result = createExpenseByType({ + transactionType: getTransactionType(transaction), + params, + transaction, + transactionDetails, + waypoints, + participants, + policyRecentlyUsedCurrencies, + quickAction, + customUnitPolicyID: targetPolicy?.id, + personalDetails, + recentWaypoints, + }); - const transactionType = getTransactionType(transaction); - - switch (transactionType) { - case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: { - const distanceParams: CreateDistanceRequestInformation = { - ...params, - participants, - existingTransaction: { - ...(params.transactionParams ?? {}), - comment: { - ...transaction.comment, - originalTransactionID: undefined, - source: undefined, - }, - iouRequestType: getRequestType(transaction), - modifiedCreated: '', - reportID: '1', - transactionID: '1', - }, - transactionParams: { - ...(params.transactionParams ?? {}), - comment: Parser.htmlToMarkdown(transactionDetails.comment ?? ''), - validWaypoints: waypoints, - }, - policyRecentlyUsedCurrencies, - quickAction, - customUnitPolicyID: targetPolicy?.id, - personalDetails, - recentWaypoints, - }; - const distanceResult = createDistanceRequest(distanceParams); - if (distanceResult?.iouReport) { - currentIOUReport = distanceResult.iouReport; - } - break; - } - case CONST.SEARCH.TRANSACTION_TYPE.PER_DIEM: { - const perDiemParams: PerDiemExpenseInformation = { - ...params, - transactionParams: { - ...(params.transactionParams ?? {}), - comment: transactionDetails.comment ?? '', - customUnit: transaction?.comment?.customUnit ?? {}, - }, - hasViolations: false, - customUnitPolicyID: targetPolicy?.id, - }; - const perDiemResult = submitPerDiemExpense(perDiemParams); - if (perDiemResult?.iouReport) { - currentIOUReport = perDiemResult.iouReport; - } - break; - } - default: { - const result = requestMoney(params); - if (result?.iouReport) { - currentIOUReport = result.iouReport; - } - break; - } + if (result?.iouReport) { + currentIOUReport = result.iouReport; } } From 649b74b100460694184fd9db7318052626a73ac9 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 15 Mar 2026 20:26:48 +0530 Subject: [PATCH 16/26] fix: ensure positive amounts and clear stale currency fields when duplicating transactions. Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index f80f94a8533ef..728b79866c9cd 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -493,6 +493,10 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra const transactionParams = { ...transactionWithoutLinkedAction, ...transactionDetails, + amount: Math.abs(transactionDetails?.amount ?? 0), + taxAmount: Math.abs(transactionDetails?.taxAmount ?? 0), + convertedAmount: undefined, + originalAmount: undefined, actionableWhisperReportActionID: undefined, attendees: transactionDetails?.attendees as Attendee[] | undefined, comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), From 88923cdfc420b605792fdf61e9e4531e99bc7e25 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 15 Mar 2026 23:16:58 +0530 Subject: [PATCH 17/26] fix: add eligibility guards and hide duplicate report when no target workspace Signed-off-by: krishna2323 --- src/components/MoneyReportHeader.tsx | 1 + src/libs/actions/IOU/Duplicate.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 2014a19f7e8c3..39ee58d43adef 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1845,6 +1845,7 @@ function MoneyReportHeader({ iconFill: isDuplicateReportActive ? undefined : theme.icon, value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DUPLICATE_REPORT, + shouldShow: !!(policy && isPolicyMember(policy, currentUserLogin)) || !!defaultExpensePolicy, shouldCloseModalOnSelect: false, onSelected: () => { if (!isDuplicateReportActive) { diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 728b79866c9cd..9339441585ace 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -20,7 +20,17 @@ import { getTransactionDetails, } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; -import {getRequestType, getTransactionType, isDistanceRequest, isExpenseSplit, isFromCreditCardImport, isPartialTransaction, isScanning} from '@libs/TransactionUtils'; +import { + getRequestType, + getTransactionType, + hasCustomUnitOutOfPolicyViolation, + isDistanceRequest, + isExpenseSplit, + isFromCreditCardImport, + isPartialTransaction, + isPerDiemRequest, + isScanning, +} from '@libs/TransactionUtils'; import {createNewReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -780,9 +790,17 @@ function duplicateReport({ translate, recentWaypoints, }: DuplicateReportParams) { + if (!targetPolicy) { + return; + } + const newReportName = translate('common.copyOfReportName', sourceReportName); const {reportPreviewReportActionID, ...newReport} = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas, false, undefined, newReportName); + const sourceReportID = sourceReportTransactions.at(0)?.reportID; + const sourceReport = sourceReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${sourceReportID}`] : undefined; + const isCrossWorkspace = sourceReport?.policyID !== targetPolicy?.id; + const eligibleTransactions = sourceReportTransactions.filter((transaction) => { if (isFromCreditCardImport(transaction)) { return false; @@ -793,6 +811,13 @@ function duplicateReport({ if (isPartialTransaction(transaction) || isScanning(transaction)) { return false; } + const txnViolations = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; + if (hasCustomUnitOutOfPolicyViolation(txnViolations)) { + return false; + } + if (isCrossWorkspace && (isPerDiemRequest(transaction) || isDistanceRequest(transaction))) { + return false; + } return true; }); From 98a482f1f44b56f9fa015feaca26013f7906f9c1 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 15 Mar 2026 23:42:36 +0530 Subject: [PATCH 18/26] fix: defer duplicate report action to prevent popover menu from closing prematurely Signed-off-by: krishna2323 --- src/components/MoneyReportHeader.tsx | 37 +++++++++++++++------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 39ee58d43adef..ffc8ee51f7f8c 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1858,23 +1858,26 @@ function MoneyReportHeader({ const targetPolicy = sourcePolicy && isPolicyMember(sourcePolicy, currentUserLogin) ? sourcePolicy : defaultExpensePolicy; const targetPolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicy?.id}`] ?? {}; - duplicateReportAction({ - sourceReportTransactions: nonPendingDeleteTransactions, - sourceReportName: moneyRequestReport?.reportName ?? '', - targetPolicy: targetPolicy ?? undefined, - targetPolicyCategories, - targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicy?.id}`] ?? {}, - ownerPersonalDetails: currentUserPersonalDetails, - isASAPSubmitBetaEnabled, - betas, - personalDetails, - quickAction, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - draftTransactionIDs, - isSelfTourViewed, - transactionViolations: allTransactionViolations, - translate, - recentWaypoints: recentWaypoints ?? [], + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + duplicateReportAction({ + sourceReportTransactions: nonPendingDeleteTransactions, + sourceReportName: moneyRequestReport?.reportName ?? '', + targetPolicy: targetPolicy ?? undefined, + targetPolicyCategories, + targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicy?.id}`] ?? {}, + ownerPersonalDetails: currentUserPersonalDetails, + isASAPSubmitBetaEnabled, + betas, + personalDetails, + quickAction, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + draftTransactionIDs, + isSelfTourViewed, + transactionViolations: allTransactionViolations, + translate, + recentWaypoints: recentWaypoints ?? [], + }); }); }, }, From a240a75227368ddb915e992c2f9f206a4b04bda5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 15 Mar 2026 23:48:03 +0530 Subject: [PATCH 19/26] fix: correct cross-workspace detection and update test for missing target policy. Signed-off-by: krishna2323 --- src/libs/actions/IOU/Duplicate.ts | 2 +- tests/actions/IOUTest/DuplicateTest.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 9339441585ace..034a3f3153c84 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -799,7 +799,7 @@ function duplicateReport({ const sourceReportID = sourceReportTransactions.at(0)?.reportID; const sourceReport = sourceReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${sourceReportID}`] : undefined; - const isCrossWorkspace = sourceReport?.policyID !== targetPolicy?.id; + const isCrossWorkspace = !!sourceReport && sourceReport.policyID !== targetPolicy.id; const eligibleTransactions = sourceReportTransactions.filter((transaction) => { if (isFromCreditCardImport(transaction)) { diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 4a3b257242689..8b8fcad2c623c 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1949,14 +1949,14 @@ describe('actions/Duplicate', () => { expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(1); }); - it('should not duplicate expenses when no parent chat report exists', async () => { + it('should not duplicate expenses when no target policy exists', async () => { const tx1 = createCashTransaction('tx1'); const tx2 = createCashTransaction('tx2'); duplicateReport(getDefaultParams([tx1, tx2], {targetPolicy: undefined})); await waitForBatchedUpdates(); - expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(0); expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(0); expect(Navigation.navigate).not.toHaveBeenCalled(); From 1a1a67a59f0c4462b0fcf3e1783a7226341f07fe Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 16 Mar 2026 09:19:00 +0530 Subject: [PATCH 20/26] fix: pass parentChatReport from UI and align report duplication with single duplication targeting. Signed-off-by: krishna2323 --- src/components/MoneyReportHeader.tsx | 15 +++++++-------- src/libs/actions/IOU/Duplicate.ts | 15 ++++++--------- tests/actions/IOUTest/DuplicateTest.ts | 11 +++++++++-- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ffc8ee51f7f8c..b425f3c86dbf2 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -66,7 +66,7 @@ import { } from '@libs/NextStepUtils'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {handleUnvalidatedAccount, selectPaymentType} from '@libs/PaymentUtils'; -import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow, isPolicyMember, sortPoliciesByName} from '@libs/PolicyUtils'; +import {getConnectedIntegration, getValidConnectedIntegration, hasDynamicExternalWorkflow, sortPoliciesByName} from '@libs/PolicyUtils'; import { getIOUActionForReportID, getOriginalMessage, @@ -1845,7 +1845,7 @@ function MoneyReportHeader({ iconFill: isDuplicateReportActive ? undefined : theme.icon, value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DUPLICATE_REPORT, - shouldShow: !!(policy && isPolicyMember(policy, currentUserLogin)) || !!defaultExpensePolicy, + shouldShow: !!defaultExpensePolicy, shouldCloseModalOnSelect: false, onSelected: () => { if (!isDuplicateReportActive) { @@ -1854,18 +1854,17 @@ function MoneyReportHeader({ temporarilyDisableDuplicateReportAction(); - const sourcePolicy = policy; - const targetPolicy = sourcePolicy && isPolicyMember(sourcePolicy, currentUserLogin) ? sourcePolicy : defaultExpensePolicy; - const targetPolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicy?.id}`] ?? {}; + const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { duplicateReportAction({ sourceReportTransactions: nonPendingDeleteTransactions, sourceReportName: moneyRequestReport?.reportName ?? '', - targetPolicy: targetPolicy ?? undefined, - targetPolicyCategories, - targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicy?.id}`] ?? {}, + targetPolicy: defaultExpensePolicy ?? undefined, + targetPolicyCategories: activePolicyCategories, + targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy?.id}`] ?? {}, + parentChatReport: activePolicyExpenseChat, ownerPersonalDetails: currentUserPersonalDetails, isASAPSubmitBetaEnabled, betas, diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 034a3f3153c84..ef5e4a246a38a 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -759,6 +759,7 @@ type DuplicateReportParams = { targetPolicy: OnyxEntry; targetPolicyCategories: OnyxEntry; targetPolicyTags: OnyxEntry; + parentChatReport: OnyxEntry; ownerPersonalDetails: CurrentUserPersonalDetails; isASAPSubmitBetaEnabled: boolean; betas: OnyxEntry; @@ -778,6 +779,7 @@ function duplicateReport({ targetPolicy, targetPolicyCategories, targetPolicyTags, + parentChatReport, ownerPersonalDetails, isASAPSubmitBetaEnabled, betas, @@ -790,10 +792,13 @@ function duplicateReport({ translate, recentWaypoints, }: DuplicateReportParams) { - if (!targetPolicy) { + if (!targetPolicy || !parentChatReport) { return; } + const userAccountID = getUserAccountID(); + const currentUserEmailValue = getCurrentUserEmail(); + const newReportName = translate('common.copyOfReportName', sourceReportName); const {reportPreviewReportActionID, ...newReport} = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas, false, undefined, newReportName); @@ -821,14 +826,6 @@ function duplicateReport({ return true; }); - const userAccountID = getUserAccountID(); - const currentUserEmailValue = getCurrentUserEmail(); - const parentChatReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${newReport.chatReportID}`]; - - if (!parentChatReport) { - return; - } - const participants = getMoneyRequestParticipantsFromReport(parentChatReport, userAccountID); const policyParams = targetPolicy diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 8b8fcad2c623c..fba820e51be91 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1789,12 +1789,21 @@ describe('actions/Duplicate', () => { ...overrides, }); + const POLICY_EXPENSE_CHAT_REPORT_ID = 'policyExpenseChatReport'; + const getDefaultParams = (sourceTransactions: Transaction[], overrides: Partial = {}): DuplicateReportParams => ({ sourceReportTransactions: sourceTransactions, sourceReportName: 'Original Report', targetPolicy: mockPolicy, targetPolicyCategories: mockPolicyCategories, targetPolicyTags: {}, + parentChatReport: { + reportID: POLICY_EXPENSE_CHAT_REPORT_ID, + policyID: mockPolicy.id, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + ownerAccountID: RORY_ACCOUNT_ID, + type: CONST.REPORT.TYPE.CHAT, + }, ownerPersonalDetails: mockOwnerPersonalDetails, isASAPSubmitBetaEnabled: false, betas: [CONST.BETAS.ALL], @@ -1811,8 +1820,6 @@ describe('actions/Duplicate', () => { const countWriteCommandCalls = (command: string) => writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === command).length; - const POLICY_EXPENSE_CHAT_REPORT_ID = 'policyExpenseChatReport'; - beforeEach(async () => { jest.clearAllMocks(); global.fetch = getGlobalFetchMock(); From af7e206f753a56738897e2f492f1a635ee5a455d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 16 Mar 2026 23:04:00 +0530 Subject: [PATCH 21/26] update translations. Signed-off-by: krishna2323 --- src/languages/de.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 8 files changed, 8 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index f84f0da1aba12..9f62958fa252a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -556,6 +556,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Hallo, wie kann ich helfen?', showHistory: 'Verlauf anzeigen'}, duplicateReport: 'Duplizierten Bericht', approver: 'Genehmiger', + copyOfReportName: (reportName: string) => `Kopie von ${reportName}`, }, socials: { podcast: 'Folgen Sie uns auf Podcast', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b65c07543413a..fed712435da77 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -556,6 +556,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Bonjour, comment puis-je vous aider ?', showHistory: 'Afficher l’historique'}, duplicateReport: 'Note de frais en double', approver: 'Approbateur', + copyOfReportName: (reportName: string) => `Copie de ${reportName}`, }, socials: { podcast: 'Suivez-nous sur Podcast', diff --git a/src/languages/it.ts b/src/languages/it.ts index babe2339b9f66..f73d57f4039e0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -556,6 +556,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Ciao, come posso aiutarti?', showHistory: 'Mostra cronologia'}, duplicateReport: 'Report duplicato', approver: 'Approvante', + copyOfReportName: (reportName: string) => `Copia di ${reportName}`, }, socials: { podcast: 'Seguici su Podcast', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b3a7c6691b6c9..2e1a5ae455dca 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -555,6 +555,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'こんにちは、どのようにお手伝いできますか?', showHistory: '履歴を表示'}, duplicateReport: 'レポートを複製', approver: '承認者', + copyOfReportName: (reportName: string) => `${reportName} のコピー`, }, socials: { podcast: 'ポッドキャストでフォロー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index be5b70b72ab6f..da4c6d077caf3 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -555,6 +555,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Hoi, waarmee kan ik je helpen?', showHistory: 'Geschiedenis weergeven'}, duplicateReport: 'Dubbel rapport', approver: 'Fiatteur', + copyOfReportName: (reportName: string) => `Kopie van ${reportName}`, }, socials: { podcast: 'Volg ons op Podcast', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 2fb31cbf66bd7..38af4c16af61d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -555,6 +555,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Cześć, w czym mogę pomóc?', showHistory: 'Pokaż historię'}, duplicateReport: 'Zduplikowany raport', approver: 'Osoba zatwierdzająca', + copyOfReportName: (reportName: string) => `Kopia raportu ${reportName}`, }, socials: { podcast: 'Śledź nas na Podcast', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index eaf08826159ef..ec8b79223e7d1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -554,6 +554,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Oi, como posso ajudar?', showHistory: 'Mostrar histórico'}, duplicateReport: 'Duplicar relatório', approver: 'Aprovador', + copyOfReportName: (reportName: string) => `Cópia de ${reportName}`, }, socials: { podcast: 'Siga-nos no Podcast', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b4f23ecbec1c9..02c14ac24fb92 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -551,6 +551,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: '你好,我能帮你做什么?', showHistory: '显示历史'}, duplicateReport: '重复报销单', approver: '审批人', + copyOfReportName: (reportName: string) => `${reportName} 的副本`, }, socials: { podcast: '在播客上关注我们', From f9d54ba8ccb6281ab4f481408cb595628f11ea69 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 18 Mar 2026 00:15:33 +0530 Subject: [PATCH 22/26] Close popover after duplicate report animation completes Signed-off-by: krishna2323 --- src/components/MoneyReportHeader.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 467ed1f582aaf..2e4041c57184c 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -424,6 +424,15 @@ function MoneyReportHeader({ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); const dropdownMenuRef = useRef(null); + const wasDuplicateReportTriggered = useRef(false); + + useEffect(() => { + if (!isDuplicateReportActive || !wasDuplicateReportTriggered.current) { + return; + } + wasDuplicateReportTriggered.current = false; + dropdownMenuRef.current?.setIsMenuVisible(false); + }, [isDuplicateReportActive]); const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [paymentType, setPaymentType] = useState(); @@ -1914,6 +1923,7 @@ function MoneyReportHeader({ } temporarilyDisableDuplicateReportAction(); + wasDuplicateReportTriggered.current = true; const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; From b191499edcc6178cb751ce33b0c253e0e87b4771 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 18 Mar 2026 15:19:40 +0530 Subject: [PATCH 23/26] Fix popover auto-closing on reopen after duplicate report action Signed-off-by: krishna2323 --- src/components/MoneyReportHeader.tsx | 6 ++++++ src/components/MoneyReportHeaderKYCDropdown.tsx | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 2e4041c57184c..2487b90ca4713 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -426,6 +426,10 @@ function MoneyReportHeader({ const dropdownMenuRef = useRef(null); const wasDuplicateReportTriggered = useRef(false); + const handleOptionsMenuHide = useCallback(() => { + wasDuplicateReportTriggered.current = false; + }, []); + useEffect(() => { if (!isDuplicateReportActive || !wasDuplicateReportTriggered.current) { return; @@ -2458,6 +2462,7 @@ function MoneyReportHeader({ primaryAction={primaryAction} applicableSecondaryActions={applicableSecondaryActions} dropdownMenuRef={dropdownMenuRef} + onOptionsMenuHide={handleOptionsMenuHide} ref={kycWallRef} /> )} @@ -2480,6 +2485,7 @@ function MoneyReportHeader({ primaryAction={primaryAction} applicableSecondaryActions={applicableSecondaryActions} dropdownMenuRef={dropdownMenuRef} + onOptionsMenuHide={handleOptionsMenuHide} ref={kycWallRef} /> )} diff --git a/src/components/MoneyReportHeaderKYCDropdown.tsx b/src/components/MoneyReportHeaderKYCDropdown.tsx index 9d43ea4cff44d..c104bd759351d 100644 --- a/src/components/MoneyReportHeaderKYCDropdown.tsx +++ b/src/components/MoneyReportHeaderKYCDropdown.tsx @@ -27,6 +27,9 @@ type MoneyReportHeaderKYCDropdownProps = Omit; + + /** Callback fired when the dropdown menu hides */ + onOptionsMenuHide?: () => void; }; function MoneyReportHeaderKYCDropdown({ @@ -39,6 +42,7 @@ function MoneyReportHeaderKYCDropdown({ customText, shouldShowSuccessStyle, dropdownMenuRef, + onOptionsMenuHide, ref, ...props }: MoneyReportHeaderKYCDropdownProps) { @@ -84,6 +88,7 @@ function MoneyReportHeaderKYCDropdown({ isSplitButton={false} wrapperStyle={shouldDisplayNarrowVersion && [!primaryAction && !customText && styles.flex1, !!customText && styles.w100]} shouldUseModalPaddingStyle + onOptionsMenuHide={onOptionsMenuHide} sentryLabel={CONST.SENTRY_LABEL.MORE_MENU.MORE_BUTTON} /> )} From 5e049e544ab90bec1202eb4dc4db27a463723784 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 18 Mar 2026 15:31:50 +0530 Subject: [PATCH 24/26] Use source report's workspace for duplicate report instead of default Signed-off-by: krishna2323 --- src/components/MoneyReportHeader.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 2487b90ca4713..d4c3a08b8e3e7 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1929,17 +1929,19 @@ function MoneyReportHeader({ temporarilyDisableDuplicateReportAction(); wasDuplicateReportTriggered.current = true; - const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`] ?? {}; + const targetPolicyForDuplicate = policy ?? defaultExpensePolicy; + const targetChatForDuplicate = policy ? chatReport : activePolicyExpenseChat; + const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicyForDuplicate?.id}`] ?? {}; // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { duplicateReportAction({ sourceReportTransactions: nonPendingDeleteTransactions, sourceReportName: moneyRequestReport?.reportName ?? '', - targetPolicy: defaultExpensePolicy ?? undefined, + targetPolicy: targetPolicyForDuplicate ?? undefined, targetPolicyCategories: activePolicyCategories, - targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy?.id}`] ?? {}, - parentChatReport: activePolicyExpenseChat, + targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyForDuplicate?.id}`] ?? {}, + parentChatReport: targetChatForDuplicate, ownerPersonalDetails: currentUserPersonalDetails, isASAPSubmitBetaEnabled, betas, From 9611b6b2c0c5ca31a0c3e774d2da97ce68268702 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 18 Mar 2026 15:36:27 +0530 Subject: [PATCH 25/26] pass sourceReport from UI Signed-off-by: krishna2323 --- src/components/MoneyReportHeader.tsx | 1 + src/libs/actions/IOU/Duplicate.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index d4c3a08b8e3e7..226f090c1b7f2 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1936,6 +1936,7 @@ function MoneyReportHeader({ // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { duplicateReportAction({ + sourceReport: moneyRequestReport, sourceReportTransactions: nonPendingDeleteTransactions, sourceReportName: moneyRequestReport?.reportName ?? '', targetPolicy: targetPolicyForDuplicate ?? undefined, diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 33445e607fefa..c71c8932a2aa6 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -760,6 +760,7 @@ function duplicateExpenseTransaction({ } type DuplicateReportParams = { + sourceReport: OnyxEntry; sourceReportTransactions: OnyxTypes.Transaction[]; sourceReportName: string; targetPolicy: OnyxEntry; @@ -780,6 +781,7 @@ type DuplicateReportParams = { }; function duplicateReport({ + sourceReport, sourceReportTransactions, sourceReportName, targetPolicy, @@ -808,8 +810,6 @@ function duplicateReport({ const newReportName = translate('common.copyOfReportName', sourceReportName); const {reportPreviewReportActionID, ...newReport} = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas, false, undefined, newReportName); - const sourceReportID = sourceReportTransactions.at(0)?.reportID; - const sourceReport = sourceReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${sourceReportID}`] : undefined; const isCrossWorkspace = !!sourceReport && sourceReport.policyID !== targetPolicy.id; const eligibleTransactions = sourceReportTransactions.filter((transaction) => { From 648598793dc68a2d2496260f3a9fba2f26cfb048 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 18 Mar 2026 15:41:16 +0530 Subject: [PATCH 26/26] fix tests. Signed-off-by: krishna2323 --- tests/actions/IOUTest/DuplicateTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index fba820e51be91..216a3846d59b4 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1792,6 +1792,7 @@ describe('actions/Duplicate', () => { const POLICY_EXPENSE_CHAT_REPORT_ID = 'policyExpenseChatReport'; const getDefaultParams = (sourceTransactions: Transaction[], overrides: Partial = {}): DuplicateReportParams => ({ + sourceReport: undefined, sourceReportTransactions: sourceTransactions, sourceReportName: 'Original Report', targetPolicy: mockPolicy,