Skip to content
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7533,6 +7533,7 @@ const CONST = {
SPLIT: 'split',
DUPLICATE: 'duplicate',
},
BULK_DUPLICATE_LIMIT: 50,
TRANSACTION_TYPE: {
CASH: 'cash',
CARD: 'card',
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useBulkDuplicateAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ function useBulkDuplicateAction({selectedTransactionsKeys, allTransactions, allR
if (onAfterDuplicate) {
onAfterDuplicate();
} else {
clearSelectedTransactions(undefined, true);
clearSelectedTransactions();
}
};

Expand Down
24 changes: 21 additions & 3 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,17 @@ function shouldShowBulkDuplicateOption({

const report = reportID ? ((searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] as Report | undefined) ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]) : undefined;

if (isPerDiemRequest(transaction) && report?.policyID && defaultExpensePolicyID !== report.policyID) {
return false;
if (isPerDiemRequest(transaction)) {
const policyID = report?.policyID;
if (!policyID || defaultExpensePolicyID !== policyID) {
return false;
}
}

if (isDistanceRequest(transaction) && reportID) {
if (reportID === CONST.REPORT.UNREPORTED_REPORT_ID && activePolicyExpenseChat) {
return false;
}
const chatReportID = report?.chatReportID ?? report?.parentReportID;
const chatReport = chatReportID
? ((searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] as Report | undefined) ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`])
Expand Down Expand Up @@ -1326,12 +1332,24 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
}

if (isDuplicateOptionVisible) {
const exceedsBulkDuplicateLimit = selectedTransactionsKeys.length > CONST.SEARCH.BULK_DUPLICATE_LIMIT;
options.push({
text: translate('search.bulkActions.duplicateExpense', {count: selectedTransactionsKeys.length}),
icon: expensifyIcons.ExpenseCopy,
value: CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE,
shouldCloseModalOnSelect: true,
onSelected: invokeDuplicateHandler,
onSelected: () => {
if (exceedsBulkDuplicateLimit) {
showConfirmModal({
title: translate('common.duplicateExpense'),
prompt: translate('iou.bulkDuplicateLimit'),
confirmText: translate('common.buttonConfirm'),
shouldShowCancelButton: false,
});
return;
}
invokeDuplicateHandler();
},
});
}

Expand Down
16 changes: 15 additions & 1 deletion src/hooks/useSelectedTransactionsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import ROUTES from '@src/ROUTES';
import type {Route} from '@src/ROUTES';
import type {Policy, Report, ReportAction, Session, Transaction} from '@src/types/onyx';
import useAllTransactions from './useAllTransactions';
import useConfirmModal from './useConfirmModal';
import {useCurrencyListActions} from './useCurrencyList';
import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
import useDefaultExpensePolicy from './useDefaultExpensePolicy';
Expand Down Expand Up @@ -153,6 +154,7 @@ function useSelectedTransactionsActions({
const hasTransactionsFromMultipleOwners = hasUnknownOwner ? knownOwnerIDs.size > 0 || selectedTransactionIDs.length > 1 : knownOwnerIDs.size > 1;

const {translate, localeCompare} = useLocalize();
const {showConfirmModal} = useConfirmModal();
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);

const selectedTransactionsForDuplicate = useMemo(() => {
Expand Down Expand Up @@ -475,13 +477,25 @@ function useSelectedTransactionsActions({
}

if (isDuplicateOptionVisible) {
const exceedsBulkDuplicateLimit = selectedTransactionIDs.length > CONST.SEARCH.BULK_DUPLICATE_LIMIT;
// eslint-disable-next-line react-hooks/refs -- invokeDuplicateHandler reads a ref, but only at event-handler time (onSelected), never during render
options.push({
text: translate('search.bulkActions.duplicateExpense', {count: selectedTransactionIDs.length}),
icon: expensifyIcons.ExpenseCopy,
value: DUPLICATE,
shouldCloseModalOnSelect: true,
onSelected: invokeDuplicateHandler,
onSelected: () => {
if (exceedsBulkDuplicateLimit) {
showConfirmModal({
title: translate('common.duplicateExpense'),
prompt: translate('iou.bulkDuplicateLimit'),
confirmText: translate('common.buttonConfirm'),
shouldShowCancelButton: false,
});
return;
}
invokeDuplicateHandler();
},
});
}

Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1679,6 +1679,7 @@ const translations = {
},
duplicateNonDefaultWorkspacePerDiemError: "You can't duplicate per diem expenses across workspaces because the rates may differ between workspaces.",
cannotDuplicateDistanceExpense: "You can't duplicate distance expenses across workspaces because the rates may differ between workspaces.",
bulkDuplicateLimit: `You can duplicate up to ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} expenses at a time. Please select fewer expenses and try again.`,
taxDisabledAlert: {
title: 'Tax disabled',
prompt: 'Enable tax tracking on the workspace to edit the expense details or delete the tax from this expense.',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,7 @@ const translations: TranslationDeepObject<typeof en> = {
},
duplicateNonDefaultWorkspacePerDiemError: 'No puedes duplicar gastos de viáticos entre espacios de trabajo porque las tarifas pueden variar entre ellos.',
cannotDuplicateDistanceExpense: 'No puedes duplicar gastos de distancia entre espacios de trabajo porque las tasas pueden diferir entre espacios de trabajo.',
bulkDuplicateLimit: `Solo puedes duplicar hasta ${CONST.SEARCH.BULK_DUPLICATE_LIMIT} gastos a la vez. Por favor, selecciona menos gastos e inténtalo de nuevo.`,
taxDisabledAlert: {
title: 'Impuesto deshabilitado',
prompt: 'Habilita el seguimiento de impuestos en el espacio de trabajo para editar los detalles del gasto o eliminar el impuesto de este gasto.',
Expand Down
14 changes: 13 additions & 1 deletion src/libs/actions/IOU/Duplicate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ type DuplicateExpenseTransactionParams = {
targetPolicyTags: OnyxEntry<OnyxTypes.PolicyTagLists>;
shouldPlaySound?: boolean;
shouldDeferAutoSubmit?: boolean;
existingIOUReport?: OnyxEntry<OnyxTypes.Report>;
};

function duplicateExpenseTransaction({
Expand All @@ -684,6 +685,7 @@ function duplicateExpenseTransaction({
targetPolicyTags,
shouldPlaySound = true,
shouldDeferAutoSubmit = false,
existingIOUReport,
}: DuplicateExpenseTransactionParams) {
if (!transaction) {
return;
Expand All @@ -698,6 +700,7 @@ function duplicateExpenseTransaction({

const params: RequestMoneyInformation = {
report: targetReport,
existingIOUReport,
optimisticChatReportID,
optimisticCreatedReportActionID: NumberUtils.rand64(),
optimisticIOUReportID,
Expand Down Expand Up @@ -990,7 +993,11 @@ function bulkDuplicateExpenses({
// iterations must know its ID so getMoneyRequestInformation can find and
// MERGE into it instead of SET-overwriting it. We carry a local copy of
// targetReport whose iouReportID is patched after the first pass.
// We also pass the optimistic IOU report object directly via existingIOUReport
// to avoid a stale-state race: Onyx subscriber callbacks are deferred, so the
// module-level allReports in IOU/index.ts is not yet updated when iteration 2 runs.
let currentTargetReport = targetReport;
let optimisticIOUReport: OnyxEntry<OnyxTypes.Report>;

for (let i = 0; i < transactionsToDuplicate.length; i++) {
const item = transactionsToDuplicate.at(i);
Expand All @@ -1001,7 +1008,7 @@ function bulkDuplicateExpenses({
const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction);
const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined;

duplicateExpenseTransaction({
const result = duplicateExpenseTransaction({
transaction: item,
optimisticChatReportID,
optimisticIOUReportID,
Expand All @@ -1023,8 +1030,13 @@ function bulkDuplicateExpenses({
targetPolicyTags,
shouldPlaySound: false,
shouldDeferAutoSubmit: !isLastExpense,
existingIOUReport: optimisticIOUReport,
});

if (result?.iouReport) {
optimisticIOUReport = result.iouReport;
}

if (currentTargetReport && !currentTargetReport.iouReportID) {
currentTargetReport = {...currentTargetReport, iouReportID: optimisticIOUReportID};
}
Expand Down
10 changes: 9 additions & 1 deletion tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ describe('useSearchBulkActions - duplicate option', () => {

it('should show duplicate option for a Per Diem expense with dates', async () => {
const txnID = '1500';
const policyID = 'policy1';
mockDefaultExpensePolicy = {id: policyID, type: CONST.POLICY.TYPE.TEAM, name: 'Test WS'} as Policy;
const txn = {
...createRandomTransaction(1),
transactionID: txnID,
Expand All @@ -342,6 +344,7 @@ describe('useSearchBulkActions - duplicate option', () => {

mockSelectedTransactions = {[txnID]: makeSelectedTransaction()};
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}report1`, {reportID: 'report1', policyID});

const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));

