diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 3dcd1b037b7d..0c1992e2939a 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1766,7 +1766,7 @@ "../../tests/actions/IOUTest/ReportWorkflowTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 62 "../../tests/actions/IOUTest/SendInvoiceTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/actions/IOUTest/SplitSelfDMTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/actions/IOUTest/SplitTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 +"../../tests/actions/IOUTest/SplitTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 "../../tests/actions/IOUTest/TrackExpenseTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/actions/IOUTest/TrackExpenseTest.ts" "no-restricted-imports" 1 "../../tests/actions/IOUTest/UpdateMoneyRequestTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index 7cf9975cd254..3abe9e417f85 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -15,6 +15,7 @@ import { getChildTransactions, getOriginalTransactionWithSplitInfo, isPerDiemRequest as isPerDiemRequestTransactionUtils, + isSplitChildTransaction, shouldRedirectDeleteToSplitExpenseEdit, } from '@libs/TransactionUtils'; @@ -58,6 +59,8 @@ type DeleteTransactionsResult = deletedTransactionThreadReportIDs: string[]; }; +type TransactionWithAction = {transactionID: string; action?: ReportAction; transaction?: Transaction}; + function redistributeRemainingPerDiemSplitExpenses(splitExpenses: SplitExpense[], total: number, currency: string): SplitExpense[] { const lastSplitIndex = splitExpenses.length - 1; @@ -222,7 +225,11 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac return acc; } - if (isExpenseSplit && originalTransactionID) { + const transactionCurrentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; + const isMovedExpenseSplitChild = + !isExpenseSplit && !!originalTransactionID && isSplitChildTransaction(transaction) && !originalTransaction?.comment?.splits && isIOUReport(transactionCurrentReport); + + if ((isExpenseSplit || isMovedExpenseSplitChild) && originalTransactionID) { acc.splitTransactionsByOriginalTransactionID[originalTransactionID] ??= []; acc.splitTransactionsByOriginalTransactionID[originalTransactionID].push(item); } else { @@ -232,8 +239,8 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac return acc; }, {splitTransactionsByOriginalTransactionID: {}, nonSplitTransactions: []} as { - splitTransactionsByOriginalTransactionID: Record>; - nonSplitTransactions: Array<{transactionID: string; action?: ReportAction; transaction?: Transaction}>; + splitTransactionsByOriginalTransactionID: Record; + nonSplitTransactions: TransactionWithAction[]; }, ); diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 8a1fc433dbdf..ae84f195b636 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -8,7 +8,7 @@ import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {format, parseISO} from 'date-fns'; @@ -596,6 +596,24 @@ function getRateByCustomUnitRateID({customUnitRateID, policy}: {customUnitRateID return getMileageRates(policy, true, customUnitRateID)[customUnitRateID]; } +/** + * Resolve an enabled mileage rate by its ID across every policy the user belongs to. + * Useful when the caller doesn't know which policy owns the rate (e.g. a self-DM split on the personal rate). + * Returns `undefined` for the P2P rate, a missing ID, or a disabled/deleted rate. + */ +function getEnabledRateByCustomUnitRateIDFromAnyPolicy(customUnitRateID: string | undefined, policies: OnyxCollection): MileageRate | undefined { + if (!customUnitRateID || customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID) { + return undefined; + } + for (const policy of Object.values(policies ?? {})) { + const rate = getMileageRates(policy)[customUnitRateID]; + if (rate) { + return rate; + } + } + return undefined; +} + /** * Returns whether the selected custom unit rate is out of its valid date range for the given expense date. */ @@ -696,6 +714,7 @@ export default { getUpdatedDistanceUnit, getRate, getRateByCustomUnitRateID, + getEnabledRateByCustomUnitRateIDFromAnyPolicy, getDistanceForDisplayLabel, convertDistanceUnit, getRateForExpenseDisplay, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index bb6a660ce059..ce3efe3df495 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -104,7 +104,7 @@ function getActivePolicies(policies: OnyxCollection | null, currentUserL * These will be policies that has expense chat enabled. * These are policies that we can use to create reports with in NewDot. */ -function getActivePoliciesWithExpenseChat(policies: OnyxCollection | null, currentUserLogin: string | undefined): Policy[] { +function getActivePoliciesWithExpenseChat(policies: OnyxCollection | null, currentUserLogin: string | undefined, isSubmit2026BetaEnabled = false): Policy[] { return Object.values(policies ?? {}).filter( (policy): policy is Policy => !!policy && @@ -112,7 +112,7 @@ function getActivePoliciesWithExpenseChat(policies: OnyxCollection | nul !!policy.name && !!policy.id && !!getPolicyRole(policy, currentUserLogin) && - isPaidGroupPolicy(policy), + (isPaidGroupPolicy(policy) || canAccessSubmitWorkspaceFeatures(policy, isSubmit2026BetaEnabled)), ); } @@ -2883,6 +2883,7 @@ export { canPolicyAccessFeature, escapeTagName, getActivePolicies, + getActivePoliciesWithExpenseChat, getAdminEmployees, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 879516700609..676e97760f0e 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -607,6 +607,7 @@ function getUpdatedTransaction({ isFromExpenseReport, shouldUpdateReceiptState = true, policy = undefined, + policies = undefined, isSplitTransaction = false, personalPolicyOutputCurrency, }: { @@ -615,6 +616,7 @@ function getUpdatedTransaction({ isFromExpenseReport: boolean; shouldUpdateReceiptState?: boolean; policy?: OnyxEntry; + policies?: OnyxCollection; isSplitTransaction?: boolean; personalPolicyOutputCurrency: string | undefined; }): Transaction { @@ -737,7 +739,25 @@ function getUpdatedTransaction({ // When the waypoints are being fetched from the server, we have no information about the distance, and cannot recalculate the updated amount. // Otherwise, recalculate the fields based on the new rate. - const updatedMileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, useTransactionDistanceUnit: false, personalPolicyOutputCurrency}); + let updatedMileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, useTransactionDistanceUnit: false, personalPolicyOutputCurrency}); + // The provided `policy` may not own the new rate, leaving the amount at 0. Fall back to + // resolving the rate across every policy the user belongs to. + if (!updatedMileageRate.rate && transactionChanges.customUnitRateID) { + const rateFromAnyPolicy = DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(transactionChanges.customUnitRateID, policies); + if (rateFromAnyPolicy?.rate) { + updatedMileageRate = rateFromAnyPolicy; + + // The fallback rate wasn't known when the distance unit/quantity were set above from the + // (rate-less) provided policy, so redo that conversion against the fallback rate's actual unit. + if (rateFromAnyPolicy.unit && rateFromAnyPolicy.unit !== newDistanceUnit && !isOdometerDistanceRequest(transaction)) { + lodashSet(updatedTransaction, 'comment.customUnit.distanceUnit', rateFromAnyPolicy.unit); + const fallbackConversionFactor = + newDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? CONST.CUSTOM_UNITS.MILES_TO_KILOMETERS : CONST.CUSTOM_UNITS.KILOMETERS_TO_MILES; + const currentQuantity = updatedTransaction?.comment?.customUnit?.quantity ?? 0; + lodashSet(updatedTransaction, 'comment.customUnit.quantity', roundToTwoDecimalPlaces(currentQuantity * fallbackConversionFactor)); + } + } + } const {unit, rate} = updatedMileageRate; const distanceInMeters = getDistanceInMeters(updatedTransaction, unit); @@ -2887,6 +2907,15 @@ function isSplitChildTransaction(transaction: OnyxEntry | Transacti return transaction?.comment?.source === CONST.IOU.TYPE.SPLIT; } +/** + * The original (container) transaction of a split lives in SPLIT_REPORT_ID while the split exists, so it's + * hidden and has no dismiss UI of its own. Used to decide whether a split failure error on the original + * should be cleared alongside the visible child's error. + */ +function isSplitContainerTransaction(transaction: OnyxEntry | Transaction): boolean { + return transaction?.reportID === CONST.REPORT.SPLIT_REPORT_ID; +} + function hasSplitExpenseInSelection(transactions: Transaction[]): boolean { return transactions.some(isSplitChildTransaction); } @@ -3274,6 +3303,7 @@ export { isExpenseSplit, hasSplitExpenseInSelection, isSplitChildTransaction, + isSplitContainerTransaction, getAttendeesListDisplayString, isCorporateCardTransaction, isExpenseUnreported, diff --git a/src/libs/actions/IOU/BulkEdit.ts b/src/libs/actions/IOU/BulkEdit.ts index 298105285abe..f8a1c7a2e862 100644 --- a/src/libs/actions/IOU/BulkEdit.ts +++ b/src/libs/actions/IOU/BulkEdit.ts @@ -363,6 +363,7 @@ function updateMultipleMoneyRequests({ transactionChanges, isFromExpenseReport, policy: transactionPolicy, + policies: allPolicies, personalPolicyOutputCurrency, }); const isTransactionOnHold = isOnHold(transaction); diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 6f9bc5969ee8..50ae939df647 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1282,6 +1282,7 @@ function setDraftSplitTransaction( transactionChanges: TransactionChanges = {}, policy?: OnyxEntry, personalPolicyOutputCurrency?: string, + policies?: OnyxCollection, ) { if (!transactionID) { return undefined; @@ -1299,6 +1300,7 @@ function setDraftSplitTransaction( isFromExpenseReport: false, shouldUpdateReceiptState: false, policy, + policies, isSplitTransaction: true, personalPolicyOutputCurrency, }) diff --git a/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts index 9da213154c5d..cea22424d29f 100644 --- a/src/libs/actions/IOU/SplitExpenseItems.ts +++ b/src/libs/actions/IOU/SplitExpenseItems.ts @@ -648,6 +648,7 @@ function updateSplitExpenseAmountField( policy: OnyxEntry, isSelfDMSplit: boolean, personalPolicyOutputCurrency: string | undefined, + policies?: OnyxCollection, ) { if (!draftTransaction?.transactionID || !currentItemTransactionID || Number.isNaN(amount)) { return; @@ -672,12 +673,18 @@ 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 {rate: currentRate = 0} = - DistanceRequestUtils.getRateByCustomUnitRateID({policy, customUnitRateID: splitExpense.customUnit?.customUnitRateID ?? String(CONST.DEFAULT_NUMBER_ID)}) ?? {}; - const {unit, rate: mileageRateValue} = mileageRate; - const preferredRate = isSelfDMSplit ? mileageRateValue : currentRate; - const secondaryRate = isSelfDMSplit ? currentRate : mileageRateValue; - const rate = preferredRate && preferredRate > 0 ? preferredRate : secondaryRate; + 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; 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 cc2a4376afb1..278708ac374f 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -45,7 +45,13 @@ import { } from '@libs/ReportUtils'; import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {isTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; -import {getChildTransactions, isDistanceRequest as isDistanceRequestTransactionUtils, isOnHold, isPerDiemRequest as isPerDiemRequestTransactionUtils} from '@libs/TransactionUtils'; +import { + getChildTransactions, + hasValidModifiedAmount, + isDistanceRequest as isDistanceRequestTransactionUtils, + isOnHold, + isPerDiemRequest as isPerDiemRequestTransactionUtils, +} from '@libs/TransactionUtils'; import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction'; @@ -98,6 +104,7 @@ type UpdateSplitTransactionsParams = { policyRecentlyUsedCategories: OnyxTypes.RecentlyUsedCategories | undefined; iouReport: OnyxEntry; firstIOU: OnyxEntry | undefined; + extraIOUActions?: OnyxTypes.ReportAction[]; isASAPSubmitBetaEnabled: boolean; currentUserPersonalDetails: CurrentUserPersonalDetails; transactionViolations: OnyxCollection; @@ -114,6 +121,16 @@ type UpdateSplitTransactionsParams = { isTrackIntentUser: boolean | undefined; }; +function resetSnapshotGroupAmount(transaction: T): T { + const splitAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : (transaction.amount ?? 0); + return { + ...transaction, + groupAmount: splitAmount, + groupCurrency: transaction.currency, + groupExchangeRate: undefined, + }; +} + function updateSplitTransactions({ allTransactionsList, allReportsList, @@ -128,6 +145,7 @@ function updateSplitTransactions({ policyRecentlyUsedCategories, iouReport, firstIOU, + extraIOUActions = [], isASAPSubmitBetaEnabled, currentUserPersonalDetails, transactionViolations, @@ -216,15 +234,13 @@ function updateSplitTransactions({ const splitExpenses = transactionData?.splitExpenses ?? []; const allChildTransactions = getChildTransactions(allTransactionsList, originalTransactionID, false); - const originalChildTransactions = allChildTransactions.filter((tx) => tx?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID); const processedChildTransactionIDs: string[] = []; const splitExpensesTotal = transactionData?.splitExpensesTotal ?? 0; - const isCreationOfSplits = originalChildTransactions.length === 0; + const isCreationOfSplits = allChildTransactions.length === 0; const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); - const isReverseSplitOperation = - splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft && allChildTransactions.length === originalChildTransactions.length; + const isReverseSplitOperation = splitExpenses.length === 1 && allChildTransactions.length > 0 && hasEditableSplitExpensesLeft; let splitThreadComments: OnyxTypes.ReportAction[] = []; let splitThreadReportAction: OnyxTypes.ReportAction | undefined; @@ -766,15 +782,13 @@ function updateSplitTransactions({ if (isReverseSplitOperation) { delete transactionChanges.transactionID; - if (isSelfDMSplit) { - // For revert selfDM splits, ALL field changes are already captured in - // requestMoneyInformation.transactionParams (amount, date, merchant, category, etc.). - for (const key of Object.keys(transactionChanges)) { - delete transactionChanges[key as keyof typeof transactionChanges]; - } - // Ensure moneyRequestInformationOnyxData is applied even though transactionChanges is now empty. - hasChanges = true; + // For revert splits (self-DM and workspace alike), ALL field changes are already captured in + // requestMoneyInformation.transactionParams (amount, date, merchant, category, etc.) + for (const key of Object.keys(transactionChanges)) { + delete transactionChanges[key as keyof typeof transactionChanges]; } + // Ensure moneyRequestInformationOnyxData is applied even though transactionChanges is now empty. + hasChanges = true; } if (Object.keys(transactionChanges).length > 0) { @@ -786,8 +800,7 @@ function updateSplitTransactions({ const transactionThreadReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportKey}`]; const iouReportID = workspaceExpenseReportID ?? splitExpense?.reportID ?? transactionThreadReport?.parentReportID; const transactionIOUReport = getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`]; - const isSelfDMPerDiemSplit = isSelfDMSplit && isPerDiemRequestTransactionUtils(originalTransaction); - const newTransactionReportID = isSelfDMPerDiemSplit ? CONST.REPORT.UNREPORTED_REPORT_ID : (workspaceExpenseReportID ?? splitExpense?.reportID); + const newTransactionReportID = isSelfDMSplit ? CONST.REPORT.UNREPORTED_REPORT_ID : (workspaceExpenseReportID ?? splitExpense?.reportID); const {onyxData: moneyRequestParamsOnyxData, params} = getUpdateMoneyRequestParams({ transactionID: existingTransactionID, transactionThreadReport, @@ -1189,10 +1202,18 @@ function updateSplitTransactions({ const expectedMerchant = optimisticTransactionFromGetMoneyRequest?.merchant; if (expectedMerchant && transactionUpdateValue.merchant !== expectedMerchant) { transactionUpdateValue.merchant = expectedMerchant; - // For distance transactions, also update modifiedMerchant to ensure consistency - if (isDistanceRequestTransactionUtils(transactionUpdateValue)) { - transactionUpdateValue.modifiedMerchant = expectedMerchant; - } + } + // For distance transactions, the split inherits the original transaction's modifiedMerchant + // (e.g. the full-distance "10.00 mi @ rate" string set when the original's rate was edited). + // The UI shows modifiedMerchant in preference to merchant, so align it with the split's own + // merchant — otherwise the split displays the stale original merchant instead of its own. + if ( + expectedMerchant && + isDistanceRequestTransactionUtils(transactionUpdateValue) && + !!transactionUpdateValue.modifiedMerchant && + transactionUpdateValue.modifiedMerchant !== expectedMerchant + ) { + transactionUpdateValue.modifiedMerchant = expectedMerchant; } } } @@ -1203,12 +1224,21 @@ function updateSplitTransactions({ // as the Onyx transactions. This prevents getChildTransactions from treating them as separate // orphaned children on the next edit, which would incorrectly delete them from the snapshot. const snapshotTransactionID = isCreationOfSplits ? splitExpense.transactionID : optimisticTransactionFromGetMoneyRequest.transactionID; - newSelfDMSplitTransactions.push({ - ...optimisticTransactionFromGetMoneyRequest, - transactionID: snapshotTransactionID, - // For edits, show a pending indicator in the snapshot while the request is in-flight. - ...(!isCreationOfSplits && {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), - }); + // Align the snapshot's modifiedMerchant with the split's own merchant for distance transactions, + // so the Search/Expenses view doesn't show the stale inherited original merchant (see the same fix + // applied to the main transaction's optimisticData above). + const snapshotModifiedMerchant = + isDistanceRequestTransactionUtils(optimisticTransactionFromGetMoneyRequest) && !!optimisticTransactionFromGetMoneyRequest.modifiedMerchant + ? optimisticTransactionFromGetMoneyRequest.merchant + : optimisticTransactionFromGetMoneyRequest.modifiedMerchant; + newSelfDMSplitTransactions.push( + resetSnapshotGroupAmount({ + ...optimisticTransactionFromGetMoneyRequest, + transactionID: snapshotTransactionID, + modifiedMerchant: snapshotModifiedMerchant, + ...(!isCreationOfSplits && {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + }), + ); const reportActionsTargetReportID = selfDMReportID ?? originalSelfDMReportID; const targetReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsTargetReportID}` as const; @@ -1239,7 +1269,7 @@ function updateSplitTransactions({ transactionUpdate && 'value' in transactionUpdate && typeof transactionUpdate.value === 'object' && transactionUpdate.value !== null ? (transactionUpdate.value as OnyxTypes.Transaction) : optimisticTransactionFromGetMoneyRequest; - optimisticChildSnapshotEntries[transactionKey] = snapshotTransaction; + optimisticChildSnapshotEntries[transactionKey] = resetSnapshotGroupAmount(snapshotTransaction); optimisticChildSnapshotKeys.push(transactionKey); } @@ -1253,8 +1283,9 @@ function updateSplitTransactions({ onyxData.failureData?.push(...(updateMoneyRequestParamsOnyxData.failureData ?? []), ...failureDataComments); } - // All transactions that were deleted in the split list will be marked as deleted in onyx - const undeletedTransactions = originalChildTransactions.filter( + // All transactions that were deleted in the split list will be marked as deleted in onyx. + // Unfiltered — the loop below already branches on isSelfDMTransaction per item. + const undeletedTransactions = allChildTransactions.filter( (currentTransaction) => !processedChildTransactionIDs.includes(currentTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID), ); @@ -1376,7 +1407,7 @@ function updateSplitTransactions({ } } if (isReverseSplitOperation) { - const deletedSplitSnapshotKeys = originalChildTransactions.reduce>((acc, childTransaction) => { + const deletedSplitSnapshotKeys = allChildTransactions.reduce>((acc, childTransaction) => { if (!childTransaction?.transactionID) { return acc; } @@ -1499,90 +1530,100 @@ function updateSplitTransactions({ value: originalTransaction ?? null, }); - if (firstIOU && isCreationOfSplits) { + // On repeated split→revert→split cycles, a reverse split always mints a brand-new report action for the + // revived original transaction (see `currentReportActionID: undefined` below) instead of reusing/deleting + // the previous one. If the user never goes back online between cycles, those old report actions pile up + // as undeleted duplicates of the same transaction. Clean up every one of them here, not just `firstIOU`. + const iouActionsToCleanUp = [firstIOU, ...extraIOUActions].filter( + (action): action is OnyxTypes.ReportAction => !!action && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + if (iouActionsToCleanUp.length > 0 && isCreationOfSplits) { // For selfDM splits, also resolve the Concierge "What would you like to do with this expense?" // whisper so it disappears along with the original expense when splits are created. const whisperAction = isOriginalTransactionInSelfDM ? getTrackExpenseActionableWhisper(originalTransactionID, originalSelfDMReportID, allReportActionsList?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalSelfDMReportID}`]) : undefined; const whisperActionID = whisperAction?.reportActionID; - const updatedReportAction = { - [firstIOU.reportActionID]: { - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - previousMessage: firstIOU.message, - message: [ - { - type: 'COMMENT', - html: '', - text: '', - isEdited: true, - isDeletedParentAction: true, - }, - ], - originalMessage: { - IOUTransactionID: null, - }, - errors: null, - childReportID: null, - }, - ...(whisperActionID && { - [whisperActionID]: { - originalMessage: {resolution: CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING}, - }, - }), - }; // For selfDM, use the selfDM report ID for report actions const reportActionsReportID = isOriginalTransactionInSelfDM ? originalSelfDMReportID : iouReport?.reportID; - const {optimisticData, successData, failureData} = getCleanUpTransactionThreadReportOnyxData({ - transactionThreadID: firstIOU.childReportID, - shouldDeleteTransactionThread: true, - reportAction: firstIOU, - updatedReportPreviewAction: updatedReportPreviewAction as OnyxTypes.ReportAction, - currentUserAccountID: currentUserPersonalDetails.accountID, - }); - - onyxData.optimisticData?.push(...optimisticData); - onyxData.optimisticData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsReportID}`, - value: updatedReportAction, - }); - - onyxData.successData?.push(...successData); - onyxData.successData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${isOriginalTransactionInSelfDM ? originalSelfDMReportID : iouReport?.reportID}`, - value: { - [firstIOU.reportActionID]: { - pendingAction: null, - }, - }, - }); - - onyxData.failureData?.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsReportID}`, - value: { - [firstIOU.reportActionID]: { - ...firstIOU, - pendingAction: null, + for (const iouActionToCleanUp of iouActionsToCleanUp) { + const updatedReportAction = { + [iouActionToCleanUp.reportActionID]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + previousMessage: iouActionToCleanUp.message, + message: [ + { + type: 'COMMENT', + html: '', + text: '', + isEdited: true, + isDeletedParentAction: true, + }, + ], + originalMessage: { + IOUTransactionID: null, + }, + errors: null, + childReportID: null, }, - // Revert the optimistic "resolved" state on the Concierge actionable whisper so that if - // the split API call fails, the whisper reappears alongside the restored original expense. ...(whisperActionID && { [whisperActionID]: { - originalMessage: { - resolution: - (whisperAction && isActionOfType(whisperAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER) - ? getOriginalMessage(whisperAction)?.resolution - : null) ?? null, - }, + originalMessage: {resolution: CONST.REPORT.ACTIONABLE_TRACK_EXPENSE_WHISPER_RESOLUTION.NOTHING}, }, }), - }, - }); - onyxData.failureData?.push(...failureData); + }; + + const {optimisticData, successData, failureData} = getCleanUpTransactionThreadReportOnyxData({ + transactionThreadID: iouActionToCleanUp.childReportID, + shouldDeleteTransactionThread: true, + reportAction: iouActionToCleanUp, + updatedReportPreviewAction: updatedReportPreviewAction as OnyxTypes.ReportAction, + currentUserAccountID: currentUserPersonalDetails.accountID, + }); + + onyxData.optimisticData?.push(...optimisticData); + onyxData.optimisticData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsReportID}`, + value: updatedReportAction, + }); + + onyxData.successData?.push(...successData); + onyxData.successData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${isOriginalTransactionInSelfDM ? originalSelfDMReportID : iouReport?.reportID}`, + value: { + [iouActionToCleanUp.reportActionID]: { + pendingAction: null, + }, + }, + }); + + onyxData.failureData?.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsReportID}`, + value: { + [iouActionToCleanUp.reportActionID]: { + ...iouActionToCleanUp, + pendingAction: null, + }, + // Revert the optimistic "resolved" state on the Concierge actionable whisper so that if + // the split API call fails, the whisper reappears alongside the restored original expense. + ...(whisperActionID && { + [whisperActionID]: { + originalMessage: { + resolution: + (whisperAction && isActionOfType(whisperAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_TRACK_EXPENSE_WHISPER) + ? getOriginalMessage(whisperAction)?.resolution + : null) ?? null, + }, + }, + }), + }, + }); + onyxData.failureData?.push(...failureData); + } } else { pushUpdatedReportPreviewActionToOnyxData(); } @@ -1690,11 +1731,16 @@ function updateSplitTransactions({ }, }); pushUpdatedReportPreviewActionToOnyxData(); - const isLastTransactionInReport = Object.values(allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID).length === 1; - if (isLastTransactionInReport) { + // Skip only when the reverse split's restored transaction stays in expenseReportID — that + // report isn't becoming empty. If the surviving split lives in a different report (e.g. it + // was moved elsewhere), expenseReportID can still genuinely lose its last transaction. + const reverseSplitKeepsOriginalInThisReport = isReverseSplitOperation && splitExpenses.at(0)?.reportID === expenseReportID; + const isLastTransactionInReport = + !reverseSplitKeepsOriginalInThisReport && Object.values(allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID).length === 1; + if (isLastTransactionInReport && expenseReportID) { onyxData.optimisticData?.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionData.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, value: { reportID: null, pendingFields: { @@ -1704,14 +1750,14 @@ function updateSplitTransactions({ }); onyxData.successData?.push({ onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionData.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, value: null, }); onyxData.failureData?.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${transactionData.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, value: { - reportID: transactionData.reportID, + reportID: expenseReportID, pendingFields: null, }, }); @@ -1863,10 +1909,10 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const splitExpenses = params.transactionData?.splitExpenses ?? []; const originalTransactionID = params.transactionData?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; const allChildTransactions = getChildTransactions(params.allTransactionsList, originalTransactionID, false); - const originalChildTransactions = allChildTransactions.filter((tx) => tx?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID); const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); - const isReverseSplitOperation = - splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft && allChildTransactions.length === originalChildTransactions.length; + // Unfiltered, so a pure selfDM 2-split still collapses via REVERT_SPLIT_TRANSACTION. The mixed + // workspace/selfDM case is guarded below via reverseSplitKeepsOriginalInExpenseReport instead. + const isReverseSplitOperation = splitExpenses.length === 1 && allChildTransactions.length > 0 && hasEditableSplitExpensesLeft; // Newly created split transaction IDs, excluding ones already present in allChildTransactions. function getNewSplitTransactionIDs(): string[] { @@ -1890,7 +1936,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac !!expenseReportID && areAllExpenseReportTransactionsSplitChildren && !anyRemainingSplitStaysInExpenseReport && !reverseSplitKeepsOriginalInExpenseReport; const isLastTransactionInReport = willExpenseReportBecomeEmpty || - (isReverseSplitOperation && Object.values(params.allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID).length === 1); + (isReverseSplitOperation && + !reverseSplitKeepsOriginalInExpenseReport && + Object.values(params.allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID).length === 1); const fallbackReportID = params.expenseReport?.chatReportID ?? params.expenseReport?.parentReportID; if (isLastTransactionInReport && fallbackReportID) { @@ -1921,7 +1969,6 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac return; } - updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); const transactionThreadReportID = params.firstIOU?.childReportID; const transactionThreadReportScreen = Navigation.getReportRouteByID(transactionThreadReportID); @@ -1962,6 +2009,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) { registerSearchRouteHighlight(); + updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); if (!isSelfDMSplit) { Navigation.navigateBackToLastSuperWideRHPScreen(); @@ -1984,6 +2032,8 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac // (dismissToSuperWideRHP + goBack) instead of dismissModalWithReport. This naturally pops // stale screens from the stack instead of leaving them behind. if (isLastTransactionInReport && fallbackReportID) { + updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); + const backRoute = ROUTES.REPORT_WITH_ID.getRoute(fallbackReportID); navigateBackOnDeleteTransaction(backRoute); @@ -2012,11 +2062,11 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac if (isTracking()) { setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, targetReportID); } - Navigation.dismissModalWithReport({reportID: targetReportID}); - // After the modal is dismissed, remove the transaction thread report screen - // to avoid navigating back to a report removed by the split transaction. + popReportsSplitNavigatorToReport(targetReportID); + Navigation.dismissModalWithReport({reportID: targetReportID}); requestAnimationFrame(() => { + updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); if (!transactionThreadReportScreen?.key) { return; } diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 5de4061d37ea..cd792dfe8db2 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -56,6 +56,7 @@ import { isManualDistanceRequest, isOdometerDistanceRequest, isOnHold, + isSplitContainerTransaction, shouldClearConvertedAmount, waypointHasValidAddress, } from '@libs/TransactionUtils'; @@ -87,10 +88,13 @@ import type TransactionState from '@src/types/utils/TransactionStateType'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, OnyxUpdate} from 'react-native-onyx'; +import {originalTransactionIDSelector} from '@selectors/Transaction'; import {getUnixTime} from 'date-fns'; import lodashClone from 'lodash/clone'; import Onyx from 'react-native-onyx'; +import {getAllTransactions} from './IOU'; + let allReports: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, @@ -718,6 +722,20 @@ function clearError(transactionID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {errors: null, errorFields: {route: null, waypoints: null, routes: null}}); } +/** + * Clears a transaction's error and, when it is a split child whose original is still the hidden split + * container (`SPLIT_REPORT_ID`), clears the original's error too + */ +function clearErrorWithOriginalTransactionError(transactionID: string) { + clearError(transactionID); + const transactions = getAllTransactions(); + const originalTransactionID = originalTransactionIDSelector(transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]); + if (!originalTransactionID || !isSplitContainerTransaction(transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`])) { + return; + } + clearError(originalTransactionID); +} + function getLastModifiedExpense(reportID?: string): OriginalMessageModifiedExpense | undefined { const modifiedExpenseActions = Object.values(getAllReportActions(reportID)).filter(isModifiedExpenseAction); modifiedExpenseActions.sort((a, b) => Number(a.reportActionID) - Number(b.reportActionID)); @@ -2015,6 +2033,7 @@ export { getRoute, updateWaypoints, clearError, + clearErrorWithOriginalTransactionError, markAsCash, markPendingRTERTransactionsAsCash, dismissDuplicateTransactionViolation, diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 4690543a8f1a..19ca3cdba68a 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -141,7 +141,7 @@ import type {ValueOf} from 'type-fest'; import {StackActions, useFocusEffect} from '@react-navigation/native'; import {delegateEmailSelector} from '@selectors/Account'; -import {createFilteredPoliciesInfoSelector} from '@selectors/Policy'; +import {createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; @@ -175,9 +175,9 @@ type CaseID = ValueOf; function DynamicReportDetailsPage({policy, report, route, reportMetadata, reportLoadingState}: DynamicReportDetailsPageProps) { const {translate, formatPhoneNumber} = useLocalize(); const {isOffline} = useNetwork(); + const {isBetaEnabled} = usePermissions(); const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); const activePolicy = useActivePolicy(); - const {isBetaEnabled} = usePermissions(); const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); const lastWorkspaceNumber = useLastWorkspaceNumber(); const styles = useThemeStyles(); @@ -349,6 +349,12 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; const [iouTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); const [iouOriginalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransaction?.comment?.originalTransactionID)}`); + const isSubmit2026BetaEnabled = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + const hasWorkspaceToSubmitToSelector = useMemo( + () => createHasWorkspaceToSubmitToSelector(currentUserPersonalDetails.login, isSubmit2026BetaEnabled), + [currentUserPersonalDetails.login, isSubmit2026BetaEnabled], + ); + const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(iouTransactionID ? [iouTransactionID] : []); const {deleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete} = useDeleteTransactions({ report: parentReport, @@ -514,47 +520,67 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const whisperAction = getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID, moneyRequestReportActions); const actionableWhisperReportActionID = whisperAction?.reportActionID; const currentUserLocalCurrency = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD; - const baseSubmitParams = { - reportID: actionReportID, - reportActionID: actionableWhisperReportActionID, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - isRestrictedToPreferredPolicy, - preferredPolicyID, - transaction: iouTransaction, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', - currentUserLocalCurrency, - filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicyID: filteredPoliciesInfo?.firstPolicyID, - }; - if (canUseSubmit2026) { - // On the Submit (submit2026) plan, "Submit to someone" splits into two destinations here too, matching the - // track-expense whisper: submit to an individual ("a friend") or a submit-enabled workspace ("my employer"). - const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserPersonalDetails.email ?? '', lastWorkspaceNumber, translate, currentUserPersonalDetails.displayName); - items.push( - { - key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT_TO_FRIEND, - translationKey: 'actionableMentionTrackExpense.submitToFriend', - icon: expensifyIcons.Send, - isAnonymousAction: false, - shouldShowRightIcon: true, - action: () => { - createDraftTransactionAndNavigateToParticipantSelector({ - ...baseSubmitParams, - actionName: CONST.IOU.ACTION.SUBMIT, - submitDestination: CONST.IOU.SUBMIT_DESTINATION.FRIEND, - defaultWorkspaceName, - }); + const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(iouTransaction, iouOriginalTransaction); + // Hide the "Submit it to someone" option for self-DM split expenses when the user isn't a member of any workspace. + if (!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) { + const baseSubmitParams = { + reportID: actionReportID, + reportActionID: actionableWhisperReportActionID, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + isRestrictedToPreferredPolicy, + preferredPolicyID, + transaction: iouTransaction, + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.email ?? '', + currentUserLocalCurrency, + filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, + firstPolicyID: filteredPoliciesInfo?.firstPolicyID, + }; + if (canUseSubmit2026) { + // On the Submit (submit2026) plan, "Submit to someone" splits into two destinations here too, matching the + // track-expense whisper: submit to an individual ("a friend") or a submit-enabled workspace ("my employer"). + const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserPersonalDetails.email ?? '', lastWorkspaceNumber, translate, currentUserPersonalDetails.displayName); + items.push( + { + key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT_TO_FRIEND, + translationKey: 'actionableMentionTrackExpense.submitToFriend', + icon: expensifyIcons.Send, + isAnonymousAction: false, + shouldShowRightIcon: true, + action: () => { + createDraftTransactionAndNavigateToParticipantSelector({ + ...baseSubmitParams, + actionName: CONST.IOU.ACTION.SUBMIT, + submitDestination: CONST.IOU.SUBMIT_DESTINATION.FRIEND, + defaultWorkspaceName, + }); + }, }, - }, - { - key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT_TO_EMPLOYER, - translationKey: 'actionableMentionTrackExpense.submitToEmployer', + { + key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT_TO_EMPLOYER, + translationKey: 'actionableMentionTrackExpense.submitToEmployer', + icon: expensifyIcons.Send, + isAnonymousAction: false, + shouldShowRightIcon: true, + action: () => { + createDraftTransactionAndNavigateToParticipantSelector({ + ...baseSubmitParams, + actionName: CONST.IOU.ACTION.SUBMIT, + submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, + defaultWorkspaceName, + }); + }, + }, + ); + } else { + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT, + translationKey: 'actionableMentionTrackExpense.submit', icon: expensifyIcons.Send, isAnonymousAction: false, shouldShowRightIcon: true, @@ -562,26 +588,10 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report createDraftTransactionAndNavigateToParticipantSelector({ ...baseSubmitParams, actionName: CONST.IOU.ACTION.SUBMIT, - submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, - defaultWorkspaceName, }); }, - }, - ); - } else { - items.push({ - key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT, - translationKey: 'actionableMentionTrackExpense.submit', - icon: expensifyIcons.Send, - isAnonymousAction: false, - shouldShowRightIcon: true, - action: () => { - createDraftTransactionAndNavigateToParticipantSelector({ - ...baseSubmitParams, - actionName: CONST.IOU.ACTION.SUBMIT, - }); - }, - }); + }); + } } if (Permissions.canUseTrackFlows()) { items.push({ @@ -777,6 +787,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report amountOwed, ownerBillingGracePeriodEnd, iouTransaction, + iouOriginalTransaction, + hasWorkspaceToSubmitTo, filteredPoliciesInfo?.filteredPoliciesCount, filteredPoliciesInfo?.firstPolicyID, parentReport, diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 7a7db7dfedc3..09a1ccdae7eb 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -65,7 +65,7 @@ import AttachmentModalContext from '@pages/media/AttachmentModalScreen/Attachmen import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors'; import {hideEmojiPicker, isActive} from '@userActions/EmojiPickerAction'; import {expandURLPreview} from '@userActions/Report'; -import {clearError} from '@userActions/Transaction'; +import {clearErrorWithOriginalTransactionError} from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -260,7 +260,7 @@ function ReportActionItem({ navigation.setParams({reportActionID: ''}); } if (transactionIDToDismiss) { - clearError(transactionIDToDismiss); + clearErrorWithOriginalTransactionError(transactionIDToDismiss); } clearAllRelatedReportActionErrors(reportID, action, originalReportID); }; diff --git a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx index 7b4324109dc8..a9c38a12fbe7 100644 --- a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx +++ b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx @@ -32,6 +32,7 @@ import type {CreateDraftTransactionParams} from '@libs/ReportUtils'; import {createDraftTransactionAndNavigateToParticipantSelector} from '@libs/ReportUtils'; import shouldRenderAddPaymentCard from '@libs/shouldRenderAppPaymentCard'; import {doesUserHavePaymentCardAdded} from '@libs/SubscriptionUtils'; +import {isSplitChildTransaction} from '@libs/TransactionUtils'; import {dismissTrackExpenseActionableWhisper, resolveConciergeCategoryOptions, resolveConciergeDescriptionOptions} from '@userActions/Report'; @@ -42,7 +43,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {ValueOf} from 'type-fest'; -import {createFilteredPoliciesInfoSelector} from '@selectors/Policy'; +import {createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React from 'react'; @@ -62,9 +63,9 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const actionOwnerReport = originalReport ?? report; const personalDetail = useCurrentUserPersonalDetails(); - const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); const {isBetaEnabled} = usePermissions(); - const canUseSubmit2026 = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + const isSubmit2026BetaEnabled = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); const activePolicy = useActivePolicy(); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, { @@ -80,6 +81,7 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo const firstPolicyID = filteredPoliciesInfo?.firstPolicyID; const trackExpenseTransactionID = isActionableTrackExpense(action) ? getOriginalMessage(action)?.transactionID : undefined; const [trackExpenseTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(trackExpenseTransactionID)}`); + const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(personalDetail.login, isSubmit2026BetaEnabled)}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const delegateAccountID = useDelegateAccountID(); @@ -216,6 +218,7 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo }); }, }); + const isSplitExpense = isSplitChildTransaction(trackExpenseTransaction); // On the Submit (submit2026) plan, "Submit it to someone" splits into two destinations: // submit to an individual ("a friend") or route to a submit-enabled workspace ("my employer"). const prepareSubmitDestinationButton = (destination: ValueOf, textKey: 'submitToFriend' | 'submitToEmployer'): ActionableItem => ({ @@ -232,9 +235,9 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo }); }, }); - const submitButtons: ActionableItem[] = canUseSubmit2026 + const submitButtons: ActionableItem[] = isSubmit2026BetaEnabled ? [ - prepareSubmitDestinationButton(CONST.IOU.SUBMIT_DESTINATION.FRIEND, 'submitToFriend'), + ...(isSplitExpense ? [] : [prepareSubmitDestinationButton(CONST.IOU.SUBMIT_DESTINATION.FRIEND, 'submitToFriend')]), prepareSubmitDestinationButton(CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, 'submitToEmployer'), ] : [ @@ -243,7 +246,7 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo preferredPolicyID, }), ]; - const options = [...submitButtons]; + const options = !isSplitExpense || hasWorkspaceToSubmitTo ? [...submitButtons] : []; if (Permissions.canUseTrackFlows()) { options.push(prepareTrackExpenseButton('categorize'), prepareTrackExpenseButton('share')); diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index 8c5f8e31c037..a2536df08364 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -84,7 +84,7 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { // Detect selfDM splits whose source workspace is gone: nothing for the Rate step to render. const hasAnyPaidWorkspace = hasAnyPaidPolicy(allPolicies ?? {}); - const {shouldSelectPolicy, shouldNavigateToUpgradePath} = usePolicyForMovingExpenses(); + const {policyForMovingExpenses, shouldSelectPolicy, shouldNavigateToUpgradePath} = usePolicyForMovingExpenses(); const effectivePolicyID = effectivePolicy?.id; @@ -200,8 +200,12 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { const rawPolicyRate = !isP2PRate && currentRateID && effectivePolicy ? getDistanceRateCustomUnitRate(effectivePolicy, currentRateID) : undefined; const isRateBroken = isDistance && !isP2PRate && (!rates[currentRateID] || !rate || rawPolicyRate?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || rawPolicyRate?.enabled === false); - const hasAvailableEnabledRates = Object.keys(DistanceRequestUtils.getMileageRates(effectivePolicy)).length > 0; - const isCustomUnitOutOfPolicy = isSelfDMSplit ? isRateBroken : !rates[currentRateID] || (isDistance && !rate); + const policyWithAvailableRates = effectivePolicy ?? policyForMovingExpenses; + const hasAvailableEnabledRates = Object.keys(DistanceRequestUtils.getMileageRates(policyWithAvailableRates)).length > 0; + // `shouldSelectPolicy` means rates exist across workspaces but none is resolved yet — keep it flagged out-of-policy. + const isCustomUnitOutOfPolicy = isSelfDMSplit + ? isRateBroken || (isDistance && isP2PRate && (hasAvailableEnabledRates || shouldSelectPolicy)) + : !rates[currentRateID] || (isDistance && !rate); const rateToDisplay = DistanceRequestUtils.getRateForExpenseDisplay(rateName, isCustomUnitOutOfPolicy, unit, rate, currency, translate, toLocaleDigit, getCurrencySymbol, isOffline); const getErrorForField = (field: ViolationField) => { @@ -257,13 +261,21 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { { + const rateRoute = ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.SPLIT_EXPENSE, + CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + reportID, + Navigation.getActiveRoute(), + ); + // SelfDM split whose source workspace is gone and user has no other paid workspace: // mirror the selfDM track-expense Rate flow (MoneyRequestView) and route through the // IOU-level upgrade screen so the user can create a workspace, then a distance rate. @@ -281,15 +293,16 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { ); return; } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute( - CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.SPLIT_EXPENSE, - CONST.IOU.OPTIMISTIC_TRANSACTION_ID, - reportID, - Navigation.getActiveRoute(), - ), - ); + + // SelfDM split with paid workspaces but none is default/active paid (e.g. personal + // is the active policy): open the workspace selector first — same UX as the parent + // self-DM expense's Rate field in MoneyRequestView and the Category branch below. + if (!effectivePolicy && shouldSelectPolicy) { + Navigation.navigate(ROUTES.SET_DEFAULT_WORKSPACE.getRoute(rateRoute)); + return; + } + + Navigation.navigate(rateRoute); }} /> diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 9e017c2a4737..1d95e55bca38 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -24,6 +24,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; @@ -59,15 +60,7 @@ import {getActiveGroupSearchHashes} from '@libs/SearchUIUtils'; import {computeSplitSaveErrorMessage, computeSplitWarningMessage} from '@libs/SplitExpenseUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {TranslationPathOrText} from '@libs/TransactionPreviewUtils'; -import { - getChildTransactions, - getExpenseTypeTranslationKey, - getTransactionType, - isCustomUnitRateIDForP2P, - isDistanceRequest, - isManagedCardTransaction, - isPerDiemRequest, -} from '@libs/TransactionUtils'; +import {getChildTransactions, getExpenseTypeTranslationKey, getTransactionType, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest} from '@libs/TransactionUtils'; import variables from '@styles/variables'; @@ -140,6 +133,16 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [betas] = useOnyx(ONYXKEYS.BETAS); const personalPolicy = usePersonalPolicy(); const effectivePolicy = useSplitEffectivePolicy(currentReport, draftTransaction, transaction); + const {policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses(); + // `effectivePolicy` is undefined for a self-DM split on the personal (P2P) rate, so fall back to the + // moving-expenses policy to detect whether a workspace with selectable rates exists. + const policyWithAvailableRates = effectivePolicy ?? policyForMovingExpenses; + // When the user belongs to more than one eligible workspace, `usePolicyForMovingExpenses` can't resolve a + // default (`shouldSelectPolicy: true`). Derive availability from the actual enabled rates across all + // policies rather than `shouldSelectPolicy` alone — otherwise the P2P split is blocked with no rate to pick. + const hasAvailableEnabledRates = + Object.keys(DistanceRequestUtils.getMileageRates(policyWithAvailableRates)).length > 0 || + (shouldSelectPolicy && Object.values(allPolicies ?? {}).some((policyItem) => Object.keys(DistanceRequestUtils.getMileageRates(policyItem)).length > 0)); const normalizedBackTo = backTo?.replace(/^\//, ''); const isSearchBackToRoute = normalizedBackTo?.startsWith(ROUTES.SEARCH_ROOT.route) ?? false; @@ -208,7 +211,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const originalTransactionID = draftTransaction?.comment?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; // For selfDM expenses, the IOU action lives in the selfDM report, not in an expense report. const iouReportIDForActions = expenseReport?.reportID ?? (isSelfDM(draftTransactionReport) ? draftTransactionReport?.reportID : undefined); - const iouActions = getIOUActionForTransactions([originalTransactionID], iouReportIDForActions); + const iouActions = getIOUActionForTransactions([originalTransactionID], iouReportIDForActions).filter((action) => action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const {iouReport} = useGetIOUReportFromReportAction(iouActions.at(0)); const [iouReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(iouReport?.reportID)}`); @@ -249,17 +252,22 @@ function SplitExpensePage({route}: SplitExpensePageProps) { continue; } const isSplitDistance = isDistanceRequest(splitTransaction); - if (!isSplitDistance || isCustomUnitRateIDForP2P(splitTransaction)) { + if (!isSplitDistance) { continue; } - if (!effectivePolicy) { - isUnitRateIDOutOfPolicy = true; + const currentRateID = splitExpense?.customUnit?.customUnitRateID ?? String(CONST.DEFAULT_NUMBER_ID); + if (currentRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID) { + if (isDraftSelfDMContext && hasAvailableEnabledRates) { + isUnitRateIDOutOfPolicy = true; + } continue; } - const currentRateID = splitExpense?.customUnit?.customUnitRateID ?? String(CONST.DEFAULT_NUMBER_ID); - const rates = DistanceRequestUtils.getMileageRates(effectivePolicy, false, currentRateID); - const splitRate = rates[currentRateID]?.rate; - if (!rates[currentRateID] || !splitRate) { + // `effectivePolicy` is undefined when the top-level draft is still on the personal rate, so resolve + // the split's picked rate across all policies too — like the per-split edit screen does. + const splitSelectedRate = + (effectivePolicy ? DistanceRequestUtils.getMileageRates(effectivePolicy, false, currentRateID)[currentRateID] : undefined) ?? + DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(currentRateID, allPolicies); + if (!splitSelectedRate?.rate) { isUnitRateIDOutOfPolicy = true; } } @@ -374,6 +382,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { policyRecentlyUsedCategories, iouReport, firstIOU: iouActions.at(0), + extraIOUActions: iouActions.slice(1), isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), currentUserPersonalDetails, transactionViolations, @@ -393,10 +402,10 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const onSplitExpenseValueChange = (id: string, value: number, mode: ValueOf) => { if (mode === CONST.TAB.SPLIT.AMOUNT || mode === CONST.TAB.SPLIT.DATE) { const amountInCents = convertToBackendAmount(value); - updateSplitExpenseAmountField(draftTransaction, id, amountInCents, effectivePolicy, isDraftSelfDMContext, personalPolicy?.outputCurrency); + updateSplitExpenseAmountField(draftTransaction, id, amountInCents, effectivePolicy, isDraftSelfDMContext, personalPolicy?.outputCurrency, allPolicies); } else { const amountInCents = calculateSplitAmountFromPercentage(transactionDetailsAmount, value); - updateSplitExpenseAmountField(draftTransaction, id, amountInCents, effectivePolicy, isDraftSelfDMContext, personalPolicy?.outputCurrency); + updateSplitExpenseAmountField(draftTransaction, id, amountInCents, effectivePolicy, isDraftSelfDMContext, personalPolicy?.outputCurrency, allPolicies); } }; diff --git a/src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx b/src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx index 466bff992411..583f8e1298fc 100644 --- a/src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx @@ -7,6 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; @@ -17,6 +18,7 @@ import {createPolicyCategory} from '@libs/actions/Policy/Category'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import {hasTags} from '@libs/PolicyUtils'; +import {isSelfDM} from '@libs/ReportUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CategoryForm from '@pages/workspace/categories/CategoryForm'; @@ -60,13 +62,21 @@ function IOURequestStepCategoryCreate({ const policyIdReal = getIOURequestPolicyID(transaction, reportReal); const policyIdDraft = getIOURequestPolicyID(transaction, reportDraft); - const {policy} = usePolicyForTransaction({ + const {policy: policyFromTransaction} = usePolicyForTransaction({ transaction, reportPolicyID: policyIdReal ?? policyIdDraft, action, iouType, isPerDiemRequest: false, }); + const {policyForMovingExpenses} = usePolicyForMovingExpenses(); + + const report = reportReal ?? reportDraft; + // Mirror IOURequestStepCategory: for self-DM split edits the draft's reportID points to the + // self-DM (not UNREPORTED_REPORT_ID), so usePolicyForTransaction can't resolve a policy. Fall + // back to policyForMovingExpenses so AccessOrNotFoundWrapper below has a real policyID instead + // of rendering the "not here" page when the user taps "Add category" on a self-DM split. + const policy = policyFromTransaction ?? (isEditingSplit && isSelfDM(report) ? policyForMovingExpenses : undefined); const policyID = policy?.id; const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); @@ -79,8 +89,6 @@ function IOURequestStepCategoryCreate({ const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const report = reportReal ?? reportDraft; - useRestartOnReceiptFailure(transaction, reportID, iouType, action); const policyHasTags = hasTags(policyTags); @@ -170,15 +178,22 @@ function IOURequestStepCategoryCreate({ Navigation.goBack(backTo); }; + const navigateBackToCategoryList = () => Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, reportID, backTo, reportActionID)); + return ( Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, reportID, backTo, reportActionID))} + onBackButtonPress={navigateBackToCategoryList} shouldShowWrapper testID="IOURequestStepCategoryCreate" > diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index dc4080c4256c..6c2769b3ff83 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -11,6 +11,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -82,6 +83,7 @@ function IOURequestStepDistanceRate({ const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const {policy: policyForTransaction} = usePolicyForTransaction({transaction, reportPolicyID: report?.policyID, action, iouType, policyDraft}); + const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const styles = useThemeStyles(); const {translate, toLocaleDigit, localeCompare} = useLocalize(); @@ -106,7 +108,8 @@ function IOURequestStepDistanceRate({ : undefined; const availablePaidPolicies = isEditingSplit ? getGroupPaidPolicies(allPolicies ?? {}) : []; const fallbackAvailablePolicy = isEditingSplit && !isP2PRate && !policyForTransaction && !policyByCustomUnitID && !policyByCustomUnitRateID ? availablePaidPolicies.at(0) : undefined; - const policy = policyForTransaction ?? policyByCustomUnitID ?? policyByCustomUnitRateID ?? fallbackAvailablePolicy; + const fallbackMovingExpensesPolicy = isEditingSplit ? policyForMovingExpenses : undefined; + const policy = policyForTransaction ?? policyByCustomUnitID ?? policyByCustomUnitRateID ?? fallbackMovingExpensesPolicy ?? fallbackAvailablePolicy; const isDistanceRequest = isDistanceRequestTransactionUtils(currentTransaction); const {getCurrencySymbol} = useCurrencyListActions(); const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); @@ -208,7 +211,7 @@ function IOURequestStepDistanceRate({ if (currentRateID !== customUnitRateID || (isMovingTransactionFromTrackExpense && transactionUnit !== selectedRateUnit)) { // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value if (isEditingSplit && transaction) { - setDraftSplitTransaction(transaction.transactionID, splitDraftTransaction, {customUnitRateID}, policy, personalPolicy?.outputCurrency); + setDraftSplitTransaction(transaction.transactionID, splitDraftTransaction, {customUnitRateID}, policy, personalPolicy?.outputCurrency, allPolicies); saveAndNavigateBack(); return; } diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index e0945288fbfd..e299e3f78e5e 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -9,7 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getIsWorkspacesOnlyForTransaction, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {endSpan} from '@libs/telemetry/activeSpans'; -import {getRequestType, isFromCreditCardImport, isPerDiemRequest, isTimeRequest as isTimeRequestUtil} from '@libs/TransactionUtils'; +import {getRequestType, isFromCreditCardImport, isPerDiemRequest, isSplitChildTransaction, isTimeRequest as isTimeRequestUtil} from '@libs/TransactionUtils'; import MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestParticipantsSelector'; @@ -126,9 +126,13 @@ function IOURequestStepParticipants({ navigateToStartMoneyRequestStep(iouRequestType, iouTypeValue, initialTransactionID, reportID, action); }; + // Split expenses can only be submitted to a workspace, so restrict the recipient list to workspaces. // In new flow - the amount step is skipped, so we need to include the recents for all the cases. // Submit-only implies workspaces-only (we still hide individuals/recents in the Submit-to-employer picker). - const isWorkspacesOnly = isWorkspacesOnlyFromRoute || (isNewManualExpenseFlowEnabled ? false : getIsWorkspacesOnlyForTransaction(initialTransaction, iouRequestType)); + const isWorkspacesOnly = + isWorkspacesOnlyFromRoute || + (action === CONST.IOU.ACTION.SUBMIT && isSplitChildTransaction(initialTransaction)) || + (isNewManualExpenseFlowEnabled ? false : getIsWorkspacesOnlyForTransaction(initialTransaction, iouRequestType)); const selectedParticipant = isSplitRequest ? undefined : participants?.find((participant) => participant.selected && !participant.isSender); // Participants with a reportID are found in the list and highlighted via initiallySelectedReportID. // Those without one (e.g. users to invite who don't have an account yet) must be passed explicitly diff --git a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx index 5170f9f5882f..76781f53bc40 100644 --- a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx @@ -16,6 +16,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -62,6 +63,7 @@ function IOURequestStepUpgrade({ const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); const activePolicy = useActivePolicy(); + const personalPolicy = usePersonalPolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); const lastWorkspaceNumber = useLastWorkspaceNumber(); @@ -279,12 +281,16 @@ function IOURequestStepUpgrade({ } const email = currentUserPersonalDetails?.email ?? ''; + // In the split-expense flow inherit the user's chosen default currency (personal policy + // `outputCurrency`) rather than the geo-derived `localCurrencyCode`. + const isSplitExpense = iouType === CONST.IOU.TYPE.SPLIT_EXPENSE; + const upgradeCurrency = (isSplitExpense ? personalPolicy?.outputCurrency : undefined) ?? currentUserPersonalDetails?.localCurrencyCode ?? ''; const policyData = Policy.createWorkspace({ policyOwnerEmail: undefined, policyName: Policy.generateDefaultWorkspaceName(email, lastWorkspaceNumber, translate), policyID: undefined, engagementChoice: CONST.ONBOARDING_CHOICES.TRACK_WORKSPACE, - currency: currentUserPersonalDetails?.localCurrencyCode ?? '', + currency: upgradeCurrency, featuresMap: [ { id: CONST.POLICY.MORE_FEATURES.ARE_DISTANCE_RATES_ENABLED, diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index 3a15511e36b6..29bcb2b64c31 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -1,7 +1,7 @@ import {hasSynchronizationErrorMessage, isConnectionUnverified} from '@libs/actions/connections'; import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy'; // eslint-disable-next-line no-restricted-imports -- isPaidGroupPolicy is intentional: copy-settings targets are billing/paid-only (Collect/Control), so free group plans like Submit must be excluded (see createCopySettingsEligibleTargetsSelector). -import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; +import {getActiveAdminWorkspaces, getActivePoliciesWithExpenseChat, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; @@ -130,6 +130,16 @@ const activeAdminPoliciesSelector = (policies: OnyxCollection, currentUs const hasActiveAdminPoliciesSelector = (policies: OnyxCollection, currentUserAccountLogin: string) => !!activeAdminPoliciesSelector(policies, currentUserAccountLogin).length; +/** + * Creates a selector returning only whether the user has any active workspace they can submit expenses to + * (paid Collect/Control workspaces, plus free Submit (submit2026) workspaces when the beta is enabled), + * so subscribers don't re-render when anything else on the policy collection changes. + */ +const createHasWorkspaceToSubmitToSelector = + (currentUserLogin: string | undefined, isSubmit2026BetaEnabled = false) => + (policies: OnyxCollection): boolean => + getActivePoliciesWithExpenseChat(policies, currentUserLogin, isSubmit2026BetaEnabled).length > 0; + /** * Creates a selector that aggregates all non-formula policy report fields from all policies, * sorted alphabetically by field key using the provided locale compare function @@ -376,6 +386,7 @@ export { createWorkspaceListPoliciesSelector, activeAdminPoliciesSelector, hasActiveAdminPoliciesSelector, + createHasWorkspaceToSubmitToSelector, createPoliciesForDomainCardsSelector, policyTimeTrackingSelector, iouRequestPolicyCollectionSelector, diff --git a/src/selectors/Transaction.ts b/src/selectors/Transaction.ts index 3b1dbf90da6b..6c849d4484c0 100644 --- a/src/selectors/Transaction.ts +++ b/src/selectors/Transaction.ts @@ -1,6 +1,9 @@ import type TransactionType from '@src/types/onyx/Transaction'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +/** Resolves the original (container) transaction ID that a split child points to. */ +const originalTransactionIDSelector = (transaction: OnyxEntry): string | undefined => transaction?.comment?.originalTransactionID; const transactionsByReportIDSelector = (transactions: OnyxCollection): Record => { const result: Record = {}; @@ -15,5 +18,4 @@ const transactionsByReportIDSelector = (transactions: OnyxCollection { + return {transactionID: CHILD_ID, reportID: CONST.REPORT.UNREPORTED_REPORT_ID, comment: {originalTransactionID: ORIGINAL_ID}, errors: {[ERROR_KEY]: 'child error'}}; +} + +describe('clearErrorWithOriginalTransactionError', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it("clears both the child's and the hidden container original's errors", async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${ORIGINAL_ID}`, {transactionID: ORIGINAL_ID, reportID: CONST.REPORT.SPLIT_REPORT_ID, errors: {[ERROR_KEY]: 'original error'}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`, buildChild()); + await waitForBatchedUpdates(); + + clearErrorWithOriginalTransactionError(CHILD_ID); + await waitForBatchedUpdates(); + + const child = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`); + const original = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${ORIGINAL_ID}`); + expect(child?.errors).toBeFalsy(); + expect(original?.errors).toBeFalsy(); + }); + + it('does not clear the original when it is no longer a split container (e.g. restored after a failed creation)', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${ORIGINAL_ID}`, {transactionID: ORIGINAL_ID, reportID: '987654', errors: {[ERROR_KEY]: 'original error'}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`, buildChild()); + await waitForBatchedUpdates(); + + clearErrorWithOriginalTransactionError(CHILD_ID); + await waitForBatchedUpdates(); + + const child = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`); + const original = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${ORIGINAL_ID}`); + expect(child?.errors).toBeFalsy(); + expect(original?.errors).toEqual({[ERROR_KEY]: 'original error'}); + }); + + it('clears only the transaction when it is not a split child', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`, {transactionID: CHILD_ID, reportID: '987654', errors: {[ERROR_KEY]: 'some error'}}); + await waitForBatchedUpdates(); + + clearErrorWithOriginalTransactionError(CHILD_ID); + await waitForBatchedUpdates(); + + const child = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`); + expect(child?.errors).toBeFalsy(); + }); +}); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 569f50e28ec2..ae845cd47a59 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -11,7 +11,7 @@ import {addComment, notifyNewAction} from '@libs/actions/Report'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {WRITE_COMMANDS} from '@libs/API/types'; import {rand64} from '@libs/NumberUtils'; -import {getIOUActionForReportID, getOriginalMessage, isActionOfType, isAddCommentAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getIOUActionForReportID, getIOUActionForTransactionID, getOriginalMessage, isActionOfType, isAddCommentAction, isDeletedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {buildOptimisticIOUReportAction, getAncestors, getReportOrDraftReport} from '@libs/ReportUtils'; import {completeSplitBill, createDistanceRequest, setDraftSplitTransaction, splitBill, startSplitBill} from '@userActions/IOU/Split'; @@ -52,7 +52,7 @@ import type {MockFetch} from '../../utils/TestHelper'; import createPersonalDetails from '../../utils/collections/personalDetails'; import createRandomPolicy, {createCategoryTaxExpenseRules} from '../../utils/collections/policies'; import createRandomReportAction from '../../utils/collections/reportActions'; -import {createRandomReport} from '../../utils/collections/reports'; +import {createRandomReport, createSelfDM} from '../../utils/collections/reports'; import createRandomTransaction from '../../utils/collections/transaction'; import getOnyxValue from '../../utils/getOnyxValue'; import initCurrencyListContext from '../../utils/initCurrencyListContext'; @@ -2375,6 +2375,688 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { expect(isDeleted).toBe(true); }); + it('should not leave a duplicate IOU action behind when a transaction is split, reverted, split again, and reverted again', async () => { + const amount = 10000; + let expenseReport: OnyxEntry; + let chatReport: OnyxEntry; + let originalTransactionID: string | undefined; + + // Create workspace and expense + const policyID = generatePolicyID(); + createWorkspace({ + policyOwnerEmail: CARLOS_EMAIL, + makeMeAdmin: true, + policyName: "Carlos's Workspace", + policyID, + introSelected: {choice: CONST.ONBOARDING_CHOICES.MANAGE_TEAM}, + currentUserAccountIDParam: CARLOS_ACCOUNT_ID, + currentUserEmailParam: CARLOS_EMAIL, + currency: undefined, + isSelfTourViewed: false, + betas: undefined, + hasActiveAdminPolicies: false, + activePolicy: undefined, + }); + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, false); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + callback: (allReports) => { + chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); + }, + }); + + requestMoney({ + report: chatReport, + betas: [CONST.BETAS.ALL], + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {login: CARLOS_EMAIL, accountID: CARLOS_ACCOUNT_ID, isPolicyExpenseChat: true, reportID: chatReport?.reportID}, + }, + transactionParams: { + amount, + attendees: [], + currency: CONST.CURRENCY.USD, + created: '', + merchant: 'TestMerchant', + comment: 'test comment', + }, + shouldGenerateTransactionThreadReport: true, + isASAPSubmitBetaEnabled: false, + currentUserAccountIDParam: RORY_ACCOUNT_ID, + currentUserEmailParam: RORY_EMAIL, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + isSelfTourViewed: false, + existingTransactionDraft: undefined, + personalDetails: {}, + delegateAccountID: undefined, + isTrackIntentUser: false, + }); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + callback: (allReportActions) => { + const iouActions = Object.values(allReportActions ?? {}).filter((reportAction): reportAction is ReportAction => + isMoneyRequestAction(reportAction), + ); + const originalMessage = isMoneyRequestAction(iouActions?.at(0)) ? getOriginalMessage(iouActions?.at(0)) : undefined; + originalTransactionID = originalMessage?.IOUTransactionID; + }, + }); + + const originalTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + const originalReportID = originalTransaction?.reportID; + const reportID = originalReportID ?? String(CONST.DEFAULT_NUMBER_ID); + const allPolicyTags = await getAllPolicyTags(); + + // A selfDM report to receive one of the split children, per the QA repro ("move one of the split + // transactions to your self-DM"). + const selfDMReport = createSelfDM(9, RORY_ACCOUNT_ID); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await waitForBatchedUpdates(); + + const getLatestCollections = async () => { + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + let allReportActions: OnyxCollection; + let allSnapshots: OnyxCollection; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + callback: (value) => { + allReportActions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.SNAPSHOT, + callback: (value) => { + allSnapshots = value; + }, + }); + return {allTransactions, allReports, allReportNameValuePairs, allReportActions, allSnapshots}; + }; + + // Step 1: Split the original expense into 2 (creation path). One split stays in the workspace + // report, the other is routed to the selfDM/unreported context (mirrors "move one of the split + // transactions to your self-DM"). + // `firstIOU` is resolved exactly like the real caller (SplitExpensePage) resolves it: + // the IOU action for the original transaction in the expense report. + const splitTransactionID1 = rand64(); + const splitTransactionID2 = rand64(); + + let {allTransactions, allReports, allReportNameValuePairs, allReportActions} = await getLatestCollections(); + let reports = getTransactionAndExpenseReports(reportID); + let firstIOU = getIOUActionForReportID(reports.expenseReport?.reportID, originalTransactionID); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + betas: [CONST.BETAS.ALL], + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID, + originalTransactionID: originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: [ + {transactionID: splitTransactionID1, amount: amount / 2, created: DateUtils.getDBTime(), reportID: expenseReport?.reportID}, + {transactionID: splitTransactionID2, amount: amount / 2, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + ], + splitExpensesTotal: undefined, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + allPolicyTags, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports.transactionReport, + expenseReport: reports.expenseReport, + isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + }); + await waitForBatchedUpdates(); + + // The "Spend > Expenses" page has finished loading (prerequisite from the bug report): seed a + // search snapshot that currently shows both live split children. + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}` as const; + const split1SnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID1}` as const; + const split2SnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID2}` as const; + const split1TransactionForSnapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID1}`); + const split2TransactionForSnapshot = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID2}`); + const snapshotData: SearchResults['data'] = {}; + if (split1TransactionForSnapshot) { + snapshotData[split1SnapshotKey] = split1TransactionForSnapshot; + } + if (split2TransactionForSnapshot) { + snapshotData[split2SnapshotKey] = split2TransactionForSnapshot; + } + await Onyx.merge(snapshotKey, { + data: snapshotData, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, + }); + await waitForBatchedUpdates(); + + // Step 2: Revert - remove the selfDM split (opened from within selfDM, per the repro), leaving the + // workspace split as the sole remaining split. This merges the remaining split's data back into the + // original transaction (reverse split #1). + const latestCollectionsBeforeFirstRevert = await getLatestCollections(); + ({allTransactions, allReports, allReportNameValuePairs, allReportActions} = latestCollectionsBeforeFirstRevert); + const {allSnapshots} = latestCollectionsBeforeFirstRevert; + reports = getTransactionAndExpenseReports(reportID); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + betas: [CONST.BETAS.ALL], + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + allSnapshots, + transactionData: { + reportID, + originalTransactionID: originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: [{transactionID: splitTransactionID1, amount, created: DateUtils.getDBTime(), reportID: expenseReport?.reportID}], + splitExpensesTotal: undefined, + }, + searchContext: {currentSearchHash: -2}, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + allPolicyTags, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: selfDMReport, + expenseReport: reports.expenseReport, + isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + }); + await waitForBatchedUpdates(); + + // The deletion of the selfDM split (splitTransactionID2) is later confirmed by the backend, so its + // transaction record is fully removed from the live TRANSACTION collection (not just optimistically + // marked pendingAction: DELETE). Only a stale copy in the search snapshot can still reference it. + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID2}`, null); + await waitForBatchedUpdates(); + + // Step 3: Split the now-reverted original transaction into 2 again, initiated from "Spend > Expenses". + // `SplitExpensePage` reads transactions via `useAllTransactions()`, which merges the (possibly stale) + // search snapshot underneath the live transactions collection: `{...snapshotTransactions, + // ...liveTransactions}`. Reproduce that merge here exactly. + ({allTransactions, allReports, allReportNameValuePairs, allReportActions} = await getLatestCollections()); + const latestSnapshot = await getOnyxValue(snapshotKey); + // Reference the specific transaction keys we seeded the snapshot with directly (rather than a generic + // `Object.entries` walk) so this stays type-safe without narrowing the wide `SearchResultDataType` union. + const mergedTransactionsFromSearchPage: OnyxCollection = { + [split1SnapshotKey]: latestSnapshot?.data?.[split1SnapshotKey], + [split2SnapshotKey]: latestSnapshot?.data?.[split2SnapshotKey], + ...allTransactions, + }; + + reports = getTransactionAndExpenseReports(reportID); + firstIOU = getIOUActionForReportID(reports.expenseReport?.reportID, originalTransactionID); + + const splitTransactionID3 = rand64(); + const splitTransactionID4 = rand64(); + + updateSplitTransactionsFromSplitExpensesFlow({ + // Use the search-page-merged view (live data plus any stale snapshot-only ghosts) exactly as + // `SplitExpensePage` would pass it via `useAllTransactions()` when opened from Spend > Expenses. + allTransactionsList: mergedTransactionsFromSearchPage, + betas: [CONST.BETAS.ALL], + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID, + originalTransactionID: originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: [ + {transactionID: splitTransactionID3, amount: amount / 2, created: DateUtils.getDBTime(), reportID: expenseReport?.reportID}, + {transactionID: splitTransactionID4, amount: amount / 2, created: DateUtils.getDBTime(), reportID: expenseReport?.reportID}, + ], + splitExpensesTotal: undefined, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + allPolicyTags, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports.transactionReport, + expenseReport: reports.expenseReport, + isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + }); + await waitForBatchedUpdates(); + + // Step 3b: Remove splitTransactionID4 from the report - move it out of the workspace report into the selfDM report. + // This mirrors what `changeTransactionsReport` does: a brand-new IOU action is created in the + // destination report, and the old IOU action in the workspace report is blanked out (its + // `IOUTransactionID` cleared) rather than removed. + ({allReportActions} = await getLatestCollections()); + const workspaceActionsBeforeRemoval = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`] ?? {}; + const splitTransactionID4Action = getIOUActionForTransactionID(Object.values(workspaceActionsBeforeRemoval), splitTransactionID4); + + const unreportedIOUAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount: amount / 2, + currency: CONST.CURRENCY.USD, + comment: '', + participants: [{accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}], + transactionID: splitTransactionID4, + isPersonalTrackingExpense: true, + }), + } as ReportAction; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID4}`, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, { + [unreportedIOUAction.reportActionID]: unreportedIOUAction, + }); + if (splitTransactionID4Action) { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, { + [splitTransactionID4Action.reportActionID]: { + previousMessage: splitTransactionID4Action.message, + message: [{type: CONST.REPORT.MESSAGE.TYPE.COMMENT, html: '', text: '', isEdited: true, isDeletedParentAction: false}], + originalMessage: {IOUTransactionID: null}, + }, + }); + } + await waitForBatchedUpdates(); + + // Step 4: Revert a second time - remove one of the second-round splits, leaving the other as the sole + // remaining split. This merges back into the original transaction again (reverse split #2). + ({allTransactions, allReports, allReportNameValuePairs, allReportActions} = await getLatestCollections()); + reports = getTransactionAndExpenseReports(reportID); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + betas: [CONST.BETAS.ALL], + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID, + originalTransactionID: originalTransactionID ?? String(CONST.DEFAULT_NUMBER_ID), + splitExpenses: [{transactionID: splitTransactionID3, amount, created: DateUtils.getDBTime(), reportID: expenseReport?.reportID}], + splitExpensesTotal: undefined, + }, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + allPolicyTags, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: reports.transactionReport, + expenseReport: reports.expenseReport, + isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + }); + await waitForBatchedUpdates(); + + // Then the expense report should show exactly ONE non-deleted IOU action for the original transaction, + // not two (i.e. no leftover IOU action from the first reverse split). + const finalReportActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`); + const nonDeletedIOUActionsForOriginalTransaction = Object.values(finalReportActions ?? {}).filter((action): action is ReportAction => { + if (!isMoneyRequestAction(action) || isDeletedAction(action) || action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return false; + } + return getOriginalMessage(action)?.IOUTransactionID === originalTransactionID; + }); + expect(nonDeletedIOUActionsForOriginalTransaction).toHaveLength(1); + }); + + it('should remove ALL split children (including selfDM/unreported ones) from search snapshots when reverting a mixed split', async () => { + // Given a workspace expense report and a selfDM report + const expenseReport: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + }; + const selfDMReport = createSelfDM(2, RORY_ACCOUNT_ID); + const originalTransactionID = 'mixed-revert-original'; + const workspaceChildTransactionID = 'mixed-revert-workspace-child'; + const selfDMChildTransactionID = 'mixed-revert-selfDM-child'; + + const originalTransaction: Transaction = { + transactionID: originalTransactionID, + amount: -10000, + currency: 'USD', + merchant: 'Test Merchant', + comment: {comment: 'Original expense'}, + created: DateUtils.getDBTime(), + // Hidden while split into children, same as `updateSplitTransactions` leaves it after creation + reportID: CONST.REPORT.SPLIT_REPORT_ID, + }; + const workspaceChildTransaction: Transaction = { + transactionID: workspaceChildTransactionID, + amount: -5000, + currency: 'USD', + merchant: 'Test Merchant', + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + created: DateUtils.getDBTime(), + reportID: expenseReport.reportID, + }; + const selfDMChildTransaction: Transaction = { + transactionID: selfDMChildTransactionID, + amount: -5000, + currency: 'USD', + merchant: 'Test Merchant', + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + created: DateUtils.getDBTime(), + // Split children that live in the selfDM/unreported context use UNREPORTED_REPORT_ID + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + + const workspaceChildIOUAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 5000, + currency: 'USD', + comment: '', + participants: [], + transactionID: workspaceChildTransactionID, + iouReportID: expenseReport.reportID, + }), + }; + const selfDMChildIOUAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount: 5000, + currency: 'USD', + comment: '', + participants: [{accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}], + transactionID: selfDMChildTransactionID, + isPersonalTrackingExpense: true, + }), + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, originalTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${workspaceChildTransactionID}`, workspaceChildTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${selfDMChildTransactionID}`, selfDMChildTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, { + [workspaceChildIOUAction.reportActionID]: workspaceChildIOUAction, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, { + [selfDMChildIOUAction.reportActionID]: selfDMChildIOUAction, + }); + + // And a search snapshot (e.g. "Spend > Expenses") that has already loaded and shows both split children + const workspaceChildSnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${workspaceChildTransactionID}` as const; + const selfDMChildSnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${selfDMChildTransactionID}` as const; + const originalTransactionSnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}` as const; + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}` as const; + const snapshotData: SearchResults['data'] = {}; + snapshotData[workspaceChildSnapshotKey] = workspaceChildTransaction; + snapshotData[selfDMChildSnapshotKey] = selfDMChildTransaction; + await Onyx.merge(snapshotKey, { + data: snapshotData, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, + }); + await waitForBatchedUpdates(); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + let allReportActions: OnyxCollection; + let allSnapshots: OnyxCollection; + await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, callback: (value) => (allTransactions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, callback: (value) => (allReports = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, callback: (value) => (allReportNameValuePairs = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, callback: (value) => (allReportActions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.SNAPSHOT, callback: (value) => (allSnapshots = value)}); + + // When the user removes the selfDM split (from within the selfDM transaction's own split-edit screen), + // leaving the workspace split as the sole remaining split — this triggers a reverse split that merges + // the workspace split's data back into the original transaction. + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + allSnapshots, + transactionData: { + reportID: selfDMReport.reportID, + originalTransactionID, + splitExpenses: [{transactionID: workspaceChildTransactionID, amount: 10000, created: DateUtils.getDBTime(), reportID: expenseReport.reportID}], + splitExpensesTotal: undefined, + }, + searchContext: {currentSearchHash: -2}, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + betas: [CONST.BETAS.ALL], + allPolicyTags: {}, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: selfDMReport, + expenseReport, + isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + }); + await waitForBatchedUpdates(); + + // Then BOTH split children should be removed from the snapshot (not just the workspace one), + // and the revived original transaction should take their place. + const snapshotAfter = (await getOnyxValue(snapshotKey)) as SearchResults | undefined; + const snapshotDataAfter = snapshotAfter?.data ?? {}; + expect(snapshotDataAfter[workspaceChildSnapshotKey]).toBeFalsy(); + expect(snapshotDataAfter[selfDMChildSnapshotKey]).toBeFalsy(); + expect(snapshotDataAfter[originalTransactionSnapshotKey]).toBeTruthy(); + }); + + it('should show the reverted transaction in search snapshots (not stale children) when reverting a pure selfDM split', async () => { + // Given a selfDM report with an unreported expense that was split into two selfDM children + const selfDMReport = createSelfDM(2, RORY_ACCOUNT_ID); + const originalTransactionID = 'pure-selfDM-revert-original'; + const childTransactionID1 = 'pure-selfDM-revert-child-1'; + const childTransactionID2 = 'pure-selfDM-revert-child-2'; + + const originalTransaction: Transaction = { + transactionID: originalTransactionID, + amount: -10000, + currency: 'USD', + merchant: 'Test Merchant', + comment: {comment: 'Original expense'}, + created: DateUtils.getDBTime(), + // Hidden while split into children, same as `updateSplitTransactions` leaves it after creation + reportID: CONST.REPORT.SPLIT_REPORT_ID, + }; + const childTransaction1: Transaction = { + transactionID: childTransactionID1, + amount: -5000, + currency: 'USD', + merchant: 'Test Merchant', + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + created: DateUtils.getDBTime(), + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + const childTransaction2: Transaction = { + transactionID: childTransactionID2, + amount: -5000, + currency: 'USD', + merchant: 'Test Merchant', + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + created: DateUtils.getDBTime(), + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + + const child1IOUAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount: 5000, + currency: 'USD', + comment: '', + participants: [{accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}], + transactionID: childTransactionID1, + isPersonalTrackingExpense: true, + }), + }; + const child2IOUAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount: 5000, + currency: 'USD', + comment: '', + participants: [{accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}], + transactionID: childTransactionID2, + isPersonalTrackingExpense: true, + }), + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, originalTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID1}`, childTransaction1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID2}`, childTransaction2); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, { + [child1IOUAction.reportActionID]: child1IOUAction, + [child2IOUAction.reportActionID]: child2IOUAction, + }); + + // And a search snapshot (e.g. "Spend > Expenses") that has already loaded and shows both split children, + // per the bug report's prerequisite step: "Go to Spend > Expenses and verify that both split transactions + // are displayed." + const child1SnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID1}` as const; + const child2SnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID2}` as const; + const originalTransactionSnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}` as const; + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}` as const; + const snapshotData: SearchResults['data'] = {}; + snapshotData[child1SnapshotKey] = childTransaction1; + snapshotData[child2SnapshotKey] = childTransaction2; + await Onyx.merge(snapshotKey, { + data: snapshotData, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, + }); + await waitForBatchedUpdates(); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + let allReportActions: OnyxCollection; + let allSnapshots: OnyxCollection; + await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, callback: (value) => (allTransactions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, callback: (value) => (allReports = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, callback: (value) => (allReportNameValuePairs = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, callback: (value) => (allReportActions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.SNAPSHOT, callback: (value) => (allSnapshots = value)}); + + // When the user (from within the selfDM, per the bug repro) removes one of the two selfDM splits, + // leaving the other as the sole remaining split — this triggers a reverse split that merges it back + // into the original transaction. + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + allSnapshots, + transactionData: { + reportID: selfDMReport.reportID, + originalTransactionID, + splitExpenses: [{transactionID: childTransactionID1, amount: 10000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}], + splitExpensesTotal: undefined, + }, + searchContext: {currentSearchHash: -2}, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: undefined, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + betas: [CONST.BETAS.ALL], + allPolicyTags: {}, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: selfDMReport, + expenseReport: undefined, + isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, + }); + await waitForBatchedUpdates(); + + // Then BOTH split children should be removed from the snapshot, and the revived original transaction + // should take their place — so "Spend > Expenses" shows the reverted expense instead of nothing + // (bug step 9) and doesn't show stale children on a subsequent re-split (bug step 12). + const snapshotAfter = (await getOnyxValue(snapshotKey)) as SearchResults | undefined; + const snapshotDataAfter = snapshotAfter?.data ?? {}; + expect(snapshotDataAfter[child1SnapshotKey]).toBeFalsy(); + expect(snapshotDataAfter[child2SnapshotKey]).toBeFalsy(); + expect(snapshotDataAfter[originalTransactionSnapshotKey]).toBeTruthy(); + }); + it('should migrate split thread comments to the original transaction thread when reverting a split', async () => { const amount = 10000; let expenseReport: OnyxEntry; @@ -5083,6 +5765,124 @@ describe('updateSplitTransactions', () => { const updatedReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); expect(updatedReport?.total).toBe(totalAfterSplit); }); + + it('should clean up ALL stale un-deleted IOU actions for the original transaction, not just the first one, when creating a fresh split', async () => { + // Repeated offline split→revert cycles each mint a brand-new report action for the revived original + // transaction (see `currentReportActionID: undefined` for reverse splits) without deleting the previous + // one. If a prior "isCreationOfSplits" save only ever cleaned up `iouActions.at(0)`, any additional + // leftover report actions from earlier cycles would never get cleaned, leaving duplicate "expense" + // entries (and an inflated report total) behind forever while offline. + const originalTransactionID = 'stale-cleanup-original'; + const expenseReportID = 'stale-cleanup-expense-report'; + + const expenseReport: Report = { + ...createRandomReport(1, undefined), + reportID: expenseReportID, + type: CONST.REPORT.TYPE.EXPENSE, + }; + const originalTransaction: Transaction = { + transactionID: originalTransactionID, + amount: -20000, + currency: 'USD', + merchant: 'Test Merchant', + comment: {comment: 'Original expense'}, + created: DateUtils.getDBTime(), + reportID: expenseReportID, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, originalTransaction); + + // Two still-active (non-deleted) report actions both reference the original transaction — simulating + // two prior revert cycles whose cleanup was skipped/incomplete while offline. + const staleIOUAction1: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 20000, + currency: 'USD', + comment: '', + participants: [], + transactionID: originalTransactionID, + iouReportID: expenseReportID, + }), + }; + const staleIOUAction2: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 20000, + currency: 'USD', + comment: '', + participants: [], + transactionID: originalTransactionID, + iouReportID: expenseReportID, + }), + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, { + [staleIOUAction1.reportActionID]: staleIOUAction1, + [staleIOUAction2.reportActionID]: staleIOUAction2, + }); + await waitForBatchedUpdates(); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, callback: (v) => (allTransactions = v)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, callback: (v) => (allReports = v)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, callback: (v) => (allReportNameValuePairs = v)}); + + // A fresh split (isCreationOfSplits: no live children exist yet) is initiated, passing `firstIOU` as + // just the first stale action (matching `iouActions.at(0)` in SplitExpensePage.tsx) and the rest via + // `extraIOUActions` (matching `iouActions.slice(1)`). + updateSplitTransactions({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportActionsList: undefined, + allReportNameValuePairsList: allReportNameValuePairs, + transactionData: { + reportID: expenseReportID, + originalTransactionID, + splitExpenses: [ + {transactionID: 'stale-cleanup-split-1', amount: -10000, description: 'Split 1', created: DateUtils.getDBTime()}, + {transactionID: 'stale-cleanup-split-2', amount: -10000, description: 'Split 2', created: DateUtils.getDBTime()}, + ], + splitExpensesTotal: undefined, + }, + searchContext: {currentSearchHash: -2}, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: staleIOUAction1, + extraIOUActions: [staleIOUAction2], + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + betas: [CONST.BETAS.ALL], + allPolicyTags: {}, + personalDetails: {[RORY_ACCOUNT_ID]: {accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}}, + transactionReport: expenseReport, + expenseReport, + isOffline: true, + delegateAccountID: undefined, + isTrackIntentUser: false, + }); + await waitForBatchedUpdates(); + + // Both stale report actions — not just the first — should be marked deleted, so no duplicate "expense" + // entries linger in the report. + // Success data resolves synchronously in this test harness, so by the time we read back the state each + // cleaned-up action's `pendingAction` has already been confirmed (merged back to null/removed) rather + // than sitting at DELETE — assert on the actual deleted-message shape instead, which is unambiguous. + const updatedActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`); + const updatedStaleAction1 = updatedActions?.[staleIOUAction1.reportActionID]; + const updatedStaleAction2 = updatedActions?.[staleIOUAction2.reportActionID]; + expect(isMoneyRequestAction(updatedStaleAction1) && getOriginalMessage(updatedStaleAction1)?.IOUTransactionID).toBeFalsy(); + expect(isMoneyRequestAction(updatedStaleAction2) && getOriginalMessage(updatedStaleAction2)?.IOUTransactionID).toBeFalsy(); + expect(isDeletedAction(updatedStaleAction1)).toBe(true); + expect(isDeletedAction(updatedStaleAction2)).toBe(true); + }); }); describe('initSplitExpense', () => { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 1b1e2657ab42..fb2309a2849b 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -3861,6 +3861,27 @@ describe('TransactionUtils', () => { expect(TransactionUtils.isMapBasedDistanceRequest(transaction)).toBe(false); }); }); + + describe('isSplitContainerTransaction', () => { + it('returns true when the transaction lives in SPLIT_REPORT_ID (hidden container)', () => { + const transaction = generateTransaction({reportID: CONST.REPORT.SPLIT_REPORT_ID}); + expect(TransactionUtils.isSplitContainerTransaction(transaction)).toBe(true); + }); + + it('returns false for a transaction in a normal report (e.g. restored original)', () => { + const transaction = generateTransaction({reportID: '123456'}); + expect(TransactionUtils.isSplitContainerTransaction(transaction)).toBe(false); + }); + + it('returns false for an unreported transaction', () => { + const transaction = generateTransaction({reportID: CONST.REPORT.UNREPORTED_REPORT_ID}); + expect(TransactionUtils.isSplitContainerTransaction(transaction)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(TransactionUtils.isSplitContainerTransaction(undefined)).toBe(false); + }); + }); }); describe('getSupersededPendingCardTransactionIDs', () => { diff --git a/tests/unit/selectors/PolicyTest.ts b/tests/unit/selectors/PolicyTest.ts index d2dccb2a2e8b..2dd8a860ee90 100644 --- a/tests/unit/selectors/PolicyTest.ts +++ b/tests/unit/selectors/PolicyTest.ts @@ -6,6 +6,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import { activeAdminPoliciesSelector, adminPoliciesConnectedToQBDSelector, + createHasWorkspaceToSubmitToSelector, createOwnedPaidPoliciesCountsSelector, hasOnlyPersonalPoliciesSelector, hasReusablePoliciesConnectedToSelector, @@ -418,3 +419,89 @@ describe('hasOnlyPersonalPoliciesSelector', () => { expect(hasOnlyPersonalPoliciesSelector(policies)).toBe(false); }); }); + +describe('createHasWorkspaceToSubmitToSelector', () => { + const USER_LOGIN = 'user@test.com'; + + it('returns false when there are no policies', () => { + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN)({})).toBe(false); + }); + + it('returns false when policies are undefined', () => { + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN)(undefined)).toBe(false); + }); + + it('returns true when there is an active paid group policy the user has a role in', () => { + const policies: OnyxCollection = { + policy1: buildSelectorPolicy(1, {type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.ADMIN}), + }; + + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN)(policies)).toBe(true); + }); + + it('returns false when the only policy is personal (not a paid group)', () => { + const policies: OnyxCollection = { + policy1: buildSelectorPolicy(1, {type: CONST.POLICY.TYPE.PERSONAL, role: CONST.POLICY.ROLE.ADMIN}), + }; + + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN)(policies)).toBe(false); + }); + + it('returns false when the only paid group policy is pending deletion', () => { + const policies: OnyxCollection = { + policy1: buildSelectorPolicy(1, {type: CONST.POLICY.TYPE.TEAM, role: CONST.POLICY.ROLE.ADMIN, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), + }; + + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN)(policies)).toBe(false); + }); + + it('returns false when the user has no role in the paid group policy', () => { + const policies: OnyxCollection = { + policy1: buildSelectorPolicy(1, {type: CONST.POLICY.TYPE.TEAM, role: undefined, employeeList: {}}), + }; + + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN)(policies)).toBe(false); + }); + + it("resolves the user's access from the policy employeeList when no global role is set", () => { + const policies: OnyxCollection = { + policy1: buildSelectorPolicy(1, { + type: CONST.POLICY.TYPE.TEAM, + role: undefined, + employeeList: {[USER_LOGIN]: {email: USER_LOGIN, role: CONST.POLICY.ROLE.USER, submitsTo: ''}}, + }), + }; + + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN)(policies)).toBe(true); + expect(createHasWorkspaceToSubmitToSelector('other@test.com')(policies)).toBe(false); + }); + + it('returns false when login is undefined and policies rely on the employeeList', () => { + const policies: OnyxCollection = { + policy1: buildSelectorPolicy(1, { + type: CONST.POLICY.TYPE.TEAM, + role: undefined, + employeeList: {[USER_LOGIN]: {email: USER_LOGIN, role: CONST.POLICY.ROLE.USER, submitsTo: ''}}, + }), + }; + + expect(createHasWorkspaceToSubmitToSelector(undefined)(policies)).toBe(false); + }); + + it('returns false for a Submit (submit2026) workspace when the SUBMIT_2026 beta is disabled', () => { + const policies: OnyxCollection = { + policy1: buildSelectorPolicy(1, {type: CONST.POLICY.TYPE.SUBMIT, role: CONST.POLICY.ROLE.USER}), + }; + + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN)(policies)).toBe(false); + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN, false)(policies)).toBe(false); + }); + + it('returns true for a Submit (submit2026) workspace when the SUBMIT_2026 beta is enabled', () => { + const policies: OnyxCollection = { + policy1: buildSelectorPolicy(1, {type: CONST.POLICY.TYPE.SUBMIT, role: CONST.POLICY.ROLE.USER}), + }; + + expect(createHasWorkspaceToSubmitToSelector(USER_LOGIN, true)(policies)).toBe(true); + }); +}); diff --git a/tests/unit/selectors/TransactionTest.ts b/tests/unit/selectors/TransactionTest.ts new file mode 100644 index 000000000000..00f7876a5bb8 --- /dev/null +++ b/tests/unit/selectors/TransactionTest.ts @@ -0,0 +1,26 @@ +import type {Transaction} from '@src/types/onyx'; + +import {originalTransactionIDSelector} from '@selectors/Transaction'; + +import createRandomTransaction from '../../utils/collections/transaction'; + +describe('originalTransactionIDSelector', () => { + it('returns the originalTransactionID of a split child', () => { + const transaction: Transaction = {...createRandomTransaction(0), comment: {originalTransactionID: '789'}}; + expect(originalTransactionIDSelector(transaction)).toBe('789'); + }); + + it('returns undefined when there is no originalTransactionID', () => { + const transaction: Transaction = {...createRandomTransaction(0), comment: {}}; + expect(originalTransactionIDSelector(transaction)).toBeUndefined(); + }); + + it('returns undefined when there is no comment', () => { + const transaction: Transaction = {...createRandomTransaction(0), comment: undefined}; + expect(originalTransactionIDSelector(transaction)).toBeUndefined(); + }); + + it('returns undefined when the transaction is undefined', () => { + expect(originalTransactionIDSelector(undefined)).toBeUndefined(); + }); +});