diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 0e870212ee48..c398a9303f97 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -1,4 +1,3 @@ -import {useIsFocused} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -40,8 +39,6 @@ import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {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'; @@ -56,9 +53,7 @@ import Log from '@libs/Log'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; -import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import { findSelfDMReportID, generateReportID, @@ -69,8 +64,7 @@ import { isReportOutstanding, 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,10 +86,8 @@ import { createDistanceRequest as createDistanceRequestIOUActions, getIOURequestPolicyID, setMoneyRequestBillable, - setMoneyRequestCategory, setMoneyRequestParticipantsFromReport, setMoneyRequestReimbursable, - startMoneyRequest, updateLastLocationPermissionPrompt, } from '@userActions/IOU'; import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpenseIOUActions} from '@userActions/IOU/PerDiem'; @@ -104,8 +96,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'; @@ -118,6 +109,13 @@ 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 OdometerReceiptStitcher from './confirmation/OdometerReceiptStitcher'; +import ReceiptFileValidator from './confirmation/ReceiptFileValidator'; +import TelemetrySpanManager from './confirmation/TelemetrySpanManager'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -193,7 +191,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 +252,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(() => { @@ -290,7 +289,6 @@ function IOURequestStepConfirmation({ const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); - const isFocused = useIsFocused(); const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); const transactionDistance = isManualDistanceRequest || isOdometerDistanceRequest || isGPSDistanceRequest ? (transaction?.comment?.customUnit?.quantity ?? undefined) : undefined; const isTimeRequest = requestType === CONST.IOU.REQUEST_TYPE.TIME; @@ -322,11 +320,6 @@ function IOURequestStepConfirmation({ const [isConfirming, setIsConfirming] = useState(false); const [isStitchingReceipt, setIsStitchingReceipt] = useState(false); const [stitchError, setStitchError] = useState(''); - const lastStitchedImages = useRef<{ - startImage: FileObject | string | undefined; - endImage: FileObject | string | undefined; - } | null>(null); - const headerTitle = useMemo(() => { if (isCategorizingTrackExpense) { return translate('iou.categorize'); @@ -356,9 +349,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]); @@ -369,204 +364,12 @@ 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 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]); - - useEffect(() => { - if (!isOdometerDistanceRequest || !isFocused) { - return; - } - - const odometerStartImage = transaction?.comment?.odometerStartImage; - const odometerEndImage = transaction?.comment?.odometerEndImage; - - // Skip stitching when source images haven't changed (compare by URI not reference - // because Onyx may create new object instances when restoring a backup transaction) - const startUri = getOdometerImageUri(odometerStartImage); - const endUri = getOdometerImageUri(odometerEndImage); - if ( - lastStitchedImages.current !== null && - getOdometerImageUri(lastStitchedImages.current.startImage) === startUri && - getOdometerImageUri(lastStitchedImages.current.endImage) === endUri - ) { - return; - } - - if (!odometerStartImage || !odometerEndImage) { - const singleImage = odometerStartImage ?? odometerEndImage; - - if (!singleImage) { - return; - } - - setMoneyRequestReceipt( - currentTransactionID, - getOdometerImageUri(singleImage), - getOdometerImageName(singleImage), - shouldUseTransactionDraft(action, iouType), - getOdometerImageType(singleImage), - ); - lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; - return; - } + const policyExpenseChatPolicyID = participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID; - let ignore = false; - setIsStitchingReceipt(true); - setStitchError(''); + const senderPolicyID = participants?.find((participant) => !!participant && 'isSender' in participant && participant.isSender)?.policyID; - stitchOdometerImages(odometerStartImage, odometerEndImage) - .then((stitchedImage) => { - if (ignore || !stitchedImage) { - return; - } - setMoneyRequestReceipt( - currentTransactionID, - getOdometerImageUri(stitchedImage), - getOdometerImageName(stitchedImage), - shouldUseTransactionDraft(action, iouType), - getOdometerImageType(stitchedImage), - ); - lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; - }) - .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, isFocused, currentTransactionID, transaction?.comment?.odometerStartImage, transaction?.comment?.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 odometerStartImage = transaction?.comment?.odometerStartImage; + const odometerEndImage = transaction?.comment?.odometerEndImage; const navigateBack = useCallback(() => { if (backTo) { @@ -632,67 +435,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 = {}; - 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) { @@ -810,7 +552,7 @@ function IOURequestStepConfirmation({ isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, currentUserEmailParam: currentUserPersonalDetails.email ?? '', - transactionViolations, + transactionViolations: transactionViolationsRef.current, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, existingTransactionDraft, @@ -845,7 +587,6 @@ function IOURequestStepConfirmation({ shouldGenerateTransactionThreadReport, backToReport, isASAPSubmitBetaEnabled, - transactionViolations, policyRecentlyUsedCurrencies, quickAction, isSelfTourViewed, @@ -1107,7 +848,7 @@ function IOURequestStepConfirmation({ }, backToReport, isASAPSubmitBetaEnabled, - transactionViolations, + transactionViolations: transactionViolationsRef.current, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], personalDetails, @@ -1138,7 +879,6 @@ function IOURequestStepConfirmation({ gpsDraftDetails, backToReport, isASAPSubmitBetaEnabled, - transactionViolations, quickAction, policyRecentlyUsedCurrencies, personalDetails, @@ -1286,7 +1026,7 @@ function IOURequestStepConfirmation({ policyRecentlyUsedCategories, policyRecentlyUsedTags, isASAPSubmitBetaEnabled, - transactionViolations, + transactionViolations: transactionViolationsRef.current, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], betas, @@ -1321,7 +1061,7 @@ function IOURequestStepConfirmation({ policyRecentlyUsedCategories, policyRecentlyUsedTags, isASAPSubmitBetaEnabled, - transactionViolations, + transactionViolations: transactionViolationsRef.current, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], betas, @@ -1442,7 +1182,6 @@ function IOURequestStepConfirmation({ policyRecentlyUsedTags, quickAction, isASAPSubmitBetaEnabled, - transactionViolations, existingInvoiceReport, policy, policyTags, @@ -1642,6 +1381,61 @@ function IOURequestStepConfirmation({ shouldAvoidScrollOnVirtualViewport={!isMobileSafari()} testID="IOURequestStepConfirmation" > + + + + + + + ; + policyCategories: OnyxEntry; + policy: OnyxEntry; + isDistanceRequest: boolean; + requestType: string | undefined; + isMovingTransactionFromTrackExpense: boolean; +}; + +/** + * 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 + */ +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..ac9654a7220b --- /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; +}; + +/** + * 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 + */ +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..1e3d5557279e --- /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; + isPolicyExpenseChat: boolean | undefined; + isMovingTransactionFromTrackExpense: boolean; + isCreatingTrackExpense: boolean; +}; + +/** + * 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) { + 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..d6ce8dc3ecb8 --- /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; + iouType: IOUType; + reportID: string; + draftTransactionIDs: string[] | undefined; +}; + +/** + * 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) { + 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/OdometerReceiptStitcher.tsx b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx new file mode 100644 index 000000000000..2df5a7234d1c --- /dev/null +++ b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx @@ -0,0 +1,123 @@ +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useRef} from 'react'; +import useLocalize from '@hooks/useLocalize'; +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/Receipt'; +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. + * Skips stitching when source images haven't changed (compares by URI, not reference, + * because Onyx may create new object instances when restoring a backup transaction). + */ +function OdometerReceiptStitcher({ + isOdometerDistanceRequest, + currentTransactionID, + odometerStartImage, + odometerEndImage, + action, + iouType, + onStitchingChange, + onStitchError, +}: OdometerReceiptStitcherProps) { + const {translate} = useLocalize(); + const isFocused = useIsFocused(); + const lastStitchedImages = useRef<{ + startImage: FileObject | string | null | undefined; + endImage: FileObject | string | null | undefined; + } | null>(null); + + useEffect(() => { + if (!isOdometerDistanceRequest || !isFocused) { + return; + } + + // Skip stitching when source images haven't changed (compare by URI not reference + // because Onyx may create new object instances when restoring a backup transaction) + const startUri = getOdometerImageUri(odometerStartImage); + const endUri = getOdometerImageUri(odometerEndImage); + if ( + lastStitchedImages.current !== null && + getOdometerImageUri(lastStitchedImages.current.startImage) === startUri && + getOdometerImageUri(lastStitchedImages.current.endImage) === endUri + ) { + return; + } + + if (!odometerStartImage || !odometerEndImage) { + const singleImage = odometerStartImage ?? odometerEndImage; + + if (!singleImage) { + return; + } + + setMoneyRequestReceipt( + currentTransactionID, + getOdometerImageUri(singleImage), + getOdometerImageName(singleImage), + shouldUseTransactionDraft(action, iouType), + getOdometerImageType(singleImage), + ); + lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; + return; + } + + let ignore = false; + onStitchingChange(true); + onStitchError(''); + + stitchOdometerImages(odometerStartImage, odometerEndImage) + .then((stitchedImage) => { + if (ignore || !stitchedImage) { + return; + } + setMoneyRequestReceipt( + currentTransactionID, + getOdometerImageUri(stitchedImage), + getOdometerImageName(stitchedImage), + shouldUseTransactionDraft(action, iouType), + getOdometerImageType(stitchedImage), + ); + lastStitchedImages.current = {startImage: odometerStartImage, endImage: odometerEndImage}; + }) + .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, isFocused, 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..db77bec6ed63 --- /dev/null +++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx @@ -0,0 +1,122 @@ +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/Receipt'; +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; + participants: Participant[]; + draftTransactionIDs: string[] | undefined; + onReceiptFilesChange: (files: Record) => void; +}; + +/** + * 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, + 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 ignore = false; + let newReceiptFiles: Record = {}; + 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 (ignore) { + return; + } + 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); + } + }); + + 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]); + + return null; +} + +ReceiptFileValidator.displayName = 'ReceiptFileValidator'; + +export default ReceiptFileValidator; 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..617ff656ecef --- /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; +}; + +/** + * 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. + */ +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;