From 8677040effcaa0670b940ea7608bfe3225cd41a6 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 25 Sep 2025 05:08:15 +0700 Subject: [PATCH 01/54] display tax rate in merge details page --- src/libs/MergeTransactionUtils.ts | 21 +++++++++++++------ .../TransactionMerge/DetailsReviewPage.tsx | 8 +++++-- src/types/onyx/MergeTransaction.ts | 3 +++ tests/unit/MergeTransactionUtilsTest.ts | 16 +++++++------- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index d731a51b400d7..53c80495ec397 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -3,7 +3,7 @@ import type {TupleToUnion} from 'type-fest'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type {MergeTransaction, Transaction} from '@src/types/onyx'; +import type {MergeTransaction, Policy, Transaction} from '@src/types/onyx'; import type {Receipt} from '@src/types/onyx/Transaction'; import {convertToDisplayString} from './CurrencyUtils'; import getReceiptFilenameFromTransaction from './getReceiptFilenameFromTransaction'; @@ -13,12 +13,12 @@ import {getIOUActionForReportID} from './ReportActionsUtils'; import {findSelfDMReportID, getReportName, getReportOrDraftReport, getTransactionDetails} from './ReportUtils'; import type {TransactionDetails} from './ReportUtils'; import StringUtils from './StringUtils'; -import {getCurrency, getReimbursable, isCardTransaction, isMerchantMissing} from './TransactionUtils'; +import {getCurrency, getReimbursable, getTaxName, isCardTransaction, isMerchantMissing} from './TransactionUtils'; const RECEIPT_SOURCE_URL = 'https://www.expensify.com/receipts/'; // Define the specific merge fields we want to handle -const MERGE_FIELDS = ['amount', 'currency', 'merchant', 'created', 'category', 'tag', 'description', 'reimbursable', 'billable', 'reportID'] as const; +const MERGE_FIELDS = ['amount', 'currency', 'merchant', 'created', 'category', 'tag', 'description', 'taxValue', 'reimbursable', 'billable', 'reportID'] as const; type MergeFieldKey = TupleToUnion; type MergeFieldOption = { transaction: Transaction; @@ -43,6 +43,7 @@ const MERGE_FIELD_TRANSLATION_KEYS = { billable: 'common.billable', created: 'common.date', reportID: 'common.report', + taxValue: 'iou.taxRate', } as const; // Get the filename from the receipt @@ -144,6 +145,9 @@ function getMergeFieldValue(transactionDetails: TransactionDetails | undefined, if (field === 'merchant' && isMerchantMissing(transaction)) { return ''; } + if (field === 'taxValue') { + return transaction.taxValue; + } return transactionDetails[field] ?? ''; } @@ -328,7 +332,7 @@ function selectTargetAndSourceTransactionsForMerge(originalTargetTransaction: On * @param translate - The translation function * @returns The formatted display string for the field value */ -function getDisplayValue(field: MergeFieldKey, transaction: Transaction, translate: LocaleContextProps['translate']): string { +function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy: Policy | undefined, translate: LocaleContextProps['translate']): string { const fieldValue = getMergeFieldValue(getTransactionDetails(transaction), transaction, field); if (isEmptyMergeValue(fieldValue)) { @@ -349,6 +353,9 @@ function getDisplayValue(field: MergeFieldKey, transaction: Transaction, transla if (field === 'reportID') { return fieldValue === CONST.REPORT.UNREPORTED_REPORT_ID ? translate('common.none') : getReportName(getReportOrDraftReport(fieldValue.toString())); } + if (field === 'taxValue') { + return getTaxName(policy, transaction) ?? ''; + } return String(fieldValue); } /** @@ -365,6 +372,8 @@ function buildMergeFieldsData( targetTransaction: Transaction | undefined, sourceTransaction: Transaction | undefined, mergeTransaction: MergeTransaction | null | undefined, + targetTransactionPolicy: Policy | undefined, + sourceTransactionPolicy: Policy | undefined, translate: LocaleContextProps['translate'], ): MergeFieldData[] { if (!targetTransaction || !sourceTransaction) { @@ -379,12 +388,12 @@ function buildMergeFieldsData( const options: MergeFieldOption[] = [ { transaction: targetTransaction, - displayValue: getDisplayValue(field, targetTransaction, translate), + displayValue: getDisplayValue(field, targetTransaction, targetTransactionPolicy, translate), isSelected: selectedTransactionId === targetTransaction.transactionID, }, { transaction: sourceTransaction, - displayValue: getDisplayValue(field, sourceTransaction, translate), + displayValue: getDisplayValue(field, sourceTransaction, sourceTransactionPolicy, translate), isSelected: selectedTransactionId === sourceTransaction.transactionID, }, ]; diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index 8adba95720498..a0b76c82ece13 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -74,6 +74,10 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { }); const [targetTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`, {canBeMissing: true}); const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector, canBeMissing: false}); + const sourceTransactionThreadReportID = getTransactionThreadReportID(sourceTransaction); + const [sourceTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${sourceTransactionThreadReportID}`, {canBeMissing: true}); + const [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionThreadReport?.policyID}`, {canBeMissing: true}); + const [targetTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${targetTransactionThreadReport?.policyID}`, {canBeMissing: true}); const [hasErrors, setHasErrors] = useState>>({}); const [conflictFields, setConflictFields] = useState([]); @@ -181,8 +185,8 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { // Build merge fields array with all necessary information const mergeFields = useMemo( - () => buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, translate), - [conflictFields, targetTransaction, sourceTransaction, mergeTransaction, translate], + () => buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionPolicy, sourceTransactionPolicy, translate), + [conflictFields, targetTransaction, sourceTransaction, mergeTransaction, 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/types/onyx/MergeTransaction.ts b/src/types/onyx/MergeTransaction.ts index 988ff987ec9a1..3481d783c5ed6 100644 --- a/src/types/onyx/MergeTransaction.ts +++ b/src/types/onyx/MergeTransaction.ts @@ -50,6 +50,9 @@ type MergeTransaction = { /** The report ID of the transaction */ reportID: string; + + /** Tax percentage value of the transaction */ + taxValue: string; }; export default MergeTransaction; diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 72dc2c095323f..a1e0ef52b940d 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -520,7 +520,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(''); @@ -535,8 +535,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'); @@ -552,7 +552,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'); @@ -568,7 +568,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'); @@ -582,7 +582,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'); @@ -598,8 +598,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'); From bd346d2da1ef4eca3b885fd880139f8540ae213a Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 30 Sep 2025 01:21:36 +0700 Subject: [PATCH 02/54] auto calculate tax amount when selecting tax rate or amount --- .../ReportActionItem/MoneyRequestView.tsx | 7 ++++--- src/libs/MergeTransactionUtils.ts | 14 ++++++++++++-- src/pages/TransactionMerge/DetailsReviewPage.tsx | 13 +++++++++++++ src/types/onyx/MergeTransaction.ts | 6 ++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3b1ad6aae3c1b..0f3948ce0774e 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -210,9 +210,10 @@ function MoneyRequestView({ const isInvoice = isInvoiceReport(moneyRequestReport); const isTrackExpense = isTrackExpenseReport(report); 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), transactionCurrency) + : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), transactionCurrency); const taxRatesDescription = taxRates?.name; const taxRateTitle = updatedTransaction ? getTaxName(policy, updatedTransaction) : getTaxName(policy, transaction); diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 53c80495ec397..92cf680ddca2d 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -5,7 +5,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {MergeTransaction, Policy, Transaction} from '@src/types/onyx'; import type {Receipt} from '@src/types/onyx/Transaction'; -import {convertToDisplayString} from './CurrencyUtils'; +import {convertToBackendAmount, convertToDisplayString} from './CurrencyUtils'; import getReceiptFilenameFromTransaction from './getReceiptFilenameFromTransaction'; import Parser from './Parser'; import {getCommaSeparatedTagNameWithSanitizedColons} from './PolicyUtils'; @@ -13,7 +13,7 @@ import {getIOUActionForReportID} from './ReportActionsUtils'; import {findSelfDMReportID, getReportName, getReportOrDraftReport, getTransactionDetails} from './ReportUtils'; import type {TransactionDetails} from './ReportUtils'; import StringUtils from './StringUtils'; -import {getCurrency, getReimbursable, getTaxName, isCardTransaction, isMerchantMissing} from './TransactionUtils'; +import {calculateTaxAmount, getCurrency, getReimbursable, getTaxName, isCardTransaction, isMerchantMissing} from './TransactionUtils'; const RECEIPT_SOURCE_URL = 'https://www.expensify.com/receipts/'; @@ -161,6 +161,12 @@ function getMergeFieldTranslationKey(field: MergeFieldKey) { return MERGE_FIELD_TRANSLATION_KEYS[field]; } +function getMergeTaxAmount(taxPercentage: string | undefined, amount: number, currency: string) { + const taxAmount = calculateTaxAmount(taxPercentage, amount, currency); + const taxAmountInSmallestCurrencyUnits = convertToBackendAmount(Number.parseFloat(taxAmount.toString())); + return taxAmountInSmallestCurrencyUnits; +} + /** * Get mergeableData data if one is missing, and conflict fields that need to be resolved by the user * @param targetTransaction - The target transaction @@ -301,6 +307,9 @@ function buildMergedTransactionData(targetTransaction: OnyxEntry, m created: mergeTransaction.created, modifiedCreated: mergeTransaction.created, reportID: mergeTransaction.reportID, + taxValue: mergeTransaction.taxValue, + taxAmount: mergeTransaction.taxAmount, + taxCode: mergeTransaction.taxCode, }; } @@ -422,6 +431,7 @@ export { getDisplayValue, buildMergeFieldsData, getReportIDForExpense, + getMergeTaxAmount, }; export type {MergeFieldKey, MergeFieldData}; diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index 54c21c92fff81..e7f468c03f27a 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -19,6 +19,7 @@ import { buildMergeFieldsData, getMergeableDataAndConflictFields, getMergeFieldValue, + getMergeTaxAmount, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction, getTransactionThreadReportID, @@ -154,6 +155,18 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { // Update both the field value and track which transaction was selected (persisted in Onyx) const currentSelections = mergeTransaction?.selectedTransactionByField ?? {}; + + // Update tax amount when either tax value or amount is selected + if (mergeTransaction && ((field === 'taxValue' && !isEmptyMergeValue(mergeTransaction.amount)) || (field === 'amount' && !isEmptyMergeValue(mergeTransaction.taxValue)))) { + setMergeTransactionKey(transactionID, { + taxAmount: + field === 'taxValue' + ? getMergeTaxAmount(transaction.taxValue, mergeTransaction.amount, mergeTransaction.currency) + : getMergeTaxAmount(mergeTransaction.taxValue, transaction.amount, transaction.currency), + taxCode: field === 'taxValue' ? transaction.taxCode : mergeTransaction.taxCode, + }); + } + setMergeTransactionKey(transactionID, { [field]: fieldValue, ...(field === 'amount' && {currency: getCurrency(transaction)}), diff --git a/src/types/onyx/MergeTransaction.ts b/src/types/onyx/MergeTransaction.ts index 3481d783c5ed6..6c1d48e3e4e67 100644 --- a/src/types/onyx/MergeTransaction.ts +++ b/src/types/onyx/MergeTransaction.ts @@ -53,6 +53,12 @@ type MergeTransaction = { /** Tax percentage value of the transaction */ taxValue: string; + + /** Tax amount of the transaction */ + taxAmount: number; + + /** Tax code of the transaction */ + taxCode: string; }; export default MergeTransaction; From cee6050591cfa5296e4e0ef0cc5cd4aa2e0340e4 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 30 Sep 2025 01:46:41 +0700 Subject: [PATCH 03/54] auto calculate tax amount when selecting tax value --- src/libs/MergeTransactionUtils.ts | 5 ++++- src/pages/TransactionMerge/DetailsReviewPage.tsx | 16 ++-------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 92cf680ddca2d..a263bdac476e4 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -241,6 +241,9 @@ function getMergeableDataAndConflictFields(targetTransaction: OnyxEntry, m modifiedCreated: mergeTransaction.created, reportID: mergeTransaction.reportID, taxValue: mergeTransaction.taxValue, - taxAmount: mergeTransaction.taxAmount, + taxAmount: getMergeTaxAmount(mergeTransaction.taxValue, mergeTransaction.amount, mergeTransaction.currency), taxCode: mergeTransaction.taxCode, }; } diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index e7f468c03f27a..75c52c4095b93 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -19,7 +19,6 @@ import { buildMergeFieldsData, getMergeableDataAndConflictFields, getMergeFieldValue, - getMergeTaxAmount, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction, getTransactionThreadReportID, @@ -31,7 +30,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import {getTransactionDetails} from '@libs/ReportUtils'; -import {getCurrency} from '@libs/TransactionUtils'; +import {getCurrency, getTaxCode} from '@libs/TransactionUtils'; import {createTransactionThreadReport, openReport} from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -155,21 +154,10 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { // Update both the field value and track which transaction was selected (persisted in Onyx) const currentSelections = mergeTransaction?.selectedTransactionByField ?? {}; - - // Update tax amount when either tax value or amount is selected - if (mergeTransaction && ((field === 'taxValue' && !isEmptyMergeValue(mergeTransaction.amount)) || (field === 'amount' && !isEmptyMergeValue(mergeTransaction.taxValue)))) { - setMergeTransactionKey(transactionID, { - taxAmount: - field === 'taxValue' - ? getMergeTaxAmount(transaction.taxValue, mergeTransaction.amount, mergeTransaction.currency) - : getMergeTaxAmount(mergeTransaction.taxValue, transaction.amount, transaction.currency), - taxCode: field === 'taxValue' ? transaction.taxCode : mergeTransaction.taxCode, - }); - } - setMergeTransactionKey(transactionID, { [field]: fieldValue, ...(field === 'amount' && {currency: getCurrency(transaction)}), + ...(field === 'taxValue' && {taxCode: getTaxCode(transaction)}), selectedTransactionByField: { ...currentSelections, [field]: transaction.transactionID, From 20274b36f1cd743ef5c57b9de1ff47c6d0bc1548 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 30 Sep 2025 02:18:49 +0700 Subject: [PATCH 04/54] centralize merge tax logic --- src/components/ReportActionItem/MoneyRequestView.tsx | 4 ++-- src/libs/API/parameters/MergeTransactionParams.ts | 9 +++++++++ src/libs/actions/MergeTransaction.ts | 3 +++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 0f3948ce0774e..84db652a0f458 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -212,8 +212,8 @@ function MoneyRequestView({ const taxRates = policy?.taxRates; const formattedTaxAmount = updatedTransaction?.taxAmount !== undefined - ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), transactionCurrency) - : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), transactionCurrency); + ? convertToDisplayString(Math.abs(updatedTransaction.taxAmount), actualCurrency) + : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); const taxRatesDescription = taxRates?.name; const taxRateTitle = updatedTransaction ? getTaxName(policy, updatedTransaction) : getTaxName(policy, transaction); diff --git a/src/libs/API/parameters/MergeTransactionParams.ts b/src/libs/API/parameters/MergeTransactionParams.ts index 9fb0c04ebdb19..140e9878b5613 100644 --- a/src/libs/API/parameters/MergeTransactionParams.ts +++ b/src/libs/API/parameters/MergeTransactionParams.ts @@ -28,6 +28,15 @@ type MergeTransactionParams = { /** The receiptID we want to keep */ receiptID: number | undefined; + + /** Tax percentage value we're keeping */ + taxValue: string; + + /** Tax amount we're keeping */ + taxAmount: number; + + /** Tax code we're keeping */ + taxCode: string; }; export default MergeTransactionParams; diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 807d10e29d554..c5a4d18e27685 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -181,6 +181,9 @@ function mergeTransactionRequest(mergeTransactionID: string, mergeTransaction: M tag: mergeTransaction.tag, receiptID: mergeTransaction.receipt?.receiptID, reportID: mergeTransaction.reportID, + taxValue: mergeTransaction.taxValue, + taxAmount: mergeTransaction.taxAmount, + taxCode: mergeTransaction.taxCode, }; const targetTransactionUpdated = getOptimisticTargetTransactionData(targetTransaction, mergeTransaction); From 33185a351e6b5b5d3c54185aad04ab643be3d31e Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 30 Sep 2025 02:20:38 +0700 Subject: [PATCH 05/54] fix typescript check --- tests/utils/collections/mergeTransaction.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/utils/collections/mergeTransaction.ts b/tests/utils/collections/mergeTransaction.ts index dd45e28fa951b..0ce796037df00 100644 --- a/tests/utils/collections/mergeTransaction.ts +++ b/tests/utils/collections/mergeTransaction.ts @@ -23,5 +23,8 @@ export default function createRandomMergeTransaction(index: number): MergeTransa receipt: {}, created: format(randPastDate(), CONST.DATE.FNS_DB_FORMAT_STRING), reportID: index.toString(), + taxAmount: randAmount(), + taxValue: randAmount().toString(), + taxCode: randWord(), }; } From 93172bf684a4a913f1a257c6e89d5552e9e678e3 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 30 Sep 2025 02:45:42 +0700 Subject: [PATCH 06/54] fix unit test --- tests/unit/MergeTransactionUtilsTest.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index a1e0ef52b940d..5e6689f706bc4 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -5,6 +5,7 @@ import { getMergeableDataAndConflictFields, getMergeFieldTranslationKey, getMergeFieldValue, + getMergeTaxAmount, getSourceTransactionFromMergeTransaction, isEmptyMergeValue, selectTargetAndSourceTransactionsForMerge, @@ -410,6 +411,8 @@ describe('MergeTransactionUtils', () => { receipt: {receiptID: 1235, source: 'merged.jpg', filename: 'merged.jpg'}, created: '2025-01-02T00:00:00.000Z', reportID: '1', + taxValue: '9%', + taxCode: 'id_TAX_RATE_1', }; const result = buildMergedTransactionData(targetTransaction, mergeTransaction); @@ -435,6 +438,9 @@ describe('MergeTransactionUtils', () => { created: '2025-01-02T00:00:00.000Z', modifiedCreated: '2025-01-02T00:00:00.000Z', reportID: '1', + taxValue: '9%', + taxAmount: getMergeTaxAmount('9%', 2000, 'USD'), + taxCode: 'id_TAX_RATE_1', }); }); }); From 7b3c39386a84fc8017e105de6c6f872546ce9192 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 2 Oct 2025 01:16:44 +0700 Subject: [PATCH 07/54] remove redundant conversion --- src/libs/MergeTransactionUtils.ts | 9 +-------- tests/unit/MergeTransactionUtilsTest.ts | 5 +++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index a263bdac476e4..5ee359c38b743 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -161,12 +161,6 @@ function getMergeFieldTranslationKey(field: MergeFieldKey) { return MERGE_FIELD_TRANSLATION_KEYS[field]; } -function getMergeTaxAmount(taxPercentage: string | undefined, amount: number, currency: string) { - const taxAmount = calculateTaxAmount(taxPercentage, amount, currency); - const taxAmountInSmallestCurrencyUnits = convertToBackendAmount(Number.parseFloat(taxAmount.toString())); - return taxAmountInSmallestCurrencyUnits; -} - /** * Get mergeableData data if one is missing, and conflict fields that need to be resolved by the user * @param targetTransaction - The target transaction @@ -311,7 +305,7 @@ function buildMergedTransactionData(targetTransaction: OnyxEntry, m modifiedCreated: mergeTransaction.created, reportID: mergeTransaction.reportID, taxValue: mergeTransaction.taxValue, - taxAmount: getMergeTaxAmount(mergeTransaction.taxValue, mergeTransaction.amount, mergeTransaction.currency), + taxAmount: convertToBackendAmount(calculateTaxAmount(mergeTransaction.taxValue, mergeTransaction.amount, mergeTransaction.currency)), taxCode: mergeTransaction.taxCode, }; } @@ -434,7 +428,6 @@ export { getDisplayValue, buildMergeFieldsData, getReportIDForExpense, - getMergeTaxAmount, }; export type {MergeFieldKey, MergeFieldData}; diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 5e6689f706bc4..87d93deaf1ab8 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -1,3 +1,4 @@ +import {convertToBackendAmount} from '@libs/CurrencyUtils'; import {translateLocal} from '@libs/Localize'; import { buildMergedTransactionData, @@ -5,13 +6,13 @@ import { getMergeableDataAndConflictFields, getMergeFieldTranslationKey, getMergeFieldValue, - getMergeTaxAmount, getSourceTransactionFromMergeTransaction, isEmptyMergeValue, selectTargetAndSourceTransactionsForMerge, shouldNavigateToReceiptReview, } from '@libs/MergeTransactionUtils'; import {getTransactionDetails} from '@libs/ReportUtils'; +import {calculateTaxAmount} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import createRandomMergeTransaction from '../utils/collections/mergeTransaction'; import createRandomTransaction from '../utils/collections/transaction'; @@ -439,7 +440,7 @@ describe('MergeTransactionUtils', () => { modifiedCreated: '2025-01-02T00:00:00.000Z', reportID: '1', taxValue: '9%', - taxAmount: getMergeTaxAmount('9%', 2000, 'USD'), + taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, 'USD')), taxCode: 'id_TAX_RATE_1', }); }); From 2175bced0d6fdd286007a3db479f588ecb2da63d Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 9 Oct 2025 16:41:53 +0700 Subject: [PATCH 08/54] only show policy specific fields for supported reports --- src/pages/TransactionMerge/ConfirmationPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index b9b8e331dcd95..495b7665c2acf 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -45,10 +45,11 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const targetTransactionThreadReportID = getTransactionThreadReportID(targetTransaction); const targetTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`]; - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${targetTransactionThreadReport?.policyID}`, {canBeMissing: true}); // Build the merged transaction data for display const mergedTransactionData = useMemo(() => buildMergedTransactionData(targetTransaction, mergeTransaction), [targetTransaction, mergeTransaction]); + const mergedTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${mergedTransactionData?.reportID}`]; + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${mergedTransactionThreadReport?.policyID ?? targetTransactionThreadReport?.policyID}`, {canBeMissing: true}); const contextValue = useMemo( () => ({ From 029421346cc8b4c53b65cabb5073206e980fe4cc Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 9 Oct 2025 18:03:47 +0700 Subject: [PATCH 09/54] update merge transaction in Onyx after calculating tax --- src/libs/actions/MergeTransaction.ts | 2 +- src/pages/TransactionMerge/ConfirmationPage.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 17dd32bf20c8a..b91db0ece53f1 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -22,7 +22,7 @@ function setupMergeTransactionData(transactionID: string, values: Partial) { +function setMergeTransactionKey(transactionID: string, values: Partial | null) { Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, values); } diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 495b7665c2acf..a090944d7a927 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -14,7 +14,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {mergeTransactionRequest} from '@libs/actions/MergeTransaction'; +import {mergeTransactionRequest, setMergeTransactionKey} from '@libs/actions/MergeTransaction'; import {buildMergedTransactionData, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction, getTransactionThreadReportID} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -51,6 +51,10 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const mergedTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${mergedTransactionData?.reportID}`]; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${mergedTransactionThreadReport?.policyID ?? targetTransactionThreadReport?.policyID}`, {canBeMissing: true}); + useEffect(() => { + setMergeTransactionKey(transactionID, mergedTransactionData); + }, [mergedTransactionData, transactionID]); + const contextValue = useMemo( () => ({ transactionThreadReport: targetTransactionThreadReport, From d0c7366daea3781c2d0fa43a2256c4961652c8b0 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 14 Oct 2025 16:58:34 +0700 Subject: [PATCH 10/54] fix typecheck --- tests/unit/MergeTransactionUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 1e1e97b00758c..63680bb258856 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -701,7 +701,7 @@ describe('MergeTransactionUtils', () => { {email: 'test1@example.com', displayName: 'Test User 1', avatarUrl: '', login: 'test1'}, ]; - const result = getDisplayValue('attendees', transaction, translateLocal); + const result = getDisplayValue('attendees', transaction, undefined, translateLocal); expect(result).toBe('Test User 2, Test User 1'); }); From 84cbc1b8d849c2240be5d4624b41b3ab1a4a8708 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 17 Oct 2025 02:32:53 +0700 Subject: [PATCH 11/54] revert: only show policy specific fields for supported reports --- src/pages/TransactionMerge/ConfirmationPage.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index e0dbf26c62ff0..9ee9befcb56c8 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -52,8 +52,6 @@ function ConfirmationPage({route}: ConfirmationPageProps) { // Build the merged transaction data for display const mergedTransactionData = useMemo(() => buildMergedTransactionData(targetTransaction, mergeTransaction), [targetTransaction, mergeTransaction]); - const mergedTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${mergedTransactionData?.reportID}`]; - const [mergedTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${mergedTransactionThreadReport?.policyID ?? targetTransactionThreadReport?.policyID}`, {canBeMissing: true}); useEffect(() => { setMergeTransactionKey(transactionID, mergedTransactionData); @@ -114,7 +112,7 @@ function ConfirmationPage({route}: ConfirmationPageProps) { Date: Fri, 17 Oct 2025 03:23:06 +0700 Subject: [PATCH 12/54] sort eligible transactions list by creation data --- .../MergeTransactionsListContent.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx index 605826866606a..0deb0331067bd 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx @@ -65,12 +65,14 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr const sections = useMemo(() => { return [ { - data: (eligibleTransactions ?? []).map((eligibleTransaction) => ({ - ...fillMissingReceiptSource(eligibleTransaction), - keyForList: eligibleTransaction.transactionID, - isSelected: eligibleTransaction.transactionID === mergeTransaction?.sourceTransactionID, - errors: eligibleTransaction.errors as Errors | undefined, - })), + data: (eligibleTransactions ?? []) + .map((eligibleTransaction) => ({ + ...fillMissingReceiptSource(eligibleTransaction), + keyForList: eligibleTransaction.transactionID, + isSelected: eligibleTransaction.transactionID === mergeTransaction?.sourceTransactionID, + errors: eligibleTransaction.errors as Errors | undefined, + })) + .sort((a, b) => b.created.localeCompare(a.created)), shouldShow: true, }, ]; From 3e06a582ca6d9553d2d34f62a8d723cd1fff1fb5 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 17 Oct 2025 03:23:20 +0700 Subject: [PATCH 13/54] save tax optimistically --- src/libs/actions/MergeTransaction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 61f328934a164..98dbd5da40a19 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -159,7 +159,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 (key !== 'taxCode' && key !== 'taxAmount' && !(MERGE_FIELDS as readonly string[]).includes(key)) { return false; } From 552fafef45a43a3bf0bce8f5a4a79df061d816f2 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 17 Oct 2025 03:54:44 +0700 Subject: [PATCH 14/54] fix lint --- src/pages/TransactionMerge/MergeTransactionsListContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx index 0deb0331067bd..f62fba4f5c37f 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx @@ -72,11 +72,11 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr isSelected: eligibleTransaction.transactionID === mergeTransaction?.sourceTransactionID, errors: eligibleTransaction.errors as Errors | undefined, })) - .sort((a, b) => b.created.localeCompare(a.created)), + .sort((a, b) => localeCompare(b.created, a.created)), shouldShow: true, }, ]; - }, [eligibleTransactions, mergeTransaction]); + }, [eligibleTransactions, mergeTransaction, localeCompare]); const handleSelectRow = useCallback( (item: MergeTransactionListItemType) => { From d4011dea264c631230813e2fcc2730699b5b5fb2 Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 20 Oct 2025 17:24:47 +0700 Subject: [PATCH 15/54] refactor: define list of derived merge fields --- src/libs/MergeTransactionUtils.ts | 3 +++ src/libs/actions/MergeTransaction.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 1a1820d0e63a5..1a1ccf084ea2a 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -21,6 +21,8 @@ const RECEIPT_SOURCE_URL = 'https://www.expensify.com/receipts/'; // Define the specific merge fields we want to handle const MERGE_FIELDS = ['amount', 'currency', '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; @@ -459,6 +461,7 @@ export { getReportIDForExpense, getMergeFieldErrorText, MERGE_FIELDS, + DERIVED_MERGE_FIELDS, }; export type {MergeFieldKey, MergeFieldData}; diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 98dbd5da40a19..f6c8af5ccc555 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -4,7 +4,7 @@ import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; import type {GetTransactionsForMergingParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import {getMergeFieldValue, getTransactionThreadReportID, MERGE_FIELDS} from '@libs/MergeTransactionUtils'; +import {DERIVED_MERGE_FIELDS, getMergeFieldValue, getTransactionThreadReportID} from '@libs/MergeTransactionUtils'; import type {MergeFieldKey} from '@libs/MergeTransactionUtils'; import {isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import {getIOUActionForReportID} from '@libs/ReportActionsUtils'; @@ -159,7 +159,7 @@ function getOnyxTargetTransactionData( const targetTransactionDetails = getTransactionDetails(targetTransaction); const filteredTransactionChanges = Object.fromEntries( Object.entries(mergeTransaction).filter(([key, mergeValue]) => { - if (key !== 'taxCode' && key !== 'taxAmount' && !(MERGE_FIELDS as readonly string[]).includes(key)) { + if (!(DERIVED_MERGE_FIELDS as readonly string[]).includes(key)) { return false; } From 899649a5dec21d8faecf7079e02646496a27f2fc Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 23 Oct 2025 23:34:25 +0700 Subject: [PATCH 16/54] use comment.udfs --- src/libs/API/parameters/MergeTransactionParams.ts | 9 --------- src/libs/actions/MergeTransaction.ts | 7 ++++--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/libs/API/parameters/MergeTransactionParams.ts b/src/libs/API/parameters/MergeTransactionParams.ts index 140e9878b5613..9fb0c04ebdb19 100644 --- a/src/libs/API/parameters/MergeTransactionParams.ts +++ b/src/libs/API/parameters/MergeTransactionParams.ts @@ -28,15 +28,6 @@ type MergeTransactionParams = { /** The receiptID we want to keep */ receiptID: number | undefined; - - /** Tax percentage value we're keeping */ - taxValue: string; - - /** Tax amount we're keeping */ - taxAmount: number; - - /** Tax code we're keeping */ - taxCode: string; }; export default MergeTransactionParams; diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 8e5773db88d59..50ebf62a3e4fd 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -224,15 +224,16 @@ function mergeTransactionRequest({mergeTransactionID, mergeTransaction, targetTr ...targetTransaction.comment, comment: mergeTransaction.description, attendees: mergeTransaction.attendees, + udfs: { + modifiedTaxAmount: mergeTransaction.taxAmount, + value: mergeTransaction.taxValue, + }, }), billable: mergeTransaction.billable, reimbursable: mergeTransaction.reimbursable, tag: mergeTransaction.tag, receiptID: mergeTransaction.receipt?.receiptID, reportID: mergeTransaction.reportID, - taxValue: mergeTransaction.taxValue, - taxAmount: mergeTransaction.taxAmount, - taxCode: mergeTransaction.taxCode, }; const onyxTargetTransactionData = getOnyxTargetTransactionData(targetTransaction, mergeTransaction, policy, policyTags, policyCategories); From 7ba8864e4607566cad8b16a9164b0a0e2c8a2846 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 31 Oct 2025 06:04:40 +0700 Subject: [PATCH 17/54] remove redundant changes --- src/libs/MergeTransactionUtils.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 839c8013059d7..e143949d3ac79 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -394,10 +394,6 @@ function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy: return getTaxName(policy, transaction) ?? ''; } - if (field === 'taxValue') { - return getTaxName(policy, transaction) ?? ''; - } - return SafeString(fieldValue); } /** From a29850e247841a610e358f3817bfe33c541efaaf Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 31 Oct 2025 06:31:09 +0700 Subject: [PATCH 18/54] incrementally update tax and amount based on selection --- src/libs/MergeTransactionUtils.ts | 42 ++++++++++++++++++- src/libs/actions/MergeTransaction.ts | 8 ++-- .../TransactionMerge/ConfirmationPage.tsx | 8 +--- .../TransactionMerge/DetailsReviewPage.tsx | 7 ++-- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index e143949d3ac79..4fb174d2d520f 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -37,6 +37,9 @@ type MergeFieldData = { options: MergeFieldOption[]; }; +/** Type for merge transaction values that can be null to clear existing values in Onyx */ +type MergeTransactionUpdateValues = Partial>; + const MERGE_FIELD_TRANSLATION_KEYS = { amount: 'iou.amount', currency: 'iou.currency', @@ -332,7 +335,7 @@ function buildMergedTransactionData(targetTransaction: OnyxEntry, m modifiedCreated: mergeTransaction.created, reportID: mergeTransaction.reportID, taxValue: mergeTransaction.taxValue, - taxAmount: convertToBackendAmount(calculateTaxAmount(mergeTransaction.taxValue, mergeTransaction.amount, mergeTransaction.currency)), + taxAmount: mergeTransaction.taxAmount, taxCode: mergeTransaction.taxCode, }; } @@ -444,6 +447,40 @@ function buildMergeFieldsData( }); } +type GetMergeFieldUpdatedValuesParams = { + transaction: OnyxEntry; + field: K; + fieldValue: MergeTransaction[K]; + mergeTransaction: OnyxEntry; +}; + +/** + * Build updated values for merge transaction field selection + * Handles special cases like currency for amount field, reportID + */ +function getMergeFieldUpdatedValues(params: GetMergeFieldUpdatedValuesParams): MergeTransactionUpdateValues { + const {transaction, field, fieldValue, mergeTransaction} = params; + 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, getCurrency(transaction))); + } + } + + if (field === 'taxValue') { + updatedValues.taxCode = transaction?.taxCode; + if (mergeTransaction?.amount) { + updatedValues.taxAmount = convertToBackendAmount(calculateTaxAmount(transaction?.taxValue, mergeTransaction.amount, mergeTransaction.currency)); + } + } + + return updatedValues; +} + export { getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction, @@ -460,9 +497,10 @@ export { getDisplayValue, buildMergeFieldsData, getReportIDForExpense, + getMergeFieldUpdatedValues, getMergeFieldErrorText, MERGE_FIELDS, DERIVED_MERGE_FIELDS, }; -export type {MergeFieldKey, MergeFieldData}; +export type {MergeFieldKey, MergeFieldData, MergeTransactionUpdateValues}; diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 50ebf62a3e4fd..3850dca2c7b36 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -1,11 +1,11 @@ import {deepEqual} from 'fast-equals'; import Onyx from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, OnyxMergeInput, OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; import type {GetTransactionsForMergingParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {DERIVED_MERGE_FIELDS, getMergeFieldValue, getTransactionThreadReportID} from '@libs/MergeTransactionUtils'; -import type {MergeFieldKey} from '@libs/MergeTransactionUtils'; +import type {MergeFieldKey, MergeTransactionUpdateValues} from '@libs/MergeTransactionUtils'; import {isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import {getIOUActionForReportID} from '@libs/ReportActionsUtils'; import { @@ -34,8 +34,8 @@ function setupMergeTransactionData(transactionID: string, values: Partial | null) { - Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, values); +function setMergeTransactionKey(transactionID: string, values: MergeTransactionUpdateValues) { + Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, values as OnyxMergeInput<`${typeof ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${string}`>); } /** diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 3690deb728f17..0f6b2dee426d4 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -14,7 +14,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {mergeTransactionRequest, setMergeTransactionKey} from '@libs/actions/MergeTransaction'; +import {mergeTransactionRequest} from '@libs/actions/MergeTransaction'; import {buildMergedTransactionData, getReportIDForExpense, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -65,10 +65,6 @@ function ConfirmationPage({route}: ConfirmationPageProps) { // Build the merged transaction data for display const mergedTransactionData = useMemo(() => buildMergedTransactionData(targetTransaction, mergeTransaction), [targetTransaction, mergeTransaction]); - useEffect(() => { - setMergeTransactionKey(transactionID, mergedTransactionData); - }, [mergedTransactionData, transactionID]); - const contextValue = useMemo( () => ({ transactionThreadReport: targetTransactionThreadReport, diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index 06dc5e039191b..a87fa2bab05b3 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -19,6 +19,7 @@ import { buildMergeFieldsData, getMergeableDataAndConflictFields, getMergeFieldErrorText, + getMergeFieldUpdatedValues, getMergeFieldValue, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction, @@ -31,7 +32,6 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; import {getTransactionDetails} from '@libs/ReportUtils'; -import {getCurrency, getTaxCode} from '@libs/TransactionUtils'; import {createTransactionThreadReport, openReport} from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -155,10 +155,9 @@ 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, mergeTransaction}); setMergeTransactionKey(transactionID, { - [field]: fieldValue, - ...(field === 'amount' && {currency: getCurrency(transaction)}), - ...(field === 'taxValue' && {taxCode: getTaxCode(transaction)}), + ...updatedValues, selectedTransactionByField: { ...currentSelections, [field]: transaction.transactionID, From 3ce7d2f3c6398d41370abccf9d80b2191bdebd79 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 31 Oct 2025 06:32:39 +0700 Subject: [PATCH 19/54] send taxCode to Transaction_Merge API --- src/libs/actions/MergeTransaction.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 3850dca2c7b36..03367306de64e 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -224,14 +224,11 @@ function mergeTransactionRequest({mergeTransactionID, mergeTransaction, targetTr ...targetTransaction.comment, comment: mergeTransaction.description, attendees: mergeTransaction.attendees, - udfs: { - modifiedTaxAmount: mergeTransaction.taxAmount, - value: mergeTransaction.taxValue, - }, }), billable: mergeTransaction.billable, reimbursable: mergeTransaction.reimbursable, tag: mergeTransaction.tag, + taxCode: mergeTransaction.taxCode, receiptID: mergeTransaction.receipt?.receiptID, reportID: mergeTransaction.reportID, }; From 130ff06ccb5a231b0f791600e2479d1e31376fcb Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 4 Nov 2025 00:03:20 +0700 Subject: [PATCH 20/54] fix unit test --- tests/unit/MergeTransactionUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 3180346177c4f..be74284f209db 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -523,6 +523,7 @@ describe('MergeTransactionUtils', () => { reportID: '1', reportName: 'Test Report', taxValue: '9%', + taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, 'USD')), taxCode: 'id_TAX_RATE_1', }; From d14c2b445b7a5f18580696b729c8cb2759af8851 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 6 Nov 2025 14:58:56 +0700 Subject: [PATCH 21/54] fallback tax rate --- src/components/ReportActionItem/MoneyRequestView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 4afbed8a26dfa..5e8098baa90d7 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -235,7 +235,7 @@ function MoneyRequestView({ const taxRateTitle = updatedTransaction ? getTaxName(policy, updatedTransaction) : getTaxName(policy, transaction); const actualTransactionDate = isFromMergeTransaction && updatedTransaction ? getFormattedCreated(updatedTransaction) : transactionDate; - const fallbackTaxRateTitle = transaction?.taxValue; + const fallbackTaxRateTitle = updatedTransaction?.taxValue ?? transaction?.taxValue; const isSettled = isSettledReportUtils(moneyRequestReport?.reportID); const isCancelled = moneyRequestReport && moneyRequestReport?.isCancelledIOU; From 56387698b77fbd53792df49d06462d1bc7b5efbd Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 10 Nov 2025 17:36:05 +0700 Subject: [PATCH 22/54] auto merge taxAmount --- src/libs/MergeTransactionUtils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 53d439b13e992..28a9dac247904 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -269,6 +269,9 @@ function getMergeableDataAndConflictFields(targetTransaction: OnyxEntry Date: Mon, 24 Nov 2025 19:33:44 +0700 Subject: [PATCH 23/54] fix unit test --- tests/unit/MergeTransactionUtilsTest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 17cd2b844eeae..c472867699d08 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -768,7 +768,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({ @@ -785,7 +785,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({ @@ -816,7 +816,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({ From 6af2fd0a1bbd1ca8b45a0f6a07af8831f3c0fc93 Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 24 Nov 2025 19:34:38 +0700 Subject: [PATCH 24/54] fix source transaction tax name is missing --- src/libs/MergeTransactionUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index d69cec5924218..922819e3caba7 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -421,7 +421,7 @@ function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy: } if (field === 'taxValue') { - return getTaxName(policy, transaction) ?? ''; + return getTaxName(policy, transaction) ?? transaction.taxValue ?? ''; } return SafeString(fieldValue); From 823cc4c994d5d3c9b568145927192ef49647e9b7 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 27 Nov 2025 15:00:16 +0700 Subject: [PATCH 25/54] fix typecheck --- src/libs/MergeTransactionUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 15b072e76bf0b..ca4258c809157 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -255,7 +255,7 @@ function getMergeableDataAndConflictFields(targetTransaction: OnyxEntry Date: Thu, 27 Nov 2025 15:01:58 +0700 Subject: [PATCH 26/54] fix typecheck --- tests/unit/MergeTransactionUtilsTest.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 710f19e1c1fcd..890f0fe6225ca 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -800,7 +800,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'); @@ -815,7 +815,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'); @@ -841,7 +841,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); @@ -891,7 +891,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({ From cd3db9449cf02449fb0cad2b68f20ddcc96a2dce Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 2 Dec 2025 02:08:52 +0700 Subject: [PATCH 27/54] fix: tax does not show in confirm step if target policy does not support tax --- src/components/ReportActionItem/MoneyRequestView.tsx | 10 +++++++--- src/pages/TransactionMerge/ConfirmationPage.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 539367cc7b33b..03094637499c3 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -98,7 +98,7 @@ type MoneyRequestViewProps = { /** The report currently being looked at */ report: OnyxEntry; - /** 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 */ @@ -115,12 +115,16 @@ type MoneyRequestViewProps = { /** Merge transaction ID to show in merge transaction flow */ mergeTransactionID?: string; + + /** Source transaction policy in merge transaction flow */ + sourceTransactionPolicy?: OnyxEntry; }; function MoneyRequestView({ allReports, report, expensePolicy, + sourceTransactionPolicy, shouldShowAnimatedBackground, readonly = false, updatedTransaction, @@ -244,7 +248,7 @@ function MoneyRequestView({ : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); const taxRatesDescription = taxRates?.name; - const taxRateTitle = updatedTransaction ? getTaxName(policy, updatedTransaction) : getTaxName(policy, transaction); + const taxRateTitle = updatedTransaction ? getTaxName(sourceTransactionPolicy ?? policy, updatedTransaction) : getTaxName(policy, transaction); const actualTransactionDate = isFromMergeTransaction && updatedTransaction ? getFormattedCreated(updatedTransaction) : transactionDate; const fallbackTaxRateTitle = updatedTransaction?.taxValue ?? transaction?.taxValue; @@ -308,7 +312,7 @@ function MoneyRequestView({ const canEditReimbursable = isEditable && canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, undefined, isChatReportArchived); const shouldShowAttendees = useMemo(() => shouldShowAttendeesTransactionUtils(iouType, policy), [iouType, policy]); - const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat || isExpenseUnreported, policy, isDistanceRequest, isPerDiemRequest); + const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat || isExpenseUnreported, sourceTransactionPolicy ?? policy, isDistanceRequest, isPerDiemRequest); const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 1be16a85a8d13..7ff5c88e7c248 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -17,7 +17,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {mergeTransactionRequest} from '@libs/actions/MergeTransaction'; -import {buildMergedTransactionData, getReportIDForExpense, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction} from '@libs/MergeTransactionUtils'; +import {buildMergedTransactionData, getReportIDForExpense, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction, getTransactionThreadReportID} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; @@ -63,6 +63,11 @@ function ConfirmationPage({route}: ConfirmationPageProps) { 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 sourceTransactionThreadReportID = getTransactionThreadReportID(sourceTransaction); + const [sourceTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${sourceTransactionThreadReportID}`, {canBeMissing: true}); + const [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionThreadReport?.policyID}`, {canBeMissing: true}); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; @@ -151,6 +156,7 @@ function ConfirmationPage({route}: ConfirmationPageProps) { Date: Tue, 2 Dec 2025 02:14:49 +0700 Subject: [PATCH 28/54] fix failed checks --- src/libs/MergeTransactionUtils.ts | 3 ++- src/pages/TransactionMerge/ConfirmationPage.tsx | 8 +++++++- src/pages/TransactionMerge/DetailsReviewPage.tsx | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index b0706655c5b3c..8105482487610 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -23,7 +23,8 @@ import { getReimbursable, getTaxName, getWaypoints, - isDistanceRequest, isExpenseSplit, + isDistanceRequest, + isExpenseSplit, isManagedCardTransaction, isMerchantMissing, } from './TransactionUtils'; diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 7ff5c88e7c248..13c0b65a86b70 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -17,7 +17,13 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {mergeTransactionRequest} from '@libs/actions/MergeTransaction'; -import {buildMergedTransactionData, getReportIDForExpense, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction, getTransactionThreadReportID} from '@libs/MergeTransactionUtils'; +import { + buildMergedTransactionData, + getReportIDForExpense, + getSourceTransactionFromMergeTransaction, + getTargetTransactionFromMergeTransaction, + getTransactionThreadReportID, +} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index 7e05fc02fce74..c8b76c60d0bd5 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -170,7 +170,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { } as Partial>, }); }, - [mergeTransaction?.selectedTransactionByField, transactionID], + [mergeTransaction, transactionID], ); // Handle continue From 2b755941f40cf79d85a169a5b8badbd664d7b318 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 5 Dec 2025 01:27:20 +0700 Subject: [PATCH 29/54] do not use transactionThreadReport --- src/pages/TransactionMerge/ConfirmationPage.tsx | 13 +++---------- src/pages/TransactionMerge/DetailsReviewPage.tsx | 5 ++--- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 13c0b65a86b70..17a65aba15508 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -17,13 +17,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {mergeTransactionRequest} from '@libs/actions/MergeTransaction'; -import { - buildMergedTransactionData, - getReportIDForExpense, - getSourceTransactionFromMergeTransaction, - getTargetTransactionFromMergeTransaction, - getTransactionThreadReportID, -} from '@libs/MergeTransactionUtils'; +import {buildMergedTransactionData, getReportIDForExpense, getSourceTransactionFromMergeTransaction, getTargetTransactionFromMergeTransaction} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; @@ -70,9 +64,8 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); - const sourceTransactionThreadReportID = getTransactionThreadReportID(sourceTransaction); - const [sourceTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${sourceTransactionThreadReportID}`, {canBeMissing: true}); - const [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionThreadReport?.policyID}`, {canBeMissing: true}); + const sourceTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`]; + const [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionReport?.policyID}`, {canBeMissing: true}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index c8b76c60d0bd5..a7254dd4ab29e 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -85,10 +85,9 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { const [originalTargetTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction?.comment?.originalTransactionID}`, {canBeMissing: true}); const [targetTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`, {canBeMissing: true}); const [currentUserEmail] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector, canBeMissing: false}); - const sourceTransactionThreadReportID = getTransactionThreadReportID(sourceTransaction); - const [sourceTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${sourceTransactionThreadReportID}`, {canBeMissing: true}); - const [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionThreadReport?.policyID}`, {canBeMissing: true}); const [targetTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${targetTransactionThreadReport?.policyID}`, {canBeMissing: true}); + const [sourceTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`, {canBeMissing: true}); + const [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionReport?.policyID}`, {canBeMissing: true}); const [hasErrors, setHasErrors] = useState>>({}); const [conflictFields, setConflictFields] = useState([]); const [isCheckingDataBeforeGoNext, setIsCheckingDataBeforeGoNext] = useState(false); From bdf1c6978370d29b26809a6846d6d495d40eb411 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 5 Dec 2025 02:41:12 +0700 Subject: [PATCH 30/54] rely on selectedTransactionByField to know which policy does the selected tax comes from --- src/components/ReportActionItem/MoneyRequestView.tsx | 12 ++++++------ src/pages/TransactionMerge/ConfirmationPage.tsx | 9 ++++++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 7e133cc5c65c4..1462ce412f67f 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -116,20 +116,20 @@ type MoneyRequestViewProps = { /** Merge transaction ID to show in merge transaction flow */ mergeTransactionID?: string; - /** Source transaction policy in merge transaction flow */ - sourceTransactionPolicy?: OnyxEntry; + /** Tax name to display in merge transaction flow */ + taxName?: string; }; function MoneyRequestView({ allReports, report, expensePolicy, - sourceTransactionPolicy, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false, mergeTransactionID, + taxName, }: MoneyRequestViewProps) { const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase'] as const); const styles = useThemeStyles(); @@ -248,7 +248,7 @@ function MoneyRequestView({ : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); const taxRatesDescription = taxRates?.name; - const taxRateTitle = updatedTransaction ? getTaxName(sourceTransactionPolicy ?? policy, updatedTransaction) : getTaxName(policy, transaction); + const taxRateTitle = updatedTransaction ? getTaxName(policy, updatedTransaction) : getTaxName(policy, transaction); const actualTransactionDate = isFromMergeTransaction && updatedTransaction ? getFormattedCreated(updatedTransaction) : transactionDate; const fallbackTaxRateTitle = updatedTransaction?.taxValue ?? transaction?.taxValue; @@ -312,7 +312,7 @@ function MoneyRequestView({ const canEditReimbursable = isEditable && canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, undefined, isChatReportArchived); const shouldShowAttendees = useMemo(() => shouldShowAttendeesTransactionUtils(iouType, policy), [iouType, policy]); - const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, sourceTransactionPolicy ?? policy, isDistanceRequest, isPerDiemRequest); + const shouldShowTax = !!taxName || isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest, isPerDiemRequest); const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; @@ -514,7 +514,7 @@ function MoneyRequestView({ const decodedCategoryName = getDecodedCategoryName(categoryValue); const categoryCopyValue = !canEdit ? decodedCategoryName : undefined; const cardCopyValue = cardProgramName; - const taxRateValue = taxRateTitle ?? fallbackTaxRateTitle; + const taxRateValue = taxName ?? taxRateTitle ?? fallbackTaxRateTitle; const taxRateCopyValue = !canEditTaxFields ? taxRateValue : undefined; const taxAmountTitle = formattedTaxAmount ? formattedTaxAmount.toString() : ''; const taxAmountCopyValue = !canEditTaxFields ? taxAmountTitle : undefined; diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 17a65aba15508..4452db03d0727 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -22,6 +22,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {getTaxName} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -66,6 +67,12 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const sourceTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`]; const [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionReport?.policyID}`, {canBeMissing: true}); + const taxName = + mergeTransaction?.taxValue && + getTaxName( + mergeTransaction?.selectedTransactionByField?.taxValue === mergeTransaction?.sourceTransactionID ? sourceTransactionPolicy : policy, + mergeTransaction as unknown as OnyxEntry, + ); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; @@ -155,12 +162,12 @@ function ConfirmationPage({route}: ConfirmationPageProps) { } mergeTransactionID={transactionID} + taxName={taxName} /> From 90bec5bae51e80cd280ccbf29c0b0123c1739906 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 5 Dec 2025 02:41:31 +0700 Subject: [PATCH 31/54] handle selectedTransactionByField in auto merge case --- src/libs/MergeTransactionUtils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index a13d340fe7819..23dd7ae3874aa 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -209,7 +209,7 @@ function getMergeableDataAndConflictFields( localeCompare: (a: string, b: string) => number, ) { const conflictFields: string[] = []; - const mergeableData: Record = {}; + const mergeableData: Record = {selectedTransactionByField: {}}; const targetTransactionDetails = getTransactionDetails(targetTransaction); const sourceTransactionDetails = getTransactionDetails(sourceTransaction); @@ -296,6 +296,9 @@ function getMergeableDataAndConflictFields( mergeTransaction: mergeableData as MergeTransaction, }); Object.assign(mergeableData, updatedValues); + if (updatedValues[field]) { + (mergeableData.selectedTransactionByField as Record)[field] = selectedTransaction?.transactionID; + } } else { conflictFields.push(field); } From 4c3c29ee6e14bcef1abbc4920f614ad05342a090 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 5 Dec 2025 02:56:28 +0700 Subject: [PATCH 32/54] fix unit test --- src/libs/MergeTransactionUtils.ts | 2 +- tests/unit/MergeTransactionUtilsTest.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 23dd7ae3874aa..4c2f949c406d2 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -296,7 +296,7 @@ function getMergeableDataAndConflictFields( mergeTransaction: mergeableData as MergeTransaction, }); Object.assign(mergeableData, updatedValues); - if (updatedValues[field]) { + if (!isEmptyMergeValue(updatedValues[field])) { (mergeableData.selectedTransactionByField as Record)[field] = selectedTransaction?.transactionID; } } else { diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index a7f40406063af..4bff796556ede 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -379,6 +379,12 @@ describe('MergeTransactionUtils', () => { tag: 'Same Tag', billable: false, attendees: [], + selectedTransactionByField: { + merchant: targetTransaction.transactionID, + category: targetTransaction.transactionID, + tag: sourceTransaction.transactionID, + billable: sourceTransaction.transactionID, + }, }); }); From 86db0f395993ce24535dd5f9d97de93ee87a93f4 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 5 Dec 2025 03:10:03 +0700 Subject: [PATCH 33/54] fix unit test --- tests/unit/MergeTransactionUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 4bff796556ede..14e63be88499d 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -383,7 +383,7 @@ describe('MergeTransactionUtils', () => { merchant: targetTransaction.transactionID, category: targetTransaction.transactionID, tag: sourceTransaction.transactionID, - billable: sourceTransaction.transactionID, + billable: targetTransaction.transactionID, }, }); }); From a7cb2bbc7d555631bcb4b05e25d5b7c4fad5f0a7 Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 22 Dec 2025 19:00:51 +0700 Subject: [PATCH 34/54] fix tax push row missing description --- src/components/MoneyRequestConfirmationListFooter.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index b49a1555b6ba0..b9c7229abc50d 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -359,7 +359,7 @@ function MoneyRequestConfirmationListFooter({ // since the destination is already determined and there's no need to show a selectable list. const shouldReportBeEditable = (isFromGlobalCreate ? shouldReportBeEditableFromFAB : availableOutstandingReports.length > 1) && !isMoneyRequestReport(reportID, allReports); - const taxRates = policy?.taxRates ?? null; + const taxRatesName = policy?.taxRates?.name ?? CONST.DEFAULT_TAX.name; // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item const shouldShowDate = shouldShowSmartScanFields || isDistanceRequest; // Determines whether the tax fields can be modified. @@ -682,10 +682,10 @@ function MoneyRequestConfirmationListFooter({ { item: ( { @@ -704,7 +704,7 @@ function MoneyRequestConfirmationListFooter({ { item: ( Date: Mon, 22 Dec 2025 19:02:55 +0700 Subject: [PATCH 35/54] fix: tax is auto merged even when target transaction does not have tax enabled --- src/libs/MergeTransactionUtils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index e2b15c996f536..149994a16383d 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -286,6 +286,9 @@ 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({ From 027d7371f4f72ceabe3742a268380468bb13f60d Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 29 Dec 2025 19:00:57 +0700 Subject: [PATCH 36/54] lint --- src/libs/MergeTransactionUtils.ts | 10 ++++++++-- .../TransactionMerge/DetailsReviewPage.tsx | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 1431eb33e94a9..884dc357a50e7 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -297,7 +297,7 @@ function getMergeableDataAndConflictFields( const updatedValues = getMergeFieldUpdatedValues({ transaction: selectedTransaction, field, - fieldValue: selectedFieldValue as MergeTransaction[typeof field], searchReports, + fieldValue: selectedFieldValue as MergeTransaction[typeof field], mergeTransaction: mergeableData as MergeTransaction, searchReports, }); @@ -566,7 +566,13 @@ type GetMergeFieldUpdatedValuesParams = { * Build updated values for merge transaction field selection * Handles special cases like currency for amount field, reportID, taxValue */ -function getMergeFieldUpdatedValues({transaction, field, fieldValue, mergeTransaction, searchReports}: GetMergeFieldUpdatedValuesParams): MergeTransactionUpdateValues { +function getMergeFieldUpdatedValues({ + transaction, + field, + fieldValue, + mergeTransaction, + searchReports, +}: GetMergeFieldUpdatedValuesParams): MergeTransactionUpdateValues { const updatedValues: MergeTransactionUpdateValues = { [field]: fieldValue, }; diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index daf642be2053e..f39315fb34268 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -114,8 +114,22 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { // Build merge fields array with all necessary information const mergeFields = useMemo( - () => buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionPolicy, sourceTransactionPolicy, translate, [targetTransactionReport, sourceTransactionReport]), - [conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionReport, sourceTransactionReport, targetTransactionPolicy, sourceTransactionPolicy, 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 From 496e727851cbb8025e533cffad16e85560dc655f Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 29 Dec 2025 19:10:28 +0700 Subject: [PATCH 37/54] lint --- src/libs/MergeTransactionUtils.ts | 3 ++- src/libs/actions/MergeTransaction.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 884dc357a50e7..5e3316cd2aabd 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -464,6 +464,7 @@ function selectTargetAndSourceTransactionsForMerge(targetTransaction: OnyxEntry< * 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 */ @@ -564,7 +565,7 @@ type GetMergeFieldUpdatedValuesParams = { /** * Build updated values for merge transaction field selection - * Handles special cases like currency for amount field, reportID, taxValue + * Handles special cases like currency for amount field, report name, tax value and additional fields for distance requests */ function getMergeFieldUpdatedValues({ transaction, diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index c7f247e614686..419d75f276c88 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -11,7 +11,6 @@ import { getMergeableDataAndConflictFields, getMergeFieldValue, getTransactionThreadReportID, - MERGE_FIELDS, selectTargetAndSourceTransactionsForMerge, shouldNavigateToReceiptReview, } from '@libs/MergeTransactionUtils'; From 0d8581f5812cd8d55d684efab49885ce514a63d7 Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 29 Dec 2025 19:12:57 +0700 Subject: [PATCH 38/54] get sourceTransactionReport from useMergeTransactions --- src/pages/TransactionMerge/ConfirmationPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 0eaa2ea5557a3..9eb072f12c19c 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -39,14 +39,13 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {canBeMissing: true}); - const {targetTransaction, sourceTransaction, targetTransactionReport} = useMergeTransactions({mergeTransaction}); + const {targetTransaction, sourceTransaction, targetTransactionReport, sourceTransactionReport} = useMergeTransactions({mergeTransaction}); 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 sourceTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`]; const [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionReport?.policyID}`, {canBeMissing: true}); const taxName = mergeTransaction?.taxValue && From 86d0b4cdc0f5e8df35052a242e478fdd1565a601 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 1 Jan 2026 01:15:29 +0700 Subject: [PATCH 39/54] use snapshot policy data --- src/hooks/useMergeTransactions.ts | 17 +++++++++++++++-- src/pages/TransactionMerge/ConfirmationPage.tsx | 16 +++++++--------- .../TransactionMerge/DetailsReviewPage.tsx | 6 +++--- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/hooks/useMergeTransactions.ts b/src/hooks/useMergeTransactions.ts index d33eb28b3f802..aa1db52f82655 100644 --- a/src/hooks/useMergeTransactions.ts +++ b/src/hooks/useMergeTransactions.ts @@ -4,7 +4,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getTransactionFromMergeTransaction} from '@libs/MergeTransactionUtils'; import CONST from '@src/CONST'; 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'; type UseMergeTransactionsProps = { @@ -16,6 +16,8 @@ type UseMergeTransactionsReturn = { sourceTransaction?: Transaction; targetTransactionReport?: Report; sourceTransactionReport?: Report; + targetTransactionPolicy?: Policy; + sourceTransactionPolicy?: Policy; }; function getTransaction( @@ -57,11 +59,20 @@ function useMergeTransactions({mergeTransaction}: UseMergeTransactionsProps): Us let [sourceTransactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(sourceTransaction?.reportID)}`, { canBeMissing: true, }); + let [targetTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(targetTransactionReport?.policyID)}`, { + canBeMissing: true, + }); + let [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(sourceTransactionReport?.policyID)}`, { + canBeMissing: true, + }); - // If we're on search and main collection reports are not available, get them from the search snapshot if (searchHash && currentSearchResults?.data) { + // 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}${targetTransaction?.reportID}`]; sourceTransactionReport = sourceTransactionReport ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`]; + // If we're on search, search snapshot policies are more up to date + targetTransactionPolicy = currentSearchResults?.data[`${ONYXKEYS.COLLECTION.POLICY}${targetTransactionReport?.policyID}`]; + sourceTransactionPolicy = currentSearchResults?.data[`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionReport?.policyID}`]; } return { @@ -69,6 +80,8 @@ function useMergeTransactions({mergeTransaction}: UseMergeTransactionsProps): Us sourceTransaction, targetTransactionReport, sourceTransactionReport, + targetTransactionPolicy, + sourceTransactionPolicy, }; } diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 1b446c1f89cc2..8382b45658603 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -40,18 +40,16 @@ 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, sourceTransactionReport} = useMergeTransactions({mergeTransaction}); + const {targetTransaction, sourceTransaction, targetTransactionReport, targetTransactionPolicy, sourceTransactionPolicy} = useMergeTransactions({mergeTransaction}); - 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 targetPolicyID = targetTransactionPolicy?.id; + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicyID}`, {canBeMissing: true}); - const [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionReport?.policyID}`, {canBeMissing: true}); const taxName = mergeTransaction?.taxValue && getTaxName( - mergeTransaction?.selectedTransactionByField?.taxValue === mergeTransaction?.sourceTransactionID ? sourceTransactionPolicy : policy, + mergeTransaction?.selectedTransactionByField?.taxValue === mergeTransaction?.sourceTransactionID ? sourceTransactionPolicy : targetTransactionPolicy, mergeTransaction as unknown as OnyxEntry, ); @@ -76,7 +74,7 @@ function ConfirmationPage({route}: ConfirmationPageProps) { mergeTransaction, targetTransaction, sourceTransaction, - policy, + policy: targetTransactionPolicy, policyTags, policyCategories, currentUserAccountIDParam, @@ -117,7 +115,7 @@ function ConfirmationPage({route}: ConfirmationPageProps) { >>({}); const [conflictFields, setConflictFields] = useState([]); From 0e599880940bf3720bfcfd13f1c852db1c11346b Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 1 Jan 2026 01:37:55 +0700 Subject: [PATCH 40/54] Revert "fix tax push row missing description" This reverts commit a7cb2bbc7d555631bcb4b05e25d5b7c4fad5f0a7. --- src/components/MoneyRequestConfirmationListFooter.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index b9c7229abc50d..b49a1555b6ba0 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -359,7 +359,7 @@ function MoneyRequestConfirmationListFooter({ // since the destination is already determined and there's no need to show a selectable list. const shouldReportBeEditable = (isFromGlobalCreate ? shouldReportBeEditableFromFAB : availableOutstandingReports.length > 1) && !isMoneyRequestReport(reportID, allReports); - const taxRatesName = policy?.taxRates?.name ?? CONST.DEFAULT_TAX.name; + const taxRates = policy?.taxRates ?? null; // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item const shouldShowDate = shouldShowSmartScanFields || isDistanceRequest; // Determines whether the tax fields can be modified. @@ -682,10 +682,10 @@ function MoneyRequestConfirmationListFooter({ { item: ( { @@ -704,7 +704,7 @@ function MoneyRequestConfirmationListFooter({ { item: ( Date: Tue, 6 Jan 2026 00:45:36 +0700 Subject: [PATCH 41/54] update transaction report ID based on selection --- src/libs/TransactionUtils/index.ts | 4 +++ src/libs/actions/MergeTransaction.ts | 40 +++++++++++++--------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 59a9b7d19204b..a600762775a04 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -670,6 +670,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 419d75f276c88..a53d1ad33a065 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -339,28 +339,26 @@ function mergeTransactionRequest({ value: sourceTransaction, }; const transactionsOfSourceReport = getReportTransactions(sourceTransaction.reportID); - const optimisticSourceReportData: OnyxUpdate[] = - 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: OnyxUpdate[] = 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: OnyxUpdate[] = - transactionsOfSourceReport.length === 1 - ? [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction.reportID}`, - value: getReportOrDraftReport(sourceTransaction.reportID), - }, - ] - : []; + const failureSourceReportData: OnyxUpdate[] = shouldDeleteSourceReport + ? [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction.reportID}`, + value: getReportOrDraftReport(sourceTransaction.reportID), + }, + ] + : []; const iouActionOfSourceTransaction = getIOUActionForReportID(sourceTransaction.reportID, sourceTransaction.transactionID); const optimisticSourceReportActionData: OnyxUpdate[] = iouActionOfSourceTransaction ? [ From 6328a3be85b8b032689b4897fb6110de0dc55426 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 8 Jan 2026 23:06:50 +0700 Subject: [PATCH 42/54] Revert "fallback tax rate" This reverts commit d14c2b445b7a5f18580696b729c8cb2759af8851. --- src/components/ReportActionItem/MoneyRequestView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index ded86807ab508..4a614b05fe79b 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -310,7 +310,7 @@ function MoneyRequestView({ const taxRateTitle = updatedTransaction ? getTaxName(policy, updatedTransaction) : getTaxName(policy, transaction); const actualTransactionDate = isFromMergeTransaction && updatedTransaction ? getFormattedCreated(updatedTransaction) : transactionDate; - const fallbackTaxRateTitle = updatedTransaction?.taxValue ?? transaction?.taxValue; + const fallbackTaxRateTitle = transaction?.taxValue; const isSettled = isSettledReportUtils(moneyRequestReport); const isCancelled = moneyRequestReport && moneyRequestReport?.isCancelledIOU; From 6518595332e242ac4984d412be169c9881486ce1 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 8 Jan 2026 23:18:38 +0700 Subject: [PATCH 43/54] fix: do not reset selectedTransactionByField --- src/libs/MergeTransactionUtils.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 9165ef637d879..46828900ac7f0 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -211,7 +211,8 @@ function getMergeableDataAndConflictFields( searchReports: Array> = [], ) { const conflictFields: string[] = []; - const mergeableData: Record = {selectedTransactionByField: {}}; + const mergeableData: Record = {}; + const selectedTransactionByField: Record = {}; const targetTransactionDetails = getTransactionDetails(targetTransaction); const sourceTransactionDetails = getTransactionDetails(sourceTransaction); @@ -303,13 +304,17 @@ function getMergeableDataAndConflictFields( }); Object.assign(mergeableData, updatedValues); if (!isEmptyMergeValue(updatedValues[field])) { - (mergeableData.selectedTransactionByField as Record)[field] = selectedTransaction?.transactionID; + selectedTransactionByField[field] = selectedTransaction?.transactionID; } } else { conflictFields.push(field); } } + if (Object.keys(selectedTransactionByField).length > 0) { + mergeableData.selectedTransactionByField = selectedTransactionByField; + } + return {mergeableData, conflictFields}; } From b9099215c95ef57c412265ab2a3e4ed0695b0b2d Mon Sep 17 00:00:00 2001 From: dominictb Date: Wed, 14 Jan 2026 23:57:34 +0700 Subject: [PATCH 44/54] fix lint --- src/libs/actions/MergeTransaction.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 2cdd42928cd10..70eeed67b1a34 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -10,7 +10,6 @@ import { DERIVED_MERGE_FIELDS, getMergeableDataAndConflictFields, getMergeFieldValue, - getTransactionThreadReportID, selectTargetAndSourceTransactionsForMerge, shouldNavigateToReceiptReview, } from '@libs/MergeTransactionUtils'; From f12a9352f1c5b59a269713adcc66c803f8a3b756 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 16 Jan 2026 00:47:05 +0700 Subject: [PATCH 45/54] compute taxName on selection --- .../ReportActionItem/MoneyRequestView.tsx | 8 +--- src/hooks/useSelectedTransactionsActions.ts | 11 ++++- src/libs/DebugUtils.ts | 1 + src/libs/MergeTransactionUtils.ts | 40 ++++++++++++------- src/libs/actions/MergeTransaction.ts | 17 +++++++- src/pages/Search/SearchPage.tsx | 2 +- .../TransactionMerge/ConfirmationPage.tsx | 16 ++------ .../TransactionMerge/DetailsReviewPage.tsx | 25 ++++++++---- .../MergeTransactionsListContent.tsx | 16 +++++--- .../TransactionMerge/ReceiptReviewPage.tsx | 4 +- src/types/onyx/MergeTransaction.ts | 3 ++ src/types/onyx/Transaction.ts | 3 ++ tests/utils/collections/mergeTransaction.ts | 1 + 13 files changed, 95 insertions(+), 52 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index b2a8940cf2601..30d51e960a3c5 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -135,9 +135,6 @@ type MoneyRequestViewProps = { /** Merge transaction ID to show in merge transaction flow */ mergeTransactionID?: string; - - /** Tax name to display in merge transaction flow */ - taxName?: string; }; const perDiemPoliciesSelector = (policies: OnyxCollection) => { @@ -161,7 +158,6 @@ function MoneyRequestView({ updatedTransaction, isFromReviewDuplicates = false, mergeTransactionID, - taxName, }: MoneyRequestViewProps) { const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase']); const styles = useThemeStyles(); @@ -378,7 +374,7 @@ function MoneyRequestView({ const canEditReimbursable = isEditable && canEditFieldOfMoneyRequest(parentReportAction, CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, undefined, isChatReportArchived); const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); - const shouldShowTax = !!taxName || isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); + const shouldShowTax = !!transaction?.taxName || isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; @@ -548,7 +544,7 @@ function MoneyRequestView({ const decodedCategoryName = getDecodedCategoryName(categoryValue); const categoryCopyValue = !canEdit ? decodedCategoryName : undefined; const cardCopyValue = cardProgramName; - const taxRateValue = taxName ?? 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/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 6a640914a309b..c0b61bf1a4ab2 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -310,7 +310,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, + Array(selectedTransactionsList.length).fill(policy), + ), }); } } diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 8d7af62b6891c..38f25b3ec3859 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1112,6 +1112,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 46828900ac7f0..eac6251c8c9c0 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -201,7 +201,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( @@ -209,10 +211,11 @@ function getMergeableDataAndConflictFields( sourceTransaction: OnyxEntry, localeCompare: LocaleContextProps['localeCompare'], searchReports: Array> = [], + targetTransactionPolicy?: OnyxEntry, + sourceTransactionPolicy?: OnyxEntry, ) { const conflictFields: string[] = []; const mergeableData: Record = {}; - const selectedTransactionByField: Record = {}; const targetTransactionDetails = getTransactionDetails(targetTransaction); const sourceTransactionDetails = getTransactionDetails(sourceTransaction); @@ -295,26 +298,20 @@ function getMergeableDataAndConflictFields( } const selectedTransaction = isTargetValueEmpty ? sourceTransaction : targetTransaction; const selectedFieldValue = isTargetValueEmpty ? sourceValue : targetValue; + 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); - if (!isEmptyMergeValue(updatedValues[field])) { - selectedTransactionByField[field] = selectedTransaction?.transactionID; - } } else { conflictFields.push(field); } } - - if (Object.keys(selectedTransactionByField).length > 0) { - mergeableData.selectedTransactionByField = selectedTransactionByField; - } - return {mergeableData, conflictFields}; } @@ -453,16 +450,28 @@ 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}; } /** @@ -566,6 +575,7 @@ type GetMergeFieldUpdatedValuesParams = { fieldValue: MergeTransaction[K]; mergeTransaction?: OnyxEntry; searchReports?: Array>; + policy?: OnyxEntry; }; /** @@ -578,6 +588,7 @@ function getMergeFieldUpdatedValues({ fieldValue, mergeTransaction, searchReports, + policy, }: GetMergeFieldUpdatedValuesParams): MergeTransactionUpdateValues { const updatedValues: MergeTransactionUpdateValues = { [field]: fieldValue, @@ -609,6 +620,7 @@ function getMergeFieldUpdatedValues({ if (field === 'taxValue') { updatedValues.taxCode = transaction?.taxCode; + updatedValues.taxName = getTaxName(policy, transaction) ?? transaction?.taxValue ?? ''; if (mergeTransaction?.amount) { updatedValues.taxAmount = convertToBackendAmount(calculateTaxAmount(transaction?.taxValue, mergeTransaction.amount, mergeTransaction.currency)); } diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 70eeed67b1a34..5f64ff1256593 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -47,6 +47,7 @@ function setupMergeTransactionDataAndNavigate( searchReports?: Report[], isSelectingSourceTransaction?: boolean, isOnSearch?: boolean, + policies?: OnyxEntry[], ) { if (!transactions.length || transactions.length > 2) { return; @@ -61,7 +62,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; } @@ -82,7 +88,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); diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4207312b69340..d403e8bbea4f7 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -917,7 +917,7 @@ function SearchPage({route}: SearchPageProps) { text: translate('common.merge'), icon: expensifyIcons.ArrowCollapse, value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE, - onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, transactions, localeCompare, reports, false, true), + onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, transactions, localeCompare, reports, false, true, transactionPolicies), }); } } diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index 811e9c177f8ee..f2274fbe87e43 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -23,7 +23,6 @@ import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTop import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; -import {getTaxName} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -42,21 +41,13 @@ 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, targetTransactionPolicy, sourceTransactionPolicy} = useMergeTransactions({mergeTransaction}); + const {targetTransaction, sourceTransaction, targetTransactionReport, targetTransactionPolicy} = useMergeTransactions({mergeTransaction}); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, { canBeMissing: false, }); - const targetPolicyID = targetTransactionPolicy?.id; - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`, {canBeMissing: true}); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetPolicyID}`, {canBeMissing: true}); - - const taxName = - mergeTransaction?.taxValue && - getTaxName( - mergeTransaction?.selectedTransactionByField?.taxValue === mergeTransaction?.sourceTransactionID ? sourceTransactionPolicy : targetTransactionPolicy, - mergeTransaction as unknown as OnyxEntry, - ); + 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; @@ -144,7 +135,6 @@ function ConfirmationPage({route}: ConfirmationPageProps) { readonly updatedTransaction={mergedTransactionData as unknown as OnyxEntry} mergeTransactionID={transactionID} - taxName={taxName} /> diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index fac7b176e3078..b4c272136eedb 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -56,14 +56,18 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { 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]); + }, [targetTransaction, sourceTransaction, transactionID, localeCompare, sourceTransactionReport, targetTransactionReport, targetTransactionPolicy, sourceTransactionPolicy]); // Handle selection const handleSelect = useCallback( @@ -79,7 +83,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, mergeTransaction, searchReports: [targetTransactionReport, sourceTransactionReport]}); + const updatedValues = getMergeFieldUpdatedValues({ + transaction, + field, + fieldValue, + mergeTransaction, + searchReports: [targetTransactionReport, sourceTransactionReport], + policy: transaction.transactionID === targetTransaction?.transactionID ? targetTransactionPolicy : sourceTransactionPolicy, + }); setMergeTransactionKey(transactionID, { ...updatedValues, @@ -89,7 +100,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { } as Partial>, }); }, - [mergeTransaction, transactionID, targetTransactionReport, sourceTransactionReport], + [mergeTransaction, transactionID, targetTransactionReport, sourceTransactionReport, targetTransaction?.transactionID, targetTransactionPolicy, sourceTransactionPolicy], ); // Handle continue 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 15dcc28b48359..22bcb91ed05b3 100644 --- a/src/types/onyx/MergeTransaction.ts +++ b/src/types/onyx/MergeTransaction.ts @@ -82,6 +82,9 @@ type MergeTransaction = { /** Tax code of the transaction */ taxCode: string; + + /** Tax name to display in merge transaction flow */ + taxName: string; }; export default MergeTransaction; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index aa97dbbde5fe2..b65368de314f1 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -445,6 +445,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/utils/collections/mergeTransaction.ts b/tests/utils/collections/mergeTransaction.ts index 7e47dc0258163..1ba99c8bae13f 100644 --- a/tests/utils/collections/mergeTransaction.ts +++ b/tests/utils/collections/mergeTransaction.ts @@ -27,5 +27,6 @@ export default function createRandomMergeTransaction(index: number): MergeTransa taxAmount: randAmount(), taxValue: randAmount().toString(), taxCode: randWord(), + taxName: randWord(), }; } From 02fbb148ef1415aead7417cd2b02b1ad7425e755 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 16 Jan 2026 01:33:45 +0700 Subject: [PATCH 46/54] fix lint --- src/hooks/useSelectedTransactionsActions.ts | 5 ++--- src/libs/DebugUtils.ts | 1 + src/libs/actions/MergeTransaction.ts | 2 +- src/pages/TransactionMerge/DetailsReviewPage.tsx | 7 +++---- tests/unit/MergeTransactionUtilsTest.ts | 6 ------ 5 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index c0b61bf1a4ab2..b0e286a1bf1eb 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -318,7 +318,7 @@ function useSelectedTransactionsActions({ [], false, isOnSearch, - Array(selectedTransactionsList.length).fill(policy), + selectedTransactionsList.length > 1 ? [policy, policy] : undefined, ), }); } @@ -376,7 +376,7 @@ function useSelectedTransactionsActions({ } return options; }, [ - session?.email, + session, selectedTransactionIDs, report, selectedTransactionsList, @@ -398,7 +398,6 @@ function useSelectedTransactionsActions({ lastVisitedPath, allTransactions, allReports, - session?.accountID, showDeleteModal, allTransactionViolations, expensifyIcons.Stopwatch, diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 38f25b3ec3859..cd1c064a6186b 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -951,6 +951,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) case 'category': case 'merchant': case 'taxCode': + case 'taxName': case 'modifiedCurrency': case 'modifiedMerchant': case 'transactionID': diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 5f64ff1256593..cda3a8fc43ffb 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -47,7 +47,7 @@ function setupMergeTransactionDataAndNavigate( searchReports?: Report[], isSelectingSourceTransaction?: boolean, isOnSearch?: boolean, - policies?: OnyxEntry[], + policies?: Array>, ) { if (!transactions.length || transactions.length > 2) { return; diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index b4c272136eedb..a228a48c5c4b4 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -49,11 +49,10 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { }); const [hasErrors, setHasErrors] = useState>>({}); - const [conflictFields, setConflictFields] = useState([]); - useEffect(() => { + const conflictFields = useMemo(() => { if (!transactionID || !targetTransaction || !sourceTransaction) { - return; + return []; } const {conflictFields: detectedConflictFields, mergeableData} = getMergeableDataAndConflictFields( @@ -66,7 +65,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { ); setMergeTransactionKey(transactionID, mergeableData); - setConflictFields(detectedConflictFields as MergeFieldKey[]); + return detectedConflictFields as MergeFieldKey[]; }, [targetTransaction, sourceTransaction, transactionID, localeCompare, sourceTransactionReport, targetTransactionReport, targetTransactionPolicy, sourceTransactionPolicy]); // Handle selection diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index f2d3657d28725..6ce505283fcf7 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -348,12 +348,6 @@ describe('MergeTransactionUtils', () => { tag: 'Same Tag', billable: false, attendees: [], - selectedTransactionByField: { - merchant: targetTransaction.transactionID, - category: targetTransaction.transactionID, - tag: sourceTransaction.transactionID, - billable: targetTransaction.transactionID, - }, }); }); From 10ed005a4ba5c4ca35507b1a47a36b14e5ef813d Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 16 Jan 2026 08:43:50 +0700 Subject: [PATCH 47/54] fix lint --- src/hooks/useSelectedTransactionsActions.ts | 4 +++- src/pages/TransactionMerge/DetailsReviewPage.tsx | 2 +- tests/unit/hooks/useSelectedTransactionsActions.test.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index b0e286a1bf1eb..5f6a8aa76d277 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -165,6 +165,7 @@ function useSelectedTransactionsActions({ setIsDeleteModalVisible(false); }, []); + // eslint-disable-next-line react-hooks/preserve-manual-memoization const computedOptions = useMemo(() => { if (!selectedTransactionIDs.length) { return []; @@ -376,7 +377,8 @@ function useSelectedTransactionsActions({ } return options; }, [ - session, + session?.email, + session?.accountID, selectedTransactionIDs, report, selectedTransactionsList, diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index a228a48c5c4b4..d7575790f8517 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts index f5043ae589305..ab8dd3a440cff 100644 --- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts +++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts @@ -655,6 +655,6 @@ describe('useSelectedTransactionsActions', () => { mergeOption?.onSelected?.(); - expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transaction.transactionID, [transaction], mockLocalCompare, [], false, false); + expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transaction.transactionID, [transaction], mockLocalCompare, [], false, false, undefined); }); }); From 0e348f1ee2e4f5347399f2b0752678d8c4622bc5 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 16 Jan 2026 16:00:34 +0700 Subject: [PATCH 48/54] remove lint silent --- src/hooks/useSelectedTransactionsActions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 5f6a8aa76d277..66b427b701ea1 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -165,7 +165,6 @@ function useSelectedTransactionsActions({ setIsDeleteModalVisible(false); }, []); - // eslint-disable-next-line react-hooks/preserve-manual-memoization const computedOptions = useMemo(() => { if (!selectedTransactionIDs.length) { return []; From 4f45a0b7fe8e6c7fb63ba68532b31086965e1fd4 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 27 Jan 2026 01:37:59 +0700 Subject: [PATCH 49/54] fix: tax does not show in confirmation step --- src/libs/MergeTransactionUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index eac6251c8c9c0..c2d3fc80f0601 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -383,6 +383,7 @@ function buildMergedTransactionData(targetTransaction: OnyxEntry, m taxValue: mergeTransaction.taxValue, taxAmount: mergeTransaction.taxAmount, taxCode: mergeTransaction.taxCode, + taxName: mergeTransaction.taxName, }; } From 07a2c7c66731e630fddb1edee5c9f850b0bea454 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 27 Jan 2026 02:20:41 +0700 Subject: [PATCH 50/54] fix unit test --- tests/unit/MergeTransactionUtilsTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 6ce505283fcf7..5240b6be1d7c5 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -601,6 +601,7 @@ describe('MergeTransactionUtils', () => { taxValue: '9%', taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, 'USD')), taxCode: 'id_TAX_RATE_1', + taxName: 'Tax Rate 1 (9%)', }; const result = buildMergedTransactionData(targetTransaction, mergeTransaction); @@ -631,6 +632,7 @@ describe('MergeTransactionUtils', () => { taxValue: '9%', taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, 'USD')), taxCode: 'id_TAX_RATE_1', + taxName: 'Tax Rate 1 (9%)', }); }); }); From 6a7da5ec5ed3d802d3736ed787435922a6e5308e Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 30 Jan 2026 02:32:12 +0700 Subject: [PATCH 51/54] fix: get correct report and policy for unreported expenses --- src/hooks/useMergeTransactions.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/hooks/useMergeTransactions.ts b/src/hooks/useMergeTransactions.ts index 262fc8cc81c57..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, Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; import useOnyx from './useOnyx'; +import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; type UseMergeTransactionsProps = { mergeTransaction?: MergeTransaction; @@ -39,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, @@ -50,26 +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, }); - let [targetTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(targetTransactionReport?.policyID)}`, { + + 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(sourceTransactionReport?.policyID)}`, { + let [sourceTransactionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(sourceTransactionPolicyID)}`, { canBeMissing: true, }); if (currentSearchHash && currentSearchResults?.data) { // 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}${targetTransaction?.reportID}`]; - sourceTransactionReport = sourceTransactionReport ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction?.reportID}`]; + 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}${targetTransactionReport?.policyID}`]; - sourceTransactionPolicy = currentSearchResults?.data[`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionReport?.policyID}`]; + targetTransactionPolicy = currentSearchResults?.data[`${ONYXKEYS.COLLECTION.POLICY}${targetTransactionPolicyID}`]; + sourceTransactionPolicy = currentSearchResults?.data[`${ONYXKEYS.COLLECTION.POLICY}${sourceTransactionPolicyID}`]; } return { From f319cd27e23fb7f6d99cd47b277e33e6ea822ecd Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 9 Feb 2026 21:20:02 +0700 Subject: [PATCH 52/54] pass taxPolicyID param --- src/libs/MergeTransactionUtils.ts | 1 + src/libs/actions/MergeTransaction.ts | 1 + src/types/onyx/MergeTransaction.ts | 3 +++ 3 files changed, 5 insertions(+) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index ce0f9ccdaed7c..29dcd80dfbc0b 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -627,6 +627,7 @@ function getMergeFieldUpdatedValues({ 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, mergeTransaction.currency)); } diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 2e0c2a7194c33..e8f67b724326b 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -355,6 +355,7 @@ function mergeTransactionRequest({ reimbursable: mergeTransaction.reimbursable, tag: mergeTransaction.tag, taxCode: mergeTransaction.taxCode, + taxPolicyID: mergeTransaction.taxPolicyID, receiptID: mergeTransaction.receipt?.receiptID, reportID: mergeTransaction.reportID, }; diff --git a/src/types/onyx/MergeTransaction.ts b/src/types/onyx/MergeTransaction.ts index 22bcb91ed05b3..08be4883f296b 100644 --- a/src/types/onyx/MergeTransaction.ts +++ b/src/types/onyx/MergeTransaction.ts @@ -85,6 +85,9 @@ type MergeTransaction = { /** 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; From 48f006c895a4845835e5506c04547572bd29a6f2 Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 9 Feb 2026 21:29:53 +0700 Subject: [PATCH 53/54] fix typescript check --- tests/utils/collections/mergeTransaction.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/utils/collections/mergeTransaction.ts b/tests/utils/collections/mergeTransaction.ts index 1ba99c8bae13f..e7f1c0bd8d332 100644 --- a/tests/utils/collections/mergeTransaction.ts +++ b/tests/utils/collections/mergeTransaction.ts @@ -28,5 +28,6 @@ export default function createRandomMergeTransaction(index: number): MergeTransa taxValue: randAmount().toString(), taxCode: randWord(), taxName: randWord(), + taxPolicyID: randWord(), }; } From 064603da228a8cf6430927ee7e0e316cd3d0fe14 Mon Sep 17 00:00:00 2001 From: dominictb Date: Wed, 18 Feb 2026 01:16:48 +0700 Subject: [PATCH 54/54] fix typescript --- src/libs/MergeTransactionUtils.ts | 6 +++--- tests/unit/MergeTransactionUtilsTest.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 29dcd80dfbc0b..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 {convertToBackendAmount, convertToDisplayString} from './CurrencyUtils'; +import {convertToBackendAmount, convertToDisplayString, getCurrencyDecimals} from './CurrencyUtils'; import Parser from './Parser'; import {getCommaSeparatedTagNameWithSanitizedColons} from './PolicyUtils'; import {getIOUActionForReportID} from './ReportActionsUtils'; @@ -603,7 +603,7 @@ function getMergeFieldUpdatedValues({ if (field === 'amount') { updatedValues.currency = getCurrency(transaction); if (mergeTransaction?.taxValue && transaction?.amount) { - updatedValues.taxAmount = convertToBackendAmount(calculateTaxAmount(mergeTransaction?.taxValue, transaction.amount, getCurrency(transaction))); + updatedValues.taxAmount = convertToBackendAmount(calculateTaxAmount(mergeTransaction?.taxValue, transaction.amount, getCurrencyDecimals(getCurrency(transaction)))); } } @@ -629,7 +629,7 @@ function getMergeFieldUpdatedValues({ updatedValues.taxName = getTaxName(policy, transaction) ?? transaction?.taxValue ?? ''; updatedValues.taxPolicyID = policy?.id; if (mergeTransaction?.amount) { - updatedValues.taxAmount = convertToBackendAmount(calculateTaxAmount(transaction?.taxValue, mergeTransaction.amount, mergeTransaction.currency)); + updatedValues.taxAmount = convertToBackendAmount(calculateTaxAmount(transaction?.taxValue, mergeTransaction.amount, getCurrencyDecimals(getCurrency(transaction)))); } } diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index c43ed9ce43e7a..4fe745b772a27 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -1,5 +1,5 @@ import Onyx from 'react-native-onyx'; -import {convertToBackendAmount} from '@libs/CurrencyUtils'; +import {convertToBackendAmount, getCurrencyDecimals} from '@libs/CurrencyUtils'; import { areTransactionsEligibleForMerge, buildMergedTransactionData, @@ -600,7 +600,7 @@ describe('MergeTransactionUtils', () => { waypoints: {waypoint0: {name: 'Selected waypoint'}}, customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, customUnitID: 'distance1', quantity: 100}, taxValue: '9%', - taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, 'USD')), + taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, getCurrencyDecimals(CONST.CURRENCY.USD))), taxCode: 'id_TAX_RATE_1', taxName: 'Tax Rate 1 (9%)', }; @@ -631,7 +631,7 @@ describe('MergeTransactionUtils', () => { reportID: '1', reportName: 'Test Report', taxValue: '9%', - taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, 'USD')), + taxAmount: convertToBackendAmount(calculateTaxAmount('9%', 2000, getCurrencyDecimals(CONST.CURRENCY.USD))), taxCode: 'id_TAX_RATE_1', taxName: 'Tax Rate 1 (9%)', });