From 4180756c0abafdb4b22052cb6b8f96cc34f698f7 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 2 Apr 2026 13:25:41 +0200 Subject: [PATCH 1/4] extract side-effect components from MoneyRequestConfirmationList --- .../MoneyRequestConfirmationList.tsx | 357 +++++------------- .../ConfirmationTelemetry.tsx | 29 ++ .../DistanceRequestController.tsx | 216 +++++++++++ .../FieldAutoSelector.tsx | 79 ++++ .../SplitBillController.tsx | 70 ++++ .../TaxController.tsx | 63 ++++ 6 files changed, 541 insertions(+), 273 deletions(-) create mode 100644 src/components/MoneyRequestConfirmationList/ConfirmationTelemetry.tsx create mode 100644 src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx create mode 100644 src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx create mode 100644 src/components/MoneyRequestConfirmationList/SplitBillController.tsx create mode 100644 src/components/MoneyRequestConfirmationList/TaxController.tsx diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 6edffb9e74678..6034558910bae 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -16,23 +16,13 @@ import usePreferredPolicy from '@hooks/usePreferredPolicy'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; -import { - setCustomUnitRateID, - setMoneyRequestAmount, - setMoneyRequestCategory, - setMoneyRequestMerchant, - setMoneyRequestPendingFields, - setMoneyRequestTag, - setMoneyRequestTaxAmount, - setMoneyRequestTaxRateValues, -} from '@libs/actions/IOU'; import {computePerDiemExpenseAmount, isValidPerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem'; -import {adjustRemainingSplitShares, resetSplitShares, setIndividualShare, setSplitShares} from '@libs/actions/IOU/Split'; +import {resetSplitShares, setIndividualShare} from '@libs/actions/IOU/Split'; import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {isCategoryDescriptionRequired} from '@libs/CategoryUtils'; import {convertToBackendAmount, convertToDisplayString, convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {calculateAmount, insertTagIntoTransactionTagsString, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils'; +import {calculateAmount, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils'; import Log from '@libs/Log'; import {validateAmount} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -41,7 +31,6 @@ import {getTagLists, isTaxTrackingEnabled} from '@libs/PolicyUtils'; import {isSelectedManagerMcTest} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; import {hasEnabledTags, hasMatchingTag} from '@libs/TagsOptionsListUtils'; -import {endSpan} from '@libs/telemetry/activeSpans'; import {isValidTimeExpenseAmount} from '@libs/TimeTrackingUtils'; import { areRequiredFieldsEmpty, @@ -68,13 +57,17 @@ import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; -import type {SplitShares} from '@src/types/onyx/Transaction'; import Button from './Button'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import type {DropdownOption} from './ButtonWithDropdownMenu/types'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from './DelegateNoAccessModalProvider'; import FormHelpMessage from './FormHelpMessage'; import MoneyRequestAmountInput from './MoneyRequestAmountInput'; +import ConfirmationTelemetry from './MoneyRequestConfirmationList/ConfirmationTelemetry'; +import DistanceRequestController from './MoneyRequestConfirmationList/DistanceRequestController'; +import FieldAutoSelector from './MoneyRequestConfirmationList/FieldAutoSelector'; +import SplitBillController from './MoneyRequestConfirmationList/SplitBillController'; +import TaxController from './MoneyRequestConfirmationList/TaxController'; import MoneyRequestConfirmationListFooter from './MoneyRequestConfirmationListFooter'; import {PressableWithFeedback} from './Pressable'; import {useProductTrainingContext} from './ProductTrainingContext'; @@ -334,19 +327,10 @@ function MoneyRequestConfirmationList({ const defaultMileageRate = defaultMileageRateDraft ?? defaultMileageRateReal; const styles = useThemeStyles(); - const {translate, toLocaleDigit} = useLocalize(); + const {translate} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); - const hasEndedListReadySpan = useRef(false); - useEffect(() => { - if (hasEndedListReadySpan.current || !transaction?.transactionID) { - return; - } - hasEndedListReadySpan.current = true; - endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY); - }, [transaction?.transactionID]); - const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; const isTypeSend = iouType === CONST.IOU.TYPE.PAY; @@ -391,24 +375,6 @@ function MoneyRequestConfirmationList({ const previousDefaultTaxCode = getDefaultTaxCode(policy, transaction, previousTransactionCurrency); const shouldKeepCurrentTaxSelection = hasTaxRateWithMatchingValue(policy, transaction) && transaction?.taxCode !== previousDefaultTaxCode; - useEffect(() => { - if (!transactionID || isReadOnly || !shouldShowTax || isMovingTransactionFromTrackExpense) { - return; - } - - // Keep the user's current selection when it's still valid for the active policy. - if (shouldKeepCurrentTaxSelection) { - return; - } - - setMoneyRequestTaxRateValues(transactionID, { - taxCode: defaultTaxCode, - taxValue: defaultTaxValue, - taxAmount: transaction?.taxAmount ?? null, - }); - // trigger this useEffect also when policyID changes - the defaultTaxCode may stay the same - }, [defaultTaxCode, defaultTaxValue, isMovingTransactionFromTrackExpense, isReadOnly, transactionID, policyID, shouldShowTax, shouldKeepCurrentTaxSelection, transaction?.taxAmount]); - const distance = getDistanceInMeters(transaction, unit); const prevDistance = usePrevious(distance); const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate || prevDistance !== distance || prevCurrency !== currency || prevUnit !== unit); @@ -519,81 +485,7 @@ function MoneyRequestConfirmationList({ // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isViolationFixed]); - const prevPolicy = usePrevious(policy); - - useEffect(() => { - // We want this effect to run when the transaction is moving from Self DM to an expense chat, or when the policy changes - const isPolicyChanged = prevPolicy?.id !== policy?.id; - if (!transactionID || !isDistanceRequest || !isPolicyExpenseChat || (!isMovingTransactionFromTrackExpense && !isPolicyChanged)) { - return; - } - - const errorKey = 'iou.error.invalidRate'; - const policyRates = DistanceRequestUtils.getMileageRates(policy); - - // If the selected rate belongs to the policy, clear the error - if (customUnitRateID && customUnitRateID in policyRates) { - clearFormErrors([errorKey]); - return; - } - - // If there is a distance rate in the policy that matches the rate and unit of the currently selected mileage rate, select it automatically - const matchingRate = Object.values(policyRates).find((policyRate) => policyRate.rate === mileageRate.rate && policyRate.unit === mileageRate.unit); - if (matchingRate?.customUnitRateID) { - setCustomUnitRateID(transactionID, matchingRate.customUnitRateID, transaction, policy); - clearFormErrors([errorKey]); - return; - } - - // If none of the above conditions are met, display the rate error - setFormError(errorKey); - }, [ - isDistanceRequest, - isPolicyExpenseChat, - transactionID, - mileageRate.rate, - mileageRate.unit, - customUnitRateID, - policy, - isMovingTransactionFromTrackExpense, - setFormError, - clearFormErrors, - transaction, - prevPolicy?.id, - ]); - const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); - const isFirstUpdatedDistanceAmount = useRef(false); - - useEffect(() => { - if (isFirstUpdatedDistanceAmount.current) { - return; - } - if (!isDistanceRequest || !transactionID) { - return; - } - if (isReadOnly) { - return; - } - const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); - setMoneyRequestAmount(transactionID, amount, currency ?? ''); - isFirstUpdatedDistanceAmount.current = true; - }, [distance, rate, isReadOnly, unit, transactionID, currency, isDistanceRequest]); - - useEffect(() => { - if (!shouldCalculateDistanceAmount || !transactionID || isReadOnly) { - return; - } - - const amount = distanceRequestAmount; - setMoneyRequestAmount(transactionID, amount, currency ?? ''); - - // If it's a split request among individuals, set the split shares - const participantAccountIDs: number[] = selectedParticipantsProp.map((participant) => participant.accountID ?? CONST.DEFAULT_NUMBER_ID); - if (isTypeSplit && !isPolicyExpenseChat && amount && transaction?.currency) { - setSplitShares(transaction, amount, currency, participantAccountIDs); - } - }, [shouldCalculateDistanceAmount, isReadOnly, distanceRequestAmount, transactionID, currency, isTypeSplit, isPolicyExpenseChat, selectedParticipantsProp, transaction]); // Calculate and set tax amount in transaction draft const taxableAmount = isDistanceRequest ? DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, distance) : Math.abs(transaction?.amount ?? 0); @@ -605,13 +497,6 @@ function MoneyRequestConfirmationList({ const taxAmount = isMovingTransactionFromTrackExpense && transaction?.taxAmount ? Math.abs(transaction?.taxAmount ?? 0) : calculateTaxAmount(taxPercentage, taxableAmount, taxDecimals); const taxAmountInSmallestCurrencyUnits = convertToBackendAmount(Number.parseFloat(taxAmount.toString())); - useEffect(() => { - if (!transactionID || isReadOnly || !shouldShowTax || isMovingTransactionFromTrackExpense) { - return; - } - setMoneyRequestTaxAmount(transactionID, taxAmountInSmallestCurrencyUnits); - }, [transactionID, taxAmountInSmallestCurrencyUnits, isReadOnly, shouldShowTax, isMovingTransactionFromTrackExpense]); - // If completing a split expense fails, set didConfirm to false to allow the user to edit the fields again if (isEditingSplitBill && didConfirm) { setDidConfirm(false); @@ -686,65 +571,10 @@ function MoneyRequestConfirmationList({ [transaction?.transactionID], ); - useEffect(() => { - if (!isTypeSplit || !transaction?.splitShares || !isFocused) { - return; - } - - const splitSharesMap: SplitShares = transaction.splitShares; - const shares: number[] = Object.values(splitSharesMap).map((splitShare) => splitShare?.amount ?? 0); - const sumOfShares = shares?.reduce((prev, current): number => prev + current, 0); - if (sumOfShares !== iouAmount) { - setFormError('iou.error.invalidSplit'); - return; - } - - const participantsWithAmount = Object.keys(transaction?.splitShares ?? {}) - .filter((accountID: string): boolean => (transaction?.splitShares?.[Number(accountID)]?.amount ?? 0) > 0) - .map((accountID) => Number(accountID)); - - // A split must have at least two participants with amounts bigger than 0 - if (participantsWithAmount.length === 1) { - setFormError('iou.error.invalidSplitParticipants'); - return; - } - - // Amounts should be bigger than 0 for the split bill creator (yourself) - if (transaction?.splitShares[currentUserPersonalDetails.accountID] && (transaction.splitShares[currentUserPersonalDetails.accountID]?.amount ?? 0) === 0) { - setFormError('iou.error.invalidSplitYourself'); - return; - } - - setFormError(''); - }, [isFocused, transaction, isTypeSplit, transaction?.splitShares, currentUserPersonalDetails.accountID, iouAmount, iouCurrencyCode, setFormError, translate]); - - useEffect(() => { - if (!isTypeSplit || !transaction?.splitShares) { - return; - } - adjustRemainingSplitShares(transaction); - }, [isTypeSplit, transaction]); - const selectedParticipants = useMemo(() => selectedParticipantsProp.filter((participant) => participant.selected), [selectedParticipantsProp]); const payeePersonalDetails = useMemo(() => payeePersonalDetailsProp ?? currentUserPersonalDetails, [payeePersonalDetailsProp, currentUserPersonalDetails]); const shouldShowReadOnlySplits = useMemo(() => isPolicyExpenseChat || isReadOnly || isScanRequest, [isPolicyExpenseChat, isReadOnly, isScanRequest]); - useEffect(() => { - if ( - !['-1', CONST.CUSTOM_UNITS.FAKE_P2P_ID].includes(customUnitRateID) || - !isDistanceRequest || - !isPolicyExpenseChat || - !transactionID || - !lastSelectedRate || - (isMovingTransactionFromTrackExpense && customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID) || - !selectedParticipants.some((participant) => participant.policyID === policy?.id) - ) { - return; - } - - setCustomUnitRateID(transactionID, lastSelectedRate, transaction, policy); - }, [customUnitRateID, transactionID, lastSelectedRate, isDistanceRequest, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, transaction, policy, selectedParticipants]); - const splitParticipants = useMemo(() => { if (!isTypeSplit) { return []; @@ -921,88 +751,6 @@ function MoneyRequestConfirmationList({ shouldHideToSection, ]); - useEffect(() => { - if (!isDistanceRequest || (isMovingTransactionFromTrackExpense && !isPolicyExpenseChat) || !transactionID || isReadOnly) { - // We don't want to recalculate the distance merchant when moving a transaction from Track Expense to a 1:1 chat, because the distance rate will be the same default P2P rate. - // When moving to a policy chat (e.g. sharing with an accountant), we should recalculate the distance merchant with the policy's rate. - return; - } - - /* - Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as: - When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page. - In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending. - */ - setMoneyRequestPendingFields(transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); - - const distanceMerchant = DistanceRequestUtils.getDistanceMerchant( - hasRoute, - distance, - unit, - rate ?? 0, - currency ?? CONST.CURRENCY.USD, - translate, - toLocaleDigit, - getCurrencySymbol, - isManualDistanceRequest, - ); - setMoneyRequestMerchant(transactionID, distanceMerchant, true); - }, [ - isDistanceRequestWithPendingRoute, - hasRoute, - distance, - unit, - rate, - currency, - translate, - toLocaleDigit, - isDistanceRequest, - isPolicyExpenseChat, - transaction, - transactionID, - action, - isReadOnly, - isMovingTransactionFromTrackExpense, - getCurrencySymbol, - isManualDistanceRequest, - ]); - - // Auto select the category if there is only one enabled category and it is required - useEffect(() => { - const enabledCategories = Object.values(policyCategories ?? {}).filter((category) => category.enabled); - if (!transactionID || iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) { - return; - } - setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? '', policy, isMovingTransactionFromTrackExpense); - // Keep 'transaction' out to ensure that we auto select the option only once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldShowCategories, policyCategories, isCategoryRequired, policy?.id]); - - // Auto select the tag if there is only one enabled tag and it is required - useEffect(() => { - if (!transactionID) { - return; - } - - let updatedTagsString = getTag(transaction); - for (const [index, tagList] of policyTagLists.entries()) { - const isTagListRequired = tagList.required ?? false; - if (!isTagListRequired) { - continue; - } - const enabledTags = Object.values(tagList.tags).filter((tag) => tag.enabled); - if (enabledTags.length !== 1 || getTag(transaction, index)) { - continue; - } - updatedTagsString = insertTagIntoTransactionTagsString(updatedTagsString, enabledTags.at(0)?.name ?? '', index, policy?.hasMultipleTagLists ?? false); - } - if (updatedTagsString !== getTag(transaction) && updatedTagsString) { - setMoneyRequestTag(transactionID, updatedTagsString); - } - // Keep 'transaction' out to ensure that we auto select the option only once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [transactionID, policyTagLists, policyTags]); - /** * Navigate to the participant step */ @@ -1181,6 +929,7 @@ function MoneyRequestConfirmationList({ currentUserPersonalDetails, isTimeRequest, getCurrencyDecimals, + isNewManualExpenseFlowEnabled, ], ); @@ -1395,20 +1144,82 @@ function MoneyRequestConfirmationList({ ); return ( - - - sections={sections} - ListItem={UserListItem} - onSelectRow={navigateToParticipantPage} - shouldSingleExecuteRowSelect - shouldPreventDefaultFocusOnSelectRow - shouldShowListEmptyContent={false} - footerContent={footerContent} - listFooterContent={listFooterContent} - style={selectionListStyle} - disableKeyboardShortcuts + <> + + + + + - + + + sections={sections} + ListItem={UserListItem} + onSelectRow={navigateToParticipantPage} + shouldSingleExecuteRowSelect + shouldPreventDefaultFocusOnSelectRow + shouldShowListEmptyContent={false} + footerContent={footerContent} + listFooterContent={listFooterContent} + style={selectionListStyle} + disableKeyboardShortcuts + /> + + ); } diff --git a/src/components/MoneyRequestConfirmationList/ConfirmationTelemetry.tsx b/src/components/MoneyRequestConfirmationList/ConfirmationTelemetry.tsx new file mode 100644 index 0000000000000..1c699c879d7c9 --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/ConfirmationTelemetry.tsx @@ -0,0 +1,29 @@ +import {useEffect, useRef} from 'react'; +import {endSpan} from '@libs/telemetry/activeSpans'; +import CONST from '@src/CONST'; + +type ConfirmationTelemetryProps = { + transactionID: string | undefined; +}; + +/** + * Side-effect-only component that ends the confirmation list ready + * telemetry span once the transaction ID becomes available. + */ +function ConfirmationTelemetry({transactionID}: ConfirmationTelemetryProps) { + const hasEndedListReadySpan = useRef(false); + + useEffect(() => { + if (hasEndedListReadySpan.current || !transactionID) { + return; + } + hasEndedListReadySpan.current = true; + endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY); + }, [transactionID]); + + return null; +} + +ConfirmationTelemetry.displayName = 'ConfirmationTelemetry'; + +export default ConfirmationTelemetry; diff --git a/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx new file mode 100644 index 0000000000000..69961080aad56 --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx @@ -0,0 +1,216 @@ +import {useEffect, useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useLocalize from '@hooks/useLocalize'; +import usePrevious from '@hooks/usePrevious'; +import {setCustomUnitRateID, setMoneyRequestAmount, setMoneyRequestMerchant, setMoneyRequestPendingFields} from '@libs/actions/IOU'; +import {setSplitShares} from '@libs/actions/IOU/Split'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import type {MileageRate} from '@libs/DistanceRequestUtils'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type {Policy, Transaction} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; +import type {Unit} from '@src/types/onyx/Policy'; + +type DistanceRequestControllerProps = { + transactionID: string | undefined; + transaction: OnyxEntry; + policy: OnyxEntry; + isDistanceRequest: boolean; + isManualDistanceRequest: boolean; + isPolicyExpenseChat: boolean; + isMovingTransactionFromTrackExpense: boolean; + isReadOnly: boolean; + isTypeSplit: boolean; + customUnitRateID: string; + mileageRate: MileageRate; + rate: number | undefined; + unit: Unit | undefined; + currency: string; + distance: number; + distanceRequestAmount: number; + shouldCalculateDistanceAmount: boolean; + isDistanceRequestWithPendingRoute: boolean; + hasRoute: boolean; + lastSelectedRate: string | undefined; + selectedParticipants: Participant[]; + selectedParticipantsProp: Participant[]; + setFormError: (error: TranslationPaths | '') => void; + clearFormErrors: (errors: string[]) => void; +}; + +/** + * Side-effect-only component that manages distance request effects: + * validates distance rates on policy change, calculates distance amounts, + * auto-selects the last saved distance rate, and updates the merchant. + */ +function DistanceRequestController({ + transactionID, + transaction, + policy, + isDistanceRequest, + isManualDistanceRequest, + isPolicyExpenseChat, + isMovingTransactionFromTrackExpense, + isReadOnly, + isTypeSplit, + customUnitRateID, + mileageRate, + rate, + unit, + currency, + distance, + distanceRequestAmount, + shouldCalculateDistanceAmount, + isDistanceRequestWithPendingRoute, + hasRoute, + lastSelectedRate, + selectedParticipants, + selectedParticipantsProp, + setFormError, + clearFormErrors, +}: DistanceRequestControllerProps) { + const {translate, toLocaleDigit} = useLocalize(); + const {getCurrencySymbol} = useCurrencyListActions(); + const prevPolicy = usePrevious(policy); + const isFirstUpdatedDistanceAmount = useRef(false); + + useEffect(() => { + // We want this effect to run when the transaction is moving from Self DM to an expense chat, or when the policy changes + const isPolicyChanged = prevPolicy?.id !== policy?.id; + if (!transactionID || !isDistanceRequest || !isPolicyExpenseChat || (!isMovingTransactionFromTrackExpense && !isPolicyChanged)) { + return; + } + + const errorKey = 'iou.error.invalidRate'; + const policyRates = DistanceRequestUtils.getMileageRates(policy); + + // If the selected rate belongs to the policy, clear the error + if (customUnitRateID && customUnitRateID in policyRates) { + clearFormErrors([errorKey]); + return; + } + + // If there is a distance rate in the policy that matches the rate and unit of the currently selected mileage rate, select it automatically + const matchingRate = Object.values(policyRates).find((policyRate) => policyRate.rate === mileageRate.rate && policyRate.unit === mileageRate.unit); + if (matchingRate?.customUnitRateID) { + setCustomUnitRateID(transactionID, matchingRate.customUnitRateID, transaction, policy); + clearFormErrors([errorKey]); + return; + } + + // If none of the above conditions are met, display the rate error + setFormError(errorKey); + }, [ + isDistanceRequest, + isPolicyExpenseChat, + transactionID, + mileageRate.rate, + mileageRate.unit, + customUnitRateID, + policy, + isMovingTransactionFromTrackExpense, + setFormError, + clearFormErrors, + transaction, + prevPolicy?.id, + ]); + + useEffect(() => { + if (isFirstUpdatedDistanceAmount.current) { + return; + } + if (!isDistanceRequest || !transactionID) { + return; + } + if (isReadOnly) { + return; + } + const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); + setMoneyRequestAmount(transactionID, amount, currency ?? ''); + isFirstUpdatedDistanceAmount.current = true; + }, [distance, rate, isReadOnly, unit, transactionID, currency, isDistanceRequest]); + + useEffect(() => { + if (!shouldCalculateDistanceAmount || !transactionID || isReadOnly) { + return; + } + + const amount = distanceRequestAmount; + setMoneyRequestAmount(transactionID, amount, currency ?? ''); + + // If it's a split request among individuals, set the split shares + const participantAccountIDs: number[] = selectedParticipantsProp.map((participant) => participant.accountID ?? CONST.DEFAULT_NUMBER_ID); + if (isTypeSplit && !isPolicyExpenseChat && amount && transaction?.currency) { + setSplitShares(transaction, amount, currency, participantAccountIDs); + } + }, [shouldCalculateDistanceAmount, isReadOnly, distanceRequestAmount, transactionID, currency, isTypeSplit, isPolicyExpenseChat, selectedParticipantsProp, transaction]); + + useEffect(() => { + if ( + !['-1', CONST.CUSTOM_UNITS.FAKE_P2P_ID].includes(customUnitRateID) || + !isDistanceRequest || + !isPolicyExpenseChat || + !transactionID || + !lastSelectedRate || + (isMovingTransactionFromTrackExpense && customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID) || + !selectedParticipants.some((participant) => participant.policyID === policy?.id) + ) { + return; + } + + setCustomUnitRateID(transactionID, lastSelectedRate, transaction, policy); + }, [customUnitRateID, transactionID, lastSelectedRate, isDistanceRequest, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, transaction, policy, selectedParticipants]); + + useEffect(() => { + if (!isDistanceRequest || (isMovingTransactionFromTrackExpense && !isPolicyExpenseChat) || !transactionID || isReadOnly) { + // We don't want to recalculate the distance merchant when moving a transaction from Track Expense to a 1:1 chat, because the distance rate will be the same default P2P rate. + // When moving to a policy chat (e.g. sharing with an accountant), we should recalculate the distance merchant with the policy's rate. + return; + } + + /* + Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as: + When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page. + In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending. + */ + setMoneyRequestPendingFields(transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); + + const distanceMerchant = DistanceRequestUtils.getDistanceMerchant( + hasRoute, + distance, + unit, + rate ?? 0, + currency ?? CONST.CURRENCY.USD, + translate, + toLocaleDigit, + getCurrencySymbol, + isManualDistanceRequest, + ); + setMoneyRequestMerchant(transactionID, distanceMerchant, true); + }, [ + isDistanceRequestWithPendingRoute, + hasRoute, + distance, + unit, + rate, + currency, + translate, + toLocaleDigit, + isDistanceRequest, + isPolicyExpenseChat, + transaction, + transactionID, + isReadOnly, + isMovingTransactionFromTrackExpense, + getCurrencySymbol, + isManualDistanceRequest, + ]); + + return null; +} + +DistanceRequestController.displayName = 'DistanceRequestController'; + +export default DistanceRequestController; diff --git a/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx b/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx new file mode 100644 index 0000000000000..04253bc0063c9 --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/FieldAutoSelector.tsx @@ -0,0 +1,79 @@ +import {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import {setMoneyRequestCategory, setMoneyRequestTag} from '@libs/actions/IOU'; +import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils'; +import {getTag} from '@libs/TransactionUtils'; +import type {Policy, PolicyCategories, PolicyTagLists, Transaction} from '@src/types/onyx'; + +type FieldAutoSelectorProps = { + transactionID: string | undefined; + transaction: OnyxEntry; + policyCategories: OnyxEntry; + policyTagLists: Array>; + policyTags: OnyxEntry; + policy: OnyxEntry; + shouldShowCategories: boolean; + isCategoryRequired: boolean; + iouCategory: string | undefined; + isMovingTransactionFromTrackExpense: boolean; +}; + +/** + * Side-effect-only component that auto-selects the only enabled category + * and required single tags when the confirmation list mounts. + */ +function FieldAutoSelector({ + transactionID, + transaction, + policyCategories, + policyTagLists, + policyTags, + policy, + shouldShowCategories, + isCategoryRequired, + iouCategory, + isMovingTransactionFromTrackExpense, +}: FieldAutoSelectorProps) { + // Auto select the category if there is only one enabled category and it is required + useEffect(() => { + const enabledCategories = Object.values(policyCategories ?? {}).filter((category) => category.enabled); + if (!transactionID || iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) { + return; + } + setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? '', policy, isMovingTransactionFromTrackExpense); + // Keep 'transaction' out to ensure that we auto select the option only once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldShowCategories, policyCategories, isCategoryRequired, policy?.id]); + + // Auto select the tag if there is only one enabled tag and it is required + useEffect(() => { + if (!transactionID) { + return; + } + + let updatedTagsString = getTag(transaction); + for (const [index, tagList] of policyTagLists.entries()) { + const isTagListRequired = tagList.required ?? false; + if (!isTagListRequired) { + continue; + } + const enabledTags = Object.values(tagList.tags).filter((tag) => tag.enabled); + if (enabledTags.length !== 1 || getTag(transaction, index)) { + continue; + } + updatedTagsString = insertTagIntoTransactionTagsString(updatedTagsString, enabledTags.at(0)?.name ?? '', index, policy?.hasMultipleTagLists ?? false); + } + if (updatedTagsString !== getTag(transaction) && updatedTagsString) { + setMoneyRequestTag(transactionID, updatedTagsString); + } + // Keep 'transaction' out to ensure that we auto select the option only once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transactionID, policyTagLists, policyTags]); + + return null; +} + +FieldAutoSelector.displayName = 'FieldAutoSelector'; + +export default FieldAutoSelector; diff --git a/src/components/MoneyRequestConfirmationList/SplitBillController.tsx b/src/components/MoneyRequestConfirmationList/SplitBillController.tsx new file mode 100644 index 0000000000000..22911f691f99a --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/SplitBillController.tsx @@ -0,0 +1,70 @@ +import {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import {adjustRemainingSplitShares} from '@libs/actions/IOU/Split'; +import type {TranslationPaths} from '@src/languages/types'; +import type {Transaction} from '@src/types/onyx'; +import type {SplitShares} from '@src/types/onyx/Transaction'; + +type SplitBillControllerProps = { + transaction: OnyxEntry; + isTypeSplit: boolean; + iouAmount: number; + iouCurrencyCode: string | undefined; + currentUserAccountID: number; + isFocused: boolean; + onFormError: (error: TranslationPaths | '') => void; +}; + +/** + * Side-effect-only component that validates split share amounts + * and adjusts remaining split shares when the transaction changes. + */ +function SplitBillController({transaction, isTypeSplit, iouAmount, iouCurrencyCode, currentUserAccountID, isFocused, onFormError}: SplitBillControllerProps) { + const {translate} = useLocalize(); + + useEffect(() => { + if (!isTypeSplit || !transaction?.splitShares || !isFocused) { + return; + } + + const splitSharesMap: SplitShares = transaction.splitShares; + const shares: number[] = Object.values(splitSharesMap).map((splitShare) => splitShare?.amount ?? 0); + const sumOfShares = shares?.reduce((prev, current): number => prev + current, 0); + if (sumOfShares !== iouAmount) { + onFormError('iou.error.invalidSplit'); + return; + } + + const participantsWithAmount = Object.keys(transaction?.splitShares ?? {}) + .filter((accountID: string): boolean => (transaction?.splitShares?.[Number(accountID)]?.amount ?? 0) > 0) + .map((accountID) => Number(accountID)); + + // A split must have at least two participants with amounts bigger than 0 + if (participantsWithAmount.length === 1) { + onFormError('iou.error.invalidSplitParticipants'); + return; + } + + // Amounts should be bigger than 0 for the split bill creator (yourself) + if (transaction?.splitShares[currentUserAccountID] && (transaction.splitShares[currentUserAccountID]?.amount ?? 0) === 0) { + onFormError('iou.error.invalidSplitYourself'); + return; + } + + onFormError(''); + }, [isFocused, transaction, isTypeSplit, transaction?.splitShares, currentUserAccountID, iouAmount, iouCurrencyCode, onFormError, translate]); + + useEffect(() => { + if (!isTypeSplit || !transaction?.splitShares) { + return; + } + adjustRemainingSplitShares(transaction); + }, [isTypeSplit, transaction]); + + return null; +} + +SplitBillController.displayName = 'SplitBillController'; + +export default SplitBillController; diff --git a/src/components/MoneyRequestConfirmationList/TaxController.tsx b/src/components/MoneyRequestConfirmationList/TaxController.tsx new file mode 100644 index 0000000000000..c2b430af9290f --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/TaxController.tsx @@ -0,0 +1,63 @@ +import {useEffect} from 'react'; +import {setMoneyRequestTaxAmount, setMoneyRequestTaxRateValues} from '@libs/actions/IOU'; + +type TaxControllerProps = { + transactionID: string | undefined; + policyID: string | undefined; + isReadOnly: boolean; + shouldShowTax: boolean; + isMovingTransactionFromTrackExpense: boolean; + defaultTaxCode: string; + defaultTaxValue: string | null; + shouldKeepCurrentTaxSelection: boolean; + taxAmountInSmallestCurrencyUnits: number; + transactionTaxAmount: number | undefined; +}; + +/** + * Side-effect-only component that syncs tax rate defaults + * and tax amount when the transaction or policy changes. + */ +function TaxController({ + transactionID, + policyID, + isReadOnly, + shouldShowTax, + isMovingTransactionFromTrackExpense, + defaultTaxCode, + defaultTaxValue, + shouldKeepCurrentTaxSelection, + taxAmountInSmallestCurrencyUnits, + transactionTaxAmount, +}: TaxControllerProps) { + useEffect(() => { + if (!transactionID || isReadOnly || !shouldShowTax || isMovingTransactionFromTrackExpense) { + return; + } + + // Keep the user's current selection when it's still valid for the active policy. + if (shouldKeepCurrentTaxSelection) { + return; + } + + setMoneyRequestTaxRateValues(transactionID, { + taxCode: defaultTaxCode, + taxValue: defaultTaxValue, + taxAmount: transactionTaxAmount ?? null, + }); + // trigger this useEffect also when policyID changes - the defaultTaxCode may stay the same + }, [defaultTaxCode, defaultTaxValue, isMovingTransactionFromTrackExpense, isReadOnly, transactionID, policyID, shouldShowTax, shouldKeepCurrentTaxSelection, transactionTaxAmount]); + + useEffect(() => { + if (!transactionID || isReadOnly || !shouldShowTax || isMovingTransactionFromTrackExpense) { + return; + } + setMoneyRequestTaxAmount(transactionID, taxAmountInSmallestCurrencyUnits); + }, [transactionID, taxAmountInSmallestCurrencyUnits, isReadOnly, shouldShowTax, isMovingTransactionFromTrackExpense]); + + return null; +} + +TaxController.displayName = 'TaxController'; + +export default TaxController; From 3d0e61b00012e4f20c3e3d48f9e5160fb6b445a6 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 2 Apr 2026 15:33:39 +0200 Subject: [PATCH 2/4] remove redundant props --- .../MoneyRequestConfirmationList.tsx | 70 ++++--------------- src/pages/Share/SubmitDetailsPage.tsx | 3 - src/pages/iou/SplitBillDetailsPage.tsx | 20 +----- .../step/IOURequestStepConfirmation.tsx | 13 +--- 4 files changed, 17 insertions(+), 89 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 6034558910bae..b3b395bc5b58b 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -35,6 +35,7 @@ import {isValidTimeExpenseAmount} from '@libs/TimeTrackingUtils'; import { areRequiredFieldsEmpty, calculateTaxAmount, + getAttendees, getDefaultTaxCode, getDistanceInMeters, getRateID, @@ -55,7 +56,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import type {Attendee, Participant} from '@src/types/onyx/IOU'; +import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import Button from './Button'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; @@ -86,39 +87,9 @@ type MoneyRequestConfirmationListProps = { /** Callback to parent modal to pay someone */ onSendMoney?: (paymentMethod: PaymentMethodType | undefined) => void; - /** IOU amount */ - iouAmount: number; - - /** IOU attendees list */ - iouAttendees?: Attendee[]; - - /** IOU comment */ - iouComment?: string; - - /** IOU currency */ - iouCurrencyCode?: string; - /** IOU type */ iouType?: Exclude; - /** IOU date */ - iouCreated?: string; - - /** IOU merchant */ - iouMerchant?: string; - - /** IOU Category */ - iouCategory?: string; - - /** IOU isBillable */ - iouIsBillable?: boolean; - - /** Time expense's hour count */ - iouTimeCount?: number; - - /** Time expense's hourly rate */ - iouTimeRate?: number; - /** Callback to toggle the billable state */ onToggleBillable?: (isOn: boolean) => void; @@ -209,9 +180,6 @@ type MoneyRequestConfirmationListProps = { /** Function to toggle reimbursable */ onToggleReimbursable?: (isOn: boolean) => void; - /** Flag indicating if the IOU is reimbursable */ - iouIsReimbursable?: boolean; - /** Show remove expense confirmation modal */ showRemoveExpenseConfirmModal?: () => void; @@ -230,7 +198,6 @@ function MoneyRequestConfirmationList({ onSendMoney, onConfirm, iouType = CONST.IOU.TYPE.SUBMIT, - iouAmount, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest = false, @@ -238,23 +205,16 @@ function MoneyRequestConfirmationList({ isGPSDistanceRequest, isPerDiemRequest = false, isPolicyExpenseChat = false, - iouCategory = '', shouldShowSmartScanFields = true, isEditingSplitBill, - iouCurrencyCode, isReceiptEditable, - iouMerchant, selectedParticipants: selectedParticipantsProp, payeePersonalDetails: payeePersonalDetailsProp, isReadOnly = false, policyID, reportID = '', receiptPath = '', - iouAttendees, - iouComment, receiptFilename = '', - iouCreated, - iouIsBillable = false, onToggleBillable, hasSmartScanFailed, reportActionID, @@ -265,12 +225,9 @@ function MoneyRequestConfirmationList({ isConfirming, onPDFLoadError, onPDFPassword, - iouIsReimbursable = true, onToggleReimbursable, showRemoveExpenseConfirmModal, isTimeRequest = false, - iouTimeCount, - iouTimeRate, shouldHideToSection = false, }: MoneyRequestConfirmationListProps) { const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -331,6 +288,19 @@ function MoneyRequestConfirmationList({ const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); + // Derive iou values from transaction instead of receiving as props (CLEAN-REACT-PATTERNS-2) + const iouAmount = transaction?.amount ?? 0; + const iouComment = transaction?.comment?.comment ?? ''; + const iouCurrencyCode = transaction?.currency; + const iouMerchant = transaction?.merchant; + const iouCreated = transaction?.created; + const iouCategory = transaction?.category ?? ''; + const iouIsBillable = transaction?.billable ?? false; + const iouIsReimbursable = transaction?.reimbursable ?? true; + const iouTimeCount = transaction?.comment?.units?.count; + const iouTimeRate = transaction?.comment?.units?.rate; + const iouAttendees = useMemo(() => getAttendees(transaction, currentUserPersonalDetails), [transaction, currentUserPersonalDetails]); + const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; const isTypeSend = iouType === CONST.IOU.TYPE.PAY; @@ -1230,15 +1200,11 @@ export default memo( prevProps.onSendMoney === nextProps.onSendMoney && prevProps.onConfirm === nextProps.onConfirm && prevProps.iouType === nextProps.iouType && - prevProps.iouAmount === nextProps.iouAmount && prevProps.isDistanceRequest === nextProps.isDistanceRequest && prevProps.isPolicyExpenseChat === nextProps.isPolicyExpenseChat && prevProps.expensesNumber === nextProps.expensesNumber && - prevProps.iouCategory === nextProps.iouCategory && prevProps.shouldShowSmartScanFields === nextProps.shouldShowSmartScanFields && prevProps.isEditingSplitBill === nextProps.isEditingSplitBill && - prevProps.iouCurrencyCode === nextProps.iouCurrencyCode && - prevProps.iouMerchant === nextProps.iouMerchant && // eslint-disable-next-line rulesdir/no-deep-equal-in-memo -- selectedParticipants is derived with .map() which creates new array references deepEqual(prevProps.selectedParticipants, nextProps.selectedParticipants) && prevProps.payeePersonalDetails === nextProps.payeePersonalDetails && @@ -1246,19 +1212,13 @@ export default memo( prevProps.policyID === nextProps.policyID && prevProps.reportID === nextProps.reportID && prevProps.receiptPath === nextProps.receiptPath && - prevProps.iouAttendees === nextProps.iouAttendees && - prevProps.iouComment === nextProps.iouComment && prevProps.receiptFilename === nextProps.receiptFilename && - prevProps.iouCreated === nextProps.iouCreated && - prevProps.iouIsBillable === nextProps.iouIsBillable && prevProps.onToggleBillable === nextProps.onToggleBillable && prevProps.hasSmartScanFailed === nextProps.hasSmartScanFailed && prevProps.reportActionID === nextProps.reportActionID && prevProps.action === nextProps.action && prevProps.shouldDisplayReceipt === nextProps.shouldDisplayReceipt && prevProps.isTimeRequest === nextProps.isTimeRequest && - prevProps.iouTimeCount === nextProps.iouTimeCount && - prevProps.iouTimeRate === nextProps.iouTimeRate && prevProps.shouldHideToSection === nextProps.shouldHideToSection && prevProps.isLoadingReceipt === nextProps.isLoadingReceipt, ); diff --git a/src/pages/Share/SubmitDetailsPage.tsx b/src/pages/Share/SubmitDetailsPage.tsx index 2cbbf9614d2a2..18fcc89e7f5b0 100644 --- a/src/pages/Share/SubmitDetailsPage.tsx +++ b/src/pages/Share/SubmitDetailsPage.tsx @@ -296,9 +296,6 @@ function SubmitDetailsPage({ onConfirm(true)} receiptPath={fileUri} receiptFilename={getFileName(fileName)} diff --git a/src/pages/iou/SplitBillDetailsPage.tsx b/src/pages/iou/SplitBillDetailsPage.tsx index a7b43fe8e85f0..5f6f831ff45cb 100644 --- a/src/pages/iou/SplitBillDetailsPage.tsx +++ b/src/pages/iou/SplitBillDetailsPage.tsx @@ -21,9 +21,8 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SplitDetailsNavigatorParamList} from '@libs/Navigation/types'; import {getParticipantsOption, getPolicyExpenseReportOption} from '@libs/OptionsListUtils'; -import Parser from '@libs/Parser'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {getTransactionDetails, isPolicyExpenseChat} from '@libs/ReportUtils'; +import {isPolicyExpenseChat} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; import { areRequiredFieldsEmpty, @@ -93,16 +92,6 @@ function SplitBillDetailsPage({route, report, reportAction}: SplitBillDetailsPag const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - const { - amount: splitAmount, - currency: splitCurrency, - comment: splitComment, - merchant: splitMerchant, - created: splitCreated, - category: splitCategory, - billable: splitBillable, - } = getTransactionDetails(isEditingSplitBill && draftTransaction ? draftTransaction : transaction) ?? {}; - const onConfirm = useCallback(() => { setIsConfirmed(true); completeSplitBill( @@ -148,14 +137,7 @@ function SplitBillDetailsPage({route, report, reportAction}: SplitBillDetailsPag { @@ -1719,8 +1713,6 @@ function IOURequestStepConfirmation({ shouldDisplayReceipt={!isMovingTransactionFromTrackExpense && (!isDistanceRequest || isManualDistanceRequest || isOdometerDistanceRequest) && !isPerDiemRequest} isPolicyExpenseChat={isPolicyExpenseChat} policyID={policyID} - iouMerchant={transaction?.merchant} - iouCreated={transaction?.created} isDistanceRequest={isDistanceRequest} isManualDistanceRequest={isManualDistanceRequest} isOdometerDistanceRequest={isOdometerDistanceRequest} @@ -1731,13 +1723,10 @@ function IOURequestStepConfirmation({ action={action} isConfirmed={isConfirmed} isConfirming={isConfirming} - iouIsReimbursable={transaction?.reimbursable} onToggleReimbursable={setReimbursable} expensesNumber={transactions.length} isReceiptEditable isTimeRequest={isTimeRequest} - iouTimeCount={transaction?.comment?.units?.count} - iouTimeRate={transaction?.comment?.units?.rate} shouldHideToSection={shouldHideToSection} /> From 81a54d4df3a7ecf1e054acea1eee51e9edb77b13 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Apr 2026 09:50:57 +0200 Subject: [PATCH 3/4] address codex review --- .../MoneyRequestConfirmationList.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index b3b395bc5b58b..d168625c55a65 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -35,10 +35,18 @@ import {isValidTimeExpenseAmount} from '@libs/TimeTrackingUtils'; import { areRequiredFieldsEmpty, calculateTaxAmount, + getAmount, getAttendees, + getBillable, + getCategory, + getCreated, + getCurrency, getDefaultTaxCode, + getDescription, getDistanceInMeters, + getMerchant, getRateID, + getReimbursable, getTag, getTaxValue, hasMissingSmartscanFields, @@ -288,15 +296,14 @@ function MoneyRequestConfirmationList({ const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); - // Derive iou values from transaction instead of receiving as props (CLEAN-REACT-PATTERNS-2) - const iouAmount = transaction?.amount ?? 0; - const iouComment = transaction?.comment?.comment ?? ''; - const iouCurrencyCode = transaction?.currency; - const iouMerchant = transaction?.merchant; - const iouCreated = transaction?.created; - const iouCategory = transaction?.category ?? ''; - const iouIsBillable = transaction?.billable ?? false; - const iouIsReimbursable = transaction?.reimbursable ?? true; + const iouAmount = getAmount(transaction); + const iouComment = getDescription(transaction); + const iouCurrencyCode = getCurrency(transaction); + const iouMerchant = getMerchant(transaction); + const iouCreated = getCreated(transaction); + const iouCategory = getCategory(transaction); + const iouIsBillable = getBillable(transaction); + const iouIsReimbursable = getReimbursable(transaction); const iouTimeCount = transaction?.comment?.units?.count; const iouTimeRate = transaction?.comment?.units?.rate; const iouAttendees = useMemo(() => getAttendees(transaction, currentUserPersonalDetails), [transaction, currentUserPersonalDetails]); From 22c3bdf7f95d9f2e12eb658cde914c3c361b71ac Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Apr 2026 18:00:52 +0200 Subject: [PATCH 4/4] negative amount fix --- src/components/MoneyRequestConfirmationList.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index d168625c55a65..cf06512ccabd1 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -35,7 +35,6 @@ import {isValidTimeExpenseAmount} from '@libs/TimeTrackingUtils'; import { areRequiredFieldsEmpty, calculateTaxAmount, - getAmount, getAttendees, getBillable, getCategory, @@ -296,7 +295,7 @@ function MoneyRequestConfirmationList({ const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); - const iouAmount = getAmount(transaction); + const iouAmount = transaction?.amount ?? 0; const iouComment = getDescription(transaction); const iouCurrencyCode = getCurrency(transaction); const iouMerchant = getMerchant(transaction);