diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index af01b6e04b4a..5acc43785078 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -63,7 +63,7 @@ import { 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 6e2bb8da28bd..e57f22ec8988 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 4eeeac2de6e8..029dbd344d82 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, getUserAccountID} from '.'; import {getReportFromHoldRequestsOnyxData} from './Hold'; @@ -1491,8 +1501,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 bd83874b0e21..5fbf16bc023f 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -8,9 +8,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'; @@ -37,28 +36,23 @@ import {getManagerMcTestParticipant} from '@libs/OptionsListUtils'; import {getCustomUnitID} from '@libs/PerDiemRequestUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {getDistanceRateCustomUnit, hasDependentTags, isPaidGroupPolicy} from '@libs/PolicyUtils'; -import {getIOUActionForTransactionID, getOriginalMessage, getReportActionHtml, getReportActionText, isReportPreviewAction} from '@libs/ReportActionsUtils'; -import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; +import {getOriginalMessage, getReportActionHtml, getReportActionText, isReportPreviewAction} from '@libs/ReportActionsUtils'; +import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils'; import { buildOptimisticAddCommentReportAction, - buildOptimisticChangeApproverReportAction, buildOptimisticChatReport, buildOptimisticCreatedReportAction, buildOptimisticExpenseReport, buildOptimisticIOUReport, buildOptimisticIOUReportAction, - buildOptimisticModifiedExpenseReportAction, buildOptimisticMoneyRequestEntities, buildOptimisticReportPreview, - canEditFieldOfMoneyRequest, - findSelfDMReportID, generateReportID, getChatByParticipants, getOutstandingChildRequest, getParsedComment, getReportNotificationPreference, getReportOrDraftReport, - getTransactionDetails, hasOutstandingChildRequest, hasViolations as hasViolationsReportUtils, isDeprecatedGroupDM, @@ -76,7 +70,6 @@ import { isTestTransactionReport, populateOptimisticReportFormula, shouldCreateNewMoneyRequestReport as shouldCreateNewMoneyRequestReportReportUtils, - shouldEnableNegative, updateReportPreview, } from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; @@ -86,25 +79,20 @@ 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, 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 {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'; @@ -588,6 +576,10 @@ function getUserAccountID(): number { return deprecatedUserAccountID; } +function getCurrentUserPersonalDetails(): OnyxEntry { + return deprecatedCurrentUserPersonalDetails; +} + function getRecentAttendees(): OnyxEntry { return recentAttendees; } @@ -3851,792 +3843,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, @@ -4681,14 +3887,8 @@ export { calculateDiffAmount, getUpdatedMoneyRequestReportData, startDistanceRequest, - assignReportToMe, - addReportApprover, hasOutstandingChildRequest, getReportPreviewAction, - updateMultipleMoneyRequests, - initBulkEditDraftTransaction, - clearBulkEditDraftTransaction, - updateBulkEditDraftTransaction, mergePolicyRecentlyUsedCurrencies, mergePolicyRecentlyUsedCategories, getAllPersonalDetails, @@ -4699,6 +3899,7 @@ export { getAllReportNameValuePairs, getAllTransactionDrafts, getCurrentUserEmail, + getCurrentUserPersonalDetails, getUserAccountID, getRecentAttendees, getReceiptError, @@ -4711,13 +3912,13 @@ export { setMoneyRequestTimeRate, setMoneyRequestTimeCount, handleNavigateAfterExpenseCreate, - highlightTransactionOnSearchRouteIfNeeded, buildMinimalTransactionForFormula, buildOnyxDataForMoneyRequest, createSplitsAndOnyxData, getMoneyRequestInformation, getOrCreateOptimisticSplitChatReport, getTransactionWithPreservedLocalReceiptSource, + 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..a9d10dc7613a 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -4,16 +4,14 @@ 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'; import { calculateDiffAmount, - clearBulkEditDraftTransaction, createDistanceRequest, handleNavigateAfterExpenseCreate, - initBulkEditDraftTransaction, initMoneyRequest, removeMoneyRequestOdometerImage, resetDraftTransactionsCustomUnit, @@ -28,8 +26,6 @@ import { setMoneyRequestOdometerImage, setMoneyRequestTag, shouldOptimisticallyUpdateSearch, - updateBulkEditDraftTransaction, - updateMultipleMoneyRequests, } from '@libs/actions/IOU'; import {putOnHold} from '@libs/actions/IOU/Hold'; import {completeSplitBill, splitBill, startSplitBill, updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/Split'; @@ -6069,1280 +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 () => { let personalDetailsList: OnyxEntry; diff --git a/tests/actions/IOUTest/BulkEditTest.ts b/tests/actions/IOUTest/BulkEditTest.ts new file mode 100644 index 000000000000..e1d520544a26 --- /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 {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'; +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(); + }); + }); +});