Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
30 changes: 28 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,35 @@ 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).
// Each sub-rate takes its price from the workspace, which is also what the server charges.
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: policySubRates.find((policySubRate) => policySubRate.id === subRate.id)?.rate ?? subRate.rate,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recompute report totals when refreshing per diem prices

When a workspace's sub-rate price has changed since the original expense was created, duplicating its report now creates the optimistic transaction at the new price but leaves the report totals at the old price. duplicateReport() calculates its running totals from the source transactions through buildDuplicateReportCurrencyAmounts(), and getPerDiemExpenseInformation() applies those new*Total overrides instead of accumulating the newly computed per diem amount. For example, duplicating a report containing one 50.00 per diem after its rate becomes 60.00 shows a 60.00 expense in a 50.00 report, persisting while offline. Update the report-total calculation to account for refreshed prices, and avoid reusing stored converted amounts when the underlying amount changes. This also preserves the offline optimistic-update behavior documented in AGENTS.md.

AGENTS.md reference: AGENTS.md:L97-L100

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(Neil's AI agent)

Agreed, this was new in this PR. duplicateReport sets the report total from the source expenses' amounts, so re-pricing a sub-rate that already had a price would make the copied expense and the report total disagree after a price change.

Fixed in 2a76dc2: a sub-rate keeps its own price, and only one without a price (Expensify Classic data) takes it from the workspace. Added the test per diem expense whose workspace price changed since it was created, which fails on the previous commit.

})),
};
const perDiemParams: PerDiemExpenseInformation = {
...params,
transactionParams: {
...(params.transactionParams ?? {}),
comment: transactionDetails?.comment ?? '',
customUnit: transaction.comment?.customUnit ?? {},
customUnit,
},
hasViolations: false,
customUnitPolicyID,
Expand Down
86 changes: 85 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,90 @@ 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('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