From 8ed948281c6a52c2ea9a1a26858b0661bbd7dc93 Mon Sep 17 00:00:00 2001
From: Test <olimpia.zurek@gmail.com>
Date: Wed, 1 Apr 2026 12:49:20 +0200
Subject: [PATCH 1/8] extract renderless components from
 IOURequestStepConfirmation and fix collection subs

---
 .../step/IOURequestStepConfirmation.tsx       | 189 +++++-------------
 .../confirmation/CategoryDefaultsSetter.tsx   |  71 +++++++
 .../confirmation/DraftWorkspaceOpener.tsx     |  45 +++++
 .../confirmation/ExpenseDefaultsSetter.tsx    |  46 +++++
 .../confirmation/MoneyRequestInitializer.tsx  |  44 ++++
 .../confirmation/TelemetrySpanManager.tsx     |  50 +++++
 6 files changed, 308 insertions(+), 137 deletions(-)
 create mode 100644 src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx
 create mode 100644 src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx
 create mode 100644 src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx
 create mode 100644 src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx
 create mode 100644 src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx

diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index a63ded546c1f..496b4c313b8b 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -56,7 +56,6 @@ import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'
 import Navigation from '@libs/Navigation/Navigation';
 import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils';
 import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils';
-import {isPaidGroupPolicy} from '@libs/PolicyUtils';
 import {
     findSelfDMReportID,
     generateReportID,
@@ -68,7 +67,7 @@ import {
     isSelectedManagerMcTest,
 } from '@libs/ReportUtils';
 import stitchOdometerImages from '@libs/stitchOdometerImages';
-import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
+import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
 import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario';
 import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd';
 import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
@@ -92,11 +91,9 @@ import {
     getIOURequestPolicyID,
     requestMoney as requestMoneyIOUActions,
     setMoneyRequestBillable,
-    setMoneyRequestCategory,
     setMoneyRequestParticipantsFromReport,
     setMoneyRequestReceipt,
     setMoneyRequestReimbursable,
-    startMoneyRequest,
     trackExpense as trackExpenseIOUActions,
     updateLastLocationPermissionPrompt,
 } from '@userActions/IOU';
@@ -104,7 +101,6 @@ import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpe
 import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice';
 import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney';
 import {splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split';
-import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy';
 import {removeDraftTransaction, removeDraftTransactionsByIDs, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit';
 import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
@@ -118,6 +114,11 @@ import type {Receipt} from '@src/types/onyx/Transaction';
 import type {FileObject} from '@src/types/utils/Attachment';
 import {isEmptyObject} from '@src/types/utils/EmptyObject';
 import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+import CategoryDefaultsSetter from './confirmation/CategoryDefaultsSetter';
+import DraftWorkspaceOpener from './confirmation/DraftWorkspaceOpener';
+import ExpenseDefaultsSetter from './confirmation/ExpenseDefaultsSetter';
+import MoneyRequestInitializer from './confirmation/MoneyRequestInitializer';
+import TelemetrySpanManager from './confirmation/TelemetrySpanManager';
 import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound';
 import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
 import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound';
@@ -193,7 +194,6 @@ function IOURequestStepConfirmation({
     const draftPolicyID = getIOURequestPolicyID(initialTransaction, reportDraft);
     const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${draftPolicyID}`);
     const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${realPolicyID}`);
-    const [reportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT);
     const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS);
     const [betas] = useOnyx(ONYXKEYS.BETAS);
     const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
@@ -255,6 +255,8 @@ function IOURequestStepConfirmation({
     const reportAttributesDerived = useReportAttributes();
     const [recentlyUsedDestinations] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS}${policyID}`);
     const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
+    const transactionViolationsRef = useRef(transactionViolations);
+    transactionViolationsRef.current = transactionViolations;
     const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserPersonalDetails.accountID, currentUserPersonalDetails.login ?? '');
 
     const policyCategories = useMemo(() => {
@@ -350,9 +352,11 @@ function IOURequestStepConfirmation({
                 const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`];
                 return participant.accountID
                     ? getParticipantsOption(participant, personalDetails)
-                    : getReportOption(participant, privateIsArchived, policy, personalDetails, conciergeReportID, reportAttributesDerived, reportDrafts);
+                    : getReportOption(participant, privateIsArchived, policy, personalDetails, conciergeReportID, reportAttributesDerived);
             }) ?? [],
-        [transaction?.participants, iouType, personalDetails, reportAttributesDerived, reportDrafts, privateIsArchivedMap, policy, conciergeReportID],
+        // getReportOrDraftReport (called inside getReportOption) falls back to its module-level allReportsDraft
+        // connection, so we don't need to subscribe to COLLECTION.REPORT_DRAFT here.
+        [transaction?.participants, iouType, personalDetails, reportAttributesDerived, privateIsArchivedMap, policy, conciergeReportID],
     );
     const participantsPolicyTags = useParticipantsPolicyTags(participants ?? []);
     const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]);
@@ -363,60 +367,9 @@ function IOURequestStepConfirmation({
 
     useFetchRoute(transaction, transaction?.comment?.waypoints, action, shouldUseTransactionDraft(action, iouType) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT);
 
-    useEffect(() => {
-        endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE);
-        endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT);
-
-        // Grab parent ref before ending it — children need it for parent_span_id linking
-        const parentSpan = getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
-
-        startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, {
-            name: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY,
-            op: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY,
-            parentSpan,
-            attributes: {[CONST.TELEMETRY.ATTRIBUTE_IOU_TYPE]: iouType},
-        });
-        startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, {
-            name: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD,
-            op: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD,
-            parentSpan,
-        });
-
-        // End parent AFTER children are created — Sentry preserves parent_span_id regardless
-        endSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
-
-        return () => {
-            cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY);
-            cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD);
-        };
-        // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this to run on mount/unmount
-    }, []);
-
-    useEffect(() => {
-        if (!isCreatingTrackExpense || policyID === undefined) {
-            return;
-        }
-
-        openDraftWorkspaceRequest(policyID);
-    }, [isCreatingTrackExpense, policy?.pendingAction, policyID]);
-
-    const policyExpenseChatPolicyID = useMemo(() => {
-        return participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID;
-    }, [participants]);
+    const policyExpenseChatPolicyID = participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID;
 
