Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 1 addition & 13 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
}
Expand Down
2 changes: 0 additions & 2 deletions src/hooks/useSelectedTransactionsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,6 @@ function useSelectedTransactionsActions({
false,
isOnSearch,
selectedTransactionsList.length > 1 ? [policy, policy] : undefined,
currentUserAccountID,
personalDetails,
),
});
}
Expand Down
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11404,6 +11404,7 @@ ${reportName}`,
},
systemMessage: {
mergedWithCashTransaction: 'έγινε αντιστοίχιση μιας απόδειξης με αυτή τη συναλλαγή',
mergedExpenseDeleted: 'συγχώνευσε έξοδα και μετέφερε ένα στα Διαγραμμένα',
},
subscription: {
authenticatePaymentCard: 'Πιστοποιήστε την κάρτα πληρωμής',
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10981,6 +10981,7 @@ ${reportName}`,
},
systemMessage: {
mergedWithCashTransaction: 'この取引にレシートを照合しました',
mergedExpenseDeleted: '経費を統合し、1件を削除済みに移動しました',
},
subscription: {
authenticatePaymentCard: '支払カードを認証',
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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ą',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10693,6 +10693,7 @@ ${reportName}`,
},
systemMessage: {
mergedWithCashTransaction: '已将一张收据匹配到此交易',
mergedExpenseDeleted: '已合并报销并将其中一笔移至已删除',
},
subscription: {
authenticatePaymentCard: '验证支付卡',
Expand Down
14 changes: 6 additions & 8 deletions src/libs/MergeTransactionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,19 +232,16 @@ function getMergeableDataAndConflictFields(
searchReports: Array<OnyxEntry<Report>> = [],
targetTransactionPolicy?: OnyxEntry<Policy>,
sourceTransactionPolicy?: OnyxEntry<Policy>,
targetReportOwnerAsAttendee?: Attendee,
sourceReportOwnerAsAttendee?: Attendee,
) {
const conflictFields: string[] = [];
const mergeableData: Record<string, unknown> = {};
// 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);
Expand Down Expand Up @@ -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);
Expand Down
94 changes: 50 additions & 44 deletions src/libs/actions/MergeTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<Transaction>,
currentUserAccountID: number | undefined,
personalDetails: OnyxEntry<PersonalDetailsList>,
): Attendee | undefined {
const ownerAccountID = getReportOwnerAccountIDAsAttendee(transaction, currentUserAccountID);
return getReportOwnerAsAttendee(ownerAccountID ? (personalDetails?.[ownerAccountID] ?? undefined) : undefined);
}

/**
* Setup merge transaction data for merging flow
*/
Expand Down Expand Up @@ -119,8 +91,6 @@ function setupMergeTransactionDataAndNavigate(
isSelectingSourceTransaction?: boolean,
isOnSearch?: boolean,
policies?: Array<OnyxEntry<Policy>>,
currentUserAccountID?: number,
personalDetails?: OnyxEntry<PersonalDetailsList>,
) {
if (!transactions.length || transactions.length > 2) {
return;
Expand Down Expand Up @@ -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
Expand All @@ -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<Transaction>, rules: OnyxCollection<Rule>, isAdmin = false) {
function getTransactionsForMergingLocally(
transactionID: string,
targetTransaction: Transaction,
transactions: OnyxCollection<Transaction>,
rules: OnyxCollection<Rule>,
allPolicies: OnyxCollection<Policy>,
currentUserLogin: string | undefined,
allReports?: OnyxCollection<Report>,
) {
const transactionsArray = Object.values(transactions ?? {});

const eligibleTransactions = transactionsArray.filter((transaction): transaction is Transaction => {
Expand All @@ -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)
);
});

Expand All @@ -228,6 +227,8 @@ function getTransactionsForMerging({
report,
currentUserLogin,
rules,
allPolicies,
allReports,
}: {
isOffline: boolean;
targetTransaction: Transaction;
Expand All @@ -238,22 +239,27 @@ function getTransactionsForMerging({
currentUserLogin: string | undefined;
cardList?: CardList;
rules: OnyxCollection<Rule>;
allPolicies?: OnyxCollection<Policy>;
allReports?: OnyxCollection<Report>;
}) {
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;
Expand All @@ -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);
}
Expand Down
4 changes: 0 additions & 4 deletions src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,6 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) {
[targetTransactionReport, sourceTransactionReport],
targetTransactionPolicy,
sourceTransactionPolicy,
targetReportOwnerAsAttendee,
sourceReportOwnerAsAttendee,
);

setMergeTransactionKey(transactionID, mergeableData);
Expand All @@ -98,8 +96,6 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) {
targetTransactionPolicy,
sourceTransactionPolicy,
getCurrencyDecimals,
targetReportOwnerAsAttendee,
sourceReportOwnerAsAttendee,
]);

// Handle selection
Expand Down
5 changes: 0 additions & 5 deletions src/pages/TransactionMerge/DynamicReceiptReviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand All @@ -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
Expand Down
Loading
Loading