From 7d22b03e0815292972403a1fcf7d70c17f52dfa7 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Thu, 28 May 2026 10:15:30 +0200 Subject: [PATCH 01/29] Merge the first bunch of fixes --- src/hooks/useDeleteTransactions.ts | 10 +++++- .../actions/IOU/SplitTransactionUpdate.ts | 12 +++---- src/pages/iou/SplitExpenseEditPage.tsx | 31 ++++++++++++------- .../step/IOURequestStepCategoryCreate.tsx | 23 +++++++++++--- .../step/IOURequestStepDistanceRate.tsx | 5 ++- 5 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index 3e7cb0259d5a..76d7c2089a26 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -166,7 +166,15 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); const originalTransactionID = transaction?.comment?.originalTransactionID; - if (isExpenseSplit && originalTransactionID) { + const transactionCurrentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; + const isMovedExpenseSplitChild = + !isExpenseSplit && + !!originalTransactionID && + transaction?.comment?.source === CONST.IOU.TYPE.SPLIT && + !originalTransaction?.comment?.splits && + transactionCurrentReport?.type === CONST.REPORT.TYPE.IOU; + + if ((isExpenseSplit || isMovedExpenseSplitChild) && originalTransactionID) { acc.splitTransactionsByOriginalTransactionID[originalTransactionID] ??= []; acc.splitTransactionsByOriginalTransactionID[originalTransactionID].push(item); } else { diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 576de4112d75..b970bfdecc73 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1248,7 +1248,7 @@ function updateSplitTransactions({ // resulting in 3 transactions(deleted, undeleted, and original) being shown at the same time when offline. // Since original transaction will be reverted and both splits will eventually be deleted, we remove // the undeleted split entirely instead of marking it for deletion. - const forceDeleteSplitTransactionID = isReverseSplitOperation && !isOffline ? splitExpenses.at(0)?.transactionID : undefined; + const forceDeleteSplitTransactionID = isReverseSplitOperation ? splitExpenses.at(0)?.transactionID : undefined; const { optimisticData: deleteExpenseOptimisticData, @@ -1654,10 +1654,10 @@ function updateSplitTransactions({ }); pushUpdatedReportPreviewActionToOnyxData(); const isLastTransactionInReport = Object.values(allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID).length === 1; - if (isLastTransactionInReport) { + 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: { @@ -1667,14 +1667,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, }, }); diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index 1716ba5d758f..0211ec7a5ed7 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -239,13 +239,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. @@ -263,15 +271,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/request/step/IOURequestStepCategoryCreate.tsx b/src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx index b9cb603d444b..ca3bb076df80 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'; import {getIOURequestPolicyID, setMoneyRequestCategory} from '@libs/actions/IOU/MoneyRequest'; @@ -16,6 +17,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'; import CONST from '@src/CONST'; @@ -51,13 +53,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}`); @@ -67,8 +77,6 @@ function IOURequestStepCategoryCreate({ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportReal?.parentReportID ?? reportDraft?.parentReportID)}`); const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(reportReal?.parentReportID ?? reportDraft?.parentReportID)}`); - const report = reportReal ?? reportDraft; - useRestartOnReceiptFailure(transaction, reportID, iouType, action); const policyHasTags = hasTags(policyTags); @@ -155,15 +163,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 641d74e65c29..7d80498e94d3 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -11,6 +11,7 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -73,6 +74,7 @@ function IOURequestStepDistanceRate({ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {policy: policyForTransaction} = usePolicyForTransaction({transaction, reportPolicyID: report?.policyID, action, iouType, policyDraft}); + const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const styles = useThemeStyles(); const {translate, toLocaleDigit, localeCompare} = useLocalize(); @@ -96,7 +98,8 @@ function IOURequestStepDistanceRate({ : undefined; const availablePaidPolicies = isEditingSplit ? getGroupPaidPoliciesWithExpenseChatEnabled(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 ?? fallbackAvailablePolicy ?? fallbackMovingExpensesPolicy; const isDistanceRequest = isDistanceRequestTransactionUtils(currentTransaction); const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const isPolicyExpenseChat = isReportInGroupPolicy(report); From a88c95a2386ff467dcc0c51303b1ec72e1ce379f Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Fri, 29 May 2026 12:34:58 +0200 Subject: [PATCH 02/29] Add fix for 91907 --- src/libs/PolicyUtils.ts | 1 + .../actions/IOU/SplitTransactionUpdate.ts | 10 ++- src/pages/DynamicReportDetailsPage.tsx | 63 +++++++++++-------- .../actionContents/ChatActionableButtons.tsx | 7 ++- .../step/IOURequestStepParticipants.tsx | 7 ++- 5 files changed, 53 insertions(+), 35 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index ae90b3d2494e..d8a489d17581 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -2314,6 +2314,7 @@ export { canPolicyAccessFeature, escapeTagName, getActivePolicies, + getActivePoliciesWithExpenseChat, getAdminEmployees, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 85aafb6c652e..e16955ebe58f 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -194,15 +194,14 @@ 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 originalChildTransactions = allChildTransactions; const processedChildTransactionIDs: string[] = []; const splitExpensesTotal = transactionData?.splitExpensesTotal ?? 0; const isCreationOfSplits = originalChildTransactions.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 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft; let splitThreadComments: OnyxTypes.ReportAction[] = []; let splitThreadReportAction: OnyxTypes.ReportAction | undefined; @@ -1827,10 +1826,9 @@ 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 originalChildTransactions = allChildTransactions; 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 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft; const expenseReportID = params.expenseReport?.reportID; // Detect whether the expense report the user is editing from will be emptied by this save. diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 794758a1b07c..9591941e3f1c 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -52,7 +52,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {ReportDetailsNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; import Permissions from '@libs/Permissions'; -import {isPolicyAdmin as isPolicyAdminUtil, isPolicyEmployee as isPolicyEmployeeUtil, shouldShowPolicy} from '@libs/PolicyUtils'; +import {getActivePoliciesWithExpenseChat, isPolicyAdmin as isPolicyAdminUtil, isPolicyEmployee as isPolicyEmployeeUtil, shouldShowPolicy} from '@libs/PolicyUtils'; import {getOneTransactionThreadReportID, getOriginalMessage, getTrackExpenseActionableWhisper, isDeletedAction, isMoneyRequestAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; import {getReportName as getReportNameFromReportNameUtils} from '@libs/ReportNameUtils'; import { @@ -325,6 +325,7 @@ 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 [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(iouTransactionID ? [iouTransactionID] : []); const {deleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete} = useDeleteTransactions({ report: parentReport, @@ -470,32 +471,37 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const whisperAction = getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID); const actionableWhisperReportActionID = whisperAction?.reportActionID; const currentUserLocalCurrency = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD; - items.push({ - key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT, - translationKey: 'actionableMentionTrackExpense.submit', - icon: expensifyIcons.Send, - isAnonymousAction: false, - shouldShowRightIcon: true, - action: () => { - createDraftTransactionAndNavigateToParticipantSelector({ - reportID: actionReportID, - actionName: CONST.IOU.ACTION.SUBMIT, - reportActionID: actionableWhisperReportActionID, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - isRestrictedToPreferredPolicy, - preferredPolicyID, - transaction: iouTransaction, - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail: currentUserPersonalDetails.email ?? '', - currentUserLocalCurrency, - }); - }, - }); + const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(iouTransaction, iouOriginalTransaction); + // Split expenses can only be submitted to a workspace, so hide the "Submit it to someone" option when the user isn't a member of any workspace. + const hasWorkspaceToSubmitTo = getActivePoliciesWithExpenseChat(allPolicies, currentUserPersonalDetails.login).length > 0; + if (!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) { + items.push({ + key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT, + translationKey: 'actionableMentionTrackExpense.submit', + icon: expensifyIcons.Send, + isAnonymousAction: false, + shouldShowRightIcon: true, + action: () => { + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: actionReportID, + actionName: CONST.IOU.ACTION.SUBMIT, + reportActionID: actionableWhisperReportActionID, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + isRestrictedToPreferredPolicy, + preferredPolicyID, + transaction: iouTransaction, + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail: currentUserPersonalDetails.email ?? '', + currentUserLocalCurrency, + }); + }, + }); + } if (Permissions.canUseTrackFlows()) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.CATEGORIZE, @@ -669,6 +675,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report currentUserPersonalDetails.accountID, currentUserPersonalDetails.email, currentUserPersonalDetails.localCurrencyCode, + currentUserPersonalDetails.login, isTaskActionable, isRootGroupChat, leaveChat, @@ -683,6 +690,8 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report amountOwed, ownerBillingGracePeriodEnd, iouTransaction, + iouOriginalTransaction, + allPolicies, parentReport, delegateEmail, ]); diff --git a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx index 3325a1fa69c7..02beb3be8294 100644 --- a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx +++ b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx @@ -13,6 +13,7 @@ import {resolveSuggestedFollowup} from '@libs/actions/Report/SuggestedFollowup'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import Permissions from '@libs/Permissions'; +import {getActivePoliciesWithExpenseChat} from '@libs/PolicyUtils'; import {containsActionableFollowUps, parseFollowupsFromHtml} from '@libs/ReportActionFollowupUtils'; import { getOriginalMessage, @@ -28,6 +29,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'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -59,6 +61,7 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const trackExpenseTransactionID = isActionableTrackExpense(action) ? getOriginalMessage(action)?.transactionID : undefined; const [trackExpenseTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(trackExpenseTransactionID)}`); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const delegateAccountID = useDelegateAccountID(); const actionableItemButtons = ((): ActionableItem[] => { @@ -191,7 +194,9 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo }); }, }); - const options = [prepareTrackExpenseButton('submit', {isRestrictedToPreferredPolicy, preferredPolicyID})]; + const isSplitExpense = isSplitChildTransaction(trackExpenseTransaction); + const hasWorkspaceToSubmitTo = getActivePoliciesWithExpenseChat(allPolicies, personalDetail.login).length > 0; + const options = !isSplitExpense || hasWorkspaceToSubmitTo ? [prepareTrackExpenseButton('submit', {isRestrictedToPreferredPolicy, preferredPolicyID})] : []; if (Permissions.canUseTrackFlows()) { options.push(prepareTrackExpenseButton('categorize'), prepareTrackExpenseButton('share')); diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 96dba30b5c2f..bbe9bf8d733f 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 {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {endSpan} from '@libs/telemetry/activeSpans'; -import {getRequestType, hasRoute, isCorporateCardTransaction, isDistanceRequest, isPerDiemRequest, isTimeRequest as isTimeRequestUtil} from '@libs/TransactionUtils'; +import {getRequestType, hasRoute, isCorporateCardTransaction, isDistanceRequest, isPerDiemRequest, isSplitChildTransaction, isTimeRequest as isTimeRequestUtil} from '@libs/TransactionUtils'; import MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestParticipantsSelector'; import {navigateToStartStepIfScanFileCannotBeRead} from '@userActions/IOU/Receipt'; import CONST from '@src/CONST'; @@ -117,6 +117,11 @@ function IOURequestStepParticipants({ }; const getIsWorkspacesOnly = () => { + // Split expenses can only be submitted to a workspace, so restrict the recipient list to workspaces. + if (action === CONST.IOU.ACTION.SUBMIT && isSplitChildTransaction(initialTransaction)) { + return true; + } + if (isDistanceRequest(initialTransaction)) { if (!hasRoute(initialTransaction, true)) { return false; From 56fc7d1ffe7d200aea777e3c9d7aaffd7e62a913 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Fri, 29 May 2026 13:38:28 +0200 Subject: [PATCH 03/29] Add fix for 91920 --- .../actions/IOU/SplitTransactionUpdate.ts | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index e16955ebe58f..9d3d7c93c50c 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -43,7 +43,13 @@ import { updateOptimisticParentReportAction, } from '@libs/ReportUtils'; 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 {removeDraftSplitTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; @@ -99,6 +105,16 @@ type UpdateSplitTransactionsParams = { isOffline: boolean; }; +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, @@ -1168,12 +1184,13 @@ 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}), - }); + newSelfDMSplitTransactions.push( + resetSnapshotGroupAmount({ + ...optimisticTransactionFromGetMoneyRequest, + transactionID: snapshotTransactionID, + ...(!isCreationOfSplits && {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + }), + ); const reportActionsTargetReportID = selfDMReportID ?? originalSelfDMReportID; const targetReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportActionsTargetReportID}` as const; @@ -1204,7 +1221,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); } From b4a8331f56126584c4d650890b5202ddd8a22f02 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Thu, 4 Jun 2026 14:25:51 +0200 Subject: [PATCH 04/29] Add fix for 91917 --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 9d3d7c93c50c..f600f0b4667a 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -1894,7 +1894,6 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac return; } - updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); const transactionThreadReportID = params.firstIOU?.childReportID; const transactionThreadReportScreen = Navigation.getReportRouteByID(transactionThreadReportID); @@ -1909,6 +1908,8 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac } if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) { + updateSplitTransactions({...params, isFromSplitExpensesFlow: true}); + if (!isSelfDMSplit) { Navigation.navigateBackToLastSuperWideRHPScreen(); } @@ -1930,6 +1931,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); @@ -1949,11 +1952,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; } From f5a75fb18d6b24a4212e6747d34eb717ac28be61 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Mon, 22 Jun 2026 18:50:01 +0200 Subject: [PATCH 05/29] Refactor code --- src/hooks/useDeleteTransactions.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index ab3cd991251b..a31cadbfaed3 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -17,6 +17,7 @@ import { getChildTransactions, getOriginalTransactionWithSplitInfo, isPerDiemRequest as isPerDiemRequestTransactionUtils, + isSplitChildTransaction, shouldRedirectDeleteToSplitExpenseEdit, } from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -188,11 +189,7 @@ function useDeleteTransactions({report, reportActions, policy}: UseDeleteTransac const transactionCurrentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; const isMovedExpenseSplitChild = - !isExpenseSplit && - !!originalTransactionID && - transaction?.comment?.source === CONST.IOU.TYPE.SPLIT && - !originalTransaction?.comment?.splits && - transactionCurrentReport?.type === CONST.REPORT.TYPE.IOU; + !isExpenseSplit && !!originalTransactionID && isSplitChildTransaction(transaction) && !originalTransaction?.comment?.splits && isIOUReport(transactionCurrentReport); if ((isExpenseSplit || isMovedExpenseSplitChild) && originalTransactionID) { acc.splitTransactionsByOriginalTransactionID[originalTransactionID] ??= []; From 7477e466d5e0ff0501b6bde7fab0e89289a06c5e Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Mon, 22 Jun 2026 19:10:22 +0200 Subject: [PATCH 06/29] Refactor code x2 --- src/hooks/useDeleteTransactions.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useDeleteTransactions.ts b/src/hooks/useDeleteTransactions.ts index a31cadbfaed3..bbc1b510544e 100644 --- a/src/hooks/useDeleteTransactions.ts +++ b/src/hooks/useDeleteTransactions.ts @@ -48,6 +48,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; @@ -201,8 +203,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[]; }, ); From 57e546e7df0a7cd781387aa702441fa4b7665d76 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Mon, 22 Jun 2026 19:14:35 +0200 Subject: [PATCH 07/29] Refactor code x3 --- src/libs/actions/IOU/SplitTransactionUpdate.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 49b2998ee99d..ea5a54c7f2df 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -210,14 +210,13 @@ function updateSplitTransactions({ const splitExpenses = transactionData?.splitExpenses ?? []; const allChildTransactions = getChildTransactions(allTransactionsList, originalTransactionID, false); - const originalChildTransactions = allChildTransactions; 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; + const isReverseSplitOperation = splitExpenses.length === 1 && allChildTransactions.length > 0 && hasEditableSplitExpensesLeft; let splitThreadComments: OnyxTypes.ReportAction[] = []; let splitThreadReportAction: OnyxTypes.ReportAction | undefined; @@ -1236,7 +1235,7 @@ function updateSplitTransactions({ } // All transactions that were deleted in the split list will be marked as deleted in onyx - const undeletedTransactions = originalChildTransactions.filter( + const undeletedTransactions = allChildTransactions.filter( (currentTransaction) => !processedChildTransactionIDs.includes(currentTransaction?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID), ); @@ -1358,7 +1357,7 @@ function updateSplitTransactions({ } } if (isReverseSplitOperation) { - const deletedSplitSnapshotKeys = originalChildTransactions.reduce>((acc, childTransaction) => { + const deletedSplitSnapshotKeys = allChildTransactions.reduce>((acc, childTransaction) => { if (!childTransaction?.transactionID) { return acc; } @@ -1843,9 +1842,8 @@ 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; const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); - const isReverseSplitOperation = splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft; + const isReverseSplitOperation = splitExpenses.length === 1 && allChildTransactions.length > 0 && hasEditableSplitExpensesLeft; const expenseReportID = params.expenseReport?.reportID; // Detect whether the expense report the user is editing from will be emptied by this save. From 83781934f45366d0a345105186db6715392fef7b Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Mon, 22 Jun 2026 19:50:28 +0200 Subject: [PATCH 08/29] Fix comments with new selector for hasWorkspaceToSubmitTo and policy order for IOURequestStepDistanceRate --- src/pages/DynamicReportDetailsPage.tsx | 13 ++-- .../actionContents/ChatActionableButtons.tsx | 5 +- .../step/IOURequestStepDistanceRate.tsx | 2 +- src/selectors/Policy.ts | 12 +++- tests/unit/selectors/PolicyTest.ts | 70 +++++++++++++++++++ 5 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 18528245faa6..90e4739ac957 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -1,5 +1,6 @@ import {StackActions, useFocusEffect} from '@react-navigation/native'; import {delegateEmailSelector} from '@selectors/Account'; +import {createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -53,7 +54,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {ReportDetailsNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; import Permissions from '@libs/Permissions'; -import {getActivePoliciesWithExpenseChat, isPolicyAdmin as isPolicyAdminUtil, isPolicyEmployee as isPolicyEmployeeUtil, shouldShowPolicy} from '@libs/PolicyUtils'; +import {isPolicyAdmin as isPolicyAdminUtil, isPolicyEmployee as isPolicyEmployeeUtil, shouldShowPolicy} from '@libs/PolicyUtils'; import {getOneTransactionThreadReportID, getOriginalMessage, getTrackExpenseActionableWhisper, isDeletedAction, isMoneyRequestAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; import {getReportName as getReportNameFromReportNameUtils} from '@libs/ReportNameUtils'; import { @@ -328,7 +329,9 @@ 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 [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(currentUserPersonalDetails.login)}, [ + currentUserPersonalDetails.login, + ]); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(iouTransactionID ? [iouTransactionID] : []); const {deleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete} = useDeleteTransactions({ report: parentReport, @@ -496,8 +499,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const actionableWhisperReportActionID = whisperAction?.reportActionID; const currentUserLocalCurrency = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD; const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(iouTransaction, iouOriginalTransaction); - // Split expenses can only be submitted to a workspace, so hide the "Submit it to someone" option when the user isn't a member of any workspace. - const hasWorkspaceToSubmitTo = getActivePoliciesWithExpenseChat(allPolicies, currentUserPersonalDetails.login).length > 0; + // 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) { items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT, @@ -701,7 +703,6 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report currentUserPersonalDetails.accountID, currentUserPersonalDetails.email, currentUserPersonalDetails.localCurrencyCode, - currentUserPersonalDetails.login, isTaskActionable, isRootGroupChat, leaveChat, @@ -717,7 +718,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report ownerBillingGracePeriodEnd, iouTransaction, iouOriginalTransaction, - allPolicies, + hasWorkspaceToSubmitTo, parentReport, delegateEmail, conciergeReportID, diff --git a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx index 02beb3be8294..cf894866e7df 100644 --- a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx +++ b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx @@ -1,3 +1,4 @@ +import {createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React from 'react'; import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; @@ -13,7 +14,6 @@ import {resolveSuggestedFollowup} from '@libs/actions/Report/SuggestedFollowup'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import Permissions from '@libs/Permissions'; -import {getActivePoliciesWithExpenseChat} from '@libs/PolicyUtils'; import {containsActionableFollowUps, parseFollowupsFromHtml} from '@libs/ReportActionFollowupUtils'; import { getOriginalMessage, @@ -61,7 +61,7 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const trackExpenseTransactionID = isActionableTrackExpense(action) ? getOriginalMessage(action)?.transactionID : undefined; const [trackExpenseTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(trackExpenseTransactionID)}`); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(personalDetail.login)}, [personalDetail.login]); const delegateAccountID = useDelegateAccountID(); const actionableItemButtons = ((): ActionableItem[] => { @@ -195,7 +195,6 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo }, }); const isSplitExpense = isSplitChildTransaction(trackExpenseTransaction); - const hasWorkspaceToSubmitTo = getActivePoliciesWithExpenseChat(allPolicies, personalDetail.login).length > 0; const options = !isSplitExpense || hasWorkspaceToSubmitTo ? [prepareTrackExpenseButton('submit', {isRestrictedToPreferredPolicy, preferredPolicyID})] : []; if (Permissions.canUseTrackFlows()) { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 5552be0ef5fa..f6bf1fb818d3 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -102,7 +102,7 @@ function IOURequestStepDistanceRate({ const availablePaidPolicies = isEditingSplit ? getGroupPaidPolicies(allPolicies ?? {}) : []; const fallbackAvailablePolicy = isEditingSplit && !isP2PRate && !policyForTransaction && !policyByCustomUnitID && !policyByCustomUnitRateID ? availablePaidPolicies.at(0) : undefined; const fallbackMovingExpensesPolicy = isEditingSplit ? policyForMovingExpenses : undefined; - const policy = policyForTransaction ?? policyByCustomUnitID ?? policyByCustomUnitRateID ?? fallbackAvailablePolicy ?? fallbackMovingExpensesPolicy; + const policy = policyForTransaction ?? policyByCustomUnitID ?? policyByCustomUnitRateID ?? fallbackMovingExpensesPolicy ?? fallbackAvailablePolicy; const isDistanceRequest = isDistanceRequestTransactionUtils(currentTransaction); const {getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index 0c59111e7e34..67127ff4a572 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -2,7 +2,7 @@ import escapeRegExp from 'lodash/escapeRegExp'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {hasSynchronizationErrorMessage, isConnectionUnverified} from '@libs/actions/connections'; import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy'; -import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {getActiveAdminWorkspaces, getActivePoliciesWithExpenseChat, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyReportField} from '@src/types/onyx'; @@ -72,6 +72,15 @@ 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 paid policy with an expense chat, + * so subscribers don't re-render when anything else on the policy collection changes. + */ +const createHasWorkspaceToSubmitToSelector = + (currentUserLogin: string | undefined) => + (policies: OnyxCollection): boolean => + getActivePoliciesWithExpenseChat(policies, currentUserLogin).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 @@ -303,6 +312,7 @@ export { createCopySettingsEligibleTargetsSelector, activeAdminPoliciesSelector, hasActiveAdminPoliciesSelector, + createHasWorkspaceToSubmitToSelector, createPoliciesForDomainCardsSelector, policyTimeTrackingSelector, hasMultipleOutputCurrenciesSelector, diff --git a/tests/unit/selectors/PolicyTest.ts b/tests/unit/selectors/PolicyTest.ts index 53fef58b441f..b5840bda8c8b 100644 --- a/tests/unit/selectors/PolicyTest.ts +++ b/tests/unit/selectors/PolicyTest.ts @@ -1,6 +1,7 @@ import { activeAdminPoliciesSelector, adminPoliciesConnectedToQBDSelector, + createHasWorkspaceToSubmitToSelector, createOwnedPaidPoliciesCountsSelector, hasMultipleOutputCurrenciesSelector, hasOnlyPersonalPoliciesSelector, @@ -482,3 +483,72 @@ 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); + }); +}); From 61aa7c99da67119da8c0052095922eda9dcd6648 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Fri, 26 Jun 2026 11:21:18 +0200 Subject: [PATCH 09/29] Fix comments, new bugs and improve fix for 91917 --- src/libs/PolicyUtils.ts | 4 +- .../actions/IOU/SplitTransactionUpdate.ts | 38 +++++++++++++------ src/pages/DynamicReportDetailsPage.tsx | 6 ++- .../actionContents/ChatActionableButtons.tsx | 8 +++- src/selectors/Policy.ts | 7 ++-- tests/unit/selectors/PolicyTest.ts | 17 +++++++++ 6 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 4cb68cd2ad70..749c6a52fa46 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -98,7 +98,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 && @@ -106,7 +106,7 @@ function getActivePoliciesWithExpenseChat(policies: OnyxCollection | nul !!policy.name && !!policy.id && !!getPolicyRole(policy, currentUserLogin) && - isPaidGroupPolicy(policy), + (isPaidGroupPolicy(policy) || canAccessSubmitWorkspaceFeatures(policy, isSubmit2026BetaEnabled)), ); } diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index ea5a54c7f2df..fab94895e1ad 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -747,15 +747,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) { @@ -1169,10 +1167,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; } } } @@ -1183,10 +1189,18 @@ 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; + // 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}), }), ); diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 7ae1f5beb25c..30833490ade0 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -40,6 +40,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; +import usePermissions from '@hooks/usePermissions'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -164,6 +165,7 @@ 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 styles = useThemeStyles(); @@ -329,8 +331,10 @@ 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 [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(currentUserPersonalDetails.login)}, [ + const isSubmit2026BetaEnabled = isBetaEnabled(CONST.BETAS.SUBMIT_2026); + const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(currentUserPersonalDetails.login, isSubmit2026BetaEnabled)}, [ currentUserPersonalDetails.login, + isSubmit2026BetaEnabled, ]); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(iouTransactionID ? [iouTransactionID] : []); const {deleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete} = useDeleteTransactions({ diff --git a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx index cf894866e7df..ac46e7957a82 100644 --- a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx +++ b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx @@ -8,6 +8,7 @@ import useActivePolicy from '@hooks/useActivePolicy'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {resolveSuggestedFollowup} from '@libs/actions/Report/SuggestedFollowup'; @@ -50,6 +51,8 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const actionOwnerReport = originalReport ?? report; const personalDetail = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + const isSubmit2026BetaEnabled = isBetaEnabled(CONST.BETAS.SUBMIT_2026); const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); const activePolicy = useActivePolicy(); @@ -61,7 +64,10 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); 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)}, [personalDetail.login]); + const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(personalDetail.login, isSubmit2026BetaEnabled)}, [ + personalDetail.login, + isSubmit2026BetaEnabled, + ]); const delegateAccountID = useDelegateAccountID(); const actionableItemButtons = ((): ActionableItem[] => { diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index 02f713692b15..7fb0c9de4eee 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -132,13 +132,14 @@ 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 paid policy with an expense chat, + * 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) => + (currentUserLogin: string | undefined, isSubmit2026BetaEnabled = false) => (policies: OnyxCollection): boolean => - getActivePoliciesWithExpenseChat(policies, currentUserLogin).length > 0; + getActivePoliciesWithExpenseChat(policies, currentUserLogin, isSubmit2026BetaEnabled).length > 0; /** * Creates a selector that aggregates all non-formula policy report fields from all policies, diff --git a/tests/unit/selectors/PolicyTest.ts b/tests/unit/selectors/PolicyTest.ts index b5840bda8c8b..6654359c147a 100644 --- a/tests/unit/selectors/PolicyTest.ts +++ b/tests/unit/selectors/PolicyTest.ts @@ -551,4 +551,21 @@ describe('createHasWorkspaceToSubmitToSelector', () => { 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); + }); }); From eb1986002ac5ca538c2be93196e3019487f86086 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Mon, 29 Jun 2026 11:50:35 +0200 Subject: [PATCH 10/29] Improve logic for selfdm rates --- src/libs/DistanceRequestUtils.ts | 19 ++++++++++++ src/libs/actions/IOU/SplitExpenseItems.ts | 18 ++++++++---- src/pages/iou/SplitExpenseEditPage.tsx | 5 ++-- src/pages/iou/SplitExpensePage.tsx | 35 ++++++++++++----------- 4 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index a0288d2847c6..d078fe715811 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -599,6 +599,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 (cached `allPolicies`). + * 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): MileageRate | undefined { + if (!customUnitRateID || customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID) { + return undefined; + } + for (const policy of Object.values(allPolicies ?? {})) { + const rate = getMileageRates(policy)[customUnitRateID]; + if (rate) { + return rate; + } + } + return undefined; +} + /** * Returns whether the calculated distance expense amount (distance * rate) is within the backend's safe limit. * The backend WAF rejects amounts exceeding 12 digits (999,999,999,999 cents). @@ -675,6 +693,7 @@ export default { getUpdatedDistanceUnit, getRate, getRateByCustomUnitRateID, + getEnabledRateByCustomUnitRateIDFromAnyPolicy, getDistanceForDisplayLabel, convertDistanceUnit, getRateForExpenseDisplay, diff --git a/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts index aca25c7e3b5d..79b0a51029c4 100644 --- a/src/libs/actions/IOU/SplitExpenseItems.ts +++ b/src/libs/actions/IOU/SplitExpenseItems.ts @@ -661,12 +661,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}); - 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); + 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; + 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/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index 88822ae67a24..557ac59afab4 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -199,8 +199,9 @@ 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; + const isCustomUnitOutOfPolicy = isSelfDMSplit ? isRateBroken || (isDistance && isP2PRate && hasAvailableEnabledRates) : !rates[currentRateID] || (isDistance && !rate); const rateToDisplay = DistanceRequestUtils.getRateForExpenseDisplay(rateName, isCustomUnitOutOfPolicy, unit, rate, currency, translate, toLocaleDigit, getCurrencySymbol, isOffline); const getErrorForField = (field: ViolationField) => { diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index f54e99d7732a..023f51a4d43b 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -26,6 +26,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; @@ -60,15 +61,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'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -126,6 +119,11 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getIOURequestPolicyID(transaction, currentReport)}`); const [betas] = useOnyx(ONYXKEYS.BETAS); const effectivePolicy = useSplitEffectivePolicy(currentReport, draftTransaction, transaction); + const {policyForMovingExpenses} = 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; + const hasAvailableEnabledRates = Object.keys(DistanceRequestUtils.getMileageRates(policyWithAvailableRates)).length > 0; const normalizedBackTo = backTo?.replace(/^\//, ''); const isSearchBackToRoute = normalizedBackTo?.startsWith(ROUTES.SEARCH_ROOT.route) ?? false; @@ -235,17 +233,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); + if (!splitSelectedRate?.rate) { isUnitRateIDOutOfPolicy = true; } } From 1e8d7c7ff1e37f80619e238b2b1cf29f02daa556 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Tue, 30 Jun 2026 09:49:59 +0200 Subject: [PATCH 11/29] Improve logic for rates and add shouldSelectPolicy --- src/pages/iou/SplitExpenseEditPage.tsx | 6 +++++- src/pages/iou/SplitExpensePage.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index 557ac59afab4..61540a9e118b 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -201,7 +201,11 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { isDistance && !isP2PRate && (!rates[currentRateID] || !rate || rawPolicyRate?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || rawPolicyRate?.enabled === false); const policyWithAvailableRates = effectivePolicy ?? policyForMovingExpenses; const hasAvailableEnabledRates = Object.keys(DistanceRequestUtils.getMileageRates(policyWithAvailableRates)).length > 0; - const isCustomUnitOutOfPolicy = isSelfDMSplit ? isRateBroken || (isDistance && isP2PRate && hasAvailableEnabledRates) : !rates[currentRateID] || (isDistance && !rate); + // `shouldSelectPolicy` covers the multi-workspace case where `policyForMovingExpenses` is undefined but + // selectable rates exist across workspaces — keep the P2P rate 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) => { diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 023f51a4d43b..d3361fbeba3e 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -119,11 +119,14 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getIOURequestPolicyID(transaction, currentReport)}`); const [betas] = useOnyx(ONYXKEYS.BETAS); const effectivePolicy = useSplitEffectivePolicy(currentReport, draftTransaction, transaction); - const {policyForMovingExpenses} = usePolicyForMovingExpenses(); + 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; - const hasAvailableEnabledRates = Object.keys(DistanceRequestUtils.getMileageRates(policyWithAvailableRates)).length > 0; + // When the user belongs to more than one eligible workspace, `usePolicyForMovingExpenses` returns no + // resolved policy (`shouldSelectPolicy: true`) because it can't decide which one — but enabled rates do + // exist across those workspaces, so treat that as available to keep blocking the P2P split correctly. + const hasAvailableEnabledRates = shouldSelectPolicy || Object.keys(DistanceRequestUtils.getMileageRates(policyWithAvailableRates)).length > 0; const normalizedBackTo = backTo?.replace(/^\//, ''); const isSearchBackToRoute = normalizedBackTo?.startsWith(ROUTES.SEARCH_ROOT.route) ?? false; From 4bc6e578b3b206171ff3802e030208afd47f0309 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Tue, 30 Jun 2026 11:31:06 +0200 Subject: [PATCH 12/29] Fix bug with zero amount after creating worksapce in selfDM splits flow --- src/libs/TransactionUtils/index.ts | 10 +++++++++- src/pages/iou/SplitExpenseEditPage.tsx | 3 +-- src/pages/iou/request/step/IOURequestStepUpgrade.tsx | 8 +++++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index a5cb12d0092d..1f0050005cff 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -712,7 +712,15 @@ 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}); + let updatedMileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, useTransactionDistanceUnit: false}); + // 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); + if (rateFromAnyPolicy?.rate) { + updatedMileageRate = rateFromAnyPolicy; + } + } const {unit, rate} = updatedMileageRate; const distanceInMeters = getDistanceInMeters(updatedTransaction, unit); diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index 61540a9e118b..8d1ed7b8d796 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -201,8 +201,7 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { isDistance && !isP2PRate && (!rates[currentRateID] || !rate || rawPolicyRate?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || rawPolicyRate?.enabled === false); const policyWithAvailableRates = effectivePolicy ?? policyForMovingExpenses; const hasAvailableEnabledRates = Object.keys(DistanceRequestUtils.getMileageRates(policyWithAvailableRates)).length > 0; - // `shouldSelectPolicy` covers the multi-workspace case where `policyForMovingExpenses` is undefined but - // selectable rates exist across workspaces — keep the P2P rate flagged out-of-policy + // `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); diff --git a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx index 9c4daba0a29f..41b1ca44ba26 100644 --- a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx @@ -17,6 +17,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'; import {createNewReport} from '@libs/actions/Report'; @@ -54,6 +55,7 @@ function IOURequestStepUpgrade({ const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); const activePolicy = useActivePolicy(); + const personalPolicy = usePersonalPolicy(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); const lastWorkspaceNumber = useLastWorkspaceNumber(); @@ -256,12 +258,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, From 5be0b7eec0583dbbb2640901ef05fb9858bd8a98 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Tue, 30 Jun 2026 16:42:27 +0200 Subject: [PATCH 13/29] Fix comments and implement clearErrorWithOriginalTransaction --- src/libs/TransactionUtils/index.ts | 10 +++ src/libs/actions/IOU/SplitExpenseItems.ts | 2 +- .../actions/IOU/SplitTransactionUpdate.ts | 3 +- src/libs/actions/Transaction.ts | 18 ++++++ src/pages/inbox/report/ReportActionItem.tsx | 4 +- src/selectors/Transaction.ts | 8 ++- .../actions/ClearSplitTransactionErrorTest.ts | 64 +++++++++++++++++++ tests/unit/TransactionUtilsTest.ts | 21 ++++++ tests/unit/selectors/TransactionTest.ts | 24 +++++++ 9 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 tests/actions/ClearSplitTransactionErrorTest.ts create mode 100644 tests/unit/selectors/TransactionTest.ts diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 1f0050005cff..64136258542e 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -2733,6 +2733,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); } @@ -3087,6 +3096,7 @@ export { isExpenseSplit, hasSplitExpenseInSelection, isSplitChildTransaction, + isSplitContainerTransaction, getAttendeesListDisplayString, isCorporateCardTransaction, isExpenseUnreported, diff --git a/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts index 79b0a51029c4..cf47d2e2a893 100644 --- a/src/libs/actions/IOU/SplitExpenseItems.ts +++ b/src/libs/actions/IOU/SplitExpenseItems.ts @@ -670,7 +670,7 @@ function updateSplitExpenseAmountField( 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; + 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; diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index fab94895e1ad..9650d25230c5 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -765,8 +765,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, diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 97ccfe409436..d2729f1c8049 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -1,3 +1,4 @@ +import {originalTransactionIDSelector} from '@selectors/Transaction'; import {getUnixTime} from 'date-fns'; import lodashClone from 'lodash/clone'; import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxKey, OnyxUpdate} from 'react-native-onyx'; @@ -56,6 +57,7 @@ import { isManualDistanceRequest, isOdometerDistanceRequest, isOnHold, + isSplitContainerTransaction, shouldClearConvertedAmount, waypointHasValidAddress, } from '@libs/TransactionUtils'; @@ -82,6 +84,7 @@ import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type {Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type TransactionState from '@src/types/utils/TransactionStateType'; +import {getAllTransactions} from './IOU'; let allReports: OnyxCollection = {}; Onyx.connect({ @@ -707,6 +710,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 clearErrorWithOriginalTransaction(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)); @@ -1887,6 +1904,7 @@ export { getRoute, updateWaypoints, clearError, + clearErrorWithOriginalTransaction, markAsCash, markPendingRTERTransactionsAsCash, dismissDuplicateTransactionViolation, diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 26d1cb98e5b0..f333df4425d9 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -70,7 +70,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 {clearErrorWithOriginalTransaction} from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -251,7 +251,7 @@ function ReportActionItem({ navigation.setParams({reportActionID: ''}); } if (transactionIDToDismiss) { - clearError(transactionIDToDismiss); + clearErrorWithOriginalTransaction(transactionIDToDismiss); } clearAllRelatedReportActionErrors(reportID, action, originalReportID); }; diff --git a/src/selectors/Transaction.ts b/src/selectors/Transaction.ts index 8baf26465432..4237a0a12c73 100644 --- a/src/selectors/Transaction.ts +++ b/src/selectors/Transaction.ts @@ -1,6 +1,9 @@ -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type TransactionType from '@src/types/onyx/Transaction'; +/** 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 = {}; if (!transactions) { @@ -14,5 +17,4 @@ const transactionsByReportIDSelector = (transactions: OnyxCollection { + return {transactionID: CHILD_ID, reportID: CONST.REPORT.UNREPORTED_REPORT_ID, comment: {originalTransactionID: ORIGINAL_ID}, errors: {1: 'child error'}}; +} + +describe('clearErrorWithOriginalTransaction', () => { + 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: {1: 'original error'}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`, buildChild()); + await waitForBatchedUpdates(); + + clearErrorWithOriginalTransaction(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: {1: 'original error'}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`, buildChild()); + await waitForBatchedUpdates(); + + clearErrorWithOriginalTransaction(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({1: '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: {1: 'some error'}}); + await waitForBatchedUpdates(); + + clearErrorWithOriginalTransaction(CHILD_ID); + await waitForBatchedUpdates(); + + const child = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`); + expect(child?.errors).toBeFalsy(); + }); +}); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index f8055d16efdc..8dd8cb8268b5 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -3626,4 +3626,25 @@ 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); + }); + }); }); diff --git a/tests/unit/selectors/TransactionTest.ts b/tests/unit/selectors/TransactionTest.ts new file mode 100644 index 000000000000..ec3f4ee83aa0 --- /dev/null +++ b/tests/unit/selectors/TransactionTest.ts @@ -0,0 +1,24 @@ +import {originalTransactionIDSelector} from '@selectors/Transaction'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {Transaction} from '@src/types/onyx'; + +describe('originalTransactionIDSelector', () => { + it('returns the originalTransactionID of a split child', () => { + const transaction = {comment: {originalTransactionID: '789'}} as OnyxEntry; + expect(originalTransactionIDSelector(transaction)).toBe('789'); + }); + + it('returns undefined when there is no originalTransactionID', () => { + const transaction = {comment: {}} as OnyxEntry; + expect(originalTransactionIDSelector(transaction)).toBeUndefined(); + }); + + it('returns undefined when there is no comment', () => { + const transaction = {} as OnyxEntry; + expect(originalTransactionIDSelector(transaction)).toBeUndefined(); + }); + + it('returns undefined when the transaction is undefined', () => { + expect(originalTransactionIDSelector(undefined)).toBeUndefined(); + }); +}); From 3d1fb50d06a2271aa08eb72e3eda4396bc4a4212 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Tue, 30 Jun 2026 16:42:58 +0200 Subject: [PATCH 14/29] Rename clearErrorWithOriginalTransactionError --- src/libs/actions/Transaction.ts | 4 ++-- src/pages/inbox/report/ReportActionItem.tsx | 4 ++-- tests/actions/ClearSplitTransactionErrorTest.ts | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index d2729f1c8049..25ee67eded9a 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -714,7 +714,7 @@ function clearError(transactionID: string) { * 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 clearErrorWithOriginalTransaction(transactionID: string) { +function clearErrorWithOriginalTransactionError(transactionID: string) { clearError(transactionID); const transactions = getAllTransactions(); const originalTransactionID = originalTransactionIDSelector(transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]); @@ -1904,7 +1904,7 @@ export { getRoute, updateWaypoints, clearError, - clearErrorWithOriginalTransaction, + clearErrorWithOriginalTransactionError, markAsCash, markPendingRTERTransactionsAsCash, dismissDuplicateTransactionViolation, diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index f333df4425d9..44a2dec2ef76 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -70,7 +70,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 {clearErrorWithOriginalTransaction} from '@userActions/Transaction'; +import {clearErrorWithOriginalTransactionError} from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -251,7 +251,7 @@ function ReportActionItem({ navigation.setParams({reportActionID: ''}); } if (transactionIDToDismiss) { - clearErrorWithOriginalTransaction(transactionIDToDismiss); + clearErrorWithOriginalTransactionError(transactionIDToDismiss); } clearAllRelatedReportActionErrors(reportID, action, originalReportID); }; diff --git a/tests/actions/ClearSplitTransactionErrorTest.ts b/tests/actions/ClearSplitTransactionErrorTest.ts index 4408881d9d15..a266afc66121 100644 --- a/tests/actions/ClearSplitTransactionErrorTest.ts +++ b/tests/actions/ClearSplitTransactionErrorTest.ts @@ -1,5 +1,5 @@ import Onyx from 'react-native-onyx'; -import {clearErrorWithOriginalTransaction} from '@libs/actions/Transaction'; +import {clearErrorWithOriginalTransactionError} from '@libs/actions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; @@ -13,7 +13,7 @@ function buildChild(): Partial { return {transactionID: CHILD_ID, reportID: CONST.REPORT.UNREPORTED_REPORT_ID, comment: {originalTransactionID: ORIGINAL_ID}, errors: {1: 'child error'}}; } -describe('clearErrorWithOriginalTransaction', () => { +describe('clearErrorWithOriginalTransactionError', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); }); @@ -28,7 +28,7 @@ describe('clearErrorWithOriginalTransaction', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`, buildChild()); await waitForBatchedUpdates(); - clearErrorWithOriginalTransaction(CHILD_ID); + clearErrorWithOriginalTransactionError(CHILD_ID); await waitForBatchedUpdates(); const child = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`); @@ -42,7 +42,7 @@ describe('clearErrorWithOriginalTransaction', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`, buildChild()); await waitForBatchedUpdates(); - clearErrorWithOriginalTransaction(CHILD_ID); + clearErrorWithOriginalTransactionError(CHILD_ID); await waitForBatchedUpdates(); const child = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`); @@ -55,7 +55,7 @@ describe('clearErrorWithOriginalTransaction', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`, {transactionID: CHILD_ID, reportID: '987654', errors: {1: 'some error'}}); await waitForBatchedUpdates(); - clearErrorWithOriginalTransaction(CHILD_ID); + clearErrorWithOriginalTransactionError(CHILD_ID); await waitForBatchedUpdates(); const child = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`); From 464842f3110c8143a34750cf045f89ef04f46546 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Tue, 30 Jun 2026 17:25:48 +0200 Subject: [PATCH 15/29] Fix lint issues --- tests/actions/ClearSplitTransactionErrorTest.ts | 11 ++++++----- tests/unit/selectors/TransactionTest.ts | 8 ++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/actions/ClearSplitTransactionErrorTest.ts b/tests/actions/ClearSplitTransactionErrorTest.ts index a266afc66121..54ce656227d0 100644 --- a/tests/actions/ClearSplitTransactionErrorTest.ts +++ b/tests/actions/ClearSplitTransactionErrorTest.ts @@ -8,9 +8,10 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const ORIGINAL_ID = 'original1'; const CHILD_ID = 'child1'; +const ERROR_KEY = '1'; function buildChild(): Partial { - return {transactionID: CHILD_ID, reportID: CONST.REPORT.UNREPORTED_REPORT_ID, comment: {originalTransactionID: ORIGINAL_ID}, errors: {1: 'child error'}}; + return {transactionID: CHILD_ID, reportID: CONST.REPORT.UNREPORTED_REPORT_ID, comment: {originalTransactionID: ORIGINAL_ID}, errors: {[ERROR_KEY]: 'child error'}}; } describe('clearErrorWithOriginalTransactionError', () => { @@ -24,7 +25,7 @@ describe('clearErrorWithOriginalTransactionError', () => { }); 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: {1: 'original error'}}); + 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(); @@ -38,7 +39,7 @@ describe('clearErrorWithOriginalTransactionError', () => { }); 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: {1: 'original error'}}); + 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(); @@ -48,11 +49,11 @@ describe('clearErrorWithOriginalTransactionError', () => { 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({1: 'original error'}); + 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: {1: 'some error'}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${CHILD_ID}`, {transactionID: CHILD_ID, reportID: '987654', errors: {[ERROR_KEY]: 'some error'}}); await waitForBatchedUpdates(); clearErrorWithOriginalTransactionError(CHILD_ID); diff --git a/tests/unit/selectors/TransactionTest.ts b/tests/unit/selectors/TransactionTest.ts index ec3f4ee83aa0..21f6861f8caa 100644 --- a/tests/unit/selectors/TransactionTest.ts +++ b/tests/unit/selectors/TransactionTest.ts @@ -1,20 +1,20 @@ import {originalTransactionIDSelector} from '@selectors/Transaction'; -import type {OnyxEntry} from 'react-native-onyx'; import type {Transaction} from '@src/types/onyx'; +import createRandomTransaction from '../../utils/collections/transaction'; describe('originalTransactionIDSelector', () => { it('returns the originalTransactionID of a split child', () => { - const transaction = {comment: {originalTransactionID: '789'}} as OnyxEntry; + const transaction: Transaction = {...createRandomTransaction(0), comment: {originalTransactionID: '789'}}; expect(originalTransactionIDSelector(transaction)).toBe('789'); }); it('returns undefined when there is no originalTransactionID', () => { - const transaction = {comment: {}} as OnyxEntry; + const transaction: Transaction = {...createRandomTransaction(0), comment: {}}; expect(originalTransactionIDSelector(transaction)).toBeUndefined(); }); it('returns undefined when there is no comment', () => { - const transaction = {} as OnyxEntry; + const transaction: Transaction = {...createRandomTransaction(0), comment: undefined}; expect(originalTransactionIDSelector(transaction)).toBeUndefined(); }); From 2eff98f64b37e033e9079d3d80a41c3e165e4c52 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Wed, 1 Jul 2026 11:37:01 +0200 Subject: [PATCH 16/29] Fix comments --- src/pages/iou/SplitExpensePage.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index d3361fbeba3e..1f64a5af1a58 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -123,10 +123,12 @@ function SplitExpensePage({route}: SplitExpensePageProps) { // `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` returns no - // resolved policy (`shouldSelectPolicy: true`) because it can't decide which one — but enabled rates do - // exist across those workspaces, so treat that as available to keep blocking the P2P split correctly. - const hasAvailableEnabledRates = shouldSelectPolicy || Object.keys(DistanceRequestUtils.getMileageRates(policyWithAvailableRates)).length > 0; + // 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; From 0398f5e5220040b416179ab5b544226e0764e317 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Thu, 2 Jul 2026 17:59:14 +0200 Subject: [PATCH 17/29] Fix comments --- .../actions/IOU/SplitTransactionUpdate.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index ef03817ccc43..605938f8ba8c 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -214,6 +214,7 @@ function updateSplitTransactions({ const splitExpenses = transactionData?.splitExpenses ?? []; const allChildTransactions = getChildTransactions(allTransactionsList, originalTransactionID, false); + const originalChildTransactions = allChildTransactions.filter((childTransaction) => childTransaction?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID); const processedChildTransactionIDs: string[] = []; const splitExpensesTotal = transactionData?.splitExpensesTotal ?? 0; @@ -1257,7 +1258,8 @@ function updateSplitTransactions({ onyxData.failureData?.push(...(updateMoneyRequestParamsOnyxData.failureData ?? []), ...failureDataComments); } - // All transactions that were deleted in the split list will be marked as deleted in onyx + // 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), ); @@ -1380,7 +1382,7 @@ function updateSplitTransactions({ } } if (isReverseSplitOperation) { - const deletedSplitSnapshotKeys = allChildTransactions.reduce>((acc, childTransaction) => { + const deletedSplitSnapshotKeys = originalChildTransactions.reduce>((acc, childTransaction) => { if (!childTransaction?.transactionID) { return acc; } @@ -1692,7 +1694,12 @@ function updateSplitTransactions({ }, }); pushUpdatedReportPreviewActionToOnyxData(); - const isLastTransactionInReport = Object.values(allTransactionsList ?? {}).filter((itemTransaction) => itemTransaction?.reportID === expenseReportID).length === 1; + // 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, @@ -1866,6 +1873,8 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const originalTransactionID = params.transactionData?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; const allChildTransactions = getChildTransactions(params.allTransactionsList, originalTransactionID, false); const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); + // 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; const expenseReportID = params.expenseReport?.reportID; @@ -1884,7 +1893,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) { From 20e4e3573b3e759c5a5a0a51f61850cadb1f03b1 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Fri, 3 Jul 2026 14:44:31 +0200 Subject: [PATCH 18/29] Fix fmt issues --- tests/actions/ClearSplitTransactionErrorTest.ts | 5 ++++- tests/unit/selectors/TransactionTest.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/actions/ClearSplitTransactionErrorTest.ts b/tests/actions/ClearSplitTransactionErrorTest.ts index 54ce656227d0..81c275ffdff0 100644 --- a/tests/actions/ClearSplitTransactionErrorTest.ts +++ b/tests/actions/ClearSplitTransactionErrorTest.ts @@ -1,8 +1,11 @@ -import Onyx from 'react-native-onyx'; import {clearErrorWithOriginalTransactionError} from '@libs/actions/Transaction'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + import getOnyxValue from '../utils/getOnyxValue'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; diff --git a/tests/unit/selectors/TransactionTest.ts b/tests/unit/selectors/TransactionTest.ts index 21f6861f8caa..00f7876a5bb8 100644 --- a/tests/unit/selectors/TransactionTest.ts +++ b/tests/unit/selectors/TransactionTest.ts @@ -1,5 +1,7 @@ -import {originalTransactionIDSelector} from '@selectors/Transaction'; import type {Transaction} from '@src/types/onyx'; + +import {originalTransactionIDSelector} from '@selectors/Transaction'; + import createRandomTransaction from '../../utils/collections/transaction'; describe('originalTransactionIDSelector', () => { From 7ed9083f33a056e362c15b7d86c13b2f90644ecd Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Mon, 6 Jul 2026 10:53:08 +0200 Subject: [PATCH 19/29] Fix issues from review comments --- config/eslint/eslint.seatbelt.tsv | 2 +- .../actions/IOU/SplitTransactionUpdate.ts | 155 ++-- src/pages/iou/SplitExpensePage.tsx | 3 +- tests/actions/IOUTest/SplitTest.ts | 792 +++++++++++++++++- 4 files changed, 876 insertions(+), 76 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 9ccf499f832f..4248d336a580 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1510,7 +1510,7 @@ "../../tests/actions/IOUTest/SendInvoiceTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../tests/actions/IOUTest/SplitDistanceMessageTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 12 "../../tests/actions/IOUTest/SplitSelfDMTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/actions/IOUTest/SplitTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 +"../../tests/actions/IOUTest/SplitTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 24 "../../tests/actions/IOUTest/TrackExpenseTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 "../../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/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index b089997e1f35..58c73a0aadc6 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -101,6 +101,7 @@ type UpdateSplitTransactionsParams = { policyRecentlyUsedCategories: OnyxTypes.RecentlyUsedCategories | undefined; iouReport: OnyxEntry; firstIOU: OnyxEntry | undefined; + extraIOUActions?: OnyxTypes.ReportAction[]; isASAPSubmitBetaEnabled: boolean; currentUserPersonalDetails: CurrentUserPersonalDetails; transactionViolations: OnyxCollection; @@ -139,6 +140,7 @@ function updateSplitTransactions({ policyRecentlyUsedCategories, iouReport, firstIOU, + extraIOUActions = [], isASAPSubmitBetaEnabled, currentUserPersonalDetails, transactionViolations, @@ -225,7 +227,6 @@ function updateSplitTransactions({ const splitExpenses = transactionData?.splitExpenses ?? []; const allChildTransactions = getChildTransactions(allTransactionsList, originalTransactionID, false); - const originalChildTransactions = allChildTransactions.filter((childTransaction) => childTransaction?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID); const processedChildTransactionIDs: string[] = []; const splitExpensesTotal = transactionData?.splitExpensesTotal ?? 0; @@ -1396,7 +1397,7 @@ function updateSplitTransactions({ } } if (isReverseSplitOperation) { - const deletedSplitSnapshotKeys = originalChildTransactions.reduce>((acc, childTransaction) => { + const deletedSplitSnapshotKeys = allChildTransactions.reduce>((acc, childTransaction) => { if (!childTransaction?.transactionID) { return acc; } @@ -1519,88 +1520,98 @@ 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) : 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(); } diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 43be01d176cb..1a23b1f289f8 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -208,7 +208,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)}`); @@ -378,6 +378,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { policyRecentlyUsedCategories, iouReport, firstIOU: iouActions.at(0), + extraIOUActions: iouActions.slice(1), isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), currentUserPersonalDetails, transactionViolations, diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index f2b1bd2022e0..6a794f08b211 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'; @@ -2303,6 +2303,678 @@ 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); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + 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: {}, + }); + await waitForBatchedUpdates(); + + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); + }, + }); + await getOnyxData({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, + waitForCollectionCallback: false, + 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, + waitForCollectionCallback: true, + callback: (value) => { + allTransactions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, + waitForCollectionCallback: true, + callback: (value) => { + allReportNameValuePairs = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, + waitForCollectionCallback: true, + callback: (value) => { + allReportActions = value; + }, + }); + await getOnyxData({ + key: ONYXKEYS.COLLECTION.SNAPSHOT, + waitForCollectionCallback: true, + 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, + }); + 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}`); + await Onyx.merge(snapshotKey, { + data: { + [split1SnapshotKey]: split1TransactionForSnapshot, + [split2SnapshotKey]: split2TransactionForSnapshot, + }, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: CONST.SEARCH.STATUS.EXPENSE.ALL, isLoading: false}, + } as unknown as SearchResults); + 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, + }); + 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, + }); + await waitForBatchedUpdates(); + + // Step 3b: Unreport splitTransactionID4 - 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 workspaceActionsBeforeUnreport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`] ?? {}; + const splitTransactionID4Action = getIOUActionForTransactionID(Object.values(workspaceActionsBeforeUnreport), 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, + }); + 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; + await Onyx.merge(snapshotKey, { + data: { + [workspaceChildSnapshotKey]: workspaceChildTransaction, + [selfDMChildSnapshotKey]: selfDMChildTransaction, + }, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: CONST.SEARCH.STATUS.EXPENSE.ALL, isLoading: false}, + } as unknown as SearchResults); + await waitForBatchedUpdates(); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + let allReportActions: OnyxCollection; + let allSnapshots: OnyxCollection; + await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, callback: (value) => (allTransactions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (value) => (allReports = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, waitForCollectionCallback: true, callback: (value) => (allReportNameValuePairs = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, callback: (value) => (allReportActions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.SNAPSHOT, waitForCollectionCallback: true, 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, + }); + 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; + await Onyx.merge(snapshotKey, { + data: { + [child1SnapshotKey]: childTransaction1, + [child2SnapshotKey]: childTransaction2, + }, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: CONST.SEARCH.STATUS.EXPENSE.ALL, isLoading: false}, + } as unknown as SearchResults); + await waitForBatchedUpdates(); + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: OnyxCollection; + let allReportActions: OnyxCollection; + let allSnapshots: OnyxCollection; + await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, callback: (value) => (allTransactions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (value) => (allReports = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, waitForCollectionCallback: true, callback: (value) => (allReportNameValuePairs = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, callback: (value) => (allReportActions = value)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.SNAPSHOT, waitForCollectionCallback: true, 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, + }); + 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; @@ -5006,6 +5678,122 @@ 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, waitForCollectionCallback: true, callback: (v) => (allTransactions = v)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (v) => (allReports = v)}); + await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, waitForCollectionCallback: true, 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, + }); + 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', () => { From 1214e45d864344a517ea3aad9de53bc0678252f9 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Mon, 6 Jul 2026 11:13:32 +0200 Subject: [PATCH 20/29] Fix spellcheck issues --- tests/actions/IOUTest/SplitTest.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 6a794f08b211..55b62d0ea5f8 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2605,13 +2605,13 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { }); await waitForBatchedUpdates(); - // Step 3b: Unreport splitTransactionID4 - move it out of the workspace report into the selfDM report. + // 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 workspaceActionsBeforeUnreport = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`] ?? {}; - const splitTransactionID4Action = getIOUActionForTransactionID(Object.values(workspaceActionsBeforeUnreport), splitTransactionID4); + const workspaceActionsBeforeRemoval = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`] ?? {}; + const splitTransactionID4Action = getIOUActionForTransactionID(Object.values(workspaceActionsBeforeRemoval), splitTransactionID4); const unreportedIOUAction = { ...buildOptimisticIOUReportAction({ @@ -2697,7 +2697,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { 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 selfDMChildTransactionID = 'mixed-revert-selfDM-child'; const originalTransaction: Transaction = { transactionID: originalTransactionID, @@ -2837,9 +2837,9 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { 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 originalTransactionID = 'pure-selfDM-revert-original'; + const childTransactionID1 = 'pure-selfDM-revert-child-1'; + const childTransactionID2 = 'pure-selfDM-revert-child-2'; const originalTransaction: Transaction = { transactionID: originalTransactionID, From 627c7e82fedde23e1291cfd66b5182898ab436cd Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Fri, 10 Jul 2026 11:51:26 +0200 Subject: [PATCH 21/29] Fix TS issues --- src/pages/iou/SplitExpenseEditPage.tsx | 2 +- tests/actions/IOUTest/SplitTest.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index f1f92067d543..a6c26529f2b1 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -86,7 +86,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; diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 6dfb32128196..8762396a3b5e 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2364,6 +2364,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { isSelfTourViewed: false, existingTransactionDraft: undefined, personalDetails: {}, + delegateAccountID: undefined, }); await waitForBatchedUpdates(); From eeca781a77cb97ee2451d8fbc39e53631245760f Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Tue, 14 Jul 2026 12:10:21 +0200 Subject: [PATCH 22/29] Fix lint issues --- src/libs/DistanceRequestUtils.ts | 8 ++++---- src/libs/TransactionUtils/index.ts | 4 +++- src/libs/actions/IOU/BulkEdit.ts | 1 + src/libs/actions/IOU/Split.ts | 2 ++ src/libs/actions/IOU/SplitExpenseItems.ts | 3 ++- src/pages/iou/SplitExpensePage.tsx | 6 +++--- src/pages/iou/request/step/IOURequestStepDistanceRate.tsx | 2 +- tests/actions/IOUTest/SplitTest.ts | 6 +++--- 8 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index bafb8b1fed7e..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'; @@ -597,15 +597,15 @@ function getRateByCustomUnitRateID({customUnitRateID, policy}: {customUnitRateID } /** - * Resolve an enabled mileage rate by its ID across every policy the user belongs to (cached `allPolicies`). + * 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): MileageRate | undefined { +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(allPolicies ?? {})) { + for (const policy of Object.values(policies ?? {})) { const rate = getMileageRates(policy)[customUnitRateID]; if (rate) { return rate; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 845b4eccd78e..652f7d172623 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -608,6 +608,7 @@ function getUpdatedTransaction({ isFromExpenseReport, shouldUpdateReceiptState = true, policy = undefined, + policies = undefined, isSplitTransaction = false, personalPolicyOutputCurrency, }: { @@ -616,6 +617,7 @@ function getUpdatedTransaction({ isFromExpenseReport: boolean; shouldUpdateReceiptState?: boolean; policy?: OnyxEntry; + policies?: OnyxCollection; isSplitTransaction?: boolean; personalPolicyOutputCurrency: string | undefined; }): Transaction { @@ -742,7 +744,7 @@ function getUpdatedTransaction({ // 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); + const rateFromAnyPolicy = DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(transactionChanges.customUnitRateID, policies); if (rateFromAnyPolicy?.rate) { updatedMileageRate = rateFromAnyPolicy; } diff --git a/src/libs/actions/IOU/BulkEdit.ts b/src/libs/actions/IOU/BulkEdit.ts index 395eba1567f9..425ca81635dc 100644 --- a/src/libs/actions/IOU/BulkEdit.ts +++ b/src/libs/actions/IOU/BulkEdit.ts @@ -360,6 +360,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 09941faf3f25..30774af57678 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1243,6 +1243,7 @@ function setDraftSplitTransaction( transactionChanges: TransactionChanges = {}, policy?: OnyxEntry, personalPolicyOutputCurrency?: string, + policies?: OnyxCollection, ) { if (!transactionID) { return undefined; @@ -1260,6 +1261,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 006ec5cab90b..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; @@ -677,7 +678,7 @@ function updateSplitExpenseAmountField( // picked rate across all policies, so the selection isn't lost. const splitSelectedRate = DistanceRequestUtils.getRateByCustomUnitRateID({policy, customUnitRateID: splitRateID}) ?? - DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(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). diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 1a23b1f289f8..c495a94ec15a 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -263,7 +263,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { // 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); + DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(currentRateID, allPolicies); if (!splitSelectedRate?.rate) { isUnitRateIDOutOfPolicy = true; } @@ -396,10 +396,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/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 72a9ce778d55..224564f11697 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -202,7 +202,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); navigateBack(); return; } diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index d454ff43f227..673b0eacb3d4 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2500,7 +2500,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { [split1SnapshotKey]: split1TransactionForSnapshot, [split2SnapshotKey]: split2TransactionForSnapshot, }, - search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: CONST.SEARCH.STATUS.EXPENSE.ALL, isLoading: false}, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, } as unknown as SearchResults); await waitForBatchedUpdates(); @@ -2776,7 +2776,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { [workspaceChildSnapshotKey]: workspaceChildTransaction, [selfDMChildSnapshotKey]: selfDMChildTransaction, }, - search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: CONST.SEARCH.STATUS.EXPENSE.ALL, isLoading: false}, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, } as unknown as SearchResults); await waitForBatchedUpdates(); @@ -2916,7 +2916,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { [child1SnapshotKey]: childTransaction1, [child2SnapshotKey]: childTransaction2, }, - search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, status: CONST.SEARCH.STATUS.EXPENSE.ALL, isLoading: false}, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, } as unknown as SearchResults); await waitForBatchedUpdates(); From 7b1ab3235d78c2fe4e807fd2998982450c3f981e Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Mon, 20 Jul 2026 16:26:19 +0200 Subject: [PATCH 23/29] Fix lint issues --- src/pages/DynamicReportDetailsPage.tsx | 9 ++------- tests/actions/IOUTest/SplitTest.ts | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index a88ce78a52ae..7a000984a626 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -226,9 +226,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, {selector: delegateEmailSelector}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createFilteredPoliciesInfoSelector(currentUserPersonalDetails?.email)}, [ - currentUserPersonalDetails?.email, - ]); + const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createFilteredPoliciesInfoSelector(currentUserPersonalDetails?.email)}); const {showConfirmModal} = useConfirmModal(); const reportAttributes = useReportAttributes(); const derivedParentReportName = useDerivedReportNameByReportID(report?.parentReportID); @@ -347,10 +345,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report 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 [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(currentUserPersonalDetails.login, isSubmit2026BetaEnabled)}, [ - currentUserPersonalDetails.login, - isSubmit2026BetaEnabled, - ]); + const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(currentUserPersonalDetails.login, isSubmit2026BetaEnabled)}); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(iouTransactionID ? [iouTransactionID] : []); const {deleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete} = useDeleteTransactions({ report: parentReport, diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 847b692d3349..d0f4e4198d49 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2395,7 +2395,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { activePolicy: undefined, }); const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL); + setWorkspaceApprovalMode(policy, CARLOS_EMAIL, CONST.POLICY.APPROVAL_MODE.BASIC, RORY_ACCOUNT_ID, RORY_EMAIL, false); await waitForBatchedUpdates(); await getOnyxData({ @@ -2433,6 +2433,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { existingTransactionDraft: undefined, personalDetails: {}, delegateAccountID: undefined, + isTrackIntentUser: false, }); await waitForBatchedUpdates(); @@ -2553,6 +2554,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionReport: reports.transactionReport, expenseReport: reports.expenseReport, isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, }); await waitForBatchedUpdates(); @@ -2610,6 +2613,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionReport: selfDMReport, expenseReport: reports.expenseReport, isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, }); await waitForBatchedUpdates(); @@ -2672,6 +2677,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionReport: reports.transactionReport, expenseReport: reports.expenseReport, isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, }); await waitForBatchedUpdates(); @@ -2743,6 +2750,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionReport: reports.transactionReport, expenseReport: reports.expenseReport, isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, }); await waitForBatchedUpdates(); @@ -2892,6 +2901,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionReport: selfDMReport, expenseReport, isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, }); await waitForBatchedUpdates(); @@ -3032,6 +3043,8 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { transactionReport: selfDMReport, expenseReport: undefined, isOffline: false, + delegateAccountID: undefined, + isTrackIntentUser: false, }); await waitForBatchedUpdates(); @@ -5911,6 +5924,8 @@ describe('updateSplitTransactions', () => { transactionReport: expenseReport, expenseReport, isOffline: true, + delegateAccountID: undefined, + isTrackIntentUser: false, }); await waitForBatchedUpdates(); From e9cba9cf57ab2673aa56e8d4b91ab73597c8a51c Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Wed, 22 Jul 2026 11:53:12 +0200 Subject: [PATCH 24/29] Fix comments --- config/eslint/eslint.seatbelt.tsv | 4 +- src/libs/TransactionUtils/index.ts | 10 +++ .../actionContents/ChatActionableButtons.tsx | 2 +- tests/actions/IOUTest/SplitTest.ts | 68 +++++++++---------- 4 files changed, 45 insertions(+), 39 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index a64f72a12786..2f1e62d4065c 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1798,8 +1798,8 @@ "../../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" 23 -"../../tests/actions/IOUTest/TrackExpenseTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 +"../../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 "../../tests/actions/IOUTest/UpdateMoneyRequestTest.ts" "no-restricted-imports" 1 diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index adda6fbb3a76..676e97760f0e 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -746,6 +746,16 @@ function getUpdatedTransaction({ 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; diff --git a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx index 2137b371ba8b..a9c38a12fbe7 100644 --- a/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx +++ b/src/pages/inbox/report/actionContents/ChatActionableButtons.tsx @@ -237,7 +237,7 @@ function ChatActionableButtons({action, originalReportID, reportID, hasPendingFo }); 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'), ] : [ diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index af9984a6df3f..153d26844d99 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2382,7 +2382,6 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { await getOnyxData({ key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, callback: (allReports) => { chatReport = Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT); }, @@ -2421,14 +2420,12 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { await getOnyxData({ key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, callback: (allReports) => { expenseReport = Object.values(allReports ?? {}).find((report) => report?.type === CONST.REPORT.TYPE.EXPENSE); }, }); await getOnyxData({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`, - waitForCollectionCallback: false, callback: (allReportActions) => { const iouActions = Object.values(allReportActions ?? {}).filter((reportAction): reportAction is ReportAction => isMoneyRequestAction(reportAction), @@ -2457,35 +2454,30 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { let allSnapshots: OnyxCollection; await getOnyxData({ key: ONYXKEYS.COLLECTION.TRANSACTION, - waitForCollectionCallback: true, callback: (value) => { allTransactions = value; }, }); await getOnyxData({ key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, callback: (value) => { allReports = value; }, }); await getOnyxData({ key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, - waitForCollectionCallback: true, callback: (value) => { allReportNameValuePairs = value; }, }); await getOnyxData({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, callback: (value) => { allReportActions = value; }, }); await getOnyxData({ key: ONYXKEYS.COLLECTION.SNAPSHOT, - waitForCollectionCallback: true, callback: (value) => { allSnapshots = value; }, @@ -2548,13 +2540,17 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { 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: { - [split1SnapshotKey]: split1TransactionForSnapshot, - [split2SnapshotKey]: split2TransactionForSnapshot, - }, + data: snapshotData, search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, - } as unknown as SearchResults); + }); await waitForBatchedUpdates(); // Step 2: Revert - remove the selfDM split (opened from within selfDM, per the repro), leaving the @@ -2830,13 +2826,13 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { 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: { - [workspaceChildSnapshotKey]: workspaceChildTransaction, - [selfDMChildSnapshotKey]: selfDMChildTransaction, - }, + data: snapshotData, search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, - } as unknown as SearchResults); + }); await waitForBatchedUpdates(); let allTransactions: OnyxCollection; @@ -2844,11 +2840,11 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { let allReportNameValuePairs: OnyxCollection; let allReportActions: OnyxCollection; let allSnapshots: OnyxCollection; - await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, callback: (value) => (allTransactions = value)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (value) => (allReports = value)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, waitForCollectionCallback: true, callback: (value) => (allReportNameValuePairs = value)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, callback: (value) => (allReportActions = value)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.SNAPSHOT, waitForCollectionCallback: true, callback: (value) => (allSnapshots = value)}); + 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 @@ -2972,13 +2968,13 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { 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: { - [child1SnapshotKey]: childTransaction1, - [child2SnapshotKey]: childTransaction2, - }, + data: snapshotData, search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, - } as unknown as SearchResults); + }); await waitForBatchedUpdates(); let allTransactions: OnyxCollection; @@ -2986,11 +2982,11 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { let allReportNameValuePairs: OnyxCollection; let allReportActions: OnyxCollection; let allSnapshots: OnyxCollection; - await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, callback: (value) => (allTransactions = value)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (value) => (allReports = value)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, waitForCollectionCallback: true, callback: (value) => (allReportNameValuePairs = value)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, callback: (value) => (allReportActions = value)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.SNAPSHOT, waitForCollectionCallback: true, callback: (value) => (allSnapshots = value)}); + 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 @@ -5808,9 +5804,9 @@ describe('updateSplitTransactions', () => { let allTransactions: OnyxCollection; let allReports: OnyxCollection; let allReportNameValuePairs: OnyxCollection; - await getOnyxData({key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, callback: (v) => (allTransactions = v)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (v) => (allReports = v)}); - await getOnyxData({key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, waitForCollectionCallback: true, callback: (v) => (allReportNameValuePairs = v)}); + 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 From 6340729b5bdd11839986527260e9f47de60000a4 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Fri, 24 Jul 2026 17:24:13 +0200 Subject: [PATCH 25/29] Fix lint issues --- src/pages/DynamicReportDetailsPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 0fa6dea84856..19ca3cdba68a 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -350,7 +350,11 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report 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 [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createHasWorkspaceToSubmitToSelector(currentUserPersonalDetails.login, isSubmit2026BetaEnabled)}); + 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, From 4a338507661de436fcadbb6ad9e832d74badf439 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Mon, 27 Jul 2026 11:27:27 +0200 Subject: [PATCH 26/29] Update seatbelt --- config/eslint/eslint.seatbelt.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 0b05ef5cf05e..9083b0dade1a 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1691,7 +1691,7 @@ "../../tests/actions/IOUTest/RejectMoneyRequestTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../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" 21 +"../../tests/actions/IOUTest/SplitTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 "../../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 From 868f2fed844ef68c2cd9babb524091caa65465e1 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Tue, 28 Jul 2026 13:31:09 +0200 Subject: [PATCH 27/29] Fix comment --- src/pages/DynamicReportDetailsPage.tsx | 38 ++++++++++++++------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index 19ca3cdba68a..e910cf076899 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -545,8 +545,10 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report // 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( - { + // Self-DM split expenses can only be submitted to a workspace, so the "a friend" destination is omitted here + // just like it is on the track-expense whisper. + if (!isSelfDMExpenseSplit) { + items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.TRACK.SUBMIT_TO_FRIEND, translationKey: 'actionableMentionTrackExpense.submitToFriend', icon: expensifyIcons.Send, @@ -560,23 +562,23 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report defaultWorkspaceName, }); }, + }); + } + items.push({ + 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, + }); }, - { - 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, From 76c928a08e3c15813e5237ea7bbfdb16363f8c5a Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Tue, 28 Jul 2026 17:22:05 +0200 Subject: [PATCH 28/29] Fix comments --- src/libs/TransactionUtils/index.ts | 1 + src/libs/actions/IOU/SplitExpenseItems.ts | 2 ++ src/libs/actions/IOU/SplitTransactionUpdate.ts | 6 ++++++ src/pages/DynamicReportDetailsPage.tsx | 2 ++ src/pages/iou/SplitExpenseEditPage.tsx | 1 + src/pages/iou/SplitExpensePage.tsx | 4 ++++ src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx | 1 + src/pages/iou/request/step/IOURequestStepUpgrade.tsx | 1 + tests/actions/IOUTest/SplitTest.ts | 1 + 9 files changed, 19 insertions(+) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 676e97760f0e..230dfd06a5f9 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -740,6 +740,7 @@ function getUpdatedTransaction({ // Otherwise, recalculate the fields based on the new rate. 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) { diff --git a/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts index cea22424d29f..6a85aeea9905 100644 --- a/src/libs/actions/IOU/SplitExpenseItems.ts +++ b/src/libs/actions/IOU/SplitExpenseItems.ts @@ -674,12 +674,14 @@ function updateSplitExpenseAmountField( if (isDistanceRequest && originalTransaction && splitExpense.customUnit) { const mileageRate = resolveSplitMileageRate({transaction: originalTransaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); const splitRateID = splitExpense.customUnit?.customUnitRateID ?? String(CONST.DEFAULT_NUMBER_ID); + // `policy` is undefined for a self-DM split on the personal rate, so also resolve the split's // picked rate across all policies, so the selection isn't lost. const splitSelectedRate = DistanceRequestUtils.getRateByCustomUnitRateID({policy, customUnitRateID: splitRateID}) ?? DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(splitRateID, policies); const isSplitP2PRate = splitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID; + // Prefer the split's own selected rate when it's a real enabled rate; otherwise fall back to // the original-transaction rate (covers the P2P and deleted-rate cases). const useSplitSelectedRate = !isSplitP2PRate && !!splitSelectedRate?.rate && splitSelectedRate.rate > 0 && splitSelectedRate.enabled !== false; diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 278708ac374f..7492916e543e 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -782,11 +782,13 @@ function updateSplitTransactions({ if (isReverseSplitOperation) { delete transactionChanges.transactionID; + // 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; } @@ -1203,6 +1205,7 @@ function updateSplitTransactions({ if (expectedMerchant && transactionUpdateValue.merchant !== expectedMerchant) { transactionUpdateValue.merchant = 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 @@ -1224,6 +1227,7 @@ 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; + // 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). @@ -1731,6 +1735,7 @@ function updateSplitTransactions({ }, }); pushUpdatedReportPreviewActionToOnyxData(); + // 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. @@ -1910,6 +1915,7 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const originalTransactionID = params.transactionData?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; const allChildTransactions = getChildTransactions(params.allTransactionsList, originalTransactionID, false); const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); + // 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; diff --git a/src/pages/DynamicReportDetailsPage.tsx b/src/pages/DynamicReportDetailsPage.tsx index e910cf076899..afe176c49ade 100644 --- a/src/pages/DynamicReportDetailsPage.tsx +++ b/src/pages/DynamicReportDetailsPage.tsx @@ -521,6 +521,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report const actionableWhisperReportActionID = whisperAction?.reportActionID; const currentUserLocalCurrency = currentUserPersonalDetails.localCurrencyCode ?? CONST.CURRENCY.USD; 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 = { @@ -545,6 +546,7 @@ function DynamicReportDetailsPage({policy, report, route, reportMetadata, report // 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); + // Self-DM split expenses can only be submitted to a workspace, so the "a friend" destination is omitted here // just like it is on the track-expense whisper. if (!isSelfDMExpenseSplit) { diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index a2536df08364..d6ea36caf1a9 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -202,6 +202,7 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { isDistance && !isP2PRate && (!rates[currentRateID] || !rate || rawPolicyRate?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || rawPolicyRate?.enabled === false); 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)) diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 0f7a352ffc67..9bbd04a3196f 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -134,9 +134,11 @@ function SplitExpensePage({route}: SplitExpensePageProps) { 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. @@ -209,6 +211,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const isDistance = isDistanceRequest(transaction); const isCard = isManagedCardTransaction(transaction); 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], allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportIDForActions}`]).filter( @@ -264,6 +267,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { } continue; } + // `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 = diff --git a/src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx b/src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx index 583f8e1298fc..3da995232cf1 100644 --- a/src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx +++ b/src/pages/iou/request/step/IOURequestStepCategoryCreate.tsx @@ -72,6 +72,7 @@ function IOURequestStepCategoryCreate({ 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 diff --git a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx index 76781f53bc40..401bf7ce9940 100644 --- a/src/pages/iou/request/step/IOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/IOURequestStepUpgrade.tsx @@ -281,6 +281,7 @@ 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; diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index ae845cd47a59..960342b98767 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -2629,6 +2629,7 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { // ...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 = { From 2974c9a10b5b6cfb9833e9409b7c75e1464fa9a3 Mon Sep 17 00:00:00 2001 From: yauhenihorbach Date: Wed, 29 Jul 2026 09:41:19 +0200 Subject: [PATCH 29/29] Fix issue with optimistic group ammount in spend screen --- .../actions/IOU/SplitTransactionUpdate.ts | 128 +++- tests/actions/IOUTest/SplitTest.ts | 564 ++++++++++++++++++ 2 files changed, 666 insertions(+), 26 deletions(-) diff --git a/src/libs/actions/IOU/SplitTransactionUpdate.ts b/src/libs/actions/IOU/SplitTransactionUpdate.ts index 7492916e543e..b63a1160807f 100644 --- a/src/libs/actions/IOU/SplitTransactionUpdate.ts +++ b/src/libs/actions/IOU/SplitTransactionUpdate.ts @@ -121,13 +121,53 @@ type UpdateSplitTransactionsParams = { isTrackIntentUser: boolean | undefined; }; -function resetSnapshotGroupAmount(transaction: T): T { +/** + * Picks the transaction in `snapshotData` whose conversion can be reused for `transaction`: candidates are + * tried in order and the first one holding a conversion for the same currency wins. + */ +function findSnapshotGroupSourceTransaction( + snapshotData: OnyxTypes.SearchResults['data'] | undefined, + transaction: OnyxTypes.Transaction, + candidateTransactionIDs: Array, +): OnyxTypes.Transaction | undefined { + if (!snapshotData) { + return undefined; + } + + return candidateTransactionIDs.reduce((foundTransaction, candidateTransactionID) => { + if (foundTransaction || !candidateTransactionID) { + return foundTransaction; + } + + const candidateTransaction = snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${candidateTransactionID}`]; + const hasConversion = !!candidateTransaction?.groupCurrency && candidateTransaction.groupAmount !== undefined; + return hasConversion && candidateTransaction.currency === transaction.currency ? candidateTransaction : undefined; + }, undefined); +} + +/** + * Returns `transaction` with its group fields expressed in `groupSourceTransaction`'s grouping currency, + * scaled by the two amounts. Every snapshot has its own grouping currency, so the source must come from the + * snapshot being written to. Falls back to the transaction's own currency when the source has no conversion. + */ +function rescaleSnapshotGroupAmount(transaction: T, groupSourceTransaction: OnyxTypes.Transaction | undefined): T { const splitAmount = hasValidModifiedAmount(transaction) ? Number(transaction.modifiedAmount) : (transaction.amount ?? 0); + const sourceAmount = hasValidModifiedAmount(groupSourceTransaction) ? Number(groupSourceTransaction?.modifiedAmount) : (groupSourceTransaction?.amount ?? 0); + + if (!groupSourceTransaction?.groupCurrency || groupSourceTransaction.groupAmount === undefined || !sourceAmount) { + return { + ...transaction, + groupAmount: splitAmount, + groupCurrency: transaction.currency, + groupExchangeRate: undefined, + }; + } + return { ...transaction, - groupAmount: splitAmount, - groupCurrency: transaction.currency, - groupExchangeRate: undefined, + groupAmount: Math.round(groupSourceTransaction.groupAmount * (splitAmount / sourceAmount)), + groupCurrency: groupSourceTransaction.groupCurrency, + groupExchangeRate: groupSourceTransaction.groupExchangeRate, }; } @@ -238,6 +278,13 @@ function updateSplitTransactions({ const splitExpensesTotal = transactionData?.splitExpensesTotal ?? 0; + // Transactions whose snapshot conversion can be reused for this expense's splits, best source first + const groupSourceCandidateTransactionIDs = [ + ...allChildTransactions.map((childTransaction) => childTransaction?.transactionID), + ...splitExpenses.map((splitExpense) => splitExpense.transactionID), + originalTransactionID, + ]; + const isCreationOfSplits = allChildTransactions.length === 0; const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED); const isReverseSplitOperation = splitExpenses.length === 1 && allChildTransactions.length > 0 && hasEditableSplitExpensesLeft; @@ -1235,14 +1282,12 @@ function updateSplitTransactions({ 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}), - }), - ); + newSelfDMSplitTransactions.push({ + ...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; @@ -1273,7 +1318,7 @@ function updateSplitTransactions({ transactionUpdate && 'value' in transactionUpdate && typeof transactionUpdate.value === 'object' && transactionUpdate.value !== null ? (transactionUpdate.value as OnyxTypes.Transaction) : optimisticTransactionFromGetMoneyRequest; - optimisticChildSnapshotEntries[transactionKey] = resetSnapshotGroupAmount(snapshotTransaction); + optimisticChildSnapshotEntries[transactionKey] = snapshotTransaction; optimisticChildSnapshotKeys.push(transactionKey); } @@ -1463,15 +1508,26 @@ function updateSplitTransactions({ } if (revertedOriginalTransaction) { - optimisticSnapshotData[originalSnapshotTransactionKey] = revertedOriginalTransaction; + const groupSourceTransaction = findSnapshotGroupSourceTransaction(previousSnapshotData, revertedOriginalTransaction, groupSourceCandidateTransactionIDs); + + optimisticSnapshotData[originalSnapshotTransactionKey] = groupSourceTransaction + ? rescaleSnapshotGroupAmount(revertedOriginalTransaction, groupSourceTransaction) + : revertedOriginalTransaction; failureSnapshotData[originalSnapshotTransactionKey] = previousSnapshotData[originalSnapshotTransactionKey] ?? null; } } else if (snapshotKeysToUpdate.has(typedSnapshotKey)) { // Snapshot doesn't contain the split children but is an active search snapshot — // inject the restored original transaction so it appears in Reports > Expenses. - for (const tx of newSelfDMSplitTransactions) { - optimisticSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${tx.transactionID}`] = tx; - failureSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${tx.transactionID}`] = null; + for (const newSelfDMSplitTransaction of newSelfDMSplitTransactions) { + const groupSourceTransaction = findSnapshotGroupSourceTransaction(previousSnapshotData, newSelfDMSplitTransaction, [ + newSelfDMSplitTransaction.transactionID, + ...groupSourceCandidateTransactionIDs, + ]); + optimisticSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${newSelfDMSplitTransaction.transactionID}`] = rescaleSnapshotGroupAmount( + newSelfDMSplitTransaction, + groupSourceTransaction, + ); + failureSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${newSelfDMSplitTransaction.transactionID}`] = null; } } @@ -1649,18 +1705,27 @@ function updateSplitTransactions({ // When creating splits: replace the original transaction with the new split transactions. if (isCreationOfSplits && Object.hasOwn(snapshot.data, originalTransactionSnapshotKey)) { + const previousOriginalTransaction = snapshot.data[originalTransactionSnapshotKey] as OnyxTypes.Transaction | undefined; optimisticSnapshotData[originalTransactionSnapshotKey] = null; - failureSnapshotData[originalTransactionSnapshotKey] = snapshot.data[originalTransactionSnapshotKey] ?? originalTransaction ?? null; - for (const tx of newSelfDMSplitTransactions) { - optimisticSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${tx.transactionID}`] = tx; - failureSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${tx.transactionID}`] = null; + failureSnapshotData[originalTransactionSnapshotKey] = previousOriginalTransaction ?? originalTransaction ?? null; + for (const newSelfDMSplitTransaction of newSelfDMSplitTransactions) { + optimisticSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${newSelfDMSplitTransaction.transactionID}`] = rescaleSnapshotGroupAmount( + newSelfDMSplitTransaction, + previousOriginalTransaction, + ); + failureSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${newSelfDMSplitTransaction.transactionID}`] = null; } } else if (!isCreationOfSplits && splitTransactionKeys.some((k) => Object.hasOwn(snapshot.data, k))) { // When editing splits: update the existing split transactions in place. - for (const tx of newSelfDMSplitTransactions) { - const txKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${tx.transactionID}` as const; - optimisticSnapshotData[txKey] = tx; - failureSnapshotData[txKey] = snapshot.data[txKey] ?? null; + for (const newSelfDMSplitTransaction of newSelfDMSplitTransactions) { + const splitTransactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${newSelfDMSplitTransaction.transactionID}` as const; + const previousSplitTransaction = snapshot.data[splitTransactionKey] as OnyxTypes.Transaction | undefined; + const groupSourceTransaction = findSnapshotGroupSourceTransaction(snapshot.data, newSelfDMSplitTransaction, [ + newSelfDMSplitTransaction.transactionID, + ...groupSourceCandidateTransactionIDs, + ]); + optimisticSnapshotData[splitTransactionKey] = rescaleSnapshotGroupAmount(newSelfDMSplitTransaction, groupSourceTransaction); + failureSnapshotData[splitTransactionKey] = previousSplitTransaction ?? null; } } @@ -1698,9 +1763,20 @@ function updateSplitTransactions({ } } // Build the snapshot data update: remove original transaction and add child transactions + const currentSnapshotData = allSnapshots?.[`${ONYXKEYS.COLLECTION.SNAPSHOT}${searchContext?.currentSearchHash}`]?.data; + const rescaledChildSnapshotEntries: SearchResultDataType = {}; + for (const childKey of optimisticChildSnapshotKeys) { + const childTransaction = optimisticChildSnapshotEntries[childKey]; + if (!childTransaction) { + continue; + } + const groupSourceTransaction = findSnapshotGroupSourceTransaction(currentSnapshotData, childTransaction, [childTransaction.transactionID, ...groupSourceCandidateTransactionIDs]); + rescaledChildSnapshotEntries[childKey] = rescaleSnapshotGroupAmount(childTransaction, groupSourceTransaction); + } + const optimisticSnapshotData: SearchResultDataType = { [`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`]: null, - ...optimisticChildSnapshotEntries, + ...rescaledChildSnapshotEntries, }; // On failure, restore the original transaction and remove the child transactions diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 960342b98767..d1fb6995dcc4 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -3058,6 +3058,570 @@ describe('updateSplitTransactionsFromSplitExpensesFlow', () => { expect(snapshotDataAfter[originalTransactionSnapshotKey]).toBeTruthy(); }); + it('should keep the snapshot grouping currency when creating splits of a foreign-currency expense', async () => { + // Given an EUR expense that a search snapshot has already converted into its USD grouping currency + const expenseReport: Report = { + ...createRandomReport(31, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + }; + const transaction: Transaction = { + amount: 10000, + currency: 'EUR', + transactionID: 'group-currency-split-original', + reportID: expenseReport.reportID, + created: DateUtils.getDBTime(), + merchant: 'test', + }; + const transactionThread: Report = {...createRandomReport(32, undefined)}; + const iouAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: transaction.amount, + currency: transaction.currency, + comment: '', + participants: [], + transactionID: transaction.transactionID, + iouReportID: expenseReport.reportID, + }), + childReportID: transactionThread.reportID, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`, expenseReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.reportID}`, transactionThread); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`, {[iouAction.reportActionID]: iouAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + + const originalTransactionSnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}` as const; + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}` as const; + const snapshotData: SearchResults['data'] = {}; + snapshotData[originalTransactionSnapshotKey] = { + ...transaction, + groupAmount: 11000, + groupCurrency: 'USD', + groupExchangeRate: 1.1, + }; + await Onyx.merge(snapshotKey, { + data: snapshotData, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, + }); + await waitForBatchedUpdates(); + + const splitTransactionID1 = 'group-currency-split-child-1'; + const splitTransactionID2 = 'group-currency-split-child-2'; + + let allTransactions: OnyxCollection; + let allReports: OnyxCollection; + let allReportNameValuePairs: 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.SNAPSHOT, callback: (value) => (allSnapshots = value)}); + + const transactionReportID = transaction.reportID ?? String(CONST.DEFAULT_NUMBER_ID); + const reports = getTransactionAndExpenseReports(transactionReportID); + + // When the expense is split in half + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportActionsList: undefined, + allReportNameValuePairsList: allReportNameValuePairs, + allSnapshots, + transactionData: { + reportID: transactionReportID, + originalTransactionID: transaction.transactionID, + splitExpenses: [ + {amount: transaction.amount / 2, transactionID: splitTransactionID1, created: ''}, + {amount: transaction.amount / 2, transactionID: splitTransactionID2, created: ''}, + ], + splitExpensesTotal: undefined, + }, + searchContext: {currentSearchHash: unapprovedCashHash}, + policyCategories: undefined, + policy: undefined, + policyRecentlyUsedCategories: [], + iouReport: expenseReport, + firstIOU: undefined, + isASAPSubmitBetaEnabled: false, + currentUserPersonalDetails, + transactionViolations: {}, + policyRecentlyUsedCurrencies: [], + quickAction: undefined, + iouReportNextStep: undefined, + betas: [CONST.BETAS.ALL], + allPolicyTags: await getAllPolicyTags(), + 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 each split keeps the snapshot's USD grouping instead of switching to the expense's own EUR, + // so the Search selection total stays in a single currency while the request is pending. + const snapshotAfter = await getOnyxValue(snapshotKey); + const snapshotDataAfter = snapshotAfter?.data ?? {}; + const split1 = snapshotDataAfter[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID1}`]; + const split2 = snapshotDataAfter[`${ONYXKEYS.COLLECTION.TRANSACTION}${splitTransactionID2}`]; + + expect(split1?.groupCurrency).toBe('USD'); + expect(split2?.groupCurrency).toBe('USD'); + expect(split1?.groupExchangeRate).toBe(1.1); + expect(split2?.groupExchangeRate).toBe(1.1); + expect(Math.abs(split1?.groupAmount ?? 0) + Math.abs(split2?.groupAmount ?? 0)).toBe(11000); + }); + + it('should keep the snapshot grouping currency for the reverted transaction when reverting a foreign-currency split', async () => { + // Given a selfDM EUR expense split into two children that the snapshot converted into USD + const selfDMReport = createSelfDM(33, RORY_ACCOUNT_ID); + const originalTransactionID = 'group-currency-revert-original'; + const childTransactionID1 = 'group-currency-revert-child-1'; + const childTransactionID2 = 'group-currency-revert-child-2'; + + const originalTransaction: Transaction = { + transactionID: originalTransactionID, + amount: -10000, + currency: 'EUR', + merchant: 'Test Merchant', + comment: {comment: 'Original expense'}, + created: DateUtils.getDBTime(), + reportID: CONST.REPORT.SPLIT_REPORT_ID, + }; + const childTransaction1: Transaction = { + transactionID: childTransactionID1, + amount: -5000, + currency: 'EUR', + merchant: 'Test Merchant', + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + created: DateUtils.getDBTime(), + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + const childTransaction2: Transaction = {...childTransaction1, transactionID: childTransactionID2}; + + const child1IOUAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount: 5000, + currency: 'EUR', + 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: 'EUR', + 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, + }); + + 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, groupAmount: -5500, groupCurrency: 'USD', groupExchangeRate: 1.1}; + snapshotData[child2SnapshotKey] = {...childTransaction2, groupAmount: -5500, groupCurrency: 'USD', groupExchangeRate: 1.1}; + 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 one of the two splits is removed, reverting the split back into a single expense + 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 the restored expense is converted from the removed children's rate against the amount it was + // actually reverted to (10000 EUR-cents), not left in EUR and not scaled from a single child's amount. + const snapshotAfter = await getOnyxValue(snapshotKey); + const revertedTransaction = snapshotAfter?.data?.[originalTransactionSnapshotKey]; + + expect(revertedTransaction?.groupCurrency).toBe('USD'); + expect(revertedTransaction?.groupExchangeRate).toBe(1.1); + expect(Math.abs(revertedTransaction?.groupAmount ?? 0)).toBe(11000); + }); + + it('should keep the snapshot grouping currency when editing existing foreign-currency splits', async () => { + // Given a selfDM EUR expense already split into two children that the snapshot converted into USD. + // The original is no longer in the snapshot — creating the splits removed it. + const selfDMReport = createSelfDM(34, RORY_ACCOUNT_ID); + const originalTransactionID = 'group-currency-edit-original'; + const childTransactionID1 = 'group-currency-edit-child-1'; + const childTransactionID2 = 'group-currency-edit-child-2'; + + const originalTransaction: Transaction = { + transactionID: originalTransactionID, + amount: -10000, + currency: 'EUR', + merchant: 'Test Merchant', + comment: {comment: 'Original expense'}, + created: DateUtils.getDBTime(), + reportID: CONST.REPORT.SPLIT_REPORT_ID, + }; + const childTransaction1: Transaction = { + transactionID: childTransactionID1, + amount: -5000, + currency: 'EUR', + merchant: 'Test Merchant', + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + created: DateUtils.getDBTime(), + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + const childTransaction2: Transaction = {...childTransaction1, transactionID: childTransactionID2}; + + const child1IOUAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount: 5000, + currency: 'EUR', + 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: 'EUR', + 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, + }); + + const child1SnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID1}` as const; + const child2SnapshotKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID2}` as const; + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}` as const; + const snapshotData: SearchResults['data'] = {}; + snapshotData[child1SnapshotKey] = {...childTransaction1, groupAmount: -5500, groupCurrency: 'USD', groupExchangeRate: 1.1}; + snapshotData[child2SnapshotKey] = {...childTransaction2, groupAmount: -5500, groupCurrency: 'USD', groupExchangeRate: 1.1}; + 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 amounts of both splits are edited + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + allSnapshots, + transactionData: { + reportID: selfDMReport.reportID, + originalTransactionID, + splitExpenses: [ + {transactionID: childTransactionID1, amount: 7000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + {transactionID: childTransactionID2, amount: 3000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + ], + splitExpensesTotal: undefined, + }, + searchContext: {currentSearchHash: unapprovedCashHash}, + 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 the edited splits stay converted into the snapshot's USD grouping + const snapshotAfter = await getOnyxValue(snapshotKey); + const editedSplit1 = snapshotAfter?.data?.[child1SnapshotKey]; + const editedSplit2 = snapshotAfter?.data?.[child2SnapshotKey]; + + expect(editedSplit1?.groupCurrency).toBe('USD'); + expect(editedSplit2?.groupCurrency).toBe('USD'); + expect(editedSplit1?.groupExchangeRate).toBe(1.1); + expect(editedSplit2?.groupExchangeRate).toBe(1.1); + expect(Math.abs(editedSplit1?.groupAmount ?? 0)).toBe(7700); + expect(Math.abs(editedSplit2?.groupAmount ?? 0)).toBe(3300); + }); + + /** + * Sets up a selfDM EUR expense already split into `childTransactionIDs`, each worth 5000 EUR-cents, plus a + * search snapshot holding those children. With `shouldConvertInSnapshot` the snapshot converts them to USD. + */ + async function setUpConvertedSelfDMSplits(selfDMReportNumber: number, originalTransactionID: string, childTransactionIDs: string[], shouldConvertInSnapshot: boolean) { + const selfDMReport = createSelfDM(selfDMReportNumber, RORY_ACCOUNT_ID); + const originalTransaction: Transaction = { + transactionID: originalTransactionID, + amount: -5000 * childTransactionIDs.length, + currency: 'EUR', + merchant: 'Test Merchant', + comment: {comment: 'Original expense'}, + created: DateUtils.getDBTime(), + reportID: CONST.REPORT.SPLIT_REPORT_ID, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, originalTransaction); + + const snapshotData: SearchResults['data'] = {}; + const childIOUActions: Record = {}; + for (const childTransactionID of childTransactionIDs) { + const childTransaction: Transaction = { + transactionID: childTransactionID, + amount: -5000, + currency: 'EUR', + merchant: 'Test Merchant', + comment: {originalTransactionID, source: CONST.IOU.TYPE.SPLIT}, + created: DateUtils.getDBTime(), + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID}`, childTransaction); + + const childIOUAction: ReportAction = { + ...buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount: 5000, + currency: 'EUR', + comment: '', + participants: [{accountID: RORY_ACCOUNT_ID, login: RORY_EMAIL}], + transactionID: childTransactionID, + isPersonalTrackingExpense: true, + }), + }; + childIOUActions[childIOUAction.reportActionID] = childIOUAction; + snapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID}`] = shouldConvertInSnapshot + ? {...childTransaction, groupAmount: -5500, groupCurrency: 'USD', groupExchangeRate: 1.1} + : childTransaction; + } + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, childIOUActions); + + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}` as const; + await Onyx.merge(snapshotKey, { + data: snapshotData, + search: {type: CONST.SEARCH.DATA_TYPES.EXPENSE, isLoading: false}, + }); + await waitForBatchedUpdates(); + + return {selfDMReport, snapshotKey}; + } + + /** Runs the split-expenses flow against the scenario built by `setUpConvertedSelfDMSplits`. */ + async function runSelfDMSplitExpensesFlow(selfDMReport: Report, originalTransactionID: string, splitExpenses: SplitExpense[]) { + 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)}); + + updateSplitTransactionsFromSplitExpensesFlow({ + allTransactionsList: allTransactions, + allReportsList: allReports, + allReportActionsList: allReportActions, + allReportNameValuePairsList: allReportNameValuePairs, + allSnapshots, + transactionData: { + reportID: selfDMReport.reportID, + originalTransactionID, + splitExpenses, + splitExpensesTotal: undefined, + }, + searchContext: {currentSearchHash: unapprovedCashHash}, + 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(); + } + + it('should keep the snapshot grouping currency for a split added to an existing foreign-currency split set', async () => { + // Given two existing EUR splits that the snapshot converted into USD + const originalTransactionID = 'group-currency-add-original'; + const childTransactionID1 = 'group-currency-add-child-1'; + const childTransactionID2 = 'group-currency-add-child-2'; + const addedTransactionID = 'group-currency-add-child-3'; + const {selfDMReport, snapshotKey} = await setUpConvertedSelfDMSplits(35, originalTransactionID, [childTransactionID1, childTransactionID2], true); + + // When a third split is added, so the new split has no entry of its own in the snapshot yet + await runSelfDMSplitExpensesFlow(selfDMReport, originalTransactionID, [ + {transactionID: childTransactionID1, amount: 4000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + {transactionID: childTransactionID2, amount: 3000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + {transactionID: addedTransactionID, amount: 3000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + ]); + + // Then it borrows the conversion of its siblings instead of falling back to the expense's own EUR + const snapshotAfter = await getOnyxValue(snapshotKey); + const addedSplit = snapshotAfter?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${addedTransactionID}`]; + + expect(addedSplit?.groupCurrency).toBe('USD'); + expect(addedSplit?.groupExchangeRate).toBe(1.1); + expect(Math.abs(addedSplit?.groupAmount ?? 0)).toBe(3300); + }); + + it('should keep the snapshot grouping currency for the splits left after deleting one of them', async () => { + // Given three existing EUR splits that the snapshot converted into USD + const originalTransactionID = 'group-currency-delete-original'; + const childTransactionID1 = 'group-currency-delete-child-1'; + const childTransactionID2 = 'group-currency-delete-child-2'; + const childTransactionID3 = 'group-currency-delete-child-3'; + const {selfDMReport, snapshotKey} = await setUpConvertedSelfDMSplits(36, originalTransactionID, [childTransactionID1, childTransactionID2, childTransactionID3], true); + + // When one of them is deleted and the remaining two absorb its amount + await runSelfDMSplitExpensesFlow(selfDMReport, originalTransactionID, [ + {transactionID: childTransactionID1, amount: 10000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + {transactionID: childTransactionID2, amount: 5000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + ]); + + // Then the surviving splits stay converted and the deleted one is gone from the snapshot + const snapshotAfter = await getOnyxValue(snapshotKey); + const survivingSplit1 = snapshotAfter?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID1}`]; + const survivingSplit2 = snapshotAfter?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID2}`]; + + expect(survivingSplit1?.groupCurrency).toBe('USD'); + expect(survivingSplit2?.groupCurrency).toBe('USD'); + expect(Math.abs(survivingSplit1?.groupAmount ?? 0)).toBe(11000); + expect(Math.abs(survivingSplit2?.groupAmount ?? 0)).toBe(5500); + expect(snapshotAfter?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID3}`]).toBeFalsy(); + }); + + it('should fall back to the expense currency when the snapshot holds no conversion', async () => { + // Given two existing EUR splits that the snapshot stores without any grouping conversion + const originalTransactionID = 'no-conversion-original'; + const childTransactionID1 = 'no-conversion-child-1'; + const childTransactionID2 = 'no-conversion-child-2'; + const {selfDMReport, snapshotKey} = await setUpConvertedSelfDMSplits(37, originalTransactionID, [childTransactionID1, childTransactionID2], false); + + // When the splits are edited + await runSelfDMSplitExpensesFlow(selfDMReport, originalTransactionID, [ + {transactionID: childTransactionID1, amount: 7000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + {transactionID: childTransactionID2, amount: 3000, created: DateUtils.getDBTime(), reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, + ]); + + // Then each split is grouped by its own currency, with no invented exchange rate + const snapshotAfter = await getOnyxValue(snapshotKey); + const editedSplit1 = snapshotAfter?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID1}`]; + const editedSplit2 = snapshotAfter?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${childTransactionID2}`]; + + expect(editedSplit1?.groupCurrency).toBe('EUR'); + expect(editedSplit2?.groupCurrency).toBe('EUR'); + expect(editedSplit1?.groupExchangeRate).toBeUndefined(); + expect(editedSplit2?.groupExchangeRate).toBeUndefined(); + expect(Math.abs(editedSplit1?.groupAmount ?? 0)).toBe(7000); + expect(Math.abs(editedSplit2?.groupAmount ?? 0)).toBe(3000); + }); + it('should migrate split thread comments to the original transaction thread when reverting a split', async () => { const amount = 10000; let expenseReport: OnyxEntry;