-    const senderPolicyID = useMemo(() => {
-        return participants?.find((participant) => !!participant && 'isSender' in participant && participant.isSender)?.policyID;
-    }, [participants]);
-
-    useEffect(() => {
-        if (policyExpenseChatPolicyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
-            openDraftWorkspaceRequest(policyExpenseChatPolicyID);
-            return;
-        }
-        if (senderPolicyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
-            openDraftWorkspaceRequest(senderPolicyID);
-        }
-    }, [isOffline, policy?.pendingAction, policyExpenseChatPolicyID, senderPolicyID]);
+    const senderPolicyID = participants?.find((participant) => !!participant && 'isSender' in participant && participant.isSender)?.policyID;
 
     const odometerStartImage = transaction?.comment?.odometerStartImage;
     const odometerEndImage = transaction?.comment?.odometerEndImage;
@@ -478,75 +431,6 @@ function IOURequestStepConfirmation({
         };
     }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, iouType]);
 
-    const defaultBillable = !!policy?.defaultBillable;
-    useEffect(() => {
-        if (isMovingTransactionFromTrackExpense) {
-            return;
-        }
-        for (const transactionID of transactionIDs) {
-            setMoneyRequestBillable(transactionID, defaultBillable);
-        }
-    }, [transactionIDs, defaultBillable, isMovingTransactionFromTrackExpense]);
-
-    useEffect(() => {
-        if (isMovingTransactionFromTrackExpense) {
-            return;
-        }
-        const defaultReimbursable = (isPolicyExpenseChat && isPaidGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true;
-        for (const transactionID of transactionIDs) {
-            setMoneyRequestReimbursable(transactionID, defaultReimbursable);
-        }
-    }, [transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense]);
-
-    useEffect(() => {
-        // Exit early if the transaction is still loading
-        if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) {
-            return;
-        }
-
-        startMoneyRequest(
-            iouType ?? CONST.IOU.TYPE.CREATE,
-            // When starting to create an expense from the global FAB, If there is not an existing report yet, a random optimistic reportID is generated and used
-            // for all of the routes in the creation flow.
-            reportID ?? generateReportID(),
-            draftTransactionIDs,
-        );
-        // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again
-    }, [isLoadingTransaction]);
-
-    useEffect(() => {
-        for (const item of transactions) {
-            if (!item.category) {
-                // If the expense had his category cleared due to unsaved changes (i.e. changing to recipient to one that does not have category)
-                // then we should reset the category to it's last saved value
-                const existingCategory = existingTransaction?.category;
-                if (existingCategory) {
-                    const isExistingCategoryEnabled = policyCategories?.[existingCategory]?.enabled;
-                    if (isExistingCategoryEnabled) {
-                        setMoneyRequestCategory(item.transactionID, existingCategory, policy);
-                    }
-                }
-                continue;
-            }
-        }
-        // We don't want to clear out category every time the transactions change
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [policy?.id, policyCategories, transactions.length]);
-
-    const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
-    const defaultCategory = policyDistance?.defaultCategory ?? '';
-
-    useEffect(() => {
-        for (const item of transactions) {
-            if (!isDistanceRequest || !!item?.category) {
-                continue;
-            }
-            setMoneyRequestCategory(item.transactionID, defaultCategory, policy, isMovingTransactionFromTrackExpense);
-        }
-        // Prevent resetting to default when unselect category
-        // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [transactionIDs, requestType, defaultCategory, policy?.id]);
-
     const navigateBack = useCallback(() => {
         if (backTo) {
             Navigation.goBack(backTo);
@@ -669,6 +553,7 @@ function IOURequestStepConfirmation({
             removeDraftTransactionsByIDs(draftTransactionIDs, true);
             navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID);
         });
+        // eslint-disable-next-line react-hooks/exhaustive-deps -- draftTransactionIDs is intentionally excluded to avoid re-running on draft changes
     }, [requestType, iouType, initialTransactionID, reportID, action, report, transactions, participants]);
 
     const requestMoney = useCallback(
@@ -788,7 +673,7 @@ function IOURequestStepConfirmation({
                     isASAPSubmitBetaEnabled,
                     currentUserAccountIDParam: currentUserPersonalDetails.accountID,
                     currentUserEmailParam: currentUserPersonalDetails.email ?? '',
-                    transactionViolations,
+                    transactionViolations: transactionViolationsRef.current,
                     policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
                     quickAction,
                     existingTransactionDraft,
@@ -823,7 +708,6 @@ function IOURequestStepConfirmation({
             shouldGenerateTransactionThreadReport,
             backToReport,
             isASAPSubmitBetaEnabled,
-            transactionViolations,
             policyRecentlyUsedCurrencies,
             quickAction,
             isSelfTourViewed,
@@ -1085,7 +969,7 @@ function IOURequestStepConfirmation({
                 },
                 backToReport,
                 isASAPSubmitBetaEnabled,
-                transactionViolations,
+                transactionViolations: transactionViolationsRef.current,
                 quickAction,
                 policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
                 personalDetails,
@@ -1116,7 +1000,6 @@ function IOURequestStepConfirmation({
             gpsDraftDetails,
             backToReport,
             isASAPSubmitBetaEnabled,
-            transactionViolations,
             quickAction,
             policyRecentlyUsedCurrencies,
             personalDetails,
@@ -1264,7 +1147,7 @@ function IOURequestStepConfirmation({
                         policyRecentlyUsedCategories,
                         policyRecentlyUsedTags,
                         isASAPSubmitBetaEnabled,
-                        transactionViolations,
+                        transactionViolations: transactionViolationsRef.current,
                         quickAction,
                         policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
                         betas,
@@ -1299,7 +1182,7 @@ function IOURequestStepConfirmation({
                         policyRecentlyUsedCategories,
                         policyRecentlyUsedTags,
                         isASAPSubmitBetaEnabled,
-                        transactionViolations,
+                        transactionViolations: transactionViolationsRef.current,
                         quickAction,
                         policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
                         betas,
@@ -1419,7 +1302,6 @@ function IOURequestStepConfirmation({
             policyRecentlyUsedTags,
             quickAction,
             isASAPSubmitBetaEnabled,
-            transactionViolations,
             existingInvoiceReport,
             policy,
             policyTags,
@@ -1618,6 +1500,39 @@ function IOURequestStepConfirmation({
             shouldAvoidScrollOnVirtualViewport={!isMobileSafari()}
             testID="IOURequestStepConfirmation"
         >
+            <TelemetrySpanManager iouType={iouType} />
+            <DraftWorkspaceOpener
+                isCreatingTrackExpense={isCreatingTrackExpense}
+                policyID={policyID}
+                policyPendingAction={policy?.pendingAction}
+                policyExpenseChatPolicyID={policyExpenseChatPolicyID}
+                senderPolicyID={senderPolicyID}
+                isOffline={isOffline}
+            />
+            <ExpenseDefaultsSetter
+                transactionIDs={transactionIDs}
+                policy={policy}
+                isPolicyExpenseChat={isPolicyExpenseChat}
+                isMovingTransactionFromTrackExpense={isMovingTransactionFromTrackExpense}
+                isCreatingTrackExpense={isCreatingTrackExpense}
+            />
+            <MoneyRequestInitializer
+                isLoadingTransaction={!!isLoadingTransaction}
+                transaction={transaction}
+                iouType={iouType}
+                reportID={reportID}
+                draftTransactionIDs={draftTransactionIDs}
+            />
+            <CategoryDefaultsSetter
+                transactions={transactions}
+                transactionIDs={transactionIDs}
+                existingTransaction={existingTransaction}
+                policyCategories={policyCategories}
+                policy={policy}
+                isDistanceRequest={isDistanceRequest}
+                requestType={requestType}
+                isMovingTransactionFromTrackExpense={isMovingTransactionFromTrackExpense}
+            />
             <DragAndDropProvider isDisabled={!showReceiptEmptyState || isOdometerDistanceRequest}>
                 <View style={styles.flex1}>
                     <HeaderWithBackButton
diff --git a/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx b/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx
new file mode 100644
index 000000000000..c9362d62a884
--- /dev/null
+++ b/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx
@@ -0,0 +1,71 @@
+import {useEffect} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import {setMoneyRequestCategory} from '@userActions/IOU';
+import CONST from '@src/CONST';
+import type {Policy, PolicyCategories, Transaction} from '@src/types/onyx';
+
+type CategoryDefaultsSetterProps = {
+    transactions: Transaction[];
+    transactionIDs: string[];
+    existingTransaction: OnyxEntry<Transaction>;
+    policyCategories: OnyxEntry<PolicyCategories>;
+    policy: OnyxEntry<Policy>;
+    isDistanceRequest: boolean;
+    requestType: string | undefined;
+    isMovingTransactionFromTrackExpense: boolean;
+};
+
+/**
+ * Renderless component that handles two category-related effects:
+ * 1. Resets cleared categories back to their last saved value
+ * 2. Sets the default distance category for distance requests
+ */
+function CategoryDefaultsSetter({
+    transactions,
+    transactionIDs,
+    existingTransaction,
+    policyCategories,
+    policy,
+    isDistanceRequest,
+    requestType,
+    isMovingTransactionFromTrackExpense,
+}: CategoryDefaultsSetterProps) {
+    useEffect(() => {
+        for (const item of transactions) {
+            if (!item.category) {
+                // If the expense had his category cleared due to unsaved changes (i.e. changing to recipient to one that does not have category)
+                // then we should reset the category to it's last saved value
+                const existingCategory = existingTransaction?.category;
+                if (existingCategory) {
+                    const isExistingCategoryEnabled = policyCategories?.[existingCategory]?.enabled;
+                    if (isExistingCategoryEnabled) {
+                        setMoneyRequestCategory(item.transactionID, existingCategory, policy);
+                    }
+                }
+                continue;
+            }
+        }
+        // We don't want to clear out category every time the transactions change
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [policy?.id, policyCategories, transactions.length]);
+
+    const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE);
+    const defaultCategory = policyDistance?.defaultCategory ?? '';
+
+    useEffect(() => {
+        for (const item of transactions) {
+            if (!isDistanceRequest || !!item?.category) {
+                continue;
+            }
+            setMoneyRequestCategory(item.transactionID, defaultCategory, policy, isMovingTransactionFromTrackExpense);
+        }
+        // Prevent resetting to default when unselect category
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [transactionIDs, requestType, defaultCategory, policy?.id]);
+
+    return null;
+}
+
+CategoryDefaultsSetter.displayName = 'CategoryDefaultsSetter';
+
+export default CategoryDefaultsSetter;
diff --git a/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx b/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx
new file mode 100644
index 000000000000..50a70a28161d
--- /dev/null
+++ b/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx
@@ -0,0 +1,45 @@
+import {useEffect} from 'react';
+import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy';
+import CONST from '@src/CONST';
+import type {PendingAction} from '@src/types/onyx/OnyxCommon';
+
+type DraftWorkspaceOpenerProps = {
+    isCreatingTrackExpense: boolean;
+    policyID: string | undefined;
+    policyPendingAction: PendingAction | undefined;
+    policyExpenseChatPolicyID: string | undefined;
+    senderPolicyID: string | undefined;
+    isOffline: boolean;
+};
+
+/**
+ * Renderless component that opens draft workspace requests when needed.
+ * Handles two cases:
+ * 1. When creating a track expense, opens workspace for the policy
+ * 2. When a policy expense chat or sender policy is present, opens that workspace
+ */
+function DraftWorkspaceOpener({isCreatingTrackExpense, policyID, policyPendingAction, policyExpenseChatPolicyID, senderPolicyID, isOffline}: DraftWorkspaceOpenerProps) {
+    useEffect(() => {
+        if (!isCreatingTrackExpense || policyID === undefined) {
+            return;
+        }
+
+        openDraftWorkspaceRequest(policyID);
+    }, [isCreatingTrackExpense, policyPendingAction, policyID]);
+
+    useEffect(() => {
+        if (policyExpenseChatPolicyID && policyPendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
+            openDraftWorkspaceRequest(policyExpenseChatPolicyID);
+            return;
+        }
+        if (senderPolicyID && policyPendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) {
+            openDraftWorkspaceRequest(senderPolicyID);
+        }
+    }, [isOffline, policyPendingAction, policyExpenseChatPolicyID, senderPolicyID]);
+
+    return null;
+}
+
+DraftWorkspaceOpener.displayName = 'DraftWorkspaceOpener';
+
+export default DraftWorkspaceOpener;
diff --git a/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx
new file mode 100644
index 000000000000..ff342c436d03
--- /dev/null
+++ b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx
@@ -0,0 +1,46 @@
+import {useEffect} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import {isPaidGroupPolicy} from '@libs/PolicyUtils';
+import {setMoneyRequestBillable, setMoneyRequestReimbursable} from '@userActions/IOU';
+import type {Policy} from '@src/types/onyx';
+
+type ExpenseDefaultsSetterProps = {
+    transactionIDs: string[];
+    policy: OnyxEntry<Policy>;
+    isPolicyExpenseChat: boolean | undefined;
+    isMovingTransactionFromTrackExpense: boolean;
+    isCreatingTrackExpense: boolean;
+};
+
+/**
+ * Renderless component that sets default billable and reimbursable values
+ * on transactions based on the policy configuration.
+ */
+function ExpenseDefaultsSetter({transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense}: ExpenseDefaultsSetterProps) {
+    const defaultBillable = !!policy?.defaultBillable;
+
+    useEffect(() => {
+        if (isMovingTransactionFromTrackExpense) {
+            return;
+        }
+        for (const transactionID of transactionIDs) {
+            setMoneyRequestBillable(transactionID, defaultBillable);
+        }
+    }, [transactionIDs, defaultBillable, isMovingTransactionFromTrackExpense]);
+
+    useEffect(() => {
+        if (isMovingTransactionFromTrackExpense) {
+            return;
+        }
+        const defaultReimbursable = (isPolicyExpenseChat && isPaidGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true;
+        for (const transactionID of transactionIDs) {
+            setMoneyRequestReimbursable(transactionID, defaultReimbursable);
+        }
+    }, [transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense]);
+
+    return null;
+}
+
+ExpenseDefaultsSetter.displayName = 'ExpenseDefaultsSetter';
+
+export default ExpenseDefaultsSetter;
diff --git a/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx b/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx
new file mode 100644
index 000000000000..6c9613efae50
--- /dev/null
+++ b/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx
@@ -0,0 +1,44 @@
+import {useEffect} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import {generateReportID} from '@libs/ReportUtils';
+import {startMoneyRequest} from '@userActions/IOU';
+import type {IOUType} from '@src/CONST';
+import CONST from '@src/CONST';
+import type {Transaction} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+type MoneyRequestInitializerProps = {
+    isLoadingTransaction: boolean;
+    transaction: OnyxEntry<Transaction>;
+    iouType: IOUType;
+    reportID: string;
+    draftTransactionIDs: string[] | undefined;
+};
+
+/**
+ * Renderless component that initializes a money request (calls startMoneyRequest)
+ * when the transaction loads for the first time and has no participants yet.
+ */
+function MoneyRequestInitializer({isLoadingTransaction, transaction, iouType, reportID, draftTransactionIDs}: MoneyRequestInitializerProps) {
+    useEffect(() => {
+        // Exit early if the transaction is still loading
+        if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) {
+            return;
+        }
+
+        startMoneyRequest(
+            iouType ?? CONST.IOU.TYPE.CREATE,
+            // When starting to create an expense from the global FAB, If there is not an existing report yet, a random optimistic reportID is generated and used
+            // for all of the routes in the creation flow.
+            reportID ?? generateReportID(),
+            draftTransactionIDs,
+        );
+        // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again
+    }, [isLoadingTransaction]);
+
+    return null;
+}
+
+MoneyRequestInitializer.displayName = 'MoneyRequestInitializer';
+
+export default MoneyRequestInitializer;
diff --git a/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx b/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx
new file mode 100644
index 000000000000..e68855905528
--- /dev/null
+++ b/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx
@@ -0,0 +1,50 @@
+import {useEffect} from 'react';
+import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
+import CONST from '@src/CONST';
+import type {IOUType} from '@src/CONST';
+
+type TelemetrySpanManagerProps = {
+    iouType: IOUType;
+};
+
+/**
+ * Renderless component that manages telemetry spans for the confirmation step.
+ * On mount: ends the open/mount spans, starts list-ready and receipt-load spans.
+ * On unmount: cancels any still-open child spans.
+ */
+function TelemetrySpanManager({iouType}: TelemetrySpanManagerProps) {
+    useEffect(() => {
+        endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE);
+        endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT);
+
+        // Grab parent ref before ending it — children need it for parent_span_id linking
+        const parentSpan = getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
+
+        startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, {
+            name: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY,
+            op: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY,
+            parentSpan,
+            attributes: {[CONST.TELEMETRY.ATTRIBUTE_IOU_TYPE]: iouType},
+        });
+        startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, {
+            name: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD,
+            op: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD,
+            parentSpan,
+        });
+
+        // End parent AFTER children are created — Sentry preserves parent_span_id regardless
+        endSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION);
+
+        return () => {
+            cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY);
+            cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD);
+        };
+        // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this to run on mount/unmount
+    }, []);
+
+    return null;
+}
+
+TelemetrySpanManager.displayName = 'TelemetrySpanManager';
+
+export default TelemetrySpanManager;

From ab22b0b8a3e81f120665511bb4bd3e1519098ec5 Mon Sep 17 00:00:00 2001
From: Test <olimpia.zurek@gmail.com>
Date: Wed, 1 Apr 2026 13:13:58 +0200
Subject: [PATCH 2/8] fix spell check

---
 .../iou/request/step/confirmation/CategoryDefaultsSetter.tsx    | 2 +-
 .../iou/request/step/confirmation/DraftWorkspaceOpener.tsx      | 2 +-
 .../iou/request/step/confirmation/ExpenseDefaultsSetter.tsx     | 2 +-
 .../iou/request/step/confirmation/MoneyRequestInitializer.tsx   | 2 +-
 .../iou/request/step/confirmation/TelemetrySpanManager.tsx      | 2 +-
 5 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx b/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx
index c9362d62a884..3ef579a47008 100644
--- a/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx
+++ b/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx
@@ -16,7 +16,7 @@ type CategoryDefaultsSetterProps = {
 };
 
 /**
- * Renderless component that handles two category-related effects:
+ * Side-effect-only component that handles two category-related effects:
  * 1. Resets cleared categories back to their last saved value
  * 2. Sets the default distance category for distance requests
  */
diff --git a/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx b/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx
index 50a70a28161d..ac9654a7220b 100644
--- a/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx
+++ b/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx
@@ -13,7 +13,7 @@ type DraftWorkspaceOpenerProps = {
 };
 
 /**
- * Renderless component that opens draft workspace requests when needed.
+ * Side-effect-only component that opens draft workspace requests when needed.
  * Handles two cases:
  * 1. When creating a track expense, opens workspace for the policy
  * 2. When a policy expense chat or sender policy is present, opens that workspace
diff --git a/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx
index ff342c436d03..1e3d5557279e 100644
--- a/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx
+++ b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx
@@ -13,7 +13,7 @@ type ExpenseDefaultsSetterProps = {
 };
 
 /**
- * Renderless component that sets default billable and reimbursable values
+ * Side-effect-only component that sets default billable and reimbursable values
  * on transactions based on the policy configuration.
  */
 function ExpenseDefaultsSetter({transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense}: ExpenseDefaultsSetterProps) {
diff --git a/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx b/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx
index 6c9613efae50..d6ce8dc3ecb8 100644
--- a/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx
+++ b/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx
@@ -16,7 +16,7 @@ type MoneyRequestInitializerProps = {
 };
 
 /**
- * Renderless component that initializes a money request (calls startMoneyRequest)
+ * Side-effect-only component that initializes a money request (calls startMoneyRequest)
  * when the transaction loads for the first time and has no participants yet.
  */
 function MoneyRequestInitializer({isLoadingTransaction, transaction, iouType, reportID, draftTransactionIDs}: MoneyRequestInitializerProps) {
diff --git a/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx b/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx
index e68855905528..617ff656ecef 100644
--- a/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx
+++ b/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx
@@ -8,7 +8,7 @@ type TelemetrySpanManagerProps = {
 };
 
 /**
- * Renderless component that manages telemetry spans for the confirmation step.
+ * Side-effect-only component that manages telemetry spans for the confirmation step.
  * On mount: ends the open/mount spans, starts list-ready and receipt-load spans.
  * On unmount: cancels any still-open child spans.
  */

From 641d33cec431f65044a6295124188a303c8251ec Mon Sep 17 00:00:00 2001
From: Test <olimpia.zurek@gmail.com>
Date: Wed, 1 Apr 2026 16:29:19 +0200
Subject: [PATCH 3/8] extract two more components

---
 .../step/IOURequestStepConfirmation.tsx       | 147 +++---------------
 .../confirmation/OdometerReceiptStitcher.tsx  | 100 ++++++++++++
 .../confirmation/ReceiptFileValidator.tsx     | 114 ++++++++++++++
 3 files changed, 239 insertions(+), 122 deletions(-)
 create mode 100644 src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx
 create mode 100644 src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx

diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index 496b4c313b8b..6a08dd1517d3 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -39,8 +39,6 @@ import {getCurrencySymbol} from '@libs/CurrencyUtils';
 import DateUtils from '@libs/DateUtils';
 import {canUseTouchScreen} from '@libs/DeviceCapabilities';
 import DistanceRequestUtils from '@libs/DistanceRequestUtils';
-import {getMimeTypeFromUri, isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils';
-import validateReceiptFile from '@libs/fileDownload/validateReceiptFile';
 import getCurrentPosition from '@libs/getCurrentPosition';
 import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
 import {getGPSCoordinates} from '@libs/GPSDraftDetailsUtils';
@@ -66,7 +64,6 @@ import {
     isReportOutstanding,
     isSelectedManagerMcTest,
 } from '@libs/ReportUtils';
-import stitchOdometerImages from '@libs/stitchOdometerImages';
 import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans';
 import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario';
 import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd';
@@ -101,7 +98,7 @@ import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpe
 import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice';
 import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney';
 import {splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split';
-import {removeDraftTransaction, removeDraftTransactionsByIDs, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit';
+import {removeDraftTransaction, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit';
 import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
 import ROUTES from '@src/ROUTES';
@@ -118,6 +115,8 @@ import CategoryDefaultsSetter from './confirmation/CategoryDefaultsSetter';
 import DraftWorkspaceOpener from './confirmation/DraftWorkspaceOpener';
 import ExpenseDefaultsSetter from './confirmation/ExpenseDefaultsSetter';
 import MoneyRequestInitializer from './confirmation/MoneyRequestInitializer';
+import OdometerReceiptStitcher from './confirmation/OdometerReceiptStitcher';
+import ReceiptFileValidator from './confirmation/ReceiptFileValidator';
 import TelemetrySpanManager from './confirmation/TelemetrySpanManager';
 import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound';
 import withFullTransactionOrNotFound from './withFullTransactionOrNotFound';
@@ -374,63 +373,6 @@ function IOURequestStepConfirmation({
     const odometerStartImage = transaction?.comment?.odometerStartImage;
     const odometerEndImage = transaction?.comment?.odometerEndImage;
 
-    useEffect(() => {
-        if (!isOdometerDistanceRequest) {
-            return;
-        }
-
-        const getImageUri = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? img : (img?.uri ?? ''));
-        const getImageName = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? (img.split('/').pop() ?? '') : (img?.name ?? ''));
-        const getImageType = (img: FileObject | string | null | undefined): string | undefined =>
-            typeof img === 'string' ? getMimeTypeFromUri(img) : (img?.type ?? getMimeTypeFromUri(img?.uri ?? ''));
-
-        if (!odometerStartImage || !odometerEndImage) {
-            const singleImage = odometerStartImage ?? odometerEndImage;
-
-            if (!singleImage) {
-                return;
-            }
-
-            setMoneyRequestReceipt(currentTransactionID, getImageUri(singleImage), getImageName(singleImage), shouldUseTransactionDraft(action, iouType), getImageType(singleImage));
-            return;
-        }
-
-        let ignore = false;
-        setIsStitchingReceipt(true);
-        setStitchError('');
-
-        stitchOdometerImages(odometerStartImage, odometerEndImage)
-            .then((stitchedImage) => {
-                if (ignore || !stitchedImage) {
-                    return;
-                }
-                setMoneyRequestReceipt(
-                    currentTransactionID,
-                    getImageUri(stitchedImage),
-                    getImageName(stitchedImage),
-                    shouldUseTransactionDraft(action, iouType),
-                    getImageType(stitchedImage),
-                );
-            })
-            .catch((error: unknown) => {
-                if (ignore) {
-                    return;
-                }
-                Log.warn('stitchOdometerImages failed', {error});
-                setStitchError(translate('iou.error.stitchOdometerImagesFailed'));
-            })
-            .finally(() => {
-                if (ignore) {
-                    return;
-                }
-                setIsStitchingReceipt(false);
-            });
-
-        return () => {
-            ignore = true;
-        };
-    }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, iouType]);
-
     const navigateBack = useCallback(() => {
         if (backTo) {
             Navigation.goBack(backTo);
@@ -495,67 +437,6 @@ function IOURequestStepConfirmation({
         backToReport,
     ]);
 
-    // When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow.
-    // This is because until the request is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then
-    // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the request process.
-    // skip this in case user is moving the transaction as the receipt path will be valid in that case
-    useEffect(() => {
-        let newReceiptFiles: Record<string, Receipt> = {};
-        let isScanFilesCanBeRead = true;
-
-        Promise.all(
-            transactions.map((item) => {
-                const itemReceiptFilename = item.receipt?.filename;
-                const itemReceiptPath = item.receipt?.source;
-                const itemReceiptType = item.receipt?.type;
-                const isLocalFile = isLocalFileFileUtils(itemReceiptPath);
-
-                if (!isLocalFile) {
-                    if (item.receipt) {
-                        newReceiptFiles = {...newReceiptFiles, [item.transactionID]: item.receipt};
-                    }
-                    return Promise.resolve();
-                }
-
-                const onSuccess = (file: File) => {
-                    const receipt: Receipt = file;
-                    if (item?.receipt?.isTestReceipt) {
-                        receipt.isTestReceipt = true;
-                        receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE;
-                    } else if (item?.receipt?.isTestDriveReceipt) {
-                        receipt.isTestDriveReceipt = true;
-                        receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE;
-                    } else {
-                        receipt.state = file && requestType === CONST.IOU.REQUEST_TYPE.MANUAL ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCAN_READY;
-                    }
-
-                    newReceiptFiles = {...newReceiptFiles, [item.transactionID]: receipt};
-                };
-
-                const onFailure = () => {
-                    isScanFilesCanBeRead = false;
-                    if (initialTransactionID === item.transactionID) {
-                        setMoneyRequestReceipt(item.transactionID, '', '', true, '');
-                    }
-                };
-
-                return validateReceiptFile(itemReceiptFilename, itemReceiptPath, itemReceiptType, onSuccess, onFailure) ?? Promise.resolve();
-            }),
-        ).then(() => {
-            if (isScanFilesCanBeRead) {
-                setReceiptFiles(newReceiptFiles);
-                return;
-            }
-            if (requestType === CONST.IOU.REQUEST_TYPE.MANUAL) {
-                Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, initialTransactionID, reportID, Navigation.getActiveRouteWithoutParams()));
-                return;
-            }
-            removeDraftTransactionsByIDs(draftTransactionIDs, true);
-            navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID);
-        });
-        // eslint-disable-next-line react-hooks/exhaustive-deps -- draftTransactionIDs is intentionally excluded to avoid re-running on draft changes
-    }, [requestType, iouType, initialTransactionID, reportID, action, report, transactions, participants]);
-
     const requestMoney = useCallback(
         (selectedParticipants: Participant[], gpsPoint?: GpsPoint) => {
             if (!transactions.length) {
@@ -1533,6 +1414,28 @@ function IOURequestStepConfirmation({
                 requestType={requestType}
                 isMovingTransactionFromTrackExpense={isMovingTransactionFromTrackExpense}
             />
+            <OdometerReceiptStitcher
+                isOdometerDistanceRequest={isOdometerDistanceRequest}
+                currentTransactionID={currentTransactionID}
+                odometerStartImage={odometerStartImage}
+                odometerEndImage={odometerEndImage}
+                action={action}
+                iouType={iouType}
+                onStitchingChange={setIsStitchingReceipt}
+                onStitchError={setStitchError}
+            />
+            <ReceiptFileValidator
+                transactions={transactions}
+                requestType={requestType}
+                iouType={iouType}
+                initialTransactionID={initialTransactionID}
+                reportID={reportID}
+                action={action}
+                report={report}
+                participants={participants}
+                draftTransactionIDs={draftTransactionIDs}
+                onReceiptFilesChange={setReceiptFiles}
+            />
             <DragAndDropProvider isDisabled={!showReceiptEmptyState || isOdometerDistanceRequest}>
                 <View style={styles.flex1}>
                     <HeaderWithBackButton
diff --git a/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx
new file mode 100644
index 000000000000..10ca977ec1ae
--- /dev/null
+++ b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx
@@ -0,0 +1,100 @@
+import {useEffect} from 'react';
+import useLocalize from '@hooks/useLocalize';
+import {getMimeTypeFromUri} from '@libs/fileDownload/FileUtils';
+import {shouldUseTransactionDraft} from '@libs/IOUUtils';
+import Log from '@libs/Log';
+import stitchOdometerImages from '@libs/stitchOdometerImages';
+import {setMoneyRequestReceipt} from '@userActions/IOU';
+import type {IOUAction, IOUType} from '@src/CONST';
+import type {FileObject} from '@src/types/utils/Attachment';
+
+type OdometerReceiptStitcherProps = {
+    isOdometerDistanceRequest: boolean;
+    currentTransactionID: string;
+    odometerStartImage: FileObject | string | null | undefined;
+    odometerEndImage: FileObject | string | null | undefined;
+    action: IOUAction;
+    iouType: IOUType;
+    onStitchingChange: (isStitching: boolean) => void;
+    onStitchError: (error: string) => void;
+};
+
+/**
+ * Side-effect-only component that stitches two odometer images into a single
+ * receipt, or sets a single odometer image as the receipt when only one exists.
+ */
+function OdometerReceiptStitcher({
+    isOdometerDistanceRequest,
+    currentTransactionID,
+    odometerStartImage,
+    odometerEndImage,
+    action,
+    iouType,
+    onStitchingChange,
+    onStitchError,
+}: OdometerReceiptStitcherProps) {
+    const {translate} = useLocalize();
+
+    useEffect(() => {
+        if (!isOdometerDistanceRequest) {
+            return;
+        }
+
+        const getImageUri = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? img : (img?.uri ?? ''));
+        const getImageName = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? (img.split('/').pop() ?? '') : (img?.name ?? ''));
+        const getImageType = (img: FileObject | string | null | undefined): string | undefined =>
+            typeof img === 'string' ? getMimeTypeFromUri(img) : (img?.type ?? getMimeTypeFromUri(img?.uri ?? ''));
+
+        if (!odometerStartImage || !odometerEndImage) {
+            const singleImage = odometerStartImage ?? odometerEndImage;
+
+            if (!singleImage) {
+                return;
+            }
+
+            setMoneyRequestReceipt(currentTransactionID, getImageUri(singleImage), getImageName(singleImage), shouldUseTransactionDraft(action, iouType), getImageType(singleImage));
+            return;
+        }
+
+        let ignore = false;
+        onStitchingChange(true);
+        onStitchError('');
+
+        stitchOdometerImages(odometerStartImage, odometerEndImage)
+            .then((stitchedImage) => {
+                if (ignore || !stitchedImage) {
+                    return;
+                }
+                setMoneyRequestReceipt(
+                    currentTransactionID,
+                    getImageUri(stitchedImage),
+                    getImageName(stitchedImage),
+                    shouldUseTransactionDraft(action, iouType),
+                    getImageType(stitchedImage),
+                );
+            })
+            .catch((error: unknown) => {
+                if (ignore) {
+                    return;
+                }
+                Log.warn('stitchOdometerImages failed', {error});
+                onStitchError(translate('iou.error.stitchOdometerImagesFailed'));
+            })
+            .finally(() => {
+                if (ignore) {
+                    return;
+                }
+                onStitchingChange(false);
+            });
+
+        return () => {
+            ignore = true;
+        };
+    }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, iouType, onStitchingChange, onStitchError]);
+
+    return null;
+}
+
+OdometerReceiptStitcher.displayName = 'OdometerReceiptStitcher';
+
+export default OdometerReceiptStitcher;
diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
new file mode 100644
index 000000000000..4bd83aee1da2
--- /dev/null
+++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
@@ -0,0 +1,114 @@
+import {useEffect} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils';
+import validateReceiptFile from '@libs/fileDownload/validateReceiptFile';
+import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import {setMoneyRequestReceipt} from '@userActions/IOU';
+import {removeDraftTransactionsByIDs} from '@userActions/TransactionEdit';
+import CONST from '@src/CONST';
+import type {IOUAction, IOURequestType, IOUType} from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import type {Report, Transaction} from '@src/types/onyx';
+import type {Participant} from '@src/types/onyx/IOU';
+import type {Receipt} from '@src/types/onyx/Transaction';
+
+type ReceiptFileValidatorProps = {
+    transactions: Transaction[];
+    requestType: IOURequestType | undefined;
+    iouType: IOUType;
+    initialTransactionID: string;
+    reportID: string;
+    action: IOUAction;
+    report: OnyxEntry<Report>;
+    participants: Participant[];
+    draftTransactionIDs: string[] | undefined;
+    onReceiptFilesChange: (files: Record<string, Receipt>) => void;
+};
+
+/**
+ * Side-effect-only component that validates receipt files on mount.
+ * If blob URLs have expired (e.g. after a browser refresh), navigates
+ * the user back to the start of the request flow to re-capture.
+ */
+function ReceiptFileValidator({
+    transactions,
+    requestType,
+    iouType,
+    initialTransactionID,
+    reportID,
+    action,
+    report,
+    participants,
+    draftTransactionIDs,
+    onReceiptFilesChange,
+}: ReceiptFileValidatorProps) {
+    // When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow.
+    // This is because until the request is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then
+    // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the request process.
+    // skip this in case user is moving the transaction as the receipt path will be valid in that case
+    useEffect(() => {
+        let newReceiptFiles: Record<string, Receipt> = {};
+        let isScanFilesCanBeRead = true;
+
+        Promise.all(
+            transactions.map((item) => {
+                const itemReceiptFilename = item.receipt?.filename;
+                const itemReceiptPath = item.receipt?.source;
+                const itemReceiptType = item.receipt?.type;
+                const isLocalFile = isLocalFileFileUtils(itemReceiptPath);
+
+                if (!isLocalFile) {
+                    if (item.receipt) {
+                        newReceiptFiles = {...newReceiptFiles, [item.transactionID]: item.receipt};
+                    }
+                    return Promise.resolve();
+                }
+
+                const onSuccess = (file: File) => {
+                    const receipt: Receipt = file;
+                    if (item?.receipt?.isTestReceipt) {
+                        receipt.isTestReceipt = true;
+                        receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE;
+                    } else if (item?.receipt?.isTestDriveReceipt) {
+                        receipt.isTestDriveReceipt = true;
+                        receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE;
+                    } else {
+                        receipt.state = file && requestType === CONST.IOU.REQUEST_TYPE.MANUAL ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCAN_READY;
+                    }
+
+                    newReceiptFiles = {...newReceiptFiles, [item.transactionID]: receipt};
+                };
+
+                const onFailure = () => {
+                    isScanFilesCanBeRead = false;
+                    if (initialTransactionID === item.transactionID) {
+                        setMoneyRequestReceipt(item.transactionID, '', '', true, '');
+                    }
+                };
+
+                return validateReceiptFile(itemReceiptFilename, itemReceiptPath, itemReceiptType, onSuccess, onFailure) ?? Promise.resolve();
+            }),
+        ).then(() => {
+            if (isScanFilesCanBeRead) {
+                onReceiptFilesChange(newReceiptFiles);
+                return;
+            }
+            if (requestType === CONST.IOU.REQUEST_TYPE.MANUAL) {
+                Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, initialTransactionID, reportID, Navigation.getActiveRouteWithoutParams()));
+                return;
+            }
+            removeDraftTransactionsByIDs(draftTransactionIDs, true);
+            if (requestType) {
+                navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID);
+            }
+        });
+        // eslint-disable-next-line react-hooks/exhaustive-deps -- draftTransactionIDs is intentionally excluded to avoid re-running on draft changes
+    }, [requestType, iouType, initialTransactionID, reportID, action, report, transactions, participants, onReceiptFilesChange]);
+
+    return null;
+}
+
+ReceiptFileValidator.displayName = 'ReceiptFileValidator';
+
+export default ReceiptFileValidator;

From 32ed6672cdf7c3f1b266169b388efdbf1cc65728 Mon Sep 17 00:00:00 2001
From: Test <olimpia.zurek@gmail.com>
Date: Thu, 2 Apr 2026 10:21:56 +0200
Subject: [PATCH 4/8] add async cleanup

---
 .../request/step/confirmation/ReceiptFileValidator.tsx    | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
index 4bd83aee1da2..7f6904e45e7f 100644
--- a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
+++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
@@ -48,6 +48,7 @@ function ReceiptFileValidator({
     // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the request process.
     // skip this in case user is moving the transaction as the receipt path will be valid in that case
     useEffect(() => {
+        let ignore = false;
         let newReceiptFiles: Record<string, Receipt> = {};
         let isScanFilesCanBeRead = true;
 
@@ -90,6 +91,9 @@ function ReceiptFileValidator({
                 return validateReceiptFile(itemReceiptFilename, itemReceiptPath, itemReceiptType, onSuccess, onFailure) ?? Promise.resolve();
             }),
         ).then(() => {
+            if (ignore) {
+                return;
+            }
             if (isScanFilesCanBeRead) {
                 onReceiptFilesChange(newReceiptFiles);
                 return;
@@ -103,6 +107,10 @@ function ReceiptFileValidator({
                 navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID);
             }
         });
+
+        return () => {
+            ignore = true;
+        };
         // eslint-disable-next-line react-hooks/exhaustive-deps -- draftTransactionIDs is intentionally excluded to avoid re-running on draft changes
     }, [requestType, iouType, initialTransactionID, reportID, action, report, transactions, participants, onReceiptFilesChange]);
 

From 281fe466d429a5ccb56267174b0fb88f8aa9cc88 Mon Sep 17 00:00:00 2001
From: Test <olimpia.zurek@gmail.com>
Date: Thu, 2 Apr 2026 13:35:50 +0200
Subject: [PATCH 5/8] revert submodule changes

---
 Mobile-Expensify | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Mobile-Expensify b/Mobile-Expensify
index 0d1a3c2c172a..1a9ab5eabe26 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit 0d1a3c2c172a110f2c963a5f110be6e6077ebf52
+Subproject commit 1a9ab5eabe26ae841ed0d560c10844fe27488e43

From e2b86a7db731ebd51ef0cf2d2f094f89ee1fe37e Mon Sep 17 00:00:00 2001
From: Test <olimpia.zurek@gmail.com>
Date: Fri, 3 Apr 2026 10:18:03 +0200
Subject: [PATCH 6/8] update comment

---
 .../iou/request/step/confirmation/ReceiptFileValidator.tsx  | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
index 7f6904e45e7f..f8b7491f620e 100644
--- a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
+++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
@@ -27,9 +27,9 @@ type ReceiptFileValidatorProps = {
 };
 
 /**
- * Side-effect-only component that validates receipt files on mount.
- * If blob URLs have expired (e.g. after a browser refresh), navigates
- * the user back to the start of the request flow to re-capture.
+ * Side-effect-only component that validates receipt files when transactions,
+ * participants, or report change. If blob URLs have expired (e.g. after a
+ * browser refresh), navigates the user back to the start of the request flow.
  */
 function ReceiptFileValidator({
     transactions,

From 7a001d62cfbc18e5c78a3a4db09ea2a41b9985b3 Mon Sep 17 00:00:00 2001
From: Test <olimpia.zurek@gmail.com>
Date: Tue, 7 Apr 2026 10:11:06 +0200
Subject: [PATCH 7/8] eslint fix

---
 src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index 45dd3f47d1d5..9d47d9aad53f 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -90,7 +90,6 @@ import {
     setMoneyRequestParticipantsFromReport,
     setMoneyRequestReceipt,
     setMoneyRequestReimbursable,
-    startMoneyRequest,
     updateLastLocationPermissionPrompt,
 } from '@userActions/IOU';
 import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpenseIOUActions} from '@userActions/IOU/PerDiem';
@@ -98,8 +97,7 @@ import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice';
 import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney';
 import {splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split';
 import {requestMoney as requestMoneyIOUActions, trackExpense as trackExpenseIOUActions} from '@userActions/IOU/TrackExpense';
-import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy';
-import {removeDraftTransaction, removeDraftTransactionsByIDs, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit';
+import {removeDraftTransaction, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit';
 import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
 import ROUTES from '@src/ROUTES';

From 142bd069661d4071eea2f2da529c782ce3c3902b Mon Sep 17 00:00:00 2001
From: Test <olimpia.zurek@gmail.com>
Date: Wed, 8 Apr 2026 09:43:55 +0200
Subject: [PATCH 8/8] eslint and ts fix

---
 .../iou/request/step/confirmation/OdometerReceiptStitcher.tsx   | 2 +-
 .../iou/request/step/confirmation/ReceiptFileValidator.tsx      | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx
index cf88f3c6afab..2df5a7234d1c 100644
--- a/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx
+++ b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx
@@ -5,7 +5,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils';
 import Log from '@libs/Log';
 import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils';
 import stitchOdometerImages from '@libs/stitchOdometerImages';
-import {setMoneyRequestReceipt} from '@userActions/IOU';
+import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
 import type {IOUAction, IOUType} from '@src/CONST';
 import type {FileObject} from '@src/types/utils/Attachment';
 
diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
index f8b7491f620e..db77bec6ed63 100644
--- a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
+++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx
@@ -4,7 +4,7 @@ import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'
 import validateReceiptFile from '@libs/fileDownload/validateReceiptFile';
 import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
 import Navigation from '@libs/Navigation/Navigation';
-import {setMoneyRequestReceipt} from '@userActions/IOU';
+import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
 import {removeDraftTransactionsByIDs} from '@userActions/TransactionEdit';
 import CONST from '@src/CONST';
 import type {IOUAction, IOURequestType, IOUType} from '@src/CONST';
