diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b270d62582b08..949c29d795d73 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1227,7 +1227,7 @@ const CONST = { EXPORT: 'export', PAY: 'pay', MERGE: 'merge', - DUPLICATE: 'duplicate', + DUPLICATE_EXPENSE: 'duplicateExpense', DUPLICATE_REPORT: 'duplicateReport', MOVE_EXPENSE: 'moveExpense', }, diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index f5478ed1ca6a3..226f090c1b7f2 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -40,7 +40,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import useTransactionViolations from '@hooks/useTransactionViolations'; -import {duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; +import {duplicateReport as duplicateReportAction, duplicateExpenseTransaction as duplicateTransactionAction} from '@libs/actions/IOU/Duplicate'; import {openOldDotLink} from '@libs/actions/Link'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -237,7 +237,8 @@ function MoneyReportHeader({ | PlatformStackRouteProp | PlatformStackRouteProp >(); - const {login: currentUserLogin, accountID, email} = useCurrentUserPersonalDetails(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {login: currentUserLogin, accountID, email} = currentUserPersonalDetails; const personalDetails = usePersonalDetails(); const defaultExpensePolicy = useDefaultExpensePolicy(); const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id); @@ -421,7 +422,21 @@ function MoneyReportHeader({ const shouldShowSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); const dropdownMenuRef = useRef(null); + const wasDuplicateReportTriggered = useRef(false); + + const handleOptionsMenuHide = useCallback(() => { + wasDuplicateReportTriggered.current = false; + }, []); + + useEffect(() => { + if (!isDuplicateReportActive || !wasDuplicateReportTriggered.current) { + return; + } + wasDuplicateReportTriggered.current = false; + dropdownMenuRef.current?.setIsMenuVisible(false); + }, [isDuplicateReportActive]); const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [paymentType, setPaymentType] = useState(); @@ -1867,11 +1882,11 @@ function MoneyReportHeader({ setupMergeTransactionDataAndNavigate(currentTransaction.transactionID, [currentTransaction], localeCompare); }, }, - [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE]: { + [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE]: { text: isDuplicateActive ? translate('common.duplicateExpense') : translate('common.duplicated'), icon: isDuplicateActive ? expensifyIcons.ExpenseCopy : expensifyIcons.Checkmark, iconFill: isDuplicateActive ? undefined : theme.icon, - value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE, + value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE, onSelected: () => { if (hasCustomUnitOutOfPolicyViolation) { setRateErrorModalVisible(true); @@ -1899,15 +1914,49 @@ function MoneyReportHeader({ shouldCloseModalOnSelect: shouldDuplicateCloseModalOnSelect, }, [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT]: { - text: translate('common.duplicateReport'), - icon: expensifyIcons.ReportCopy, + text: isDuplicateReportActive ? translate('common.duplicateReport') : translate('common.duplicated'), + icon: isDuplicateReportActive ? expensifyIcons.ReportCopy : expensifyIcons.Checkmark, + iconFill: isDuplicateReportActive ? undefined : theme.icon, value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_REPORT, sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.DUPLICATE_REPORT, - // To be implemented in https://github.com/Expensify/App/issues/82153 - // onSelected: () => { - // }, - // Remove after implementation - shouldShow: false, + shouldShow: !!defaultExpensePolicy, + shouldCloseModalOnSelect: false, + onSelected: () => { + if (!isDuplicateReportActive) { + return; + } + + temporarilyDisableDuplicateReportAction(); + wasDuplicateReportTriggered.current = true; + + const targetPolicyForDuplicate = policy ?? defaultExpensePolicy; + const targetChatForDuplicate = policy ? chatReport : activePolicyExpenseChat; + const activePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicyForDuplicate?.id}`] ?? {}; + + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + duplicateReportAction({ + sourceReport: moneyRequestReport, + sourceReportTransactions: nonPendingDeleteTransactions, + sourceReportName: moneyRequestReport?.reportName ?? '', + targetPolicy: targetPolicyForDuplicate ?? undefined, + targetPolicyCategories: activePolicyCategories, + targetPolicyTags: allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyForDuplicate?.id}`] ?? {}, + parentChatReport: targetChatForDuplicate, + ownerPersonalDetails: currentUserPersonalDetails, + isASAPSubmitBetaEnabled, + betas, + personalDetails, + quickAction, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + draftTransactionIDs, + isSelfTourViewed, + transactionViolations: allTransactionViolations, + translate, + recentWaypoints: recentWaypoints ?? [], + }); + }); + }, }, [CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE]: { text: translate('iou.changeWorkspace'), @@ -2416,6 +2465,7 @@ function MoneyReportHeader({ primaryAction={primaryAction} applicableSecondaryActions={applicableSecondaryActions} dropdownMenuRef={dropdownMenuRef} + onOptionsMenuHide={handleOptionsMenuHide} ref={kycWallRef} /> )} @@ -2438,6 +2488,7 @@ function MoneyReportHeader({ primaryAction={primaryAction} applicableSecondaryActions={applicableSecondaryActions} dropdownMenuRef={dropdownMenuRef} + onOptionsMenuHide={handleOptionsMenuHide} ref={kycWallRef} /> )} diff --git a/src/components/MoneyReportHeaderKYCDropdown.tsx b/src/components/MoneyReportHeaderKYCDropdown.tsx index 9d43ea4cff44d..c104bd759351d 100644 --- a/src/components/MoneyReportHeaderKYCDropdown.tsx +++ b/src/components/MoneyReportHeaderKYCDropdown.tsx @@ -27,6 +27,9 @@ type MoneyReportHeaderKYCDropdownProps = Omit; + + /** Callback fired when the dropdown menu hides */ + onOptionsMenuHide?: () => void; }; function MoneyReportHeaderKYCDropdown({ @@ -39,6 +42,7 @@ function MoneyReportHeaderKYCDropdown({ customText, shouldShowSuccessStyle, dropdownMenuRef, + onOptionsMenuHide, ref, ...props }: MoneyReportHeaderKYCDropdownProps) { @@ -84,6 +88,7 @@ function MoneyReportHeaderKYCDropdown({ isSplitButton={false} wrapperStyle={shouldDisplayNarrowVersion && [!primaryAction && !customText && styles.flex1, !!customText && styles.w100]} shouldUseModalPaddingStyle + onOptionsMenuHide={onOptionsMenuHide} sentryLabel={CONST.SENTRY_LABEL.MORE_MENU.MORE_BUTTON} /> )} diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 873479b5ea7a7..5ec69efdab499 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -534,11 +534,11 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre setupMergeTransactionDataAndNavigate(transaction.transactionID, [transaction], localeCompare, [], false, isOnSearch); }, }, - [CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE]: { + [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.DUPLICATE]: { text: isDuplicateActive ? translate('common.duplicateExpense') : translate('common.duplicated'), icon: isDuplicateActive ? expensifyIcons.ExpenseCopy : expensifyIcons.Checkmark, iconFill: isDuplicateActive ? undefined : theme.icon, - value: CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE, + value: CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.DUPLICATE, onSelected: () => { if (hasCustomUnitOutOfPolicyViolation) { showConfirmModal({ diff --git a/src/languages/de.ts b/src/languages/de.ts index 58a4a05d93cba..8db0a9e9c03d3 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -522,6 +522,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Hallo, wie kann ich helfen?', showHistory: 'Verlauf anzeigen'}, duplicateReport: 'Duplizierten Bericht', approver: 'Genehmiger', + copyOfReportName: (reportName: string) => `Kopie von ${reportName}`, }, socials: { podcast: 'Folgen Sie uns auf Podcast', diff --git a/src/languages/en.ts b/src/languages/en.ts index e458b4da96ccf..e661970db171d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -527,6 +527,7 @@ const translations = { duplicated: 'Duplicated', duplicateExpense: 'Duplicate expense', duplicateReport: 'Duplicate report', + copyOfReportName: (reportName: string) => `Copy of ${reportName}`, exchangeRate: 'Exchange rate', reimbursableTotal: 'Reimbursable total', nonReimbursableTotal: 'Non-reimbursable total', diff --git a/src/languages/es.ts b/src/languages/es.ts index 216f041bfadc1..646c9349b5530 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -414,6 +414,7 @@ const translations: TranslationDeepObject = { duplicated: 'Duplicado', duplicateExpense: 'Duplicar gasto', duplicateReport: 'Duplicar informe', + copyOfReportName: (reportName: string) => `Copia de ${reportName}`, exchangeRate: 'Tipo de cambio', reimbursableTotal: 'Total reembolsable', nonReimbursableTotal: 'Total no reembolsable', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index f243ac232122a..1102ba0985cca 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -522,6 +522,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Bonjour, comment puis-je vous aider ?', showHistory: 'Afficher l’historique'}, duplicateReport: 'Note de frais en double', approver: 'Approbateur', + copyOfReportName: (reportName: string) => `Copie de ${reportName}`, }, socials: { podcast: 'Suivez-nous sur Podcast', diff --git a/src/languages/it.ts b/src/languages/it.ts index e33cdb2c20859..a3c77aa8611ee 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -522,6 +522,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Ciao, come posso aiutarti?', showHistory: 'Mostra cronologia'}, duplicateReport: 'Report duplicato', approver: 'Approvante', + copyOfReportName: (reportName: string) => `Copia di ${reportName}`, }, socials: { podcast: 'Seguici su Podcast', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c92c109d2ae63..19e743a0dedbf 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -521,6 +521,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'こんにちは、どのようにお手伝いできますか?', showHistory: '履歴を表示'}, duplicateReport: 'レポートを複製', approver: '承認者', + copyOfReportName: (reportName: string) => `${reportName} のコピー`, }, socials: { podcast: 'ポッドキャストでフォロー', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0b5bda64feb8d..b66c09fbfd4a5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -521,6 +521,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Hoi, waarmee kan ik je helpen?', showHistory: 'Geschiedenis weergeven'}, duplicateReport: 'Dubbel rapport', approver: 'Fiatteur', + copyOfReportName: (reportName: string) => `Kopie van ${reportName}`, }, socials: { podcast: 'Volg ons op Podcast', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 085c3541ae136..4cce772fe7c33 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -521,6 +521,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Cześć, w czym mogę pomóc?', showHistory: 'Pokaż historię'}, duplicateReport: 'Zduplikowany raport', approver: 'Osoba zatwierdzająca', + copyOfReportName: (reportName: string) => `Kopia raportu ${reportName}`, }, socials: { podcast: 'Śledź nas na Podcast', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4f4203ed8c823..f8f0a5342768c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -520,6 +520,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: 'Oi, como posso ajudar?', showHistory: 'Mostrar histórico'}, duplicateReport: 'Duplicar relatório', approver: 'Aprovador', + copyOfReportName: (reportName: string) => `Cópia de ${reportName}`, }, socials: { podcast: 'Siga-nos no Podcast', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index ea1435b7645b5..9f7af146120ee 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -517,6 +517,7 @@ const translations: TranslationDeepObject = { concierge: {sidePanelGreeting: '你好,我能帮你做什么?', showHistory: '显示历史'}, duplicateReport: '重复报销单', approver: '审批人', + copyOfReportName: (reportName: string) => `${reportName} 的副本`, }, socials: { podcast: '在播客上关注我们', diff --git a/src/libs/API/parameters/CreateAppReportParams.ts b/src/libs/API/parameters/CreateAppReportParams.ts index de9e533b86d28..b5776ca3a4018 100644 --- a/src/libs/API/parameters/CreateAppReportParams.ts +++ b/src/libs/API/parameters/CreateAppReportParams.ts @@ -6,5 +6,6 @@ type CreateAppReportParams = { reportPreviewReportActionID: string; ownerEmail?: string; shouldDismissEmptyReportsConfirmation?: boolean; + reportName?: string; }; export default CreateAppReportParams; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index ba1866aad9153..7c9c62152fcd2 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -959,7 +959,7 @@ function getSecondaryReportActions({ } if (isDuplicateAction(report, reportTransactions)) { - options.push(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE); + options.push(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE); } if (isDuplicateReportAction(report)) { diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index bc2feddfbb4e8..c71c8932a2aa6 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -1,7 +1,8 @@ import {format} from 'date-fns'; -import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {PartialDeep} from 'type-fest'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import * as API from '@libs/API'; import type {MergeDuplicatesParams, ResolveDuplicatesParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -18,11 +19,25 @@ import { buildTransactionThread, getTransactionDetails, } from '@libs/ReportUtils'; -import {getRequestType, getTransactionType, isDistanceRequest, isExpenseSplit, isOdometerDistanceRequest} from '@libs/TransactionUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; +import { + getRequestType, + getTransactionType, + hasCustomUnitOutOfPolicyViolation, + isDistanceRequest, + isExpenseSplit, + isFromCreditCardImport, + isOdometerDistanceRequest, + isPartialTransaction, + isPerDiemRequest, + isScanning, +} from '@libs/TransactionUtils'; +import {createNewReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import type {Attendee} from '@src/types/onyx/IOU'; +import type {Attendee, Participant} from '@src/types/onyx/IOU'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type {CreateDistanceRequestInformation, CreateTrackExpenseParams, RequestMoneyInformation} from '.'; import { @@ -478,6 +493,130 @@ function resolveDuplicates(params: MergeDuplicatesParams) { API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData}); } +/** + * Builds the transactionParams object and computes waypoints used when duplicating a transaction. + * Shared between duplicateExpenseTransaction and duplicateReport. + */ +function buildDuplicateTransactionParams(transaction: OnyxTypes.Transaction, transactionDetails: ReturnType) { + const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; + const waypoints = !isExpenseSplit(transaction) ? (transactionDetails?.waypoints as WaypointCollection | undefined) : undefined; + + const transactionParams = { + ...transactionWithoutLinkedAction, + ...transactionDetails, + amount: Math.abs(transactionDetails?.amount ?? 0), + taxAmount: Math.abs(transactionDetails?.taxAmount ?? 0), + convertedAmount: undefined, + originalAmount: undefined, + actionableWhisperReportActionID: undefined, + attendees: transactionDetails?.attendees as Attendee[] | undefined, + comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + customUnitRateID: transaction.comment?.customUnit?.customUnitRateID, + isFromGlobalCreate: undefined, + isLinkedTrackedExpenseReportArchived: undefined, + isTestDrive: transaction.receipt?.isTestDriveReceipt, + linkedTrackedExpenseReportID: undefined, + merchant: transaction.modifiedMerchant ? transaction.modifiedMerchant : (transaction.merchant ?? ''), + modifiedAmount: undefined, + originalTransactionID: undefined, + odometerStart: transaction.comment?.odometerStart ?? undefined, + odometerEnd: transaction.comment?.odometerEnd ?? undefined, + receipt: undefined, + source: undefined, + waypoints, + type: transaction.comment?.type, + count: transaction.comment?.units?.count, + rate: transaction.comment?.units?.rate, + unit: transaction.comment?.units?.unit, + }; + + if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction))) { + transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; + } + + return {transactionParams, waypoints}; +} + +/** + * Routes a duplicate expense to the correct creation function based on transaction type. + * Shared between duplicateExpenseTransaction and duplicateReport. + */ +function createExpenseByType({ + transactionType, + params, + transaction, + transactionDetails, + waypoints, + participants, + policyRecentlyUsedCurrencies, + quickAction, + customUnitPolicyID, + personalDetails, + recentWaypoints, +}: { + transactionType: string; + params: RequestMoneyInformation; + transaction: OnyxTypes.Transaction; + transactionDetails: ReturnType; + waypoints: WaypointCollection | undefined; + participants: Participant[]; + policyRecentlyUsedCurrencies: string[]; + quickAction: OnyxEntry; + customUnitPolicyID?: string; + personalDetails: OnyxEntry; + recentWaypoints: OnyxEntry; +}) { + switch (transactionType) { + case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: { + const distanceParams: CreateDistanceRequestInformation = { + ...params, + participants, + existingTransaction: { + ...(params.transactionParams ?? {}), + comment: { + ...transaction.comment, + originalTransactionID: undefined, + source: undefined, + waypoints, + }, + iouRequestType: getRequestType(transaction), + modifiedCreated: '', + reportID: '1', + transactionID: '1', + }, + transactionParams: { + ...(params.transactionParams ?? {}), + comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), + validWaypoints: waypoints, + modifiedAmount: transactionDetails?.amount, + }, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + quickAction, + customUnitPolicyID, + personalDetails, + recentWaypoints, + }; + return createDistanceRequest(distanceParams); + } + case CONST.SEARCH.TRANSACTION_TYPE.PER_DIEM: { + const perDiemParams: PerDiemExpenseInformation = { + ...params, + transactionParams: { + ...(params.transactionParams ?? {}), + comment: transactionDetails?.comment ?? '', + customUnit: transaction.comment?.customUnit ?? {}, + }, + hasViolations: false, + customUnitPolicyID, + }; + return submitPerDiemExpense(perDiemParams); + } + default: + return requestMoney(params); + } +} + type DuplicateExpenseTransactionParams = { transaction: OnyxEntry; optimisticChatReportID: string; @@ -530,15 +669,7 @@ function duplicateExpenseTransaction({ const participants = getMoneyRequestParticipantsFromReport(targetReport, userAccountID); const transactionDetails = getTransactionDetails(transaction); - - // Exclude linkedTrackedExpenseReportAction from the original transaction to avoid reportID collisions - // when duplicating split expenses that were removed from a report. linkedTrackedExpenseReportAction.childReportID - // gets used as existingTransactionThreadReportID in getMoneyRequestInformation, which would cause the backend - // to try to create a transaction thread report with an ID that already exists. - const {linkedTrackedExpenseReportAction, ...transactionWithoutLinkedAction} = transaction; - - // We remove waypoints for split distance expenses in order to preserve the split's amount and distance. - const waypoints = !isExpenseSplit(transaction) ? (transactionDetails?.waypoints as WaypointCollection) : undefined; + const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails); const params: RequestMoneyInformation = { report: targetReport, @@ -553,27 +684,7 @@ function duplicateExpenseTransaction({ }, gpsPoint: undefined, action: CONST.IOU.ACTION.CREATE, - transactionParams: { - ...transactionWithoutLinkedAction, - ...transactionDetails, - attendees: transactionDetails?.attendees as Attendee[] | undefined, - comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), - created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), - customUnitRateID: transaction?.comment?.customUnit?.customUnitRateID, - isTestDrive: transaction?.receipt?.isTestDriveReceipt, - merchant: transaction?.modifiedMerchant ? transaction.modifiedMerchant : (transaction?.merchant ?? ''), - modifiedAmount: undefined, - originalTransactionID: undefined, - odometerStart: transaction?.comment?.odometerStart ?? undefined, - odometerEnd: transaction?.comment?.odometerEnd ?? undefined, - receipt: undefined, - source: undefined, - waypoints, - type: transaction?.comment?.type, - count: transaction?.comment?.units?.count, - rate: transaction?.comment?.units?.rate, - unit: transaction?.comment?.units?.unit, - }, + transactionParams, shouldHandleNavigation: false, shouldGenerateTransactionThreadReport: true, isASAPSubmitBetaEnabled, @@ -589,12 +700,6 @@ function duplicateExpenseTransaction({ personalDetails, }; - // We remove waypoints for split distance expenses, so we have to re-add the distance param here. - // Odometer expenses don't have the distance parameter so we also need to pass it here. - if (isDistanceRequest(transaction) && (isExpenseSplit(transaction) || isOdometerDistanceRequest(transaction))) { - params.transactionParams.distance = transaction.comment?.customUnit?.quantity ?? undefined; - } - // If no workspace is provided the expense should be unreported if (!targetPolicy) { const trackExpenseParams: CreateTrackExpenseParams = { @@ -639,57 +744,164 @@ function duplicateExpenseTransaction({ policyCategories: targetPolicyCategories ?? {}, }; - const transactionType = getTransactionType(transaction); + return createExpenseByType({ + transactionType: getTransactionType(transaction), + params, + transaction, + transactionDetails, + waypoints, + participants, + policyRecentlyUsedCurrencies, + quickAction, + customUnitPolicyID, + personalDetails, + recentWaypoints, + }); +} - switch (transactionType) { - case CONST.SEARCH.TRANSACTION_TYPE.DISTANCE: { - const distanceParams: CreateDistanceRequestInformation = { - ...params, - participants, - existingTransaction: { - ...(params.transactionParams ?? {}), - comment: { - ...transaction.comment, - originalTransactionID: undefined, - source: undefined, - waypoints, - }, - iouRequestType: getRequestType(transaction), - modifiedCreated: '', - reportID: '1', - transactionID: '1', - }, - transactionParams: { - ...(params.transactionParams ?? {}), - comment: Parser.htmlToMarkdown(transactionDetails?.comment ?? ''), - validWaypoints: waypoints, - modifiedAmount: transactionDetails?.amount, - }, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - quickAction, - customUnitPolicyID, - personalDetails, - recentWaypoints, - }; - return createDistanceRequest(distanceParams); +type DuplicateReportParams = { + sourceReport: OnyxEntry; + sourceReportTransactions: OnyxTypes.Transaction[]; + sourceReportName: string; + targetPolicy: OnyxEntry; + targetPolicyCategories: OnyxEntry; + targetPolicyTags: OnyxEntry; + parentChatReport: OnyxEntry; + ownerPersonalDetails: CurrentUserPersonalDetails; + isASAPSubmitBetaEnabled: boolean; + betas: OnyxEntry; + personalDetails: OnyxEntry; + quickAction: OnyxEntry; + policyRecentlyUsedCurrencies: string[]; + draftTransactionIDs: string[]; + isSelfTourViewed: boolean; + transactionViolations: OnyxCollection; + translate: LocalizedTranslate; + recentWaypoints: OnyxEntry; +}; + +function duplicateReport({ + sourceReport, + sourceReportTransactions, + sourceReportName, + targetPolicy, + targetPolicyCategories, + targetPolicyTags, + parentChatReport, + ownerPersonalDetails, + isASAPSubmitBetaEnabled, + betas, + personalDetails, + quickAction, + policyRecentlyUsedCurrencies, + draftTransactionIDs, + isSelfTourViewed, + transactionViolations, + translate, + recentWaypoints, +}: DuplicateReportParams) { + if (!targetPolicy || !parentChatReport) { + return; + } + + const userAccountID = getUserAccountID(); + const currentUserEmailValue = getCurrentUserEmail(); + + const newReportName = translate('common.copyOfReportName', sourceReportName); + const {reportPreviewReportActionID, ...newReport} = createNewReport(ownerPersonalDetails, false, isASAPSubmitBetaEnabled, targetPolicy, betas, false, undefined, newReportName); + + const isCrossWorkspace = !!sourceReport && sourceReport.policyID !== targetPolicy.id; + + const eligibleTransactions = sourceReportTransactions.filter((transaction) => { + if (isFromCreditCardImport(transaction)) { + return false; } - case CONST.SEARCH.TRANSACTION_TYPE.PER_DIEM: { - const perDiemParams: PerDiemExpenseInformation = { - ...params, - transactionParams: { - ...(params.transactionParams ?? {}), - comment: transactionDetails?.comment ?? '', - customUnit: transaction?.comment?.customUnit ?? {}, - }, - hasViolations: false, - customUnitPolicyID, - }; - return submitPerDiemExpense(perDiemParams); + if (transaction.accountant) { + return false; + } + if (isPartialTransaction(transaction) || isScanning(transaction)) { + return false; + } + const txnViolations = transactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; + if (hasCustomUnitOutOfPolicyViolation(txnViolations)) { + return false; + } + if (isCrossWorkspace && (isPerDiemRequest(transaction) || isDistanceRequest(transaction))) { + return false; + } + return true; + }); + + const participants = getMoneyRequestParticipantsFromReport(parentChatReport, userAccountID); + + const policyParams = targetPolicy + ? { + policy: targetPolicy, + policyTagList: targetPolicyTags, + policyCategories: targetPolicyCategories ?? {}, + } + : undefined; + + let currentIOUReport = newReport as OnyxEntry; + + for (const transaction of eligibleTransactions) { + const transactionDetails = getTransactionDetails(transaction); + if (!transactionDetails) { + continue; + } + + const {transactionParams, waypoints} = buildDuplicateTransactionParams(transaction, transactionDetails); + + const params: RequestMoneyInformation = { + report: parentChatReport, + existingIOUReport: currentIOUReport, + optimisticReportPreviewActionID: reportPreviewReportActionID, + participantParams: { + payeeAccountID: userAccountID, + payeeEmail: currentUserEmailValue, + participant: participants.at(0) ?? {}, + }, + policyParams, + gpsPoint: undefined, + action: CONST.IOU.ACTION.CREATE, + transactionParams, + shouldHandleNavigation: false, + shouldPlaySound: false, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled, + currentUserAccountIDParam: userAccountID, + currentUserEmailParam: currentUserEmailValue, + transactionViolations: transactionViolations ?? {}, + quickAction, + policyRecentlyUsedCurrencies, + existingTransactionDraft: undefined, + draftTransactionIDs, + isSelfTourViewed, + betas, + personalDetails, + }; + + const result = createExpenseByType({ + transactionType: getTransactionType(transaction), + params, + transaction, + transactionDetails, + waypoints, + participants, + policyRecentlyUsedCurrencies, + quickAction, + customUnitPolicyID: targetPolicy?.id, + personalDetails, + recentWaypoints, + }); + + if (result?.iouReport) { + currentIOUReport = result.iouReport; } - default: - return requestMoney(params); } + + playSound(SOUNDS.DONE); } -export {getIOUActionForTransactions, mergeDuplicates, resolveDuplicates, duplicateExpenseTransaction}; -export type {DuplicateExpenseTransactionParams}; +export {getIOUActionForTransactions, mergeDuplicates, resolveDuplicates, duplicateExpenseTransaction, duplicateReport}; +export type {DuplicateExpenseTransactionParams, DuplicateReportParams}; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 18bb428c98b2b..3eb3d4983d251 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -235,6 +235,7 @@ type PerDiemExpenseInformation = { policyParams?: BasePolicyParams; recentlyUsedParams?: RecentlyUsedParams; transactionParams: PerDiemExpenseTransactionParams; + existingIOUReport?: OnyxEntry; isASAPSubmitBetaEnabled: boolean; currentUserAccountIDParam: number; currentUserEmailParam: string; @@ -245,6 +246,8 @@ type PerDiemExpenseInformation = { customUnitPolicyID?: string; personalDetails: OnyxEntry; shouldHandleNavigation?: boolean; + shouldPlaySound?: boolean; + optimisticReportPreviewActionID?: string; }; type PerDiemExpenseInformationParams = { @@ -253,6 +256,7 @@ type PerDiemExpenseInformationParams = { participantParams: RequestMoneyParticipantParams; policyParams?: BasePolicyParams; recentlyUsedParams?: RecentlyUsedParams; + existingIOUReport?: OnyxEntry; moneyRequestReportID?: string; isASAPSubmitBetaEnabled: boolean; currentUserAccountIDParam: number; @@ -261,6 +265,7 @@ type PerDiemExpenseInformationParams = { quickAction: OnyxEntry; policyRecentlyUsedCurrencies: string[]; betas: OnyxEntry; + optimisticReportPreviewActionID?: string; personalDetails: OnyxEntry; }; @@ -299,6 +304,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI participantParams, policyParams = {}, recentlyUsedParams = {}, + existingIOUReport: existingIOUReportParam, moneyRequestReportID = '', isASAPSubmitBetaEnabled, currentUserAccountIDParam, @@ -307,6 +313,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI quickAction, policyRecentlyUsedCurrencies, betas, + optimisticReportPreviewActionID, personalDetails, } = perDiemExpenseInformation; const {payeeAccountID = getUserAccountID(), payeeEmail = getCurrentUserEmail(), participant} = participantParams; @@ -348,10 +355,12 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI }); } - // STEP 2: Get the Expense/IOU report. If the moneyRequestReportID has been provided, we want to add the transaction to this specific report. + // STEP 2: Get the Expense/IOU report. If the existingIOUReport or moneyRequestReportID has been provided, we want to add the transaction to this specific report. // If no such reportID has been provided, let's use the chatReport.iouReportID property. In case that is not present, build a new optimistic Expense/IOU report. let iouReport: OnyxInputValue = null; - if (moneyRequestReportID) { + if (existingIOUReportParam) { + iouReport = existingIOUReportParam; + } else if (moneyRequestReportID) { iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`] ?? null; } else { iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null; @@ -454,7 +463,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI if (reportPreviewAction) { reportPreviewAction = updateReportPreview(iouReport, reportPreviewAction, false, comment, optimisticTransaction); } else { - reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction); + reportPreviewAction = buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction, undefined, optimisticReportPreviewActionID); chatReport.lastVisibleActionCreated = reportPreviewAction.created; // Generated ReportPreview action is a parent report action of the iou report. @@ -881,6 +890,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf policyParams = {}, recentlyUsedParams = {}, transactionParams, + existingIOUReport, isASAPSubmitBetaEnabled, currentUserAccountIDParam, currentUserEmailParam, @@ -891,6 +901,8 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf customUnitPolicyID, personalDetails, shouldHandleNavigation = true, + shouldPlaySound: shouldPlaySoundParam = true, + optimisticReportPreviewActionID, } = submitPerDiemExpenseInformation; const {payeeAccountID} = participantParams; const {currency, comment = '', category, tag, created, customUnit, attendees, isFromGlobalCreate} = transactionParams; @@ -930,6 +942,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf policyParams, recentlyUsedParams, transactionParams, + existingIOUReport, moneyRequestReportID, isASAPSubmitBetaEnabled, currentUserAccountIDParam, @@ -938,6 +951,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf quickAction, policyRecentlyUsedCurrencies, betas, + optimisticReportPreviewActionID, personalDetails, }); @@ -978,7 +992,9 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf customUnitPolicyID, }; - playSound(SOUNDS.DONE); + if (shouldPlaySoundParam) { + playSound(SOUNDS.DONE); + } API.write(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST, parameters, onyxData); // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -988,6 +1004,8 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf if (activeReportID) { notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam); } + + return {iouReport}; } /** diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 47d72fd6e80e0..55b5a105bd5e5 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -631,6 +631,7 @@ type CreateDistanceRequestInformation = { currentUserLogin?: string; currentUserAccountID?: number; iouType?: ValueOf; + existingIOUReport?: OnyxEntry; existingTransaction?: OnyxEntry; transactionParams: DistanceRequestTransactionParams; policyParams?: BasePolicyParams; @@ -642,8 +643,10 @@ type CreateDistanceRequestInformation = { recentWaypoints: OnyxEntry; customUnitPolicyID?: string; shouldHandleNavigation?: boolean; + shouldPlaySound?: boolean; personalDetails: OnyxEntry; betas: OnyxEntry; + optimisticReportPreviewActionID?: string; }; type CreateSplitsTransactionParams = Omit & { @@ -7567,6 +7570,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest currentUserLogin = '', currentUserAccountID = -1, iouType = CONST.IOU.TYPE.SUBMIT, + existingIOUReport, existingTransaction, transactionParams, policyParams = {}, @@ -7578,8 +7582,10 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest recentWaypoints = [], customUnitPolicyID, shouldHandleNavigation = true, + shouldPlaySound: shouldPlaySoundParam = true, personalDetails, betas, + optimisticReportPreviewActionID, } = distanceRequestInformation; const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; const parsedComment = getParsedComment(transactionParams.comment); @@ -7624,6 +7630,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest let parameters: CreateDistanceRequestParams; let onyxData: OnyxData; + let distanceIouReport: OnyxInputValue = null; const sanitizedWaypoints = !isManualDistanceRequest ? sanitizeWaypointsForAPI(validWaypoints) : null; if (iouType === CONST.IOU.TYPE.SPLIT) { const { @@ -7703,6 +7710,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest onyxData: moneyRequestOnyxData, } = getMoneyRequestInformation({ parentChatReport: currentChatReport, + existingIOUReport, existingTransaction, moneyRequestReportID, participantParams: { @@ -7744,9 +7752,11 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest policyRecentlyUsedCurrencies, personalDetails, betas, + optimisticReportPreviewActionID, }); onyxData = moneyRequestOnyxData; + distanceIouReport = iouReport; const isGPSDistanceRequest = transaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_GPS; @@ -7812,7 +7822,9 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest value: recentServerValidatedWaypoints, }); - playSound(SOUNDS.DONE); + if (shouldPlaySoundParam) { + playSound(SOUNDS.DONE); + } API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData); // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -7826,6 +7838,8 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest if (!isMoneyRequestReport) { notifyNewAction(activeReportID, undefined, true); } + + return {iouReport: distanceIouReport}; } type UpdateMoneyRequestAmountAndCurrencyParams = { diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index e60ebb5bebb82..eba0cc42031b3 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -3440,12 +3440,17 @@ function buildNewReportOptimisticData( hasViolationsParam: boolean, isASAPSubmitBetaEnabled: boolean, betas: OnyxEntry, + reportName?: string, ) { const {accountID, login, email} = ownerPersonalDetails; const timeOfCreation = DateUtils.getDBTime(); const parentReport = getPolicyExpenseChat(accountID, policy?.id); const optimisticReportData = buildOptimisticEmptyReport(reportID, accountID, parentReport, reportPreviewReportActionID, policy, timeOfCreation, betas); + if (reportName) { + optimisticReportData.reportName = reportName; + } + const optimisticNextStep = buildOptimisticNextStep({ report: optimisticReportData, predictedNextStatus: CONST.REPORT.STATUS_NUM.OPEN, @@ -3671,6 +3676,7 @@ function createNewReport( betas: OnyxEntry, shouldNotifyNewAction = false, shouldDismissEmptyReportsConfirmation?: boolean, + reportName?: string, ) { const optimisticReportID = generateReportID(); const reportActionID = rand64(); @@ -3685,6 +3691,7 @@ function createNewReport( hasViolationsParam, isASAPSubmitBetaEnabled, betas, + reportName, ); if (shouldDismissEmptyReportsConfirmation) { @@ -3701,6 +3708,7 @@ function createNewReport( reportPreviewReportActionID, ownerEmail: ownerPersonalDetails.login, ...(shouldDismissEmptyReportsConfirmation ? {shouldDismissEmptyReportsConfirmation} : {}), + ...(reportName ? {reportName} : {}), }, {optimisticData, successData, failureData}, ); @@ -3708,7 +3716,7 @@ function createNewReport( notifyNewAction(parentReportID, reportPreviewAction, true); } - return optimisticReportData; + return {...optimisticReportData, reportPreviewReportActionID}; } /** diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 2c61fa9272f66..216a3846d59b4 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -3,10 +3,12 @@ import type {OnyxEntry, OnyxInputValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {getReportPreviewAction} from '@libs/actions/IOU'; -import {duplicateExpenseTransaction, mergeDuplicates, resolveDuplicates} from '@libs/actions/IOU/Duplicate'; +import {duplicateExpenseTransaction, duplicateReport, mergeDuplicates, resolveDuplicates} from '@libs/actions/IOU/Duplicate'; +import type {DuplicateReportParams} from '@libs/actions/IOU/Duplicate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {addComment, openReport} from '@libs/actions/Report'; import {WRITE_COMMANDS} from '@libs/API/types'; +import Navigation from '@libs/Navigation/Navigation'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, getReportAction} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReport, buildOptimisticIOUReportAction, buildTransactionThread} from '@libs/ReportUtils'; @@ -1746,4 +1748,530 @@ describe('actions/Duplicate', () => { }); }); }); + + describe('duplicateReport', () => { + let writeSpy: jest.SpyInstance; + + const mockPolicy = createRandomPolicy(1); + const mockPolicyCategories = createRandomPolicyCategories(3); + const mockPersonalDetails = { + [RORY_ACCOUNT_ID]: { + accountID: RORY_ACCOUNT_ID, + login: RORY_EMAIL, + displayName: 'Rory', + }, + }; + const mockOwnerPersonalDetails = { + accountID: RORY_ACCOUNT_ID, + login: RORY_EMAIL, + displayName: 'Rory', + }; + + const mockTranslate = ((path: string, ...args: string[]) => { + if (path === 'common.copyOfReportName') { + return `Copy of ${args.at(0)}`; + } + return path; + }) as DuplicateReportParams['translate']; + + const createCashTransaction = (id: string, overrides: Partial = {}): Transaction => ({ + ...createRandomTransaction(Number(id)), + transactionID: id, + amount: -500, + merchant: 'Test Merchant', + modifiedMerchant: '', + currency: CONST.CURRENCY.USD, + cardNumber: '', + cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, + managedCard: false, + bank: '', + receipt: {}, + ...overrides, + }); + + const POLICY_EXPENSE_CHAT_REPORT_ID = 'policyExpenseChatReport'; + + const getDefaultParams = (sourceTransactions: Transaction[], overrides: Partial = {}): DuplicateReportParams => ({ + sourceReport: undefined, + sourceReportTransactions: sourceTransactions, + sourceReportName: 'Original Report', + targetPolicy: mockPolicy, + targetPolicyCategories: mockPolicyCategories, + targetPolicyTags: {}, + parentChatReport: { + reportID: POLICY_EXPENSE_CHAT_REPORT_ID, + policyID: mockPolicy.id, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + ownerAccountID: RORY_ACCOUNT_ID, + type: CONST.REPORT.TYPE.CHAT, + }, + ownerPersonalDetails: mockOwnerPersonalDetails, + isASAPSubmitBetaEnabled: false, + betas: [CONST.BETAS.ALL], + personalDetails: mockPersonalDetails, + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + draftTransactionIDs: [], + isSelfTourViewed: false, + transactionViolations: {}, + translate: mockTranslate, + recentWaypoints: [], + ...overrides, + }); + + const countWriteCommandCalls = (command: string) => writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === command).length; + + beforeEach(async () => { + jest.clearAllMocks(); + global.fetch = getGlobalFetchMock(); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + writeSpy = jest.spyOn(API, 'write').mockImplementation((command, params, options) => { + if (options?.optimisticData) { + for (const update of options.optimisticData) { + if (update.onyxMethod === Onyx.METHOD.MERGE) { + Onyx.merge(update.key, update.value); + } else if (update.onyxMethod === Onyx.METHOD.SET) { + Onyx.set(update.key, update.value); + } + } + } + return Promise.resolve(); + }); + await Onyx.clear(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${POLICY_EXPENSE_CHAT_REPORT_ID}`, { + reportID: POLICY_EXPENSE_CHAT_REPORT_ID, + policyID: mockPolicy.id, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + ownerAccountID: RORY_ACCOUNT_ID, + type: CONST.REPORT.TYPE.CHAT, + }); + await waitForBatchedUpdates(); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('should create a new report and duplicate all eligible transactions', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2', {merchant: 'Coffee Shop'}); + + duplicateReport(getDefaultParams([tx1, tx2])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); + + const createReportCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_APP_REPORT) as unknown[] | undefined; + expect(createReportCall?.at(1)).toEqual(expect.objectContaining({reportName: 'Copy of Original Report'})); + + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should filter out credit card import transactions', async () => { + const cashTx = createCashTransaction('cash1'); + const cardTx = createCashTransaction('card1', { + transactionType: CONST.SEARCH.TRANSACTION_TYPE.CARD, + }); + const cashTx2 = createCashTransaction('cash2'); + + duplicateReport(getDefaultParams([cashTx, cardTx, cashTx2])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); + }); + + it('should filter out accountant (Expensiworks) transactions', async () => { + const normalTx = createCashTransaction('normal1'); + const accountantTx = createCashTransaction('acct1', { + accountant: {accountID: 999, login: 'accountant@test.com'}, + }); + + duplicateReport(getDefaultParams([normalTx, accountantTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + }); + + it('should filter out scanning transactions', async () => { + const normalTx = createCashTransaction('normal1'); + const scanningTx = createCashTransaction('scan1', { + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + receipt: { + source: 'receipt.jpg', + state: CONST.IOU.RECEIPT_STATE.SCANNING, + }, + }); + + duplicateReport(getDefaultParams([normalTx, scanningTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + }); + + it('should route distance transactions through createDistanceRequest', async () => { + const cashTx = createCashTransaction('cash1'); + const distanceTx = createCashTransaction('dist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + }, + }, + }); + + duplicateReport(getDefaultParams([cashTx, distanceTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)).toBe(1); + }); + + it('should route per diem transactions through submitPerDiemExpense', async () => { + const cashTx = createCashTransaction('cash1'); + const perDiemTx = createCashTransaction('pd1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: 'unit1', + customUnitRateID: 'rate1', + subRates: [{id: 'sub1', quantity: 1, name: 'Full Day', rate: 100}], + attributes: {dates: {start: '2024-01-01', end: '2024-01-02'}}, + }, + }, + }); + + duplicateReport(getDefaultParams([cashTx, perDiemTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(1); + }); + + it('should not duplicate expenses when no target policy exists', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2'); + + duplicateReport(getDefaultParams([tx1, tx2], {targetPolicy: undefined})); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(0); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(0); + + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should still create the report when all transactions are ineligible', async () => { + const cardTx = createCashTransaction('card1', { + transactionType: CONST.SEARCH.TRANSACTION_TYPE.CARD, + }); + const accountantTx = createCashTransaction('acct1', { + accountant: {accountID: 999, login: 'accountant@test.com'}, + }); + + duplicateReport(getDefaultParams([cardTx, accountantTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(0); + + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should set duplicated transaction dates to today', async () => { + const oldDate = '2023-06-15'; + const tx = createCashTransaction('tx1', {created: oldDate}); + + duplicateReport(getDefaultParams([tx])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + + const today = new Date().toISOString().slice(0, 10); + expect(requestMoneyCall?.at(1)).toEqual(expect.objectContaining({created: today})); + }); + + it('should clear receipt data from duplicated transactions', async () => { + const txWithReceipt = createCashTransaction('tx1', { + receipt: {source: 'https://example.com/receipt.jpg', state: CONST.IOU.RECEIPT_STATE.OPEN}, + }); + + duplicateReport(getDefaultParams([txWithReceipt])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + expect(requestMoneyCall?.at(1)).toEqual(expect.objectContaining({receipt: undefined})); + }); + + it('should use modifiedMerchant when available', async () => { + const tx = createCashTransaction('tx1', { + merchant: 'Original Merchant', + modifiedMerchant: 'Modified Merchant', + }); + + duplicateReport(getDefaultParams([tx])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + expect(requestMoneyCall?.at(1)).toEqual(expect.objectContaining({merchant: 'Modified Merchant'})); + }); + + it('should pass the same reportPreviewReportActionID to all expense calls', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2'); + const tx3 = createCashTransaction('tx3'); + + duplicateReport(getDefaultParams([tx1, tx2, tx3])); + await waitForBatchedUpdates(); + + const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as Array<[string, Record]>; + expect(requestMoneyCalls).toHaveLength(3); + + const firstPreviewID = requestMoneyCalls.at(0)?.[1]?.reportPreviewReportActionID; + expect(firstPreviewID).toBeDefined(); + for (const call of requestMoneyCalls) { + expect(call[1].reportPreviewReportActionID).toBe(firstPreviewID); + } + }); + + it('should target the same chat report for all expense calls', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2'); + + duplicateReport(getDefaultParams([tx1, tx2])); + await waitForBatchedUpdates(); + + const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as Array<[string, Record]>; + expect(requestMoneyCalls).toHaveLength(2); + + const firstChatReportID = requestMoneyCalls.at(0)?.[1]?.chatReportID; + const secondChatReportID = requestMoneyCalls.at(1)?.[1]?.chatReportID; + expect(firstChatReportID).toBeDefined(); + expect(firstChatReportID).toBe(secondChatReportID); + }); + + it('should filter out partial (incomplete) transactions', async () => { + const normalTx = createCashTransaction('normal1'); + const partialTx = createCashTransaction('partial1', { + amount: 0, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + }); + + duplicateReport(getDefaultParams([normalTx, partialTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + }); + + it('should handle mixed eligible and ineligible transactions correctly', async () => { + const cashTx = createCashTransaction('cash1'); + const cardTx = createCashTransaction('card1', {transactionType: CONST.SEARCH.TRANSACTION_TYPE.CARD}); + const accountantTx = createCashTransaction('acct1', {accountant: {accountID: 999, login: 'a@test.com'}}); + const cashTx2 = createCashTransaction('cash2'); + const scanningTx = createCashTransaction('scan1', { + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + receipt: {source: 'r.jpg', state: CONST.IOU.RECEIPT_STATE.SCANNING}, + }); + + duplicateReport(getDefaultParams([cashTx, cardTx, accountantTx, cashTx2, scanningTx])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); + }); + + it('should strip waypoints and use stored distance for split distance expenses', async () => { + const splitDistanceTx = createCashTransaction('splitDist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + originalTransactionID: 'origTx1', + source: 'split', + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 42, + }, + }, + }); + + duplicateReport(getDefaultParams([splitDistanceTx])); + await waitForBatchedUpdates(); + + const distanceCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_DISTANCE_REQUEST) as [string, Record] | undefined; + expect(distanceCall).toBeDefined(); + expect(distanceCall?.at(1)).toEqual(expect.objectContaining({waypoints: 'null'})); + }); + + it('should preserve waypoints for non-split distance expenses', async () => { + const distanceTx = createCashTransaction('dist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + }, + waypoints: { + waypoint0: {lat: 37.7749, lng: -122.4194, address: 'San Francisco'}, + waypoint1: {lat: 34.0522, lng: -118.2437, address: 'Los Angeles'}, + }, + }, + }); + + duplicateReport(getDefaultParams([distanceTx])); + await waitForBatchedUpdates(); + + const distanceCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_DISTANCE_REQUEST) as [string, Record] | undefined; + expect(distanceCall).toBeDefined(); + + const waypoints = distanceCall?.[1]?.waypoints; + expect(waypoints).toBeDefined(); + expect(waypoints).not.toBe('null'); + }); + + it('should correctly route a report with mixed cash, distance, and per diem transactions', async () => { + const cashTx = createCashTransaction('cash1'); + const distanceTx = createCashTransaction('dist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + }, + }, + }); + const perDiemTx = createCashTransaction('pd1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL, + customUnitID: 'unit1', + customUnitRateID: 'rate1', + subRates: [{id: 'sub1', quantity: 1, name: 'Full Day', rate: 100}], + attributes: {dates: {start: '2024-01-01', end: '2024-01-02'}}, + }, + }, + }); + const cashTx2 = createCashTransaction('cash2'); + + duplicateReport(getDefaultParams([cashTx, distanceTx, perDiemTx, cashTx2])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(1); + }); + + it('should preserve transaction fields like category, tag, and currency', async () => { + const tx = createCashTransaction('tx1', { + category: 'Travel', + tag: 'Business', + currency: 'EUR', + amount: -1500, + billable: true, + }); + + duplicateReport(getDefaultParams([tx])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + expect(requestMoneyCall?.at(1)).toEqual( + expect.objectContaining({ + category: 'Travel', + tag: 'Business', + currency: 'EUR', + amount: 1500, + billable: true, + }), + ); + }); + + it('should strip originalTransactionID and source when duplicating split distance expenses', async () => { + const splitDistanceTx = createCashTransaction('splitDist1', { + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: { + originalTransactionID: 'origParent123', + source: 'split', + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 10, + }, + }, + }); + + duplicateReport(getDefaultParams([splitDistanceTx])); + await waitForBatchedUpdates(); + + const distanceCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.CREATE_DISTANCE_REQUEST) as [string, Record] | undefined; + expect(distanceCall).toBeDefined(); + + const allTransactions = await getOnyxValue(ONYXKEYS.COLLECTION.TRANSACTION); + const duplicatedTransactions = (Object.values(allTransactions ?? {}) as Array).filter( + (tx): tx is Transaction => !!tx && tx.transactionID !== splitDistanceTx.transactionID, + ); + expect(duplicatedTransactions.length).toBeGreaterThan(0); + for (const tx of duplicatedTransactions) { + expect(tx.comment?.originalTransactionID).toBeFalsy(); + expect(tx.comment?.source).not.toBe('split'); + } + }); + + it('should clear modifiedAmount from duplicated transactions', async () => { + const tx = createCashTransaction('tx1', { + modifiedAmount: 999, + }); + + duplicateReport(getDefaultParams([tx])); + await waitForBatchedUpdates(); + + const requestMoneyCall = writeSpy.mock.calls.find((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as [string, Record] | undefined; + expect(requestMoneyCall).toBeDefined(); + expect(requestMoneyCall?.[1]?.modifiedAmount).toBeUndefined(); + }); + + it('should not create any expense calls for an empty transactions array', async () => { + duplicateReport(getDefaultParams([])); + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(0); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST)).toBe(0); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST)).toBe(0); + }); + + it('should pass shouldPlaySound false to individual expense calls', async () => { + const tx1 = createCashTransaction('tx1'); + const tx2 = createCashTransaction('tx2'); + + duplicateReport(getDefaultParams([tx1, tx2])); + await waitForBatchedUpdates(); + + const requestMoneyCalls = writeSpy.mock.calls.filter((call: unknown[]) => call.at(0) === WRITE_COMMANDS.REQUEST_MONEY) as Array<[string, Record]>; + expect(requestMoneyCalls).toHaveLength(2); + + for (const call of requestMoneyCalls) { + expect(call[1]).not.toEqual(expect.objectContaining({shouldPlaySound: true})); + } + }); + }); }); diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 23e31ea0de041..e3a986f27b4ab 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -2256,7 +2256,7 @@ describe('getSecondaryAction', () => { policy, reportActions, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(true); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(true); }); it('does not include DUPLICATE option if there are no transactions', async () => { @@ -2286,7 +2286,7 @@ describe('getSecondaryAction', () => { originalTransaction: {} as Transaction, policy, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(false); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); it('does not include DUPLICATE option for expense report with multiple transactions', () => { @@ -2346,7 +2346,7 @@ describe('getSecondaryAction', () => { policy, reportActions, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(false); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); it('does not include DUPLICATE option for card transaction', async () => { @@ -2386,7 +2386,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(false); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); it('does not include DUPLICATE option for expenses from other users', () => { @@ -2431,7 +2431,7 @@ describe('getSecondaryAction', () => { policy, reportActions, }); - expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE)).toBe(false); + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DUPLICATE_EXPENSE)).toBe(false); }); it('includes MOVE_EXPENSE option for single expense report when user can move expense', async () => {