From 6c0a48f1c1d891d82341161b501c1a9ec303e932 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 14 Apr 2026 14:48:05 +0700 Subject: [PATCH 1/8] refactor: extract bulk edit functions into IOU/BulkEdit.ts and add report assignment to ReportWorkflow.ts Move removeUnchangedBulkEditFields, updateMultipleMoneyRequests, initBulkEditDraftTransaction, clearBulkEditDraftTransaction, updateBulkEditDraftTransaction into new BulkEdit.ts module. Move assignReportToMe and addReportApprover into existing ReportWorkflow.ts as they are report workflow operations. Update 15 consumer files and 1 test file with new import paths. Part of #72804 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/useSearchBulkActions.ts | 2 +- src/hooks/useSelectedTransactionsActions.ts | 2 +- src/libs/actions/IOU/BulkEdit.ts | 584 +++++++++++++ src/libs/actions/IOU/ReportWorkflow.ts | 243 +++++- src/libs/actions/IOU/index.ts | 818 +----------------- src/pages/ReportAddApproverPage.tsx | 2 +- src/pages/ReportChangeApproverPage.tsx | 2 +- src/pages/Search/SearchAddApproverPage.tsx | 2 +- src/pages/Search/SearchChangeApproverPage.tsx | 2 +- .../SearchEditMultipleAmountPage.tsx | 2 +- .../SearchEditMultipleBooleanPage.tsx | 2 +- .../SearchEditMultipleCategoryPage.tsx | 2 +- .../SearchEditMultipleDatePage.tsx | 2 +- .../SearchEditMultipleDescriptionPage.tsx | 2 +- .../SearchEditMultipleMerchantPage.tsx | 2 +- .../SearchEditMultiplePage.tsx | 2 +- .../SearchEditMultipleTagPage.tsx | 2 +- .../SearchEditMultipleTaxPage.tsx | 2 +- tests/actions/IOUTest.ts | 5 +- 19 files changed, 858 insertions(+), 822 deletions(-) create mode 100644 src/libs/actions/IOU/BulkEdit.ts diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 136e1fe756bd..7b69bf633f7c 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -56,7 +56,7 @@ import {navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {hasCustomUnitOutOfPolicyViolation, hasTransactionBeenRejected, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest, isScanning} from '@libs/TransactionUtils'; import variables from '@styles/variables'; -import {initBulkEditDraftTransaction} from '@userActions/IOU'; +import {initBulkEditDraftTransaction} from '@userActions/IOU/BulkEdit'; import {dismissRejectUseExplanation} from '@userActions/IOU/RejectMoneyRequest'; import {canIOUBePaid} from '@userActions/IOU/ReportWorkflow'; import CONST from '@src/CONST'; diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 0b4fa4b3a837..06880e1a63a8 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -4,7 +4,7 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; -import {initBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {initBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {exportReportToCSV} from '@libs/actions/Report'; diff --git a/src/libs/actions/IOU/BulkEdit.ts b/src/libs/actions/IOU/BulkEdit.ts new file mode 100644 index 000000000000..c02cdbdf5136 --- /dev/null +++ b/src/libs/actions/IOU/BulkEdit.ts @@ -0,0 +1,584 @@ +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import * as API from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import {convertToBackendAmount, getCurrencyDecimals} from '@libs/CurrencyUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import * as NumberUtils from '@libs/NumberUtils'; +import {hasDependentTags} from '@libs/PolicyUtils'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import type {TransactionDetails} from '@libs/ReportUtils'; +import { + buildOptimisticCreatedReportAction, + buildOptimisticModifiedExpenseReportAction, + canEditFieldOfMoneyRequest, + findSelfDMReportID, + getOutstandingChildRequest, + getParsedComment, + getTransactionDetails, + isExpenseReport, + isInvoiceReport as isInvoiceReportReportUtils, + isSelfDM, + shouldEnableNegative, +} from '@libs/ReportUtils'; +import {calculateTaxAmount, getAmount, getClearedPendingFields, getCurrency, getTaxValue, getUpdatedTransaction, isOnHold} from '@libs/TransactionUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import {createTransactionThreadReport} from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {TransactionChanges} from '@src/types/onyx/Transaction'; +import {getAllTransactionViolations, getCurrentUserEmail, getUpdatedMoneyRequestReportData, getUserAccountID} from '.'; + +function removeUnchangedBulkEditFields( + transactionChanges: TransactionChanges, + transaction: OnyxTypes.Transaction, + baseIOUReport: OnyxEntry | null, + policy: OnyxEntry, +): TransactionChanges { + const iouType = isInvoiceReportReportUtils(baseIOUReport ?? undefined) ? CONST.IOU.TYPE.INVOICE : CONST.IOU.TYPE.SUBMIT; + const allowNegative = shouldEnableNegative(baseIOUReport ?? undefined, policy, iouType); + const currentDetails = getTransactionDetails(transaction, undefined, policy, allowNegative); + if (!currentDetails) { + return transactionChanges; + } + + const changeKeys = Object.keys(transactionChanges) as Array; + if (changeKeys.length === 0) { + return transactionChanges; + } + + let filteredChanges: TransactionChanges = {}; + + for (const field of changeKeys) { + const nextValue = transactionChanges[field]; + const currentValue = currentDetails[field as keyof TransactionDetails]; + + if (nextValue !== currentValue) { + filteredChanges = { + ...filteredChanges, + [field]: nextValue, + }; + } + } + + return filteredChanges; +} + +type UpdateMultipleMoneyRequestsParams = { + transactionIDs: string[]; + changes: TransactionChanges; + policy: OnyxEntry; + reports: OnyxCollection; + transactions: OnyxCollection; + reportActions: OnyxCollection; + policyCategories: OnyxCollection; + policyTags: OnyxCollection; + hash?: number; + allPolicies?: OnyxCollection; + introSelected: OnyxEntry; + betas: OnyxEntry; +}; + +function updateMultipleMoneyRequests({ + transactionIDs, + changes, + policy, + reports, + transactions, + reportActions, + policyCategories, + policyTags, + hash, + allPolicies, + introSelected, + betas, +}: UpdateMultipleMoneyRequestsParams) { + // Track running totals per report so multiple edits in the same report compound correctly. + const optimisticReportsByID: Record = {}; + for (const transactionID of transactionIDs) { + const transaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + continue; + } + + const iouReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`] ?? undefined; + const baseIouReport = iouReport?.reportID ? (optimisticReportsByID[iouReport.reportID] ?? iouReport) : iouReport; + const isFromExpenseReport = isExpenseReport(baseIouReport); + + const transactionReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`] ?? {}; + let reportAction = getIOUActionForTransactionID(Object.values(transactionReportActions), transactionID); + + // Track expenses created via self DM are stored with reportID = UNREPORTED_REPORT_ID ('0') + // because they have never been submitted to a report. As a result, the lookup above returns + // nothing — the IOU action is stored under the self DM report (chat with yourself, unique + // per user) rather than under transaction.reportID. Without this fallback we cannot resolve + // the transaction thread, which means no optimistic MODIFIED_EXPENSE comment would be + // generated during bulk edit for these expenses. + if (!reportAction && (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID)) { + const selfDMReportID = findSelfDMReportID(reports); + if (selfDMReportID) { + const selfDMReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReportID}`] ?? {}; + reportAction = getIOUActionForTransactionID(Object.values(selfDMReportActions), transactionID); + } + } + + let transactionThreadReportID = transaction.transactionThreadReportID ?? reportAction?.childReportID; + let transactionThread = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + + // Offline-created expenses can be missing a transaction thread until it's opened once. + // Ensure the thread exists before adding optimistic MODIFIED_EXPENSE actions so + // bulk-edit comments are visible immediately while still offline. + let didCreateThreadInThisIteration = false; + if (!transactionThreadReportID && iouReport?.reportID) { + const optimisticTransactionThread = createTransactionThreadReport(introSelected, getCurrentUserEmail(), getUserAccountID(), betas, iouReport, reportAction, transaction); + if (optimisticTransactionThread?.reportID) { + transactionThreadReportID = optimisticTransactionThread.reportID; + transactionThread = optimisticTransactionThread; + didCreateThreadInThisIteration = true; + } + } + + const isUnreportedExpense = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + // Category, tag, tax, and billable only apply to expense/invoice reports and unreported (track) expenses. + // For plain IOU transactions these fields are not applicable and must be silently skipped. + const supportsExpenseFields = isUnreportedExpense || isFromExpenseReport || isInvoiceReportReportUtils(baseIouReport ?? undefined); + // Use the transaction's own policy for all per-transaction checks (permissions, tax, change-diffing). + // Falls back to the shared bulk-edit policy when the transaction's workspace cannot be resolved. + const transactionPolicy = (iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined) ?? policy; + const canEditField = (field: ValueOf) => { + // Unreported (track) expenses have no report, so there is no reportAction to validate against. + // They are never approved or settled, so all bulk-editable fields are allowed. + if (isUnreportedExpense) { + return true; + } + + return canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report: iouReport, policy: transactionPolicy}); + }; + + let transactionChanges: TransactionChanges = {}; + + if (changes.merchant && canEditField(CONST.EDIT_REQUEST_FIELD.MERCHANT)) { + transactionChanges.merchant = changes.merchant; + } + if (changes.created && canEditField(CONST.EDIT_REQUEST_FIELD.DATE)) { + transactionChanges.created = changes.created; + } + if (changes.amount !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.AMOUNT)) { + transactionChanges.amount = changes.amount; + } + if (changes.currency && canEditField(CONST.EDIT_REQUEST_FIELD.CURRENCY)) { + transactionChanges.currency = changes.currency; + } + if (changes.category !== undefined && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.CATEGORY)) { + transactionChanges.category = changes.category; + } + if (changes.tag && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAG)) { + transactionChanges.tag = changes.tag; + } + if (changes.comment && canEditField(CONST.EDIT_REQUEST_FIELD.DESCRIPTION)) { + transactionChanges.comment = getParsedComment(changes.comment); + } + if (changes.taxCode && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) { + transactionChanges.taxCode = changes.taxCode; + const taxValue = getTaxValue(transactionPolicy, transaction, changes.taxCode); + transactionChanges.taxValue = taxValue; + const decimals = getCurrencyDecimals(getCurrency(transaction)); + const effectiveAmount = transactionChanges.amount !== undefined ? Math.abs(transactionChanges.amount) : Math.abs(getAmount(transaction)); + const taxAmount = calculateTaxAmount(taxValue, effectiveAmount, decimals); + transactionChanges.taxAmount = convertToBackendAmount(taxAmount); + } + + if (changes.billable !== undefined && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.BILLABLE)) { + transactionChanges.billable = changes.billable; + } + if (changes.reimbursable !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.REIMBURSABLE)) { + transactionChanges.reimbursable = changes.reimbursable; + } + + transactionChanges = removeUnchangedBulkEditFields(transactionChanges, transaction, baseIouReport, transactionPolicy); + + const updates: Record = {}; + if (transactionChanges.merchant) { + updates.merchant = transactionChanges.merchant; + } + if (transactionChanges.created) { + updates.created = transactionChanges.created; + } + if (transactionChanges.currency) { + updates.currency = transactionChanges.currency; + } + if (transactionChanges.category !== undefined) { + updates.category = transactionChanges.category; + } + if (transactionChanges.tag) { + updates.tag = transactionChanges.tag; + } + if (transactionChanges.comment) { + updates.comment = transactionChanges.comment; + } + if (transactionChanges.taxCode) { + updates.taxCode = transactionChanges.taxCode; + } + if (transactionChanges.taxValue) { + updates.taxValue = transactionChanges.taxValue; + } + if (transactionChanges.taxAmount !== undefined) { + updates.taxAmount = transactionChanges.taxAmount; + } + if (transactionChanges.amount !== undefined) { + updates.amount = transactionChanges.amount; + } + if (transactionChanges.billable !== undefined) { + updates.billable = transactionChanges.billable; + } + if (transactionChanges.reimbursable !== undefined) { + updates.reimbursable = transactionChanges.reimbursable; + } + + // Skip if no updates + if (Object.keys(updates).length === 0) { + continue; + } + + // Generate optimistic report action ID + const modifiedExpenseReportActionID = NumberUtils.rand64(); + + const optimisticData: Array< + OnyxUpdate< + typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + > = []; + const successData: Array< + OnyxUpdate< + typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + > = []; + const failureData: Array< + OnyxUpdate< + typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + > = []; + const snapshotOptimisticData: Array> = []; + const snapshotSuccessData: Array> = []; + const snapshotFailureData: Array> = []; + + // Pending fields for the transaction + const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((field) => [field, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const clearedPendingFields = getClearedPendingFields(transactionChanges); + + const errorFields = Object.fromEntries(Object.keys(pendingFields).map((field) => [field, getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')])); + + // Build updated transaction + const updatedTransaction = getUpdatedTransaction({ + transaction, + transactionChanges, + isFromExpenseReport, + policy: transactionPolicy, + }); + const isTransactionOnHold = isOnHold(transaction); + + // Optimistically update violations so they disappear immediately when the edited field resolves them. + // Skip for unreported expenses: they have no iouReport context so isSelfDM() returns false, + // which would incorrectly trigger policy-required violations (e.g. missingCategory). + let optimisticViolationsData: ReturnType | undefined; + let currentTransactionViolations: OnyxTypes.TransactionViolation[] | undefined; + if (transactionPolicy && !isUnreportedExpense) { + currentTransactionViolations = getAllTransactionViolations()[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; + let optimisticViolations = + transactionChanges.amount !== undefined || transactionChanges.created || transactionChanges.currency + ? currentTransactionViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION) + : currentTransactionViolations; + optimisticViolations = + transactionChanges.category !== undefined && transactionChanges.category === '' + ? optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY) + : optimisticViolations; + const transactionPolicyTagList = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${transactionPolicy?.id}`] ?? {}; + const transactionPolicyCategories = policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${transactionPolicy?.id}`] ?? {}; + optimisticViolationsData = ViolationsUtils.getViolationsOnyxData( + updatedTransaction, + optimisticViolations, + transactionPolicy, + transactionPolicyTagList, + transactionPolicyCategories, + hasDependentTags(transactionPolicy, transactionPolicyTagList), + isInvoiceReportReportUtils(iouReport), + isSelfDM(iouReport), + iouReport, + isFromExpenseReport, + ); + optimisticData.push(optimisticViolationsData); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: currentTransactionViolations, + }); + } + + // Optimistic transaction update + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + pendingFields, + isLoading: false, + errorFields: null, + }, + }); + + // Optimistically update the search snapshot so the search list reflects the + // new values immediately (the snapshot is the exclusive data source for search + // result rendering and is not automatically updated by the TRANSACTION write above). + if (hash) { + snapshotOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` as const, + value: { + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: { + ...updatedTransaction, + pendingFields, + }, + ...(optimisticViolationsData && {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: optimisticViolationsData.value}), + }, + }, + }); + snapshotSuccessData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` as const, + value: { + data: { + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {pendingFields: clearedPendingFields}, + }, + }, + }); + snapshotFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` as const, + value: { + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: { + ...transaction, + pendingFields: clearedPendingFields, + }, + ...(currentTransactionViolations && {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: currentTransactionViolations}), + }, + }, + }); + } + + // To build proper offline update message, we need to include the currency + const optimisticTransactionChanges = + transactionChanges?.amount !== undefined && !transactionChanges?.currency ? {...transactionChanges, currency: getCurrency(transaction)} : transactionChanges; + + // Build optimistic modified expense report action + const optimisticReportAction = buildOptimisticModifiedExpenseReportAction( + transactionThread, + transaction, + optimisticTransactionChanges, + isFromExpenseReport, + transactionPolicy, + updatedTransaction, + ); + + const {updatedMoneyRequestReport, isTotalIndeterminate} = getUpdatedMoneyRequestReportData( + baseIouReport, + updatedTransaction, + transaction, + isTransactionOnHold, + transactionPolicy, + optimisticReportAction?.actorAccountID, + transactionChanges, + ); + + if (updatedMoneyRequestReport) { + if (updatedMoneyRequestReport.reportID) { + optimisticReportsByID[updatedMoneyRequestReport.reportID] = updatedMoneyRequestReport; + } + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {...updatedMoneyRequestReport, ...(isTotalIndeterminate && {pendingFields: {total: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}})}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.parentReportID}`, + value: getOutstandingChildRequest(updatedMoneyRequestReport), + }, + ); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {...iouReport, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, + }); + } + + // Optimistic report action + let backfilledCreatedActionID: string | undefined; + if (transactionThreadReportID) { + // Backfill a CREATED action for threads never opened locally so + // MoneyRequestView renders and the skeleton doesn't loop offline. + // Skip when the thread was just created above (openReport handles it). + const threadReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}; + const hasCreatedAction = didCreateThreadInThisIteration || Object.values(threadReportActions).some((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + const optimisticCreatedValue: Record> = {}; + if (!hasCreatedAction) { + const optimisticCreatedAction = buildOptimisticCreatedReportAction(CONST.REPORT.OWNER_EMAIL_FAKE); + optimisticCreatedAction.pendingAction = null; + backfilledCreatedActionID = optimisticCreatedAction.reportActionID; + optimisticCreatedValue[optimisticCreatedAction.reportActionID] = optimisticCreatedAction; + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + ...optimisticCreatedValue, + [modifiedExpenseReportActionID]: { + ...optimisticReportAction, + reportActionID: modifiedExpenseReportActionID, + }, + }, + }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastReadTime: optimisticReportAction.created, + reportID: transactionThreadReportID, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastReadTime: transactionThread?.lastReadTime, + }, + }); + } + + // Success data - clear pending fields + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + }, + }); + + if (transactionThreadReportID) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportActionID]: {pendingAction: null}, + // Remove the backfilled CREATED action so it doesn't duplicate one from OpenReport + ...(backfilledCreatedActionID ? {[backfilledCreatedActionID]: null} : {}), + }, + }); + } + + // Failure data - revert transaction + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...transaction, + pendingFields: clearedPendingFields, + errorFields, + }, + }); + + // Failure data - remove optimistic report action + if (transactionThreadReportID) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportActionID]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, + ...(backfilledCreatedActionID ? {[backfilledCreatedActionID]: null} : {}), + }, + }); + } + + const params = { + transactionID, + reportActionID: modifiedExpenseReportActionID, + updates: JSON.stringify(updates), + }; + + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST, params, { + optimisticData: [...optimisticData, ...snapshotOptimisticData] as Array< + OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + >, + successData: [...successData, ...snapshotSuccessData] as Array< + OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + >, + failureData: [...failureData, ...snapshotFailureData] as Array< + OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + >, + }); + } +} + +/** + * Initializes the bulk-edit draft transaction under one fixed placeholder ID. + * We keep a single draft in Onyx to store the shared edits for a multi-select, + * then apply those edits to each real transaction later. The placeholder ID is + * just the storage key and never equals any actual transactionID. + */ +function initBulkEditDraftTransaction(selectedTransactionIDs: string[]) { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, { + transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, + selectedTransactionIDs, + }); +} + +/** + * Clears the draft transaction used for bulk editing + */ +function clearBulkEditDraftTransaction() { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, null); +} + +/** + * Updates the draft transaction for bulk editing multiple expenses + */ +function updateBulkEditDraftTransaction(transactionChanges: NullishDeep) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, transactionChanges); +} + +export {removeUnchangedBulkEditFields, updateMultipleMoneyRequests, initBulkEditDraftTransaction, clearBulkEditDraftTransaction, updateBulkEditDraftTransaction}; +export type {UpdateMultipleMoneyRequestsParams}; diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 97765de48d67..2791c95cc074 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -2,7 +2,15 @@ import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; -import type {ApproveMoneyRequestParams, ReopenReportParams, RetractReportParams, SubmitReportParams, UnapproveExpenseReportParams} from '@libs/API/parameters'; +import type { + AddReportApproverParams, + ApproveMoneyRequestParams, + AssignReportToMeParams, + ReopenReportParams, + RetractReportParams, + SubmitReportParams, + UnapproveExpenseReportParams, +} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -14,6 +22,7 @@ import {arePaymentsEnabled, getSubmitToAccountID, hasDynamicExternalWorkflow, is import {getAllReportActions, getReportActionHtml, getReportActionText, hasPendingDEWApprove, isCreatedAction, isDeletedAction} from '@libs/ReportActionsUtils'; import { buildOptimisticApprovedReportAction, + buildOptimisticChangeApproverReportAction, buildOptimisticReopenedReportAction, buildOptimisticRetractedReportAction, buildOptimisticSubmittedReportAction, @@ -62,6 +71,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type ReportAction from '@src/types/onyx/ReportAction'; +import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {getAllReportActionsFromIOU, getAllReportNameValuePairs, getAllTransactionViolations, getCurrentUserEmail, getReportFromHoldRequestsOnyxData, getUserAccountID} from '.'; @@ -1488,8 +1498,239 @@ function submitReport({ API.write(WRITE_COMMANDS.SUBMIT_REPORT, parameters, {optimisticData, successData, failureData}); } +function assignReportToMe( + report: OnyxTypes.Report, + accountID: number, + email: string, + policy: OnyxEntry, + hasViolations: boolean, + isASAPSubmitBetaEnabled: boolean, + reportCurrentNextStepDeprecated: OnyxEntry, +) { + const takeControlReportAction = buildOptimisticChangeApproverReportAction(accountID, accountID); + + // buildOptimisticNextStep is used in parallel + // eslint-disable-next-line @typescript-eslint/no-deprecated + const optimisticNextStepDeprecated = buildNextStepNew({ + report: {...report, managerID: accountID}, + predictedNextStatus: report.statusNum ?? CONST.REPORT.STATUS_NUM.SUBMITTED, + shouldFixViolations: false, + isUnapprove: true, + policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email, + hasViolations, + isASAPSubmitBetaEnabled, + bypassNextApproverID: accountID, + }); + const optimisticNextStep = buildOptimisticNextStep({ + report: {...report, managerID: accountID}, + predictedNextStatus: report.statusNum ?? CONST.REPORT.STATUS_NUM.SUBMITTED, + shouldFixViolations: false, + isUnapprove: true, + policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email, + hasViolations, + isASAPSubmitBetaEnabled, + bypassNextApproverID: accountID, + }); + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: { + managerID: accountID, + nextStep: optimisticNextStep, + pendingFields: { + nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, + value: { + [takeControlReportAction.reportActionID]: takeControlReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${report.reportID}`, + value: optimisticNextStepDeprecated, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: { + pendingFields: { + nextStep: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, + value: { + [takeControlReportAction.reportActionID]: { + pendingAction: null, + isOptimisticAction: null, + errors: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: { + managerID: report.managerID, + nextStep: report.nextStep ?? null, + pendingFields: { + nextStep: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${report?.reportID}`, + value: reportCurrentNextStepDeprecated ?? null, + }, + ], + }; + + const params: AssignReportToMeParams = { + reportID: report.reportID, + reportActionID: takeControlReportAction.reportActionID, + }; + + API.write(WRITE_COMMANDS.ASSIGN_REPORT_TO_ME, params, onyxData); +} + +function addReportApprover( + report: OnyxTypes.Report, + newApproverEmail: string, + newApproverAccountID: number, + accountID: number, + email: string, + policy: OnyxEntry, + hasViolations: boolean, + isASAPSubmitBetaEnabled: boolean, + reportCurrentNextStepDeprecated: OnyxEntry, +) { + const takeControlReportAction = buildOptimisticChangeApproverReportAction(newApproverAccountID, accountID); + + // buildOptimisticNextStep is used in parallel + // eslint-disable-next-line @typescript-eslint/no-deprecated + const optimisticNextStepDeprecated = buildNextStepNew({ + report: {...report, managerID: newApproverAccountID}, + predictedNextStatus: report.statusNum ?? CONST.REPORT.STATUS_NUM.SUBMITTED, + shouldFixViolations: false, + isUnapprove: true, + policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email, + hasViolations, + isASAPSubmitBetaEnabled, + bypassNextApproverID: newApproverAccountID, + }); + const optimisticNextStep = buildOptimisticNextStep({ + report: {...report, managerID: newApproverAccountID}, + predictedNextStatus: report.statusNum ?? CONST.REPORT.STATUS_NUM.SUBMITTED, + shouldFixViolations: false, + isUnapprove: true, + policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email, + hasViolations, + isASAPSubmitBetaEnabled, + bypassNextApproverID: newApproverAccountID, + }); + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: { + managerID: newApproverAccountID, + nextStep: optimisticNextStep, + pendingFields: { + nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, + value: { + [takeControlReportAction.reportActionID]: takeControlReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${report.reportID}`, + value: optimisticNextStepDeprecated, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: { + pendingFields: { + nextStep: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, + value: { + [takeControlReportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: { + managerID: report.managerID, + nextStep: report.nextStep ?? null, + pendingFields: { + nextStep: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${report?.reportID}`, + value: reportCurrentNextStepDeprecated ?? null, + }, + ], + }; + + const params: AddReportApproverParams = { + reportID: report.reportID, + reportActionID: takeControlReportAction.reportActionID, + newApproverEmail, + }; + + API.write(WRITE_COMMANDS.ADD_REPORT_APPROVER, params, onyxData); +} + export { + addReportApprover, approveMoneyRequest, + assignReportToMe, canApproveIOU, canCancelPayment, canIOUBePaid, diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 8488b07248fb..af224148a58a 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -603,6 +603,10 @@ function getUserAccountID(): number { return deprecatedUserAccountID; } +function getCurrentUserPersonalDetails(): OnyxEntry { + return deprecatedCurrentUserPersonalDetails; +} + /** * This function uses Onyx.connect and should be replaced with useOnyx for reactive data access. * TODO: remove `getPolicyTagsData` from this file (https://github.com/Expensify/App/issues/72721) @@ -702,13 +706,6 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: bool * Marks a transaction for highlight on the Search page when the expense was created * from the global create button and the user is not on the Inbox tab. */ -function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | undefined, transactionID: string | undefined, dataType: SearchDataTypes) { - if (!isFromGlobalCreate || isReportTopmostSplitNavigator() || !transactionID) { - return; - } - mergeTransactionIdsHighlightOnSearchRoute(dataType, {[transactionID]: true}); -} - /** * Helper to navigate after an expense is created in order to standardize the post‑creation experience * when creating an expense from the global create button. @@ -716,6 +713,13 @@ function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | * - If it is created on the inbox tab, it will open the chat report containing that expense. * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. */ +function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | undefined, transactionID: string | undefined, dataType: SearchDataTypes) { + if (!isFromGlobalCreate || isReportTopmostSplitNavigator() || !transactionID) { + return; + } + mergeTransactionIdsHighlightOnSearchRoute(dataType, {[transactionID]: true}); +} + function handleNavigateAfterExpenseCreate({ activeReportID, transactionID, @@ -5065,792 +5069,6 @@ function getSearchOnyxUpdate({ } } -function assignReportToMe( - report: OnyxTypes.Report, - accountID: number, - email: string, - policy: OnyxEntry, - hasViolations: boolean, - isASAPSubmitBetaEnabled: boolean, - reportCurrentNextStepDeprecated: OnyxEntry, -) { - const takeControlReportAction = buildOptimisticChangeApproverReportAction(accountID, accountID); - - // buildOptimisticNextStep is used in parallel - // eslint-disable-next-line @typescript-eslint/no-deprecated - const optimisticNextStepDeprecated = buildNextStepNew({ - report: {...report, managerID: accountID}, - predictedNextStatus: report.statusNum ?? CONST.REPORT.STATUS_NUM.SUBMITTED, - shouldFixViolations: false, - isUnapprove: true, - policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email, - hasViolations, - isASAPSubmitBetaEnabled, - bypassNextApproverID: accountID, - }); - const optimisticNextStep = buildOptimisticNextStep({ - report: {...report, managerID: accountID}, - predictedNextStatus: report.statusNum ?? CONST.REPORT.STATUS_NUM.SUBMITTED, - shouldFixViolations: false, - isUnapprove: true, - policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email, - hasViolations, - isASAPSubmitBetaEnabled, - bypassNextApproverID: accountID, - }); - - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, - value: { - managerID: accountID, - nextStep: optimisticNextStep, - pendingFields: { - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - value: { - [takeControlReportAction.reportActionID]: takeControlReportAction, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${report.reportID}`, - value: optimisticNextStepDeprecated, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, - value: { - pendingFields: { - nextStep: null, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - value: { - [takeControlReportAction.reportActionID]: { - pendingAction: null, - isOptimisticAction: null, - errors: null, - }, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, - value: { - managerID: report.managerID, - nextStep: report.nextStep ?? null, - pendingFields: { - nextStep: null, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${report?.reportID}`, - value: reportCurrentNextStepDeprecated ?? null, - }, - ], - }; - - const params: AssignReportToMeParams = { - reportID: report.reportID, - reportActionID: takeControlReportAction.reportActionID, - }; - - API.write(WRITE_COMMANDS.ASSIGN_REPORT_TO_ME, params, onyxData); -} - -function addReportApprover( - report: OnyxTypes.Report, - newApproverEmail: string, - newApproverAccountID: number, - accountID: number, - email: string, - policy: OnyxEntry, - hasViolations: boolean, - isASAPSubmitBetaEnabled: boolean, - reportCurrentNextStepDeprecated: OnyxEntry, -) { - const takeControlReportAction = buildOptimisticChangeApproverReportAction(newApproverAccountID, accountID); - - // buildOptimisticNextStep is used in parallel - // eslint-disable-next-line @typescript-eslint/no-deprecated - const optimisticNextStepDeprecated = buildNextStepNew({ - report: {...report, managerID: newApproverAccountID}, - predictedNextStatus: report.statusNum ?? CONST.REPORT.STATUS_NUM.SUBMITTED, - shouldFixViolations: false, - isUnapprove: true, - policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email, - hasViolations, - isASAPSubmitBetaEnabled, - bypassNextApproverID: newApproverAccountID, - }); - const optimisticNextStep = buildOptimisticNextStep({ - report: {...report, managerID: newApproverAccountID}, - predictedNextStatus: report.statusNum ?? CONST.REPORT.STATUS_NUM.SUBMITTED, - shouldFixViolations: false, - isUnapprove: true, - policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email, - hasViolations, - isASAPSubmitBetaEnabled, - bypassNextApproverID: newApproverAccountID, - }); - const onyxData: OnyxData = { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, - value: { - managerID: newApproverAccountID, - nextStep: optimisticNextStep, - pendingFields: { - nextStep: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - value: { - [takeControlReportAction.reportActionID]: takeControlReportAction, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${report.reportID}`, - value: optimisticNextStepDeprecated, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, - value: { - pendingFields: { - nextStep: null, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - value: { - [takeControlReportAction.reportActionID]: { - pendingAction: null, - errors: null, - }, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, - value: { - managerID: report.managerID, - nextStep: report.nextStep ?? null, - pendingFields: { - nextStep: null, - }, - }, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${report?.reportID}`, - value: reportCurrentNextStepDeprecated ?? null, - }, - ], - }; - - const params: AddReportApproverParams = { - reportID: report.reportID, - reportActionID: takeControlReportAction.reportActionID, - newApproverEmail, - }; - - API.write(WRITE_COMMANDS.ADD_REPORT_APPROVER, params, onyxData); -} - -function removeUnchangedBulkEditFields( - transactionChanges: TransactionChanges, - transaction: OnyxTypes.Transaction, - baseIOUReport: OnyxEntry | null, - policy: OnyxEntry, -): TransactionChanges { - const iouType = isInvoiceReportReportUtils(baseIOUReport ?? undefined) ? CONST.IOU.TYPE.INVOICE : CONST.IOU.TYPE.SUBMIT; - const allowNegative = shouldEnableNegative(baseIOUReport ?? undefined, policy, iouType); - const currentDetails = getTransactionDetails(transaction, undefined, policy, allowNegative); - if (!currentDetails) { - return transactionChanges; - } - - const changeKeys = Object.keys(transactionChanges) as Array; - if (changeKeys.length === 0) { - return transactionChanges; - } - - let filteredChanges: TransactionChanges = {}; - - for (const field of changeKeys) { - const nextValue = transactionChanges[field]; - const currentValue = currentDetails[field as keyof TransactionDetails]; - - if (nextValue !== currentValue) { - filteredChanges = { - ...filteredChanges, - [field]: nextValue, - }; - } - } - - return filteredChanges; -} - -type UpdateMultipleMoneyRequestsParams = { - transactionIDs: string[]; - changes: TransactionChanges; - policy: OnyxEntry; - reports: OnyxCollection; - transactions: OnyxCollection; - reportActions: OnyxCollection; - policyCategories: OnyxCollection; - policyTags: OnyxCollection; - hash?: number; - allPolicies?: OnyxCollection; - introSelected: OnyxEntry; - betas: OnyxEntry; -}; - -function updateMultipleMoneyRequests({ - transactionIDs, - changes, - policy, - reports, - transactions, - reportActions, - policyCategories, - policyTags, - hash, - allPolicies, - introSelected, - betas, -}: UpdateMultipleMoneyRequestsParams) { - // Track running totals per report so multiple edits in the same report compound correctly. - const optimisticReportsByID: Record = {}; - for (const transactionID of transactionIDs) { - const transaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - if (!transaction) { - continue; - } - - const iouReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`] ?? undefined; - const baseIouReport = iouReport?.reportID ? (optimisticReportsByID[iouReport.reportID] ?? iouReport) : iouReport; - const isFromExpenseReport = isExpenseReport(baseIouReport); - - const transactionReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`] ?? {}; - let reportAction = getIOUActionForTransactionID(Object.values(transactionReportActions), transactionID); - - // Track expenses created via self DM are stored with reportID = UNREPORTED_REPORT_ID ('0') - // because they have never been submitted to a report. As a result, the lookup above returns - // nothing — the IOU action is stored under the self DM report (chat with yourself, unique - // per user) rather than under transaction.reportID. Without this fallback we cannot resolve - // the transaction thread, which means no optimistic MODIFIED_EXPENSE comment would be - // generated during bulk edit for these expenses. - if (!reportAction && (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID)) { - const selfDMReportID = findSelfDMReportID(reports); - if (selfDMReportID) { - const selfDMReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReportID}`] ?? {}; - reportAction = getIOUActionForTransactionID(Object.values(selfDMReportActions), transactionID); - } - } - - let transactionThreadReportID = transaction.transactionThreadReportID ?? reportAction?.childReportID; - let transactionThread = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; - - // Offline-created expenses can be missing a transaction thread until it's opened once. - // Ensure the thread exists before adding optimistic MODIFIED_EXPENSE actions so - // bulk-edit comments are visible immediately while still offline. - let didCreateThreadInThisIteration = false; - if (!transactionThreadReportID && iouReport?.reportID) { - const optimisticTransactionThread = createTransactionThreadReport( - introSelected, - deprecatedCurrentUserEmail, - deprecatedUserAccountID, - betas, - iouReport, - reportAction, - transaction, - ); - if (optimisticTransactionThread?.reportID) { - transactionThreadReportID = optimisticTransactionThread.reportID; - transactionThread = optimisticTransactionThread; - didCreateThreadInThisIteration = true; - } - } - - const isUnreportedExpense = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; - // Category, tag, tax, and billable only apply to expense/invoice reports and unreported (track) expenses. - // For plain IOU transactions these fields are not applicable and must be silently skipped. - const supportsExpenseFields = isUnreportedExpense || isFromExpenseReport || isInvoiceReportReportUtils(baseIouReport ?? undefined); - // Use the transaction's own policy for all per-transaction checks (permissions, tax, change-diffing). - // Falls back to the shared bulk-edit policy when the transaction's workspace cannot be resolved. - const transactionPolicy = (iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined) ?? policy; - const canEditField = (field: ValueOf) => { - // Unreported (track) expenses have no report, so there is no reportAction to validate against. - // They are never approved or settled, so all bulk-editable fields are allowed. - if (isUnreportedExpense) { - return true; - } - - return canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report: iouReport, policy: transactionPolicy}); - }; - - let transactionChanges: TransactionChanges = {}; - - if (changes.merchant && canEditField(CONST.EDIT_REQUEST_FIELD.MERCHANT)) { - transactionChanges.merchant = changes.merchant; - } - if (changes.created && canEditField(CONST.EDIT_REQUEST_FIELD.DATE)) { - transactionChanges.created = changes.created; - } - if (changes.amount !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.AMOUNT)) { - transactionChanges.amount = changes.amount; - } - if (changes.currency && canEditField(CONST.EDIT_REQUEST_FIELD.CURRENCY)) { - transactionChanges.currency = changes.currency; - } - if (changes.category !== undefined && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.CATEGORY)) { - transactionChanges.category = changes.category; - } - if (changes.tag && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAG)) { - transactionChanges.tag = changes.tag; - } - if (changes.comment && canEditField(CONST.EDIT_REQUEST_FIELD.DESCRIPTION)) { - transactionChanges.comment = getParsedComment(changes.comment); - } - if (changes.taxCode && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) { - transactionChanges.taxCode = changes.taxCode; - const taxValue = getTaxValue(transactionPolicy, transaction, changes.taxCode); - transactionChanges.taxValue = taxValue; - const decimals = getCurrencyDecimals(getCurrency(transaction)); - const effectiveAmount = transactionChanges.amount !== undefined ? Math.abs(transactionChanges.amount) : Math.abs(getAmount(transaction)); - const taxAmount = calculateTaxAmount(taxValue, effectiveAmount, decimals); - transactionChanges.taxAmount = convertToBackendAmount(taxAmount); - } - - if (changes.billable !== undefined && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.BILLABLE)) { - transactionChanges.billable = changes.billable; - } - if (changes.reimbursable !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.REIMBURSABLE)) { - transactionChanges.reimbursable = changes.reimbursable; - } - - transactionChanges = removeUnchangedBulkEditFields(transactionChanges, transaction, baseIouReport, transactionPolicy); - - const updates: Record = {}; - if (transactionChanges.merchant) { - updates.merchant = transactionChanges.merchant; - } - if (transactionChanges.created) { - updates.created = transactionChanges.created; - } - if (transactionChanges.currency) { - updates.currency = transactionChanges.currency; - } - if (transactionChanges.category !== undefined) { - updates.category = transactionChanges.category; - } - if (transactionChanges.tag) { - updates.tag = transactionChanges.tag; - } - if (transactionChanges.comment) { - updates.comment = transactionChanges.comment; - } - if (transactionChanges.taxCode) { - updates.taxCode = transactionChanges.taxCode; - } - if (transactionChanges.taxValue) { - updates.taxValue = transactionChanges.taxValue; - } - if (transactionChanges.taxAmount !== undefined) { - updates.taxAmount = transactionChanges.taxAmount; - } - if (transactionChanges.amount !== undefined) { - updates.amount = transactionChanges.amount; - } - if (transactionChanges.billable !== undefined) { - updates.billable = transactionChanges.billable; - } - if (transactionChanges.reimbursable !== undefined) { - updates.reimbursable = transactionChanges.reimbursable; - } - - // Skip if no updates - if (Object.keys(updates).length === 0) { - continue; - } - - // Generate optimistic report action ID - const modifiedExpenseReportActionID = NumberUtils.rand64(); - - const optimisticData: Array< - OnyxUpdate< - typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - > - > = []; - const successData: Array< - OnyxUpdate< - typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - > - > = []; - const failureData: Array< - OnyxUpdate< - typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - > - > = []; - const snapshotOptimisticData: Array> = []; - const snapshotSuccessData: Array> = []; - const snapshotFailureData: Array> = []; - - // Pending fields for the transaction - const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((field) => [field, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); - const clearedPendingFields = getClearedPendingFields(transactionChanges); - - const errorFields = Object.fromEntries(Object.keys(pendingFields).map((field) => [field, getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')])); - - // Build updated transaction - const updatedTransaction = getUpdatedTransaction({ - transaction, - transactionChanges, - isFromExpenseReport, - policy: transactionPolicy, - }); - const isTransactionOnHold = isOnHold(transaction); - - // Optimistically update violations so they disappear immediately when the edited field resolves them. - // Skip for unreported expenses: they have no iouReport context so isSelfDM() returns false, - // which would incorrectly trigger policy-required violations (e.g. missingCategory). - let optimisticViolationsData: ReturnType | undefined; - let currentTransactionViolations: OnyxTypes.TransactionViolation[] | undefined; - if (transactionPolicy && !isUnreportedExpense) { - currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; - let optimisticViolations = - transactionChanges.amount !== undefined || transactionChanges.created || transactionChanges.currency - ? currentTransactionViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION) - : currentTransactionViolations; - optimisticViolations = - transactionChanges.category !== undefined && transactionChanges.category === '' - ? optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY) - : optimisticViolations; - const transactionPolicyTagList = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${transactionPolicy?.id}`] ?? {}; - const transactionPolicyCategories = policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${transactionPolicy?.id}`] ?? {}; - optimisticViolationsData = ViolationsUtils.getViolationsOnyxData( - updatedTransaction, - optimisticViolations, - transactionPolicy, - transactionPolicyTagList, - transactionPolicyCategories, - hasDependentTags(transactionPolicy, transactionPolicyTagList), - isInvoiceReportReportUtils(iouReport), - isSelfDM(iouReport), - iouReport, - isFromExpenseReport, - ); - optimisticData.push(optimisticViolationsData); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, - value: currentTransactionViolations, - }); - } - - // Optimistic transaction update - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - ...updatedTransaction, - pendingFields, - isLoading: false, - errorFields: null, - }, - }); - - // Optimistically update the search snapshot so the search list reflects the - // new values immediately (the snapshot is the exclusive data source for search - // result rendering and is not automatically updated by the TRANSACTION write above). - if (hash) { - snapshotOptimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` as const, - value: { - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - data: { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: { - ...updatedTransaction, - pendingFields, - }, - ...(optimisticViolationsData && {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: optimisticViolationsData.value}), - }, - }, - }); - snapshotSuccessData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` as const, - value: { - data: { - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {pendingFields: clearedPendingFields}, - }, - }, - }); - snapshotFailureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` as const, - value: { - // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 - data: { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: { - ...transaction, - pendingFields: clearedPendingFields, - }, - ...(currentTransactionViolations && {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: currentTransactionViolations}), - }, - }, - }); - } - - // To build proper offline update message, we need to include the currency - const optimisticTransactionChanges = - transactionChanges?.amount !== undefined && !transactionChanges?.currency ? {...transactionChanges, currency: getCurrency(transaction)} : transactionChanges; - - // Build optimistic modified expense report action - const optimisticReportAction = buildOptimisticModifiedExpenseReportAction( - transactionThread, - transaction, - optimisticTransactionChanges, - isFromExpenseReport, - transactionPolicy, - updatedTransaction, - ); - - const {updatedMoneyRequestReport, isTotalIndeterminate} = getUpdatedMoneyRequestReportData( - baseIouReport, - updatedTransaction, - transaction, - isTransactionOnHold, - transactionPolicy, - optimisticReportAction?.actorAccountID, - transactionChanges, - ); - - if (updatedMoneyRequestReport) { - if (updatedMoneyRequestReport.reportID) { - optimisticReportsByID[updatedMoneyRequestReport.reportID] = updatedMoneyRequestReport; - } - optimisticData.push( - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {...updatedMoneyRequestReport, ...(isTotalIndeterminate && {pendingFields: {total: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}})}, - }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.parentReportID}`, - value: getOutstandingChildRequest(updatedMoneyRequestReport), - }, - ); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {pendingAction: null, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {...iouReport, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, - }); - } - - // Optimistic report action - let backfilledCreatedActionID: string | undefined; - if (transactionThreadReportID) { - // Backfill a CREATED action for threads never opened locally so - // MoneyRequestView renders and the skeleton doesn't loop offline. - // Skip when the thread was just created above (openReport handles it). - const threadReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}; - const hasCreatedAction = didCreateThreadInThisIteration || Object.values(threadReportActions).some((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const optimisticCreatedValue: Record> = {}; - if (!hasCreatedAction) { - const optimisticCreatedAction = buildOptimisticCreatedReportAction(CONST.REPORT.OWNER_EMAIL_FAKE); - optimisticCreatedAction.pendingAction = null; - backfilledCreatedActionID = optimisticCreatedAction.reportActionID; - optimisticCreatedValue[optimisticCreatedAction.reportActionID] = optimisticCreatedAction; - } - - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: { - ...optimisticCreatedValue, - [modifiedExpenseReportActionID]: { - ...optimisticReportAction, - reportActionID: modifiedExpenseReportActionID, - }, - }, - }); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, - value: { - lastReadTime: optimisticReportAction.created, - reportID: transactionThreadReportID, - }, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, - value: { - lastReadTime: transactionThread?.lastReadTime, - }, - }); - } - - // Success data - clear pending fields - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - pendingFields: clearedPendingFields, - }, - }); - - if (transactionThreadReportID) { - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: { - [modifiedExpenseReportActionID]: {pendingAction: null}, - // Remove the backfilled CREATED action so it doesn't duplicate one from OpenReport - ...(backfilledCreatedActionID ? {[backfilledCreatedActionID]: null} : {}), - }, - }); - } - - // Failure data - revert transaction - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - value: { - ...transaction, - pendingFields: clearedPendingFields, - errorFields, - }, - }); - - // Failure data - remove optimistic report action - if (transactionThreadReportID) { - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, - value: { - [modifiedExpenseReportActionID]: { - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), - }, - ...(backfilledCreatedActionID ? {[backfilledCreatedActionID]: null} : {}), - }, - }); - } - - const params = { - transactionID, - reportActionID: modifiedExpenseReportActionID, - updates: JSON.stringify(updates), - }; - - API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST, params, { - optimisticData: [...optimisticData, ...snapshotOptimisticData] as Array< - OnyxUpdate< - | typeof ONYXKEYS.COLLECTION.TRANSACTION - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.REPORT - | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - > - >, - successData: [...successData, ...snapshotSuccessData] as Array< - OnyxUpdate< - | typeof ONYXKEYS.COLLECTION.TRANSACTION - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.REPORT - | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - > - >, - failureData: [...failureData, ...snapshotFailureData] as Array< - OnyxUpdate< - | typeof ONYXKEYS.COLLECTION.TRANSACTION - | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS - | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.REPORT - | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS - > - >, - }); - } -} - -/** - * Initializes the bulk-edit draft transaction under one fixed placeholder ID. - * We keep a single draft in Onyx to store the shared edits for a multi-select, - * then apply those edits to each real transaction later. The placeholder ID is - * just the storage key and never equals any actual transactionID. - */ -function initBulkEditDraftTransaction(selectedTransactionIDs: string[]) { - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, { - transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, - selectedTransactionIDs, - }); -} - -/** - * Clears the draft transaction used for bulk editing - */ -function clearBulkEditDraftTransaction() { - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, null); -} - -/** - * Updates the draft transaction for bulk editing multiple expenses - */ -function updateBulkEditDraftTransaction(transactionChanges: NullishDeep) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, transactionChanges); -} - export { clearMoneyRequest, createDistanceRequest, @@ -5890,21 +5108,15 @@ export { setMoneyRequestTaxRateValues, startMoneyRequest, updateLastLocationPermissionPrompt, - shouldOptimisticallyUpdateSearch, calculateDiffAmount, setMoneyRequestReimbursable, startDistanceRequest, - assignReportToMe, - addReportApprover, hasOutstandingChildRequest, getReportFromHoldRequestsOnyxData, + getUpdatedMoneyRequestReportData, getUpdateMoneyRequestParams, getUpdateTrackExpenseParams, getReportPreviewAction, - updateMultipleMoneyRequests, - initBulkEditDraftTransaction, - clearBulkEditDraftTransaction, - updateBulkEditDraftTransaction, mergePolicyRecentlyUsedCurrencies, mergePolicyRecentlyUsedCategories, getAllPersonalDetails, @@ -5915,20 +5127,22 @@ export { getAllReportNameValuePairs, getAllTransactionDrafts, getCurrentUserEmail, + getCurrentUserPersonalDetails, getUserAccountID, getReceiptError, - getSearchOnyxUpdate, getPolicyTags, setMoneyRequestTimeRate, setMoneyRequestTimeCount, handleNavigateAfterExpenseCreate, - highlightTransactionOnSearchRouteIfNeeded, buildMinimalTransactionForFormula, buildOnyxDataForMoneyRequest, createSplitsAndOnyxData, getMoneyRequestInformation, getOrCreateOptimisticSplitChatReport, getTransactionWithPreservedLocalReceiptSource, + shouldOptimisticallyUpdateSearch, + getSearchOnyxUpdate, + highlightTransactionOnSearchRouteIfNeeded, }; export type { GPSPoint as GpsPoint, diff --git a/src/pages/ReportAddApproverPage.tsx b/src/pages/ReportAddApproverPage.tsx index c7c75fc1636f..18b5085e1bfb 100644 --- a/src/pages/ReportAddApproverPage.tsx +++ b/src/pages/ReportAddApproverPage.tsx @@ -10,7 +10,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import {addReportApprover} from '@libs/actions/IOU'; +import {addReportApprover} from '@libs/actions/IOU/ReportWorkflow'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportChangeApproverParamList} from '@libs/Navigation/types'; diff --git a/src/pages/ReportChangeApproverPage.tsx b/src/pages/ReportChangeApproverPage.tsx index 45fc3bdbf368..843a888eacd2 100644 --- a/src/pages/ReportChangeApproverPage.tsx +++ b/src/pages/ReportChangeApproverPage.tsx @@ -14,7 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import {assignReportToMe} from '@libs/actions/IOU'; +import {assignReportToMe} from '@libs/actions/IOU/ReportWorkflow'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportChangeApproverParamList} from '@libs/Navigation/types'; diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index f7c0f663883b..a6d63201e227 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -14,7 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import {addReportApprover} from '@libs/actions/IOU'; +import {addReportApprover} from '@libs/actions/IOU/ReportWorkflow'; import Navigation from '@libs/Navigation/Navigation'; import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; import {getDisplayNameForParticipant, hasViolations as hasViolationsReportUtils, isAllowedToApproveExpenseReport} from '@libs/ReportUtils'; diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index cf7d2557441a..53114d6f7309 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -19,7 +19,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import {assignReportToMe} from '@libs/actions/IOU'; +import {assignReportToMe} from '@libs/actions/IOU/ReportWorkflow'; import {openBulkChangeApproverPage} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx index 99d0ff3c5ef7..4601b679eeb5 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx @@ -7,7 +7,7 @@ import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputF import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isInvoiceReport, isIOUReport, shouldEnableNegative} from '@libs/ReportUtils'; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx index f467fdf001c5..16f4aa33b51c 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx @@ -9,7 +9,7 @@ import type {ListItem} from '@components/SelectionList/ListItem/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx index c5c5ae732d1d..c82e1596e507 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx @@ -6,7 +6,7 @@ import type {ListItem} from '@components/SelectionList/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchBulkEditPolicyID from '@hooks/useSearchBulkEditPolicyID'; -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx index d0188800f5e5..44f2d074c549 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx @@ -9,7 +9,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import Navigation from '@libs/Navigation/Navigation'; import {isValidDate} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx index d23491a63c28..2368a17e4785 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx @@ -10,7 +10,7 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import {addErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx index b60ce401bc53..1dc593ba8b65 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx @@ -10,7 +10,7 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import Navigation from '@libs/Navigation/Navigation'; import {isInvalidMerchantValue, isValidInputLength} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx index 7da8f8868a73..aebc34c9b778 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -11,7 +11,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU'; +import {clearBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU/BulkEdit'; import {convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx index b0ded90f6a0d..c786c9253e87 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx @@ -6,7 +6,7 @@ import TagPicker from '@components/TagPicker'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchBulkEditPolicyID from '@hooks/useSearchBulkEditPolicyID'; -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import Navigation from '@libs/Navigation/Navigation'; import {getTagList, hasDependentTags as hasDependentTagsPolicyUtils} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage.tsx index c41c2543171e..06a00bf84278 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage.tsx @@ -7,7 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchBulkEditPolicyID from '@hooks/useSearchBulkEditPolicyID'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import Navigation from '@libs/Navigation/Navigation'; import type {TaxRatesOption} from '@libs/TaxOptionsListUtils'; import {transformedTaxRates} from '@libs/TransactionUtils'; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 8e119659357f..93d1a4353584 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -10,10 +10,8 @@ import useOnyx from '@hooks/useOnyx'; import {clearAllRelatedReportActionErrors} from '@libs/actions/ClearReportActionErrors'; import { calculateDiffAmount, - clearBulkEditDraftTransaction, createDistanceRequest, handleNavigateAfterExpenseCreate, - initBulkEditDraftTransaction, initMoneyRequest, removeMoneyRequestOdometerImage, resetDraftTransactionsCustomUnit, @@ -28,9 +26,8 @@ import { setMoneyRequestOdometerImage, setMoneyRequestTag, shouldOptimisticallyUpdateSearch, - updateBulkEditDraftTransaction, - updateMultipleMoneyRequests, } from '@libs/actions/IOU'; +import {clearBulkEditDraftTransaction, initBulkEditDraftTransaction, updateBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU/BulkEdit'; import {putOnHold} from '@libs/actions/IOU/Hold'; import {completeSplitBill, splitBill, startSplitBill, updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/Split'; import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; From da0e275375c37cf9fc24d0e138815c2de5306485 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 14 Apr 2026 15:07:27 +0700 Subject: [PATCH 2/8] fix: remove unused imports from IOU/index.ts after bulk edit extraction Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/actions/IOU/index.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index af224148a58a..9ffa91d6c9c7 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -10,9 +10,8 @@ import type {ValueOf} from 'type-fest'; import ReceiptGeneric from '@assets/images/receipt-generic.png'; import type {SearchQueryJSON} from '@components/Search/types'; import * as API from '@libs/API'; -import type {AddReportApproverParams, AssignReportToMeParams, CreateDistanceRequestParams, UpdateMoneyRequestParams} from '@libs/API/parameters'; +import type {CreateDistanceRequestParams, UpdateMoneyRequestParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; -import {convertToBackendAmount, getCurrencyDecimals} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {registerDeferredWrite} from '@libs/deferredLayoutWrite'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -41,7 +40,6 @@ import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getDistanceRateCustomUnit, hasDependentTags, isPaidGroupPolicy} from '@libs/PolicyUtils'; import { getAllReportActions, - getIOUActionForTransactionID, getOriginalMessage, getReportActionHtml, getReportActionText, @@ -51,7 +49,6 @@ import { import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; import { buildOptimisticAddCommentReportAction, - buildOptimisticChangeApproverReportAction, buildOptimisticChatReport, buildOptimisticCreatedReportAction, buildOptimisticCreatedReportForUnapprovedAction, @@ -61,8 +58,6 @@ import { buildOptimisticModifiedExpenseReportAction, buildOptimisticMoneyRequestEntities, buildOptimisticReportPreview, - canEditFieldOfMoneyRequest, - findSelfDMReportID, generateReportID, getChatByParticipants, getOutstandingChildRequest, @@ -99,13 +94,11 @@ import {getSpan, startSpan} from '@libs/telemetry/activeSpans'; import {endSubmitFollowUpActionSpan, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; import { buildOptimisticTransaction, - calculateTaxAmount, getAmount, getCategoryTaxCodeAndAmount, getClearedPendingFields, getCurrency, getDistanceInMeters, - getTaxValue, getUpdatedTransaction, isDistanceRequest as isDistanceRequestTransactionUtils, isFetchingWaypointsFromServer, @@ -119,7 +112,7 @@ import { } from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; -import {createTransactionThreadReport, notifyNewAction} from '@userActions/Report'; +import {notifyNewAction} from '@userActions/Report'; import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeWaypointsForAPI, stringifyWaypointsForAPI} from '@userActions/Transaction'; import {getRemoveDraftTransactionsByIDsData, removeDraftTransaction, removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; import type {IOUAction, IOUActionParams, OdometerImageType} from '@src/CONST'; From bae5f331af790c17abd997cb4ecc40c74d4aa893 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 14 Apr 2026 15:26:59 +0700 Subject: [PATCH 3/8] fix: run prettier on IOU/index.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/actions/IOU/index.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 9ffa91d6c9c7..efe16c4261d2 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -38,14 +38,7 @@ import {getManagerMcTestParticipant} from '@libs/OptionsListUtils'; import {getCustomUnitID} from '@libs/PerDiemRequestUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getDistanceRateCustomUnit, hasDependentTags, isPaidGroupPolicy} from '@libs/PolicyUtils'; -import { - getAllReportActions, - getOriginalMessage, - getReportActionHtml, - getReportActionText, - isMoneyRequestAction, - isReportPreviewAction, -} from '@libs/ReportActionsUtils'; +import {getAllReportActions, getOriginalMessage, getReportActionHtml, getReportActionText, isMoneyRequestAction, isReportPreviewAction} from '@libs/ReportActionsUtils'; import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; import { buildOptimisticAddCommentReportAction, From dd2fe86d9f3ec97a772d8f3b17e69dfaf6b67bd7 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 14 Apr 2026 16:18:34 +0700 Subject: [PATCH 4/8] fix: restore correct JSDoc comment for highlightTransactionOnSearchRouteIfNeeded Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/actions/IOU/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index efe16c4261d2..1ccb98555dae 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -693,11 +693,8 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: bool * from the global create button and the user is not on the Inbox tab. */ /** - * Helper to navigate after an expense is created in order to standardize the post‑creation experience - * when creating an expense from the global create button. - * If the expense is created from the global create button then: - * - If it is created on the inbox tab, it will open the chat report containing that expense. - * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. + * Marks a transaction for highlight on the Search page when the expense was created + * from the global create button and the user is not on the Inbox tab. */ function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | undefined, transactionID: string | undefined, dataType: SearchDataTypes) { if (!isFromGlobalCreate || isReportTopmostSplitNavigator() || !transactionID) { @@ -706,6 +703,13 @@ function highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate: boolean | mergeTransactionIdsHighlightOnSearchRoute(dataType, {[transactionID]: true}); } +/** + * Helper to navigate after an expense is created in order to standardize the post‑creation experience + * when creating an expense from the global create button. + * If the expense is created from the global create button then: + * - If it is created on the inbox tab, it will open the chat report containing that expense. + * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. + */ function handleNavigateAfterExpenseCreate({ activeReportID, transactionID, From 6df7c9a39644764eeba31b3208e85d57d9bb45f5 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 14 Apr 2026 16:21:47 +0700 Subject: [PATCH 5/8] fix: move bulk edit tests to BulkEditTest.ts, fix duplicate comment Move updateMultipleMoneyRequests and bulk edit draft transaction test blocks from IOUTest.ts to tests/actions/IOUTest/BulkEditTest.ts. Remove duplicate JSDoc comment on highlightTransactionOnSearchRouteIfNeeded. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/actions/IOU/index.ts | 4 - tests/actions/IOUTest.ts | 1274 ------------------------ tests/actions/IOUTest/BulkEditTest.ts | 1291 +++++++++++++++++++++++++ 3 files changed, 1291 insertions(+), 1278 deletions(-) create mode 100644 tests/actions/IOUTest/BulkEditTest.ts diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 1ccb98555dae..2618b07d261f 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -688,10 +688,6 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: bool } } -/** - * Marks a transaction for highlight on the Search page when the expense was created - * from the global create button and the user is not on the Inbox tab. - */ /** * Marks a transaction for highlight on the Search page when the expense was created * from the global create button and the user is not on the Inbox tab. diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 93d1a4353584..6ea0402d00b5 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -27,7 +27,6 @@ import { setMoneyRequestTag, shouldOptimisticallyUpdateSearch, } from '@libs/actions/IOU'; -import {clearBulkEditDraftTransaction, initBulkEditDraftTransaction, updateBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU/BulkEdit'; import {putOnHold} from '@libs/actions/IOU/Hold'; import {completeSplitBill, splitBill, startSplitBill, updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/Split'; import {requestMoney, trackExpense} from '@libs/actions/IOU/TrackExpense'; @@ -6066,1279 +6065,6 @@ describe('actions/IOU', () => { }); }); - describe('updateMultipleMoneyRequests', () => { - it('applies expense report sign to amount updates', () => { - const transactionID = 'transaction-1'; - const transactionThreadReportID = 'thread-1'; - const iouReportID = 'iou-1'; - const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); - - const transactionThread: Report = { - ...createRandomReport(1, undefined), - reportID: transactionThreadReportID, - parentReportID: iouReportID, - policyID: policy.id, - }; - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - const buildOptimisticSpy = jest.spyOn(require('@libs/ReportUtils'), 'buildOptimisticModifiedExpenseReportAction'); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {amount: 1000}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - - const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; - const updates = JSON.parse(params.updates) as {amount: number}; - expect(updates.amount).toBe(1000); - expect(buildOptimisticSpy).toHaveBeenCalledWith( - transactionThread, - transaction, - expect.objectContaining({amount: 1000, currency: CONST.CURRENCY.USD}), - true, - policy, - expect.anything(), - ); - - writeSpy.mockRestore(); - buildOptimisticSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - - it('skips updates when bulk edit value matches the current transaction field', () => { - const transactionID = 'transaction-1'; - const transactionThreadReportID = 'thread-1'; - const iouReportID = 'iou-1'; - const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); - - const transactionThread: Report = { - ...createRandomReport(1, undefined), - reportID: transactionThreadReportID, - parentReportID: iouReportID, - policyID: policy.id, - }; - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: -1000, - currency: CONST.CURRENCY.USD, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {amount: 1000}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - - expect(writeSpy).not.toHaveBeenCalled(); - - writeSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - - it('updates report totals across multiple transactions in the same report', () => { - const firstTransactionID = 'transaction-4'; - const secondTransactionID = 'transaction-5'; - const iouReportID = 'iou-4'; - const policy = createRandomPolicy(4, CONST.POLICY.TYPE.TEAM); - - const iouReport: Report = { - ...createRandomReport(4, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - total: -2600, - currency: CONST.CURRENCY.USD, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`]: { - ...createRandomTransaction(4), - transactionID: firstTransactionID, - reportID: iouReportID, - amount: -1300, - currency: CONST.CURRENCY.USD, - }, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${secondTransactionID}`]: { - ...createRandomTransaction(5), - transactionID: secondTransactionID, - reportID: iouReportID, - amount: -1300, - currency: CONST.CURRENCY.USD, - }, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - updateMultipleMoneyRequests({ - transactionIDs: [firstTransactionID, secondTransactionID], - changes: {amount: 1000}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - - const getOptimisticTotal = (callIndex: number) => { - const onyxData = writeSpy.mock.calls.at(callIndex)?.[2] as {optimisticData: Array<{key: string; value?: {total?: number}}>}; - const reportUpdate = onyxData.optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`); - return reportUpdate?.value?.total; - }; - - expect(getOptimisticTotal(0)).toBe(-2300); - expect(getOptimisticTotal(1)).toBe(-2000); - - writeSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - - it('supports negative amount updates for expense reports', () => { - const transactionID = 'transaction-3'; - const transactionThreadReportID = 'thread-3'; - const iouReportID = 'iou-3'; - const policy = createRandomPolicy(3, CONST.POLICY.TYPE.TEAM); - - const transactionThread: Report = { - ...createRandomReport(3, undefined), - reportID: transactionThreadReportID, - parentReportID: iouReportID, - policyID: policy.id, - }; - const iouReport: Report = { - ...createRandomReport(4, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(3), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - // Expense reports store amounts with the opposite sign of what the UI displays. - amount: -1000, - currency: CONST.CURRENCY.USD, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - const buildOptimisticSpy = jest.spyOn(require('@libs/ReportUtils'), 'buildOptimisticModifiedExpenseReportAction'); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {amount: -1000}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - - const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; - const updates = JSON.parse(params.updates) as {amount: number}; - expect(updates.amount).toBe(-1000); - expect(buildOptimisticSpy).toHaveBeenCalledWith( - transactionThread, - transaction, - expect.objectContaining({amount: -1000, currency: CONST.CURRENCY.USD}), - true, - policy, - expect.anything(), - ); - - writeSpy.mockRestore(); - buildOptimisticSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - - it('sends billable and reimbursable updates for bulk edit', () => { - const transactionID = 'transaction-7'; - const transactionThreadReportID = 'thread-7'; - const iouReportID = 'iou-7'; - const policy = createRandomPolicy(7, CONST.POLICY.TYPE.TEAM); - - const transactionThread: Report = { - ...createRandomReport(7, undefined), - reportID: transactionThreadReportID, - parentReportID: iouReportID, - policyID: policy.id, - }; - const iouReport: Report = { - ...createRandomReport(8, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(7), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: 1000, - billable: false, - reimbursable: true, - currency: CONST.CURRENCY.USD, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {billable: true, reimbursable: false}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - - const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; - const updates = JSON.parse(params.updates) as {billable: boolean; reimbursable: boolean}; - expect(updates.billable).toBe(true); - expect(updates.reimbursable).toBe(false); - - writeSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - - it('keeps invoice amount updates positive', () => { - const transactionID = 'transaction-2'; - const transactionThreadReportID = 'thread-2'; - const iouReportID = 'iou-2'; - const policy = createRandomPolicy(2, CONST.POLICY.TYPE.TEAM); - - const transactionThread: Report = { - ...createRandomReport(3, undefined), - reportID: transactionThreadReportID, - parentReportID: iouReportID, - policyID: policy.id, - }; - const iouReport: Report = { - ...createRandomReport(4, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.INVOICE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(2), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - const buildOptimisticSpy = jest.spyOn(require('@libs/ReportUtils'), 'buildOptimisticModifiedExpenseReportAction'); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {amount: 1000}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - - const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; - const updates = JSON.parse(params.updates) as {amount: number}; - expect(updates.amount).toBe(1000); - expect(buildOptimisticSpy).toHaveBeenCalledWith( - transactionThread, - transaction, - expect.objectContaining({amount: 1000, currency: CONST.CURRENCY.USD}), - false, - policy, - expect.anything(), - ); - - writeSpy.mockRestore(); - buildOptimisticSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - - it('saves changes for unreported (track) expenses without a reportAction', () => { - const transactionID = 'transaction-unreported'; - const transactionThreadReportID = 'thread-unreported'; - const policy = createRandomPolicy(10, CONST.POLICY.TYPE.TEAM); - - const transactionThread: Report = { - ...createRandomReport(10, undefined), - reportID: transactionThreadReportID, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, - }; - - const transaction: Transaction = { - ...createRandomTransaction(10), - transactionID, - reportID: CONST.REPORT.UNREPORTED_REPORT_ID, - transactionThreadReportID, - amount: 500, - currency: CONST.CURRENCY.USD, - merchant: 'Old merchant', - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - // No canEditFieldOfMoneyRequest mock — unreported expenses must bypass that check - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {merchant: 'New merchant'}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - - expect(writeSpy).toHaveBeenCalled(); - const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; - const updates = JSON.parse(params.updates) as {merchant: string}; - expect(updates.merchant).toBe('New merchant'); - - writeSpy.mockRestore(); - }); - - it('does not add violations for unreported expenses during bulk edit', async () => { - const transactionID = 'transaction-unreported-viol'; - const transactionThreadReportID = 'thread-unreported-viol'; - const policy = {...createRandomPolicy(10, CONST.POLICY.TYPE.TEAM), requiresCategory: true, requiresTag: true}; - - const transactionThread: Report = { - ...createRandomReport(10, undefined), - reportID: transactionThreadReportID, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, - }; - - const transaction: Transaction = { - ...createRandomTransaction(10), - transactionID, - reportID: CONST.REPORT.UNREPORTED_REPORT_ID, - transactionThreadReportID, - amount: 500, - currency: CONST.CURRENCY.USD, - category: undefined, - tag: undefined, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); - await waitForBatchedUpdates(); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {amount: 1000}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: { - [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`]: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Food: {name: 'Food', enabled: true, 'GL Code': '', unencodedName: 'Food', externalID: '', areCommentsRequired: false, origin: ''}, - }, - }, - policyTags: { - [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy.id}`]: { - Department: { - name: 'Department', - required: true, - orderWeight: 0, - tags: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Engineering: {name: 'Engineering', enabled: true}, - }, - }, - }, - }, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - await waitForBatchedUpdates(); - - // Unreported expenses should not get any violations (missingCategory, missingTag, etc.) - const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - expect(updatedViolations ?? []).toHaveLength(0); - }); - - it('removes DUPLICATED_TRANSACTION violation optimistically when amount is changed', async () => { - const transactionID = 'transaction-1'; - const transactionThreadReportID = 'thread-1'; - const iouReportID = 'iou-1'; - const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [{name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, type: CONST.VIOLATION_TYPES.VIOLATION}]); - await waitForBatchedUpdates(); - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {amount: 2000}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - await waitForBatchedUpdates(); - - const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - const violationNames = updatedViolations?.map((v) => v.name) ?? []; - expect(violationNames).not.toContain(CONST.VIOLATIONS.DUPLICATED_TRANSACTION); - - canEditFieldSpy.mockRestore(); - }); - - it('removes CATEGORY_OUT_OF_POLICY violation optimistically when category is cleared', async () => { - const transactionID = 'transaction-1'; - const transactionThreadReportID = 'thread-1'; - const iouReportID = 'iou-1'; - const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - category: 'OldCategory', - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [{name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION}]); - await waitForBatchedUpdates(); - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {category: ''}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - await waitForBatchedUpdates(); - - const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - const violationNames = updatedViolations?.map((v) => v.name) ?? []; - expect(violationNames).not.toContain(CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY); - - canEditFieldSpy.mockRestore(); - }); - - it('clears category-out-of-policy violation when the new category is valid', async () => { - const transactionID = 'transaction-1'; - const transactionThreadReportID = 'thread-1'; - const iouReportID = 'iou-1'; - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), requiresCategory: true}; - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - category: 'InvalidCategory', - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - const policyCategories = { - // eslint-disable-next-line @typescript-eslint/naming-convention - Food: {name: 'Food', enabled: true, 'GL Code': '', unencodedName: 'Food', externalID: '', areCommentsRequired: false, origin: ''}, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [{name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION}]); - await waitForBatchedUpdates(); - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {category: 'Food'}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: { - [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`]: policyCategories, - }, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - await waitForBatchedUpdates(); - - const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - const violationNames = updatedViolations?.map((v) => v.name) ?? []; - expect(violationNames).not.toContain(CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY); - - canEditFieldSpy.mockRestore(); - }); - - it('uses the transaction own policy for violation checks in cross-policy bulk edits', async () => { - const transactionID = 'transaction-1'; - const transactionThreadReportID = 'thread-1'; - const bulkEditPolicyID = '1'; - const transactionPolicyID = '2'; - - // bulkEditPolicy requires categories — would add missingCategory if used for the transaction - const bulkEditPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: bulkEditPolicyID, requiresCategory: true}; - // transactionPolicy does NOT require categories — correct policy for this transaction - const transactionPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: transactionPolicyID, requiresCategory: false}; - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: 'iou-1', - policyID: transactionPolicyID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}iou-1`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: 'iou-1', - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - category: undefined, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); - await waitForBatchedUpdates(); - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {merchant: 'New Merchant'}, - policy: bulkEditPolicy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - allPolicies: { - [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicyID}`]: transactionPolicy, - }, - introSelected: undefined, - betas: undefined, - }); - await waitForBatchedUpdates(); - - // transactionPolicy does not require categories, so no missingCategory violation should be added - const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - const violationNames = updatedViolations?.map((v) => v.name) ?? []; - expect(violationNames).not.toContain(CONST.VIOLATIONS.MISSING_CATEGORY); - - canEditFieldSpy.mockRestore(); - }); - - it('does not add false categoryOutOfPolicy violation in cross-policy bulk edit when category exists in transaction policy', async () => { - const transactionID = 'transaction-cross-cat-1'; - const transactionThreadReportID = 'thread-cross-cat-1'; - const bulkEditPolicyID = 'bulk-policy'; - const transactionPolicyID = 'tx-policy'; - - // bulkEditPolicy does NOT have "Engineering" category - const bulkEditPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: bulkEditPolicyID, requiresCategory: true}; - // transactionPolicy DOES have "Engineering" category — the transaction's category is valid here - const txPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: transactionPolicyID, requiresCategory: true}; - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: 'iou-cross-cat-1', - policyID: transactionPolicyID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}iou-cross-cat-1`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: 'iou-cross-cat-1', - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - category: 'Engineering', - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); - await waitForBatchedUpdates(); - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - - // Pass categories for BOTH policies — "Engineering" only exists in the transaction's policy - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {amount: 2000}, - policy: bulkEditPolicy, - reports, - transactions, - reportActions: {}, - policyCategories: { - [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${bulkEditPolicyID}`]: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Marketing: {name: 'Marketing', enabled: true, 'GL Code': '', unencodedName: 'Marketing', externalID: '', areCommentsRequired: false, origin: ''}, - }, - [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${transactionPolicyID}`]: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Engineering: {name: 'Engineering', enabled: true, 'GL Code': '', unencodedName: 'Engineering', externalID: '', areCommentsRequired: false, origin: ''}, - }, - }, - policyTags: {}, - hash: undefined, - allPolicies: { - [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicyID}`]: txPolicy, - }, - introSelected: undefined, - betas: undefined, - }); - await waitForBatchedUpdates(); - - // "Engineering" exists in the transaction's own policy, so no categoryOutOfPolicy violation - const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - const violationNames = updatedViolations?.map((v) => v.name) ?? []; - expect(violationNames).not.toContain(CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY); - - canEditFieldSpy.mockRestore(); - }); - - it('adds categoryOutOfPolicy violation when category does not exist in transaction own policy', async () => { - const transactionID = 'transaction-bad-cat-1'; - const transactionThreadReportID = 'thread-bad-cat-1'; - const policyID = 'cat-policy'; - - const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: policyID, requiresCategory: true}; - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: 'iou-bad-cat-1', - policyID, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}iou-bad-cat-1`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: 'iou-bad-cat-1', - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - category: 'NonExistentCategory', - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); - await waitForBatchedUpdates(); - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {amount: 2000}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: { - [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Food: {name: 'Food', enabled: true, 'GL Code': '', unencodedName: 'Food', externalID: '', areCommentsRequired: false, origin: ''}, - }, - }, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - await waitForBatchedUpdates(); - - // "NonExistentCategory" is not in the policy categories, so categoryOutOfPolicy should be added - const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - const violationNames = updatedViolations?.map((v) => v.name) ?? []; - expect(violationNames).toContain(CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY); - - canEditFieldSpy.mockRestore(); - }); - - it('uses passed policyTags to detect tagOutOfPolicy violation during bulk edit', async () => { - const transactionID = 'transaction-tag-1'; - const transactionThreadReportID = 'thread-tag-1'; - const iouReportID = 'iou-tag-1'; - const policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - requiresTag: true, - areTagsEnabled: true, - }; - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - tag: 'InvalidTag', - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - // Policy tags that do NOT contain "InvalidTag" — only "ValidTag" is enabled - const policyTagsForPolicy = { - Department: { - name: 'Department', - required: true, - orderWeight: 0, - tags: { - // eslint-disable-next-line @typescript-eslint/naming-convention - ValidTag: {name: 'ValidTag', enabled: true}, - }, - }, - }; - - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); - await waitForBatchedUpdates(); - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {amount: 2000}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: { - [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy.id}`]: policyTagsForPolicy, - }, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - await waitForBatchedUpdates(); - - // "InvalidTag" is not in the policy tag list, so tagOutOfPolicy should be added - const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); - const violationNames = updatedViolations?.map((v) => v.name) ?? []; - expect(violationNames).toContain(CONST.VIOLATIONS.TAG_OUT_OF_POLICY); - - canEditFieldSpy.mockRestore(); - }); - - it('skips category, tag, tax, and billable changes for plain IOU transactions', async () => { - const transactionID = 'transaction-iou-1'; - const transactionThreadReportID = 'thread-iou-1'; - const iouReportID = 'iou-report-1'; - const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); - - // IOU report — NOT an expense report (type is not EXPENSE) - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: policy.id, - type: CONST.REPORT.TYPE.IOU, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: 1000, - currency: CONST.CURRENCY.USD, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {category: 'Food', billable: true}, - policy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - introSelected: undefined, - betas: undefined, - }); - - // category/billable changes must be silently dropped for IOUs — - // no API call should be made since there are no valid updates - expect(writeSpy).not.toHaveBeenCalled(); - - writeSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - - it('uses per-transaction policy for category tax mapping in cross-policy bulk edit', () => { - // Given: two different policies – transactionPolicy has expense rules mapping "Advertising" → "id_TAX_RATE_1", - // while the shared bulk-edit policy has no expense rules at all. - const transactionID = 'transaction-cross-policy-1'; - const transactionThreadReportID = 'thread-cross-policy-1'; - const iouReportID = 'iou-cross-policy-1'; - - const category = 'Advertising'; - const expectedTaxCode = 'id_TAX_RATE_1'; - - // Transaction's own policy – has tax expense rules - const transactionPolicy: Policy = { - ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - taxRates: CONST.DEFAULT_TAX, - rules: {expenseRules: createCategoryTaxExpenseRules(category, expectedTaxCode)}, - }; - - // Shared bulk-edit policy – no expense rules, different ID - const sharedBulkEditPolicy: Policy = { - ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), - taxRates: CONST.DEFAULT_TAX, - // No expense rules — category should NOT resolve to a tax code via this policy - }; - - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: transactionPolicy.id, - type: CONST.REPORT.TYPE.EXPENSE, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: -1000, - currency: CONST.CURRENCY.USD, - category: '', - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - // Make the transaction's own policy resolvable via allPolicies - const allPolicies = { - [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicy.id}`]: transactionPolicy, - [`${ONYXKEYS.COLLECTION.POLICY}${sharedBulkEditPolicy.id}`]: sharedBulkEditPolicy, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - // When: bulk-editing with the shared policy (different from transaction's policy) - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {category}, - policy: sharedBulkEditPolicy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - allPolicies, - introSelected: undefined, - betas: undefined, - }); - - // Then: the optimistic transaction update should use the transaction's own policy for tax resolution. - // Check the optimistic Onyx data passed to API.write (3rd argument) for the TRANSACTION merge. - const writeCall = writeSpy.mock.calls.at(0); - expect(writeCall).toBeDefined(); - - const onyxData = writeCall?.[2] as {optimisticData: Array<{key: string; value: Partial}>} | undefined; - const transactionOnyxUpdate = onyxData?.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - expect(transactionOnyxUpdate).toBeDefined(); - - // The tax code should resolve from the transaction's policy (which has the expense rule), - // NOT from the shared bulk-edit policy (which has no expense rules) - expect(transactionOnyxUpdate?.value?.taxCode).toBe(expectedTaxCode); - - writeSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - - it('passes per-transaction policy to buildOptimisticModifiedExpenseReportAction in cross-policy bulk edit', () => { - // Given: two different policies — the transaction belongs to transactionPolicy, - // but the shared bulk-edit policy is a different workspace. - const transactionID = 'transaction-report-action-policy-1'; - const transactionThreadReportID = 'thread-report-action-policy-1'; - const iouReportID = 'iou-report-action-policy-1'; - - const transactionPolicy: Policy = { - ...createRandomPolicy(10, CONST.POLICY.TYPE.TEAM), - }; - - const sharedBulkEditPolicy: Policy = { - ...createRandomPolicy(20, CONST.POLICY.TYPE.TEAM), - }; - - const transactionThread: Report = { - ...createRandomReport(1, undefined), - reportID: transactionThreadReportID, - parentReportID: iouReportID, - policyID: transactionPolicy.id, - }; - const iouReport: Report = { - ...createRandomReport(2, undefined), - reportID: iouReportID, - policyID: transactionPolicy.id, - type: CONST.REPORT.TYPE.EXPENSE, - total: 1000, - }; - - const reports = { - [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, - [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, - }; - - const transaction: Transaction = { - ...createRandomTransaction(1), - transactionID, - reportID: iouReportID, - transactionThreadReportID, - amount: -1000, - currency: CONST.CURRENCY.USD, - reimbursable: true, - }; - const transactions = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, - }; - - const allPolicies = { - [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicy.id}`]: transactionPolicy, - [`${ONYXKEYS.COLLECTION.POLICY}${sharedBulkEditPolicy.id}`]: sharedBulkEditPolicy, - }; - - const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); - const buildOptimisticSpy = jest.spyOn(require('@libs/ReportUtils'), 'buildOptimisticModifiedExpenseReportAction'); - // eslint-disable-next-line rulesdir/no-multiple-api-calls - const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); - - // When: bulk-editing reimbursable with the shared policy (different from transaction's policy) - updateMultipleMoneyRequests({ - transactionIDs: [transactionID], - changes: {reimbursable: false}, - policy: sharedBulkEditPolicy, - reports, - transactions, - reportActions: {}, - policyCategories: undefined, - policyTags: {}, - hash: undefined, - allPolicies, - introSelected: undefined, - betas: undefined, - }); - - // Then: buildOptimisticModifiedExpenseReportAction should receive the transaction's own policy, - // not the shared bulk-edit policy. This matters because getUpdatedMoneyRequestReportData - // (called after) uses the same policy for maybeUpdateReportNameForFormulaTitle. - expect(buildOptimisticSpy).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - expect.objectContaining({id: transactionPolicy.id}), - expect.anything(), - ); - - writeSpy.mockRestore(); - buildOptimisticSpy.mockRestore(); - canEditFieldSpy.mockRestore(); - }); - }); - - describe('bulk edit draft transaction', () => { - const draftKey = `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}` as OnyxKey; - - it('initializes the bulk edit draft transaction', async () => { - await Onyx.set(draftKey, {amount: 1000}); - await waitForBatchedUpdates(); - - const testTransactionIDs = ['transaction1', 'transaction2', 'transaction3']; - initBulkEditDraftTransaction(testTransactionIDs); - await waitForBatchedUpdates(); - - const draftTransaction = await getOnyxValue(draftKey); - expect(draftTransaction).toMatchObject({ - transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, - selectedTransactionIDs: testTransactionIDs, - }); - }); - - it('updates the bulk edit draft transaction', async () => { - await Onyx.set(draftKey, {transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, merchant: 'Gym'}); - await waitForBatchedUpdates(); - - updateBulkEditDraftTransaction({amount: 1000}); - await waitForBatchedUpdates(); - - const draftTransaction = await getOnyxValue(draftKey); - expect(draftTransaction).toMatchObject({ - transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, - merchant: 'Gym', - amount: 1000, - }); - }); - - it('clears the bulk edit draft transaction', async () => { - await Onyx.set(draftKey, {transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, amount: 1000}); - await waitForBatchedUpdates(); - - clearBulkEditDraftTransaction(); - await waitForBatchedUpdates(); - - const draftTransaction = await getOnyxValue(draftKey); - expect(draftTransaction).toBeUndefined(); - }); - }); describe('changeTransactionsReport', () => { it('should set the correct optimistic onyx data for reporting a tracked expense', async () => { diff --git a/tests/actions/IOUTest/BulkEditTest.ts b/tests/actions/IOUTest/BulkEditTest.ts new file mode 100644 index 000000000000..4197aedceca2 --- /dev/null +++ b/tests/actions/IOUTest/BulkEditTest.ts @@ -0,0 +1,1291 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Onyx from 'react-native-onyx'; +import type {OnyxKey} from 'react-native-onyx'; +import {clearBulkEditDraftTransaction, initBulkEditDraftTransaction, updateBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU/BulkEdit'; +import CONST from '@src/CONST'; +import * as API from '@src/libs/API'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import type Transaction from '@src/types/onyx/Transaction'; +import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; +import {createRandomReport} from '../../utils/collections/reports'; +import createRandomTransaction from '../../utils/collections/transaction'; +import getOnyxValue from '../../utils/getOnyxValue'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +describe('actions/IOU/BulkEdit', () => { + describe('updateMultipleMoneyRequests', () => { + it('applies expense report sign to amount updates', () => { + const transactionID = 'transaction-1'; + const transactionThreadReportID = 'thread-1'; + const iouReportID = 'iou-1'; + const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); + + const transactionThread: Report = { + ...createRandomReport(1, undefined), + reportID: transactionThreadReportID, + parentReportID: iouReportID, + policyID: policy.id, + }; + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + const buildOptimisticSpy = jest.spyOn(require('@libs/ReportUtils'), 'buildOptimisticModifiedExpenseReportAction'); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {amount: 1000}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + + const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; + const updates = JSON.parse(params.updates) as {amount: number}; + expect(updates.amount).toBe(1000); + expect(buildOptimisticSpy).toHaveBeenCalledWith( + transactionThread, + transaction, + expect.objectContaining({amount: 1000, currency: CONST.CURRENCY.USD}), + true, + policy, + expect.anything(), + ); + + writeSpy.mockRestore(); + buildOptimisticSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + + it('skips updates when bulk edit value matches the current transaction field', () => { + const transactionID = 'transaction-1'; + const transactionThreadReportID = 'thread-1'; + const iouReportID = 'iou-1'; + const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); + + const transactionThread: Report = { + ...createRandomReport(1, undefined), + reportID: transactionThreadReportID, + parentReportID: iouReportID, + policyID: policy.id, + }; + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: -1000, + currency: CONST.CURRENCY.USD, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {amount: 1000}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + + expect(writeSpy).not.toHaveBeenCalled(); + + writeSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + + it('updates report totals across multiple transactions in the same report', () => { + const firstTransactionID = 'transaction-4'; + const secondTransactionID = 'transaction-5'; + const iouReportID = 'iou-4'; + const policy = createRandomPolicy(4, CONST.POLICY.TYPE.TEAM); + + const iouReport: Report = { + ...createRandomReport(4, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + total: -2600, + currency: CONST.CURRENCY.USD, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`]: { + ...createRandomTransaction(4), + transactionID: firstTransactionID, + reportID: iouReportID, + amount: -1300, + currency: CONST.CURRENCY.USD, + }, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${secondTransactionID}`]: { + ...createRandomTransaction(5), + transactionID: secondTransactionID, + reportID: iouReportID, + amount: -1300, + currency: CONST.CURRENCY.USD, + }, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + updateMultipleMoneyRequests({ + transactionIDs: [firstTransactionID, secondTransactionID], + changes: {amount: 1000}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + + const getOptimisticTotal = (callIndex: number) => { + const onyxData = writeSpy.mock.calls.at(callIndex)?.[2] as {optimisticData: Array<{key: string; value?: {total?: number}}>}; + const reportUpdate = onyxData.optimisticData.find((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`); + return reportUpdate?.value?.total; + }; + + expect(getOptimisticTotal(0)).toBe(-2300); + expect(getOptimisticTotal(1)).toBe(-2000); + + writeSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + + it('supports negative amount updates for expense reports', () => { + const transactionID = 'transaction-3'; + const transactionThreadReportID = 'thread-3'; + const iouReportID = 'iou-3'; + const policy = createRandomPolicy(3, CONST.POLICY.TYPE.TEAM); + + const transactionThread: Report = { + ...createRandomReport(3, undefined), + reportID: transactionThreadReportID, + parentReportID: iouReportID, + policyID: policy.id, + }; + const iouReport: Report = { + ...createRandomReport(4, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(3), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + // Expense reports store amounts with the opposite sign of what the UI displays. + amount: -1000, + currency: CONST.CURRENCY.USD, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + const buildOptimisticSpy = jest.spyOn(require('@libs/ReportUtils'), 'buildOptimisticModifiedExpenseReportAction'); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {amount: -1000}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + + const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; + const updates = JSON.parse(params.updates) as {amount: number}; + expect(updates.amount).toBe(-1000); + expect(buildOptimisticSpy).toHaveBeenCalledWith( + transactionThread, + transaction, + expect.objectContaining({amount: -1000, currency: CONST.CURRENCY.USD}), + true, + policy, + expect.anything(), + ); + + writeSpy.mockRestore(); + buildOptimisticSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + + it('sends billable and reimbursable updates for bulk edit', () => { + const transactionID = 'transaction-7'; + const transactionThreadReportID = 'thread-7'; + const iouReportID = 'iou-7'; + const policy = createRandomPolicy(7, CONST.POLICY.TYPE.TEAM); + + const transactionThread: Report = { + ...createRandomReport(7, undefined), + reportID: transactionThreadReportID, + parentReportID: iouReportID, + policyID: policy.id, + }; + const iouReport: Report = { + ...createRandomReport(8, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(7), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: 1000, + billable: false, + reimbursable: true, + currency: CONST.CURRENCY.USD, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {billable: true, reimbursable: false}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + + const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; + const updates = JSON.parse(params.updates) as {billable: boolean; reimbursable: boolean}; + expect(updates.billable).toBe(true); + expect(updates.reimbursable).toBe(false); + + writeSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + + it('keeps invoice amount updates positive', () => { + const transactionID = 'transaction-2'; + const transactionThreadReportID = 'thread-2'; + const iouReportID = 'iou-2'; + const policy = createRandomPolicy(2, CONST.POLICY.TYPE.TEAM); + + const transactionThread: Report = { + ...createRandomReport(3, undefined), + reportID: transactionThreadReportID, + parentReportID: iouReportID, + policyID: policy.id, + }; + const iouReport: Report = { + ...createRandomReport(4, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.INVOICE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(2), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + const buildOptimisticSpy = jest.spyOn(require('@libs/ReportUtils'), 'buildOptimisticModifiedExpenseReportAction'); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {amount: 1000}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + + const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; + const updates = JSON.parse(params.updates) as {amount: number}; + expect(updates.amount).toBe(1000); + expect(buildOptimisticSpy).toHaveBeenCalledWith( + transactionThread, + transaction, + expect.objectContaining({amount: 1000, currency: CONST.CURRENCY.USD}), + false, + policy, + expect.anything(), + ); + + writeSpy.mockRestore(); + buildOptimisticSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + + it('saves changes for unreported (track) expenses without a reportAction', () => { + const transactionID = 'transaction-unreported'; + const transactionThreadReportID = 'thread-unreported'; + const policy = createRandomPolicy(10, CONST.POLICY.TYPE.TEAM); + + const transactionThread: Report = { + ...createRandomReport(10, undefined), + reportID: transactionThreadReportID, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, + }; + + const transaction: Transaction = { + ...createRandomTransaction(10), + transactionID, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + transactionThreadReportID, + amount: 500, + currency: CONST.CURRENCY.USD, + merchant: 'Old merchant', + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + // No canEditFieldOfMoneyRequest mock — unreported expenses must bypass that check + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {merchant: 'New merchant'}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + + expect(writeSpy).toHaveBeenCalled(); + const params = writeSpy.mock.calls.at(0)?.[1] as {updates: string}; + const updates = JSON.parse(params.updates) as {merchant: string}; + expect(updates.merchant).toBe('New merchant'); + + writeSpy.mockRestore(); + }); + + it('does not add violations for unreported expenses during bulk edit', async () => { + const transactionID = 'transaction-unreported-viol'; + const transactionThreadReportID = 'thread-unreported-viol'; + const policy = {...createRandomPolicy(10, CONST.POLICY.TYPE.TEAM), requiresCategory: true, requiresTag: true}; + + const transactionThread: Report = { + ...createRandomReport(10, undefined), + reportID: transactionThreadReportID, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, + }; + + const transaction: Transaction = { + ...createRandomTransaction(10), + transactionID, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + transactionThreadReportID, + amount: 500, + currency: CONST.CURRENCY.USD, + category: undefined, + tag: undefined, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); + await waitForBatchedUpdates(); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {amount: 1000}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: { + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`]: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Food: {name: 'Food', enabled: true, 'GL Code': '', unencodedName: 'Food', externalID: '', areCommentsRequired: false, origin: ''}, + }, + }, + policyTags: { + [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy.id}`]: { + Department: { + name: 'Department', + required: true, + orderWeight: 0, + tags: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Engineering: {name: 'Engineering', enabled: true}, + }, + }, + }, + }, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + await waitForBatchedUpdates(); + + // Unreported expenses should not get any violations (missingCategory, missingTag, etc.) + const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + expect(updatedViolations ?? []).toHaveLength(0); + }); + + it('removes DUPLICATED_TRANSACTION violation optimistically when amount is changed', async () => { + const transactionID = 'transaction-1'; + const transactionThreadReportID = 'thread-1'; + const iouReportID = 'iou-1'; + const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); + + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [{name: CONST.VIOLATIONS.DUPLICATED_TRANSACTION, type: CONST.VIOLATION_TYPES.VIOLATION}]); + await waitForBatchedUpdates(); + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {amount: 2000}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + await waitForBatchedUpdates(); + + const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const violationNames = updatedViolations?.map((v) => v.name) ?? []; + expect(violationNames).not.toContain(CONST.VIOLATIONS.DUPLICATED_TRANSACTION); + + canEditFieldSpy.mockRestore(); + }); + + it('removes CATEGORY_OUT_OF_POLICY violation optimistically when category is cleared', async () => { + const transactionID = 'transaction-1'; + const transactionThreadReportID = 'thread-1'; + const iouReportID = 'iou-1'; + const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); + + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + category: 'OldCategory', + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [{name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION}]); + await waitForBatchedUpdates(); + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {category: ''}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + await waitForBatchedUpdates(); + + const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const violationNames = updatedViolations?.map((v) => v.name) ?? []; + expect(violationNames).not.toContain(CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY); + + canEditFieldSpy.mockRestore(); + }); + + it('clears category-out-of-policy violation when the new category is valid', async () => { + const transactionID = 'transaction-1'; + const transactionThreadReportID = 'thread-1'; + const iouReportID = 'iou-1'; + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), requiresCategory: true}; + + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + category: 'InvalidCategory', + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + const policyCategories = { + // eslint-disable-next-line @typescript-eslint/naming-convention + Food: {name: 'Food', enabled: true, 'GL Code': '', unencodedName: 'Food', externalID: '', areCommentsRequired: false, origin: ''}, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, [{name: CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION}]); + await waitForBatchedUpdates(); + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {category: 'Food'}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: { + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`]: policyCategories, + }, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + await waitForBatchedUpdates(); + + const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const violationNames = updatedViolations?.map((v) => v.name) ?? []; + expect(violationNames).not.toContain(CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY); + + canEditFieldSpy.mockRestore(); + }); + + it('uses the transaction own policy for violation checks in cross-policy bulk edits', async () => { + const transactionID = 'transaction-1'; + const transactionThreadReportID = 'thread-1'; + const bulkEditPolicyID = '1'; + const transactionPolicyID = '2'; + + // bulkEditPolicy requires categories — would add missingCategory if used for the transaction + const bulkEditPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: bulkEditPolicyID, requiresCategory: true}; + // transactionPolicy does NOT require categories — correct policy for this transaction + const transactionPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: transactionPolicyID, requiresCategory: false}; + + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: 'iou-1', + policyID: transactionPolicyID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}iou-1`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: 'iou-1', + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + category: undefined, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); + await waitForBatchedUpdates(); + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {merchant: 'New Merchant'}, + policy: bulkEditPolicy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + allPolicies: { + [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicyID}`]: transactionPolicy, + }, + introSelected: undefined, + betas: undefined, + }); + await waitForBatchedUpdates(); + + // transactionPolicy does not require categories, so no missingCategory violation should be added + const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const violationNames = updatedViolations?.map((v) => v.name) ?? []; + expect(violationNames).not.toContain(CONST.VIOLATIONS.MISSING_CATEGORY); + + canEditFieldSpy.mockRestore(); + }); + + it('does not add false categoryOutOfPolicy violation in cross-policy bulk edit when category exists in transaction policy', async () => { + const transactionID = 'transaction-cross-cat-1'; + const transactionThreadReportID = 'thread-cross-cat-1'; + const bulkEditPolicyID = 'bulk-policy'; + const transactionPolicyID = 'tx-policy'; + + // bulkEditPolicy does NOT have "Engineering" category + const bulkEditPolicy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: bulkEditPolicyID, requiresCategory: true}; + // transactionPolicy DOES have "Engineering" category — the transaction's category is valid here + const txPolicy = {...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), id: transactionPolicyID, requiresCategory: true}; + + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: 'iou-cross-cat-1', + policyID: transactionPolicyID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}iou-cross-cat-1`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: 'iou-cross-cat-1', + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + category: 'Engineering', + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); + await waitForBatchedUpdates(); + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + + // Pass categories for BOTH policies — "Engineering" only exists in the transaction's policy + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {amount: 2000}, + policy: bulkEditPolicy, + reports, + transactions, + reportActions: {}, + policyCategories: { + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${bulkEditPolicyID}`]: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Marketing: {name: 'Marketing', enabled: true, 'GL Code': '', unencodedName: 'Marketing', externalID: '', areCommentsRequired: false, origin: ''}, + }, + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${transactionPolicyID}`]: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Engineering: {name: 'Engineering', enabled: true, 'GL Code': '', unencodedName: 'Engineering', externalID: '', areCommentsRequired: false, origin: ''}, + }, + }, + policyTags: {}, + hash: undefined, + allPolicies: { + [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicyID}`]: txPolicy, + }, + introSelected: undefined, + betas: undefined, + }); + await waitForBatchedUpdates(); + + // "Engineering" exists in the transaction's own policy, so no categoryOutOfPolicy violation + const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const violationNames = updatedViolations?.map((v) => v.name) ?? []; + expect(violationNames).not.toContain(CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY); + + canEditFieldSpy.mockRestore(); + }); + + it('adds categoryOutOfPolicy violation when category does not exist in transaction own policy', async () => { + const transactionID = 'transaction-bad-cat-1'; + const transactionThreadReportID = 'thread-bad-cat-1'; + const policyID = 'cat-policy'; + + const policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: policyID, requiresCategory: true}; + + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: 'iou-bad-cat-1', + policyID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}iou-bad-cat-1`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: 'iou-bad-cat-1', + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + category: 'NonExistentCategory', + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); + await waitForBatchedUpdates(); + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {amount: 2000}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: { + [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Food: {name: 'Food', enabled: true, 'GL Code': '', unencodedName: 'Food', externalID: '', areCommentsRequired: false, origin: ''}, + }, + }, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + await waitForBatchedUpdates(); + + // "NonExistentCategory" is not in the policy categories, so categoryOutOfPolicy should be added + const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const violationNames = updatedViolations?.map((v) => v.name) ?? []; + expect(violationNames).toContain(CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY); + + canEditFieldSpy.mockRestore(); + }); + + it('uses passed policyTags to detect tagOutOfPolicy violation during bulk edit', async () => { + const transactionID = 'transaction-tag-1'; + const transactionThreadReportID = 'thread-tag-1'; + const iouReportID = 'iou-tag-1'; + const policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + requiresTag: true, + areTagsEnabled: true, + }; + + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + tag: 'InvalidTag', + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + // Policy tags that do NOT contain "InvalidTag" — only "ValidTag" is enabled + const policyTagsForPolicy = { + Department: { + name: 'Department', + required: true, + orderWeight: 0, + tags: { + // eslint-disable-next-line @typescript-eslint/naming-convention + ValidTag: {name: 'ValidTag', enabled: true}, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, []); + await waitForBatchedUpdates(); + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {amount: 2000}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: { + [`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy.id}`]: policyTagsForPolicy, + }, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + await waitForBatchedUpdates(); + + // "InvalidTag" is not in the policy tag list, so tagOutOfPolicy should be added + const updatedViolations = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); + const violationNames = updatedViolations?.map((v) => v.name) ?? []; + expect(violationNames).toContain(CONST.VIOLATIONS.TAG_OUT_OF_POLICY); + + canEditFieldSpy.mockRestore(); + }); + + it('skips category, tag, tax, and billable changes for plain IOU transactions', async () => { + const transactionID = 'transaction-iou-1'; + const transactionThreadReportID = 'thread-iou-1'; + const iouReportID = 'iou-report-1'; + const policy = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); + + // IOU report — NOT an expense report (type is not EXPENSE) + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: iouReportID, + policyID: policy.id, + type: CONST.REPORT.TYPE.IOU, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: 1000, + currency: CONST.CURRENCY.USD, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {category: 'Food', billable: true}, + policy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + introSelected: undefined, + betas: undefined, + }); + + // category/billable changes must be silently dropped for IOUs — + // no API call should be made since there are no valid updates + expect(writeSpy).not.toHaveBeenCalled(); + + writeSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + + it('uses per-transaction policy for category tax mapping in cross-policy bulk edit', () => { + // Given: two different policies – transactionPolicy has expense rules mapping "Advertising" → "id_TAX_RATE_1", + // while the shared bulk-edit policy has no expense rules at all. + const transactionID = 'transaction-cross-policy-1'; + const transactionThreadReportID = 'thread-cross-policy-1'; + const iouReportID = 'iou-cross-policy-1'; + + const category = 'Advertising'; + const expectedTaxCode = 'id_TAX_RATE_1'; + + // Transaction's own policy – has tax expense rules + const transactionPolicy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + taxRates: CONST.DEFAULT_TAX, + rules: {expenseRules: createCategoryTaxExpenseRules(category, expectedTaxCode)}, + }; + + // Shared bulk-edit policy – no expense rules, different ID + const sharedBulkEditPolicy: Policy = { + ...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), + taxRates: CONST.DEFAULT_TAX, + // No expense rules — category should NOT resolve to a tax code via this policy + }; + + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: iouReportID, + policyID: transactionPolicy.id, + type: CONST.REPORT.TYPE.EXPENSE, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: -1000, + currency: CONST.CURRENCY.USD, + category: '', + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + // Make the transaction's own policy resolvable via allPolicies + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicy.id}`]: transactionPolicy, + [`${ONYXKEYS.COLLECTION.POLICY}${sharedBulkEditPolicy.id}`]: sharedBulkEditPolicy, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + // When: bulk-editing with the shared policy (different from transaction's policy) + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {category}, + policy: sharedBulkEditPolicy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + allPolicies, + introSelected: undefined, + betas: undefined, + }); + + // Then: the optimistic transaction update should use the transaction's own policy for tax resolution. + // Check the optimistic Onyx data passed to API.write (3rd argument) for the TRANSACTION merge. + const writeCall = writeSpy.mock.calls.at(0); + expect(writeCall).toBeDefined(); + + const onyxData = writeCall?.[2] as {optimisticData: Array<{key: string; value: Partial}>} | undefined; + const transactionOnyxUpdate = onyxData?.optimisticData?.find((update) => update.key === `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + expect(transactionOnyxUpdate).toBeDefined(); + + // The tax code should resolve from the transaction's policy (which has the expense rule), + // NOT from the shared bulk-edit policy (which has no expense rules) + expect(transactionOnyxUpdate?.value?.taxCode).toBe(expectedTaxCode); + + writeSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + + it('passes per-transaction policy to buildOptimisticModifiedExpenseReportAction in cross-policy bulk edit', () => { + // Given: two different policies — the transaction belongs to transactionPolicy, + // but the shared bulk-edit policy is a different workspace. + const transactionID = 'transaction-report-action-policy-1'; + const transactionThreadReportID = 'thread-report-action-policy-1'; + const iouReportID = 'iou-report-action-policy-1'; + + const transactionPolicy: Policy = { + ...createRandomPolicy(10, CONST.POLICY.TYPE.TEAM), + }; + + const sharedBulkEditPolicy: Policy = { + ...createRandomPolicy(20, CONST.POLICY.TYPE.TEAM), + }; + + const transactionThread: Report = { + ...createRandomReport(1, undefined), + reportID: transactionThreadReportID, + parentReportID: iouReportID, + policyID: transactionPolicy.id, + }; + const iouReport: Report = { + ...createRandomReport(2, undefined), + reportID: iouReportID, + policyID: transactionPolicy.id, + type: CONST.REPORT.TYPE.EXPENSE, + total: 1000, + }; + + const reports = { + [`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]: transactionThread, + [`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]: iouReport, + }; + + const transaction: Transaction = { + ...createRandomTransaction(1), + transactionID, + reportID: iouReportID, + transactionThreadReportID, + amount: -1000, + currency: CONST.CURRENCY.USD, + reimbursable: true, + }; + const transactions = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + }; + + const allPolicies = { + [`${ONYXKEYS.COLLECTION.POLICY}${transactionPolicy.id}`]: transactionPolicy, + [`${ONYXKEYS.COLLECTION.POLICY}${sharedBulkEditPolicy.id}`]: sharedBulkEditPolicy, + }; + + const canEditFieldSpy = jest.spyOn(require('@libs/ReportUtils'), 'canEditFieldOfMoneyRequest').mockReturnValue(true); + const buildOptimisticSpy = jest.spyOn(require('@libs/ReportUtils'), 'buildOptimisticModifiedExpenseReportAction'); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(jest.fn()); + + // When: bulk-editing reimbursable with the shared policy (different from transaction's policy) + updateMultipleMoneyRequests({ + transactionIDs: [transactionID], + changes: {reimbursable: false}, + policy: sharedBulkEditPolicy, + reports, + transactions, + reportActions: {}, + policyCategories: undefined, + policyTags: {}, + hash: undefined, + allPolicies, + introSelected: undefined, + betas: undefined, + }); + + // Then: buildOptimisticModifiedExpenseReportAction should receive the transaction's own policy, + // not the shared bulk-edit policy. This matters because getUpdatedMoneyRequestReportData + // (called after) uses the same policy for maybeUpdateReportNameForFormulaTitle. + expect(buildOptimisticSpy).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({id: transactionPolicy.id}), + expect.anything(), + ); + + writeSpy.mockRestore(); + buildOptimisticSpy.mockRestore(); + canEditFieldSpy.mockRestore(); + }); + }); + + describe('bulk edit draft transaction', () => { + const draftKey = `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}` as OnyxKey; + + it('initializes the bulk edit draft transaction', async () => { + await Onyx.set(draftKey, {amount: 1000}); + await waitForBatchedUpdates(); + + const testTransactionIDs = ['transaction1', 'transaction2', 'transaction3']; + initBulkEditDraftTransaction(testTransactionIDs); + await waitForBatchedUpdates(); + + const draftTransaction = await getOnyxValue(draftKey); + expect(draftTransaction).toMatchObject({ + transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, + selectedTransactionIDs: testTransactionIDs, + }); + }); + + it('updates the bulk edit draft transaction', async () => { + await Onyx.set(draftKey, {transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, merchant: 'Gym'}); + await waitForBatchedUpdates(); + + updateBulkEditDraftTransaction({amount: 1000}); + await waitForBatchedUpdates(); + + const draftTransaction = await getOnyxValue(draftKey); + expect(draftTransaction).toMatchObject({ + transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, + merchant: 'Gym', + amount: 1000, + }); + }); + + it('clears the bulk edit draft transaction', async () => { + await Onyx.set(draftKey, {transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, amount: 1000}); + await waitForBatchedUpdates(); + + clearBulkEditDraftTransaction(); + await waitForBatchedUpdates(); + + const draftTransaction = await getOnyxValue(draftKey); + expect(draftTransaction).toBeUndefined(); + }); + }); +}); From 41a553f92434b86e08b9c5aa7adf728a32af6fbb Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 14 Apr 2026 16:26:57 +0700 Subject: [PATCH 6/8] fix: add missing Policy type to BulkEditTest, remove unused OnyxKey from IOUTest Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/actions/IOUTest.ts | 3 +-- tests/actions/IOUTest/BulkEditTest.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 6ea0402d00b5..a9d10dc7613a 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -4,7 +4,7 @@ import {renderHook, waitFor} from '@testing-library/react-native'; import {format} from 'date-fns'; import {deepEqual} from 'fast-equals'; import Onyx from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry, OnyxKey} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SearchQueryJSON, SearchStatus} from '@components/Search/types'; import useOnyx from '@hooks/useOnyx'; import {clearAllRelatedReportActionErrors} from '@libs/actions/ClearReportActionErrors'; @@ -6065,7 +6065,6 @@ describe('actions/IOU', () => { }); }); - describe('changeTransactionsReport', () => { it('should set the correct optimistic onyx data for reporting a tracked expense', async () => { let personalDetailsList: OnyxEntry; diff --git a/tests/actions/IOUTest/BulkEditTest.ts b/tests/actions/IOUTest/BulkEditTest.ts index 4197aedceca2..c44ab98ee4ee 100644 --- a/tests/actions/IOUTest/BulkEditTest.ts +++ b/tests/actions/IOUTest/BulkEditTest.ts @@ -7,6 +7,7 @@ import CONST from '@src/CONST'; import * as API from '@src/libs/API'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; +import type {Policy} from '@src/types/onyx'; import type Transaction from '@src/types/onyx/Transaction'; import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; import {createRandomReport} from '../../utils/collections/reports'; From a990424e6760fb459d9e055a431e90e64fe5257a Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 14 Apr 2026 16:36:01 +0700 Subject: [PATCH 7/8] fix: merge duplicate @src/types/onyx imports in BulkEditTest Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/actions/IOUTest/BulkEditTest.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/actions/IOUTest/BulkEditTest.ts b/tests/actions/IOUTest/BulkEditTest.ts index c44ab98ee4ee..e1d520544a26 100644 --- a/tests/actions/IOUTest/BulkEditTest.ts +++ b/tests/actions/IOUTest/BulkEditTest.ts @@ -6,8 +6,7 @@ import {clearBulkEditDraftTransaction, initBulkEditDraftTransaction, updateBulkE import CONST from '@src/CONST'; import * as API from '@src/libs/API'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; -import type {Policy} from '@src/types/onyx'; +import type {Policy, Report} from '@src/types/onyx'; import type Transaction from '@src/types/onyx/Transaction'; import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; import {createRandomReport} from '../../utils/collections/reports'; From 35f195f7f444d0eb6b7d84d8133159dd029be152 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 15 Apr 2026 23:35:45 +0700 Subject: [PATCH 8/8] fix: remove unused imports after merging main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove TransactionDetails, buildOptimisticModifiedExpenseReportAction, getTransactionDetails, shouldEnableNegative, getClearedPendingFields, getUpdatedTransaction, isOnHold, createTransactionThreadReport, stringifyWaypointsForAPI — no longer used after main merge. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/actions/IOU/index.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index e09673dc3b27..5fbf16bc023f 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -37,7 +37,7 @@ import {getCustomUnitID} from '@libs/PerDiemRequestUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getDistanceRateCustomUnit, hasDependentTags, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {getOriginalMessage, getReportActionHtml, getReportActionText, isReportPreviewAction} from '@libs/ReportActionsUtils'; -import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; +import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; import { buildOptimisticAddCommentReportAction, buildOptimisticChatReport, @@ -45,7 +45,6 @@ import { buildOptimisticExpenseReport, buildOptimisticIOUReport, buildOptimisticIOUReportAction, - buildOptimisticModifiedExpenseReportAction, buildOptimisticMoneyRequestEntities, buildOptimisticReportPreview, generateReportID, @@ -54,7 +53,6 @@ import { getParsedComment, getReportNotificationPreference, getReportOrDraftReport, - getTransactionDetails, hasOutstandingChildRequest, hasViolations as hasViolationsReportUtils, isDeprecatedGroupDM, @@ -72,7 +70,6 @@ import { isTestTransactionReport, populateOptimisticReportFormula, shouldCreateNewMoneyRequestReport as shouldCreateNewMoneyRequestReportReportUtils, - shouldEnableNegative, updateReportPreview, } from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; @@ -84,22 +81,19 @@ import { buildOptimisticTransaction, getAmount, getCategoryTaxCodeAndAmount, - getClearedPendingFields, getCurrency, getDistanceInMeters, - getUpdatedTransaction, isDistanceRequest as isDistanceRequestTransactionUtils, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, - isOnHold, isPerDiemRequest as isPerDiemRequestTransactionUtils, isScanRequest as isScanRequestTransactionUtils, isTimeRequest as isTimeRequestTransactionUtils, } from '@libs/TransactionUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; -import {createTransactionThreadReport, notifyNewAction} from '@userActions/Report'; -import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeWaypointsForAPI, stringifyWaypointsForAPI} from '@userActions/Transaction'; +import {notifyNewAction} from '@userActions/Report'; +import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeWaypointsForAPI} from '@userActions/Transaction'; import {getRemoveDraftTransactionsByIDsData, removeDraftTransaction, removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; import type {IOUAction, IOUActionParams, OdometerImageType} from '@src/CONST'; import CONST from '@src/CONST';