diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8d56e31f5f68..c3d41badf859 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1853,6 +1853,7 @@ const CONST = { MARKED_REIMBURSED: 'MARKEDREIMBURSED', // OldDot Action MARK_REIMBURSED_FROM_INTEGRATION: 'ACTIONMARKEDREIMBURSEDFROMINTEGRATION', // OldDot Action MERGED_WITH_CASH_TRANSACTION: 'MERGEDWITHCASHTRANSACTION', + MERGED_EXPENSE_DELETED: 'MERGEDEXPENSEDELETED', MODIFIED_EXPENSE: 'MODIFIEDEXPENSE', CONCIERGE_AUTO_MATCH_VENDOR: 'CONCIERGEAUTOMATCHVENDOR', CONCIERGE_AUTO_SELECT_DISTANCE_RATE: 'CONCIERGEAUTOSELECTDISTANCERATE', diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 3e4cdabb9dbf..d8a4aeab8d2b 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -3166,19 +3166,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { text: translate('common.merge'), icon: expensifyIcons.ArrowCollapse, value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE, - onSelected: () => - setupMergeTransactionDataAndNavigate( - transactionID, - searchedTransactions, - localeCompare, - getCurrencyDecimals, - reports, - false, - true, - undefined, - accountID, - personalDetails, - ), + onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, getCurrencyDecimals, reports, false, true, undefined), }); } } diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 2281a62f907c..d46b0101a6b4 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -508,8 +508,6 @@ function useSelectedTransactionsActions({ false, isOnSearch, selectedTransactionsList.length > 1 ? [policy, policy] : undefined, - currentUserAccountID, - personalDetails, ), }); } diff --git a/src/languages/de.ts b/src/languages/de.ts index d1b304a7edae..922434b3578d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -11192,6 +11192,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, systemMessage: { mergedWithCashTransaction: 'hat eine Quittung mit dieser Transaktion abgeglichen', + mergedExpenseDeleted: 'hat Ausgaben zusammengeführt und eine in Gelöscht verschoben', }, subscription: { authenticatePaymentCard: 'Zahlungskarte authentifizieren', diff --git a/src/languages/el.ts b/src/languages/el.ts index ceb1722baaba..08496a3f0644 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -11404,6 +11404,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: 'έγινε αντιστοίχιση μιας απόδειξης με αυτή τη συναλλαγή', + mergedExpenseDeleted: 'συγχώνευσε έξοδα και μετέφερε ένα στα Διαγραμμένα', }, subscription: { authenticatePaymentCard: 'Πιστοποιήστε την κάρτα πληρωμής', diff --git a/src/languages/en.ts b/src/languages/en.ts index d3aaf0a9adb7..514e715a29ff 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -11445,6 +11445,7 @@ const translations = { }, systemMessage: { mergedWithCashTransaction: 'matched a receipt to this transaction', + mergedExpenseDeleted: 'merged expenses and moved one to Deleted', }, subscription: { authenticatePaymentCard: 'Authenticate payment card', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5d5cb85687cb..54a44b370a75 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -11385,6 +11385,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: 'encontró un recibo para esta transacción', + mergedExpenseDeleted: 'combinó gastos y movió uno a Eliminados', }, subscription: { authenticatePaymentCard: 'Autenticar tarjeta de pago', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 9f012848468a..22a5701e5cb4 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -11216,6 +11216,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, systemMessage: { mergedWithCashTransaction: 'a fait correspondre un reçu à cette transaction', + mergedExpenseDeleted: 'a fusionné des dépenses et en a déplacé une vers Supprimées', }, subscription: { authenticatePaymentCard: 'Authentifier la carte de paiement', diff --git a/src/languages/it.ts b/src/languages/it.ts index 97f6bff4608b..72dbc2c602e0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -11144,6 +11144,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, systemMessage: { mergedWithCashTransaction: 'ha associato una ricevuta a questa transazione', + mergedExpenseDeleted: 'ha unito delle spese e ne ha spostata una in Eliminate', }, subscription: { authenticatePaymentCard: 'Autentica carta di pagamento', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9e8e3d5f000a..4a756bfd390f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10981,6 +10981,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: 'この取引にレシートを照合しました', + mergedExpenseDeleted: '経費を統合し、1件を削除済みに移動しました', }, subscription: { authenticatePaymentCard: '支払カードを認証', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 557ee9ae4c75..74453546d830 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -11107,6 +11107,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, systemMessage: { mergedWithCashTransaction: 'heeft een bonnetje aan deze transactie gekoppeld', + mergedExpenseDeleted: 'heeft uitgaven samengevoegd en er één naar Verwijderd verplaatst', }, subscription: { authenticatePaymentCard: 'Betalingskaart verifiëren', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 86a764ad2909..6d67a7d36fa4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -11121,6 +11121,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, systemMessage: { mergedWithCashTransaction: 'dopasowano paragon do tej transakcji', + mergedExpenseDeleted: 'scalono wydatki i przeniesiono jeden do Usuniętych', }, subscription: { authenticatePaymentCard: 'Uwierzytelnij kartę płatniczą', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 1fbfef890b52..6ef6661739f2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -11102,6 +11102,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, systemMessage: { mergedWithCashTransaction: 'correspondeu um recibo a esta transação', + mergedExpenseDeleted: 'mesclou despesas e moveu uma para Excluídas', }, subscription: { authenticatePaymentCard: 'Autenticar cartão de pagamento', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 9479247938d9..061249fd3392 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -10693,6 +10693,7 @@ ${reportName}`, }, systemMessage: { mergedWithCashTransaction: '已将一张收据匹配到此交易', + mergedExpenseDeleted: '已合并报销并将其中一笔移至已删除', }, subscription: { authenticatePaymentCard: '验证支付卡', diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index fbad088f740a..9456ddbcdc63 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -232,19 +232,16 @@ function getMergeableDataAndConflictFields( searchReports: Array> = [], targetTransactionPolicy?: OnyxEntry, sourceTransactionPolicy?: OnyxEntry, - targetReportOwnerAsAttendee?: Attendee, - sourceReportOwnerAsAttendee?: Attendee, ) { const conflictFields: string[] = []; const mergeableData: Record = {}; // The same object, typed for the field builders that read the values merged so far const mergedSoFar = mergeableData as MergeTransaction; - // Resolve the report-owner fallback the same way the display path (buildMergeFieldsData) does, so an expense - // with no stored attendee is compared as [owner] instead of [] and doesn't produce a false attendee conflict - // against an expense whose attendee is that same owner. - const targetTransactionDetails = getTransactionDetails(targetTransaction, undefined, undefined, undefined, undefined, targetReportOwnerAsAttendee); - const sourceTransactionDetails = getTransactionDetails(sourceTransaction, undefined, undefined, undefined, undefined, sourceReportOwnerAsAttendee); + // Attendees are compared as stored, without the report-owner display fallback. The owner can only be resolved for + // reports loaded locally, so applying it would give the two expenses different values when they sit on different reports. + const targetTransactionDetails = getTransactionDetails(targetTransaction); + const sourceTransactionDetails = getTransactionDetails(sourceTransaction); for (const field of getMergeFields(targetTransaction)) { const targetValue = getMergeFieldValue(targetTransactionDetails, targetTransaction, field); @@ -319,7 +316,8 @@ function getMergeableDataAndConflictFields( const targetAttendeeLogins = ((targetValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare); const sourceAttendeeLogins = ((sourceValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare); - if (deepEqual(targetAttendeeLogins, sourceAttendeeLogins)) { + // An expense with no attendees has nothing to choose between, so the other side's attendees are kept + if (isTargetValueEmpty || isSourceValueEmpty || deepEqual(targetAttendeeLogins, sourceAttendeeLogins)) { mergeableData[field] = isTargetValueEmpty ? sourceValue : targetValue; } else { conflictFields.push(field); diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 2854b98a6b4c..975d53377d90 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -31,24 +31,10 @@ import { } from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import {getReportOwnerAccountIDAsAttendee, getReportOwnerAsAttendee, isDistanceRequest, isTransactionPendingDelete} from '@src/libs/TransactionUtils'; +import {isDistanceRequest, isTransactionPendingDelete} from '@src/libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type { - CardList, - MergeTransaction, - PersonalDetailsList, - Policy, - PolicyCategories, - PolicyTagLists, - Report, - ReportAction, - ReportActions, - Rule, - Transaction, - TransactionViolations, -} from '@src/types/onyx'; -import type {Attendee} from '@src/types/onyx/IOU'; +import type {CardList, MergeTransaction, Policy, PolicyCategories, PolicyTagLists, Report, ReportAction, ReportActions, Rule, Transaction, TransactionViolations} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry, OnyxMergeInput, OnyxUpdate} from 'react-native-onyx'; @@ -61,20 +47,6 @@ import {getCleanUpTransactionThreadReportOnyxData} from './IOU/DeleteMoneyReques import {getDeleteTrackExpenseInformation} from './IOU/TrackExpense'; import {getUpdateMoneyRequestParams, getUpdateTrackExpenseParams} from './IOU/UpdateMoneyRequest'; -/** - * Resolve a transaction's report owner as a default attendee, mirroring the `useReportOwnerAsAttendee` hook so the - * non-React merge entry point can apply the same empty-attendee fallback the details/receipt pages use. The Onyx data - * is passed in by the caller rather than read from a module subscription. - */ -function getReportOwnerAsAttendeeForTransaction( - transaction: OnyxEntry, - currentUserAccountID: number | undefined, - personalDetails: OnyxEntry, -): Attendee | undefined { - const ownerAccountID = getReportOwnerAccountIDAsAttendee(transaction, currentUserAccountID); - return getReportOwnerAsAttendee(ownerAccountID ? (personalDetails?.[ownerAccountID] ?? undefined) : undefined); -} - /** * Setup merge transaction data for merging flow */ @@ -119,8 +91,6 @@ function setupMergeTransactionDataAndNavigate( isSelectingSourceTransaction?: boolean, isOnSearch?: boolean, policies?: Array>, - currentUserAccountID?: number, - personalDetails?: OnyxEntry, ) { if (!transactions.length || transactions.length > 2) { return; @@ -169,8 +139,6 @@ function setupMergeTransactionDataAndNavigate( searchReports, targetTransactionPolicy, sourceTransactionPolicy, - getReportOwnerAsAttendeeForTransaction(targetTransaction, currentUserAccountID, personalDetails), - getReportOwnerAsAttendeeForTransaction(sourceTransaction, currentUserAccountID, personalDetails), ); if (!conflictFields.length) { // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page @@ -195,10 +163,22 @@ function getTransactionsForMergingFromAPI(transactionID: string) { } /** - * Fetches eligible transactions for merging locally - * This is FE version of READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call + * Fetches eligible transactions for merging locally. + * This is the offline FE equivalent of the READ_COMMANDS.GET_TRANSACTIONS_FOR_MERGING API call. + * Per-candidate admin access is checked against each candidate report’s own workspace so that + * cross-workspace admin merges are not granted based on the target’s workspace alone. + * When the candidate’s policy is not in the local cache the candidate is excluded rather than + * assuming access. */ -function getTransactionsForMergingLocally(transactionID: string, targetTransaction: Transaction, transactions: OnyxCollection, rules: OnyxCollection, isAdmin = false) { +function getTransactionsForMergingLocally( + transactionID: string, + targetTransaction: Transaction, + transactions: OnyxCollection, + rules: OnyxCollection, + allPolicies: OnyxCollection, + currentUserLogin: string | undefined, + allReports?: OnyxCollection, +) { const transactionsArray = Object.values(transactions ?? {}); const eligibleTransactions = transactionsArray.filter((transaction): transaction is Transaction => { @@ -207,10 +187,29 @@ function getTransactionsForMergingLocally(transactionID: string, targetTransacti } const isUnreportedExpense = !transaction?.reportID || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + if (isUnreportedExpense) { + return areTransactionsEligibleForMerge(targetTransaction, transaction) && !isTransactionPendingDelete(transaction); + } + + // Resolve admin status against the candidate’s own workspace. If the policy is absent from + // the local cache we cannot verify access, so we exclude the candidate rather than assuming + // admin rights. + const candidateReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`] ?? getReportOrDraftReport(transaction.reportID); + if (!candidateReport) { + return false; + } + const candidatePolicyKey = `${ONYXKEYS.COLLECTION.POLICY}${candidateReport?.policyID}`; + const candidatePolicy = candidateReport?.policyID ? (allPolicies?.[candidatePolicyKey] ?? null) : null; + const isCandidatePolicyMissing = !!candidateReport?.policyID && candidatePolicy === null; + if (isCandidatePolicyMissing) { + return false; + } + const isCandidateAdmin = isPolicyAdmin(candidatePolicy, currentUserLogin); + return ( areTransactionsEligibleForMerge(targetTransaction, transaction) && !isTransactionPendingDelete(transaction) && - (isUnreportedExpense || (!!transaction.reportID && isMoneyRequestReportEligibleForMerge(transaction.reportID, isAdmin, rules))) + isMoneyRequestReportEligibleForMerge(candidateReport, isCandidateAdmin, rules) ); }); @@ -228,6 +227,8 @@ function getTransactionsForMerging({ report, currentUserLogin, rules, + allPolicies, + allReports, }: { isOffline: boolean; targetTransaction: Transaction; @@ -238,22 +239,27 @@ function getTransactionsForMerging({ currentUserLogin: string | undefined; cardList?: CardList; rules: OnyxCollection; + allPolicies?: OnyxCollection; + allReports?: OnyxCollection; }) { const transactionID = targetTransaction.transactionID; if (!transactionID) { return; } - // Collect/Control workspaces: - // - Admins and approvers: The list of eligible expenses will only contain the expenses from the report that the admin/approver triggered the merge from. This is intentionally limited since they’ll only be reviewing one report at a time. - // - Submitters will see all their editable expenses, including their IOUs/unreported expenses + // Candidate discovery: + // - Admins: See expenses from the same submitter across the submitter's open reports and submitted reports pending + // final approval that the admin can access, so admins can merge a card and cash expense on different reports. + // - Managers (non-admin approvers): Limited to same-report transactions on the processing report they triggered + // the merge from, as they review one report at a time. + // - Submitters will see all their editable expenses, including their IOUs/unreported expenses. // IOU: // - There are no admins/approvers outside of the submitter in these cases, so there’s no consideration for different roles. - // - The submitter, who is also the admin, will see all their editable expenses, including their IOUs/unreported expenses + // - The submitter, who is also the admin, will see all their editable expenses, including their IOUs/unreported expenses. const isAdmin = isPolicyAdmin(policy, currentUserLogin); const isManager = isReportManager(report); - if (isPaidGroupPolicy(policy) && (isAdmin || isManager) && !isCurrentUserSubmitter(report)) { + if (isPaidGroupPolicy(policy) && isManager && !isAdmin && !isCurrentUserSubmitter(report)) { const eligibleTransactions = reportTransactions.filter((transaction): transaction is Transaction => { if (!transaction || transaction.transactionID === transactionID) { return false; @@ -269,7 +275,7 @@ function getTransactionsForMerging({ } if (isOffline) { - getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, rules, isAdmin); + getTransactionsForMergingLocally(transactionID, targetTransaction, transactions, rules, allPolicies, currentUserLogin, allReports); } else { getTransactionsForMergingFromAPI(transactionID); } diff --git a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx index 9760acfbb2dd..14c34dabd3b3 100644 --- a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx @@ -82,8 +82,6 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { [targetTransactionReport, sourceTransactionReport], targetTransactionPolicy, sourceTransactionPolicy, - targetReportOwnerAsAttendee, - sourceReportOwnerAsAttendee, ); setMergeTransactionKey(transactionID, mergeableData); @@ -98,8 +96,6 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { targetTransactionPolicy, sourceTransactionPolicy, getCurrencyDecimals, - targetReportOwnerAsAttendee, - sourceReportOwnerAsAttendee, ]); // Handle selection diff --git a/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx b/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx index e4d2a4b7df9c..5415ec4e96f7 100644 --- a/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx @@ -12,7 +12,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useMergeTransactions from '@hooks/useMergeTransactions'; import useOnyx from '@hooks/useOnyx'; -import useReportOwnerAsAttendee from '@hooks/useReportOwnerAsAttendee'; import useThemeStyles from '@hooks/useThemeStyles'; import {setMergeTransactionKey} from '@libs/actions/MergeTransaction'; @@ -47,8 +46,6 @@ function DynamicReceiptReviewPage({route}: DynamicReceiptReviewPageProps) { const [mergeTransaction, mergeTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); const {targetTransaction, sourceTransaction, targetTransactionPolicy, sourceTransactionPolicy} = useMergeTransactions({mergeTransaction}); - const targetReportOwnerAsAttendee = useReportOwnerAsAttendee(targetTransaction); - const sourceReportOwnerAsAttendee = useReportOwnerAsAttendee(sourceTransaction); const transactions = [targetTransaction, sourceTransaction].filter((transaction): transaction is Transaction => !!transaction); @@ -69,8 +66,6 @@ function DynamicReceiptReviewPage({route}: DynamicReceiptReviewPageProps) { [], targetTransactionPolicy, sourceTransactionPolicy, - targetReportOwnerAsAttendee, - sourceReportOwnerAsAttendee, ); if (!conflictFields.length) { // If there are no conflict fields, we should set mergeable data and navigate to the confirmation page diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx index 6b541330b8b8..5953a67612c2 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx @@ -11,7 +11,6 @@ import useLocalize from '@hooks/useLocalize'; import useMergeTransactions from '@hooks/useMergeTransactions'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import useReportTransactions from '@hooks/useReportTransactions'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -23,10 +22,10 @@ import {getAmount, getCreated, getCurrency, getDescription, getMerchant, isExpen import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MergeTransaction} from '@src/types/onyx'; +import type {MergeTransaction, Policy, Report} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -40,6 +39,23 @@ type MergeTransactionsListContentProps = { mergeTransaction: OnyxEntry; }; +const reportsSelector = (reports: OnyxCollection): OnyxCollection => + Object.fromEntries( + Object.entries(reports ?? {}).map(([key, report]) => [ + key, + report && { + reportID: report.reportID, + type: report.type, + policyID: report.policyID, + ownerAccountID: report.ownerAccountID, + managerID: report.managerID, + stateNum: report.stateNum, + statusNum: report.statusNum, + total: report.total, + }, + ]), + ) as OnyxCollection; + function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTransactionsListContentProps) { const illustrations = useMemoizedLazyIllustrations(['EmptyShelves']); const {translate, localeCompare} = useLocalize(); @@ -47,10 +63,31 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const [session] = useOnyx(ONYXKEYS.SESSION); - const [personalDetails] = useAllPersonalDetails(); const currentUserLogin = session?.email; const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const policiesSelector = (policies: OnyxCollection): OnyxCollection => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only the fields used by the admin check are kept + Object.fromEntries( + Object.entries(policies ?? {}).map(([key, policy]) => { + const login = currentUserLogin ?? ''; + const employee = policy?.employeeList?.[login] ?? policy?.employeeList?.[login.toLowerCase()]; + return [ + key, + policy && { + id: policy.id, + role: policy.role, + employeeList: employee ? {[login]: employee} : undefined, + }, + ]; + }), + ) as OnyxCollection; + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: policiesSelector, + }); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + selector: reportsSelector, + }); const {isOffline} = useNetwork(); const {convertToDisplayString, getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -75,6 +112,8 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr report: targetTransactionReport, currentUserLogin, rules, + allPolicies, + allReports, }); }, [ transactions, @@ -86,6 +125,8 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr targetTransaction, targetReportTransactions, rules, + allPolicies, + allReports, ]); const data = !eligibleTransactions @@ -179,18 +220,10 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr } const reports = targetTransactionReport && sourceTransactionReport ? [targetTransactionReport, sourceTransactionReport] : undefined; - setupMergeTransactionDataAndNavigate( - transactionID, - [targetTransaction, sourceTransaction], - localeCompare, - getCurrencyDecimals, - reports, - true, - undefined, - [targetTransactionPolicy, sourceTransactionPolicy], - session?.accountID, - personalDetails, - ); + setupMergeTransactionDataAndNavigate(transactionID, [targetTransaction, sourceTransaction], localeCompare, getCurrencyDecimals, reports, true, undefined, [ + targetTransactionPolicy, + sourceTransactionPolicy, + ]); }; const confirmButtonOptions = { diff --git a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx index fbd937960d5d..991e71dc9132 100644 --- a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx @@ -43,6 +43,7 @@ const SIMPLE_MESSAGE_ACTION_TYPES = new Set([ CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION, CONST.REPORT.ACTIONS.TYPE.UNDELETED_TRANSACTION, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.MERGED_EXPENSE_DELETED, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION, CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES, CONST.REPORT.ACTIONS.TYPE.DEMOTED_FROM_WORKSPACE, @@ -101,6 +102,9 @@ function SimpleMessageContent({action}: SimpleMessageContentProps) { if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION)) { return ; } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MERGED_EXPENSE_DELETED)) { + return ; + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) { return ; } diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index aa0a2aba838d..078e09c995c1 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1697,6 +1697,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.MARK_REIMBURSED_FROM_INTEGRATION]: never; [CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED]: OriginalMessageMarkedReimbursed; [CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION]: never; + [CONST.REPORT.ACTIONS.TYPE.MERGED_EXPENSE_DELETED]: never; [CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE]: OriginalMessageModifiedExpense; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_MATCH_VENDOR]: OriginalMessageConciergeAutoMatchVendor; [CONST.REPORT.ACTIONS.TYPE.CONCIERGE_AUTO_SELECT_DISTANCE_RATE]: OriginalMessageConciergeAutoSelectDistanceRate; diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index ad7ae04714a8..9feeb0c2747c 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -1824,11 +1824,550 @@ describe('getTransactionsForMerging', () => { expect(mergeTransaction).toBeUndefined(); }); + it('should allow admin to see cash candidates from another open draft report in the same workspace (offline)', async () => { + // Given an admin user and two open draft expense reports in the same workspace + const adminLogin = 'admin@test.com'; + const submitterAccountID = 42; + + const policy = { + ...createRandomPolicy(1), + id: 'workspace-1', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + + const targetReport = { + ...createExpenseReport(1), + reportID: 'target-report-1', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const otherDraftReport = { + ...createExpenseReport(2), + reportID: 'other-report-2', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + // Card expense (target) on the first report + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-1', + reportID: 'target-report-1', + // Make it a card transaction by giving it a cardNumber + cardNumber: '1234', + bank: undefined, + }; + + // Cash expense candidate on the other draft report + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-2', + reportID: 'other-report-2', + // Cash: no cardNumber, bank, or managedCard + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 500, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`, targetReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${otherDraftReport.reportID}`, otherDraftReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy, + }; + + // When the admin requests merge candidates offline + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`]: targetReport, + [`${ONYXKEYS.COLLECTION.REPORT}${otherDraftReport.reportID}`]: otherDraftReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy, + report: targetReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + }); + await waitForBatchedUpdates(); + + // Then the cash candidate from the other draft report appears in eligible transactions + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(true); + }); + + it('should allow admin to see cash candidates from a submitted report pending final approval in the same workspace (offline)', async () => { + // Given an admin user, an open draft report, and a submitted report awaiting approval in the same workspace + const adminLogin = 'admin@test.com'; + const submitterAccountID = 42; + + const policy = { + ...createRandomPolicy(1), + id: 'workspace-1', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + + const targetReport = { + ...createExpenseReport(1), + reportID: 'target-report-1', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const submittedReport = { + ...createExpenseReport(2), + reportID: 'submitted-report-2', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-1', + reportID: 'target-report-1', + cardNumber: '1234', + bank: undefined, + }; + + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-2', + reportID: 'submitted-report-2', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 500, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`, targetReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${submittedReport.reportID}`, submittedReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy, + }; + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`]: targetReport, + [`${ONYXKEYS.COLLECTION.REPORT}${submittedReport.reportID}`]: submittedReport, + }; + + // When the admin requests merge candidates offline + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy, + report: targetReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + }); + await waitForBatchedUpdates(); + + // Then the cash candidate from the submitted report appears in eligible transactions, because admins can still edit reports before final approval + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(true); + }); + + it('should NOT allow admin to see candidates from another workspace where they are not an admin (offline)', async () => { + // Given an admin in workspace-1 but only a member in workspace-2 + const adminLogin = 'admin@test.com'; + const submitterAccountID = 42; + + const adminPolicy = { + ...createRandomPolicy(1), + id: 'workspace-1', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + const memberPolicy = { + ...createRandomPolicy(2), + id: 'workspace-2', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + }; + + const targetReport = { + ...createExpenseReport(1), + reportID: 'admin-report-1', + policyID: 'workspace-1', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const otherWorkspaceReport = { + ...createExpenseReport(2), + reportID: 'member-report-2', + policyID: 'workspace-2', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-a', + reportID: 'admin-report-1', + cardNumber: '1234', + bank: undefined, + }; + + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-b', + reportID: 'member-report-2', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 500, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`, targetReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${otherWorkspaceReport.reportID}`, otherWorkspaceReport); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${adminPolicy.id}`]: adminPolicy, + [`${ONYXKEYS.COLLECTION.POLICY}${memberPolicy.id}`]: memberPolicy, + }; + + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`]: targetReport, + [`${ONYXKEYS.COLLECTION.REPORT}${otherWorkspaceReport.reportID}`]: otherWorkspaceReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy: adminPolicy, + report: targetReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + }); + await waitForBatchedUpdates(); + + // Then the candidate from the non-admin workspace is NOT in the eligible list + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(false); + }); + + it('should exclude candidates when their policy is missing from the local cache (offline)', async () => { + // Given an admin with a candidate whose policy is not cached locally + const adminLogin = 'admin@test.com'; + const submitterAccountID = 42; + + const adminPolicy = { + ...createRandomPolicy(1), + id: 'workspace-known', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + + const targetReport = { + ...createExpenseReport(1), + reportID: 'known-report', + policyID: 'workspace-known', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const unknownPolicyReport = { + ...createExpenseReport(2), + reportID: 'unknown-policy-report', + policyID: 'workspace-unknown', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-known', + reportID: 'known-report', + cardNumber: '9999', + bank: undefined, + }; + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-unknown', + reportID: 'unknown-policy-report', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 300, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`, targetReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${unknownPolicyReport.reportID}`, unknownPolicyReport); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + // workspace-unknown is intentionally absent from the local policy cache + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${adminPolicy.id}`]: adminPolicy, + }; + + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${targetReport.reportID}`]: targetReport, + [`${ONYXKEYS.COLLECTION.REPORT}${unknownPolicyReport.reportID}`]: unknownPolicyReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy: adminPolicy, + report: targetReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + }); + await waitForBatchedUpdates(); + + // Then the candidate from the uncached workspace is excluded rather than allowed + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(false); + }); + + it('should limit manager (non-admin approver) to same-report candidates on processing reports (offline)', async () => { + // Given a manager (not admin) reviewing a processing report + const managerLogin = 'manager@test.com'; + const managerAccountID = 99; + const submitterAccountID = 42; + + // Set the session so module-level currentUserAccountID resolves to the manager + await Onyx.set(ONYXKEYS.SESSION, {email: managerLogin, accountID: managerAccountID}); + await waitForBatchedUpdates(); + + const policy = { + ...createRandomPolicy(1), + id: 'workspace-manager', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + }; + + const processingReport = { + ...createExpenseReport(1), + reportID: 'processing-report', + policyID: 'workspace-manager', + ownerAccountID: submitterAccountID, + managerID: managerAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; + const otherOpenReport = { + ...createExpenseReport(2), + reportID: 'other-open-report', + policyID: 'workspace-manager', + ownerAccountID: submitterAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'card-tx-mgr', + reportID: 'processing-report', + cardNumber: '5678', + bank: undefined, + }; + const sameReportCandidate = { + ...createRandomTransaction(2), + transactionID: 'cash-tx-same', + reportID: 'processing-report', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 400, + }; + const otherReportCandidate = { + ...createRandomTransaction(3), + transactionID: 'cash-tx-other', + reportID: 'other-open-report', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 600, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, targetTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${sameReportCandidate.transactionID}`, sameReportCandidate); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${otherReportCandidate.transactionID}`, otherReportCandidate); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${processingReport.reportID}`, processingReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${otherOpenReport.reportID}`, otherOpenReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${sameReportCandidate.transactionID}`]: sameReportCandidate, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${otherReportCandidate.transactionID}`]: otherReportCandidate, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy, + }; + + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${processingReport.reportID}`]: processingReport, + [`${ONYXKEYS.COLLECTION.REPORT}${otherOpenReport.reportID}`]: otherOpenReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [targetTransaction, sameReportCandidate], + policy, + report: processingReport, + currentUserLogin: managerLogin, + rules: undefined, + allPolicies, + allReports, + }); + await waitForBatchedUpdates(); + + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + // Same-report candidate is included; cross-report candidate is excluded for a non-admin manager + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === sameReportCandidate.transactionID)).toBe(true); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === otherReportCandidate.transactionID)).toBe(false); + }); + + it('should include candidates from other draft reports for admin who is also the submitter (treated as submitter)', async () => { + // Given an admin who is also the report owner (submitter), they go through the submitter path + const adminLogin = 'admin-owner@test.com'; + const adminAccountID = 7; + + // Set the session so module-level currentUserAccountID resolves to the admin/submitter + await Onyx.set(ONYXKEYS.SESSION, {email: adminLogin, accountID: adminAccountID}); + await waitForBatchedUpdates(); + + const policy = { + ...createRandomPolicy(1), + id: 'workspace-self', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }; + + const ownReport = { + ...createExpenseReport(1), + reportID: 'own-report', + policyID: 'workspace-self', + ownerAccountID: adminAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const otherOwnDraftReport = { + ...createExpenseReport(2), + reportID: 'own-other-report', + policyID: 'workspace-self', + ownerAccountID: adminAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'self-card-tx', + reportID: 'own-report', + cardNumber: '1111', + bank: undefined, + }; + const candidateTransaction = { + ...createRandomTransaction(2), + transactionID: 'self-cash-tx', + reportID: 'own-other-report', + cardNumber: undefined, + bank: undefined, + managedCard: false, + amount: 250, + }; + + // Set individual transactions in Onyx so the offline filter can access them + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, targetTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`, candidateTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${ownReport.reportID}`, ownReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${otherOwnDraftReport.reportID}`, otherOwnDraftReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const allTransactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`]: targetTransaction, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransaction.transactionID}`]: candidateTransaction, + }; + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`]: policy, + }; + + // Session matches the report ownerAccountID so this is treated as the submitter path + const allReports = { + [`${ONYXKEYS.COLLECTION.REPORT}${ownReport.reportID}`]: ownReport, + [`${ONYXKEYS.COLLECTION.REPORT}${otherOwnDraftReport.reportID}`]: otherOwnDraftReport, + }; + + getTransactionsForMerging({ + isOffline: true, + targetTransaction, + transactions: allTransactions, + reportTransactions: [], + policy, + report: ownReport, + currentUserLogin: adminLogin, + rules: undefined, + allPolicies, + allReports, + }); + await waitForBatchedUpdates(); + + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${targetTransaction.transactionID}`); + expect(mergeTransaction?.eligibleTransactions?.some((t) => t.transactionID === candidateTransaction.transactionID)).toBe(true); + }); + it('builds the eligible list from reportTransactions when a workspace approver merges from a report they did not submit', async () => { - // Given a workspace approver (policy admin) reviewing a report submitted by someone else (TEST_ACCOUNT_ID + // Given a non-admin workspace approver reviewing a report submitted by someone else (TEST_ACCOUNT_ID // is the signed-in user for this file, so ownerAccountID must differ from it for isCurrentUserSubmitter to be false) - const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), role: CONST.POLICY.ROLE.ADMIN}; - const report = {...createRandomReport(1), ownerAccountID: TEST_ACCOUNT_ID + 1}; + const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), role: CONST.POLICY.ROLE.USER}; + const report = {...createRandomReport(1), ownerAccountID: TEST_ACCOUNT_ID + 1, managerID: TEST_ACCOUNT_ID}; const targetTransaction = {...createRandomTransaction(1), reportID: report.reportID, managedCard: false, cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, amount: 1000}; const eligibleTransaction = {...createRandomTransaction(2), reportID: report.reportID, managedCard: false, cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, amount: 2000}; const pendingDeleteTransaction = { diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index d16e1dfdc493..fcab8dd3ab01 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -19,6 +19,7 @@ import {isFromCreditCardImport} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; import type {OnyxCollection} from 'react-native-onyx'; @@ -714,6 +715,93 @@ describe('MergeTransactionUtils', () => { expect(result.conflictFields).toContain('attendees'); }); + describe('attendee combinations', () => { + const submitter = { + email: 'submitter@example.com', + displayName: 'Submitter', + avatarUrl: '', + login: 'submitter', + }; + const alice = { + email: 'alice@example.com', + displayName: 'Alice', + avatarUrl: '', + login: 'alice', + }; + const bob = { + email: 'bob@example.com', + displayName: 'Bob', + avatarUrl: '', + login: 'bob', + }; + + const merge = (targetAttendees: Attendee[], sourceAttendees: Attendee[]) => { + const targetTransaction = { + ...createRandomTransaction(0), + comment: { + ...createRandomTransaction(0).comment, + attendees: targetAttendees, + }, + }; + const sourceTransaction = { + ...createRandomTransaction(1), + comment: { + ...createRandomTransaction(1).comment, + attendees: sourceAttendees, + }, + }; + return getMergeableDataAndConflictFields(targetTransaction, sourceTransaction, mockLocaleCompare, mockGetCurrencyDecimals); + }; + + it('should not conflict when neither expense has attendees', () => { + const result = merge([], []); + + expect(result.conflictFields).not.toContain('attendees'); + }); + + it('should keep the attendee when only the source expense has one', () => { + const result = merge([], [submitter]); + + expect(result.conflictFields).not.toContain('attendees'); + expect(result.mergeableData).toMatchObject({ + attendees: [submitter], + }); + }); + + it('should keep the attendee when only the target expense has one', () => { + const result = merge([submitter], []); + + expect(result.conflictFields).not.toContain('attendees'); + expect(result.mergeableData).toMatchObject({ + attendees: [submitter], + }); + }); + + it('should not conflict when both expenses have the same single attendee', () => { + const result = merge([submitter], [submitter]); + + expect(result.conflictFields).not.toContain('attendees'); + }); + + it('should conflict when the expenses have different single attendees', () => { + const result = merge([alice], [bob]); + + expect(result.conflictFields).toContain('attendees'); + }); + + it('should not conflict when both expenses have the same two attendees', () => { + const result = merge([alice, bob], [bob, alice]); + + expect(result.conflictFields).not.toContain('attendees'); + }); + + it('should conflict when one expense has two attendees and the other has a different one', () => { + const result = merge([alice, bob], [submitter]); + + expect(result.conflictFields).toContain('attendees'); + }); + }); + it('should automatically merge login-only attendees', () => { const targetTransaction = { ...createRandomTransaction(0), diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts index b3322fba72c2..a2a70babbfd9 100644 --- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts +++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts @@ -1026,17 +1026,6 @@ describe('useSelectedTransactionsActions', () => { mergeOption?.onSelected?.(); - expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith( - transaction.transactionID, - [transaction], - mockLocalCompare, - mockGetCurrencyDecimals, - [], - false, - false, - undefined, - CURRENT_USER_ACCOUNT_ID, - undefined, - ); + expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transaction.transactionID, [transaction], mockLocalCompare, mockGetCurrencyDecimals, [], false, false, undefined); }); });