From 9d9293988873a8a38e4efb22a9868d4d3824a3d8 Mon Sep 17 00:00:00 2001 From: X Developer Date: Tue, 11 Aug 2026 00:47:21 +0430 Subject: [PATCH 01/17] Duplicate GPS distance expenses as manual distance --- src/libs/actions/IOU/Duplicate.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 4c40d542b15f..4b82c8d8f7c1 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -26,7 +26,6 @@ import { } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import { - getDistanceRequestType, getReimbursable, getRequestType, getTransactionType, @@ -34,6 +33,7 @@ import { isDistanceRequest, isExpenseSplit, isFromCreditCardImport, + isGPSDistanceRequest, isOdometerDistanceRequest, isPartialTransaction, isPerDiemRequest, @@ -604,7 +604,8 @@ function resolveDuplicates({ */ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType) { const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; - const waypoints = !isExpenseSplit(transaction) ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; + const shouldDuplicateAsManualDistance = isGPSDistanceRequest(transaction); + const waypoints = !isExpenseSplit(transaction) && !shouldDuplicateAsManualDistance ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; const transactionParams = { ...transactionWithoutLinkedAction, @@ -636,7 +637,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra unit: transaction.comment?.units?.unit, }; - if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction))) { + if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction) || shouldDuplicateAsManualDistance)) { transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; } @@ -646,10 +647,18 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra /** * Returns the request type the duplicate should be created with. SCAN sources become MANUAL because * `buildDuplicateTransactionParams` strips the receipt — without one, the duplicate cannot be a scan request. + * GPS distance sources become manual distance requests because the saved transaction does not contain + * the raw GPS track needed to create another GPS request. */ function getDuplicateRequestType(transaction: OnyxTypes.Transaction) { const sourceRequestType = getRequestType(transaction); - return sourceRequestType === CONST.IOU.REQUEST_TYPE.SCAN ? CONST.IOU.REQUEST_TYPE.MANUAL : sourceRequestType; + if (sourceRequestType === CONST.IOU.REQUEST_TYPE.SCAN) { + return CONST.IOU.REQUEST_TYPE.MANUAL; + } + if (sourceRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS) { + return CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; + } + return sourceRequestType; } /** @@ -687,13 +696,14 @@ function createExpenseByType({ }) { switch (transactionType) { case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: { + const duplicateRequestType = getDuplicateRequestType(transaction); const distanceParams: CreateDistanceRequestInformation = { ...params, participants, currentUserLogin: params.currentUserEmailParam, currentUserAccountID: params.currentUserAccountIDParam, existingTransaction: { - iouRequestType: getDuplicateRequestType(transaction), + iouRequestType: duplicateRequestType, amount: 0, currency: '', created: '', @@ -713,7 +723,7 @@ function createExpenseByType({ comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), validWaypoints: waypoints, modifiedAmount: transactionDetails?.amount, - distanceRequestType: getDistanceRequestType(transaction), + distanceRequestType: duplicateRequestType, }, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, @@ -815,6 +825,7 @@ function duplicateExpenseTransaction({ const transactionDetails = getTransactionDetails(transaction); const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails); + const duplicateRequestType = getDuplicateRequestType(transaction); const params: RequestMoneyInformation = { report: targetReport, @@ -841,7 +852,7 @@ function duplicateExpenseTransaction({ quickAction, existingTransactionDraft, existingTransaction: { - iouRequestType: getDuplicateRequestType(transaction), + iouRequestType: duplicateRequestType, amount: 0, currency: '', created: '', @@ -868,7 +879,7 @@ function duplicateExpenseTransaction({ participant: {accountID: currentUserAccountID, selected: true}, }, existingTransaction: { - iouRequestType: getDuplicateRequestType(transaction), + iouRequestType: duplicateRequestType, amount: 0, currency: '', created: '', @@ -886,6 +897,7 @@ function duplicateExpenseTransaction({ transactionParams: { ...(params.transactionParams ?? {}), validWaypoints: waypoints, + ...(isDistanceRequest(transaction) && {distanceRequestType: duplicateRequestType}), }, report: undefined, isDraftPolicy: false, From 232efe582f26d83edc02689b7ff5c58f516d499d Mon Sep 17 00:00:00 2001 From: X Developer Date: Tue, 11 Aug 2026 09:35:56 +0430 Subject: [PATCH 02/17] Test GPS distance duplication as manual distance --- tests/actions/IOUTest/DuplicateTest.ts | 194 +++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 3b73bf21aba0..9eed36a44701 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1798,6 +1798,156 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); }); + it('should duplicate a GPS distance expense as manual distance when a target workspace is provided', async () => { + const transactionID = 'gps-workspace-1'; + const mockGPSDistanceTransaction = { + ...mockTransaction, + transactionID, + amount: mockTransaction.amount * -1, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 12.34, + }, + waypoints: { + waypoint0: {address: 'Start', lat: 37.7749, lng: -122.4194}, + waypoint1: {address: 'End', lat: 34.0522, lng: -118.2437}, + }, + }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockGPSDistanceTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: '', + targetPolicy: mockPolicy, + targetPolicyCategories: fakePolicyCategories, + targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + personalDetails: mockPersonalDetails, + betas: [CONST.BETAS.ALL], + recentWaypoints, + targetPolicyTags, + policyTagList: targetPolicyTags ?? {}, + currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserLocalCurrency: undefined, + delegateAccountID: undefined, + isTrackIntentUser: false, + formatPhoneNumber, + }); + + await waitForBatchedUpdates(); + + const distanceCall = writeSpy.mock.calls.find(isWriteMockCallForCommand(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)); + expect(distanceCall).toBeDefined(); + expect(distanceCall?.[1]).toEqual( + expect.objectContaining({ + distance: 12.34, + distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + waypoints: 'null', + }), + ); + + let duplicatedTransaction: OnyxEntry; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t); + }, + }); + + expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); + expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); + expect(duplicatedTransaction?.comment?.waypoints).toBeUndefined(); + expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(12.34); + }); + + it('should duplicate a GPS distance expense as manual distance when no targetPolicy is provided', async () => { + const transactionID = 'gps-unreported-1'; + const mockGPSDistanceTransaction = { + ...mockTransaction, + transactionID, + amount: mockTransaction.amount * -1, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 56.78, + }, + waypoints: { + waypoint0: {address: 'Start', lat: 37.7749, lng: -122.4194}, + waypoint1: {address: 'End', lat: 34.0522, lng: -118.2437}, + }, + }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + transaction: mockGPSDistanceTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: '', + targetPolicy: undefined, + targetPolicyCategories: undefined, + targetReport: undefined, + existingTransactionDraft: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + recentWaypoints, + targetPolicyTags, + policyTagList: targetPolicyTags ?? {}, + currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserLocalCurrency: undefined, + delegateAccountID: undefined, + isTrackIntentUser: false, + formatPhoneNumber, + }); + + await waitForBatchedUpdates(); + + const trackExpenseCall = writeSpy.mock.calls.find(isWriteMockCallForCommand(WRITE_COMMANDS.TRACK_EXPENSE)); + expect(trackExpenseCall).toBeDefined(); + expect(trackExpenseCall?.[1]).toEqual( + expect.objectContaining({ + distance: 56.78, + distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + waypoints: undefined, + }), + ); + + let duplicatedTransaction: OnyxEntry; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t); + }, + }); + + expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); + expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); + expect(duplicatedTransaction?.comment?.waypoints).toBeUndefined(); + expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(56.78); + }); + it('should create a duplicate expense successfully (previously with transaction drafts)', async () => { const {waypoints, ...restOfComment} = mockTransaction.comment ?? {}; const mockCashExpenseTransaction = { @@ -2928,6 +3078,50 @@ describe('actions/Duplicate', () => { expect(waypoints).not.toBe('null'); }); + it('should duplicate GPS distance expenses as manual distance expenses', async () => { + const gpsDistanceTx = createCashTransaction('gps1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 42, + }, + waypoints: { + waypoint0: {lat: 37.7749, lng: -122.4194, address: 'San Francisco'}, + waypoint1: {lat: 34.0522, lng: -118.2437, address: 'Los Angeles'}, + }, + }, + }); + + duplicateReport(getDefaultParams([gpsDistanceTx])); + await waitForBatchedUpdates(); + + const distanceCall = writeSpy.mock.calls.find(isWriteMockCallForCommand(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)); + expect(distanceCall).toBeDefined(); + expect(distanceCall?.[1]).toEqual( + expect.objectContaining({ + distance: 42, + distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + waypoints: 'null', + }), + ); + + let duplicatedTransaction: OnyxEntry; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((tx) => !!tx && tx.transactionID !== gpsDistanceTx.transactionID); + }, + }); + + expect(duplicatedTransaction).toBeDefined(); + expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); + expect(duplicatedTransaction?.comment?.waypoints).toBeUndefined(); + expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(42); + }); + it('should correctly route a report with mixed cash, distance, and per diem transactions', async () => { const cashTx = createCashTransaction('cash1'); const distanceTx = createCashTransaction('dist1', { From 8afe67b9101c7c4edea97d9c16f374863a964caf Mon Sep 17 00:00:00 2001 From: X Developer Date: Tue, 11 Aug 2026 16:39:34 +0430 Subject: [PATCH 03/17] Fix missing parameters in GPS duplicate tests --- tests/actions/IOUTest/DuplicateTest.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 531705d93875..5eb508c8d098 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1834,6 +1834,7 @@ describe('actions/Duplicate', () => { await Onyx.clear(); duplicateExpenseTransaction({ + dateFnsLocale: undefined, transaction: mockGPSDistanceTransaction, optimisticChatReportID: mockOptimisticChatReportID, optimisticIOUReportID: mockOptimisticIOUReportID, @@ -1857,6 +1858,8 @@ describe('actions/Duplicate', () => { delegateAccountID: undefined, isTrackIntentUser: false, formatPhoneNumber, + getCurrencyDecimals: getCurrencyDecimalsLocal, + participantsPolicyTags: {}, }); await waitForBatchedUpdates(); @@ -1909,6 +1912,7 @@ describe('actions/Duplicate', () => { await Onyx.clear(); duplicateExpenseTransaction({ + dateFnsLocale: undefined, transaction: mockGPSDistanceTransaction, optimisticChatReportID: mockOptimisticChatReportID, optimisticIOUReportID: mockOptimisticIOUReportID, @@ -1932,6 +1936,8 @@ describe('actions/Duplicate', () => { delegateAccountID: undefined, isTrackIntentUser: false, formatPhoneNumber, + getCurrencyDecimals: getCurrencyDecimalsLocal, + participantsPolicyTags: {}, }); await waitForBatchedUpdates(); From ff453f8be9f92787667ae8254e9c62750e9d4e0f Mon Sep 17 00:00:00 2001 From: X Developer Date: Wed, 12 Aug 2026 21:32:19 +0430 Subject: [PATCH 04/17] Centralize GPS duplicate manual-distance rule --- src/libs/actions/IOU/Duplicate.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 2d464f2570a1..82eeabf838da 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -603,14 +603,18 @@ function resolveDuplicates({ API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData}); } +function shouldDuplicateAsManualDistance(transaction: OnyxTypes.Transaction) { + return isGPSDistanceRequest(transaction); +} + /** * 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 shouldDuplicateAsManualDistance = isGPSDistanceRequest(transaction); - const waypoints = !isExpenseSplit(transaction) && !shouldDuplicateAsManualDistance ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; + const duplicateAsManualDistance = shouldDuplicateAsManualDistance(transaction); + const waypoints = !isExpenseSplit(transaction) && !duplicateAsManualDistance ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; const transactionParams = { ...transactionWithoutLinkedAction, @@ -642,7 +646,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra unit: transaction.comment?.units?.unit, }; - if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction) || shouldDuplicateAsManualDistance)) { + if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction) || duplicateAsManualDistance)) { transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; } @@ -660,7 +664,7 @@ function getDuplicateRequestType(transaction: OnyxTypes.Transaction) { if (sourceRequestType === CONST.IOU.REQUEST_TYPE.SCAN) { return CONST.IOU.REQUEST_TYPE.MANUAL; } - if (sourceRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS) { + if (shouldDuplicateAsManualDistance(transaction)) { return CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; } return sourceRequestType; From 77e4e21973a49384cf832c431f369133939057d3 Mon Sep 17 00:00:00 2001 From: X Developer Date: Wed, 12 Aug 2026 22:18:39 +0430 Subject: [PATCH 05/17] Add kilometer GPS duplication test coverage --- tests/actions/IOUTest/DuplicateTest.ts | 37 +++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 361343d00ac7..424c5faf84f0 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1898,6 +1898,29 @@ describe('actions/Duplicate', () => { it('should duplicate a GPS distance expense as manual distance when a target workspace is provided', async () => { const transactionID = 'gps-workspace-1'; + const customUnitID = 'kilometer-unit'; + const customUnitRateID = 'kilometer-rate'; + const kilometerPolicy: Policy = { + ...mockPolicy, + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, + rates: { + [customUnitRateID]: { + customUnitRateID, + currency: CONST.CURRENCY.USD, + rate: 100, + enabled: true, + name: 'Kilometer rate', + subRates: [], + }, + }, + }, + }, + }; const mockGPSDistanceTransaction = { ...mockTransaction, transactionID, @@ -1906,9 +1929,11 @@ describe('actions/Duplicate', () => { comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { - distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - quantity: 12.34, + quantity: 19.87, }, waypoints: { waypoint0: {address: 'Start', lat: 37.7749, lng: -122.4194}, @@ -1929,8 +1954,8 @@ describe('actions/Duplicate', () => { quickAction: undefined, policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, - customUnitPolicyID: '', - targetPolicy: mockPolicy, + customUnitPolicyID: kilometerPolicy.id, + targetPolicy: kilometerPolicy, targetPolicyCategories: fakePolicyCategories, targetReport: policyExpenseChat, existingTransactionDraft: undefined, @@ -1954,7 +1979,7 @@ describe('actions/Duplicate', () => { expect(distanceCall).toBeDefined(); expect(distanceCall?.[1]).toEqual( expect.objectContaining({ - distance: 12.34, + distance: 19.87, distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, waypoints: 'null', }), @@ -1971,7 +1996,7 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); expect(duplicatedTransaction?.comment?.waypoints).toBeUndefined(); - expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(12.34); + expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(19.87); }); it('should duplicate a GPS distance expense as manual distance when no targetPolicy is provided', async () => { From 631863f4414d41fa5329847825f187d7638fd62e Mon Sep 17 00:00:00 2001 From: X Developer Date: Wed, 12 Aug 2026 22:27:54 +0430 Subject: [PATCH 06/17] Add non-GPS distance request type coverage --- tests/actions/IOUTest/DuplicateTest.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 424c5faf84f0..348507904d7d 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -3242,6 +3242,11 @@ describe('actions/Duplicate', () => { const waypoints = distanceCall?.[1]?.waypoints; expect(waypoints).toBeDefined(); expect(waypoints).not.toBe('null'); + expect(distanceCall?.[1]).toEqual( + expect.objectContaining({ + distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + }), + ); }); it('should duplicate GPS distance expenses as manual distance expenses', async () => { From 96e84dcab8715a821046835f9d79fc1e8a2102c5 Mon Sep 17 00:00:00 2001 From: X Developer Date: Thu, 20 Aug 2026 17:18:51 +0430 Subject: [PATCH 07/17] Fix missing concierge chat in GPS duplicate tests --- tests/actions/IOUTest/DuplicateTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index db3c1dd03893..a48ce2472e1e 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1988,6 +1988,7 @@ describe('actions/Duplicate', () => { duplicateExpenseTransaction({ dateFnsLocale: undefined, + conciergeChat: undefined, transaction: mockGPSDistanceTransaction, optimisticChatReportID: mockOptimisticChatReportID, optimisticIOUReportID: mockOptimisticIOUReportID, @@ -2066,6 +2067,7 @@ describe('actions/Duplicate', () => { duplicateExpenseTransaction({ dateFnsLocale: undefined, + conciergeChat: undefined, transaction: mockGPSDistanceTransaction, optimisticChatReportID: mockOptimisticChatReportID, optimisticIOUReportID: mockOptimisticIOUReportID, From ce4db61442593d0531c1862f3e5efdd52a08f969 Mon Sep 17 00:00:00 2001 From: X Developer Date: Fri, 21 Aug 2026 15:59:54 +0430 Subject: [PATCH 08/17] Clarify waypoint retention for duplicate expenses --- src/libs/actions/IOU/Duplicate.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index da40411fb322..3c4b890af2c1 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -614,7 +614,8 @@ function shouldDuplicateAsManualDistance(transaction: OnyxTypes.Transaction) { function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType) { const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; const duplicateAsManualDistance = shouldDuplicateAsManualDistance(transaction); - const waypoints = !isExpenseSplit(transaction) && !duplicateAsManualDistance ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; + const shouldKeepWaypoints = !isExpenseSplit(transaction) && !duplicateAsManualDistance; + const waypoints = shouldKeepWaypoints ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; const transactionParams = { ...transactionWithoutLinkedAction, From fd2add0450e25db793118d839f35fe17b9ca2f0d Mon Sep 17 00:00:00 2001 From: X Developer Date: Wed, 26 Aug 2026 22:42:20 +0430 Subject: [PATCH 09/17] Limit GPS manual-distance duplication to self-DM expenses --- src/libs/actions/IOU/Duplicate.ts | 25 +++-- tests/actions/IOUTest/DuplicateTest.ts | 148 ------------------------- 2 files changed, 13 insertions(+), 160 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 3c4b890af2c1..11721cf18faa 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -603,17 +603,17 @@ function resolveDuplicates({ API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData}); } -function shouldDuplicateAsManualDistance(transaction: OnyxTypes.Transaction) { - return isGPSDistanceRequest(transaction); +function shouldDuplicateAsManualDistance(transaction: OnyxTypes.Transaction, shouldDuplicateSelfDMExpense: boolean) { + return shouldDuplicateSelfDMExpense && isGPSDistanceRequest(transaction); } /** * Builds the transactionParams object and computes waypoints used when duplicating a transaction. * Shared between duplicateExpenseTransaction and duplicateReport. */ -function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType) { +function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType, shouldDuplicateSelfDMExpense = false) { const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; - const duplicateAsManualDistance = shouldDuplicateAsManualDistance(transaction); + const duplicateAsManualDistance = shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense); const shouldKeepWaypoints = !isExpenseSplit(transaction) && !duplicateAsManualDistance; const waypoints = shouldKeepWaypoints ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; @@ -657,15 +657,15 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra /** * Returns the request type the duplicate should be created with. SCAN sources become MANUAL because * `buildDuplicateTransactionParams` strips the receipt — without one, the duplicate cannot be a scan request. - * GPS distance sources become manual distance requests because the saved transaction does not contain - * the raw GPS track needed to create another GPS request. + * GPS distance sources become manual distance requests for self-DM duplicates because the saved transaction + * does not contain the raw GPS track needed to create another GPS request. */ -function getDuplicateRequestType(transaction: OnyxTypes.Transaction) { +function getDuplicateRequestType(transaction: OnyxTypes.Transaction, shouldDuplicateSelfDMExpense = false) { const sourceRequestType = getRequestType(transaction); if (sourceRequestType === CONST.IOU.REQUEST_TYPE.SCAN) { return CONST.IOU.REQUEST_TYPE.MANUAL; } - if (shouldDuplicateAsManualDistance(transaction)) { + if (shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense)) { return CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; } return sourceRequestType; @@ -849,8 +849,11 @@ function duplicateExpenseTransaction({ const participants = getMoneyRequestParticipantsFromReport(targetReport, currentUserAccountID); const transactionDetails = getTransactionDetails(transaction); - const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails); - const duplicateRequestType = getDuplicateRequestType(transaction); + // A duplicate mirrors the source, so an unreported source stays unreported even when a workspace is available + const isSourceUnreported = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const shouldDuplicateSelfDMExpense = !targetPolicy; + const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails, shouldDuplicateSelfDMExpense); + const duplicateRequestType = getDuplicateRequestType(transaction, shouldDuplicateSelfDMExpense); const params: RequestMoneyInformation = { report: targetReport, @@ -896,8 +899,6 @@ function duplicateExpenseTransaction({ getCurrencyDecimals, }; - // A duplicate mirrors the source, so an unreported source stays unreported even when a workspace is available - const isSourceUnreported = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; if (!targetPolicy || isSourceUnreported) { const trackExpenseParams: CreateTrackExpenseParams = { ...params, diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index d8d9b4a07555..160486037d70 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1938,110 +1938,6 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); }); - it('should duplicate a GPS distance expense as manual distance when a target workspace is provided', async () => { - const transactionID = 'gps-workspace-1'; - const customUnitID = 'kilometer-unit'; - const customUnitRateID = 'kilometer-rate'; - const kilometerPolicy: Policy = { - ...mockPolicy, - customUnits: { - [customUnitID]: { - customUnitID, - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - enabled: true, - attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, - rates: { - [customUnitRateID]: { - customUnitRateID, - currency: CONST.CURRENCY.USD, - rate: 100, - enabled: true, - name: 'Kilometer rate', - subRates: [], - }, - }, - }, - }, - }; - const mockGPSDistanceTransaction = { - ...mockTransaction, - transactionID, - amount: mockTransaction.amount * -1, - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, - comment: { - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - customUnit: { - customUnitID, - customUnitRateID, - distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - quantity: 19.87, - }, - waypoints: { - waypoint0: {address: 'Start', lat: 37.7749, lng: -122.4194}, - waypoint1: {address: 'End', lat: 34.0522, lng: -118.2437}, - }, - }, - }; - - await Onyx.clear(); - - duplicateExpenseTransaction({ - dateFnsLocale: undefined, - conciergeChat: undefined, - transaction: mockGPSDistanceTransaction, - optimisticChatReportID: mockOptimisticChatReportID, - optimisticIOUReportID: mockOptimisticIOUReportID, - isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, - introSelected: undefined, - quickAction: undefined, - policyRecentlyUsedCurrencies: [], - isSelfTourViewed: false, - customUnitPolicyID: kilometerPolicy.id, - targetPolicy: kilometerPolicy, - targetPolicyCategories: fakePolicyCategories, - targetReport: policyExpenseChat, - existingTransactionDraft: undefined, - personalDetails: mockPersonalDetails, - betas: [CONST.BETAS.ALL], - recentWaypoints, - targetPolicyTags, - policyTagList: targetPolicyTags ?? {}, - currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, - currentUserLocalCurrency: undefined, - delegateAccountID: undefined, - isTrackIntentUser: false, - formatPhoneNumber, - getCurrencyDecimals: getCurrencyDecimalsLocal, - participantsPolicyTags: {}, - }); - - await waitForBatchedUpdates(); - - const distanceCall = writeSpy.mock.calls.find(isWriteMockCallForCommand(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)); - expect(distanceCall).toBeDefined(); - expect(distanceCall?.[1]).toEqual( - expect.objectContaining({ - distance: 19.87, - distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, - waypoints: 'null', - }), - ); - - let duplicatedTransaction: OnyxEntry; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - callback: (allTransactions) => { - duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t); - }, - }); - - expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); - expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); - expect(duplicatedTransaction?.comment?.waypoints).toBeUndefined(); - expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(19.87); - }); - it('should duplicate a GPS distance expense as manual distance when no targetPolicy is provided', async () => { const transactionID = 'gps-unreported-1'; const mockGPSDistanceTransaction = { @@ -3354,50 +3250,6 @@ describe('actions/Duplicate', () => { ); }); - it('should duplicate GPS distance expenses as manual distance expenses', async () => { - const gpsDistanceTx = createCashTransaction('gps1', { - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, - comment: { - type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, - customUnit: { - distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - name: CONST.CUSTOM_UNITS.NAME_DISTANCE, - quantity: 42, - }, - waypoints: { - waypoint0: {lat: 37.7749, lng: -122.4194, address: 'San Francisco'}, - waypoint1: {lat: 34.0522, lng: -118.2437, address: 'Los Angeles'}, - }, - }, - }); - - duplicateReport(getDefaultParams([gpsDistanceTx])); - await waitForBatchedUpdates(); - - const distanceCall = writeSpy.mock.calls.find(isWriteMockCallForCommand(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)); - expect(distanceCall).toBeDefined(); - expect(distanceCall?.[1]).toEqual( - expect.objectContaining({ - distance: 42, - distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, - waypoints: 'null', - }), - ); - - let duplicatedTransaction: OnyxEntry; - await getOnyxData({ - key: ONYXKEYS.COLLECTION.TRANSACTION, - callback: (allTransactions) => { - duplicatedTransaction = Object.values(allTransactions ?? {}).find((tx) => !!tx && tx.transactionID !== gpsDistanceTx.transactionID); - }, - }); - - expect(duplicatedTransaction).toBeDefined(); - expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); - expect(duplicatedTransaction?.comment?.waypoints).toBeUndefined(); - expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(42); - }); - it('should correctly route a report with mixed cash, distance, and per diem transactions', async () => { const cashTx = createCashTransaction('cash1'); const distanceTx = createCashTransaction('dist1', { From eab9ff3b65bbdd1f2c74ef5e752959c30def7e11 Mon Sep 17 00:00:00 2001 From: X Developer Date: Thu, 27 Aug 2026 10:39:26 +0430 Subject: [PATCH 10/17] Convert self-DM GPS duplicates to the current distance unit --- src/libs/actions/IOU/Duplicate.ts | 27 +++++++++++++++++++++++--- tests/actions/IOUTest/DuplicateTest.ts | 12 ++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 11721cf18faa..5b4d1a47f214 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -8,6 +8,7 @@ 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 DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {getExistingTransactionID} from '@libs/IOUUtils'; import * as NumberUtils from '@libs/NumberUtils'; @@ -27,6 +28,7 @@ import { } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import { + getCurrency, getReimbursable, getRequestType, getTransactionType, @@ -78,7 +80,7 @@ function getIOUActionForTransactions( if (!isMoneyRequestAction(reportAction)) { return false; } - const message = getOriginalMessage(reportAction); + const message = getOriginalMessage(reportAction); if (!message?.IOUTransactionID) { return false; } @@ -607,6 +609,18 @@ function shouldDuplicateAsManualDistance(transaction: OnyxTypes.Transaction, sho return shouldDuplicateSelfDMExpense && isGPSDistanceRequest(transaction); } +function getManualDuplicateDistance(transaction: OnyxTypes.Transaction) { + const distance = transaction.comment?.customUnit?.quantity ?? undefined; + const sourceUnit = transaction.comment?.customUnit?.distanceUnit; + const targetUnit = DistanceRequestUtils.getRateForP2P(getCurrency(transaction), transaction).unit; + if (distance === undefined || !sourceUnit || sourceUnit === targetUnit) { + return distance; + } + + const distanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(distance, sourceUnit); + return NumberUtils.roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, targetUnit)); +} + /** * Builds the transactionParams object and computes waypoints used when duplicating a transaction. * Shared between duplicateExpenseTransaction and duplicateReport. @@ -628,7 +642,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra 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, + customUnitRateID: duplicateAsManualDistance ? CONST.CUSTOM_UNITS.FAKE_P2P_ID : transaction.comment?.customUnit?.customUnitRateID, isFromGlobalCreate: undefined, isLinkedTrackedExpenseReportArchived: undefined, isTestDrive: transaction.receipt?.isTestDriveReceipt, @@ -648,7 +662,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra }; if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction) || duplicateAsManualDistance)) { - transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; + transactionParams.distance = duplicateAsManualDistance ? getManualDuplicateDistance(transaction) : (transaction.comment?.customUnit?.quantity ?? undefined); } return {transactionParams, waypoints}; @@ -852,6 +866,7 @@ function duplicateExpenseTransaction({ // A duplicate mirrors the source, so an unreported source stays unreported even when a workspace is available const isSourceUnreported = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const shouldDuplicateSelfDMExpense = !targetPolicy; + const duplicateAsManualDistance = shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense); const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails, shouldDuplicateSelfDMExpense); const duplicateRequestType = getDuplicateRequestType(transaction, shouldDuplicateSelfDMExpense); @@ -916,6 +931,12 @@ function duplicateExpenseTransaction({ transactionID: '1', comment: { ...transaction.comment, + ...(duplicateAsManualDistance && { + customUnit: { + ...transaction.comment?.customUnit, + customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, + }, + }), hold: undefined, originalTransactionID: undefined, source: undefined, diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 160486037d70..97461aab5155 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1948,6 +1948,7 @@ describe('actions/Duplicate', () => { comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { + customUnitRateID: 'deleted-workspace-rate', distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 56.78, @@ -1960,6 +1961,10 @@ describe('actions/Duplicate', () => { }; await Onyx.clear(); + await Onyx.set(ONYXKEYS.DEFAULT_P2P_MILEAGE_RATE, { + rate: 70, + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }); duplicateExpenseTransaction({ dateFnsLocale: undefined, @@ -1997,7 +2002,8 @@ describe('actions/Duplicate', () => { expect(trackExpenseCall).toBeDefined(); expect(trackExpenseCall?.[1]).toEqual( expect.objectContaining({ - distance: 56.78, + customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, + distance: 91.38, distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, waypoints: undefined, }), @@ -2014,7 +2020,9 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); expect(duplicatedTransaction?.comment?.waypoints).toBeUndefined(); - expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(56.78); + expect(duplicatedTransaction?.comment?.customUnit?.customUnitRateID).toBeUndefined(); + expect(duplicatedTransaction?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(91.38); }); it('should create a duplicate expense successfully (previously with transaction drafts)', async () => { From 2ed72ce7bed6317ca92d85b24cc91c2430462f36 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 29 Aug 2026 10:12:02 +0430 Subject: [PATCH 11/17] Revert self-DM GPS duplicate distance unit conversion --- src/libs/actions/IOU/Duplicate.ts | 27 +++----------------------- tests/actions/IOUTest/DuplicateTest.ts | 12 ++---------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 5b4d1a47f214..11721cf18faa 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -8,7 +8,6 @@ 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 DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {getExistingTransactionID} from '@libs/IOUUtils'; import * as NumberUtils from '@libs/NumberUtils'; @@ -28,7 +27,6 @@ import { } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import { - getCurrency, getReimbursable, getRequestType, getTransactionType, @@ -80,7 +78,7 @@ function getIOUActionForTransactions( if (!isMoneyRequestAction(reportAction)) { return false; } - const message = getOriginalMessage(reportAction); + const message = getOriginalMessage(reportAction); if (!message?.IOUTransactionID) { return false; } @@ -609,18 +607,6 @@ function shouldDuplicateAsManualDistance(transaction: OnyxTypes.Transaction, sho return shouldDuplicateSelfDMExpense && isGPSDistanceRequest(transaction); } -function getManualDuplicateDistance(transaction: OnyxTypes.Transaction) { - const distance = transaction.comment?.customUnit?.quantity ?? undefined; - const sourceUnit = transaction.comment?.customUnit?.distanceUnit; - const targetUnit = DistanceRequestUtils.getRateForP2P(getCurrency(transaction), transaction).unit; - if (distance === undefined || !sourceUnit || sourceUnit === targetUnit) { - return distance; - } - - const distanceInMeters = DistanceRequestUtils.convertToDistanceInMeters(distance, sourceUnit); - return NumberUtils.roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, targetUnit)); -} - /** * Builds the transactionParams object and computes waypoints used when duplicating a transaction. * Shared between duplicateExpenseTransaction and duplicateReport. @@ -642,7 +628,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra attendees: transactionDetails?.attendees as Attendee[] | undefined, comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - customUnitRateID: duplicateAsManualDistance ? CONST.CUSTOM_UNITS.FAKE_P2P_ID : transaction.comment?.customUnit?.customUnitRateID, + customUnitRateID: transaction.comment?.customUnit?.customUnitRateID, isFromGlobalCreate: undefined, isLinkedTrackedExpenseReportArchived: undefined, isTestDrive: transaction.receipt?.isTestDriveReceipt, @@ -662,7 +648,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra }; if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction) || duplicateAsManualDistance)) { - transactionParams.distance = duplicateAsManualDistance ? getManualDuplicateDistance(transaction) : (transaction.comment?.customUnit?.quantity ?? undefined); + transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; } return {transactionParams, waypoints}; @@ -866,7 +852,6 @@ function duplicateExpenseTransaction({ // A duplicate mirrors the source, so an unreported source stays unreported even when a workspace is available const isSourceUnreported = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const shouldDuplicateSelfDMExpense = !targetPolicy; - const duplicateAsManualDistance = shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense); const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails, shouldDuplicateSelfDMExpense); const duplicateRequestType = getDuplicateRequestType(transaction, shouldDuplicateSelfDMExpense); @@ -931,12 +916,6 @@ function duplicateExpenseTransaction({ transactionID: '1', comment: { ...transaction.comment, - ...(duplicateAsManualDistance && { - customUnit: { - ...transaction.comment?.customUnit, - customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, - }, - }), hold: undefined, originalTransactionID: undefined, source: undefined, diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 97461aab5155..160486037d70 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1948,7 +1948,6 @@ describe('actions/Duplicate', () => { comment: { type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: { - customUnitRateID: 'deleted-workspace-rate', distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 56.78, @@ -1961,10 +1960,6 @@ describe('actions/Duplicate', () => { }; await Onyx.clear(); - await Onyx.set(ONYXKEYS.DEFAULT_P2P_MILEAGE_RATE, { - rate: 70, - unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, - }); duplicateExpenseTransaction({ dateFnsLocale: undefined, @@ -2002,8 +1997,7 @@ describe('actions/Duplicate', () => { expect(trackExpenseCall).toBeDefined(); expect(trackExpenseCall?.[1]).toEqual( expect.objectContaining({ - customUnitRateID: CONST.CUSTOM_UNITS.FAKE_P2P_ID, - distance: 91.38, + distance: 56.78, distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, waypoints: undefined, }), @@ -2020,9 +2014,7 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); expect(duplicatedTransaction?.comment?.waypoints).toBeUndefined(); - expect(duplicatedTransaction?.comment?.customUnit?.customUnitRateID).toBeUndefined(); - expect(duplicatedTransaction?.comment?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); - expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(91.38); + expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(56.78); }); it('should create a duplicate expense successfully (previously with transaction drafts)', async () => { From 7b772b833368367490077534fd45f0aa9552f66d Mon Sep 17 00:00:00 2001 From: X Developer Date: Thu, 3 Sep 2026 12:11:09 +0430 Subject: [PATCH 12/17] Fix self-DM GPS duplication when a target policy exists --- src/libs/actions/IOU/Duplicate.ts | 2 +- tests/actions/IOUTest/DuplicateTest.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index cad88c25b6cf..6c0f2db5638e 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -851,7 +851,7 @@ function duplicateExpenseTransaction({ const transactionDetails = getTransactionDetails(transaction); // A duplicate mirrors the source, so an unreported source stays unreported even when a workspace is available const isSourceUnreported = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - const shouldDuplicateSelfDMExpense = !targetPolicy; + const shouldDuplicateSelfDMExpense = !targetPolicy || isSourceUnreported; const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails, shouldDuplicateSelfDMExpense); const duplicateRequestType = getDuplicateRequestType(transaction, shouldDuplicateSelfDMExpense); diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 82b918c9ef7e..a7ddc7fbbf8e 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1938,11 +1938,15 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); }); - it('should duplicate a GPS distance expense as manual distance when no targetPolicy is provided', async () => { + it.each([ + ['no target policy is provided', undefined, 'reported-source'], + ['a target policy is provided for an unreported source', mockPolicy, CONST.REPORT.UNREPORTED_REPORT_ID], + ])('should duplicate a GPS distance expense as manual distance when %s', async (_description, targetPolicy, reportID) => { const transactionID = 'gps-unreported-1'; const mockGPSDistanceTransaction = { ...mockTransaction, transactionID, + reportID, amount: mockTransaction.amount * -1, iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, comment: { @@ -1972,10 +1976,10 @@ describe('actions/Duplicate', () => { quickAction: undefined, policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, - customUnitPolicyID: '', - targetPolicy: undefined, - targetPolicyCategories: undefined, - targetReport: undefined, + customUnitPolicyID: targetPolicy?.id ?? '', + targetPolicy, + targetPolicyCategories: targetPolicy ? fakePolicyCategories : undefined, + targetReport: targetPolicy ? policyExpenseChat : undefined, existingTransactionDraft: undefined, betas: [CONST.BETAS.ALL], personalDetails: {}, From 89919afdd05b867db258568b9e2f6c3ba229d289 Mon Sep 17 00:00:00 2001 From: X Developer Date: Thu, 3 Sep 2026 13:33:14 +0430 Subject: [PATCH 13/17] Fix invalid policy ID fallback in GPS duplicate test --- tests/actions/IOUTest/DuplicateTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index a7ddc7fbbf8e..9efc84ea2e7f 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1976,7 +1976,7 @@ describe('actions/Duplicate', () => { quickAction: undefined, policyRecentlyUsedCurrencies: [], isSelfTourViewed: false, - customUnitPolicyID: targetPolicy?.id ?? '', + customUnitPolicyID: targetPolicy?.id, targetPolicy, targetPolicyCategories: targetPolicy ? fakePolicyCategories : undefined, targetReport: targetPolicy ? policyExpenseChat : undefined, From ab86563f37422d76e2613fbe2de94a9a75d3ad22 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 5 Sep 2026 09:31:09 +0430 Subject: [PATCH 14/17] Narrow self-DM distance request type to GPS duplicates --- src/libs/actions/IOU/Duplicate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 6c0f2db5638e..ec5409ae99d1 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -925,7 +925,7 @@ function duplicateExpenseTransaction({ transactionParams: { ...(params.transactionParams ?? {}), validWaypoints: waypoints, - ...(isDistanceRequest(transaction) && {distanceRequestType: duplicateRequestType}), + ...(isGPSDistanceRequest(transaction) && {distanceRequestType: duplicateRequestType}), }, report: undefined, isDraftPolicy: false, From 9362f6ed0bf145b31fd3e3aa8a33360856e91e75 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 5 Sep 2026 09:37:43 +0430 Subject: [PATCH 15/17] Document GPS duplicate distance source --- 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 ec5409ae99d1..60db73e05edc 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -648,6 +648,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra }; if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction) || duplicateAsManualDistance)) { + // Completed GPS expenses store their measured distance in customUnit.quantity; duplicating as manual uses that saved value because the raw GPS track is no longer available. transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; } From fdca57d85e859d22e7e0021058ee6cb9f3c58ed0 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 5 Sep 2026 09:45:22 +0430 Subject: [PATCH 16/17] Follow boolean naming convention --- src/libs/actions/IOU/Duplicate.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 60db73e05edc..e165f17b087f 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -613,8 +613,8 @@ function shouldDuplicateAsManualDistance(transaction: OnyxTypes.Transaction, sho */ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType, shouldDuplicateSelfDMExpense = false) { const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; - const duplicateAsManualDistance = shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense); - const shouldKeepWaypoints = !isExpenseSplit(transaction) && !duplicateAsManualDistance; + const isManualDistanceDuplicate = shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense); + const shouldKeepWaypoints = !isExpenseSplit(transaction) && !isManualDistanceDuplicate; const waypoints = shouldKeepWaypoints ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; const transactionParams = { @@ -647,7 +647,7 @@ function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, tra unit: transaction.comment?.units?.unit, }; - if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction) || duplicateAsManualDistance)) { + if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction) || isManualDistanceDuplicate)) { // Completed GPS expenses store their measured distance in customUnit.quantity; duplicating as manual uses that saved value because the raw GPS track is no longer available. transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; } From 766227cb7fc7c5fdc6c1ed4637ffaf583d4ac33b Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 5 Sep 2026 10:16:46 +0430 Subject: [PATCH 17/17] Add coverage for workspace GPS duplicate behavior --- tests/actions/IOUTest/DuplicateTest.ts | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 4204abae909d..edb97d92a5ae 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -2023,6 +2023,85 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.comment?.customUnit?.quantity).toBe(56.78); }); + it('should keep reported workspace GPS distance duplicates as GPS with waypoints', async () => { + const transactionID = 'gps-reported-1'; + const mockGPSDistanceTransaction = { + ...mockTransaction, + transactionID, + reportID: 'reported-gps-report', + amount: mockTransaction.amount * -1, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 56.78, + }, + waypoints: { + waypoint0: {address: 'Start', lat: 37.7749, lng: -122.4194}, + waypoint1: {address: 'End', lat: 34.0522, lng: -118.2437}, + }, + }, + }; + + await Onyx.clear(); + + duplicateExpenseTransaction({ + dateFnsLocale: undefined, + conciergeChat: undefined, + transaction: mockGPSDistanceTransaction, + optimisticChatReportID: mockOptimisticChatReportID, + optimisticIOUReportID: mockOptimisticIOUReportID, + isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled, + introSelected: undefined, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + isSelfTourViewed: false, + customUnitPolicyID: mockPolicy.id, + targetPolicy: mockPolicy, + targetPolicyCategories: fakePolicyCategories, + targetReport: policyExpenseChat, + existingTransactionDraft: undefined, + betas: [CONST.BETAS.ALL], + personalDetails: {}, + recentWaypoints, + targetPolicyTags, + policyTagList: targetPolicyTags ?? {}, + currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + currentUserLocalCurrency: undefined, + delegateAccountID: undefined, + isTrackIntentUser: false, + formatPhoneNumber, + getCurrencyDecimals: getCurrencyDecimalsLocal, + participantsPolicyTags: {}, + }); + + await waitForBatchedUpdates(); + + const distanceCall = writeSpy.mock.calls.find(isWriteMockCallForCommand(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)); + expect(distanceCall).toBeDefined(); + expect(writeSpy.mock.calls.find(isWriteMockCallForCommand(WRITE_COMMANDS.TRACK_EXPENSE))).toBeUndefined(); + expect(distanceCall?.[1]).toEqual( + expect.objectContaining({ + distanceRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_GPS, + }), + ); + expect(distanceCall?.[1]?.waypoints).toBeDefined(); + expect(distanceCall?.[1]?.waypoints).not.toBe('null'); + + let duplicatedTransaction: OnyxEntry; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (allTransactions) => { + duplicatedTransaction = Object.values(allTransactions ?? {}).find((t) => !!t); + }, + }); + + expect(duplicatedTransaction?.transactionID).not.toBe(transactionID); + expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE_GPS); + }); + it('should create a duplicate expense successfully (previously with transaction drafts)', async () => { const {waypoints, ...restOfComment} = mockTransaction.comment ?? {}; const mockCashExpenseTransaction = {