Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 29 additions & 2 deletions src/libs/actions/IOU/Duplicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {getExistingTransactionID} from '@libs/IOUUtils';
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import * as NumberUtils from '@libs/NumberUtils';
import Parser from '@libs/Parser';
import {canCreateExpensesOnPolicy, getReimbursementChoice, isInstantSubmitEnabled, isPolicyAccessible, isSubmitAndClose} from '@libs/PolicyUtils';
import {canCreateExpensesOnPolicy, getPerDiemRateCustomUnitRate, getReimbursementChoice, isInstantSubmitEnabled, isPolicyAccessible, isSubmitAndClose} from '@libs/PolicyUtils';
import {getIOUActionForReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils';
import {
buildOptimisticCreatedReportAction,
Expand Down Expand Up @@ -76,6 +76,9 @@ import signalExpenseAddedGrowl from './signalExpenseAddedGrowl';
import {createDistanceRequest} from './Split';
import {requestMoney, trackExpense} from './TrackExpense';

// Captures the date and the time of day from a per diem date saved by Expensify Classic, like 2026-07-31T05:30:00.000+02:00
const CLASSIC_PER_DIEM_DATE_REGEX = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}).*$/;

function getIOUActionForTransactions(
transactionIDList: Array<string | undefined>,
iouReportActions: OnyxEntry<OnyxTypes.ReportActions>,
Expand Down Expand Up @@ -775,12 +778,36 @@ function createExpenseByType({
return createDistanceRequest(distanceParams);
}
case CONST.SEARCH.TRANSACTION_TYPE.PER_DIEM: {
const sourceCustomUnit = transaction.comment?.customUnit ?? {};
const sourceDates = sourceCustomUnit.attributes?.dates;
const policySubRates = sourceCustomUnit.customUnitRateID ? (getPerDiemRateCustomUnitRate(params.policyParams?.policy, sourceCustomUnit.customUnitRateID)?.subRates ?? []) : [];

// Expensify Classic saves per diem dates with a time zone offset, like 2026-07-31T05:30:00.000+02:00, and saves sub-rates
// without their price. CreatePerDiemRequest rejects those dates, so the copy keeps the time the user entered (2026-07-31 05:30:00).
// A sub-rate without a price takes it from the workspace. One with a price keeps it, because duplicateReport sets
// the report total from the source expenses' amounts.
const customUnit = {
...sourceCustomUnit,
attributes: sourceDates
? {
...sourceCustomUnit.attributes,
dates: {
start: sourceDates.start.replace(CLASSIC_PER_DIEM_DATE_REGEX, '$1 $2'),
end: sourceDates.end.replace(CLASSIC_PER_DIEM_DATE_REGEX, '$1 $2'),
},
}
: sourceCustomUnit.attributes,
subRates: sourceCustomUnit.subRates?.map((subRate) => ({
...subRate,
rate: subRate.rate ?? policySubRates.find((policySubRate) => policySubRate.id === subRate.id)?.rate,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, based on the ts, subRate.rate can't be null or undefined
But this is just a small remark

})),
};
const perDiemParams: PerDiemExpenseInformation = {
...params,
transactionParams: {
...(params.transactionParams ?? {}),
comment: transactionDetails?.comment ?? '',
customUnit: transaction.comment?.customUnit ?? {},
customUnit,
},
hasViolations: false,
customUnitPolicyID,
Expand Down
165 changes: 164 additions & 1 deletion tests/actions/IOUTest/DuplicateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {OriginalMessageIOU, Policy, PolicyTagLists, RecentWaypoint, Report,
import type ReportAction from '@src/types/onyx/ReportAction';
import type {ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction';
import type Transaction from '@src/types/onyx/Transaction';
import type {TransactionCollectionDataSet} from '@src/types/onyx/Transaction';
import type {TransactionCollectionDataSet, TransactionCustomUnit} from '@src/types/onyx/Transaction';

import type {OnyxCollection, OnyxEntry, OnyxInputValue} from 'react-native-onyx';

Expand Down Expand Up @@ -2917,6 +2917,169 @@ describe('actions/Duplicate', () => {
expect(Navigation.dismissModal).not.toHaveBeenCalled();
});

it('per diem expense created in Expensify Classic', async () => {
// Given a workspace whose per diem rate prices its sub-rate at 50.00
const targetPolicy: Policy = {
...mockPolicy,
customUnits: {
perDiemUnit: {
customUnitID: 'perDiemUnit',
name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL,
rates: {
polandRate: {
customUnitRateID: 'polandRate',
name: 'Poland',
currency: 'PLN',
subRates: [{id: 'subrate-1', name: 'Full Day', rate: 5000}],
},
},
},
},
};

// And a per diem expense saved by Expensify Classic, which stores dates with a time zone offset and sub-rates without a price
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- The sub-rate type requires a price, but Expensify Classic data has none
const classicSubRates = [{key: '0', id: 'subrate-1', name: 'Full Day', quantity: 1}] as unknown as TransactionCustomUnit['subRates'];
const classicPerDiemTransaction = {
...mockTransaction,
amount: -5000,
iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM,
comment: {
type: 'customUnit' as const,
customUnit: {
name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL,
customUnitID: 'perDiemUnit',
customUnitRateID: 'polandRate',
subRates: classicSubRates,
attributes: {
dates: {
start: '2026-07-31T05:30:00.000+02:00',
end: '2026-07-31T20:00:00.000+02:00',
},
},
},
},
};

// When the expense is duplicated in New Expensify
duplicateExpenseTransaction({
isVendorMatchingBetaEnabled: false,
conciergeChat: undefined,
transaction: classicPerDiemTransaction,
optimisticChatReportID: mockOptimisticChatReportID,
optimisticIOUReportID: mockOptimisticIOUReportID,
isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled,
introSelected: undefined,
quickAction: undefined,
policyRecentlyUsedCurrencies: [],
isSelfTourViewed: false,
customUnitPolicyID: '',
targetPolicy,
targetPolicyCategories: fakePolicyCategories,
targetReport: policyExpenseChat,
existingTransactionDraft: undefined,
personalDetails: {},
recentWaypoints: [],
targetPolicyTags,
policyTagList: targetPolicyTags ?? {},
currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL},
currentUserLocalCurrency: undefined,
delegateAccountID: undefined,
isTrackIntentUser: false,
formatPhoneNumber,
getCurrencyDecimals: getCurrencyDecimalsLocal,
participantsPolicyTags: {},
rules: undefined,
});
await waitForBatchedUpdates();

// Then the server gets the dates in the format it accepts, keeping the time the user entered, so it doesn't reject the request
const writeCall = writeSpy.mock.calls.find(isWriteMockCallForCommand(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST));
expect(writeCall?.[1]).toEqual(expect.objectContaining({startDateTime: '2026-07-31 05:30:00', endDateTime: '2026-07-31 20:00:00'}));

// And the sub-rate carries the workspace's price, which Expensify Classic didn't save, so the copy isn't priced from a missing value
expect(JSON.parse(String(writeCall?.[1]?.subRates))).toEqual([expect.objectContaining({id: 'subrate-1', quantity: 1, rate: 5000})]);
});

it('per diem expense whose workspace price changed since it was created', async () => {
// Given a workspace whose per diem sub-rate now costs 60.00
const targetPolicy: Policy = {
...mockPolicy,
customUnits: {
perDiemUnit: {
customUnitID: 'perDiemUnit',
name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL,
rates: {
polandRate: {
customUnitRateID: 'polandRate',
name: 'Poland',
currency: 'PLN',
subRates: [{id: 'subrate-1', name: 'Full Day', rate: 6000}],
},
},
},
},
};

// And a per diem expense created in New Expensify when the sub-rate cost 50.00
const perDiemTransaction = {
...mockTransaction,
amount: -5000,
iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM,
comment: {
type: 'customUnit' as const,
customUnit: {
name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL,
customUnitID: 'perDiemUnit',
customUnitRateID: 'polandRate',
subRates: [{id: 'subrate-1', name: 'Full Day', quantity: 1, rate: 5000}],
attributes: {
dates: {
start: '2026-07-31 05:30:00',
end: '2026-07-31 20:00:00',
},
},
},
},
};

// When the expense is duplicated
duplicateExpenseTransaction({
isVendorMatchingBetaEnabled: false,
conciergeChat: undefined,
transaction: perDiemTransaction,
optimisticChatReportID: mockOptimisticChatReportID,
optimisticIOUReportID: mockOptimisticIOUReportID,
isASAPSubmitBetaEnabled: mockIsASAPSubmitBetaEnabled,
introSelected: undefined,
quickAction: undefined,
policyRecentlyUsedCurrencies: [],
isSelfTourViewed: false,
customUnitPolicyID: '',
targetPolicy,
targetPolicyCategories: fakePolicyCategories,
targetReport: policyExpenseChat,
existingTransactionDraft: undefined,
personalDetails: {},
recentWaypoints: [],
targetPolicyTags,
policyTagList: targetPolicyTags ?? {},
currentUser: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL},
currentUserLocalCurrency: undefined,
delegateAccountID: undefined,
isTrackIntentUser: false,
formatPhoneNumber,
getCurrencyDecimals: getCurrencyDecimalsLocal,
participantsPolicyTags: {},
rules: undefined,
});
await waitForBatchedUpdates();

// Then the sub-rate keeps its own price, because duplicating a report sets the report total from the source expenses' amounts
const writeCall = writeSpy.mock.calls.find(isWriteMockCallForCommand(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST));
expect(JSON.parse(String(writeCall?.[1]?.subRates))).toEqual([expect.objectContaining({id: 'subrate-1', rate: 5000})]);
});

it('should not pass linkedTrackedExpenseReportAction.childReportID as transactionThreadReportID to the API', async () => {
// Given a transaction with linkedTrackedExpenseReportAction set
// This simulates a split expense that was removed from a report, where the
Expand Down
Loading