diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index eb886b0f9977..e165f17b087f 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -27,7 +27,6 @@ import { } from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import { - getDistanceRequestType, getReimbursable, getRequestType, getTransactionType, @@ -35,6 +34,7 @@ import { isDistanceRequest, isExpenseSplit, isFromCreditCardImport, + isGPSDistanceRequest, isOdometerDistanceRequest, isPartialTransaction, isPerDiemRequest, @@ -603,13 +603,19 @@ function resolveDuplicates({ API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData}); } +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 waypoints = !isExpenseSplit(transaction) ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; + const isManualDistanceDuplicate = shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense); + const shouldKeepWaypoints = !isExpenseSplit(transaction) && !isManualDistanceDuplicate; + const waypoints = shouldKeepWaypoints ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; const transactionParams = { ...transactionWithoutLinkedAction, @@ -641,7 +647,8 @@ 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) || 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; } @@ -651,10 +658,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 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); - 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 (shouldDuplicateAsManualDistance(transaction, shouldDuplicateSelfDMExpense)) { + return CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL; + } + return sourceRequestType; } /** @@ -698,13 +713,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: '', @@ -724,7 +740,7 @@ function createExpenseByType({ comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), validWaypoints: waypoints, modifiedAmount: transactionDetails?.amount, - distanceRequestType: getDistanceRequestType(transaction), + distanceRequestType: duplicateRequestType, }, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, @@ -834,7 +850,11 @@ function duplicateExpenseTransaction({ const participants = getMoneyRequestParticipantsFromReport(targetReport, currentUserAccountID); const transactionDetails = getTransactionDetails(transaction); - const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails); + // 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 || isSourceUnreported; + const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails, shouldDuplicateSelfDMExpense); + const duplicateRequestType = getDuplicateRequestType(transaction, shouldDuplicateSelfDMExpense); const params: RequestMoneyInformation = { report: targetReport, @@ -861,7 +881,7 @@ function duplicateExpenseTransaction({ quickAction, existingTransactionDraft, existingTransaction: { - iouRequestType: getDuplicateRequestType(transaction), + iouRequestType: duplicateRequestType, amount: 0, currency: '', created: '', @@ -880,8 +900,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, @@ -890,7 +908,7 @@ function duplicateExpenseTransaction({ participant: {accountID: currentUserAccountID, selected: true}, }, existingTransaction: { - iouRequestType: getDuplicateRequestType(transaction), + iouRequestType: duplicateRequestType, amount: 0, currency: '', created: '', @@ -908,6 +926,7 @@ function duplicateExpenseTransaction({ transactionParams: { ...(params.transactionParams ?? {}), validWaypoints: waypoints, + ...(isGPSDistanceRequest(transaction) && {distanceRequestType: duplicateRequestType}), }, report: undefined, isDraftPolicy: false, diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index dbc2462696c6..edb97d92a5ae 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -1940,6 +1940,168 @@ describe('actions/Duplicate', () => { expect(duplicatedTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.MANUAL); }); + 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: { + 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: targetPolicy?.id, + targetPolicy, + targetPolicyCategories: targetPolicy ? fakePolicyCategories : undefined, + targetReport: targetPolicy ? policyExpenseChat : 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, + getCurrencyDecimals: getCurrencyDecimalsLocal, + participantsPolicyTags: {}, + }); + + 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 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 = { @@ -3166,6 +3328,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 correctly route a report with mixed cash, distance, and per diem transactions', async () => {