diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index f74a26af9cc2..3b794ca46aa7 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useState} from 'reac import {ActivityIndicator, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -191,6 +192,7 @@ function MoneyReportHeader({ {canBeMissing: true}, ); + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactions.map((t) => t.transactionID)); const isExported = useMemo(() => isExportedUtils(reportActions), [reportActions]); // wrapped in useMemo to improve performance because this is an operation on array const integrationNameFromExportMessage = useMemo(() => { @@ -1137,7 +1139,9 @@ function MoneyReportHeader({ } // it's deleting transaction but not the report which leads to bug (that is actually also on staging) // Money request should be deleted when interactions are done, to not show the not found page before navigating to goBackRoute - InteractionManager.runAfterInteractions(() => deleteMoneyRequest(transaction?.transactionID, requestParentReportAction)); + InteractionManager.runAfterInteractions(() => + deleteMoneyRequest(transaction?.transactionID, requestParentReportAction, duplicateTransactions, duplicateTransactionViolations), + ); goBackRoute = getNavigationUrlOnMoneyRequestDelete(transaction.transactionID, requestParentReportAction, false); } diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 7c1aef460e77..72eccf0c30d8 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -4,6 +4,7 @@ import React, {useCallback, useContext, useEffect, useMemo, useState} from 'reac import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -80,7 +81,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre {canBeMissing: true}, ); const transactionViolations = useTransactionViolations(transaction?.transactionID); - + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transaction?.transactionID ? [transaction.transactionID] : []); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const [downloadErrorModalVisible, setDownloadErrorModalVisible] = useState(false); const [dismissedHoldUseExplanation, dismissedHoldUseExplanationResult] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, {canBeMissing: true}); @@ -351,9 +352,9 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre throw new Error('Data missing'); } if (isTrackExpenseAction(parentReportAction)) { - deleteTrackExpense(report?.parentReportID, transaction.transactionID, parentReportAction, true); + deleteTrackExpense(report?.parentReportID, transaction.transactionID, parentReportAction, duplicateTransactions, duplicateTransactionViolations, true); } else { - deleteMoneyRequest(transaction.transactionID, parentReportAction, true); + deleteMoneyRequest(transaction.transactionID, parentReportAction, duplicateTransactions, duplicateTransactionViolations, true); removeTransaction(transaction.transactionID); } onBackButtonPress(); diff --git a/src/hooks/useDuplicateTransactionsAndViolations.ts b/src/hooks/useDuplicateTransactionsAndViolations.ts new file mode 100644 index 000000000000..8fa3ca82775c --- /dev/null +++ b/src/hooks/useDuplicateTransactionsAndViolations.ts @@ -0,0 +1,141 @@ +import {useMemo} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction, TransactionViolations} from '@src/types/onyx'; +import useOnyx from './useOnyx'; + +/** + * Selects violations related to provided transaction IDs and if present, the violations of their duplicates. + * @param transactionIDs - An array of transaction IDs to fetch their violations for. + * @param allTransactionsViolations - A collection of all transaction violations currently in the onyx db. + * @returns - A collection of violations related to the transaction IDs and if present, the violations of their duplicates. + * @private + */ +function selectViolationsWithDuplicates(transactionIDs: string[], allTransactionsViolations: OnyxCollection): OnyxCollection { + if (!allTransactionsViolations || !transactionIDs?.length) { + return {}; + } + + const result: OnyxCollection = {}; + + for (const transactionID of transactionIDs) { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`; + const transactionViolations = allTransactionsViolations[key]; + + if (!transactionViolations) { + continue; + } + + result[key] = transactionViolations; + + transactionViolations + .filter((violations) => violations.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION) + .flatMap((violations) => violations?.data?.duplicates ?? []) + .forEach((duplicateID) => { + if (!duplicateID) { + return; + } + + const duplicateKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${duplicateID}`; + const duplicateViolations = allTransactionsViolations[duplicateKey]; + + if (duplicateViolations) { + result[duplicateKey] = duplicateViolations; + } + }); + } + + return result; +} + +/** + * Selects transactions related to provided transaction IDs and if present, the duplicate transactions. + * @param transactionIDs - An array of transaction IDs to fetch their transactions for. + * @param allTransactions - A collection of all transactions currently in the onyx. + * @param duplicateTransactionViolations - A collection of all duplicate transaction violations currently in the onyx. + * @returns - A collection of transactions related to the transaction IDs and if present, the duplicate transactions. + */ + +function selectTransactionsWithDuplicates( + transactionIDs: string[], + allTransactions: OnyxCollection, + duplicateTransactionViolations: OnyxCollection, +): OnyxCollection { + if (!allTransactions) { + return {}; + } + + const result: OnyxCollection = {}; + + for (const transactionID of transactionIDs) { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; + const transaction = allTransactions[key]; + if (transaction) { + result[key] = transaction; + } + + const transactionViolations = duplicateTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + + if (!transactionViolations) { + continue; + } + + transactionViolations + .filter((violations) => violations.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION) + .flatMap((violations) => violations?.data?.duplicates ?? []) + .forEach((duplicateID) => { + if (!duplicateID) { + return; + } + + const duplicateKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${duplicateID}`; + const duplicateTransaction = allTransactions[duplicateKey]; + + if (duplicateTransaction) { + result[duplicateKey] = duplicateTransaction; + } + }); + } + return result; +} + +type DuplicateTransactionsAndViolations = { + duplicateTransactions: OnyxCollection; + duplicateTransactionViolations: OnyxCollection; +}; + +/** + * A hook to fetch transactions, their violations and if present, the duplicate transactions and their violations. + * @param transactionIDs - Array of transaction IDs to check for duplicates. + * @returns - An object containing duplicate transactions and their violations. + */ +function useDuplicateTransactionsAndViolations(transactionIDs: string[]): DuplicateTransactionsAndViolations { + const violationsSelectorMemo = useMemo(() => { + return (allTransactionsViolations: OnyxCollection) => selectViolationsWithDuplicates(transactionIDs, allTransactionsViolations); + }, [transactionIDs]); + + const [duplicateTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, { + canBeMissing: true, + selector: violationsSelectorMemo, + }); + + const transactionSelector = useMemo(() => { + return (allTransactions: OnyxCollection) => selectTransactionsWithDuplicates(transactionIDs, allTransactions, duplicateTransactionViolations); + }, [transactionIDs, duplicateTransactionViolations]); + + const [duplicateTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + canBeMissing: true, + selector: transactionSelector, + }); + + return useMemo( + () => ({ + duplicateTransactions, + duplicateTransactionViolations, + }), + [duplicateTransactions, duplicateTransactionViolations], + ); +} + +export default useDuplicateTransactionsAndViolations; diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 9ba07cf20d82..881eb5a1b7d1 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -21,6 +21,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {OriginalMessageIOU, Report, ReportAction, Session, Transaction} from '@src/types/onyx'; +import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import useReportIsArchived from './useReportIsArchived'; @@ -45,6 +46,7 @@ function useSelectedTransactionsActions({ }) { const {selectedTransactionIDs, clearSelectedTransactions} = useSearchContext(); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false}); + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(selectedTransactionIDs); const isReportArchived = useReportIsArchived(report?.reportID); const selectedTransactions = useMemo( () => @@ -62,6 +64,7 @@ function useSelectedTransactionsActions({ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const isTrackExpenseThread = isTrackExpenseReport(report); const isInvoice = isInvoiceReport(report); + let iouType: IOUType = CONST.IOU.TYPE.SUBMIT; if (isTrackExpenseThread) { @@ -87,7 +90,7 @@ function useSelectedTransactionsActions({ return; } - deleteMoneyRequest(transactionID, action, undefined, deletedTransactionIDs); + deleteMoneyRequest(transactionID, action, duplicateTransactions, duplicateTransactionViolations, false, deletedTransactionIDs); deletedTransactionIDs.push(transactionID); }); clearSelectedTransactions(true); @@ -95,7 +98,7 @@ function useSelectedTransactionsActions({ turnOffMobileSelectionMode(); } setIsDeleteModalVisible(false); - }, [allTransactionsLength, reportActions, selectedTransactionIDs, clearSelectedTransactions]); + }, [duplicateTransactions, duplicateTransactionViolations, allTransactionsLength, reportActions, selectedTransactionIDs, clearSelectedTransactions]); const showDeleteModal = useCallback(() => { setIsDeleteModalVisible(true); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index c47242ff7604..6e8a9ae00033 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -16,6 +16,7 @@ import DateUtils from '@libs/DateUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import {translateLocal} from '@libs/Localize'; +import Log from '@libs/Log'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import { @@ -61,6 +62,7 @@ import type { } from '@src/types/onyx'; import type {Attendee, Participant, SplitExpense} from '@src/types/onyx/IOU'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, TransactionChanges, TransactionCustomUnit, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -1388,6 +1390,152 @@ type FieldsToChange = { reimbursable?: Array; }; +/** + * Extracts a set of valid duplicate transaction IDs associated with a given transaction, + * excluding: + * - the transaction itself + * - duplicate IDs that appear more than once + * - duplicates referencing missing or invalid transactions + * - settled or approved transactions + * + * @param transactionID - The ID of the transaction being validated. + * @param transactionCollection - A collection of all transactions and their duplicates. + * @param currentTransactionViolations - The list of violations associated with this transaction. + * @returns A set of valid duplicate transaction IDs. + */ +function getValidDuplicateTransactionIDs(transactionID: string, transactionCollection: OnyxCollection, currentTransactionViolations: TransactionViolation[]): Set { + const result = new Set(); + const seen = new Set(); + let foundDuplicateViolation = false; + + if (!transactionCollection) { + return result; + } + + for (const violation of currentTransactionViolations) { + if (violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION) { + continue; + } + + // Skip further violations + if (foundDuplicateViolation) { + Log.warn(`Multiple duplicate violations found for transaction. Only one expected.`, {transactionID}); + break; + } + + foundDuplicateViolation = true; + const duplicatesIDs = violation.data?.duplicates ?? []; + + const validTransactions: Transaction[] = []; + + for (const duplicateID of duplicatesIDs) { + // Skip self-reference + if (duplicateID === transactionID || seen.has(duplicateID)) { + continue; + } + seen.add(duplicateID); + + const transaction = transactionCollection?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${duplicateID}`]; + if (!transaction?.transactionID) { + Log.warn(`Transaction does not exist or is invalid. Found in transaction.`, {duplicateID, transactionID}); + continue; + } + + validTransactions.push(transaction); + } + + // Filter out transactions assumed that they have be reviewed by removing settled and approved transactions + const filtered = removeSettledAndApprovedTransactions(validTransactions); + + for (const transaction of filtered) { + result.add(transaction.transactionID); + } + } + + return result; +} + +/** + * Adds onyx updates to the passed onyxData to update the DUPLICATED_TRANSACTION violation data + * by removing the passed transactionID from any violation that referenced it. + * @param onyxData - An object to store optimistic and failure updates. + * @param transactionID - The ID of the transaction being deleted or updated. + * @param transactions - A collection of all transactions and their duplicates. + * @param transactionViolations - The collection of the transaction violations including the duplicates violations. + * + */ +function removeTransactionFromDuplicateTransactionViolation( + onyxData: OnyxData, + transactionID: string, + transactions: OnyxCollection, + transactionViolations: OnyxCollection, +) { + if (!transactionID || !transactions || !transactionViolations) { + return; + } + const violations = transactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + + if (!violations) { + return; + } + + const duplicateIDs = getValidDuplicateTransactionIDs(transactionID, transactions, violations); + + for (const duplicateID of duplicateIDs) { + const duplicateViolations = transactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${duplicateID}`]; + + if (!duplicateViolations) { + continue; + } + + const duplicateTransactionViolations = duplicateViolations.filter((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION); + + if (duplicateTransactionViolations.length === 0) { + continue; + } + + if (duplicateTransactionViolations.length > 1) { + Log.warn(`There are duplicate transaction violations for transactionID. This should not happen.`, {duplicateTransactionViolations, duplicateID}); + continue; + } + + const duplicateTransactionViolation = duplicateTransactionViolations.at(0); + if (!duplicateTransactionViolation?.data?.duplicates) { + continue; + } + + // If the transactionID is not in the duplicates list, we don't need to update the violation + const duplicateTransactionIDs = duplicateTransactionViolation.data.duplicates.filter((duplicateTransactionID) => duplicateTransactionID !== transactionID); + if (duplicateTransactionIDs.length === duplicateTransactionViolation.data.duplicates.length) { + continue; + } + + const optimisticViolations = duplicateTransactionViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION); + + if (duplicateTransactionIDs.length > 0) { + optimisticViolations.push({ + ...duplicateTransactionViolation, + data: { + ...duplicateTransactionViolation.data, + duplicates: duplicateTransactionIDs, + }, + }); + } + + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${duplicateID}`, + value: optimisticViolations.length > 0 ? optimisticViolations : null, + }); + + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${duplicateID}`, + value: duplicateViolations, + }); + } +} + function removeSettledAndApprovedTransactions(transactions: Array>): Transaction[] { return transactions.filter((transaction) => !!transaction && !isSettled(transaction?.reportID) && !isReportIDApproved(transaction?.reportID)) as Transaction[]; } @@ -1747,6 +1895,7 @@ export { isReceiptBeingScanned, didReceiptScanSucceed, getValidWaypoints, + getValidDuplicateTransactionIDs, isDistanceRequest, isFetchingWaypointsFromServer, isExpensifyCardTransaction, @@ -1787,6 +1936,7 @@ export { getReimbursable, isPayAtEndExpense, removeSettledAndApprovedTransactions, + removeTransactionFromDuplicateTransactionViolation, getCardName, hasReceiptSource, shouldShowAttendees, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 193a4c4aba4b..4fa814361000 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -206,7 +206,7 @@ import { isPerDiemRequest as isPerDiemRequestTransactionUtils, isScanning, isScanRequest as isScanRequestTransactionUtils, - removeSettledAndApprovedTransactions, + removeTransactionFromDuplicateTransactionViolation, } from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import type {IOUAction, IOUActionParams, IOUType} from '@src/CONST'; @@ -4335,7 +4335,10 @@ function getUpdateMoneyRequestParams( value: {...iouReport, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, }); } + + const hasModifiedCurrency = 'currency' in transactionChanges; const hasModifiedComment = 'comment' in transactionChanges; + const hasModifiedDate = 'date' in transactionChanges; const isInvoice = isInvoiceReportReportUtils(iouReport); if ( @@ -4343,12 +4346,17 @@ function getUpdateMoneyRequestParams( isPaidGroupPolicy(policy) && !isInvoice && updatedTransaction && - (hasModifiedTag || hasModifiedCategory || hasModifiedComment || hasModifiedDistanceRate || hasModifiedAmount || hasModifiedCreated) + (hasModifiedTag || hasModifiedCategory || hasModifiedComment || hasModifiedDistanceRate || hasModifiedDate || hasModifiedCurrency || hasModifiedAmount || hasModifiedCreated) ) { const currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; + // If the amount, currency or date have been modified, we remove the duplicate violations since they would be out of date as the transaction has changed + const optimisticViolations = + hasModifiedAmount || hasModifiedDate || hasModifiedCurrency + ? currentTransactionViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION) + : currentTransactionViolations; const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( updatedTransaction, - currentTransactionViolations, + optimisticViolations, policy, policyTagList ?? {}, policyCategories ?? {}, @@ -4587,6 +4595,8 @@ function getUpdateTrackExpenseParams( function updateMoneyRequestDate( transactionID: string, transactionThreadReportID: string, + transactions: OnyxCollection, + transactionViolations: OnyxCollection, value: string, policy: OnyxEntry, policyTags: OnyxEntry, @@ -4602,6 +4612,7 @@ function updateMoneyRequestDate( data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, policy); } else { data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories); + removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); @@ -7556,6 +7567,8 @@ type UpdateMoneyRequestAmountAndCurrencyParams = { policyTagList?: OnyxEntry; policyCategories?: OnyxEntry; taxCode: string; + transactions: OnyxCollection; + transactionViolations: OnyxCollection; }; /** Updates the amount and currency fields of an expense */ @@ -7569,6 +7582,8 @@ function updateMoneyRequestAmountAndCurrency({ policyTagList, policyCategories, taxCode, + transactions, + transactionViolations, }: UpdateMoneyRequestAmountAndCurrencyParams) { const transactionChanges = { amount, @@ -7583,6 +7598,7 @@ function updateMoneyRequestAmountAndCurrency({ data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, policy); } else { data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList ?? null, policyCategories ?? null); + removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations); } const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY, params, onyxData); @@ -7958,7 +7974,14 @@ function cleanUpMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repo * @param isSingleTransactionView - whether we are in the transaction thread report * @return the url to navigate back once the money request is deleted */ -function deleteMoneyRequest(transactionID: string | undefined, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false, transactionIDsPendingDeletion?: string[]) { +function deleteMoneyRequest( + transactionID: string | undefined, + reportAction: OnyxTypes.ReportAction, + transactions: OnyxCollection, + violations: OnyxCollection, + isSingleTransactionView = false, + transactionIDsPendingDeletion?: string[], +) { if (!transactionID) { return; } @@ -8005,51 +8028,7 @@ function deleteMoneyRequest(transactionID: string | undefined, reportAction: Ony }, ]; - if (transactionViolations) { - const duplicates = transactionViolations - .filter((violation) => violation?.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION) - .flatMap((violation) => violation?.data?.duplicates ?? []) - .map((id) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]); - - removeSettledAndApprovedTransactions(duplicates).forEach((duplicate) => { - const duplicateID = duplicate?.transactionID; - const duplicateTransactionsViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${duplicateID}`]; - if (!duplicateTransactionsViolations) { - return; - } - - const duplicateViolation = duplicateTransactionsViolations.find((violation) => violation.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION); - if (!duplicateViolation?.data?.duplicates) { - return; - } - - const duplicateTransactionIDs = duplicateViolation.data.duplicates.filter((duplicateTransactionID) => duplicateTransactionID !== transactionID); - - const optimisticViolations: OnyxTypes.TransactionViolations = duplicateTransactionsViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION); - - if (duplicateTransactionIDs.length > 0) { - optimisticViolations.push({ - ...duplicateViolation, - data: { - ...duplicateViolation.data, - duplicates: duplicateTransactionIDs, - }, - }); - } - - optimisticData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${duplicateID}`, - value: optimisticViolations.length > 0 ? optimisticViolations : null, - }); - - failureData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${duplicateID}`, - value: duplicateTransactionsViolations, - }); - }); - } + removeTransactionFromDuplicateTransactionViolation({optimisticData, failureData}, transactionID, transactions, violations); if (shouldDeleteTransactionThread) { optimisticData.push( @@ -8291,7 +8270,14 @@ function deleteMoneyRequest(transactionID: string | undefined, reportAction: Ony return urlToNavigateBack; } -function deleteTrackExpense(chatReportID: string | undefined, transactionID: string | undefined, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { +function deleteTrackExpense( + chatReportID: string | undefined, + transactionID: string | undefined, + reportAction: OnyxTypes.ReportAction, + transactions: OnyxCollection, + violations: OnyxCollection, + isSingleTransactionView = false, +) { if (!chatReportID || !transactionID) { return; } @@ -8301,7 +8287,7 @@ function deleteTrackExpense(chatReportID: string | undefined, transactionID: str // STEP 1: Get all collections we're updating const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null; if (!isSelfDM(chatReport)) { - deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView); + deleteMoneyRequest(transactionID, reportAction, transactions, violations, isSingleTransactionView); return urlToNavigateBack; } diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 2f9bcceea687..c3a822c7c709 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -24,6 +24,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import {useSearchContext} from '@components/Search/SearchContext'; import TextWithCopy from '@components/TextWithCopy'; +import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -155,7 +156,6 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail selector: (actions) => (report?.parentReportActionID ? actions?.[report.parentReportActionID] : undefined), canBeMissing: true, }); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`, {canBeMissing: false}); const {reportActions} = usePaginatedReportActions(report.reportID); @@ -280,6 +280,7 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail const canDeleteRequest = isActionOwner && (canDeleteTransaction(moneyRequestReport, isMoneyRequestReportArchived) || isSelfDMTrackExpenseReport) && !isDeletedParentAction; const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${iouTransactionID}`, {canBeMissing: true}); + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(iouTransactionID ? [iouTransactionID] : []); const isCardTransactionCanBeDeleted = canDeleteCardTransactionByLiabilityType(iouTransaction); const shouldShowDeleteButton = shouldShowTaskDeleteButton || (canDeleteRequest && isCardTransactionCanBeDeleted) || isDemoTransaction(iouTransaction); @@ -771,12 +772,22 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail const isTrackExpense = isTrackExpenseAction(requestParentReportAction); if (isTrackExpense) { - deleteTrackExpense(moneyRequestReport?.reportID, iouTransactionID, requestParentReportAction, isSingleTransactionView); + deleteTrackExpense(moneyRequestReport?.reportID, iouTransactionID, requestParentReportAction, duplicateTransactions, duplicateTransactionViolations, isSingleTransactionView); } else { - deleteMoneyRequest(iouTransactionID, requestParentReportAction, isSingleTransactionView); + deleteMoneyRequest(iouTransactionID, requestParentReportAction, duplicateTransactions, duplicateTransactionViolations, isSingleTransactionView); removeTransaction(iouTransactionID); } - }, [caseID, iouTransactionID, isSingleTransactionView, moneyRequestReport?.reportID, removeTransaction, report, requestParentReportAction]); + }, [ + duplicateTransactions, + duplicateTransactionViolations, + caseID, + iouTransactionID, + isSingleTransactionView, + moneyRequestReport?.reportID, + removeTransaction, + report, + requestParentReportAction, + ]); // A flag to indicate whether the user chose to delete the transaction or not const isTransactionDeleted = useRef(false); diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 73d9d379481d..2f42b855cb73 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -9,6 +9,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {Actions, ActionSheetAwareScrollViewContext} from '@components/ActionSheetAwareScrollView'; import ConfirmModal from '@components/ConfirmModal'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; +import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLocalize from '@hooks/useLocalize'; import {deleteMoneyRequest, deleteTrackExpense} from '@libs/actions/IOU'; import {deleteReportComment} from '@libs/actions/Report'; @@ -54,7 +55,6 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef { callbackWhenDeleteModalHide.current = runAndResetCallback(onConfirmDeleteModal.current); const reportAction = reportActionRef.current; if (isMoneyRequestAction(reportAction)) { const originalMessage = getOriginalMessage(reportAction); if (isTrackExpenseAction(reportAction)) { - deleteTrackExpense(reportIDRef.current, originalMessage?.IOUTransactionID, reportAction); + deleteTrackExpense(reportIDRef.current, originalMessage?.IOUTransactionID, reportAction, duplicateTransactions, duplicateTransactionViolations); } else { - deleteMoneyRequest(originalMessage?.IOUTransactionID, reportAction); + deleteMoneyRequest(originalMessage?.IOUTransactionID, reportAction, duplicateTransactions, duplicateTransactionViolations); } } else if (reportAction) { InteractionManager.runAfterInteractions(() => { @@ -297,7 +307,7 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef { callbackWhenDeleteModalHide.current = () => (onCancelDeleteModal.current = runAndResetCallback(onCancelDeleteModal.current)); diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 53885a999112..1a6793e52724 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -4,6 +4,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {createDraftTransaction, removeDraftTransaction} from '@libs/actions/TransactionEdit'; @@ -82,8 +83,8 @@ function IOURequestStepAmount({ const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, {canBeMissing: true}); + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []); const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {canBeMissing: true, selector: (val) => val?.reports}); - const isEditing = action === CONST.IOU.ACTION.EDIT; const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; const isEditingSplitBill = isEditing && isSplitBill; @@ -295,6 +296,8 @@ function IOURequestStepAmount({ updateMoneyRequestAmountAndCurrency({ transactionID, transactionThreadReportID: reportID, + transactions: duplicateTransactions, + transactionViolations: duplicateTransactionViolations, currency, amount: newAmount, taxAmount, diff --git a/src/pages/iou/request/step/IOURequestStepDate.tsx b/src/pages/iou/request/step/IOURequestStepDate.tsx index c22144639f68..913355272051 100644 --- a/src/pages/iou/request/step/IOURequestStepDate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDate.tsx @@ -5,6 +5,7 @@ import DatePicker from '@components/DatePicker'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; +import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; @@ -42,6 +43,7 @@ function IOURequestStepDate({ const styles = useThemeStyles(); const {translate} = useLocalize(); const policy = usePolicy(report?.policyID); + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`, {canBeMissing: false}); const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`, {canBeMissing: false}); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: true}); @@ -107,7 +109,7 @@ function IOURequestStepDate({ setMoneyRequestCreated(transactionID, newCreated, isTransactionDraft); if (isEditing) { - updateMoneyRequestDate(transactionID, reportID, newCreated, policy, policyTags, policyCategories); + updateMoneyRequestDate(transactionID, reportID, duplicateTransactions, duplicateTransactionViolations, newCreated, policy, policyTags, policyCategories); } navigateBack(); diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index a8a3b0ee48f2..60a55c8378b2 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -3217,7 +3217,7 @@ describe('actions/IOU', () => { if (transaction && createIOUAction) { // When the expense is deleted - deleteMoneyRequest(transaction?.transactionID, createIOUAction, true); + deleteMoneyRequest(transaction?.transactionID, createIOUAction, {}, {}, true); } await waitForBatchedUpdates(); @@ -3296,7 +3296,7 @@ describe('actions/IOU', () => { if (transaction && createIOUAction) { // When the IOU expense is deleted - deleteMoneyRequest(transaction?.transactionID, createIOUAction, true); + deleteMoneyRequest(transaction?.transactionID, createIOUAction, {}, {}, true); } await waitForBatchedUpdates(); @@ -3357,7 +3357,7 @@ describe('actions/IOU', () => { // When we attempt to delete an expense from the IOU report mockFetch?.pause?.(); if (transaction && createIOUAction) { - deleteMoneyRequest(transaction?.transactionID, createIOUAction, false); + deleteMoneyRequest(transaction?.transactionID, createIOUAction, {}, {}); } await waitForBatchedUpdates(); @@ -3452,7 +3452,7 @@ describe('actions/IOU', () => { if (transaction && createIOUAction) { // When Deleting an expense - deleteMoneyRequest(transaction?.transactionID, createIOUAction, false); + deleteMoneyRequest(transaction?.transactionID, createIOUAction, {}, {}); } await waitForBatchedUpdates(); @@ -3526,6 +3526,7 @@ describe('actions/IOU', () => { createIOUAction = Object.values(reportActionsForIOUReport ?? {}).find((reportAction): reportAction is ReportAction => isMoneyRequestAction(reportAction), ); + expect(createIOUAction?.childReportID).toBe(thread.reportID); await waitForBatchedUpdates(); @@ -3534,7 +3535,9 @@ describe('actions/IOU', () => { if (transaction && createIOUAction) { updateMoneyRequestAmountAndCurrency({ transactionID: transaction.transactionID, + transactions: {}, transactionThreadReportID: thread.reportID, + transactionViolations: {}, amount: 20000, currency: CONST.CURRENCY.USD, taxAmount: 0, @@ -3574,7 +3577,7 @@ describe('actions/IOU', () => { if (transaction && createIOUAction) { // When Deleting an expense - deleteMoneyRequest(transaction?.transactionID, createIOUAction, false); + deleteMoneyRequest(transaction?.transactionID, createIOUAction, {}, {}); } await waitForBatchedUpdates(); @@ -3648,7 +3651,7 @@ describe('actions/IOU', () => { if (transaction && createIOUAction) { // When deleting expense - deleteMoneyRequest(transaction?.transactionID, createIOUAction, false); + deleteMoneyRequest(transaction?.transactionID, createIOUAction, {}, {}); } await waitForBatchedUpdates(); @@ -3799,7 +3802,7 @@ describe('actions/IOU', () => { mockFetch?.pause?.(); if (transaction && createIOUAction) { // When we delete the expense - deleteMoneyRequest(transaction.transactionID, createIOUAction, false); + deleteMoneyRequest(transaction.transactionID, createIOUAction, {}, {}); } await waitForBatchedUpdates(); @@ -3890,7 +3893,7 @@ describe('actions/IOU', () => { mockFetch?.pause?.(); jest.advanceTimersByTime(10); if (transaction && createIOUAction) { - deleteMoneyRequest(transaction.transactionID, createIOUAction, false); + deleteMoneyRequest(transaction.transactionID, createIOUAction, {}, {}); } await waitForBatchedUpdates(); @@ -3965,7 +3968,7 @@ describe('actions/IOU', () => { let navigateToAfterDelete; if (transaction && createIOUAction) { - navigateToAfterDelete = deleteMoneyRequest(transaction.transactionID, createIOUAction, true); + navigateToAfterDelete = deleteMoneyRequest(transaction.transactionID, createIOUAction, {}, {}, true); } let allReports = await new Promise>((resolve) => { @@ -4013,7 +4016,7 @@ describe('actions/IOU', () => { let navigateToAfterDelete; if (transaction && createIOUAction) { // When we delete the expense and we should delete the IOU report - navigateToAfterDelete = deleteMoneyRequest(transaction.transactionID, createIOUAction, false); + navigateToAfterDelete = deleteMoneyRequest(transaction.transactionID, createIOUAction, {}, {}); } // Then we expect to navigate to the chat report expect(chatReport?.reportID).not.toBeUndefined(); @@ -5804,6 +5807,8 @@ describe('actions/IOU', () => { }, policyTagList: {}, policyCategories: {}, + transactions: {}, + transactionViolations: {}, }); await waitForBatchedUpdates(); @@ -5862,6 +5867,8 @@ describe('actions/IOU', () => { }, policyTagList: {}, policyCategories: {}, + transactions: {}, + transactionViolations: {}, }); await waitForBatchedUpdates();