Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9d92939
Duplicate GPS distance expenses as manual distance
x-dev90 Aug 10, 2026
232efe5
Test GPS distance duplication as manual distance
x-dev90 Aug 11, 2026
e701161
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 11, 2026
8afe67b
Fix missing parameters in GPS duplicate tests
x-dev90 Aug 11, 2026
30e26b7
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 12, 2026
ff453f8
Centralize GPS duplicate manual-distance rule
x-dev90 Aug 12, 2026
77e4e21
Add kilometer GPS duplication test coverage
x-dev90 Aug 12, 2026
631863f
Add non-GPS distance request type coverage
x-dev90 Aug 12, 2026
c19a892
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 20, 2026
96e84dc
Fix missing concierge chat in GPS duplicate tests
x-dev90 Aug 20, 2026
eb5925b
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 21, 2026
ce4db61
Clarify waypoint retention for duplicate expenses
x-dev90 Aug 21, 2026
ce0cc00
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 24, 2026
46fc4b3
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 25, 2026
6115dd7
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 26, 2026
fd2add0
Limit GPS manual-distance duplication to self-DM expenses
x-dev90 Aug 26, 2026
eab9ff3
Convert self-DM GPS duplicates to the current distance unit
x-dev90 Aug 27, 2026
1386431
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 27, 2026
ac47217
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 29, 2026
2ed72ce
Revert self-DM GPS duplicate distance unit conversion
x-dev90 Aug 29, 2026
e60d703
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Aug 31, 2026
6cc456a
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Sep 1, 2026
d4ecedd
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Sep 3, 2026
7b772b8
Fix self-DM GPS duplication when a target policy exists
x-dev90 Sep 3, 2026
89919af
Fix invalid policy ID fallback in GPS duplicate test
x-dev90 Sep 3, 2026
f29151d
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Sep 3, 2026
16f5bed
Merge branch 'main' into fix/gps-distance-duplicate-manual
x-dev90 Sep 5, 2026
ab86563
Narrow self-DM distance request type to GPS duplicates
x-dev90 Sep 5, 2026
9362f6e
Document GPS duplicate distance source
x-dev90 Sep 5, 2026
fdca57d
Follow boolean naming convention
x-dev90 Sep 5, 2026
766227c
Add coverage for workspace GPS duplicate behavior
x-dev90 Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 32 additions & 13 deletions src/libs/actions/IOU/Duplicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ import {
} from '@libs/ReportUtils';
import playSound, {SOUNDS} from '@libs/Sound';
import {
getDistanceRequestType,
getReimbursable,
getRequestType,
getTransactionType,
hasCustomUnitOutOfPolicyViolation,
isDistanceRequest,
isExpenseSplit,
isFromCreditCardImport,
isGPSDistanceRequest,
isOdometerDistanceRequest,
isPartialTransaction,
isPerDiemRequest,
Expand Down Expand Up @@ -603,13 +603,19 @@ function resolveDuplicates({
API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData});
}

function shouldDuplicateAsManualDistance(transaction: OnyxTypes.Transaction, shouldDuplicateSelfDMExpense: boolean) {
Comment thread
x-dev90 marked this conversation as resolved.
Resolved
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<typeof getTransactionDetails>) {
function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType<typeof getTransactionDetails>, 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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -698,13 +713,14 @@ function createExpenseByType({
}) {
switch (transactionType) {
case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: {
const duplicateRequestType = getDuplicateRequestType(transaction);
Comment thread
x-dev90 marked this conversation as resolved.
Resolved
const distanceParams: CreateDistanceRequestInformation = {
...params,
participants,
currentUserLogin: params.currentUserEmailParam,
currentUserAccountID: params.currentUserAccountIDParam,
existingTransaction: {
iouRequestType: getDuplicateRequestType(transaction),
iouRequestType: duplicateRequestType,
amount: 0,
currency: '',
created: '',
Expand All @@ -724,7 +740,7 @@ function createExpenseByType({
comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''),
validWaypoints: waypoints,
modifiedAmount: transactionDetails?.amount,
distanceRequestType: getDistanceRequestType(transaction),
distanceRequestType: duplicateRequestType,
},
policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
quickAction,
Expand Down Expand Up @@ -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,
Expand All @@ -861,7 +881,7 @@ function duplicateExpenseTransaction({
quickAction,
existingTransactionDraft,
existingTransaction: {
iouRequestType: getDuplicateRequestType(transaction),
iouRequestType: duplicateRequestType,
amount: 0,
currency: '',
created: '',
Expand All @@ -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,
Expand All @@ -890,7 +908,7 @@ function duplicateExpenseTransaction({
participant: {accountID: currentUserAccountID, selected: true},
},
existingTransaction: {
iouRequestType: getDuplicateRequestType(transaction),
iouRequestType: duplicateRequestType,
amount: 0,
currency: '',
created: '',
Expand All @@ -908,6 +926,7 @@ function duplicateExpenseTransaction({
transactionParams: {
...(params.transactionParams ?? {}),
validWaypoints: waypoints,
...(isGPSDistanceRequest(transaction) && {distanceRequestType: duplicateRequestType}),
},
report: undefined,
isDraftPolicy: false,
Expand Down
167 changes: 167 additions & 0 deletions tests/actions/IOUTest/DuplicateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Transaction>;
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<Transaction>;
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 = {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading