diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index df0a4ccc11c5..434b348354d2 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -5,10 +5,11 @@ import {getIOUActionForTransactions} from '@libs/actions/IOU/Duplicate'; import {getIOURequestPolicyID} from '@libs/actions/IOU/MoneyRequest'; import {initSplitExpenseItemData} from '@libs/actions/IOU/SplitExpenseItems'; import {updateSplitTransactions} from '@libs/actions/IOU/SplitTransactionUpdate'; +import {deleteTrackExpense} from '@libs/actions/IOU/TrackExpense'; import initSplitExpense from '@libs/actions/SplitExpenses'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {calculateAmount as calculateIOUAmount} from '@libs/IOUUtils'; -import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, isActionableTrackExpense, isMoneyRequestAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; import {isArchivedReport, isExpenseReport, isInvoiceReport, isIOUReport, isSelfDM} from '@libs/ReportUtils'; import {getActiveGroupSearchHashes} from '@libs/SearchUIUtils'; import { @@ -21,7 +22,7 @@ import { import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, ReportAction, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolations} from '@src/types/onyx'; import type {SplitExpense} from '@src/types/onyx/IOU'; import type {OnyxCollection} from 'react-native-onyx'; @@ -360,6 +361,47 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac const iouReport = isIOUReport(candidateIOUReport) || isExpenseReport(candidateIOUReport) || isInvoiceReport(candidateIOUReport) ? candidateIOUReport : undefined; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`]; const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${action?.childReportID}`]; + + // A self-DM expense is a tracked expense: it has no IOU report to key the cleanup on, so it goes + // through the track-expense flow, which resolves the self-DM report actions and the whisper. + if (isSelfDM(candidateIOUReport) && isTrackExpenseAction(action)) { + // The Onyx collection can be missing the self-DM actions when the delete comes from Search, where + // they are read from the search snapshot instead, so the ones passed in fill the gaps. Actionable + // track expense whispers are matched on their own since they are built without a `reportID`. + const selfDMReportActions: ReportActions = { + ...Object.fromEntries( + reportActions + .filter((chatAction) => chatAction.reportID === candidateIOUReport?.reportID || isActionableTrackExpense(chatAction)) + .map((chatAction) => [chatAction.reportActionID, chatAction]), + ), + ...allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${candidateIOUReport?.reportID}`], + }; + + deleteTrackExpense({ + chatReportID: candidateIOUReport?.reportID, + chatReport: candidateIOUReport, + chatReportActions: selfDMReportActions, + transactionID, + reportAction: action, + iouReport: undefined, + chatIOUReport: undefined, + transactions: duplicateTransactions, + violations: duplicateTransactionViolations, + isSingleTransactionView, + isChatReportArchived: isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${candidateIOUReport?.reportID}`]), + isChatIOUReportArchived: false, + allTransactionViolationsParam: transactionViolations, + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.email ?? '', + policy: undefined, + }); + deletedTransactionIDs.push(transactionID); + if (action.childReportID) { + deletedTransactionThreadReportIDs.add(action.childReportID); + } + continue; + } + const chatIOUReportID = chatReport?.reportID; const isChatIOUReportArchived = isArchivedReport(allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${chatIOUReportID}`]); const iouPolicy = iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined; diff --git a/src/libs/API/parameters/SplitTransactionParams.ts b/src/libs/API/parameters/SplitTransactionParams.ts index e7e9c45870dd..3ea3e266873c 100644 --- a/src/libs/API/parameters/SplitTransactionParams.ts +++ b/src/libs/API/parameters/SplitTransactionParams.ts @@ -1,3 +1,4 @@ +import type {Unit} from '@src/types/onyx/Policy'; import type {Comment, WaypointCollection} from '@src/types/onyx/Transaction'; type SplitTransactionSplitParam = { @@ -17,6 +18,7 @@ type SplitTransactionSplitParam = { reportID?: string; quantity?: number; customUnitRateID?: string; + distanceUnit?: Unit; odometerStart?: number; odometerEnd?: number; waypoints?: WaypointCollection; diff --git a/src/libs/TransactionPreviewUtils.ts b/src/libs/TransactionPreviewUtils.ts index fe22cfde1635..d930be5685ec 100644 --- a/src/libs/TransactionPreviewUtils.ts +++ b/src/libs/TransactionPreviewUtils.ts @@ -18,7 +18,14 @@ import {isCategoryMissing} from './CategoryUtils'; import DateUtils from './DateUtils'; import createDynamicRoute from './Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {hasDynamicExternalWorkflow, isGroupPolicy as isGroupPolicyUtil} from './PolicyUtils'; -import {getMostRecentActiveDEWSubmitFailedAction, getOriginalMessage, isDynamicExternalWorkflowSubmitFailedAction, isMessageDeleted, isMoneyRequestAction} from './ReportActionsUtils'; +import { + getMostRecentActiveDEWSubmitFailedAction, + getOriginalMessage, + isDeletedAction, + isDynamicExternalWorkflowSubmitFailedAction, + isMessageDeleted, + isMoneyRequestAction, +} from './ReportActionsUtils'; import {hasActionWithErrorsForTransaction, hasReceiptError, isExpenseReport, isReportApproved, isSettled} from './ReportUtils'; import StringUtils from './StringUtils'; import { @@ -418,7 +425,7 @@ function createTransactionPreviewConditionals({ const isFullySettled = isMoneyRequestSettled && !isSettlementOrApprovalPartial; const isFullyApproved = isApproved && !isSettlementOrApprovalPartial; - const shouldShowSkeleton = isEmptyObject(transaction) && !isMessageDeleted(action) && action?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const shouldShowSkeleton = isEmptyObject(transaction) && !isMessageDeleted(action) && !isDeletedAction(action) && action?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const shouldShowTag = !!tag && isReportAPolicyExpenseChat; const categoryForDisplay = isCategoryMissing(category) ? '' : category; diff --git a/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts index 050bd866d86f..c264603ede58 100644 --- a/src/libs/actions/IOU/SplitExpenseItems.ts +++ b/src/libs/actions/IOU/SplitExpenseItems.ts @@ -80,6 +80,7 @@ function updateSplitExpenseDistanceFromAmount( const customUnit: TransactionCustomUnit = { ...existingCustomUnit, quantity, + distanceUnit: unit, }; const merchant = getDistanceMerchantFromDistance(distanceInUnits, unit, rate, transactionCurrency ?? mileageRate?.currency ?? CONST.CURRENCY.USD, getCurrencySymbol); @@ -152,6 +153,39 @@ function resolveSplitMileageRate({ return baseMileageRate; } +/** + * Resolve the rate and unit a split item is calculated with: its own selected rate when that rate still + * resolves (it can live on another workspace), and the rate of the expense being split otherwise. + * + * The unit stays the one the expense is stored with — a workspace switching between miles and kilometers + * doesn't re-express expenses that already exist, so the splits follow the same unit as their expense. + */ +function resolveSplitItemRate({ + customUnit, + fallbackMileageRate, + policy, + policies, +}: { + customUnit: TransactionCustomUnit | undefined; + fallbackMileageRate: ReturnType; + policy: OnyxEntry; + policies?: OnyxCollection; +}): {rate: number | undefined; unit: Unit | undefined} { + const unit = customUnit?.distanceUnit ?? fallbackMileageRate.unit; + const customUnitRateID = customUnit?.customUnitRateID; + if (!customUnitRateID || customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID) { + return {rate: fallbackMileageRate.rate, unit}; + } + + const selectedRate = + DistanceRequestUtils.getRateByCustomUnitRateID({policy, customUnitRateID}) ?? DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(customUnitRateID, policies); + if (!selectedRate?.rate || selectedRate.rate <= 0 || selectedRate.enabled === false) { + return {rate: fallbackMileageRate.rate, unit}; + } + + return {rate: selectedRate.rate, unit}; +} + function resolveSplitItemReportID({ childTransaction, allReports, @@ -306,6 +340,7 @@ function addSplitExpenseField( isSelfDMSplit: boolean, personalPolicyOutputCurrency: string | undefined, getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], + policies?: OnyxCollection, ) { if (!transaction || !draftTransaction) { return; @@ -327,11 +362,20 @@ function addSplitExpenseField( : undefined; const mileageRate = resolveSplitMileageRate({transaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); - const {unit, rate} = mileageRate; + const {unit, rate} = resolveSplitItemRate({customUnit, fallbackMileageRate: mileageRate, policy, policies}); if (rate && rate > 0 && customUnit) { // For amount = 0, distance = 0, but we still calculate merchant format - const {merchant: calculatedMerchant} = updateSplitExpenseDistanceFromAmount(0, rate, unit, customUnit, mileageRate, getCurrencySymbol, transaction.currency); + const {customUnit: updatedCustomUnit, merchant: calculatedMerchant} = updateSplitExpenseDistanceFromAmount( + 0, + rate, + unit, + customUnit, + mileageRate, + getCurrencySymbol, + transaction.currency, + ); + customUnit = updatedCustomUnit; merchant = calculatedMerchant; } } @@ -390,6 +434,7 @@ function evenlyDistributeSplitExpenseAmounts( isSelfDMSplit: boolean, personalPolicyOutputCurrency: string | undefined, getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], + policies?: OnyxCollection, ) { if (!draftTransaction) { return; @@ -409,15 +454,14 @@ function evenlyDistributeSplitExpenseAmounts( const isDistanceRequest = transaction && isDistanceRequestTransactionUtils(transaction); - // Floor-allocation with full remainder added to the last split so the last is always the largest + // Floor-allocation with the full remainder added to the first split, the way the amounts are allocated when + // the splits are created, so distributing them evenly doesn't move the remainder from one split to another const splitCount = splitExpenses.length; - const lastIndex = splitCount - 1; const mileageRate = resolveSplitMileageRate({transaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); - const {unit, rate} = mileageRate; const updatedSplitExpenses = splitExpenses.map((splitExpense, index) => { - const amount = calculateIOUAmount(splitCount - 1, total, currency, index === lastIndex, true); + const amount = calculateIOUAmount(splitCount - 1, total, currency, index === 0, true); let updatedSplitExpense: SplitExpense = { ...splitExpense, amount, @@ -427,6 +471,7 @@ function evenlyDistributeSplitExpenseAmounts( // Update distance for distance transactions based on new amount and rate if (isDistanceRequest && transaction && splitExpense.customUnit && amount !== 0) { + const {unit, rate} = resolveSplitItemRate({customUnit: splitExpense.customUnit, fallbackMileageRate: mileageRate, policy, policies}); if (rate && rate > 0) { const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( amount, @@ -476,6 +521,7 @@ function resetSplitExpensesByDateRange( isSelfDMSplit: boolean, personalPolicyOutputCurrency: string | undefined, getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], + policies?: OnyxCollection, ) { if (!transaction || !draftTransaction || !startDate || !endDate) { return; @@ -494,12 +540,10 @@ function resetSplitExpensesByDateRange( const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const mileageRate = resolveSplitMileageRate({transaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); - const {unit, rate} = mileageRate; - // Create split expenses for each date with proportional amounts - const lastIndex = dates.length - 1; + // Create split expenses for each date with proportional amounts, the remainder going to the first one const newSplitExpenses: SplitExpense[] = dates.map((date, index) => { - const amount = calculateIOUAmount(lastIndex, total, currency, index === lastIndex, true); + const amount = calculateIOUAmount(dates.length - 1, total, currency, index === 0, true); let splitExpense = initSplitExpenseItemData(transaction, transactionReport, { amount, transactionID: rand64(), @@ -509,6 +553,7 @@ function resetSplitExpensesByDateRange( // Update distance for distance transactions based on new amount and rate if (isDistanceRequest && splitExpense.customUnit && amount !== 0) { + const {unit, rate} = resolveSplitItemRate({customUnit: splitExpense.customUnit, fallbackMileageRate: mileageRate, policy, policies}); if (rate && rate > 0) { const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( amount, @@ -583,6 +628,7 @@ function updateSplitExpenseField( isSelfDMSplit: boolean, personalPolicyOutputCurrency: string | undefined, getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], + policies?: OnyxCollection, ) { if (!splitExpenseDraftTransaction || !splitExpenseTransactionID || !originalTransactionDraft) { return; @@ -626,7 +672,7 @@ function updateSplitExpenseField( // Recalculate amount for distance transactions when rate or distance changes if (isDistanceRequest && originalTransaction) { const mileageRate = resolveSplitMileageRate({transaction: splitExpenseDraftTransaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); - const {unit, rate} = mileageRate; + const {unit, rate} = resolveSplitItemRate({customUnit: splitExpenseDraftTransaction?.comment?.customUnit, fallbackMileageRate: mileageRate, policy, policies}); if (rate && rate > 0) { // Get distance from routes or customUnit.quantity (same logic as in initSplitExpense) @@ -702,20 +748,7 @@ function updateSplitExpenseAmountField( // Update distance for distance transactions based on new amount and rate if (isDistanceRequest && originalTransaction && splitExpense.customUnit) { const mileageRate = resolveSplitMileageRate({transaction: originalTransaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); - const splitRateID = splitExpense.customUnit?.customUnitRateID ?? String(CONST.DEFAULT_NUMBER_ID); - - // `policy` is undefined for a self-DM split on the personal rate, so also resolve the split's - // picked rate across all policies, so the selection isn't lost. - const splitSelectedRate = - DistanceRequestUtils.getRateByCustomUnitRateID({policy, customUnitRateID: splitRateID}) ?? - DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(splitRateID, policies); - const isSplitP2PRate = splitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID; - - // Prefer the split's own selected rate when it's a real enabled rate; otherwise fall back to - // the original-transaction rate (covers the P2P and deleted-rate cases). - const useSplitSelectedRate = !isSplitP2PRate && !!splitSelectedRate?.rate && splitSelectedRate.rate > 0 && splitSelectedRate.enabled !== false; - const rate = useSplitSelectedRate ? (splitSelectedRate?.rate ?? 0) : mileageRate.rate; - const unit = useSplitSelectedRate ? (splitSelectedRate?.unit ?? mileageRate.unit) : mileageRate.unit; + const {unit, rate} = resolveSplitItemRate({customUnit: splitExpense.customUnit, fallbackMileageRate: mileageRate, policy, policies}); if (rate && rate > 0) { const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index b63a1160807f..23bd6ceff9cc 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -345,6 +345,7 @@ function updateSplitTransactions({ billable: split?.billable, quantity: split.customUnit?.quantity ?? undefined, customUnitRateID: split.customUnit?.customUnitRateID, + distanceUnit: split.customUnit?.distanceUnit, odometerStart: split.odometerStart, odometerEnd: split.odometerEnd, waypoints: split.waypoints, @@ -802,6 +803,7 @@ function updateSplitTransactions({ const oldTransactionChanges = { ...existing, quantity: splitTransaction.comment?.customUnit?.quantity ?? existing?.distance, + distanceUnit: splitTransaction.comment?.customUnit?.distanceUnit, } as TransactionChanges; if (currentSplit) { diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 6e075ccc6327..66ba20cfb447 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -1532,6 +1532,10 @@ function getChangeTransactionsReportOnyxData({ IOUTransactionID: null, }, errors: undefined, + // The expense gets its own action on the new report, so this one is retired: clear its pending + // state too, since `shouldReportActionBeVisible` keeps a pending action on screen. + // `failureData` below restores the whole action. + pendingAction: null, }, ...(trackExpenseActionableWhisper ? {[trackExpenseActionableWhisper.reportActionID]: null} : {}), }, diff --git a/src/pages/iou/SplitExpenseCreateDateRagePage.tsx b/src/pages/iou/SplitExpenseCreateDateRagePage.tsx index 28efdce2b0f5..70112257b75c 100644 --- a/src/pages/iou/SplitExpenseCreateDateRagePage.tsx +++ b/src/pages/iou/SplitExpenseCreateDateRagePage.tsx @@ -49,6 +49,7 @@ function SplitExpenseCreateDateRagePage({route}: SplitExpenseCreateDateRagePageP const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const allTransactions = useAllTransactions(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`]; const originalTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`]; @@ -73,6 +74,7 @@ function SplitExpenseCreateDateRagePage({route}: SplitExpenseCreateDateRagePageP isSelfDM(currentReport) || isSelfDM(parentReport), personalPolicy?.outputCurrency, getCurrencySymbol, + allPolicies, ); Navigation.goBack(backTo); }; diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index b7405e478b05..b67b0ffd0703 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -483,6 +483,7 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { isSelfDMSplit, personalPolicy?.outputCurrency, getCurrencySymbol, + allPolicies, ); Navigation.goBack(backTo); }} diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index a880faeb59ae..078276e74b63 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -294,14 +294,14 @@ function SplitExpensePage({route}: SplitExpensePageProps) { if (draftTransaction?.errors) { clearSplitTransactionDraftErrors(transactionID); } - addSplitExpenseField(transaction, draftTransaction, transactionReport, effectivePolicy, isDraftSelfDMContext, personalPolicy?.outputCurrency, getCurrencySymbol); + addSplitExpenseField(transaction, draftTransaction, transactionReport, effectivePolicy, isDraftSelfDMContext, personalPolicy?.outputCurrency, getCurrencySymbol, allPolicies); }; const onMakeSplitsEven = () => { if (!draftTransaction) { return; } - evenlyDistributeSplitExpenseAmounts(draftTransaction, transaction, effectivePolicy, isDraftSelfDMContext, personalPolicy?.outputCurrency, getCurrencySymbol); + evenlyDistributeSplitExpenseAmounts(draftTransaction, transaction, effectivePolicy, isDraftSelfDMContext, personalPolicy?.outputCurrency, getCurrencySymbol, allPolicies); }; const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: passthroughPolicyTagListSelector}); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 3d54294a3c72..f72be36f0866 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -7430,6 +7430,78 @@ describe('addSplitExpenseField', () => { expect(splitExpenses?.[1].merchant).toBeDefined(); }); + it('should build a new split on the unit the expense is stored with', async () => { + // Given a distance expense split in miles, whose workspace unit was later switched to kilometers + const customUnitRateID = 'rate-unit-switch'; + const customUnitID = 'distance-unit'; + const policy: Policy = { + ...createRandomPolicy(4), + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, + rates: { + [customUnitRateID]: {customUnitRateID, currency: CONST.CURRENCY.USD, rate: 100, enabled: true, name: 'Default Rate', subRates: []}, + }, + }, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await waitForBatchedUpdates(); + + const customUnit: TransactionCustomUnit = { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 200, + }; + const transaction: Transaction = { + transactionID: 'unit-switch-original', + amount: -20000, + currency: 'USD', + merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: {comment: '', splitExpenses: [], attendees: [], type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit}, + created: DateUtils.getDBTime(), + reportID: '456', + }; + const draftTransaction: Transaction = { + ...transaction, + amount: 20000, + comment: { + comment: '', + splitExpenses: [ + { + transactionID: 'unit-switch-split', + amount: 10000, + description: '', + category: '', + tags: [], + created: DateUtils.getDBTime(), + customUnit: {...customUnit, quantity: 100}, + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + }; + const transactionReport: Report = {reportID: '456', type: CONST.REPORT.TYPE.EXPENSE, total: 20000, currency: 'USD'}; + + // When another split is added + addSplitExpenseField(transaction, draftTransaction, transactionReport, policy, false, undefined, getCurrencySymbol); + await waitForBatchedUpdates(); + + // Then it is stored on that same unit, so the merchant and the Distance field agree + const updatedDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`); + const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; + expect(splitExpenses).toHaveLength(2); + expect(splitExpenses?.[1].customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + expect(splitExpenses?.[1].merchant).toContain(CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + }); + it('should use EUR P2P mileage rate and EUR currency in merchant when personalPolicyOutputCurrency is EUR', async () => { // For a P2P distance transaction in EUR: personalPolicyOutputCurrency selects the EUR P2P rate (30¢/mi). // transaction.currency drives the merchant currency symbol, so both must be EUR for €0.30/mi to appear. @@ -7536,7 +7608,7 @@ describe('addSplitExpenseField', () => { }); describe('evenlyDistributeSplitExpenseAmounts', () => { - it('distributes evenly across 3 splits with remainder on last split', async () => { + it('distributes evenly across 3 splits with remainder on the first split', async () => { const originalTransactionID = 'orig-last'; const draftTransaction: Transaction = { transactionID: 'draft-2', @@ -7564,7 +7636,7 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); expect(updatedDraft).toBeTruthy(); const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([33, 33, 34]); + expect(amounts).toEqual([34, 33, 33]); }); it('assigns full amount when there is only one split', async () => { @@ -7653,7 +7725,7 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { expect(amounts).toEqual([50, 50]); }); - it('2-way split with remainder (odd cents) -> 50¢ / 51¢', async () => { + it('2-way split with remainder (odd cents) -> 51¢ / 50¢', async () => { const originalTransactionID = 'orig-2-rem'; const draftTransaction: Transaction = { transactionID: 'draft-6', @@ -7679,10 +7751,10 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([50, 51]); + expect(amounts).toEqual([51, 50]); }); - it('3-way split of $1001 with remainder -> [$333.66, $333.66, $333.68]', async () => { + it('3-way split of $1001 with remainder -> [$333.68, $333.66, $333.66]', async () => { const originalTransactionID = 'orig-1001-3-last'; const draftTransaction: Transaction = { transactionID: 'draft-7', @@ -7709,11 +7781,11 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([33366, 33366, 33368]); + expect(amounts).toEqual([33368, 33366, 33366]); expect(amounts.reduce((a, b) => a + b, 0)).toBe(100100); }); - it('preserves negative sign and evenly distributes with remainder on last for 3-way split', async () => { + it('preserves negative sign and evenly distributes with remainder on the first split for a 3-way split', async () => { const originalTransactionID = 'orig-neg-3'; const draftTransaction: Transaction = { transactionID: 'draft-neg-3', @@ -7740,7 +7812,7 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([-33, -33, -34]); + expect(amounts).toEqual([-34, -33, -33]); expect(amounts.reduce((a, b) => a + b, 0)).toBe(-100); }); @@ -7770,7 +7842,7 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); - expect(amounts).toEqual([-50, -51]); + expect(amounts).toEqual([-51, -50]); expect(amounts.reduce((a, b) => a + b, 0)).toBe(-101); }); @@ -7882,6 +7954,106 @@ describe('evenlyDistributeSplitExpenseAmounts', () => { expect(splitExpenses.at(1)?.merchant).toBeTruthy(); expect(splitExpenses.at(1)?.merchant).toContain('100'); }); + + it('should distribute the splits on the rate each one is calculated with', async () => { + // Given an expense stored in miles whose splits were switched to a rate that another workspace keeps in kilometers + const originalTransactionID = 'even-selected-rate-original'; + const expenseCustomUnitID = 'even-selected-rate-expense-unit'; + const expenseRateID = 'even-selected-rate-expense-rate'; + const selectedCustomUnitID = 'even-selected-rate-selected-unit'; + const selectedRateID = 'even-selected-rate-selected-rate'; + const expensePolicy: Policy = { + ...createRandomPolicy(11), + customUnits: { + [expenseCustomUnitID]: { + customUnitID: expenseCustomUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + [expenseRateID]: {customUnitRateID: expenseRateID, currency: CONST.CURRENCY.USD, rate: 100, enabled: true, name: 'Expense Rate', subRates: []}, + }, + }, + }, + }; + const selectedRatePolicy: Policy = { + ...createRandomPolicy(12), + customUnits: { + [selectedCustomUnitID]: { + customUnitID: selectedCustomUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, + rates: { + [selectedRateID]: {customUnitRateID: selectedRateID, currency: CONST.CURRENCY.USD, rate: 50, enabled: true, name: 'Selected Rate', subRates: []}, + }, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${expensePolicy.id}`, expensePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${selectedRatePolicy.id}`, selectedRatePolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, { + transactionID: originalTransactionID, + amount: -20000, + currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: expenseCustomUnitID, + customUnitRateID: expenseRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 200, + }, + }, + }); + await waitForBatchedUpdates(); + + const splitCustomUnit = { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: selectedCustomUnitID, + customUnitRateID: selectedRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + quantity: 200, + }; + const draftTransaction: Transaction = { + transactionID: 'even-selected-rate-draft', + amount: 20000, + currency: 'USD', + merchant: 'Test Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + comment: '', + originalTransactionID, + splitExpenses: [ + {transactionID: 'even-selected-rate-first', amount: 15000, description: '', category: '', tags: [], created: DateUtils.getDBTime(), customUnit: splitCustomUnit}, + {transactionID: 'even-selected-rate-second', amount: 5000, description: '', category: '', tags: [], created: DateUtils.getDBTime(), customUnit: splitCustomUnit}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: 'even-selected-rate-report', + }; + + // When the splits are distributed evenly + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + evenlyDistributeSplitExpenseAmounts(draftTransaction, originalTransaction, expensePolicy, false, undefined, getCurrencySymbol, { + [`${ONYXKEYS.COLLECTION.POLICY}${selectedRatePolicy.id}`]: selectedRatePolicy, + }); + await waitForBatchedUpdates(); + + // Then every split is measured with the rate it carries, on the unit it is stored with + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const splitExpenses = updatedDraft?.comment?.splitExpenses ?? []; + expect(splitExpenses.at(0)?.customUnit?.quantity).toBe(200); + expect(splitExpenses.at(0)?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + expect(splitExpenses.at(0)?.merchant).toContain(`200.00 ${CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}`); + expect(splitExpenses.at(1)?.customUnit?.quantity).toBe(200); + expect(splitExpenses.at(1)?.customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + }); }); describe('updateSplitExpenseAmountField', () => { @@ -8019,6 +8191,103 @@ describe('updateSplitExpenseAmountField', () => { expect(splitExpenses?.[0].merchant).toBeTruthy(); expect(splitExpenses?.[0].merchant).toContain('150'); }); + + it('should keep the unit the expense is stored with when the workspace distance unit changed', async () => { + // Given a distance split created in miles, whose workspace unit was later switched to kilometers + const customUnitRateID = 'rate-unit-change'; + const customUnitID = 'distance-unit'; + const originalTransactionID = '321'; + const currentTransactionID = '987'; + const policy: Policy = { + ...createRandomPolicy(2), + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS, + }, + rates: { + [customUnitRateID]: { + customUnitRateID, + currency: CONST.CURRENCY.USD, + rate: 100, + enabled: true, + name: 'Default Rate', + subRates: [], + }, + }, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, { + transactionID: originalTransactionID, + amount: -20000, + currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 200, + }, + }, + }); + await waitForBatchedUpdates(); + + const draftTransaction: Transaction = { + transactionID: '654', + amount: 20000, + currency: 'USD', + merchant: 'Test Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + comment: 'Test comment', + originalTransactionID, + splitExpenses: [ + { + transactionID: currentTransactionID, + amount: 10000, + description: 'Test comment', + category: 'Car', + tags: [], + created: DateUtils.getDBTime(), + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 100, + }, + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + category: 'Car', + created: DateUtils.getDBTime(), + reportID: '456', + }; + + // When the amount of the split is edited + updateSplitExpenseAmountField(draftTransaction, currentTransactionID, 15000, policy, false, undefined, getCurrencySymbol); + await waitForBatchedUpdates(); + + // Then the split stays on the unit the expense is stored with, and the distance it stores is expressed in that + // same unit, so the merchant and the Distance field agree + const updatedDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; + expect(splitExpenses?.[0].customUnit?.distanceUnit).toBe(CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + expect(splitExpenses?.[0].customUnit?.quantity).toBe(150); + expect(splitExpenses?.[0].merchant).toContain(`150.00 ${CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`); + expect(splitExpenses?.[0].merchant).not.toContain(CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS); + }); }); describe('setDraftSplitTransaction', () => { diff --git a/tests/actions/TransactionTest.ts b/tests/actions/TransactionTest.ts index daac3be9f4d2..9e0e438ce89a 100644 --- a/tests/actions/TransactionTest.ts +++ b/tests/actions/TransactionTest.ts @@ -11,8 +11,8 @@ import '@libs/actions/IOU/MoneyRequest'; import {createWorkspace, generatePolicyID, setWorkspaceApprovalMode} from '@libs/actions/Policy/Policy'; import {createNewReport} from '@libs/actions/Report'; import type * as PolicyUtils from '@libs/PolicyUtils'; -import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {getReportOrDraftReport} from '@libs/ReportUtils'; +import {getOriginalMessage, isMoneyRequestAction, shouldReportActionBeVisible} from '@libs/ReportActionsUtils'; +import {buildOptimisticIOUReportAction, getReportOrDraftReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -351,6 +351,83 @@ describe('actions/Transaction', () => { expect(updatedExpenseReport?.unheldNonReimbursableTotal).toBe(-amount); }); + it('stops rendering the self-DM report action once its expense moves to a report, even when the action still carries a pending state', async () => { + // Given an unreported expense in the self-DM whose IOU action is still marked as pending, which happens when + // an earlier optimistic write on that action was never resolved + const selfDMReport: Report = {...createRandomReport(77, CONST.REPORT.CHAT_TYPE.SELF_DM), reportID: '77'}; + const movePolicy: Policy = {...createRandomPolicy(78, CONST.POLICY.TYPE.TEAM, 'Move Workspace'), id: 'policy-for-move'}; + const workspaceChat: Report = {...createRandomReport(79, CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT), reportID: '79', policyID: movePolicy.id}; + const destinationReport: Report = { + ...createRandomReport(80), + reportID: '80', + type: CONST.REPORT.TYPE.EXPENSE, + policyID: movePolicy.id, + chatReportID: workspaceChat.reportID, + ownerAccountID: CARLOS_ACCOUNT_ID, + }; + const movedTransaction: Transaction = { + transactionID: 'transaction-to-move', + amount: -5000, + currency: CONST.CURRENCY.USD, + merchant: 'merchant', + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + comment: {comment: ''}, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + const trackedExpenseAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount: 5000, + currency: CONST.CURRENCY.USD, + comment: '', + participants: [{accountID: CARLOS_ACCOUNT_ID, login: CARLOS_EMAIL}], + transactionID: movedTransaction.transactionID, + isPersonalTrackingExpense: true, + }), + reportID: selfDMReport.reportID, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; + + await Onyx.merge(ONYXKEYS.SESSION, {email: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID}); + await Onyx.merge(ONYXKEYS.SELF_DM_REPORT_ID, selfDMReport.reportID); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${movePolicy.id}`, movePolicy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${workspaceChat.reportID}`, workspaceChat); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${destinationReport.reportID}`, destinationReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${movedTransaction.transactionID}`, movedTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, {[trackedExpenseAction.reportActionID]: trackedExpenseAction}); + await waitForBatchedUpdates(); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, callback: (value) => (allTransactions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, callback: (value) => (allReports = value)}); + + // When the expense is moved to a workspace report + changeTransactionsReport({ + transactionIDs: [movedTransaction.transactionID], + isASAPSubmitBetaEnabled: false, + accountID: CARLOS_ACCOUNT_ID, + email: CARLOS_EMAIL, + newReport: destinationReport, + policy: movePolicy, + allTransactions, + policyTagList: {}, + transactionViolations: {}, + allReports, + selfDMReportActions: {[trackedExpenseAction.reportActionID]: trackedExpenseAction}, + isTrackIntentUser: false, + }); + await waitForBatchedUpdates(); + + // Then the action left behind in the self-DM holds no transaction, no pending state, and is not rendered + const selfDMActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`); + const retiredAction = selfDMActions?.[trackedExpenseAction.reportActionID]; + expect(isMoneyRequestAction(retiredAction) ? getOriginalMessage(retiredAction)?.IOUTransactionID : undefined).toBeFalsy(); + expect(retiredAction?.pendingAction).toBeFalsy(); + expect(shouldReportActionBeVisible(retiredAction, trackedExpenseAction.reportActionID, true, CARLOS_ACCOUNT_ID)).toBe(false); + }); + it('recomputes a distance expense amount/merchant/currency from the destination workspace rate when moved', async () => { // Given a destination workspace whose default distance rate is defined in GBP (200/mi) const policyID = generatePolicyID(); diff --git a/tests/unit/TransactionPreviewUtils.test.ts b/tests/unit/TransactionPreviewUtils.test.ts index 8d0eca4093bf..353fe66b9cfc 100644 --- a/tests/unit/TransactionPreviewUtils.test.ts +++ b/tests/unit/TransactionPreviewUtils.test.ts @@ -471,6 +471,25 @@ describe('TransactionPreviewUtils', () => { expect(result.shouldShowSkeleton).toBeTruthy(); }); + it('should not show skeleton for an action the backend marked deleted, whose transaction will never arrive', () => { + // Given a money request action deleted the way the backend reports it — `deleted` timestamps on the + // message and the original message rather than the `isDeletedParentAction` flag — and no transaction + const functionArgs = { + ...basicProps, + transaction: undefined, + action: { + ...basicProps.action, + message: [{type: 'TEXT', text: '', deleted: '2026-07-30 10:31:05.644'}], + }, + }; + + // When the preview conditionals are computed + const result = createTransactionPreviewConditionals(functionArgs); + + // Then the preview stays out of the loading state instead of waiting for a transaction that is gone + expect(result.shouldShowSkeleton).toBeFalsy(); + }); + it('should show merchant if merchant data is valid and significant', () => { const functionArgs = {...basicProps, transactionDetails: {merchant: 'Valid Merchant'}}; const result = createTransactionPreviewConditionals(functionArgs); diff --git a/tests/unit/hooks/useDeleteTransactions.test.ts b/tests/unit/hooks/useDeleteTransactions.test.ts new file mode 100644 index 000000000000..bbd0e936586c --- /dev/null +++ b/tests/unit/hooks/useDeleteTransactions.test.ts @@ -0,0 +1,110 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import useDeleteTransactions from '@hooks/useDeleteTransactions'; + +import {buildOptimisticIOUReportAction} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import DateUtils from '@src/libs/DateUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportActions} from '@src/types/onyx'; +import type ReportAction from '@src/types/onyx/ReportAction'; +import type Transaction from '@src/types/onyx/Transaction'; + +import Onyx from 'react-native-onyx'; + +import {createSelfDM} from '../../utils/collections/reports'; +import getOnyxValue from '../../utils/getOnyxValue'; +import {getGlobalFetchMock} from '../../utils/TestHelper'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + dismissModal: jest.fn(), + goBack: jest.fn(), + getTopmostReportId: jest.fn(() => '1'), + setNavigationActionToMicrotaskQueue: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(), + getActiveRoute: jest.fn(), + navigationRef: {getRootState: jest.fn(), isReady: jest.fn(() => true)}, +})); +jest.mock('@react-navigation/native'); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); +jest.mock('@src/libs/actions/Report', () => ({ + ...jest.requireActual>('@src/libs/actions/Report'), + notifyNewAction: jest.fn(), + setDeleteTransactionNavigateBackUrl: jest.fn(), +})); + +const RORY_EMAIL = 'rory@expensifail.com'; +const RORY_ACCOUNT_ID = 3; + +beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + return waitForBatchedUpdates(); +}); + +beforeEach(async () => { + global.fetch = getGlobalFetchMock(); + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + }); +}); + +describe('useDeleteTransactions', () => { + it('deletes the self-DM IOU action along with an unreported expense instead of leaving an orphaned preview', async () => { + // Given an unreported (self-DM) expense whose IOU action lives in the self-DM chat, with no IOU report + const selfDMReport = createSelfDM(2, RORY_ACCOUNT_ID); + const transactionID = 'unreported-transaction'; + const transaction: Transaction = { + transactionID, + amount: -10000, + currency: 'USD', + merchant: 'Test Merchant', + comment: {comment: 'Test comment'}, + created: DateUtils.getDBTime(), + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + const iouAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount: 10000, + currency: 'USD', + comment: '', + participants: [{accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}], + transactionID, + isPersonalTrackingExpense: true, + }), + reportID: selfDMReport.reportID, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.merge(ONYXKEYS.SELF_DM_REPORT_ID, selfDMReport.reportID); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, {[iouAction.reportActionID]: iouAction}); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useDeleteTransactions({report: selfDMReport, reportActions: [iouAction]}), {wrapper: OnyxListItemProvider}); + await waitForBatchedUpdates(); + + // When the expense is deleted (e.g. from Search / a bulk selection) + act(() => { + result.current.deleteTransactions([transactionID], {}, {}); + }); + await waitForBatchedUpdates(); + + // Then the IOU action in the self-DM report is emptied out along with the transaction + const selfDMActions = (await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`)) as ReportActions | undefined; + const deletedAction = selfDMActions?.[iouAction.reportActionID]; + expect(Array.isArray(deletedAction?.message) ? deletedAction?.message.at(0)?.html : undefined).toBe(''); + + // And the report actions are keyed on the self-DM report, not on a missing IOU report + await expect(getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}undefined`)).resolves.toBeFalsy(); + }); +});