diff --git a/assets/images/simple-illustrations/simple-illustration__empty-shelves.svg b/assets/images/simple-illustrations/simple-illustration__empty-shelves.svg
new file mode 100644
index 000000000000..f4ac49e4d879
--- /dev/null
+++ b/assets/images/simple-illustrations/simple-illustration__empty-shelves.svg
@@ -0,0 +1,194 @@
+
diff --git a/cspell.json b/cspell.json
index aa79b299cf96..43623b13c135 100644
--- a/cspell.json
+++ b/cspell.json
@@ -8,6 +8,7 @@
"ADDCOMMENT",
"Addendums",
"ADFS",
+ "aeiou",
"Aeroplan",
"águero",
"Aircall",
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 332cc0d5ccf1..f3f98518c606 100755
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1084,6 +1084,7 @@ const CONST = {
REOPEN: 'reopen',
EXPORT: 'export',
PAY: 'pay',
+ MERGE: 'merge',
},
PRIMARY_ACTIONS: {
SUBMIT: 'submit',
@@ -1115,6 +1116,7 @@ const CONST = {
SPLIT: 'split',
VIEW_DETAILS: 'viewDetails',
DELETE: 'delete',
+ MERGE: 'merge',
},
ADD_EXPENSE_OPTIONS: {
CREATE_NEW_EXPENSE: 'createNewExpense',
@@ -3107,6 +3109,7 @@ const CONST = {
FinancialForce: 'https://help.expensify.com/articles/expensify-classic/connections/certinia/Connect-To-Certinia',
'Sage Intacct': 'https://help.expensify.com/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct',
Certinia: 'https://help.expensify.com/articles/expensify-classic/connections/certinia/Connect-To-Certinia',
+ MERGE_EXPENSES: 'https://help.expensify.com/articles/new-expensify/reports-and-expenses/Merging-expenses',
},
CUSTOM_UNITS: {
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index e7018e49efc1..cb4f85debfeb 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -607,6 +607,7 @@ const ONYXKEYS = {
SKIP_CONFIRMATION: 'skipConfirmation_',
TRANSACTION_BACKUP: 'transactionsBackup_',
SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_',
+ MERGE_TRANSACTION: 'mergeTransaction_',
PRIVATE_NOTES_DRAFT: 'privateNotesDraft_',
NEXT_STEP: 'reportNextStep_',
@@ -1008,6 +1009,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.TRANSACTION_BACKUP]: OnyxTypes.Transaction;
[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations;
[ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT]: OnyxTypes.Transaction;
+ [ONYXKEYS.COLLECTION.MERGE_TRANSACTION]: OnyxTypes.MergeTransaction;
[ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags;
[ONYXKEYS.COLLECTION.OLD_POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags;
[ONYXKEYS.COLLECTION.SELECTED_TAB]: OnyxTypes.SelectedTabRequest;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index e79811f5dd9e..452deb59a44c 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -2076,7 +2076,15 @@ const ROUTES = {
TRANSACTION_RECEIPT: {
route: 'r/:reportID/transaction/:transactionID/receipt/:action?/:iouType?',
- getRoute: (reportID: string | undefined, transactionID: string | undefined, readonly = false, isFromReviewDuplicates = false, action?: IOUAction, iouType?: IOUType) => {
+ getRoute: (
+ reportID: string | undefined,
+ transactionID: string | undefined,
+ readonly = false,
+ isFromReviewDuplicates = false,
+ action?: IOUAction,
+ iouType?: IOUType,
+ mergeTransactionID?: string,
+ ) => {
if (!reportID) {
Log.warn('Invalid reportID is used to build the TRANSACTION_RECEIPT route');
}
@@ -2085,7 +2093,7 @@ const ROUTES = {
}
return `r/${reportID}/transaction/${transactionID}/receipt${action ? `/${action}` : ''}${iouType ? `/${iouType}` : ''}?readonly=${readonly}${
isFromReviewDuplicates ? '&isFromReviewDuplicates=true' : ''
- }` as const;
+ }${mergeTransactionID ? `&mergeTransactionID=${mergeTransactionID}` : ''}` as const;
},
},
@@ -2125,6 +2133,22 @@ const ROUTES = {
route: 'r/:threadReportID/duplicates/confirm',
getRoute: (threadReportID: string, backTo?: string) => getUrlWithBackToParam(`r/${threadReportID}/duplicates/confirm` as const, backTo),
},
+ MERGE_TRANSACTION_LIST_PAGE: {
+ route: 'r/:transactionID/merge',
+ getRoute: (transactionID: string | undefined, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge` as const, backTo),
+ },
+ MERGE_TRANSACTION_RECEIPT_PAGE: {
+ route: 'r/:transactionID/merge/receipt',
+ getRoute: (transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge/receipt` as const, backTo),
+ },
+ MERGE_TRANSACTION_DETAILS_PAGE: {
+ route: 'r/:transactionID/merge/details',
+ getRoute: (transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge/details` as const, backTo),
+ },
+ MERGE_TRANSACTION_CONFIRMATION_PAGE: {
+ route: 'r/:transactionID/merge/confirmation',
+ getRoute: (transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${transactionID}/merge/confirmation` as const, backTo),
+ },
POLICY_ACCOUNTING_XERO_IMPORT: {
route: 'workspaces/:policyID/accounting/xero/import',
getRoute: (policyID: string) => `workspaces/${policyID}/accounting/xero/import` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 6e05d7a0fcfa..13158b59b4cd 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -223,6 +223,7 @@ const SCREENS = {
DEBUG: 'Debug',
ADD_UNREPORTED_EXPENSE: 'AddUnreportedExpense',
SCHEDULE_CALL: 'ScheduleCall',
+ MERGE_TRANSACTION: 'MergeTransaction',
},
PUBLIC_CONSOLE_DEBUG: 'Console_Debug',
ONBOARDING_MODAL: {
@@ -293,6 +294,13 @@ const SCREENS = {
CONFIRMATION: 'Transaction_Duplicate_Confirmation',
},
+ MERGE_TRANSACTION: {
+ LIST_PAGE: 'Merge_Transaction_List_Page',
+ RECEIPT_PAGE: 'Merge_Transaction_Receipt_Page',
+ DETAILS_PAGE: 'Merge_Transaction_Details_Page',
+ CONFIRMATION_PAGE: 'Merge_Transaction_Confirmation_Page',
+ },
+
IOU_SEND: {
ADD_BANK_ACCOUNT: 'IOU_Send_Add_Bank_Account',
ADD_DEBIT_CARD: 'IOU_Send_Add_Debit_Card',
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index 81b511cce86f..dd5107a5a7f9 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -96,6 +96,7 @@ import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustrat
import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg';
import CreditCardsNewGreen from '@assets/images/simple-illustrations/simple-illustration__creditcards--green.svg';
import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg';
+import EmptyShelves from '@assets/images/simple-illustrations/simple-illustration__empty-shelves.svg';
import EmptyState from '@assets/images/simple-illustrations/simple-illustration__empty-state.svg';
import Encryption from '@assets/images/simple-illustrations/simple-illustration__encryption.svg';
import EnvelopeReceipt from '@assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg';
@@ -324,4 +325,5 @@ export {
ReceiptsStackedOnPin,
PaperAirplane,
CardReplacementSuccess,
+ EmptyShelves,
};
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index ca7d2813624d..873afcb43970 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -16,6 +16,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import {setupMergeTransactionData} from '@libs/actions/MergeTransaction';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import {deleteAppReport, downloadReportPDF, exportReportToCSV, exportReportToPDF, exportToIntegration, markAsManuallyExported, openReport, openUnreportedExpense} from '@libs/actions/Report';
import {queueExportSearchWithTemplate} from '@libs/actions/Search';
@@ -306,6 +307,7 @@ function MoneyReportHeader({
allTransactionsLength: transactions.length,
session,
onExportFailed: () => setIsDownloadErrorModalVisible(true),
+ policy,
beginExportWithTemplate: (templateName, templateType, transactionIDList) => beginExportWithTemplate(templateName, templateType, transactionIDList),
});
@@ -891,6 +893,20 @@ function MoneyReportHeader({
initSplitExpense(currentTransaction);
},
},
+ [CONST.REPORT.SECONDARY_ACTIONS.MERGE]: {
+ text: translate('common.merge'),
+ icon: Expensicons.ArrowCollapse,
+ value: CONST.REPORT.SECONDARY_ACTIONS.MERGE,
+ onSelected: () => {
+ const currentTransaction = transactions.at(0);
+ if (!currentTransaction) {
+ return;
+ }
+
+ setupMergeTransactionData(currentTransaction.transactionID, {targetTransactionID: currentTransaction.transactionID});
+ Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(currentTransaction.transactionID, Navigation.getActiveRoute()));
+ },
+ },
[CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE]: {
text: translate('iou.changeWorkspace'),
icon: Expensicons.Buildings,
diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx
index 4613ad5301a7..218c8fee2901 100644
--- a/src/components/MoneyRequestHeader.tsx
+++ b/src/components/MoneyRequestHeader.tsx
@@ -13,6 +13,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useTransactionViolations from '@hooks/useTransactionViolations';
import {deleteMoneyRequest, deleteTrackExpense, initSplitExpense} from '@libs/actions/IOU';
+import {setupMergeTransactionData} from '@libs/actions/MergeTransaction';
import Navigation from '@libs/Navigation/Navigation';
import {getOriginalMessage, getReportActions, isMoneyRequestAction, isTrackExpenseAction} from '@libs/ReportActionsUtils';
import {getTransactionThreadPrimaryAction} from '@libs/ReportPrimaryActionUtils';
@@ -244,6 +245,19 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre
initSplitExpense(transaction);
},
},
+ [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MERGE]: {
+ text: translate('common.merge'),
+ icon: Expensicons.ArrowCollapse,
+ value: CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MERGE,
+ onSelected: () => {
+ if (!transaction) {
+ return;
+ }
+
+ setupMergeTransactionData(transaction.transactionID, {targetTransactionID: transaction.transactionID});
+ Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(transaction.transactionID, Navigation.getActiveRoute()));
+ },
+ },
[CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS]: {
value: CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS,
text: translate('iou.viewDetails'),
diff --git a/src/components/RadioButton.tsx b/src/components/RadioButton.tsx
index 1ee885681700..8f16e852bc61 100644
--- a/src/components/RadioButton.tsx
+++ b/src/components/RadioButton.tsx
@@ -21,12 +21,45 @@ type RadioButtonProps = {
/** Should the input be disabled */
disabled?: boolean;
+
+ /** Whether to use new radio button style */
+ // See https://expensify.slack.com/archives/C07HPDRELLD/p1752500012040139?thread_ts=1751637205.950179&cid=C07HPDRELLD
+ shouldUseNewStyle?: boolean;
};
-function RadioButton({isChecked, onPress, accessibilityLabel, hasError = false, disabled = false}: RadioButtonProps) {
+function RadioButton({isChecked, onPress, accessibilityLabel, hasError = false, disabled = false, shouldUseNewStyle = false}: RadioButtonProps) {
const theme = useTheme();
const styles = useThemeStyles();
+ if (shouldUseNewStyle) {
+ return (
+
+ {isChecked && (
+
+ )}
+
+ );
+ }
+
return (
;
+
+ /** Merge transaction ID to show in merge transaction flow */
+ mergeTransactionID?: string;
};
const receiptImageViolationNames: OnyxTypes.ViolationName[] = [
@@ -121,7 +126,16 @@ const receiptImageViolationNames: OnyxTypes.ViolationName[] = [
const receiptFieldViolationNames: OnyxTypes.ViolationName[] = [CONST.VIOLATIONS.MODIFIED_AMOUNT, CONST.VIOLATIONS.MODIFIED_DATE];
-function MoneyRequestView({allReports, report, policy, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) {
+function MoneyRequestView({
+ allReports,
+ report,
+ policy,
+ shouldShowAnimatedBackground,
+ readonly = false,
+ updatedTransaction,
+ isFromReviewDuplicates = false,
+ mergeTransactionID,
+}: MoneyRequestViewProps) {
const styles = useThemeStyles();
const {isOffline} = useNetwork();
const {translate, toLocaleDigit} = useLocalize();
@@ -181,9 +195,13 @@ function MoneyRequestView({allReports, report, policy, shouldShowAnimatedBackgro
const isTransactionScanning = isScanning(updatedTransaction ?? transaction);
const didReceiptScanSucceed = hasReceipt && didReceiptScanSucceedTransactionUtils(transaction);
const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest);
- const shouldDisplayTransactionAmount = ((isDistanceRequest && hasRoute) || !!transactionAmount) && transactionAmount !== undefined;
- const formattedTransactionAmount = shouldDisplayTransactionAmount ? convertToDisplayString(transactionAmount, transactionCurrency) : '';
- const formattedPerAttendeeAmount = shouldDisplayTransactionAmount ? convertToDisplayString(transactionAmount / (transactionAttendees?.length ?? 1), transactionCurrency) : '';
+
+ const actualAmount = updatedTransaction ? getAmount(updatedTransaction) : transactionAmount;
+ const actualCurrency = updatedTransaction ? getCurrency(updatedTransaction) : transactionCurrency;
+ const shouldDisplayTransactionAmount = ((isDistanceRequest && hasRoute) || !!actualAmount) && actualAmount !== undefined;
+ const formattedTransactionAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount, actualCurrency) : '';
+ const formattedPerAttendeeAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount / (transactionAttendees?.length ?? 1), actualCurrency) : '';
+
const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency);
const isCardTransaction = isCardTransactionTransactionUtils(transaction);
const cardProgramName = getCompanyCardDescription(transaction?.cardName, transaction?.cardID, cardList);
@@ -661,6 +679,7 @@ function MoneyRequestView({allReports, report, policy, shouldShowAnimatedBackgro
enablePreviewModal
readonly={readonly || !canEditReceipt}
isFromReviewDuplicates={isFromReviewDuplicates}
+ mergeTransactionID={mergeTransactionID}
/>
)}
diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx
index 83f2cf3ac536..b02810c758d4 100644
--- a/src/components/ReportActionItem/ReportActionItemImage.tsx
+++ b/src/components/ReportActionItem/ReportActionItemImage.tsx
@@ -61,6 +61,9 @@ type ReportActionItemImageProps = {
/** whether or not this report is from review duplicates */
isFromReviewDuplicates?: boolean;
+ /** Merge transaction ID to show in merge transaction flow */
+ mergeTransactionID?: string;
+
/** Callback to be called on pressing the image */
onPress?: () => void;
@@ -88,6 +91,7 @@ function ReportActionItemImage({
readonly = false,
shouldMapHaveBorderRadius,
isFromReviewDuplicates = false,
+ mergeTransactionID,
onPress,
shouldUseFullHeight,
}: ReportActionItemImageProps) {
@@ -154,7 +158,15 @@ function ReportActionItemImage({
style={[styles.w100, styles.h100, styles.noOutline as ViewStyle]}
onPress={() =>
Navigation.navigate(
- ROUTES.TRANSACTION_RECEIPT.getRoute(transactionThreadReport?.reportID ?? report?.reportID, transaction?.transactionID, readonly, isFromReviewDuplicates),
+ ROUTES.TRANSACTION_RECEIPT.getRoute(
+ transactionThreadReport?.reportID ?? report?.reportID,
+ transaction?.transactionID,
+ readonly,
+ isFromReviewDuplicates,
+ undefined,
+ undefined,
+ mergeTransactionID,
+ ),
)
}
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index c79418d3cfb5..8dc89e06443f 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -79,6 +79,7 @@ function BaseSelectionList(
listEmptyContent,
showScrollIndicator = true,
showLoadingPlaceholder = false,
+ LoadingPlaceholderComponent = OptionsListSkeletonView,
showConfirmButton = false,
isConfirmButtonDisabled = false,
shouldUseDefaultTheme = false,
@@ -681,7 +682,7 @@ function BaseSelectionList(
const renderListEmptyContent = () => {
if (showLoadingPlaceholder) {
return (
- = {
shouldShow?: boolean;
};
+type LoadingPlaceholderComponentProps = {
+ shouldStyleAsTable?: boolean;
+ fixedNumItems?: number;
+ speed?: number;
+};
+
type SectionWithIndexOffset = Section & {
/** The initial index of this section given the total number of options in each section's data array */
indexOffset?: number;
@@ -661,6 +667,9 @@ type SelectionListProps = Partial & {
/** Whether to show the loading placeholder */
showLoadingPlaceholder?: boolean;
+ /** The component to show when the list is loading */
+ LoadingPlaceholderComponent?: React.ComponentType;
+
/** Whether to show the default confirm button */
showConfirmButton?: boolean;
diff --git a/src/components/Skeletons/MergeExpensesSkeleton.tsx b/src/components/Skeletons/MergeExpensesSkeleton.tsx
new file mode 100644
index 000000000000..1fb1a647ea80
--- /dev/null
+++ b/src/components/Skeletons/MergeExpensesSkeleton.tsx
@@ -0,0 +1,90 @@
+import React, {useCallback, useLayoutEffect, useRef} from 'react';
+import {View} from 'react-native';
+import {Rect} from 'react-native-svg';
+import useThemeStyles from '@hooks/useThemeStyles';
+import ItemListSkeletonView from './ItemListSkeletonView';
+
+const barHeight = 7;
+const longBarWidth = 120;
+const mediumBarWidth = 60;
+const shortBarWidth = 40;
+
+type MergeExpensesSkeletonProps = {
+ fixedNumItems?: number;
+ speed?: number;
+};
+
+function MergeExpensesSkeleton({fixedNumItems, speed}: MergeExpensesSkeletonProps) {
+ const containerRef = useRef(null);
+ const styles = useThemeStyles();
+ const [pageWidth, setPageWidth] = React.useState(0);
+ useLayoutEffect(() => {
+ containerRef.current?.measure((x, y, width) => {
+ setPageWidth(width - 24);
+ });
+ }, []);
+
+ const skeletonItem = useCallback(() => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+ }, [pageWidth]);
+
+ return (
+
+
+
+ );
+}
+
+MergeExpensesSkeleton.displayName = 'MergeExpensesSkeleton';
+
+export default MergeExpensesSkeleton;
diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx
index 0f7bbb208fef..23747f77d8fb 100644
--- a/src/components/TransactionItemRow/index.tsx
+++ b/src/components/TransactionItemRow/index.tsx
@@ -4,6 +4,7 @@ import type {ViewStyle} from 'react-native';
import type {ValueOf} from 'type-fest';
import Checkbox from '@components/Checkbox';
import type {TransactionWithOptionalHighlight} from '@components/MoneyRequestReportView/MoneyRequestReportTransactionList';
+import RadioButton from '@components/RadioButton';
import type {TableColumnSize} from '@components/Search/types';
import ActionCell from '@components/SelectionList/Search/ActionCell';
import DateCell from '@components/SelectionList/Search/DateCell';
@@ -86,14 +87,18 @@ type TransactionItemRowProps = {
dateColumnSize: TableColumnSize;
amountColumnSize: TableColumnSize;
taxAmountColumnSize: TableColumnSize;
- onCheckboxPress: (transactionID: string) => void;
- shouldShowCheckbox: boolean;
+ onCheckboxPress?: (transactionID: string) => void;
+ shouldShowCheckbox?: boolean;
columns?: Array>;
onButtonPress?: () => void;
columnWrapperStyles?: ViewStyle[];
isReportItemChild?: boolean;
isActionLoading?: boolean;
isInSingleTransactionReport?: boolean;
+ shouldShowRadioButton?: boolean;
+ onRadioButtonPress?: (transactionID: string) => void;
+ shouldShowErrors?: boolean;
+ shouldHighlightItemWhenSelected?: boolean;
isDisabled?: boolean;
};
@@ -119,7 +124,7 @@ function TransactionItemRow({
dateColumnSize,
amountColumnSize,
taxAmountColumnSize,
- onCheckboxPress,
+ onCheckboxPress = () => {},
shouldShowCheckbox = false,
columns,
onButtonPress = () => {},
@@ -127,6 +132,10 @@ function TransactionItemRow({
isReportItemChild = false,
isActionLoading,
isInSingleTransactionReport = false,
+ shouldShowRadioButton = false,
+ onRadioButtonPress = () => {},
+ shouldShowErrors = true,
+ shouldHighlightItemWhenSelected = true,
isDisabled = false,
}: TransactionItemRowProps) {
const styles = useThemeStyles();
@@ -141,11 +150,11 @@ function TransactionItemRow({
const isTaxAmountColumnWide = taxAmountColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE;
const bgActiveStyles = useMemo(() => {
- if (!isSelected) {
+ if (!isSelected || !shouldHighlightItemWhenSelected) {
return [];
}
return styles.activeComponentBG;
- }, [isSelected, styles.activeComponentBG]);
+ }, [isSelected, styles.activeComponentBG, shouldHighlightItemWhenSelected]);
const merchant = useMemo(() => getMerchantName(transactionItem, translate), [transactionItem, translate]);
const description = getDescription(transactionItem);
@@ -426,6 +435,17 @@ function TransactionItemRow({
)}
+ {shouldShowRadioButton && (
+
+ onRadioButtonPress?.(transactionItem.transactionID)}
+ accessibilityLabel={CONST.ROLE.RADIO}
+ shouldUseNewStyle
+ />
+
+ )}
@@ -443,12 +463,14 @@ function TransactionItemRow({
/>
)}
-
+ {shouldShowErrors && (
+
+ )}
{shouldRenderChatBubbleCell && (
- {
- onCheckboxPress(transactionItem.transactionID);
- }}
- accessibilityLabel={CONST.ROLE.CHECKBOX}
- isChecked={isSelected}
- style={styles.mr1}
- wrapperStyle={styles.justifyContentCenter}
- />
+ {!shouldShowRadioButton && (
+ {
+ onCheckboxPress(transactionItem.transactionID);
+ }}
+ accessibilityLabel={CONST.ROLE.CHECKBOX}
+ isChecked={isSelected}
+ style={styles.mr1}
+ wrapperStyle={styles.justifyContentCenter}
+ />
+ )}
{columns?.map((column) => columnComponent[column])}
+ {shouldShowRadioButton && (
+
+ onRadioButtonPress?.(transactionItem.transactionID)}
+ accessibilityLabel={CONST.ROLE.RADIO}
+ shouldUseNewStyle
+ />
+
+ )}
-
+ {shouldShowErrors && (
+
+ )}
);
}
diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts
index f5b13602836c..eac0599fbe76 100644
--- a/src/hooks/useSelectedTransactionsActions.ts
+++ b/src/hooks/useSelectedTransactionsActions.ts
@@ -3,10 +3,12 @@ import * as Expensicons from '@components/Icon/Expensicons';
import type {PopoverMenuItem} from '@components/PopoverMenu';
import {useSearchContext} from '@components/Search/SearchContext';
import {deleteMoneyRequest, unholdRequest} from '@libs/actions/IOU';
+import {setupMergeTransactionData} from '@libs/actions/MergeTransaction';
import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import {exportReportToCSV} from '@libs/actions/Report';
import Navigation from '@libs/Navigation/Navigation';
import {getIOUActionForTransactionID, getOriginalMessage, isDeletedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils';
+import {isMergeAction} from '@libs/ReportSecondaryActionUtils';
import {
canDeleteCardTransactionByLiabilityType,
canDeleteTransaction,
@@ -21,7 +23,7 @@ import type {IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {OriginalMessageIOU, Report, ReportAction, Session, Transaction} from '@src/types/onyx';
+import type {OriginalMessageIOU, Policy, Report, ReportAction, Session, Transaction} from '@src/types/onyx';
import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations';
import useLocalize from './useLocalize';
import useOnyx from './useOnyx';
@@ -31,6 +33,7 @@ import useReportIsArchived from './useReportIsArchived';
const HOLD = 'HOLD';
const UNHOLD = 'UNHOLD';
const MOVE = 'MOVE';
+const MERGE = 'MERGE';
function useSelectedTransactionsActions({
report,
@@ -38,6 +41,7 @@ function useSelectedTransactionsActions({
allTransactionsLength,
session,
onExportFailed,
+ policy,
beginExportWithTemplate,
}: {
report?: Report;
@@ -45,6 +49,7 @@ function useSelectedTransactionsActions({
allTransactionsLength: number;
session?: Session;
onExportFailed?: () => void;
+ policy?: Policy;
beginExportWithTemplate: (templateName: string, templateType: string, transactionIDList: string[]) => void;
}) {
const {selectedTransactionIDs, clearSelectedTransactions} = useSearchContext();
@@ -257,6 +262,26 @@ function useSelectedTransactionsActions({
});
}
+ // In phase 1, we only show merge action if report is eligible for merge and only one transaction is selected
+ const canMergeTransaction = selectedTransactions.length === 1 && report && isMergeAction(report, selectedTransactions, policy);
+ if (canMergeTransaction) {
+ options.push({
+ text: translate('common.merge'),
+ icon: Expensicons.ArrowCollapse,
+ value: MERGE,
+ onSelected: () => {
+ const targetTransaction = selectedTransactions.at(0);
+
+ if (!report || !targetTransaction) {
+ return;
+ }
+
+ setupMergeTransactionData(targetTransaction.transactionID, {targetTransactionID: targetTransaction.transactionID});
+ Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(targetTransaction.transactionID, Navigation.getActiveRoute()));
+ },
+ });
+ }
+
const canAllSelectedTransactionsBeRemoved = Object.values(selectedTransactions).every((transaction) => {
const canRemoveTransaction = canDeleteCardTransactionByLiabilityType(transaction);
const action = getIOUActionForTransactionID(reportActions, transaction.transactionID);
@@ -290,6 +315,7 @@ function useSelectedTransactionsActions({
iouType,
session?.accountID,
showDeleteModal,
+ policy,
beginExportWithTemplate,
integrationsExportTemplates,
]);
diff --git a/src/languages/de.ts b/src/languages/de.ts
index ed6c1f598675..a59e44c3a5de 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -633,6 +633,7 @@ const translations = {
getTheApp: 'Hole dir die App',
scanReceiptsOnTheGo: 'Scannen Sie Belege von Ihrem Telefon aus',
headsUp: 'Achtung!',
+ merge: 'Zusammenführen',
unstableInternetConnection: 'Instabile Internetverbindung. Bitte überprüfe dein Netzwerk und versuche es erneut.',
},
supportalNoAccess: {
@@ -1357,6 +1358,31 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `Übermittelt an ${name}`,
moveExpenses: () => ({one: 'Ausgabe verschieben', other: 'Ausgaben verschieben'}),
},
+ transactionMerge: {
+ listPage: {
+ header: 'Ausgaben zusammenführen',
+ noEligibleExpenseFound: 'Keine geeigneten Ausgaben gefunden',
+ noEligibleExpenseFoundSubtitle: `Du hast keine Ausgaben, die mit dieser zusammengeführt werden können. Erfahre mehr über geeignete Ausgaben.`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) =>
+ `Wähle eine geeignete Ausgabe zum Zusammenführen ${reportName}.`,
+ },
+ receiptPage: {
+ header: 'Beleg auswählen',
+ pageTitle: 'Wähle den Beleg, den du behalten möchtest:',
+ },
+ detailsPage: {
+ header: 'Details auswählen',
+ pageTitle: 'Wähle die Details, die du behalten möchtest:',
+ noDifferences: 'Keine Unterschiede zwischen den Transaktionen gefunden',
+ pleaseSelectError: ({field}: {field: string}) => `Bitte wähle ein(e) ${field}`,
+ selectAllDetailsError: 'Wähle alle Details, bevor du fortfährst.',
+ },
+ confirmationPage: {
+ header: 'Details bestätigen',
+ pageTitle: 'Bestätige die Details, die du behalten möchtest. Die nicht behaltenen Details werden gelöscht.',
+ confirmButton: 'Ausgaben zusammenführen',
+ },
+ },
share: {
shareToExpensify: 'Teilen mit Expensify',
messageInputLabel: 'Nachricht',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index ef7b9e4908b5..68782c797b07 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1,6 +1,7 @@
import {CONST as COMMON_CONST} from 'expensify-common';
import startCase from 'lodash/startCase';
import type {OnboardingCompanySize, OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow';
+import StringUtils from '@libs/StringUtils';
import CONST from '@src/CONST';
import type {Country} from '@src/CONST';
import type OriginalMessage from '@src/types/onyx/OriginalMessage';
@@ -623,6 +624,7 @@ const translations = {
getTheApp: 'Get the app',
scanReceiptsOnTheGo: 'Scan receipts from your phone',
headsUp: 'Heads up!',
+ merge: 'Merge',
unstableInternetConnection: 'Unstable internet connection. Please check your network and try again.',
},
supportalNoAccess: {
@@ -1340,6 +1342,34 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `Submits to ${name}`,
moveExpenses: () => ({one: 'Move expense', other: 'Move expenses'}),
},
+ transactionMerge: {
+ listPage: {
+ header: 'Merge expenses',
+ noEligibleExpenseFound: 'No eligible expenses found',
+ noEligibleExpenseFoundSubtitle: `You don't have any expenses that can be merged with this one. Learn more about eligible expenses.`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) =>
+ `Select an eligible expense to merge with ${reportName}.`,
+ },
+ receiptPage: {
+ header: 'Select receipt',
+ pageTitle: 'Select the receipt you want to keep:',
+ },
+ detailsPage: {
+ header: 'Select details',
+ pageTitle: 'Select the details you want to keep:',
+ noDifferences: 'No differences found between the transactions',
+ pleaseSelectError: ({field}: {field: string}) => {
+ const article = StringUtils.startsWithVowel(field) ? 'an' : 'a';
+ return `Please select ${article} ${field}`;
+ },
+ selectAllDetailsError: 'Select all details before continuing.',
+ },
+ confirmationPage: {
+ header: 'Confirm details',
+ pageTitle: "Confirm the details you're keeping. The details you don't keep will be deleted.",
+ confirmButton: 'Merge expenses',
+ },
+ },
share: {
shareToExpensify: 'Share to Expensify',
messageInputLabel: 'Message',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index e8f9f1bf89eb..1dcb88e64df5 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -614,6 +614,7 @@ const translations = {
getTheApp: 'Descarga la app',
scanReceiptsOnTheGo: 'Escanea recibos desde tu teléfono',
headsUp: '¡Atención!',
+ merge: 'Fusionar',
unstableInternetConnection: 'Conexión a internet inestable. Por favor, revisa tu red e inténtalo de nuevo.',
},
supportalNoAccess: {
@@ -1338,6 +1339,31 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `Se envía a ${name}`,
moveExpenses: () => ({one: 'Mover gasto', other: 'Mover gastos'}),
},
+ transactionMerge: {
+ listPage: {
+ header: 'Fusionar gastos',
+ noEligibleExpenseFound: 'No se encontraron gastos válidos',
+ noEligibleExpenseFoundSubtitle: `No tienes ningún gasto que pueda fusionarse con éste. Obtén más información sobre gastos válidos.`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) =>
+ `Selecciona un gasto válido con el que fusionar ${reportName}.`,
+ },
+ receiptPage: {
+ header: 'Selecciona el comprobante',
+ pageTitle: 'Selecciona el comprobante que deseas conservar:',
+ },
+ detailsPage: {
+ header: 'Selecciona los detalles',
+ pageTitle: 'Selecciona los detalles que deseas conservar:',
+ noDifferences: 'No se encontraron diferencias entre las transacciones',
+ pleaseSelectError: ({field}: {field: string}) => `Por favor, selecciona un(a) ${field}`,
+ selectAllDetailsError: 'Selecciona todos los detalles antes de continuar.',
+ },
+ confirmationPage: {
+ header: 'Confirma los detalles',
+ pageTitle: 'Confirma los detalles que conservarás. Los detalles que no conserves serán eliminados.',
+ confirmButton: 'Fusionar gastos',
+ },
+ },
share: {
shareToExpensify: 'Compartir para Expensify',
messageInputLabel: 'Mensaje',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index a5fc1643ff78..8ad9fd1ce49f 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -633,6 +633,7 @@ const translations = {
getTheApp: "Obtenez l'application",
scanReceiptsOnTheGo: 'Numérisez les reçus depuis votre téléphone',
headsUp: 'Attention !',
+ merge: 'Fusionner',
unstableInternetConnection: 'Connexion Internet instable. Veuillez vérifier votre réseau et réessayer.',
},
supportalNoAccess: {
@@ -1360,6 +1361,31 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `Soumet à ${name}`,
moveExpenses: () => ({one: 'Déplacer la dépense', other: 'Déplacer les dépenses'}),
},
+ transactionMerge: {
+ listPage: {
+ header: 'Fusionner les dépenses',
+ noEligibleExpenseFound: 'Aucune dépense éligible trouvée',
+ noEligibleExpenseFoundSubtitle: `Vous n’avez aucune dépense pouvant être fusionnée avec celle-ci. En savoir plus sur les dépenses éligibles.`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) =>
+ `Sélectionnez une dépense éligible à fusionner ${reportName}.`,
+ },
+ receiptPage: {
+ header: 'Sélectionner le reçu',
+ pageTitle: 'Sélectionnez le reçu à conserver :',
+ },
+ detailsPage: {
+ header: 'Sélectionner les détails',
+ pageTitle: 'Sélectionnez les détails à conserver :',
+ noDifferences: 'Aucune différence trouvée entre les transactions',
+ pleaseSelectError: ({field}: {field: string}) => `Veuillez sélectionner un/une ${field}`,
+ selectAllDetailsError: 'Sélectionnez tous les détails avant de continuer.',
+ },
+ confirmationPage: {
+ header: 'Confirmer les détails',
+ pageTitle: 'Confirmez les détails que vous gardez. Les autres seront supprimés.',
+ confirmButton: 'Fusionner les dépenses',
+ },
+ },
share: {
shareToExpensify: 'Partager sur Expensify',
messageInputLabel: 'Message',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 1371ec2a2989..0d2ddb752d80 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -633,6 +633,7 @@ const translations = {
getTheApp: "Scarica l'app",
scanReceiptsOnTheGo: 'Scansiona le ricevute dal tuo telefono',
headsUp: 'Attenzione!',
+ merge: 'Unisci',
unstableInternetConnection: 'Connessione Internet instabile. Controlla la tua rete e riprova.',
},
supportalNoAccess: {
@@ -1353,6 +1354,31 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `Invia a ${name}`,
moveExpenses: () => ({one: 'Sposta spesa', other: 'Sposta spese'}),
},
+ transactionMerge: {
+ listPage: {
+ header: 'Unisci spese',
+ noEligibleExpenseFound: 'Nessuna spesa idonea trovata',
+ noEligibleExpenseFoundSubtitle: `Non hai spese che possono essere unite a questa. Scopri di più sulle spese idonee.`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) =>
+ `Seleziona una spesa idonea da unire ${reportName}.`,
+ },
+ receiptPage: {
+ header: 'Seleziona ricevuta',
+ pageTitle: 'Seleziona la ricevuta da conservare:',
+ },
+ detailsPage: {
+ header: 'Seleziona dettagli',
+ pageTitle: 'Seleziona i dettagli da conservare:',
+ noDifferences: 'Nessuna differenza trovata tra le transazioni',
+ pleaseSelectError: ({field}: {field: string}) => `Seleziona un/a ${field}`,
+ selectAllDetailsError: 'Seleziona tutti i dettagli prima di continuare.',
+ },
+ confirmationPage: {
+ header: 'Conferma i dettagli',
+ pageTitle: 'Conferma i dettagli che vuoi conservare. Quelli non mantenuti saranno eliminati.',
+ confirmButton: 'Unisci spese',
+ },
+ },
share: {
shareToExpensify: 'Condividi su Expensify',
messageInputLabel: 'Messaggio',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index aebcef05c0a9..a2333b815b40 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -633,6 +633,7 @@ const translations = {
getTheApp: 'アプリを入手',
scanReceiptsOnTheGo: '携帯電話から領収書をスキャンする',
headsUp: 'ご注意ください!',
+ merge: 'マージ',
unstableInternetConnection: 'インターネット接続が不安定です。ネットワークを確認してもう一度お試しください。',
},
supportalNoAccess: {
@@ -1354,6 +1355,31 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `${name}に送信`,
moveExpenses: () => ({one: '経費を移動', other: '経費を移動'}),
},
+ transactionMerge: {
+ listPage: {
+ header: '経費をマージ',
+ noEligibleExpenseFound: 'マージ対象となる経費が見つかりません',
+ noEligibleExpenseFoundSubtitle: `この経費とマージできる経費がありません。マージ可能な経費について詳しくはこちら。`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) =>
+ `マージ対象の経費を選択してください ${reportName}.`,
+ },
+ receiptPage: {
+ header: '領収書を選択',
+ pageTitle: '保存する領収書を選んでください:',
+ },
+ detailsPage: {
+ header: '詳細を選択',
+ pageTitle: '保存する詳細を選んでください:',
+ noDifferences: 'トランザクション間に差異はありません',
+ pleaseSelectError: ({field}: {field: string}) => `${field} を選択してください`,
+ selectAllDetailsError: '続行する前にすべての詳細を選択してください。',
+ },
+ confirmationPage: {
+ header: '詳細を確認',
+ pageTitle: '保持する詳細を確認してください。保持しない詳細は削除されます。',
+ confirmButton: '経費をマージ',
+ },
+ },
share: {
shareToExpensify: 'Expensifyに共有',
messageInputLabel: 'メッセージ',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index d43856e80f63..51ff7d1d18d0 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -632,6 +632,7 @@ const translations = {
getTheApp: 'Download de app',
scanReceiptsOnTheGo: 'Scan bonnetjes vanaf je telefoon',
headsUp: 'Let op!',
+ merge: 'Samenvoegen',
unstableInternetConnection: 'Onstabiele internetverbinding. Controleer je netwerk en probeer het opnieuw.',
},
supportalNoAccess: {
@@ -1355,6 +1356,31 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `Dient in bij ${name}`,
moveExpenses: () => ({one: 'Verplaats uitgave', other: 'Verplaats uitgaven'}),
},
+ transactionMerge: {
+ listPage: {
+ header: 'Kosten samenvoegen',
+ noEligibleExpenseFound: 'Geen in aanmerking komende kosten gevonden',
+ noEligibleExpenseFoundSubtitle: `Je hebt geen kosten die samengevoegd kunnen worden met deze. Meer informatie over in aanmerking komende kosten.`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) =>
+ `Selecteer een geschikte kost om te combineren ${reportName}.`,
+ },
+ receiptPage: {
+ header: 'Ontvangstbewijs selecteren',
+ pageTitle: 'Kies het ontvangstbewijs dat je wilt behouden:',
+ },
+ detailsPage: {
+ header: 'Details selecteren',
+ pageTitle: 'Selecteer de details die je wilt behouden:',
+ noDifferences: 'Geen verschillen gevonden tussen de transacties',
+ pleaseSelectError: ({field}: {field: string}) => `Selecteer een ${field}`,
+ selectAllDetailsError: 'Selecteer alle details voordat je doorgaat.',
+ },
+ confirmationPage: {
+ header: 'Bevestig details',
+ pageTitle: 'Bevestig de details die je wilt bewaren. Niet bewaarde details worden verwijderd.',
+ confirmButton: 'Kosten samenvoegen',
+ },
+ },
share: {
shareToExpensify: 'Delen met Expensify',
messageInputLabel: 'Bericht',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index d8977ee43551..cf32c155c337 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -633,6 +633,7 @@ const translations = {
getTheApp: 'Pobierz aplikację',
scanReceiptsOnTheGo: 'Skanuj paragony za pomocą telefonu',
headsUp: 'Uwaga!',
+ merge: 'Scal',
unstableInternetConnection: 'Niestabilne połączenie internetowe. Sprawdź swoją sieć i spróbuj ponownie.',
},
supportalNoAccess: {
@@ -1352,6 +1353,31 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `Przesyła do ${name}`,
moveExpenses: () => ({one: 'Przenieś wydatek', other: 'Przenieś wydatki'}),
},
+ transactionMerge: {
+ listPage: {
+ header: 'Scal wydatki',
+ noEligibleExpenseFound: 'Nie znaleziono kwalifikujących się wydatków',
+ noEligibleExpenseFoundSubtitle: `Nie masz wydatków, które można scalić z tym. Dowiedz się więcej o kwalifikujących się wydatkach.`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) =>
+ `Wybierz kwalifikujący się wydatek do scalenia ${reportName}.`,
+ },
+ receiptPage: {
+ header: 'Wybierz paragon',
+ pageTitle: 'Wybierz paragon, który chcesz zachować:',
+ },
+ detailsPage: {
+ header: 'Wybierz szczegóły',
+ pageTitle: 'Wybierz szczegóły, które chcesz zachować:',
+ noDifferences: 'Nie znaleziono różnic między transakcjami',
+ pleaseSelectError: ({field}: {field: string}) => `Proszę wybrać ${field}`,
+ selectAllDetailsError: 'Wybierz wszystkie szczegóły przed kontynuowaniem.',
+ },
+ confirmationPage: {
+ header: 'Potwierdź szczegóły',
+ pageTitle: 'Potwierdź szczegóły, które zachowujesz. Niezachowane zostaną usunięte.',
+ confirmButton: 'Scal wydatki',
+ },
+ },
share: {
shareToExpensify: 'Udostępnij do Expensify',
messageInputLabel: 'Wiadomość',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index c81252be63bb..8777c3fb24ca 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -633,6 +633,7 @@ const translations = {
getTheApp: 'Obtenha o aplicativo',
scanReceiptsOnTheGo: 'Digitalize recibos com seu celular',
headsUp: 'Atenção!',
+ merge: 'Mesclar',
unstableInternetConnection: 'Conexão de internet instável. Verifique sua rede e tente novamente.',
},
supportalNoAccess: {
@@ -1353,6 +1354,31 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `Envia para ${name}`,
moveExpenses: () => ({one: 'Mover despesa', other: 'Mover despesas'}),
},
+ transactionMerge: {
+ listPage: {
+ header: 'Mesclar despesas',
+ noEligibleExpenseFound: 'Nenhuma despesa elegível encontrada',
+ noEligibleExpenseFoundSubtitle: `Você não tem despesas que possam ser mescladas com esta. Saiba mais sobre despesas elegíveis.`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) =>
+ `Selecione uma despesa elegível para mesclar ${reportName}.`,
+ },
+ receiptPage: {
+ header: 'Selecionar recibo',
+ pageTitle: 'Selecione o recibo que deseja manter:',
+ },
+ detailsPage: {
+ header: 'Selecionar detalhes',
+ pageTitle: 'Selecione os detalhes que deseja manter:',
+ noDifferences: 'Nenhuma diferença encontrada entre as transações',
+ pleaseSelectError: ({field}: {field: string}) => `Por favor selecione um(a) ${field}`,
+ selectAllDetailsError: 'Selecione todos os detalhes antes de continuar.',
+ },
+ confirmationPage: {
+ header: 'Confirmar detalhes',
+ pageTitle: 'Confirme os detalhes que deseja manter. Os demais serão excluídos.',
+ confirmButton: 'Mesclar despesas',
+ },
+ },
share: {
shareToExpensify: 'Compartilhar no Expensify',
messageInputLabel: 'Mensagem',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 799557f2ca9f..e17435e2ff07 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -632,6 +632,7 @@ const translations = {
getTheApp: '获取应用程序',
scanReceiptsOnTheGo: '用手机扫描收据',
headsUp: '\u6CE8\u610F\uFF01',
+ merge: '合并',
unstableInternetConnection: '互联网连接不稳定。请检查你的网络,然后重试。',
},
supportalNoAccess: {
@@ -1339,6 +1340,30 @@ const translations = {
submitsTo: ({name}: SubmitsToParams) => `提交给${name}`,
moveExpenses: () => ({one: '移动费用', other: '移动费用'}),
},
+ transactionMerge: {
+ listPage: {
+ header: '合并费用',
+ noEligibleExpenseFound: '未找到可合并的费用',
+ noEligibleExpenseFoundSubtitle: `您没有可与此合并的费用。了解更多关于可合并费用的信息。`,
+ selectTransactionToMerge: ({reportName}: {reportName: string}) => `选择一个可合并的费用 ${reportName}.`,
+ },
+ receiptPage: {
+ header: '选择收据',
+ pageTitle: '选择您想保留的收据:',
+ },
+ detailsPage: {
+ header: '选择详情',
+ pageTitle: '选择您想保留的详情:',
+ noDifferences: '发现交易无差异',
+ pleaseSelectError: ({field}: {field: string}) => `请选择一个${field}`,
+ selectAllDetailsError: '继续前请选取所有详情。',
+ },
+ confirmationPage: {
+ header: '确认详情',
+ pageTitle: '确认您保留的详情。未保留的详情将被删除。',
+ confirmButton: '合并费用',
+ },
+ },
share: {
shareToExpensify: '分享到Expensify',
messageInputLabel: '消息',
diff --git a/src/libs/API/parameters/GetTransactionsForMergingParams.ts b/src/libs/API/parameters/GetTransactionsForMergingParams.ts
new file mode 100644
index 000000000000..a8d3d2f5c7b8
--- /dev/null
+++ b/src/libs/API/parameters/GetTransactionsForMergingParams.ts
@@ -0,0 +1,5 @@
+type GetTransactionsForMergingParams = {
+ transactionID: string;
+};
+
+export default GetTransactionsForMergingParams;
diff --git a/src/libs/API/parameters/MergeTransactionParams.ts b/src/libs/API/parameters/MergeTransactionParams.ts
new file mode 100644
index 000000000000..9fb0c04ebdb1
--- /dev/null
+++ b/src/libs/API/parameters/MergeTransactionParams.ts
@@ -0,0 +1,33 @@
+type MergeTransactionParams = {
+ /** Transaction ID we're keeping */
+ transactionID: string;
+
+ /** ID of the transaction we're merging into that will be deleted */
+ transactionIDList: string[];
+
+ /** Created date */
+ created: string;
+
+ /** Merchant which user want to keep */
+ merchant: string;
+
+ /** Category which user want to keep */
+ category: string;
+
+ /** Tag which user want to keep */
+ tag: string;
+
+ /** Description which user want to keep */
+ comment: string;
+
+ /** Whether the transaction is reimbursable */
+ reimbursable: boolean;
+
+ /** Whether the transaction is billable */
+ billable: boolean;
+
+ /** The receiptID we want to keep */
+ receiptID: number | undefined;
+};
+
+export default MergeTransactionParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 4626c848eec0..45c99e9565dd 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -1,3 +1,4 @@
+export type {default as GetTransactionsForMergingParams} from './GetTransactionsForMergingParams';
export type {default as ImportMultiLevelTagsParams} from './ImportMultiLevelTagsParams';
export type {default as CleanPolicyTagsParams} from './CleanPolicyTagsParams';
export type {default as ActivatePhysicalExpensifyCardParams} from './ActivatePhysicalExpensifyCardParams';
@@ -258,6 +259,7 @@ export type {default as SendInvoiceParams} from './SendInvoiceParams';
export type {default as PayInvoiceParams} from './PayInvoiceParams';
export type {default as MarkAsCashParams} from './MarkAsCashParams';
export type {default as MergeDuplicatesParams} from './MergeDuplicatesParams';
+export type {default as MergeTransactionParams} from './MergeTransactionParams';
export type {default as ResolveDuplicatesParams} from './ResolveDuplicatesParams';
export type {default as UpdateSubscriptionTypeParams} from './UpdateSubscriptionTypeParams';
export type {default as SignUpUserParams} from './SignUpUserParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 20739d73c738..f807bbb61887 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -327,6 +327,7 @@ const WRITE_COMMANDS = {
MARK_AS_CASH: 'MarkAsCash',
MERGE_DUPLICATES: 'MergeDuplicates',
RESOLVE_DUPLICATES: 'ResolveDuplicates',
+ MERGE_TRANSACTION: 'Transaction_Merge',
UPDATE_SUBSCRIPTION_TYPE: 'UpdateSubscriptionType',
SIGN_UP_USER: 'SignUpUser',
UPDATE_SUBSCRIPTION_AUTO_RENEW: 'UpdateSubscriptionAutoRenew',
@@ -832,6 +833,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams;
[WRITE_COMMANDS.MERGE_DUPLICATES]: Parameters.MergeDuplicatesParams;
[WRITE_COMMANDS.RESOLVE_DUPLICATES]: Parameters.ResolveDuplicatesParams;
+ [WRITE_COMMANDS.MERGE_TRANSACTION]: Parameters.MergeTransactionParams;
[WRITE_COMMANDS.UPDATE_SUBSCRIPTION_TYPE]: Parameters.UpdateSubscriptionTypeParams;
[WRITE_COMMANDS.SIGN_UP_USER]: Parameters.SignUpUserParams;
[WRITE_COMMANDS.UPDATE_SUBSCRIPTION_AUTO_RENEW]: Parameters.UpdateSubscriptionAutoRenewParams;
@@ -1081,6 +1083,7 @@ const READ_COMMANDS = {
CALCULATE_BILL_NEW_DOT: 'CalculateBillNewDot',
OPEN_UNREPORTED_EXPENSES_PAGE: 'OpenUnreportedExpensesPage',
GET_GUIDE_CALL_AVAILABILITY_SCHEDULE: 'GetGuideCallAvailabilitySchedule',
+ GET_TRANSACTIONS_FOR_MERGING: 'GetTransactionsForMerging',
} as const;
type ReadCommand = ValueOf;
@@ -1157,6 +1160,7 @@ type ReadCommandParameters = {
[READ_COMMANDS.CALCULATE_BILL_NEW_DOT]: null;
[READ_COMMANDS.OPEN_UNREPORTED_EXPENSES_PAGE]: Parameters.OpenUnreportedExpensesPageParams;
[READ_COMMANDS.GET_GUIDE_CALL_AVAILABILITY_SCHEDULE]: Parameters.GetGuideCallAvailabilityScheduleParams;
+ [READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING]: Parameters.GetTransactionsForMergingParams;
};
const SIDE_EFFECT_REQUEST_COMMANDS = {
diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts
new file mode 100644
index 000000000000..a2ab3b302747
--- /dev/null
+++ b/src/libs/MergeTransactionUtils.ts
@@ -0,0 +1,283 @@
+import type {OnyxEntry} from 'react-native-onyx';
+import type {TupleToUnion} from 'type-fest';
+import CONST from '@src/CONST';
+import type {MergeTransaction, Transaction} from '@src/types/onyx';
+import {getIOUActionForReportID} from './ReportActionsUtils';
+import {findSelfDMReportID} from './ReportUtils';
+import {getAmount, getBillable, getCategory, getCurrency, getDescription, getMerchant, getReimbursable, getTag, isCardTransaction} 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', 'category', 'tag', 'description', 'reimbursable', 'billable'] as const;
+type MergeFieldKey = TupleToUnion;
+type MergeValueType = string | number | boolean;
+type MergeValue = {
+ value: MergeValueType;
+ currency?: string;
+};
+
+const MERGE_FIELDS_UTILS = {
+ amount: {
+ translationKey: 'iou.amount',
+ getDataFn: (transaction: Transaction, isFromExpenseReport: boolean) => getAmount(transaction, isFromExpenseReport),
+ },
+ currency: {
+ translationKey: 'iou.currency',
+ getDataFn: getCurrency,
+ },
+ merchant: {
+ translationKey: 'common.merchant',
+ getDataFn: getMerchant,
+ },
+ category: {
+ translationKey: 'common.category',
+ getDataFn: getCategory,
+ },
+ tag: {
+ translationKey: 'common.tag',
+ getDataFn: getTag,
+ },
+ description: {
+ translationKey: 'common.description',
+ getDataFn: getDescription,
+ },
+ reimbursable: {
+ translationKey: 'common.reimbursable',
+ getDataFn: getReimbursable,
+ },
+ billable: {
+ translationKey: 'common.billable',
+ getDataFn: getBillable,
+ },
+};
+
+/**
+ * Fills the receipt.source for a transaction if it's missing
+ * Workaround while wait BE to fix the receipt.source
+ * @param transaction - The transaction to update the receipt source for
+ * @returns The updated transaction with receipt.source filled if it was missing
+ */
+function fillMissingReceiptSource(transaction: Transaction) {
+ // If receipt.source already exists, no need to modify
+ if (!transaction.receipt || !!transaction.receipt?.source || !transaction.filename) {
+ return transaction;
+ }
+
+ return {
+ ...transaction,
+ receipt: {
+ ...transaction.receipt,
+ source: `${RECEIPT_SOURCE_URL}${transaction.filename}`,
+ },
+ };
+}
+
+const getTransactionFromMergeTransaction = (mergeTransaction: OnyxEntry, transactionID: string) => {
+ if (!mergeTransaction?.eligibleTransactions) {
+ return undefined;
+ }
+ const transaction = mergeTransaction.eligibleTransactions.find((eligibleTransaction) => eligibleTransaction.transactionID === transactionID);
+ return transaction ? fillMissingReceiptSource(transaction) : transaction;
+};
+
+/**
+ * Get the source transaction from a merge transaction
+ * @param mergeTransaction - The merge transaction to get the source transaction from
+ * @returns The source transaction or null if it doesn't exist
+ */
+const getSourceTransactionFromMergeTransaction = (mergeTransaction: OnyxEntry) => {
+ if (!mergeTransaction?.sourceTransactionID) {
+ return undefined;
+ }
+
+ return getTransactionFromMergeTransaction(mergeTransaction, mergeTransaction.sourceTransactionID);
+};
+
+/**
+ * Get the target transaction from a merge transaction
+ * @param mergeTransaction - The merge transaction to get the target transaction from
+ * @returns The target transaction or null if it doesn't exist
+ */
+const getTargetTransactionFromMergeTransaction = (mergeTransaction: OnyxEntry) => {
+ if (!mergeTransaction?.targetTransactionID) {
+ return undefined;
+ }
+
+ return getTransactionFromMergeTransaction(mergeTransaction, mergeTransaction.targetTransactionID);
+};
+
+/**
+ * Check if the user should navigate to the receipt review page
+ * @param transactions - array of target and source transactions
+ * @returns True if both transactions have a receipt
+ */
+function shouldNavigateToReceiptReview(transactions: Array>): boolean {
+ return transactions.every((transaction) => transaction?.receipt?.receiptID);
+}
+
+// Check if whether merge value is truly "empty" (null, undefined, or empty string)
+// For boolean fields, false is a valid value, not an empty value
+function isEmptyMergeValue(value: unknown) {
+ return value === null || value === undefined || value === '';
+}
+
+/**
+ * Get the value of a specific merge field from a transaction
+ * @param transaction - The transaction to extract the field value from
+ * @param field - The merge field key to get the value for
+ * @returns The value of the specified field from the transaction
+ */
+function getMergeFieldValue(transaction: OnyxEntry, field: MergeFieldKey) {
+ if (!transaction) {
+ return '';
+ }
+
+ // Handle amount field separately as it requires the second parameter
+ if (field === 'amount') {
+ const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
+ return MERGE_FIELDS_UTILS[field].getDataFn(transaction, !isUnreportedExpense);
+ }
+
+ return MERGE_FIELDS_UTILS[field].getDataFn(transaction);
+}
+
+/**
+ * Get the translation key for a specific merge field
+ * @param field - The merge field key to get the translation key for
+ * @returns The translation key string for the specified field
+ */
+function getMergeFieldTranslationKey(field: MergeFieldKey) {
+ return MERGE_FIELDS_UTILS[field].translationKey;
+}
+
+/**
+ * Get mergeableData data if one is missing, and conflict fields that need to be resolved by the user
+ * @param targetTransaction - The target transaction
+ * @param sourceTransaction - The source transaction
+ * @returns mergeableData and conflictFields
+ */
+function getMergeableDataAndConflictFields(targetTransaction: OnyxEntry, sourceTransaction: OnyxEntry) {
+ const conflictFields: string[] = [];
+ const mergeableData: Record = {};
+
+ MERGE_FIELDS.forEach((field) => {
+ // Currency field is handled by the amount field
+ if (field === 'currency') {
+ return;
+ }
+
+ const targetValue = getMergeFieldValue(targetTransaction, field);
+ const sourceValue = getMergeFieldValue(sourceTransaction, field);
+
+ const isTargetValueEmpty = isEmptyMergeValue(targetValue);
+ const isSourceValueEmpty = isEmptyMergeValue(sourceValue);
+
+ if (isTargetValueEmpty || isSourceValueEmpty || targetValue === sourceValue) {
+ if (field === 'amount' && getMergeFieldValue(targetTransaction, 'currency') !== getMergeFieldValue(sourceTransaction, 'currency')) {
+ conflictFields.push('amount');
+ } else {
+ mergeableData[field] = isTargetValueEmpty ? sourceValue : targetValue;
+ }
+ } else {
+ conflictFields.push(field);
+ }
+ });
+
+ return {mergeableData, conflictFields};
+}
+
+/**
+ * Get the report ID for an expense, if it's unreported, we'll return the self DM report ID
+ */
+function getReportIDForExpense(transaction: OnyxEntry) {
+ if (!transaction) {
+ return undefined;
+ }
+
+ const isUnreportedExpense = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
+
+ if (isUnreportedExpense) {
+ return findSelfDMReportID();
+ }
+
+ return transaction.reportID;
+}
+
+/**
+ * Get the report ID for the transaction thread of a transaction
+ * @param transaction - The transaction to get the report ID for
+ * @returns The report ID for the transaction thread
+ */
+function getTransactionThreadReportID(transaction: OnyxEntry) {
+ const iouActionOfTargetTransaction = getIOUActionForReportID(getReportIDForExpense(transaction), transaction?.transactionID);
+ return iouActionOfTargetTransaction?.childReportID;
+}
+
+/**
+ * Build the merged transaction data for display by combining target transaction with merge transaction updates
+ * @param targetTransaction - The target transaction to merge into
+ * @param mergeTransaction - The merge transaction containing the updates
+ * @returns The merged transaction data or null if required data is missing
+ */
+function buildMergedTransactionData(targetTransaction: OnyxEntry, mergeTransaction: OnyxEntry): Transaction | null {
+ if (!targetTransaction || !mergeTransaction) {
+ return null;
+ }
+
+ return {
+ ...targetTransaction,
+ amount: mergeTransaction.amount,
+ modifiedAmount: mergeTransaction.amount,
+ modifiedCurrency: mergeTransaction.currency,
+ merchant: mergeTransaction.merchant,
+ modifiedMerchant: mergeTransaction.merchant,
+ category: mergeTransaction.category,
+ tag: mergeTransaction.tag,
+ comment: {
+ ...targetTransaction.comment,
+ comment: mergeTransaction.description,
+ },
+ reimbursable: mergeTransaction.reimbursable,
+ billable: mergeTransaction.billable,
+ filename: mergeTransaction.receipt?.source?.split('/').pop(),
+ receipt: mergeTransaction.receipt,
+ };
+}
+
+/**
+ * Determines the correct target and source transaction IDs for merging based on transaction types.
+ *
+ * Rules:
+ * - If one transaction is a card transaction, it becomes the target (card transactions take priority)
+ * - If both are cash transactions, the first parameter becomes the target
+ * - Users can only merge two cash expenses or one cash/one card expense
+ * - Users cannot merge two card expenses
+ *
+ * @param targetTransaction - The first transaction in the merge operation
+ * @param sourceTransaction - The second transaction in the merge operation
+ * @returns An object containing the determined targetTransactionID and sourceTransactionID
+ */
+function selectTargetAndSourceTransactionIDsForMerge(originalTargetTransaction: OnyxEntry, originalSourceTransaction: OnyxEntry) {
+ if (isCardTransaction(originalSourceTransaction)) {
+ return {targetTransactionID: originalSourceTransaction?.transactionID, sourceTransactionID: originalTargetTransaction?.transactionID};
+ }
+
+ return {targetTransactionID: originalTargetTransaction?.transactionID, sourceTransactionID: originalSourceTransaction?.transactionID};
+}
+
+export {
+ getSourceTransactionFromMergeTransaction,
+ getTargetTransactionFromMergeTransaction,
+ shouldNavigateToReceiptReview,
+ getMergeableDataAndConflictFields,
+ getMergeFieldValue,
+ getMergeFieldTranslationKey,
+ buildMergedTransactionData,
+ selectTargetAndSourceTransactionIDsForMerge,
+ isEmptyMergeValue,
+ fillMissingReceiptSource,
+ getTransactionThreadReportID,
+};
+
+export type {MergeFieldKey, MergeValueType, MergeValue};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 58e1ae6a08b9..b0fcb276f589 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -11,6 +11,7 @@ import type {
EditRequestNavigatorParamList,
EnablePaymentsNavigatorParamList,
FlagCommentNavigatorParamList,
+ MergeTransactionNavigatorParamList,
MissingPersonalDetailsParamList,
MoneyRequestNavigatorParamList,
NewChatNavigatorParamList,
@@ -716,6 +717,13 @@ const TransactionDuplicateStackNavigator = createModalStackNavigator require('../../../../pages/TransactionDuplicate/Confirmation').default,
});
+const MergeTransactionStackNavigator = createModalStackNavigator({
+ [SCREENS.MERGE_TRANSACTION.LIST_PAGE]: () => require('../../../../pages/TransactionMerge/MergeTransactionsListPage').default,
+ [SCREENS.MERGE_TRANSACTION.RECEIPT_PAGE]: () => require('../../../../pages/TransactionMerge/ReceiptReviewPage').default,
+ [SCREENS.MERGE_TRANSACTION.DETAILS_PAGE]: () => require('../../../../pages/TransactionMerge/DetailsReviewPage').default,
+ [SCREENS.MERGE_TRANSACTION.CONFIRMATION_PAGE]: () => require('../../../../pages/TransactionMerge/ConfirmationPage').default,
+});
+
const SearchReportModalStackNavigator = createModalStackNavigator(
{
[SCREENS.SEARCH.REPORT_RHP]: () => require('../../../../pages/home/ReportScreen').default,
@@ -840,4 +848,5 @@ export {
ConsoleModalStackNavigator,
AddUnreportedExpenseModalStackNavigator,
ScheduleCallModalStackNavigator,
+ MergeTransactionStackNavigator,
};
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
index 03752df588dc..8267dd03385f 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx
@@ -197,6 +197,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
name={SCREENS.RIGHT_MODAL.TRANSACTION_DUPLICATE}
component={ModalStackNavigators.TransactionDuplicateStackNavigator}
/>
+
['config'] = {
},
},
},
+ [SCREENS.RIGHT_MODAL.MERGE_TRANSACTION]: {
+ screens: {
+ [SCREENS.MERGE_TRANSACTION.LIST_PAGE]: ROUTES.MERGE_TRANSACTION_LIST_PAGE.route,
+ [SCREENS.MERGE_TRANSACTION.RECEIPT_PAGE]: ROUTES.MERGE_TRANSACTION_RECEIPT_PAGE.route,
+ [SCREENS.MERGE_TRANSACTION.DETAILS_PAGE]: ROUTES.MERGE_TRANSACTION_DETAILS_PAGE.route,
+ [SCREENS.MERGE_TRANSACTION.CONFIRMATION_PAGE]: ROUTES.MERGE_TRANSACTION_CONFIRMATION_PAGE.route,
+ },
+ },
[SCREENS.RIGHT_MODAL.SPLIT_DETAILS]: {
screens: {
[SCREENS.SPLIT_DETAILS.ROOT]: ROUTES.SPLIT_BILL_DETAILS.route,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index ccd16ad6b5b6..9eff582798be 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -1716,6 +1716,25 @@ type TransactionDuplicateNavigatorParamList = {
};
};
+type MergeTransactionNavigatorParamList = {
+ [SCREENS.MERGE_TRANSACTION.LIST_PAGE]: {
+ transactionID: string;
+ backTo?: Routes;
+ };
+ [SCREENS.MERGE_TRANSACTION.RECEIPT_PAGE]: {
+ transactionID: string;
+ backTo?: Routes;
+ };
+ [SCREENS.MERGE_TRANSACTION.DETAILS_PAGE]: {
+ transactionID: string;
+ backTo?: Routes;
+ };
+ [SCREENS.MERGE_TRANSACTION.CONFIRMATION_PAGE]: {
+ transactionID: string;
+ backTo?: Routes;
+ };
+};
+
type RightModalNavigatorParamList = {
[SCREENS.RIGHT_MODAL.SETTINGS]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.TWO_FACTOR_AUTH]: NavigatorScreenParams;
@@ -1760,6 +1779,7 @@ type RightModalNavigatorParamList = {
[SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: NavigatorScreenParams;
[SCREENS.RIGHT_MODAL.ADD_UNREPORTED_EXPENSE]: NavigatorScreenParams<{reportId: string | undefined}>;
[SCREENS.RIGHT_MODAL.SCHEDULE_CALL]: NavigatorScreenParams;
+ [SCREENS.RIGHT_MODAL.MERGE_TRANSACTION]: NavigatorScreenParams;
};
type TravelNavigatorParamList = {
@@ -2102,6 +2122,7 @@ type AuthScreensParamList = SharedScreensParamList & {
isFromReviewDuplicates?: string;
action?: IOUAction;
iouType?: IOUType;
+ mergeTransactionID?: string;
};
[SCREENS.CONNECTION_COMPLETE]: undefined;
[NAVIGATORS.SHARE_MODAL_NAVIGATOR]: NavigatorScreenParams;
@@ -2336,4 +2357,5 @@ export type {
SetParamsAction,
WorkspacesTabNavigatorName,
TestToolsModalModalNavigatorParamList,
+ MergeTransactionNavigatorParamList,
};
diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts
index 1ea23262e4d4..a3f428340577 100644
--- a/src/libs/ReportSecondaryActionUtils.ts
+++ b/src/libs/ReportSecondaryActionUtils.ts
@@ -34,6 +34,7 @@ import {
isHoldCreator,
isInvoiceReport as isInvoiceReportUtils,
isIOUReport as isIOUReportUtils,
+ isMoneyRequestReportEligibleForMerge,
isOpenReport as isOpenReportUtils,
isPayer as isPayerUtils,
isProcessingReport as isProcessingReportUtils,
@@ -524,6 +525,30 @@ function isReopenAction(report: Report, policy?: Policy): boolean {
return true;
}
+/**
+ * Checks whether the supplied report supports merging transactions from it.
+ */
+function isMergeAction(parentReport: Report, reportTransactions: Transaction[], policy?: Policy): boolean {
+ // Do not show merge action if there are multiple transactions
+ if (reportTransactions.length !== 1) {
+ return false;
+ }
+
+ const isAnyReceiptBeingScanned = reportTransactions?.some((transaction) => isReceiptBeingScanned(transaction));
+
+ if (isAnyReceiptBeingScanned) {
+ return false;
+ }
+
+ if (isSelfDMReportUtils(parentReport)) {
+ return true;
+ }
+
+ const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
+
+ return isMoneyRequestReportEligibleForMerge(parentReport.reportID, isAdmin);
+}
+
function isRemoveHoldAction(report: Report, chatReport: OnyxEntry, reportTransactions: Transaction[], reportActions?: ReportAction[], policy?: Policy): boolean {
const isReportOnHold = reportTransactions.some(isOnHoldTransactionUtils);
@@ -629,6 +654,10 @@ function getSecondaryReportActions({
options.push(CONST.REPORT.SECONDARY_ACTIONS.SPLIT);
}
+ if (isMergeAction(report, reportTransactions, policy)) {
+ options.push(CONST.REPORT.SECONDARY_ACTIONS.MERGE);
+ }
+
options.push(CONST.REPORT.SECONDARY_ACTIONS.EXPORT);
options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD_PDF);
@@ -693,6 +722,10 @@ function getSecondaryTransactionThreadActions(
options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SPLIT);
}
+ if (isMergeAction(parentReport, [reportTransaction], policy)) {
+ options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.MERGE);
+ }
+
options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS);
if (isDeleteAction(parentReport, [reportTransaction], reportActions ?? [])) {
@@ -701,4 +734,4 @@ function getSecondaryTransactionThreadActions(
return options;
}
-export {getSecondaryReportActions, getSecondaryTransactionThreadActions, isDeleteAction, getSecondaryExportReportActions, isSplitAction};
+export {getSecondaryReportActions, getSecondaryTransactionThreadActions, isDeleteAction, isMergeAction, getSecondaryExportReportActions, isSplitAction};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 3474b4f80373..ff6eaaa2d118 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -2500,6 +2500,39 @@ function canDeleteTransaction(moneyRequestReport: OnyxEntry, isReportArc
return canAddOrDeleteTransactions(moneyRequestReport, isReportArchived);
}
+/**
+ * Determines whether a money request report is eligible for merging transactions based on the user's role and permissions.
+ * Rules:
+ * - **Admins**: reports that are in "Open" or "Processing" status
+ * - **Submitters**: IOUs, unreported expenses, and expenses on Open or Processing reports at the first level of approval
+ * - **Managers**: Expenses on Open or Processing reports
+ *
+ * @param reportID - The ID of the money request report to check for merge eligibility
+ * @param isAdmin - Whether the current user is an admin of the policy associated with the target report
+ *
+ * @returns True if the report is eligible for merging transactions, false otherwise
+ */
+function isMoneyRequestReportEligibleForMerge(reportID: string, isAdmin: boolean): boolean {
+ const report = getReportOrDraftReport(reportID);
+
+ if (!isMoneyRequestReport(report)) {
+ return false;
+ }
+
+ const isManager = isReportManager(report);
+ const isSubmitter = isReportOwner(report);
+
+ if (isAdmin) {
+ return isOpenReport(report) || isProcessingReport(report);
+ }
+
+ if (isSubmitter) {
+ return isOpenReport(report) || (isIOUReport(report) && isProcessingReport(report)) || isAwaitingFirstLevelApproval(report);
+ }
+
+ return isManager && isExpenseReport(report) && isProcessingReport(report);
+}
+
/**
* Checks whether the card transaction support deleting based on liability type
*/
@@ -11670,6 +11703,7 @@ export {
getNextApproverAccountID,
isWorkspaceTaskReport,
isWorkspaceThread,
+ isMoneyRequestReportEligibleForMerge,
getReportStatusTranslation,
};
diff --git a/src/libs/StringUtils/index.ts b/src/libs/StringUtils/index.ts
index 6c9be0d3dab0..85cf14f313a7 100644
--- a/src/libs/StringUtils/index.ts
+++ b/src/libs/StringUtils/index.ts
@@ -173,6 +173,15 @@ function countWhiteSpaces(str: string): number {
return (str.match(/\s/g) ?? []).length;
}
+/**
+ * Check if the string starts with a vowel
+ * @param str - The input string
+ * @returns True if the string starts with a vowel, false otherwise
+ */
+function startsWithVowel(str: string): boolean {
+ return /^[aeiouAEIOU]/.test(str);
+}
+
export default {
sanitizeString,
isEmptyString,
@@ -190,4 +199,5 @@ export default {
getUTF8ByteLength,
decodeUnicode,
countWhiteSpaces,
+ startsWithVowel,
};
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index 992635e27df1..b6b6583cf890 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -943,6 +943,10 @@ function getTransactionViolations(transaction: OnyxEntry !isViolationDismissed(transaction, violation));
}
+function getTransactionViolationsOfTransaction(transactionID: string) {
+ return allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? [];
+}
+
/**
* Check if there is pending rter violation in transactionViolations.
*/
@@ -1975,6 +1979,7 @@ export {
isDemoTransaction,
shouldShowViolation,
isUnreportedAndHasInvalidDistanceRateTransaction,
+ getTransactionViolationsOfTransaction,
};
export type {TransactionChanges};
diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts
new file mode 100644
index 000000000000..ac2799962bc2
--- /dev/null
+++ b/src/libs/actions/MergeTransaction.ts
@@ -0,0 +1,222 @@
+import Onyx from 'react-native-onyx';
+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 {isPolicyAdmin} from '@libs/PolicyUtils';
+import {getReportOrDraftReport, getReportTransactions, isMoneyRequestReportEligibleForMerge, isReportManager} from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import {getTransactionViolationsOfTransaction, isCardTransaction} from '@src/libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {MergeTransaction, Policy, Report, Transaction} from '@src/types/onyx';
+
+/**
+ * Setup merge transaction data for merging flow
+ */
+function setupMergeTransactionData(transactionID: string, values: Partial) {
+ Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, values);
+}
+
+/**
+ * Sets merge transaction data for a specific transaction
+ */
+function setMergeTransactionKey(transactionID: string, values: Partial) {
+ Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, values);
+}
+
+/**
+ * Fetches eligible transactions for merging
+ */
+function getTransactionsForMerging(transactionID: string) {
+ const parameters: GetTransactionsForMergingParams = {
+ transactionID,
+ };
+
+ API.read(READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING, parameters);
+}
+
+function areTransactionsEligibleForMerge(transaction1: Transaction, transaction2: Transaction) {
+ // Do not allow merging two card transactions
+ return !isCardTransaction(transaction1) || !isCardTransaction(transaction2);
+}
+
+/**
+ * Fetches eligible transactions for merging locally
+ * This is FE version of READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call
+ */
+
+function getTransactionsForMergingLocally(
+ targetTransactionID: string,
+ transactions: OnyxCollection,
+ policy: OnyxEntry,
+ report: OnyxEntry,
+ currentUserLogin: string | undefined,
+) {
+ const transactionsArray = Object.values(transactions ?? {});
+ const targetTransaction = transactionsArray.find((transaction) => transaction?.transactionID === targetTransactionID);
+ const isAdmin = isPolicyAdmin(policy, currentUserLogin);
+ const isManager = isReportManager(report);
+
+ let eligibleTransactions: Transaction[] = [];
+
+ // In phase 1:
+ // for managers and admins: we have decided to only return transaction from the target transaction report;
+ // for submitter: can see all transactions that you're also a submitter
+ if (!targetTransaction) {
+ eligibleTransactions = [];
+ } else if (isAdmin || isManager) {
+ const reportTransactions = getReportTransactions(report?.reportID);
+ eligibleTransactions = reportTransactions.filter((transaction): transaction is Transaction => {
+ if (!transaction || transaction.transactionID === targetTransactionID) {
+ return false;
+ }
+ return areTransactionsEligibleForMerge(targetTransaction, transaction);
+ });
+ } else {
+ eligibleTransactions = transactionsArray.filter((transaction): transaction is Transaction => {
+ if (!transaction || transaction.transactionID === targetTransactionID) {
+ return false;
+ }
+
+ const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
+ return (
+ areTransactionsEligibleForMerge(targetTransaction, transaction) &&
+ (isUnreportedExpense || (!!transaction.reportID && isMoneyRequestReportEligibleForMerge(transaction.reportID, false)))
+ );
+ });
+ }
+
+ Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransactionID}`, {
+ eligibleTransactions,
+ });
+}
+
+/**
+ * Merges two transactions by updating the target transaction with selected fields and deleting the source transaction
+ */
+function mergeTransactionRequest(mergeTransactionID: string, mergeTransaction: MergeTransaction, targetTransaction: Transaction, sourceTransaction: Transaction) {
+ const isUnreportedExpense = !targetTransaction.reportID || targetTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
+
+ // If the target transaction we're keeping is unreported, the amount needs to be positive. Otherwise for expense reports it needs to be the opposite sign.
+ const finalAmount = isUnreportedExpense ? Math.abs(mergeTransaction.amount) : -mergeTransaction.amount;
+
+ // Call the merge transaction action
+ const params = {
+ transactionID: mergeTransaction.targetTransactionID,
+ transactionIDList: [mergeTransaction.sourceTransactionID],
+ created: targetTransaction.created,
+ merchant: mergeTransaction.merchant,
+ amount: finalAmount,
+ currency: mergeTransaction.currency,
+ category: mergeTransaction.category,
+ comment: mergeTransaction.description,
+ billable: mergeTransaction.billable,
+ reimbursable: mergeTransaction.reimbursable,
+ tag: mergeTransaction.tag,
+ receiptID: mergeTransaction.receipt?.receiptID,
+ reportID: targetTransaction.reportID,
+ };
+
+ // Optimistic update the target transaction with the new values
+ const optimisticTargetTransactionData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`,
+ value: {
+ merchant: params.merchant,
+ category: params.category,
+ tag: params.tag,
+ comment: {
+ comment: params.comment,
+ },
+ reimbursable: params.reimbursable,
+ billable: params.billable,
+ // Update receipt if receiptID is provided
+ ...(params.receiptID && {
+ receipt: {
+ source: mergeTransaction.receipt?.source ?? targetTransaction.receipt?.source,
+ receiptID: params.receiptID,
+ },
+ }),
+ },
+ };
+ const failureTargetTransactionData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`,
+ value: targetTransaction,
+ };
+
+ // Optimistic delete the source transaction and also delete its report if it was a single expense report
+ const optimisticSourceTransactionData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`,
+ value: null,
+ };
+ const failureSourceTransactionData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`,
+ 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 failureSourceReportData: OnyxUpdate[] =
+ transactionsOfSourceReport.length === 1
+ ? [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${sourceTransaction.reportID}`,
+ value: getReportOrDraftReport(sourceTransaction.reportID),
+ },
+ ]
+ : [];
+
+ // Optimistic delete the merge transaction
+ const optimisticMergeTransactionData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`,
+ value: null,
+ };
+
+ // Optimistic delete duplicated transaction violations
+ const optimisticTransactionViolations: OnyxUpdate[] = [targetTransaction.transactionID, sourceTransaction.transactionID].map((id) => {
+ const violations = getTransactionViolationsOfTransaction(id);
+
+ return {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`,
+ value: violations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION),
+ };
+ });
+ const failureTransactionViolations: OnyxUpdate[] = [targetTransaction.transactionID, sourceTransaction.transactionID].map((id) => {
+ const violations = getTransactionViolationsOfTransaction(id);
+
+ return {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`,
+ value: violations,
+ };
+ });
+
+ const optimisticData: OnyxUpdate[] = [
+ optimisticTargetTransactionData,
+ optimisticSourceTransactionData,
+ ...optimisticSourceReportData,
+ optimisticMergeTransactionData,
+ ...optimisticTransactionViolations,
+ ];
+
+ const failureData: OnyxUpdate[] = [failureTargetTransactionData, failureSourceTransactionData, ...failureSourceReportData, ...failureTransactionViolations];
+
+ API.write(WRITE_COMMANDS.MERGE_TRANSACTION, params, {optimisticData, failureData});
+}
+
+export {setupMergeTransactionData, setMergeTransactionKey, getTransactionsForMerging, getTransactionsForMergingLocally, mergeTransactionRequest};
diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx
new file mode 100644
index 000000000000..ce08116e6fbc
--- /dev/null
+++ b/src/pages/TransactionMerge/ConfirmationPage.tsx
@@ -0,0 +1,124 @@
+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';
+import Button from '@components/Button';
+import FixedFooter from '@components/FixedFooter';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MoneyRequestView from '@components/ReportActionItem/MoneyRequestView';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import {ShowContextMenuContext} from '@components/ShowContextMenuContext';
+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 {buildMergedTransactionData, 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';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import type {Transaction} from '@src/types/onyx';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+
+type ConfirmationPageProps = PlatformStackScreenProps;
+
+function ConfirmationPage({route}: ConfirmationPageProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const [isMergingExpenses, setIsMergingExpenses] = useState(false);
+
+ const {transactionID, backTo} = route.params;
+
+ const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false});
+ const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {canBeMissing: false});
+ const [targetTransaction = getTargetTransactionFromMergeTransaction(mergeTransaction)] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.targetTransactionID}`, {
+ canBeMissing: true,
+ });
+ const [sourceTransaction = getSourceTransactionFromMergeTransaction(mergeTransaction)] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.sourceTransactionID}`, {
+ canBeMissing: true,
+ });
+
+ 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 contextValue = useMemo(
+ () => ({
+ transactionThreadReport: targetTransactionThreadReport,
+ action: undefined,
+ report: targetTransactionThreadReport,
+ checkIfContextMenuActive: () => {},
+ onShowContextMenu: () => {},
+ isReportArchived: false,
+ anchor: null,
+ isDisabled: false,
+ }),
+ [targetTransactionThreadReport],
+ );
+
+ const handleMergeExpenses = useCallback(() => {
+ if (!targetTransaction || !mergeTransaction || !sourceTransaction) {
+ return;
+ }
+
+ setIsMergingExpenses(true);
+ mergeTransactionRequest(transactionID, mergeTransaction, targetTransaction, sourceTransaction);
+ Navigation.dismissModal();
+ }, [targetTransaction, mergeTransaction, sourceTransaction, transactionID]);
+
+ if (isLoadingOnyxValue(mergeTransactionMetadata) || !targetTransactionThreadReport?.reportID) {
+ return ;
+ }
+
+ return (
+
+
+ {
+ Navigation.goBack(backTo);
+ }}
+ />
+
+
+ {translate('transactionMerge.confirmationPage.pageTitle')}
+
+
+ }
+ mergeTransactionID={transactionID}
+ />
+
+
+
+
+
+
+
+ );
+}
+
+ConfirmationPage.displayName = 'ConfirmationPage';
+
+export default ConfirmationPage;
diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx
new file mode 100644
index 000000000000..13848d4af2dd
--- /dev/null
+++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx
@@ -0,0 +1,231 @@
+import React, {useEffect, useState} from 'react';
+import {View} from 'react-native';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import Button from '@components/Button';
+import FixedFooter from '@components/FixedFooter';
+import FormHelpMessage from '@components/FormHelpMessage';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {setMergeTransactionKey} from '@libs/actions/MergeTransaction';
+import {convertToDisplayString} from '@libs/CurrencyUtils';
+import {
+ getMergeableDataAndConflictFields,
+ getMergeFieldTranslationKey,
+ getMergeFieldValue,
+ getSourceTransactionFromMergeTransaction,
+ getTargetTransactionFromMergeTransaction,
+ getTransactionThreadReportID,
+ isEmptyMergeValue,
+} from '@libs/MergeTransactionUtils';
+import type {MergeFieldKey, MergeValue} from '@libs/MergeTransactionUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types';
+import {getCurrency} from '@libs/TransactionUtils';
+import {openReport} from '@userActions/Report';
+import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+import MergeFieldReview from './MergeFieldReview';
+
+type DetailsReviewPageProps = PlatformStackScreenProps;
+
+function DetailsReviewPage({route}: DetailsReviewPageProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {transactionID, backTo} = route.params;
+
+ const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false});
+ const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {canBeMissing: false});
+ const [targetTransaction = getTargetTransactionFromMergeTransaction(mergeTransaction)] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.targetTransactionID}`, {
+ canBeMissing: true,
+ });
+ const [sourceTransaction = getSourceTransactionFromMergeTransaction(mergeTransaction)] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.sourceTransactionID}`, {
+ canBeMissing: true,
+ });
+ const targetTransactionThreadReportID = getTransactionThreadReportID(targetTransaction);
+ const targetTransactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`];
+
+ const [hasErrors, setHasErrors] = useState>>({});
+ const [diffFields, setDiffFields] = useState([]);
+ const [isCheckingDataBeforeGoNext, setIsCheckingDataBeforeGoNext] = useState(false);
+
+ useEffect(() => {
+ if (!transactionID || !targetTransaction || !sourceTransaction) {
+ return;
+ }
+
+ const {conflictFields, mergeableData} = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction);
+
+ setMergeTransactionKey(transactionID, mergeableData);
+ setDiffFields(conflictFields as MergeFieldKey[]);
+ }, [targetTransaction, sourceTransaction, transactionID]);
+
+ useEffect(() => {
+ if (!isCheckingDataBeforeGoNext) {
+ return;
+ }
+
+ // When user selects a card transaction to merge, that card transaction becomes the target transaction.
+ // The App may not have the transaction thread report loaded for card transactions, so we need to trigger
+ // OpenReport to ensure the transaction thread report is available for confirmation page
+ if (!targetTransactionThreadReportID && targetTransaction?.reportID) {
+ return openReport(targetTransaction.reportID);
+ }
+ if (targetTransactionThreadReportID && !targetTransactionThreadReport) {
+ return openReport(targetTransactionThreadReportID);
+ }
+ // We need to wait for report to be loaded completely, avoid still optimistic loading
+ if (!targetTransactionThreadReport?.reportID) {
+ return;
+ }
+
+ Navigation.navigate(ROUTES.MERGE_TRANSACTION_CONFIRMATION_PAGE.getRoute(transactionID, Navigation.getActiveRoute()));
+ setIsCheckingDataBeforeGoNext(false);
+ }, [isCheckingDataBeforeGoNext, targetTransactionThreadReportID, targetTransaction?.reportID, targetTransactionThreadReport, transactionID]);
+
+ // Handle selection
+ const handleSelect = (field: MergeFieldKey, value: MergeValue) => {
+ // Clear error if it has
+ setHasErrors((prev) => {
+ const newErrors = {...prev};
+ delete newErrors[field];
+ return newErrors;
+ });
+ setMergeTransactionKey(transactionID, {
+ [field]: value.value,
+ ...(field === 'amount' && {currency: value.currency}),
+ });
+ };
+
+ // Handle continue
+ const handleContinue = () => {
+ if (!mergeTransaction) {
+ return;
+ }
+
+ const newHasErrors: Partial> = {};
+ diffFields.forEach((field) => {
+ if (!isEmptyMergeValue(mergeTransaction[field])) {
+ return;
+ }
+
+ newHasErrors[field] = true;
+ });
+ setHasErrors(newHasErrors);
+
+ if (isEmptyObject(newHasErrors)) {
+ setIsCheckingDataBeforeGoNext(true);
+ }
+ };
+
+ // If this screen has multiple "selection cards" on it and the user skips one or more, show an error above the footer button
+ const shouldShowSubmitError = diffFields.length > 1 && !isEmptyObject(hasErrors);
+
+ if (isLoadingOnyxValue(mergeTransactionMetadata)) {
+ return ;
+ }
+
+ return (
+
+
+ {
+ Navigation.goBack(backTo);
+ }}
+ />
+
+
+ {translate('transactionMerge.detailsPage.pageTitle')}
+
+ {diffFields.map((field) => {
+ const targetValue = getMergeFieldValue(targetTransaction, field);
+ const sourceValue = getMergeFieldValue(sourceTransaction, field);
+
+ const fieldTranslated = translate(getMergeFieldTranslationKey(field) as TranslationPaths);
+
+ const formatValue = (mergeValue: MergeValue) => {
+ const {value, currency} = mergeValue;
+
+ if (isEmptyMergeValue(value)) {
+ return '';
+ }
+
+ if (typeof value === 'boolean') {
+ return value ? translate('common.yes') : translate('common.no');
+ }
+
+ if (field === 'amount') {
+ return convertToDisplayString(Math.abs(Number(value)), currency);
+ }
+
+ return String(value);
+ };
+
+ const selectedValue = {
+ value: mergeTransaction?.[field] ?? '',
+ currency: mergeTransaction?.currency ?? '',
+ };
+
+ const targetMergeValue: MergeValue = {
+ value: targetValue,
+ currency: field === 'amount' ? getCurrency(targetTransaction) : '',
+ };
+
+ const sourceMergeValue: MergeValue = {
+ value: sourceValue,
+ currency: field === 'amount' ? getCurrency(sourceTransaction) : '',
+ };
+
+ return (
+ handleSelect(field, value)}
+ formatValue={formatValue}
+ errorText={hasErrors[field] ? translate('transactionMerge.detailsPage.pleaseSelectError', {field: fieldTranslated}) : undefined}
+ />
+ );
+ })}
+
+
+ {shouldShowSubmitError && (
+
+ )}
+
+
+
+
+ );
+}
+
+DetailsReviewPage.displayName = 'DetailsReviewPage';
+
+export default DetailsReviewPage;
diff --git a/src/pages/TransactionMerge/MergeFieldReview.tsx b/src/pages/TransactionMerge/MergeFieldReview.tsx
new file mode 100644
index 000000000000..a80ccc1bca47
--- /dev/null
+++ b/src/pages/TransactionMerge/MergeFieldReview.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import {View} from 'react-native';
+import FormHelpMessage from '@components/FormHelpMessage';
+import {PressableWithoutFeedback} from '@components/Pressable';
+import RadioButton from '@components/RadioButton';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+import type {MergeValue} from '@libs/MergeTransactionUtils';
+
+type MergeFieldReviewProps = {
+ field: string;
+ values: MergeValue[];
+ selectedValue: MergeValue;
+ onValueSelected: (selected: MergeValue) => void;
+ errorText: string | undefined;
+ formatValue: (mergeValue: MergeValue) => string;
+};
+
+function MergeFieldReview({field, values, selectedValue, onValueSelected, errorText, formatValue}: MergeFieldReviewProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ {field}
+ {values.map((mergeValue: MergeValue) => {
+ const {value, currency} = mergeValue;
+ const displayValue = formatValue(mergeValue);
+ const isSelected = selectedValue.value === value && (!currency || selectedValue.currency === currency);
+
+ return (
+ onValueSelected(mergeValue)}
+ accessibilityLabel={formatValue(mergeValue)}
+ accessible={false}
+ hoverStyle={styles.hoveredComponentBG}
+ style={[styles.flexRow, styles.alignItemsCenter, styles.justifyContentBetween, styles.pv5, styles.ph5]}
+ >
+ {displayValue}
+ onValueSelected(mergeValue)}
+ accessibilityLabel={displayValue}
+ shouldUseNewStyle
+ />
+
+ );
+ })}
+ {!!errorText && (
+
+ )}
+
+ );
+}
+
+MergeFieldReview.displayName = 'MergeFieldReview';
+
+export default MergeFieldReview;
diff --git a/src/pages/TransactionMerge/MergeTransactionItem.tsx b/src/pages/TransactionMerge/MergeTransactionItem.tsx
new file mode 100644
index 000000000000..8716f5fb77f7
--- /dev/null
+++ b/src/pages/TransactionMerge/MergeTransactionItem.tsx
@@ -0,0 +1,76 @@
+import React, {useRef} from 'react';
+import type {View} from 'react-native';
+import {getButtonRole} from '@components/Button/utils';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import {PressableWithFeedback} from '@components/Pressable';
+import type {ListItem, ListItemProps, TransactionListItemType} from '@components/SelectionList/types';
+import TransactionItemRow from '@components/TransactionItemRow';
+import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useSyncFocus from '@hooks/useSyncFocus';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+
+function MergeTransactionItem({item, isFocused, showTooltip, isDisabled, onFocus, shouldSyncFocus, onSelectRow}: ListItemProps) {
+ const styles = useThemeStyles();
+ const transactionItem = item as unknown as TransactionListItemType;
+ const theme = useTheme();
+
+ const animatedHighlightStyle = useAnimatedHighlightStyle({
+ borderRadius: variables.componentBorderRadius,
+ shouldHighlight: item?.shouldAnimateInHighlight ?? false,
+ highlightColor: theme.messageHighlightBG,
+ backgroundColor: theme.highlightBG,
+ });
+ const StyleUtils = useStyleUtils();
+ const pressableRef = useRef(null);
+
+ useSyncFocus(pressableRef, !!isFocused, shouldSyncFocus);
+
+ return (
+
+ {
+ onSelectRow(item);
+ }}
+ disabled={isDisabled && !item.isSelected}
+ accessibilityLabel={item.text ?? ''}
+ role={getButtonRole(true)}
+ isNested
+ onMouseDown={(e) => e.preventDefault()}
+ hoverStyle={[!item.isDisabled && styles.hoveredComponentBG]}
+ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: false}}
+ id={item.keyForList ?? ''}
+ style={[
+ styles.transactionListItemStyle,
+ isFocused && StyleUtils.getItemBackgroundColorStyle(false, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
+ ]}
+ onFocus={onFocus}
+ wrapperStyle={[styles.mb2, styles.mh5, styles.flex1, animatedHighlightStyle, styles.userSelectNone]}
+ >
+ {
+ onSelectRow(item);
+ }}
+ />
+
+
+ );
+}
+
+MergeTransactionItem.displayName = 'MergeTransactionItem';
+
+export default MergeTransactionItem;
diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx
new file mode 100644
index 000000000000..08b83d65ee89
--- /dev/null
+++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx
@@ -0,0 +1,182 @@
+import React, {useCallback, useEffect, useMemo} from 'react';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import EmptyStateComponent from '@components/EmptyStateComponent';
+import {EmptyShelves} from '@components/Icon/Illustrations';
+import RenderHTML from '@components/RenderHTML';
+import SelectionList from '@components/SelectionList';
+import type {ListItem} from '@components/SelectionList/types';
+import MergeExpensesSkeleton from '@components/Skeletons/MergeExpensesSkeleton';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getTransactionsForMerging, getTransactionsForMergingLocally, setMergeTransactionKey, setupMergeTransactionData} from '@libs/actions/MergeTransaction';
+import {
+ fillMissingReceiptSource,
+ getMergeableDataAndConflictFields,
+ getSourceTransactionFromMergeTransaction,
+ getTransactionThreadReportID,
+ selectTargetAndSourceTransactionIDsForMerge,
+ shouldNavigateToReceiptReview,
+} from '@libs/MergeTransactionUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import {getReportName} from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type {MergeTransaction} from '@src/types/onyx';
+import type {Errors} from '@src/types/onyx/OnyxCommon';
+import type Transaction from '@src/types/onyx/Transaction';
+import MergeTransactionItem from './MergeTransactionItem';
+
+type MergeTransactionsListContentProps = {
+ transactionID: string;
+ mergeTransaction: OnyxEntry;
+};
+
+type MergeTransactionListItemType = Transaction & ListItem;
+
+function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTransactionsListContentProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false});
+ const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: false});
+ const {isOffline} = useNetwork();
+ const [targetTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {canBeMissing: false});
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getTransactionThreadReportID(targetTransaction)}`, {canBeMissing: false});
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`, {canBeMissing: false});
+ const eligibleTransactions = mergeTransaction?.eligibleTransactions;
+ const currentUserLogin = session?.email;
+
+ useEffect(() => {
+ // If the eligible transactions are already loaded, don't fetch them again
+ if (Array.isArray(mergeTransaction?.eligibleTransactions)) {
+ return;
+ }
+
+ if (isOffline) {
+ getTransactionsForMergingLocally(transactionID, transactions, policy, report, currentUserLogin);
+ } else {
+ getTransactionsForMerging(transactionID);
+ }
+ }, [transactionID, transactions, isOffline, mergeTransaction, policy, report, currentUserLogin]);
+
+ const sections = useMemo(() => {
+ return [
+ {
+ data: (eligibleTransactions ?? []).map((eligibleTransaction) => ({
+ ...fillMissingReceiptSource(eligibleTransaction),
+ keyForList: eligibleTransaction.transactionID,
+ isSelected: eligibleTransaction.transactionID === mergeTransaction?.sourceTransactionID,
+ errors: eligibleTransaction.errors as Errors | undefined,
+ })),
+ shouldShow: true,
+ },
+ ];
+ }, [eligibleTransactions, mergeTransaction]);
+
+ const handleSelectRow = useCallback(
+ (item: MergeTransactionListItemType) => {
+ // Clear the merge transaction data when select a new source transaction to merge
+ setupMergeTransactionData(transactionID, {
+ targetTransactionID: transactionID,
+ sourceTransactionID: item.transactionID,
+ eligibleTransactions: mergeTransaction?.eligibleTransactions,
+ });
+ },
+ [mergeTransaction, transactionID],
+ );
+
+ const headerContent = useMemo(
+ () => (
+
+
+
+
+
+ ),
+ [report, translate, styles.ph5, styles.pb5, styles.textLabel, styles.minHeight5],
+ );
+
+ const subTitleContent = useMemo(() => {
+ return (
+
+
+
+ );
+ }, [translate, styles.textAlignCenter, styles.textSupporting, styles.textNormal]);
+
+ const handleConfirm = useCallback(() => {
+ const sourceTransaction = getSourceTransactionFromMergeTransaction(mergeTransaction);
+
+ if (!sourceTransaction || !targetTransaction) {
+ return;
+ }
+
+ const {targetTransactionID: newTargetTransactionID, sourceTransactionID: newSourceTransactionID} = selectTargetAndSourceTransactionIDsForMerge(targetTransaction, sourceTransaction);
+
+ if (shouldNavigateToReceiptReview([targetTransaction, sourceTransaction])) {
+ setMergeTransactionKey(transactionID, {
+ targetTransactionID: newTargetTransactionID,
+ sourceTransactionID: newSourceTransactionID,
+ });
+ Navigation.navigate(ROUTES.MERGE_TRANSACTION_RECEIPT_PAGE.getRoute(transactionID, Navigation.getActiveRoute()));
+ } else {
+ const mergedReceipt = targetTransaction?.receipt?.receiptID ? targetTransaction.receipt : sourceTransaction?.receipt;
+ setMergeTransactionKey(transactionID, {
+ targetTransactionID: newTargetTransactionID,
+ sourceTransactionID: newSourceTransactionID,
+ receipt: mergedReceipt,
+ });
+
+ const {conflictFields, mergeableData} = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction);
+ if (!conflictFields.length) {
+ // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page
+ setMergeTransactionKey(transactionID, mergeableData);
+ Navigation.navigate(ROUTES.MERGE_TRANSACTION_CONFIRMATION_PAGE.getRoute(transactionID, Navigation.getActiveRoute()));
+ return;
+ }
+ Navigation.navigate(ROUTES.MERGE_TRANSACTION_DETAILS_PAGE.getRoute(transactionID, Navigation.getActiveRoute()));
+ }
+ }, [mergeTransaction, transactionID, targetTransaction]);
+
+ if (eligibleTransactions?.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ sections={sections}
+ shouldShowTextInput={false}
+ ListItem={MergeTransactionItem}
+ confirmButtonStyles={[styles.justifyContentCenter]}
+ showConfirmButton
+ confirmButtonText={translate('common.continue')}
+ isConfirmButtonDisabled={!mergeTransaction?.sourceTransactionID}
+ onSelectRow={handleSelectRow}
+ showLoadingPlaceholder
+ LoadingPlaceholderComponent={MergeExpensesSkeleton}
+ fixedNumItemsForLoader={3}
+ headerContent={headerContent}
+ onConfirm={handleConfirm}
+ />
+ );
+}
+
+MergeTransactionsListContent.displayName = 'MergeTransactionsListContent';
+
+export default MergeTransactionsListContent;
diff --git a/src/pages/TransactionMerge/MergeTransactionsListPage.tsx b/src/pages/TransactionMerge/MergeTransactionsListPage.tsx
new file mode 100644
index 000000000000..c2ec18d59409
--- /dev/null
+++ b/src/pages/TransactionMerge/MergeTransactionsListPage.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+import MergeTransactionsListContent from './MergeTransactionsListContent';
+
+type MergeTransactionsListPageProps = PlatformStackScreenProps;
+
+function MergeTransactionsListPage({route}: MergeTransactionsListPageProps) {
+ const {translate} = useLocalize();
+ const {transactionID, backTo} = route.params;
+
+ const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {canBeMissing: false});
+
+ if (isLoadingOnyxValue(mergeTransactionMetadata)) {
+ return ;
+ }
+
+ return (
+
+
+ {
+ Navigation.goBack(backTo);
+ }}
+ />
+
+
+
+ );
+}
+
+MergeTransactionsListPage.displayName = 'MergeTransactionsListPage';
+
+export default MergeTransactionsListPage;
diff --git a/src/pages/TransactionMerge/ReceiptReviewPage.tsx b/src/pages/TransactionMerge/ReceiptReviewPage.tsx
new file mode 100644
index 000000000000..a74704f04995
--- /dev/null
+++ b/src/pages/TransactionMerge/ReceiptReviewPage.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import {View} from 'react-native';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import Button from '@components/Button';
+import FixedFooter from '@components/FixedFooter';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {setMergeTransactionKey} from '@libs/actions/MergeTransaction';
+import {getMergeableDataAndConflictFields, 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';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {Transaction} from '@src/types/onyx';
+import type {Receipt} from '@src/types/onyx/Transaction';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+import TransactionMergeReceipts from './TransactionMergeReceipts';
+
+type ReceiptReviewPageProps = PlatformStackScreenProps;
+
+function ReceiptReviewPage({route}: ReceiptReviewPageProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {transactionID, backTo} = route.params;
+
+ const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {canBeMissing: false});
+ const [targetTransaction = getTargetTransactionFromMergeTransaction(mergeTransaction)] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.targetTransactionID}`, {
+ canBeMissing: true,
+ });
+ const [sourceTransaction = getSourceTransactionFromMergeTransaction(mergeTransaction)] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${mergeTransaction?.sourceTransactionID}`, {
+ canBeMissing: true,
+ });
+
+ const transactions = [targetTransaction, sourceTransaction].filter((transaction): transaction is Transaction => !!transaction);
+
+ const handleSelect = (receipt: Receipt | undefined) => {
+ setMergeTransactionKey(transactionID, {receipt});
+ };
+
+ const handleContinue = () => {
+ if (!mergeTransaction?.receipt) {
+ return;
+ }
+
+ const {conflictFields, mergeableData} = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction);
+ if (!conflictFields.length) {
+ // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page
+ setMergeTransactionKey(transactionID, mergeableData);
+ Navigation.navigate(ROUTES.MERGE_TRANSACTION_CONFIRMATION_PAGE.getRoute(transactionID, Navigation.getActiveRoute()));
+ return;
+ }
+ Navigation.navigate(ROUTES.MERGE_TRANSACTION_DETAILS_PAGE.getRoute(transactionID, Navigation.getActiveRoute()));
+ };
+
+ if (isLoadingOnyxValue(mergeTransactionMetadata)) {
+ return ;
+ }
+
+ return (
+
+
+ {
+ Navigation.goBack(backTo);
+ }}
+ />
+
+
+ {translate('transactionMerge.receiptPage.pageTitle')}
+
+
+
+
+
+
+
+
+ );
+}
+
+ReceiptReviewPage.displayName = 'ReceiptReviewPage';
+
+export default ReceiptReviewPage;
diff --git a/src/pages/TransactionMerge/TransactionMergeReceipts.tsx b/src/pages/TransactionMerge/TransactionMergeReceipts.tsx
new file mode 100644
index 000000000000..ebed39f67973
--- /dev/null
+++ b/src/pages/TransactionMerge/TransactionMergeReceipts.tsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import {Zoom} from '@components/Icon/Expensicons';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import RadioButton from '@components/RadioButton';
+import ReportActionItemImage from '@components/ReportActionItem/ReportActionItemImage';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getTransactionThreadReportID} from '@libs/MergeTransactionUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type {Transaction} from '@src/types/onyx';
+import type {Receipt} from '@src/types/onyx/Transaction';
+
+type TransactionMergeReceiptsProps = {
+ transactions: Transaction[];
+ selectedReceiptID: number | undefined;
+ onSelect: (receipt: Receipt | undefined) => void;
+};
+
+function TransactionMergeReceipts({transactions, selectedReceiptID, onSelect}: TransactionMergeReceiptsProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+ {transactions.map((transaction, index) => {
+ const receiptURIs = getThumbnailAndImageURIs(transaction);
+ const isSelected = selectedReceiptID === transaction.receipt?.receiptID;
+ return (
+
+ onSelect(transaction.receipt)}
+ wrapperStyle={styles.w100}
+ hoverStyle={styles.hoveredComponentBG}
+ style={[styles.alignItemsCenter, styles.justifyContentCenter, styles.mergeTransactionReceiptThumbnail]}
+ accessibilityRole={CONST.ROLE.RADIO}
+ accessibilityLabel={`${translate('transactionMerge.receiptPage.pageTitle')} ${transaction.transactionID}`}
+ >
+
+
+ {translate('common.receipt')} {index + 1}
+
+ onSelect(transaction.receipt)}
+ accessibilityLabel={`${translate('transactionMerge.receiptPage.pageTitle')} ${transaction.transactionID}`}
+ shouldUseNewStyle
+ />
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+TransactionMergeReceipts.displayName = 'TransactionMergeReceipts';
+export default TransactionMergeReceipts;
diff --git a/src/pages/TransactionReceiptPage.tsx b/src/pages/TransactionReceiptPage.tsx
index d43111bbbbf5..f3d05288ff4f 100644
--- a/src/pages/TransactionReceiptPage.tsx
+++ b/src/pages/TransactionReceiptPage.tsx
@@ -27,6 +27,13 @@ function TransactionReceipt({route}: TransactionReceiptProps) {
const [transactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: true});
const [reportMetadata = CONST.DEFAULT_REPORT_METADATA] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, {canBeMissing: true});
+ // If we have a merge transaction, we need to use the receipt from the merge transaction
+ const mergeTransactionID = route.params.mergeTransactionID;
+ const [mergeTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, {canBeMissing: true});
+ if (mergeTransactionID && mergeTransaction && transactionMain) {
+ transactionMain.receipt = mergeTransaction.receipt;
+ }
+
const isDraftTransaction = !!action;
const transaction = isDraftTransaction ? transactionDraft : transactionMain;
const receiptURIs = getThumbnailAndImageURIs(transaction);
diff --git a/src/stories/TransactionItemRow.stories.tsx b/src/stories/TransactionItemRow.stories.tsx
index f21d81fcee20..4a768b9a2e2b 100644
--- a/src/stories/TransactionItemRow.stories.tsx
+++ b/src/stories/TransactionItemRow.stories.tsx
@@ -30,7 +30,7 @@ type TransactionItemRowProps = {
shouldUseNarrowLayout: boolean;
isSelected: boolean;
shouldShowTooltip: boolean;
- shouldShowCheckbox: boolean;
+ shouldShowCheckbox?: boolean;
columns?: Array>;
};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 40c481e9784c..bef845e43690 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3175,6 +3175,17 @@ const styles = (theme: ThemeColors) =>
alignItems: 'center',
},
+ newRadioButtonContainer: {
+ backgroundColor: theme.componentBG,
+ borderRadius: variables.componentBorderRadiusRounded,
+ height: variables.iconSizeNormal,
+ width: variables.iconSizeNormal,
+ borderColor: theme.border,
+ borderWidth: 2,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+
toggleSwitchLockIcon: {
width: variables.iconSizeExtraSmall,
height: variables.iconSizeExtraSmall,
@@ -4756,6 +4767,16 @@ const styles = (theme: ThemeColors) =>
height: 'auto',
},
+ mergeTransactionReceiptImage: {
+ overflow: 'hidden',
+ borderWidth: 1,
+ borderColor: theme.border,
+ borderRadius: variables.componentBorderRadiusNormal,
+ aspectRatio: 16 / 9,
+ height: 180,
+ maxWidth: '100%',
+ },
+
pdfErrorPlaceholder: {
overflow: 'hidden',
borderWidth: 2,
@@ -4789,6 +4810,12 @@ const styles = (theme: ThemeColors) =>
borderRadius: '50%',
},
+ mergeTransactionReceiptThumbnail: {
+ backgroundColor: theme.highlightBG,
+ borderRadius: variables.componentBorderRadiusLarge,
+ padding: 20,
+ },
+
mapViewContainer: {
...flex.flex1,
minHeight: 300,
diff --git a/src/types/onyx/MergeTransaction.ts b/src/types/onyx/MergeTransaction.ts
new file mode 100644
index 000000000000..b577195c7008
--- /dev/null
+++ b/src/types/onyx/MergeTransaction.ts
@@ -0,0 +1,46 @@
+import type Transaction from './Transaction';
+import type {Comment, Receipt} from './Transaction';
+
+/** Model of transaction merge data */
+type MergeTransaction = {
+ /** Transaction ID we're keeping */
+ targetTransactionID: string;
+
+ /** ID of the transaction we're merging into that will be deleted */
+ sourceTransactionID: string;
+
+ /** API will set this to contain eligible transactions */
+ eligibleTransactions: Transaction[];
+
+ /** Amount which user want to keep */
+ amount: number;
+
+ /** The currency the user wants to */
+ currency: string;
+
+ /** Merchant which user want to keep */
+ merchant: string;
+
+ /** Category which user want to keep */
+ category: string;
+
+ /** Tag which user want to keep */
+ tag: string;
+
+ /** Description which user want to keep */
+ description: string;
+
+ /** NVPs of the transaction that we want to keep */
+ comment: Comment;
+
+ /** Whether the transaction is reimbursable */
+ reimbursable: boolean;
+
+ /** Whether the transaction is billable */
+ billable: boolean;
+
+ /** The receipt object associated with the transaction */
+ receipt?: Receipt;
+};
+
+export default MergeTransaction;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index 4390afa64f46..a0185b731e3a 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -46,6 +46,7 @@ import type LockAccountDetails from './LockAccountDetails';
import type {LoginList} from './Login';
import type Login from './Login';
import type MapboxAccessToken from './MapboxAccessToken';
+import type MergeTransaction from './MergeTransaction';
import type Modal from './Modal';
import type Network from './Network';
import type NewGroupChatDraft from './NewGroupChatDraft';
@@ -211,6 +212,7 @@ export type {
TaxRates,
TaxRatesWithDefault,
Transaction,
+ MergeTransaction,
TransactionViolation,
TransactionViolations,
TravelSettings,
diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts
new file mode 100644
index 000000000000..09f158d05a41
--- /dev/null
+++ b/tests/actions/MergeTransactionTest.ts
@@ -0,0 +1,446 @@
+import Onyx from 'react-native-onyx';
+import {mergeTransactionRequest, setMergeTransactionKey, setupMergeTransactionData} from '@libs/actions/MergeTransaction';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {MergeTransaction as MergeTransactionType, Report, Transaction, TransactionViolation} from '@src/types/onyx';
+import createRandomMergeTransaction from '../utils/collections/mergeTransaction';
+import {createExpenseReport} from '../utils/collections/reports';
+import createRandomTransaction from '../utils/collections/transaction';
+import * as TestHelper from '../utils/TestHelper';
+import type {MockFetch} from '../utils/TestHelper';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+// Helper function to create mock violations
+function createMockViolations(): TransactionViolation[] {
+ return [
+ {
+ type: CONST.VIOLATION_TYPES.VIOLATION,
+ name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION,
+ showInReview: true,
+ },
+ {
+ type: CONST.VIOLATION_TYPES.VIOLATION,
+ name: CONST.VIOLATIONS.MISSING_CATEGORY,
+ showInReview: true,
+ },
+ ];
+}
+
+describe('mergeTransactionRequest', () => {
+ let mockFetch: MockFetch;
+
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+
+ beforeEach(() => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+ mockFetch = fetch as MockFetch;
+ return Onyx.clear().then(waitForBatchedUpdates);
+ });
+
+ it('should update target transaction with merged values optimistically', async () => {
+ // Given:
+ // - Target transaction with original merchant and category values
+ // - Source transaction that will be deleted after merge (only transaction in its report)
+ // - Merge transaction containing the final values to keep
+ const targetTransaction = {
+ ...createRandomTransaction(1),
+ transactionID: 'target123',
+ merchant: 'Original Merchant',
+ category: 'Original Category',
+ reportID: 'target-report-456',
+ };
+ const sourceExpenseReport = {
+ ...createExpenseReport(1),
+ reportID: 'source-report-123',
+ };
+ const sourceTransaction = {
+ ...createRandomTransaction(2),
+ transactionID: 'source456',
+ reportID: sourceExpenseReport.reportID,
+ };
+ const mergeTransaction = {
+ ...createRandomMergeTransaction(1),
+ targetTransactionID: 'target123',
+ sourceTransactionID: 'source456',
+ merchant: 'Updated Merchant',
+ category: 'Updated Category',
+ tag: 'Updated Tag',
+ };
+ const mergeTransactionID = 'merge789';
+
+ // Set up initial state in Onyx
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, targetTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`, sourceTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${sourceExpenseReport.reportID}`, sourceExpenseReport);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, mergeTransaction);
+
+ mockFetch?.pause?.();
+
+ // When: The merge transaction request is initiated
+ // This should immediately update the UI with optimistic values
+ mergeTransactionRequest(mergeTransactionID, mergeTransaction, targetTransaction, sourceTransaction);
+
+ await mockFetch?.resume?.();
+ await waitForBatchedUpdates();
+
+ // Then: Verify that optimistic updates are applied correctly
+ const updatedTargetTransaction = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`,
+ callback: (transaction) => {
+ Onyx.disconnect(connection);
+ resolve(transaction ?? null);
+ },
+ });
+ });
+
+ const updatedSourceTransaction = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`,
+ callback: (transaction) => {
+ Onyx.disconnect(connection);
+ resolve(transaction ?? null);
+ },
+ });
+ });
+
+ const updatedSourceReport = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${sourceExpenseReport.reportID}`,
+ callback: (report) => {
+ Onyx.disconnect(connection);
+ resolve(report ?? null);
+ },
+ });
+ });
+
+ const updatedMergeTransaction = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`,
+ callback: (transaction) => {
+ Onyx.disconnect(connection);
+ resolve(transaction ?? null);
+ },
+ });
+ });
+
+ // Verify target transaction is updated with merged values
+ expect(updatedTargetTransaction?.merchant).toBe(mergeTransaction.merchant);
+ expect(updatedTargetTransaction?.category).toBe(mergeTransaction.category);
+ expect(updatedTargetTransaction?.tag).toBe(mergeTransaction.tag);
+ expect(updatedTargetTransaction?.comment?.comment).toBe(mergeTransaction.description);
+
+ // Verify source transaction is deleted
+ expect(updatedSourceTransaction).toBeNull();
+
+ // Verify source report is deleted (since it only had one transaction)
+ expect(updatedSourceReport).toBeNull();
+
+ // Verify merge transaction is cleaned up
+ expect(updatedMergeTransaction).toBeNull();
+ });
+
+ it('should restore original state when API returns error', async () => {
+ // Given:
+ // - Target transaction with original data that should be restored on failure
+ // - Source transaction that should be restored if merge fails (only transaction in its report)
+ // - Source report that should be restored if merge fails
+ // - Transaction violations are set up in Onyx for both transactions
+ const sourceReport = {
+ ...createExpenseReport(1),
+ reportID: 'source-report-123',
+ };
+ const targetTransaction = {
+ ...createRandomTransaction(1),
+ transactionID: 'target123',
+ merchant: 'Original Merchant',
+ category: 'Original Category',
+ reportID: 'target-report-456',
+ };
+ const sourceTransaction = {
+ ...createRandomTransaction(2),
+ transactionID: 'source456',
+ merchant: 'Source Merchant',
+ reportID: sourceReport.reportID,
+ };
+ const mergeTransaction = {
+ ...createRandomMergeTransaction(1),
+ targetTransactionID: 'target123',
+ sourceTransactionID: 'source456',
+ merchant: 'Updated Merchant',
+ category: 'Updated Category',
+ };
+ const mergeTransactionID = 'merge789';
+
+ const mockViolations = createMockViolations();
+
+ mockFetch?.pause?.();
+
+ // Set up initial state in Onyx
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, targetTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`, sourceTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${sourceReport.reportID}`, sourceReport);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, mergeTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${targetTransaction.transactionID}`, mockViolations);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${sourceTransaction.transactionID}`, mockViolations);
+ await waitForBatchedUpdates();
+
+ // When: The merge request is executed but the API will return an error
+ mockFetch?.fail?.();
+
+ mergeTransactionRequest(mergeTransactionID, mergeTransaction, targetTransaction, sourceTransaction);
+
+ await waitForBatchedUpdates();
+
+ // Resume fetch to process the failed API response
+ await mockFetch?.resume?.();
+ await waitForBatchedUpdates();
+
+ // Then: Verify that original state is restored after API failure
+ const restoredTargetTransaction = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`,
+ callback: (transaction) => {
+ Onyx.disconnect(connection);
+ resolve(transaction ?? null);
+ },
+ });
+ });
+
+ const restoredSourceTransaction = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`,
+ callback: (transaction) => {
+ Onyx.disconnect(connection);
+ resolve(transaction ?? null);
+ },
+ });
+ });
+
+ const restoredSourceReport = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${sourceReport.reportID}`,
+ callback: (report) => {
+ Onyx.disconnect(connection);
+ resolve(report ?? null);
+ },
+ });
+ });
+
+ // Verify target transaction is restored to original state
+ expect(restoredTargetTransaction?.merchant).toBe('Original Merchant');
+ expect(restoredTargetTransaction?.category).toBe('Original Category');
+
+ // Verify source transaction is restored (not deleted)
+ expect(restoredSourceTransaction?.transactionID).toBe('source456');
+ expect(restoredSourceTransaction?.merchant).toBe('Source Merchant');
+
+ // Verify source report is restored (not deleted)
+ expect(restoredSourceReport?.reportID).toBe(sourceReport.reportID);
+ expect(restoredSourceReport).toEqual(sourceReport);
+ });
+
+ it('should handle transaction violations correctly during merge', async () => {
+ // Given:
+ // - Both transactions have DUPLICATED_TRANSACTION and MISSING_CATEGORY violations set in Onyx
+ // - When merged, duplicate violations should be removed optimistically
+ // - On success, only non-duplicate violations should remain
+ const targetTransaction = {
+ ...createRandomTransaction(1),
+ transactionID: 'target123',
+ };
+ const sourceTransaction = {
+ ...createRandomTransaction(2),
+ transactionID: 'source456',
+ };
+ const mergeTransaction = {
+ ...createRandomMergeTransaction(1),
+ targetTransactionID: 'target123',
+ sourceTransactionID: 'source456',
+ };
+ const mergeTransactionID = 'merge789';
+
+ const mockViolations = createMockViolations();
+
+ // Set up initial state with violations in Onyx
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, targetTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`, sourceTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, mergeTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${targetTransaction.transactionID}`, mockViolations);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${sourceTransaction.transactionID}`, mockViolations);
+
+ mockFetch?.pause?.();
+
+ // When: The merge request is executed, which should handle violation updates
+ // - Optimistically remove DUPLICATED_TRANSACTION violations since transactions are being merged
+ // - Keep other violations like MISSING_CATEGORY intact
+ mergeTransactionRequest(mergeTransactionID, mergeTransaction, targetTransaction, sourceTransaction);
+
+ await mockFetch?.resume?.();
+ await waitForBatchedUpdates();
+
+ // Then: Verify that violations are updated correctly during optimistic phase
+ // - DUPLICATED_TRANSACTION violations should be filtered out
+ // - Other violations should remain unchanged
+ const updatedTargetViolations = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${targetTransaction.transactionID}`,
+ callback: (violations) => {
+ Onyx.disconnect(connection);
+ resolve(violations ?? null);
+ },
+ });
+ });
+
+ // Should only contain non-duplicate violations
+ expect(updatedTargetViolations).toEqual([
+ expect.objectContaining({
+ name: CONST.VIOLATIONS.MISSING_CATEGORY,
+ }),
+ ]);
+
+ // Should not contain duplicate transaction violations
+ expect(updatedTargetViolations?.some((v) => v.name === CONST.VIOLATIONS.DUPLICATED_TRANSACTION)).toBeFalsy();
+ });
+
+ describe('Report deletion logic', () => {
+ it('should NOT delete source report optimistically when it contains multiple transactions', async () => {
+ // Given: A source transaction that is one of multiple transactions in its report
+ const sourceReport = {
+ ...createExpenseReport(1),
+ reportID: 'source-report-123',
+ };
+ const targetTransaction = {
+ ...createRandomTransaction(1),
+ transactionID: 'target123',
+ reportID: 'target-report-456',
+ };
+ const sourceTransaction = {
+ ...createRandomTransaction(2),
+ transactionID: 'source456',
+ reportID: sourceReport.reportID,
+ };
+ const otherTransaction = {
+ ...createRandomTransaction(3),
+ transactionID: 'other789',
+ reportID: sourceReport.reportID,
+ };
+ const mergeTransaction = {
+ ...createRandomMergeTransaction(1),
+ targetTransactionID: 'target123',
+ sourceTransactionID: 'source456',
+ };
+ const mergeTransactionID = 'merge789';
+
+ // Set up initial state
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, targetTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${sourceTransaction.transactionID}`, sourceTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${otherTransaction.transactionID}`, otherTransaction);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${sourceReport.reportID}`, sourceReport);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, mergeTransaction);
+
+ mockFetch?.pause?.();
+
+ // When: The merge request is executed
+ mergeTransactionRequest(mergeTransactionID, mergeTransaction, targetTransaction, sourceTransaction);
+
+ await mockFetch?.resume?.();
+ await waitForBatchedUpdates();
+
+ // Then: The source report should NOT be deleted (should still exist)
+ const updatedSourceReport = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.REPORT}${sourceReport.reportID}`,
+ callback: (report) => {
+ Onyx.disconnect(connection);
+ resolve(report ?? null);
+ },
+ });
+ });
+
+ expect(updatedSourceReport).toEqual(sourceReport);
+ expect(updatedSourceReport?.reportID).toBe(sourceReport.reportID);
+ });
+ });
+});
+
+describe('setupMergeTransactionData', () => {
+ beforeEach(() => {
+ return Onyx.clear().then(waitForBatchedUpdates);
+ });
+
+ it('should set merge transaction data with initial values', async () => {
+ // Given a transaction ID
+ const transactionID = 'test-transaction-123';
+
+ // When we setup merge transaction data
+ setupMergeTransactionData(transactionID, {targetTransactionID: transactionID});
+ await waitForBatchedUpdates();
+
+ // Then merge transaction should be created with the target transaction ID
+ const mergeTransaction = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`,
+ callback: (transaction) => {
+ Onyx.disconnect(connection);
+ resolve(transaction ?? null);
+ },
+ });
+ });
+
+ expect(mergeTransaction).toEqual({
+ targetTransactionID: transactionID,
+ });
+ });
+});
+
+describe('setMergeTransactionKey', () => {
+ beforeEach(() => {
+ return Onyx.clear().then(waitForBatchedUpdates);
+ });
+
+ it('should merge values into existing merge transaction data', async () => {
+ // Given an existing merge transaction
+ const transactionID = 'test-transaction-789';
+ const existingMergeTransaction = {
+ targetTransactionID: transactionID,
+ merchant: 'Original Merchant',
+ amount: 1000,
+ };
+
+ await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, existingMergeTransaction);
+
+ // When we set new merge transaction values
+ const newValues = {
+ merchant: 'Updated Merchant',
+ category: 'New Category',
+ description: 'New Description',
+ };
+
+ setMergeTransactionKey(transactionID, newValues);
+ await waitForBatchedUpdates();
+
+ // Then it should merge the new values with existing data
+ const mergeTransaction = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`,
+ callback: (transaction) => {
+ Onyx.disconnect(connection);
+ resolve(transaction ?? null);
+ },
+ });
+ });
+
+ expect(mergeTransaction).toEqual({
+ targetTransactionID: transactionID,
+ merchant: 'Updated Merchant', // Updated
+ amount: 1000, // Preserved
+ category: 'New Category', // Added
+ description: 'New Description', // Added
+ });
+ });
+});
diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts
new file mode 100644
index 000000000000..d7e363b2cde7
--- /dev/null
+++ b/tests/unit/MergeTransactionUtilsTest.ts
@@ -0,0 +1,478 @@
+import {
+ buildMergedTransactionData,
+ getMergeableDataAndConflictFields,
+ getMergeFieldTranslationKey,
+ getMergeFieldValue,
+ getSourceTransactionFromMergeTransaction,
+ isEmptyMergeValue,
+ selectTargetAndSourceTransactionIDsForMerge,
+ shouldNavigateToReceiptReview,
+} from '@libs/MergeTransactionUtils';
+import CONST from '@src/CONST';
+import createRandomMergeTransaction from '../utils/collections/mergeTransaction';
+import createRandomTransaction from '../utils/collections/transaction';
+
+describe('MergeTransactionUtils', () => {
+ describe('getSourceTransactionFromMergeTransaction', () => {
+ it('should return undefined when mergeTransaction is undefined', () => {
+ // Given a null merge transaction
+ const mergeTransaction = undefined;
+
+ // When we try to get the source transaction
+ const result = getSourceTransactionFromMergeTransaction(mergeTransaction);
+
+ // Then it should return undefined because the merge transaction is undefined
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined when sourceTransactionID is not found in eligibleTransactions', () => {
+ // Given a merge transaction with a sourceTransactionID that doesn't match any eligible transactions
+ const transaction1 = createRandomTransaction(0);
+ const transaction2 = createRandomTransaction(1);
+ const mergeTransaction = {
+ ...createRandomMergeTransaction(0),
+ sourceTransactionID: 'nonexistent',
+ eligibleTransactions: [transaction1, transaction2],
+ };
+
+ // When we try to get the source transaction
+ const result = getSourceTransactionFromMergeTransaction(mergeTransaction);
+
+ // Then it should return undefined because the source transaction ID doesn't match any eligible transaction
+ expect(result).toBeUndefined();
+ });
+
+ it('should return the correct transaction when sourceTransactionID matches an eligible transaction', () => {
+ // Given a merge transaction with a sourceTransactionID that matches one of the eligible transactions
+ const sourceTransaction = {...createRandomTransaction(0), receipt: undefined};
+ const otherTransaction = createRandomTransaction(1);
+ sourceTransaction.transactionID = 'source123';
+
+ const mergeTransaction = {
+ ...createRandomMergeTransaction(0),
+ sourceTransactionID: 'source123',
+ eligibleTransactions: [sourceTransaction, otherTransaction],
+ };
+
+ // When we try to get the source transaction
+ const result = getSourceTransactionFromMergeTransaction(mergeTransaction);
+
+ // Then it should return the matching transaction from the eligible transactions
+ expect(result).toBe(sourceTransaction);
+ expect(result?.transactionID).toBe('source123');
+ });
+ });
+
+ describe('shouldNavigateToReceiptReview', () => {
+ it('should return false when any transaction has no receipt', () => {
+ // Given transactions where one has no receipt
+ const transaction1 = createRandomTransaction(0);
+ const transaction2 = createRandomTransaction(1);
+ transaction1.receipt = {receiptID: 123};
+ transaction2.receipt = undefined;
+ const transactions = [transaction1, transaction2];
+
+ // When we check if should navigate to receipt review
+ const result = shouldNavigateToReceiptReview(transactions);
+
+ // Then it should return false because not all transactions have receipts
+ expect(result).toBe(false);
+ });
+
+ it('should return true when all transactions have receipts with receiptIDs', () => {
+ // Given transactions where all have receipts with receiptIDs
+ const transaction1 = createRandomTransaction(0);
+ const transaction2 = createRandomTransaction(1);
+ transaction1.receipt = {receiptID: 123};
+ transaction2.receipt = {receiptID: 456};
+ const transactions = [transaction1, transaction2];
+
+ // When we check if should navigate to receipt review
+ const result = shouldNavigateToReceiptReview(transactions);
+
+ // Then it should return true because all transactions have valid receipts
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('getMergeFieldValue', () => {
+ it('should return empty string when transaction is undefined', () => {
+ // Given an undefined transaction
+ const transaction = undefined;
+
+ // When we try to get a merge field value
+ const result = getMergeFieldValue(transaction, 'merchant');
+
+ // Then it should return an empty string because the transaction is undefined
+ expect(result).toBe('');
+ });
+
+ it('should return merchant value from transaction', () => {
+ // Given a transaction with a merchant value
+ const transaction = createRandomTransaction(0);
+ transaction.merchant = 'Test Merchant';
+ transaction.modifiedMerchant = 'Test Merchant';
+
+ // When we get the merchant field value
+ const result = getMergeFieldValue(transaction, 'merchant');
+
+ // Then it should return the merchant value from the transaction
+ expect(result).toBe('Test Merchant');
+ });
+
+ it('should return category value from transaction', () => {
+ // Given a transaction with a category value
+ const transaction = createRandomTransaction(0);
+ transaction.category = 'Food';
+
+ // When we get the category field value
+ const result = getMergeFieldValue(transaction, 'category');
+
+ // Then it should return the category value from the transaction
+ expect(result).toBe('Food');
+ });
+
+ it('should return currency value from transaction', () => {
+ // Given a transaction with a currency value
+ const transaction = createRandomTransaction(0);
+ transaction.currency = CONST.CURRENCY.EUR;
+
+ // When we get the currency field value
+ const result = getMergeFieldValue(transaction, 'currency');
+
+ // Then it should return the currency value from the transaction
+ expect(result).toBe(CONST.CURRENCY.EUR);
+ });
+
+ it('should handle amount field for unreported expense correctly', () => {
+ // Given a transaction that is an unreported expense (no reportID or unreported reportID)
+ const transaction = createRandomTransaction(0);
+ transaction.amount = 1000;
+ transaction.reportID = CONST.REPORT.UNREPORTED_REPORT_ID;
+
+ // When we get the amount field value
+ const result = getMergeFieldValue(transaction, 'amount');
+
+ // Then it should return the amount as positive because it's an unreported expense
+ expect(result).toBe(1000);
+ });
+
+ it('should handle amount field for reported expense correctly', () => {
+ // Given a transaction that is part of a report
+ const transaction = createRandomTransaction(0);
+ transaction.amount = 1000;
+ transaction.reportID = 'report123';
+
+ // When we get the amount field value
+ const result = getMergeFieldValue(transaction, 'amount');
+
+ // Then it should return the amount as negative because it's a reported expense
+ expect(result).toBe(-1000);
+ });
+ });
+
+ describe('getMergeFieldTranslationKey', () => {
+ it('should return correct translation key for amount field', () => {
+ // When we get the translation key for amount field
+ const result = getMergeFieldTranslationKey('amount');
+
+ // Then it should return the correct translation key for amount
+ expect(result).toBe('iou.amount');
+ });
+
+ it('should return correct translation key for merchant field', () => {
+ // When we get the translation key for merchant field
+ const result = getMergeFieldTranslationKey('merchant');
+
+ // Then it should return the correct translation key for merchant
+ expect(result).toBe('common.merchant');
+ });
+
+ it('should return correct translation key for category field', () => {
+ // When we get the translation key for category field
+ const result = getMergeFieldTranslationKey('category');
+
+ // Then it should return the correct translation key for category
+ expect(result).toBe('common.category');
+ });
+
+ it('should return correct translation key for description field', () => {
+ // When we get the translation key for description field
+ const result = getMergeFieldTranslationKey('description');
+
+ // Then it should return the correct translation key for description
+ expect(result).toBe('common.description');
+ });
+
+ it('should return correct translation key for reimbursable field', () => {
+ // When we get the translation key for reimbursable field
+ const result = getMergeFieldTranslationKey('reimbursable');
+
+ // Then it should return the correct translation key for reimbursable
+ expect(result).toBe('common.reimbursable');
+ });
+
+ it('should return correct translation key for billable field', () => {
+ // When we get the translation key for billable field
+ const result = getMergeFieldTranslationKey('billable');
+
+ // Then it should return the correct translation key for billable
+ expect(result).toBe('common.billable');
+ });
+ });
+
+ describe('isEmptyMergeValue', () => {
+ it('should return true for null value', () => {
+ // Given a null value
+ const value = null;
+
+ // When we check if it's empty
+ const result = isEmptyMergeValue(value);
+
+ // Then it should return true because null is considered empty
+ expect(result).toBe(true);
+ });
+
+ it('should return true for undefined value', () => {
+ // Given an undefined value
+ const value = undefined;
+
+ // When we check if it's empty
+ const result = isEmptyMergeValue(value);
+
+ // Then it should return true because undefined is considered empty
+ expect(result).toBe(true);
+ });
+
+ it('should return true for empty string', () => {
+ // Given an empty string
+ const value = '';
+
+ // When we check if it's empty
+ const result = isEmptyMergeValue(value);
+
+ // Then it should return true because empty string is considered empty
+ expect(result).toBe(true);
+ });
+
+ it('should return false for false boolean value', () => {
+ // Given a false boolean value
+ const value = false;
+
+ // When we check if it's empty
+ const result = isEmptyMergeValue(value);
+
+ // Then it should return false because false is a valid value, not empty
+ expect(result).toBe(false);
+ });
+
+ it('should return false for zero number', () => {
+ // Given a zero number value
+ const value = 0;
+
+ // When we check if it's empty
+ const result = isEmptyMergeValue(value);
+
+ // Then it should return false because zero is a valid number, not empty
+ expect(result).toBe(false);
+ });
+
+ it('should return false for non-empty string', () => {
+ // Given a non-empty string
+ const value = 'test';
+
+ // When we check if it's empty
+ const result = isEmptyMergeValue(value);
+
+ // Then it should return false because the string has content
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('getMergeableDataAndConflictFields', () => {
+ it('should merge matching values and identify conflicts for different ones', () => {
+ // When target and source have some same, and some different values
+ const targetTransaction = {
+ ...createRandomTransaction(0),
+ amount: 1000,
+ currency: CONST.CURRENCY.USD,
+ merchant: 'Same Merchant',
+ modifiedMerchant: 'Same Merchant',
+ category: 'Food',
+ tag: '', // Empty
+ comment: {comment: 'Different description 1'},
+ reimbursable: true,
+ billable: false,
+ };
+ const sourceTransaction = {
+ ...createRandomTransaction(1),
+ amount: 1000, // Same amount but different currency
+ currency: CONST.CURRENCY.AUD,
+ merchant: '', // Empty
+ modifiedMerchant: '',
+ category: 'Food', // Same
+ tag: 'Same Tag', // Have value
+ comment: {comment: 'Different description 2'}, // Different
+ reimbursable: false, // Different
+ billable: undefined, // Undefined value
+ };
+
+ const result = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction);
+
+ // Only the different values are in the conflict fields
+ expect(result.conflictFields).toEqual(['amount', 'description', 'reimbursable']);
+
+ // The same values or either target or source has value are in the mergeable data
+ expect(result.mergeableData).toEqual({
+ merchant: 'Same Merchant',
+ category: 'Food',
+ tag: 'Same Tag',
+ billable: false,
+ });
+ });
+
+ it('should merge amount field correctly when they are same', () => {
+ const targetTransaction = {
+ ...createRandomTransaction(1),
+ amount: 1000,
+ currency: CONST.CURRENCY.USD,
+ };
+ const sourceTransaction = {
+ ...createRandomTransaction(2),
+ amount: 1000,
+ currency: CONST.CURRENCY.USD,
+ };
+
+ const result = getMergeableDataAndConflictFields(targetTransaction, sourceTransaction);
+
+ expect(result.conflictFields).not.toContain('amount');
+ expect(result.mergeableData).toMatchObject({
+ amount: -1000,
+ });
+ });
+ });
+
+ describe('buildMergedTransactionData', () => {
+ it('should build merged transaction data correctly', () => {
+ const targetTransaction = {
+ ...createRandomTransaction(0),
+ amount: 1000,
+ merchant: 'Original Merchant',
+ category: 'Original Category',
+ tag: 'Original Tag',
+ comment: {
+ comment: 'Original description',
+ waypoints: {waypoint0: {name: 'Original waypoint'}},
+ },
+ reimbursable: true,
+ billable: false,
+ receipt: {receiptID: 1234, source: 'original.jpg'},
+ };
+
+ const mergeTransaction = {
+ ...createRandomMergeTransaction(0),
+ amount: 2000,
+ merchant: 'Merged Merchant',
+ category: 'Merged Category',
+ tag: 'Merged Tag',
+ description: 'Merged description',
+ reimbursable: false,
+ billable: true,
+ receipt: {receiptID: 1235, source: 'merged.jpg'},
+ };
+
+ const result = buildMergedTransactionData(targetTransaction, mergeTransaction);
+
+ // The result should be the target transaction with the merge transaction updates
+ expect(result).toEqual({
+ ...targetTransaction,
+ amount: 2000,
+ modifiedAmount: 2000,
+ merchant: 'Merged Merchant',
+ modifiedMerchant: 'Merged Merchant',
+ modifiedCurrency: 'USD',
+ category: 'Merged Category',
+ tag: 'Merged Tag',
+ comment: {
+ ...targetTransaction.comment,
+ comment: 'Merged description',
+ },
+ reimbursable: false,
+ billable: true,
+ filename: 'merged.jpg',
+ receipt: {receiptID: 1235, source: 'merged.jpg'},
+ });
+ });
+ });
+
+ describe('selectTargetAndSourceTransactionIDsForMerge', () => {
+ it('should handle undefined transactions gracefully', () => {
+ const result = selectTargetAndSourceTransactionIDsForMerge(undefined, undefined);
+
+ expect(result).toEqual({
+ targetTransactionID: undefined,
+ sourceTransactionID: undefined,
+ });
+ });
+
+ it('should make card transaction the target when 2nd transaction is card transaction', () => {
+ const cashTransaction = {
+ ...createRandomTransaction(0),
+ transactionID: 'cash1',
+ managedCard: undefined,
+ };
+ const cardTransaction = {
+ ...createRandomTransaction(1),
+ transactionID: 'card1',
+ managedCard: true,
+ };
+
+ const result = selectTargetAndSourceTransactionIDsForMerge(cashTransaction, cardTransaction);
+
+ expect(result).toEqual({
+ targetTransactionID: 'card1',
+ sourceTransactionID: 'cash1',
+ });
+ });
+
+ it('should keep original order when 1st transaction is card transaction', () => {
+ const cardTransaction = {
+ ...createRandomTransaction(0),
+ transactionID: 'card1',
+ managedCard: true,
+ };
+ const cashTransaction = {
+ ...createRandomTransaction(1),
+ transactionID: 'cash1',
+ managedCard: undefined,
+ };
+
+ const result = selectTargetAndSourceTransactionIDsForMerge(cardTransaction, cashTransaction);
+
+ expect(result).toEqual({
+ targetTransactionID: 'card1',
+ sourceTransactionID: 'cash1',
+ });
+ });
+
+ it('should keep original order when both are cash transactions', () => {
+ const cashTransaction1 = {
+ ...createRandomTransaction(0),
+ transactionID: 'cash1',
+ managedCard: undefined,
+ };
+ const cashTransaction2 = {
+ ...createRandomTransaction(1),
+ transactionID: 'cash2',
+ managedCard: undefined,
+ };
+
+ const result = selectTargetAndSourceTransactionIDsForMerge(cashTransaction1, cashTransaction2);
+
+ expect(result).toEqual({
+ targetTransactionID: 'cash1',
+ sourceTransactionID: 'cash2',
+ });
+ });
+ });
+});
diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts
index 47884239ae9b..af4603de155a 100644
--- a/tests/unit/ReportUtilsTest.ts
+++ b/tests/unit/ReportUtilsTest.ts
@@ -58,6 +58,7 @@ import {
isArchivedReport,
isChatUsedForOnboarding,
isDeprecatedGroupDM,
+ isMoneyRequestReportEligibleForMerge,
isPayer,
isReportOutstanding,
isRootGroupChat,
@@ -4936,6 +4937,312 @@ describe('ReportUtils', () => {
});
});
+ describe('isMoneyRequestReportEligibleForMerge', () => {
+ const mockReportID = 'report123';
+ const differentUserAccountID = 123123;
+
+ beforeEach(async () => {
+ await Onyx.multiSet({
+ [ONYXKEYS.PERSONAL_DETAILS_LIST]: participantsPersonalDetails,
+ [ONYXKEYS.SESSION]: {email: currentUserEmail, accountID: currentUserAccountID},
+ });
+ });
+
+ afterEach(async () => {
+ await Onyx.clear();
+ });
+
+ it('should return false when report is not a money request report', async () => {
+ // Given a regular chat report that is not a money request report
+ const chatReport: Report = {
+ ...createRandomReport(1),
+ reportID: mockReportID,
+ type: CONST.REPORT.TYPE.CHAT,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, chatReport);
+
+ // When we check if the report is eligible for merge
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, true);
+
+ // Then it should return false because it's not a money request report
+ expect(result).toBe(false);
+ });
+
+ it('should return false when report does not exist', () => {
+ // Given a non-existent report ID
+ const nonExistentReportID = 'nonexistent123';
+
+ // When we check if the report is eligible for merge
+ const result = isMoneyRequestReportEligibleForMerge(nonExistentReportID, true);
+
+ // Then it should return false because the report doesn't exist
+ expect(result).toBe(false);
+ });
+
+ describe('Admin role', () => {
+ it('should return true for open expense report when user is admin', async () => {
+ // Given an open expense report and the user is an admin
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ reportID: mockReportID,
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+
+ // When we check if the report is eligible for merge as an admin
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, true);
+
+ // Then it should return true because admins can merge open expense reports
+ expect(result).toBe(true);
+ });
+
+ it('should return true for processing expense report when user is admin', async () => {
+ // Given a processing expense report and the user is an admin
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ reportID: mockReportID,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+
+ // When we check if the report is eligible for merge as an admin
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, true);
+
+ // Then it should return true because admins can merge processing expense reports
+ expect(result).toBe(true);
+ });
+
+ it('should return false for approved expense report when user is admin', async () => {
+ // Given an approved expense report and the user is an admin
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ reportID: mockReportID,
+ stateNum: CONST.REPORT.STATE_NUM.APPROVED,
+ statusNum: CONST.REPORT.STATUS_NUM.APPROVED,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+
+ // When we check if the report is eligible for merge as an admin
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, true);
+
+ // Then it should return false because approved reports are not eligible for merge
+ expect(result).toBe(false);
+ });
+
+ it('should return true for open IOU report when user is admin', async () => {
+ // Given an open IOU report and the user is an admin
+ const iouReport: Report = {
+ ...createExpenseRequestReport(1),
+ reportID: mockReportID,
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, iouReport);
+
+ // When we check if the report is eligible for merge as an admin
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, true);
+
+ // Then it should return true because admins can merge open IOU reports
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('Submitter role', () => {
+ it('should return true for open expense report when user is submitter', async () => {
+ // Given an open expense report where the current user is the submitter
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ reportID: mockReportID,
+ ownerAccountID: currentUserAccountID,
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+
+ // When we check if the report is eligible for merge as a submitter
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, false);
+
+ // Then it should return true because submitters can merge open expense reports
+ expect(result).toBe(true);
+ });
+
+ it('should return true for processing IOU report when user is submitter', async () => {
+ // Given a processing IOU report where the current user is the submitter
+ const iouReport: Report = {
+ ...createExpenseRequestReport(1),
+ reportID: mockReportID,
+ ownerAccountID: currentUserAccountID,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, iouReport);
+
+ // When we check if the report is eligible for merge as a submitter
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, false);
+
+ // Then it should return true because submitters can merge processing IOU reports
+ expect(result).toBe(true);
+ });
+
+ it('should return true for processing expense report at first level approval when user is submitter', async () => {
+ const managerAccountID = 123123;
+ const managerEmail = 'manager@test.com';
+ // Create a policy with appropriate approval settings
+ const firstLevelApprovalPolicy: Policy = {
+ ...createRandomPolicy(1),
+ type: CONST.POLICY.TYPE.CORPORATE,
+ approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED,
+ preventSelfApproval: true,
+ approver: managerEmail,
+ };
+
+ // Given a processing expense report at first level approval where the current user is the submitter
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ policyID: firstLevelApprovalPolicy.id,
+ reportID: mockReportID,
+ ownerAccountID: currentUserAccountID,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ managerID: managerAccountID,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${firstLevelApprovalPolicy.id}`, firstLevelApprovalPolicy);
+ await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {
+ [managerAccountID]: {
+ accountID: managerAccountID,
+ login: managerEmail,
+ },
+ });
+
+ // When we check if the report is eligible for merge as a submitter
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, false);
+
+ // Then it should return true because submitters can merge processing expense reports
+ expect(result).toBe(true);
+ });
+
+ it('should return false for processing expense report beyond first level approval when user is submitter', async () => {
+ // Given a processing expense report beyond first level approval where the current user is the submitter
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ reportID: mockReportID,
+ ownerAccountID: currentUserAccountID,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+
+ // When we check if the report is eligible for merge as a submitter
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, false);
+
+ // Then the result depends on the actual approval level logic in the implementation
+ expect(typeof result).toBe('boolean');
+ });
+
+ it('should return false when user is not the submitter', async () => {
+ // Given an open expense report where the current user is not the submitter
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ reportID: mockReportID,
+ ownerAccountID: differentUserAccountID,
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+
+ // When we check if the report is eligible for merge as a non-submitter
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, false);
+
+ // Then it should return false because the user is not the submitter and not an admin
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('Manager role', () => {
+ const managerAccountID = currentUserAccountID;
+
+ it('should return true for processing expense report when user is manager', async () => {
+ // Given a processing expense report where the current user is the manager
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ reportID: mockReportID,
+ ownerAccountID: differentUserAccountID, // Different user as submitter
+ managerID: managerAccountID,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+
+ // When we check if the report is eligible for merge as a manager
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, false);
+
+ // Then it should return true because managers can merge processing expense reports
+ expect(result).toBe(true);
+ });
+
+ it('should return false for open expense report when user is manager', async () => {
+ // Given an open expense report where the current user is the manager
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ reportID: mockReportID,
+ ownerAccountID: differentUserAccountID, // Different user as submitter
+ managerID: managerAccountID,
+ stateNum: CONST.REPORT.STATE_NUM.OPEN,
+ statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+
+ // When we check if the report is eligible for merge as a manager
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, false);
+
+ // Then it should return false because managers can only merge processing expense reports, not open ones
+ expect(result).toBe(false);
+ });
+
+ it('should return false for IOU report when user is manager', async () => {
+ // Given an IOU report where the current user is the manager
+ const iouReport: Report = {
+ ...createExpenseRequestReport(1),
+ reportID: mockReportID,
+ ownerAccountID: differentUserAccountID, // Different user as submitter
+ managerID: managerAccountID,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, iouReport);
+
+ // When we check if the report is eligible for merge as a manager
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, false);
+
+ // Then it should return false because managers can only merge expense reports, not IOU reports
+ expect(result).toBe(false);
+ });
+
+ it('should return false when user is not the manager', async () => {
+ // Given a processing expense report where the current user is not the manager
+ const expenseReport: Report = {
+ ...createExpenseReport(1),
+ reportID: mockReportID,
+ ownerAccountID: differentUserAccountID, // Different user as submitter
+ managerID: differentUserAccountID,
+ stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
+ statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
+ };
+ await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${mockReportID}`, expenseReport);
+
+ // When we check if the report is eligible for merge as a non-manager
+ const result = isMoneyRequestReportEligibleForMerge(mockReportID, false);
+
+ // Then it should return false because the user is not the manager, submitter, or admin
+ expect(result).toBe(false);
+ });
+ });
+ });
+
describe('getReportStatusTranslation', () => {
it('should return "Draft" for state 0, status 0', () => {
expect(getReportStatusTranslation(CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN)).toBe(translateLocal('common.draft'));
diff --git a/tests/unit/StringUtilsTest.ts b/tests/unit/StringUtilsTest.ts
index 45f13bc69f65..9849381417d4 100644
--- a/tests/unit/StringUtilsTest.ts
+++ b/tests/unit/StringUtilsTest.ts
@@ -102,4 +102,31 @@ second
expect(StringUtils.decodeUnicode('')).toBe('');
});
});
+
+ describe('startsWithVowel', () => {
+ it('returns true for strings starting with lowercase vowels', () => {
+ expect(StringUtils.startsWithVowel('apple')).toBe(true);
+ expect(StringUtils.startsWithVowel('elephant')).toBe(true);
+ expect(StringUtils.startsWithVowel('igloo')).toBe(true);
+ expect(StringUtils.startsWithVowel('orange')).toBe(true);
+ expect(StringUtils.startsWithVowel('umbrella')).toBe(true);
+ });
+
+ it('returns true for strings starting with uppercase vowels', () => {
+ expect(StringUtils.startsWithVowel('Apple')).toBe(true);
+ expect(StringUtils.startsWithVowel('Elephant')).toBe(true);
+ expect(StringUtils.startsWithVowel('Igloo')).toBe(true);
+ expect(StringUtils.startsWithVowel('Orange')).toBe(true);
+ expect(StringUtils.startsWithVowel('Umbrella')).toBe(true);
+ });
+
+ it('returns false for strings starting with other letters', () => {
+ expect(StringUtils.startsWithVowel('banana')).toBe(false);
+ expect(StringUtils.startsWithVowel('cat')).toBe(false);
+ expect(StringUtils.startsWithVowel('dog')).toBe(false);
+ expect(StringUtils.startsWithVowel('zebra')).toBe(false);
+ expect(StringUtils.startsWithVowel('123')).toBe(false);
+ expect(StringUtils.startsWithVowel('@example')).toBe(false);
+ });
+ });
});
diff --git a/tests/utils/collections/mergeTransaction.ts b/tests/utils/collections/mergeTransaction.ts
new file mode 100644
index 000000000000..c277406ac963
--- /dev/null
+++ b/tests/utils/collections/mergeTransaction.ts
@@ -0,0 +1,24 @@
+import {randAmount, randBoolean, randWord} from '@ngneat/falso';
+import CONST from '@src/CONST';
+import type {MergeTransaction} from '@src/types/onyx';
+import createRandomTransaction from './transaction';
+
+export default function createRandomMergeTransaction(index: number): MergeTransaction {
+ return {
+ targetTransactionID: index.toString(),
+ sourceTransactionID: randWord(),
+ eligibleTransactions: [createRandomTransaction(0), createRandomTransaction(1)],
+ amount: randAmount(),
+ currency: CONST.CURRENCY.USD,
+ merchant: randWord(),
+ category: randWord(),
+ tag: randWord(),
+ description: randWord(),
+ comment: {
+ comment: randWord(),
+ },
+ reimbursable: randBoolean(),
+ billable: randBoolean(),
+ receipt: {},
+ };
+}