diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 6755932414e98..eb2e66ef6cbb0 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -127,7 +127,7 @@ type MoneyRequestViewProps = { parentReportID?: string; - /** Policy that the report belongs to */ + /** Policy that the report belongs to, or the target transaction policy in merge transaction flow */ expensePolicy: OnyxEntry; /** Whether we should display the animated banner above the component */ @@ -309,9 +309,10 @@ function MoneyRequestView({ const shouldShowCard = isManagedCardTransaction && cardProgramName; const taxRates = policy?.taxRates; - const formattedTaxAmount = updatedTransaction?.taxAmount - ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), transactionCurrency) - : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), transactionCurrency); + const formattedTaxAmount = + updatedTransaction?.taxAmount !== undefined + ? convertToDisplayString(Math.abs(updatedTransaction.taxAmount), actualCurrency) + : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); const taxRatesDescription = taxRates?.name; const taxRateTitle = updatedTransaction ? getTaxName(policy, updatedTransaction, isExpenseUnreported) : getTaxName(policy, transaction, isExpenseUnreported); @@ -408,7 +409,7 @@ function MoneyRequestView({ canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, undefined, isChatReportArchived, undefined, transaction, moneyRequestReport, policy); const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); - const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat || isExpenseUnreported, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); + const shouldShowTax = !!transaction?.taxName || isTaxTrackingEnabled(isPolicyExpenseChat || isExpenseUnreported, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; @@ -589,7 +590,7 @@ function MoneyRequestView({ const decodedCategoryName = getDecodedCategoryName(categoryValue); const categoryCopyValue = !canEdit ? decodedCategoryName : undefined; const cardCopyValue = cardProgramName; - const taxRateValue = taxRateTitle ?? fallbackTaxRateTitle; + const taxRateValue = transaction?.taxName ?? taxRateTitle ?? fallbackTaxRateTitle; const taxRateCopyValue = !canEditTaxFields ? taxRateValue : undefined; const taxAmountTitle = formattedTaxAmount ? formattedTaxAmount.toString() : ''; const taxAmountCopyValue = !canEditTaxFields ? taxAmountTitle : undefined; diff --git a/src/hooks/useMergeTransactions.ts b/src/hooks/useMergeTransactions.ts index b82f1afcb7428..619132aae3c03 100644 --- a/src/hooks/useMergeTransactions.ts +++ b/src/hooks/useMergeTransactions.ts @@ -1,10 +1,12 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useSearchContext} from '@components/Search/SearchContext'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getTransactionFromMergeTransaction} from '@libs/MergeTransactionUtils'; +import {getReportIDForExpense, getTransactionFromMergeTransaction} from '@libs/MergeTransactionUtils'; +import {isExpenseUnreported} from '@libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MergeTransaction, Report, SearchResults, Transaction} from '@src/types/onyx'; +import type {MergeTransaction, Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; import useOnyx from './useOnyx'; +import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; type UseMergeTransactionsProps = { mergeTransaction?: MergeTransaction; @@ -15,6 +17,8 @@ type UseMergeTransactionsReturn = { sourceTransaction?: Transaction; targetTransactionReport?: Report; sourceTransactionReport?: Report; + targetTransactionPolicy?: Policy; + sourceTransactionPolicy?: Policy; }; function getTransaction( @@ -37,6 +41,7 @@ function getTransaction( function useMergeTransactions({mergeTransaction}: UseMergeTransactionsProps): UseMergeTransactionsReturn { const {currentSearchHash, currentSearchResults} = useSearchContext(); + const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); const [onyxTargetTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(mergeTransaction?.targetTransactionID)}`, { canBeMissing: true, @@ -48,17 +53,31 @@ function useMergeTransactions({mergeTransaction}: UseMergeTransactionsProps): Us const targetTransaction = getTransaction(mergeTransaction, mergeTransaction?.targetTransactionID, onyxTargetTransaction, currentSearchResults); const sourceTransaction = getTransaction(mergeTransaction, mergeTransaction?.sourceTransactionID, onyxSourceTransaction, currentSearchResults); - let [targetTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetTransaction?.reportID)}`, { + const targetTransactionReportID = getReportIDForExpense(targetTransaction); + const sourceTransactionReportID = getReportIDForExpense(sourceTransaction); + let [targetTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetTransactionReportID)}`, { canBeMissing: true, }); - let [sourceTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(sourceTransaction?.reportID)}`, { + let [sourceTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(sourceTransactionReportID)}`, { + canBeMissing: true, + }); + + const targetTransactionPolicyID = isExpenseUnreported(targetTransaction) ? policyForMovingExpensesID : targetTransactionReport?.policyID; + const sourceTransactionPolicyID = isExpenseUnreported(sourceTransaction) ? policyForMovingExpensesID : sourceTransactionReport?.policyID; + let [targetTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(targetTransactionPolicyID)}`, { + canBeMissing: true, + }); + let [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(sourceTransactionPolicyID)}`, { canBeMissing: true, }); - // If we're on search and main collection reports are not available, get them from the search snapshot if (currentSearchHash && currentSearchResults?.data) { - targetTransactionReport = targetTransactionReport ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${targetTransaction?.reportID}`]; - sourceTransactionReport = sourceTransactionReport ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`]; + // If we're on search and main collection reports are not available, get them from the search snapshot + targetTransactionReport = targetTransactionReport ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionReportID}`]; + sourceTransactionReport = sourceTransactionReport ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${sourceTransactionReportID}`]; + // If we're on search, search snapshot policies are more up to date + targetTransactionPolicy = currentSearchResults?.data[`${ONYXKEYS.COLLECTION.POLICY}${targetTransactionPolicyID}`]; + sourceTransactionPolicy = currentSearchResults?.data[`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionPolicyID}`]; } return { @@ -66,6 +85,8 @@ function useMergeTransactions({mergeTransaction}: UseMergeTransactionsProps): Us sourceTransaction, targetTransactionReport, sourceTransactionReport, + targetTransactionPolicy, + sourceTransactionPolicy, }; } diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index fc38982791263..e378154705a8b 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -341,7 +341,16 @@ function useSelectedTransactionsActions({ text: translate('common.merge'), icon: expensifyIcons.ArrowCollapse, value: MERGE, - onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, selectedTransactionsList, localeCompare, [], false, isOnSearch), + onSelected: () => + setupMergeTransactionDataAndNavigate( + transactionID, + selectedTransactionsList, + localeCompare, + [], + false, + isOnSearch, + selectedTransactionsList.length > 1 ? [policy, policy] : undefined, + ), }); } } diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 16d9e361718c5..d56934bf1c9c8 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -952,6 +952,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) case 'category': case 'merchant': case 'taxCode': + case 'taxName': case 'modifiedCurrency': case 'modifiedMerchant': case 'transactionID': @@ -1119,6 +1120,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) isDemoTransaction: CONST.RED_BRICK_ROAD_PENDING_ACTION, splitExpensesTotal: CONST.RED_BRICK_ROAD_PENDING_ACTION, taxValue: CONST.RED_BRICK_ROAD_PENDING_ACTION, + taxName: CONST.RED_BRICK_ROAD_PENDING_ACTION, pendingAutoCategorizationTime: CONST.RED_BRICK_ROAD_PENDING_ACTION, groupAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION, groupCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 916e1fca1d22b..372d7429fa0f7 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -8,7 +8,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {MergeTransaction, Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import SafeString from '@src/utils/SafeString'; -import {convertToDisplayString} from './CurrencyUtils'; +import {convertToBackendAmount, convertToDisplayString, getCurrencyDecimals} from './CurrencyUtils'; import Parser from './Parser'; import {getCommaSeparatedTagNameWithSanitizedColons} from './PolicyUtils'; import {getIOUActionForReportID} from './ReportActionsUtils'; @@ -17,10 +17,12 @@ import {findSelfDMReportID, getReportOrDraftReport, getTransactionDetails, isIOU import type {TransactionDetails} from './ReportUtils'; import StringUtils from './StringUtils'; import { + calculateTaxAmount, getAmount, getAttendeesListDisplayString, getCurrency, getReimbursable, + getTaxName, getWaypoints, isDistanceRequest, isExpenseSplit, @@ -36,7 +38,9 @@ import { const RECEIPT_SOURCE_URL = 'https://www.expensify.com/receipts/'; // Define the specific merge fields we want to handle -const MERGE_FIELDS = ['amount', 'merchant', 'created', 'category', 'tag', 'description', 'reimbursable', 'billable', 'attendees', 'reportID'] as const; +const MERGE_FIELDS = ['amount', 'merchant', 'created', 'category', 'tag', 'description', 'taxValue', 'reimbursable', 'billable', 'attendees', 'reportID'] as const; +// Some fields are dependant on others. We need to automatically derive the correct field values depending on user selection. +const DERIVED_MERGE_FIELDS = [...MERGE_FIELDS, 'taxCode', 'taxAmount'] as const; type MergeFieldKey = TupleToUnion; type MergeFieldOption = { transaction: Transaction; @@ -64,6 +68,7 @@ const MERGE_FIELD_TRANSLATION_KEYS = { created: 'common.date', attendees: 'iou.attendees', reportID: 'common.report', + taxValue: 'iou.taxRate', } as const; function getMergeFieldErrorText(translate: LocaleContextProps['translate'], mergeField: MergeFieldData) { @@ -143,6 +148,9 @@ function getMergeFieldValue(transactionDetails: TransactionDetails | undefined, if (field === 'merchant' && isMerchantMissing(transaction)) { return ''; } + if (field === 'taxValue') { + return transaction.taxValue; + } return transactionDetails[field]; } @@ -194,7 +202,9 @@ function getTransactionsAndReportsFromSearch( * @param targetTransaction - The target transaction * @param sourceTransaction - The source transaction * @param localeCompare - The localize compare function - * @param localeCompare - The localize compare function + * @param searchReports - The search reports to use for report name lookup + * @param targetTransactionPolicy - The policy of the target transaction + * @param sourceTransactionPolicy - The policy of the source transaction * @returns mergeableData and conflictFields */ function getMergeableDataAndConflictFields( @@ -202,6 +212,8 @@ function getMergeableDataAndConflictFields( sourceTransaction: OnyxEntry, localeCompare: LocaleContextProps['localeCompare'], searchReports: Array> = [], + targetTransactionPolicy?: OnyxEntry, + sourceTransactionPolicy?: OnyxEntry, ) { const conflictFields: string[] = []; const mergeableData: Record = {}; @@ -254,7 +266,7 @@ function getMergeableDataAndConflictFields( // We allow user to select unreported report if (field === 'reportID') { if (targetValue === sourceValue) { - const updatedValues = getMergeFieldUpdatedValues(targetTransaction, field, SafeString(targetValue), searchReports); + const updatedValues = getMergeFieldUpdatedValues({transaction: targetTransaction, field, fieldValue: SafeString(targetValue), searchReports}); Object.assign(mergeableData, updatedValues); } else { conflictFields.push(field); @@ -282,15 +294,25 @@ function getMergeableDataAndConflictFields( } if (isTargetValueEmpty || isSourceValueEmpty || targetValue === sourceValue) { + if (field === 'taxValue' && isTargetValueEmpty) { + continue; + } const selectedTransaction = isTargetValueEmpty ? sourceTransaction : targetTransaction; const selectedFieldValue = isTargetValueEmpty ? sourceValue : targetValue; - const updatedValues = getMergeFieldUpdatedValues(selectedTransaction, field, selectedFieldValue as MergeTransaction[typeof field], searchReports); + const selectedPolicy = isTargetValueEmpty ? sourceTransactionPolicy : targetTransactionPolicy; + const updatedValues = getMergeFieldUpdatedValues({ + transaction: selectedTransaction, + field, + fieldValue: selectedFieldValue as MergeTransaction[typeof field], + mergeTransaction: mergeableData as MergeTransaction, + searchReports, + policy: selectedPolicy, + }); Object.assign(mergeableData, updatedValues); } else { conflictFields.push(field); } } - return {mergeableData, conflictFields}; } @@ -359,6 +381,10 @@ function buildMergedTransactionData(targetTransaction: OnyxEntry, m reportID: mergeTransaction.reportID, reportName: mergeTransaction.reportName, routes: mergeTransaction.routes, + taxValue: mergeTransaction.taxValue, + taxAmount: mergeTransaction.taxAmount, + taxCode: mergeTransaction.taxCode, + taxName: mergeTransaction.taxName, }; } @@ -430,26 +456,39 @@ function areTransactionsEligibleForMerge(transaction1: OnyxEntry, t * * @param targetTransaction - The transaction where the merge action is started from * @param sourceTransaction - The selected transaction to be merged with the target transaction - * @returns An object containing the determined targetTransaction and sourceTransaction + * @param targetTransactionPolicy - The policy of the target transaction + * @param sourceTransactionPolicy - The policy of the source transaction + * @returns An object containing the determined targetTransaction and sourceTransaction, targetTransactionPolicy and sourceTransactionPolicy */ -function selectTargetAndSourceTransactionsForMerge(targetTransaction: OnyxEntry, sourceTransaction: OnyxEntry) { +function selectTargetAndSourceTransactionsForMerge( + targetTransaction: OnyxEntry, + sourceTransaction: OnyxEntry, + targetTransactionPolicy?: Policy, + sourceTransactionPolicy?: Policy, +) { // If target transaction is a card or split expense, always preserve the target transaction // Card takes precedence over split expense if (isFromCreditCardImport(sourceTransaction) || (isExpenseSplit(sourceTransaction) && !isFromCreditCardImport(targetTransaction))) { - return {targetTransaction: sourceTransaction, sourceTransaction: targetTransaction}; + return { + targetTransaction: sourceTransaction, + sourceTransaction: targetTransaction, + targetTransactionPolicy: sourceTransactionPolicy, + sourceTransactionPolicy: targetTransactionPolicy, + }; } - return {targetTransaction, sourceTransaction}; + return {targetTransaction, sourceTransaction, targetTransactionPolicy, sourceTransactionPolicy}; } /** * Get display value for merge transaction field * @param field - The merge field key to get display value for * @param transaction - The transaction to get the field value from + * @param policy - The policy that the transaction belongs to * @param translate - The translation function * @returns The formatted display string for the field value */ -function getDisplayValue(field: MergeFieldKey, transaction: Transaction, translate: LocaleContextProps['translate'], reports?: Array>): string { +function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy: Policy | undefined, translate: LocaleContextProps['translate'], reports?: Array>): string { const fieldValue = getMergeFieldValue(getTransactionDetails(transaction), transaction, field); if (isEmptyMergeValue(fieldValue) || fieldValue === undefined) { @@ -482,6 +521,10 @@ function getDisplayValue(field: MergeFieldKey, transaction: Transaction, transla return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue) : ''; } + if (field === 'taxValue') { + return getTaxName(policy, transaction) ?? transaction.taxValue ?? ''; + } + return SafeString(fieldValue); } /** @@ -498,6 +541,8 @@ function buildMergeFieldsData( targetTransaction: Transaction | undefined, sourceTransaction: Transaction | undefined, mergeTransaction: MergeTransaction | null | undefined, + targetTransactionPolicy: Policy | undefined, + sourceTransactionPolicy: Policy | undefined, translate: LocaleContextProps['translate'], reports: Array> = [], ): MergeFieldData[] { @@ -513,12 +558,12 @@ function buildMergeFieldsData( const options: MergeFieldOption[] = [ { transaction: targetTransaction, - displayValue: getDisplayValue(field, targetTransaction, translate, reports), + displayValue: getDisplayValue(field, targetTransaction, targetTransactionPolicy, translate, reports), isSelected: selectedTransactionId === targetTransaction.transactionID, }, { transaction: sourceTransaction, - displayValue: getDisplayValue(field, sourceTransaction, translate, reports), + displayValue: getDisplayValue(field, sourceTransaction, sourceTransactionPolicy, translate, reports), isSelected: selectedTransactionId === sourceTransaction.transactionID, }, ]; @@ -530,23 +575,36 @@ function buildMergeFieldsData( }; }); } +type GetMergeFieldUpdatedValuesParams = { + transaction: OnyxEntry; + field: K; + fieldValue: MergeTransaction[K]; + mergeTransaction?: OnyxEntry; + searchReports?: Array>; + policy?: OnyxEntry; +}; /** * Build updated values for merge transaction field selection - * Handles special cases like currency for amount field, reportID and additional fields for distance requests + * Handles special cases like currency for amount field, report name, tax value and additional fields for distance requests */ -function getMergeFieldUpdatedValues( - transaction: OnyxEntry, - field: K, - fieldValue: MergeTransaction[K], - searchReports?: Array>, -): MergeTransactionUpdateValues { +function getMergeFieldUpdatedValues({ + transaction, + field, + fieldValue, + mergeTransaction, + searchReports, + policy, +}: GetMergeFieldUpdatedValuesParams): MergeTransactionUpdateValues { const updatedValues: MergeTransactionUpdateValues = { [field]: fieldValue, }; if (field === 'amount') { updatedValues.currency = getCurrency(transaction); + if (mergeTransaction?.taxValue && transaction?.amount) { + updatedValues.taxAmount = convertToBackendAmount(calculateTaxAmount(mergeTransaction?.taxValue, transaction.amount, getCurrencyDecimals(getCurrency(transaction)))); + } } if (field === 'reportID') { @@ -566,6 +624,15 @@ function getMergeFieldUpdatedValues( updatedValues.routes = transaction?.routes ?? null; } + if (field === 'taxValue') { + updatedValues.taxCode = transaction?.taxCode; + updatedValues.taxName = getTaxName(policy, transaction) ?? transaction?.taxValue ?? ''; + updatedValues.taxPolicyID = policy?.id; + if (mergeTransaction?.amount) { + updatedValues.taxAmount = convertToBackendAmount(calculateTaxAmount(transaction?.taxValue, mergeTransaction.amount, getCurrencyDecimals(getCurrency(transaction)))); + } + } + return updatedValues; } @@ -591,10 +658,11 @@ export { getDisplayValue, buildMergeFieldsData, getReportIDForExpense, + getMergeFieldUpdatedValues, getMergeFieldErrorText, areTransactionsEligibleForMerge, + DERIVED_MERGE_FIELDS, getRateFromMerchant, - getMergeFieldUpdatedValues, getTransactionsAndReportsFromSearch, MERGE_FIELDS, }; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 043f0401dd7c6..0356e4661b956 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -802,6 +802,10 @@ function getUpdatedTransaction({ updatedTransaction.tag = transactionChanges.tag; } + if (Object.hasOwn(transactionChanges, 'reportID') && typeof transactionChanges.reportID === 'string') { + updatedTransaction.reportID = transactionChanges.reportID; + } + if (Object.hasOwn(transactionChanges, 'attendees')) { updatedTransaction.comment = { ...updatedTransaction.comment, diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index dbd8a345aa68b..0159bcb3cd564 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -8,9 +8,9 @@ import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; import { areTransactionsEligibleForMerge, + DERIVED_MERGE_FIELDS, getMergeableDataAndConflictFields, getMergeFieldValue, - MERGE_FIELDS, selectTargetAndSourceTransactionsForMerge, shouldNavigateToReceiptReview, } from '@libs/MergeTransactionUtils'; @@ -48,6 +48,7 @@ function setupMergeTransactionDataAndNavigate( searchReports?: Report[], isSelectingSourceTransaction?: boolean, isOnSearch?: boolean, + policies?: Array>, ) { if (!transactions.length || transactions.length > 2) { return; @@ -62,7 +63,12 @@ function setupMergeTransactionDataAndNavigate( } } - const {targetTransaction, sourceTransaction} = selectTargetAndSourceTransactionsForMerge(transactions.at(0), transactions.at(1)); + const {targetTransaction, sourceTransaction, targetTransactionPolicy, sourceTransactionPolicy} = selectTargetAndSourceTransactionsForMerge( + transactions.at(0), + transactions.at(1), + policies?.at(0), + policies?.at(1), + ); if (!targetTransaction || !sourceTransaction) { return; } @@ -83,7 +89,14 @@ function setupMergeTransactionDataAndNavigate( } // If transactions are identical, skip to the confirmation page - const {conflictFields, mergeableData} = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction, localeCompare, searchReports); + const {conflictFields, mergeableData} = getMergeableDataAndConflictFields( + targetTransaction, + sourceTransaction, + localeCompare, + searchReports, + targetTransactionPolicy, + sourceTransactionPolicy, + ); if (!conflictFields.length) { // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page setMergeTransactionKey(navigationTransactionID, mergeableData); @@ -215,7 +228,7 @@ function getOnyxTargetTransactionData({ const targetTransactionDetails = getTransactionDetails(targetTransaction); const filteredTransactionChanges = Object.fromEntries( Object.entries(mergeTransaction).filter(([key, mergeValue]) => { - if (!(MERGE_FIELDS as readonly string[]).includes(key)) { + if (!(DERIVED_MERGE_FIELDS as readonly string[]).includes(key)) { return false; } @@ -344,6 +357,8 @@ function mergeTransactionRequest({ billable: mergeTransaction.billable, reimbursable: mergeTransaction.reimbursable, tag: mergeTransaction.tag, + taxCode: mergeTransaction.taxCode, + taxPolicyID: mergeTransaction.taxPolicyID, receiptID: mergeTransaction.receipt?.receiptID, reportID: mergeTransaction.reportID, }; @@ -386,28 +401,27 @@ function mergeTransactionRequest({ value: sourceTransaction, }; const transactionsOfSourceReport = getReportTransactions(sourceTransaction.reportID); - const optimisticSourceReportData: Array> = - transactionsOfSourceReport.length === 1 - ? [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction.reportID}`, - value: null, - }, - ] - : []; + const shouldDeleteSourceReport = transactionsOfSourceReport.length === 1 && mergeTransaction.reportID !== sourceTransaction.reportID; + const optimisticSourceReportData: Array> = shouldDeleteSourceReport + ? [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction.reportID}`, + value: null, + }, + ] + : []; // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - const failureSourceReportData: Array> = - transactionsOfSourceReport.length === 1 - ? [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction.reportID}`, - value: getReportOrDraftReport(sourceTransaction.reportID), - }, - ] - : []; + const failureSourceReportData: Array> = shouldDeleteSourceReport + ? [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction.reportID}`, + value: getReportOrDraftReport(sourceTransaction.reportID), + }, + ] + : []; const iouActionOfSourceTransaction = getIOUActionForReportID(sourceTransaction.reportID, sourceTransaction.transactionID); const optimisticSourceReportActionData: Array> = iouActionOfSourceTransaction ? [ diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index e1590d3e228ea..43497dd725517 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -969,7 +969,7 @@ function SearchPage({route}: SearchPageProps) { text: translate('common.merge'), icon: expensifyIcons.ArrowCollapse, value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE, - onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, reports, false, true), + onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, reports, false, true, transactionPolicies), }); } } diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index b6a8b43fb6d12..832260cf65189 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -42,15 +42,14 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`, {canBeMissing: true}); - const {targetTransaction, sourceTransaction, targetTransactionReport} = useMergeTransactions({mergeTransaction}); + const {targetTransaction, sourceTransaction, targetTransactionReport, targetTransactionPolicy} = useMergeTransactions({mergeTransaction}); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, { canBeMissing: false, }); - const policyID = targetTransactionReport?.policyID; - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(targetTransactionPolicy?.id)}`, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(targetTransactionPolicy?.id)}`, {canBeMissing: true}); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; @@ -85,7 +84,7 @@ function ConfirmationPage({route}: ConfirmationPageProps) { targetTransactionThreadParentReport, targetTransactionThreadParentReportNextStep, allTransactionViolations, - policy, + policy: targetTransactionPolicy, policyTags, policyCategories, currentUserAccountIDParam, @@ -138,7 +137,7 @@ function ConfirmationPage({route}: ConfirmationPageProps) { >>({}); - const [conflictFields, setConflictFields] = useState([]); - useEffect(() => { + const conflictFields = useMemo(() => { if (!transactionID || !targetTransaction || !sourceTransaction) { - return; + return []; } - const {conflictFields: detectedConflictFields, mergeableData} = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction, localeCompare, [ - targetTransactionReport, - sourceTransactionReport, - ]); + const {conflictFields: detectedConflictFields, mergeableData} = getMergeableDataAndConflictFields( + targetTransaction, + sourceTransaction, + localeCompare, + [targetTransactionReport, sourceTransactionReport], + targetTransactionPolicy, + sourceTransactionPolicy, + ); setMergeTransactionKey(transactionID, mergeableData); - setConflictFields(detectedConflictFields as MergeFieldKey[]); - }, [targetTransaction, sourceTransaction, transactionID, localeCompare, sourceTransactionReport, targetTransactionReport]); + return detectedConflictFields as MergeFieldKey[]; + }, [targetTransaction, sourceTransaction, transactionID, localeCompare, sourceTransactionReport, targetTransactionReport, targetTransactionPolicy, sourceTransactionPolicy]); // Handle selection const handleSelect = useCallback( @@ -77,7 +82,14 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { // Update both the field value and track which transaction was selected (persisted in Onyx) const currentSelections = mergeTransaction?.selectedTransactionByField ?? {}; - const updatedValues = getMergeFieldUpdatedValues(transaction, field, fieldValue, [targetTransactionReport, sourceTransactionReport]); + const updatedValues = getMergeFieldUpdatedValues({ + transaction, + field, + fieldValue, + mergeTransaction, + searchReports: [targetTransactionReport, sourceTransactionReport], + policy: transaction.transactionID === targetTransaction?.transactionID ? targetTransactionPolicy : sourceTransactionPolicy, + }); setMergeTransactionKey(transactionID, { ...updatedValues, @@ -87,7 +99,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { } as Partial>, }); }, - [mergeTransaction?.selectedTransactionByField, transactionID, targetTransactionReport, sourceTransactionReport], + [mergeTransaction, transactionID, targetTransactionReport, sourceTransactionReport, targetTransaction?.transactionID, targetTransactionPolicy, sourceTransactionPolicy], ); // Handle continue @@ -113,8 +125,22 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { // Build merge fields array with all necessary information const mergeFields = useMemo( - () => buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, translate, [targetTransactionReport, sourceTransactionReport]), - [conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionReport, sourceTransactionReport, translate], + () => + buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionPolicy, sourceTransactionPolicy, translate, [ + targetTransactionReport, + sourceTransactionReport, + ]), + [ + conflictFields, + targetTransaction, + sourceTransaction, + mergeTransaction, + targetTransactionReport, + sourceTransactionReport, + targetTransactionPolicy, + sourceTransactionPolicy, + translate, + ], ); // If this screen has multiple "selection cards" on it and the user skips one or more, show an error above the footer button diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx index d153045764616..619588e3cae3b 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx @@ -42,8 +42,9 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr const {isOffline} = useNetwork(); const eligibleTransactions = mergeTransaction?.eligibleTransactions; - const {targetTransaction, sourceTransaction, targetTransactionReport, sourceTransactionReport} = useMergeTransactions({mergeTransaction}); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${targetTransactionReport?.policyID}`, {canBeMissing: true}); + const {targetTransaction, sourceTransaction, targetTransactionReport, sourceTransactionReport, targetTransactionPolicy, sourceTransactionPolicy} = useMergeTransactions({ + mergeTransaction, + }); useEffect(() => { // If the eligible transactions are already loaded, don't fetch them again @@ -51,8 +52,8 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr return; } - getTransactionsForMerging({isOffline, targetTransaction, transactions, policy, report: targetTransactionReport, currentUserLogin}); - }, [transactions, isOffline, mergeTransaction?.eligibleTransactions, policy, targetTransactionReport, currentUserLogin, targetTransaction]); + getTransactionsForMerging({isOffline, targetTransaction, transactions, policy: targetTransactionPolicy, report: targetTransactionReport, currentUserLogin}); + }, [transactions, isOffline, mergeTransaction?.eligibleTransactions, targetTransactionPolicy, targetTransactionReport, currentUserLogin, targetTransaction]); const data = useMemo(() => { if (!eligibleTransactions) { @@ -114,8 +115,11 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr } const reports = targetTransactionReport && sourceTransactionReport ? [targetTransactionReport, sourceTransactionReport] : undefined; - setupMergeTransactionDataAndNavigate(transactionID, [targetTransaction, sourceTransaction], localeCompare, reports, true); - }, [transactionID, targetTransaction, sourceTransaction, targetTransactionReport, sourceTransactionReport, localeCompare]); + setupMergeTransactionDataAndNavigate(transactionID, [targetTransaction, sourceTransaction], localeCompare, reports, true, undefined, [ + targetTransactionPolicy, + sourceTransactionPolicy, + ]); + }, [transactionID, targetTransaction, sourceTransaction, targetTransactionReport, sourceTransactionReport, localeCompare, targetTransactionPolicy, sourceTransactionPolicy]); const confirmButtonOptions = { showButton: true, diff --git a/src/pages/TransactionMerge/ReceiptReviewPage.tsx b/src/pages/TransactionMerge/ReceiptReviewPage.tsx index 644d9109b1f89..8c523b894fcb1 100644 --- a/src/pages/TransactionMerge/ReceiptReviewPage.tsx +++ b/src/pages/TransactionMerge/ReceiptReviewPage.tsx @@ -34,7 +34,7 @@ function ReceiptReviewPage({route}: ReceiptReviewPageProps) { const {transactionID, isOnSearch, backTo} = route.params; const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`, {canBeMissing: true}); - const {targetTransaction, sourceTransaction} = useMergeTransactions({mergeTransaction}); + const {targetTransaction, sourceTransaction, targetTransactionPolicy, sourceTransactionPolicy} = useMergeTransactions({mergeTransaction}); const transactions = [targetTransaction, sourceTransaction].filter((transaction): transaction is Transaction => !!transaction); @@ -47,7 +47,7 @@ function ReceiptReviewPage({route}: ReceiptReviewPageProps) { return; } - const {conflictFields, mergeableData} = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction, localeCompare); + const {conflictFields, mergeableData} = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction, localeCompare, [], targetTransactionPolicy, sourceTransactionPolicy); if (!conflictFields.length) { // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page setMergeTransactionKey(transactionID, mergeableData); diff --git a/src/types/onyx/MergeTransaction.ts b/src/types/onyx/MergeTransaction.ts index 2f74d1ee50f3e..08be4883f296b 100644 --- a/src/types/onyx/MergeTransaction.ts +++ b/src/types/onyx/MergeTransaction.ts @@ -73,6 +73,21 @@ type MergeTransaction = { /** ID of the original transaction */ originalTransactionID?: string; + + /** Tax percentage value of the transaction */ + taxValue: string; + + /** Tax amount of the transaction */ + taxAmount: number; + + /** Tax code of the transaction */ + taxCode: string; + + /** Tax name to display in merge transaction flow */ + taxName: string; + + /** Policy ID of the selected tax rate for the transaction */ + taxPolicyID: string; }; export default MergeTransaction; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 2b2f77f484999..92cd69b7cae22 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -455,6 +455,9 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** The transaction tax value */ taxValue?: string | undefined; + /** The transaction tax name */ + taxName?: string; + /** Whether the expense is billable */ billable?: boolean; diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 6a613c28a7a67..4fe745b772a27 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -1,4 +1,5 @@ import Onyx from 'react-native-onyx'; +import {convertToBackendAmount, getCurrencyDecimals} from '@libs/CurrencyUtils'; import { areTransactionsEligibleForMerge, buildMergedTransactionData, @@ -14,6 +15,7 @@ import { shouldNavigateToReceiptReview, } from '@libs/MergeTransactionUtils'; import {getTransactionDetails} from '@libs/ReportUtils'; +import {calculateTaxAmount} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import createRandomMergeTransaction from '../utils/collections/mergeTransaction'; @@ -597,6 +599,10 @@ describe('MergeTransactionUtils', () => { reportName: 'Test Report', waypoints: {waypoint0: {name: 'Selected waypoint'}}, customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, customUnitID: 'distance1', quantity: 100}, + taxValue: '9%', + taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, getCurrencyDecimals(CONST.CURRENCY.USD))), + taxCode: 'id_TAX_RATE_1', + taxName: 'Tax Rate 1 (9%)', }; const result = buildMergedTransactionData(targetTransaction, mergeTransaction); @@ -624,6 +630,10 @@ describe('MergeTransactionUtils', () => { modifiedCreated: '2025-01-02T00:00:00.000Z', reportID: '1', reportName: 'Test Report', + taxValue: '9%', + taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, getCurrencyDecimals(CONST.CURRENCY.USD))), + taxCode: 'id_TAX_RATE_1', + taxName: 'Tax Rate 1 (9%)', }); }); }); @@ -762,7 +772,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for merchant - const result = getDisplayValue('merchant', transaction, translateLocal); + const result = getDisplayValue('merchant', transaction, undefined, translateLocal); // Then it should return empty string expect(result).toBe(''); @@ -777,8 +787,8 @@ describe('MergeTransactionUtils', () => { }; // When we get display values for boolean fields - const reimbursableResult = getDisplayValue('reimbursable', transaction, translateLocal); - const billableResult = getDisplayValue('billable', transaction, translateLocal); + const reimbursableResult = getDisplayValue('reimbursable', transaction, undefined, translateLocal); + const billableResult = getDisplayValue('billable', transaction, undefined, translateLocal); // Then it should return translated Yes/No values expect(reimbursableResult).toBe('common.yes'); @@ -794,7 +804,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for amount - const result = getDisplayValue('amount', transaction, translateLocal); + const result = getDisplayValue('amount', transaction, undefined, translateLocal); // Then it should return formatted currency string expect(result).toBe('$10.00'); @@ -810,7 +820,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for description - const result = getDisplayValue('description', transaction, translateLocal); + const result = getDisplayValue('description', transaction, undefined, translateLocal); // Then it should return cleaned text without HTML and with spaces instead of line breaks expect(result).toBe('This is a test description with line breaks and more text'); @@ -824,7 +834,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for tag - const result = getDisplayValue('tag', transaction, translateLocal); + const result = getDisplayValue('tag', transaction, undefined, translateLocal); // Then it should return sanitized tag names separated by commas expect(result).toBe('Department, Engineering, Frontend'); @@ -841,7 +851,7 @@ describe('MergeTransactionUtils', () => { ], }, }; - const result = getDisplayValue('attendees', transaction, translateLocal); + const result = getDisplayValue('attendees', transaction, undefined, translateLocal); expect(result).toBe('Test User 2, Test User 1'); }); @@ -856,8 +866,8 @@ describe('MergeTransactionUtils', () => { }; // When we get display values for string fields - const merchantResult = getDisplayValue('merchant', transaction, translateLocal); - const categoryResult = getDisplayValue('category', transaction, translateLocal); + const merchantResult = getDisplayValue('merchant', transaction, undefined, translateLocal); + const categoryResult = getDisplayValue('category', transaction, undefined, translateLocal); // Then it should return the string values expect(merchantResult).toBe('Starbucks Coffee'); @@ -872,7 +882,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for reportID - const result = getDisplayValue('reportID', transaction, translateLocal); + const result = getDisplayValue('reportID', transaction, undefined, translateLocal); // Then it should return translated "None" expect(result).toBe('common.none'); @@ -887,7 +897,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for reportID - const result = getDisplayValue('reportID', transaction, translateLocal); + const result = getDisplayValue('reportID', transaction, undefined, translateLocal); // Then it should return the reportName expect(result).toBe('Test Report Name'); @@ -913,7 +923,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for reportID - const result = getDisplayValue('reportID', transaction, translateLocal); + const result = getDisplayValue('reportID', transaction, undefined, translateLocal); // Then it should return the report's name from Onyx expect(result).toBe(report.reportName); @@ -929,7 +939,7 @@ describe('MergeTransactionUtils', () => { const fieldValue = 'New Merchant Name'; // When we get updated values for merchant field - const result = getMergeFieldUpdatedValues(transaction, 'merchant', fieldValue); + const result = getMergeFieldUpdatedValues({transaction, field: 'merchant', fieldValue, mergeTransaction: undefined}); // Then it should return an object with the field value expect(result).toEqual({ @@ -946,7 +956,7 @@ describe('MergeTransactionUtils', () => { const fieldValue = 2500; // When we get updated values for amount field - const result = getMergeFieldUpdatedValues(transaction, 'amount', fieldValue); + const result = getMergeFieldUpdatedValues({transaction, field: 'amount', fieldValue, mergeTransaction: undefined}); // Then it should include both amount and currency expect(result).toEqual({ @@ -965,7 +975,7 @@ describe('MergeTransactionUtils', () => { const fieldValue = '456'; // When we get updated values for reportID field - const result = getMergeFieldUpdatedValues(transaction, 'reportID', fieldValue); + const result = getMergeFieldUpdatedValues({transaction, field: 'reportID', fieldValue}); // Then it should include both reportID and reportName expect(result).toEqual({ @@ -996,7 +1006,7 @@ describe('MergeTransactionUtils', () => { const fieldValue = 'New Distance Merchant'; // When we get updated values for merchant field - const result = getMergeFieldUpdatedValues(transaction, 'merchant', fieldValue); + const result = getMergeFieldUpdatedValues({transaction, field: 'merchant', fieldValue, mergeTransaction: undefined}); // Then it should include merchant plus all distance-specific fields expect(result).toEqual({ diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts index 5f7d129a499d5..fd2a295ec74ff 100644 --- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts +++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts @@ -689,6 +689,6 @@ describe('useSelectedTransactionsActions', () => { mergeOption?.onSelected?.(); - expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transaction.transactionID, [transaction], mockLocalCompare, [], false, false); + expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transaction.transactionID, [transaction], mockLocalCompare, [], false, false, undefined); }); }); diff --git a/tests/utils/collections/mergeTransaction.ts b/tests/utils/collections/mergeTransaction.ts index 938720a013235..e7f1c0bd8d332 100644 --- a/tests/utils/collections/mergeTransaction.ts +++ b/tests/utils/collections/mergeTransaction.ts @@ -24,5 +24,10 @@ export default function createRandomMergeTransaction(index: number): MergeTransa created: format(randPastDate(), CONST.DATE.FNS_DB_FORMAT_STRING), reportID: index.toString(), reportName: randWord(), + taxAmount: randAmount(), + taxValue: randAmount().toString(), + taxCode: randWord(), + taxName: randWord(), + taxPolicyID: randWord(), }; }