Expand Down Expand Up @@ -369,6 +372,8 @@ describe('useSearchBulkActions - duplicate option', () => {
});

it('should show duplicate option for a mix of cash, Per Diem, and Distance expenses', async () => {
const policyID = 'policy1';
mockDefaultExpensePolicy = {id: policyID, type: CONST.POLICY.TYPE.TEAM, name: 'Test WS'} as Policy;
const cashTxn = {...createRandomTransaction(1), transactionID: '1700', managedCard: false};
const perDiemTxn = {
...createRandomTransaction(2),
Expand All @@ -393,6 +398,9 @@ describe('useSearchBulkActions - duplicate option', () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1700`, cashTxn);
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1701`, perDiemTxn);
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1702`, distanceTxn);
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}r1`, {reportID: 'r1', policyID});
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}r2`, {reportID: 'r2', policyID});
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}r3`, {reportID: 'r3', policyID});

const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));

Expand Down Expand Up @@ -496,7 +504,7 @@ describe('useSearchBulkActions - duplicate option', () => {
transactionIDs: expect.arrayContaining(['600', '601']),
}),
);
expect(mockClearSelectedTransactions).toHaveBeenCalledWith(undefined, true);
expect(mockClearSelectedTransactions).toHaveBeenCalled();
});

it('should pass defaultExpensePolicy as targetPolicy when available', async () => {
Expand Down
Loading