diff --git a/src/CONST/index.ts b/src/CONST/index.ts old mode 100755 new mode 100644 index e8d303b1b8e1..b68a39817599 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3156,6 +3156,8 @@ const CONST = { }, AMOUNT_MAX_LENGTH: 10, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, + ODOMETER_MAX_VALUE: 9999999.9, + MAX_SAFE_AMOUNT: 999999999999, RECEIPT_STATE: { SCAN_READY: 'SCANREADY', OPEN: 'OPEN', diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx old mode 100755 new mode 100644 index 5a4b39e7c894..dcb4c1f7fc61 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1032,6 +1032,11 @@ function MoneyRequestConfirmationList({ return; } + if (isDistanceRequest && Math.abs(iouAmount) > CONST.IOU.MAX_SAFE_AMOUNT) { + setFormError('iou.error.distanceAmountTooLarge'); + return; + } + if (isTimeRequest && !isValidTimeExpenseAmount(iouAmount, iouCurrencyCode, decimals)) { setFormError('iou.timeTracking.amountTooLargeError'); return; diff --git a/src/languages/de.ts b/src/languages/de.ts index 2a8b251db17b..b3f46b5f223a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1312,6 +1312,10 @@ const translations: TranslationDeepObject = { invalidDistance: 'Bitte gib eine gültige Entfernung ein, bevor du fortfährst', invalidReadings: 'Bitte geben Sie sowohl Start- als auch Endstand ein', negativeDistanceNotAllowed: 'Endstand muss größer als Anfangsstand sein', + distanceAmountTooLarge: 'Der Gesamtbetrag ist zu hoch. Verringere die Entfernung oder reduziere den Satz.', + distanceAmountTooLargeReduceDistance: 'Der Gesamtbetrag ist zu hoch. Verringere die Entfernung.', + distanceAmountTooLargeReduceRate: 'Der Gesamtbetrag ist zu hoch. Reduziere den Satz.', + odometerReadingTooLarge: (formattedMax: string) => `Kilometerstände dürfen ${formattedMax} nicht überschreiten.`, invalidIntegerAmount: 'Bitte gib einen vollen Dollarbetrag ein, bevor du fortfährst', invalidTaxAmount: (amount: string) => `Der maximale Steuerbetrag ist ${amount}`, invalidSplit: 'Die Summe der Aufteilungen muss dem Gesamtbetrag entsprechen', diff --git a/src/languages/en.ts b/src/languages/en.ts index d5c0aeb138f6..573b08fc2540 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1328,6 +1328,10 @@ const translations = { invalidDistance: 'Please enter a valid distance before continuing', invalidReadings: 'Please enter both start and end readings', negativeDistanceNotAllowed: 'End reading must be greater than start reading', + distanceAmountTooLarge: 'The total amount is too large. Reduce the distance or lower the rate.', + distanceAmountTooLargeReduceDistance: 'The total amount is too large. Reduce the distance.', + distanceAmountTooLargeReduceRate: 'The total amount is too large. Lower the rate.', + odometerReadingTooLarge: (formattedMax: string) => `Odometer readings cannot exceed ${formattedMax}.`, invalidIntegerAmount: 'Please enter a whole dollar amount before continuing', invalidTaxAmount: (amount: string) => `Maximum tax amount is ${amount}`, invalidSplit: 'The sum of splits must equal the total amount', diff --git a/src/languages/es.ts b/src/languages/es.ts index dede4ccad19e..e6d7dbb5a805 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1164,6 +1164,10 @@ const translations: TranslationDeepObject = { invalidDistance: 'Por favor, ingresa una distancia válida antes de continuar', invalidReadings: 'Por favor ingrese ambas lecturas de inicio y fin', negativeDistanceNotAllowed: 'La lectura final debe ser mayor que la lectura inicial', + distanceAmountTooLarge: 'El importe total es demasiado alto. Reduce la distancia o disminuye la tarifa.', + distanceAmountTooLargeReduceDistance: 'El importe total es demasiado alto. Reduce la distancia.', + distanceAmountTooLargeReduceRate: 'El importe total es demasiado alto. Disminuye la tarifa.', + odometerReadingTooLarge: (formattedMax: string) => `Las lecturas del odómetro no pueden superar ${formattedMax}.`, invalidIntegerAmount: 'Por favor, introduce una cantidad entera en dólares antes de continuar', invalidTaxAmount: (amount) => `El importe máximo del impuesto es ${amount}`, invalidSplit: 'La suma de las partes debe ser igual al importe total', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 624014061be1..d63753b44a51 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1316,6 +1316,10 @@ const translations: TranslationDeepObject = { invalidDistance: 'Veuillez saisir une distance valide avant de continuer', invalidReadings: 'Veuillez saisir les relevés de début et de fin', negativeDistanceNotAllowed: 'Le relevé de fin doit être supérieur au relevé de début', + distanceAmountTooLarge: 'Le montant total est trop élevé. Réduisez la distance ou diminuez le taux.', + distanceAmountTooLargeReduceDistance: 'Le montant total est trop élevé. Réduisez la distance.', + distanceAmountTooLargeReduceRate: 'Le montant total est trop élevé. Diminuez le taux.', + odometerReadingTooLarge: (formattedMax: string) => `Les lectures du compteur kilométrique ne peuvent pas dépasser ${formattedMax}.`, invalidIntegerAmount: 'Veuillez saisir un montant entier en dollars avant de continuer', invalidTaxAmount: (amount: string) => `Le montant maximal de taxe est de ${amount}`, invalidSplit: 'La somme des répartitions doit être égale au montant total', diff --git a/src/languages/it.ts b/src/languages/it.ts index 16f35a7e1ba3..714ebdbb4fae 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1309,6 +1309,10 @@ const translations: TranslationDeepObject = { invalidDistance: 'Inserisci una distanza valida prima di continuare', invalidReadings: 'Inserisci sia la lettura iniziale che quella finale', negativeDistanceNotAllowed: 'La lettura finale deve essere maggiore della lettura iniziale', + distanceAmountTooLarge: "L'importo totale è troppo alto. Riduci la distanza o abbassa la tariffa.", + distanceAmountTooLargeReduceDistance: "L'importo totale è troppo alto. Riduci la distanza.", + distanceAmountTooLargeReduceRate: "L'importo totale è troppo alto. Abbassa la tariffa.", + odometerReadingTooLarge: (formattedMax: string) => `Le letture del contachilometri non possono superare ${formattedMax}.`, invalidIntegerAmount: 'Inserisci un importo in dollari intero prima di continuare', invalidTaxAmount: (amount: string) => `L’importo massimo dell’imposta è ${amount}`, invalidSplit: 'La somma delle suddivisioni deve essere uguale all’importo totale', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index aa873fba0bb7..d675b53ee3e4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1303,6 +1303,10 @@ const translations: TranslationDeepObject = { invalidDistance: '続行する前に有効な距離を入力してください', invalidReadings: '開始値と終了値の両方を入力してください', negativeDistanceNotAllowed: '終了値は開始値より大きくなければなりません', + distanceAmountTooLarge: '合計金額が大きすぎます。距離を減らすか、レートを下げてください。', + distanceAmountTooLargeReduceDistance: '合計金額が大きすぎます。距離を減らしてください。', + distanceAmountTooLargeReduceRate: '合計金額が大きすぎます。レートを下げてください。', + odometerReadingTooLarge: (formattedMax: string) => `オドメーターの読み取り値は${formattedMax}を超えることはできません。`, invalidIntegerAmount: '続行する前にドルの整数金額を入力してください', invalidTaxAmount: (amount: string) => `最大税額は${amount}です`, invalidSplit: '分割した金額の合計は合計金額と一致している必要があります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d064fdd6e68d..ce2300844773 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1308,6 +1308,10 @@ const translations: TranslationDeepObject = { invalidDistance: 'Voer een geldige afstand in voordat je verdergaat', invalidReadings: 'Voer zowel de begin- als eindstanden in', negativeDistanceNotAllowed: 'Eindstand moet hoger zijn dan beginstand', + distanceAmountTooLarge: 'Het totale bedrag is te hoog. Verlaag de afstand of verlaag het tarief.', + distanceAmountTooLargeReduceDistance: 'Het totale bedrag is te hoog. Verlaag de afstand.', + distanceAmountTooLargeReduceRate: 'Het totale bedrag is te hoog. Verlaag het tarief.', + odometerReadingTooLarge: (formattedMax: string) => `Kilometertellerstanden mogen niet hoger zijn dan ${formattedMax}.`, invalidIntegerAmount: 'Voer een volledig dollarbedrag in voordat je doorgaat', invalidTaxAmount: (amount: string) => `Maximale belastingbedrag is ${amount}`, invalidSplit: 'De som van de splitsingen moet gelijk zijn aan het totale bedrag', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 26711f3d65a5..106929ac50ee 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1307,6 +1307,10 @@ const translations: TranslationDeepObject = { invalidDistance: 'Wprowadź prawidłowy dystans przed kontynuowaniem', invalidReadings: 'Wprowadź zarówno odczyt początkowy, jak i końcowy', negativeDistanceNotAllowed: 'Końcowy odczyt musi być większy niż początkowy odczyt', + distanceAmountTooLarge: 'Łączna kwota jest zbyt wysoka. Zmniejsz dystans lub obniż stawkę.', + distanceAmountTooLargeReduceDistance: 'Łączna kwota jest zbyt wysoka. Zmniejsz dystans.', + distanceAmountTooLargeReduceRate: 'Łączna kwota jest zbyt wysoka. Obniż stawkę.', + odometerReadingTooLarge: (formattedMax: string) => `Odczyty licznika nie mogą przekraczać ${formattedMax}.`, invalidIntegerAmount: 'Przed kontynuowaniem wprowadź kwotę w pełnych dolarach', invalidTaxAmount: (amount: string) => `Maksymalna kwota podatku to ${amount}`, invalidSplit: 'Suma podziałów musi być równa całkowitej kwocie', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index fa25aaeefb9e..b55d8a69a9cb 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1305,6 +1305,10 @@ const translations: TranslationDeepObject = { invalidDistance: 'Insira uma distância válida antes de continuar', invalidReadings: 'Insira as leituras de início e fim', negativeDistanceNotAllowed: 'A leitura final deve ser maior que a leitura inicial', + distanceAmountTooLarge: 'O valor total é muito alto. Diminua a distância ou reduza a tarifa.', + distanceAmountTooLargeReduceDistance: 'O valor total é muito alto. Diminua a distância.', + distanceAmountTooLargeReduceRate: 'O valor total é muito alto. Reduza a tarifa.', + odometerReadingTooLarge: (formattedMax: string) => `As leituras do hodômetro não podem exceder ${formattedMax}.`, invalidIntegerAmount: 'Insira um valor inteiro em dólares antes de continuar', invalidTaxAmount: (amount: string) => `O valor máximo de imposto é ${amount}`, invalidSplit: 'A soma das divisões deve ser igual ao valor total', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d9b83ec742c8..6f2163103458 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1282,6 +1282,10 @@ const translations: TranslationDeepObject = { invalidDistance: '请在继续之前输入有效的距离', invalidReadings: '请输入起始读数和结束读数', negativeDistanceNotAllowed: '结束读数必须大于开始读数', + distanceAmountTooLarge: '总金额过大。请减少距离或降低费率。', + distanceAmountTooLargeReduceDistance: '总金额过大。请减少距离。', + distanceAmountTooLargeReduceRate: '总金额过大。请降低费率。', + odometerReadingTooLarge: (formattedMax: string) => `里程表读数不能超过${formattedMax}。`, invalidIntegerAmount: '请在继续之前输入一个整数美元金额', invalidTaxAmount: (amount: string) => `最高税额为 ${amount}`, invalidSplit: '拆分金额之和必须等于总金额', diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index b54bb9136048..8c6068a354ca 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -418,6 +418,23 @@ function getRateByCustomUnitRateID({customUnitRateID, policy}: {customUnitRateID return getMileageRates(policy, true, customUnitRateID)[customUnitRateID]; } +/** + * Returns whether the calculated distance expense amount (distance * rate) is within the backend's safe limit. + * The backend WAF rejects amounts exceeding 12 digits (999,999,999,999 cents). + * + * @param distance - The distance in the unit specified (km or mi), NOT meters + * @param rate - The rate in cents per unit + * @returns true if the amount is within limits, false if it would exceed the backend limit + */ +function isDistanceAmountWithinLimit(distance: number, rate: number): boolean { + // Match the 2-decimal rounding used by getDistanceRequestAmount so boundary + // values produce identical results in both the limit check and the actual + // amount calculation. + const roundedDistance = parseFloat(Math.abs(distance).toFixed(2)); + const amount = Math.round(roundedDistance * rate); + return amount <= CONST.IOU.MAX_SAFE_AMOUNT; +} + export default { getDefaultMileageRate, getDistanceMerchant, @@ -436,6 +453,7 @@ export default { getRateByCustomUnitRateID, getDistanceForDisplayLabel, convertDistanceUnit, + isDistanceAmountWithinLimit, }; export type {MileageRate}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 77a93711502e..a35580d22c72 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -103,11 +103,13 @@ function IOURequestStepDistanceManual({ // to make sure the correct distance amount and unit will be shown we use distance unit // from defaultExpensePolicy or current report's policy instead of from transaction and // then we use transaction data (distanceUnit and quantity) for conversions - const unit = DistanceRequestUtils.getRate({ + const mileageRate = DistanceRequestUtils.getRate({ transaction, policy: shouldUseDefaultExpensePolicy ? defaultExpensePolicy : policy, useTransactionDistanceUnit: false, - }).unit; + }); + const unit = mileageRate.unit; + const rate = mileageRate.rate ?? 0; const distanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit ? transaction.comment.customUnit.distanceUnit : unit); const distance = typeof transaction?.comment?.customUnit?.quantity === 'number' ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit)) : undefined; const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -274,8 +276,14 @@ function IOURequestStepDistanceManual({ return; } + // Validation: Check that distance * rate doesn't exceed the backend's safe amount limit + if (!DistanceRequestUtils.isDistanceAmountWithinLimit(parseFloat(value), rate)) { + setFormError(translate('iou.error.distanceAmountTooLargeReduceDistance')); + return; + } + navigateToNextPage(value); - }, [navigateToNextPage, translate, report, iouType, currentUserAccountIDParam]); + }, [navigateToNextPage, translate, report, iouType, currentUserAccountIDParam, rate]); useEffect(() => { if (isLoadingSelectedTab) { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 5362bc5c5871..091a5eefe18a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -28,6 +28,7 @@ import {handleMoneyRequestStepDistanceNavigation} from '@libs/actions/IOU/MoneyR import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {replaceAllDigits} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isArchivedReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; @@ -63,7 +64,7 @@ function IOURequestStepDistanceOdometer({ transaction, currentUserPersonalDetails, }: IOURequestStepDistanceOdometerProps) { - const {translate} = useLocalize(); + const {translate, fromLocaleDigit, numberFormat} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -124,7 +125,9 @@ function IOURequestStepDistanceOdometer({ const shouldUseDefaultExpensePolicy = useMemo(() => shouldUseDefaultExpensePolicyUtil(iouType, defaultExpensePolicy), [iouType, defaultExpensePolicy]); const customUnitRateID = getRateID(transaction); - const unit = DistanceRequestUtils.getRate({transaction, policy: shouldUseDefaultExpensePolicy ? defaultExpensePolicy : policy}).unit; + const mileageRate = DistanceRequestUtils.getRate({transaction, policy: shouldUseDefaultExpensePolicy ? defaultExpensePolicy : policy}); + const unit = mileageRate.unit; + const rate = mileageRate.rate ?? 0; const shouldSkipConfirmation: boolean = !skipConfirmation || !report?.reportID ? false : !(isArchived || isPolicyExpenseChatUtils(report)); @@ -209,10 +212,25 @@ function IOURequestStepDistanceOdometer({ } }, [transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd, isEditing]); + /** + * Normalize odometer text by standardizing locale digits and stripping all + * non-numeric characters except the decimal point. This single function is + * used by both validation and parsing so the two paths always agree on the + * numeric interpretation of the input. + */ + const normalizeOdometerText = (text: string): string => { + const standardized = replaceAllDigits(text, fromLocaleDigit); + return standardized.replaceAll(/[^0-9.]/g, ''); + }; + + const parseOdometerReading = (text: string): number => { + return parseFloat(normalizeOdometerText(text)); + }; + // Calculate total distance - updated live after every input change const totalDistance = (() => { - const start = parseFloat(startReading); - const end = parseFloat(endReading); + const start = parseOdometerReading(startReading); + const end = parseOdometerReading(endReading); if (Number.isNaN(start) || Number.isNaN(end) || !startReading || !endReading) { return null; } @@ -271,37 +289,52 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); - const cleanOdometerReading = (text: string): string => { - // Allow digits and one decimal point or comma - // Remove all characters except digits, dots, and commas - let cleaned = text.replaceAll(/[^0-9.,]/g, ''); - // Replace comma with dot for consistency - cleaned = cleaned.replaceAll(',', '.'); - // Allow only one decimal point - const parts = cleaned.split('.'); + // Per-keystroke validation: enforce format constraints and cap the max value. + // The max-value check allows edits that *reduce* the value (e.g. backspacing + // a legacy over-max reading) but rejects keystrokes that would increase + // beyond ODOMETER_MAX_VALUE. Submit-time validation in handleNext is the + // final safety net. + const isOdometerInputValid = (text: string, previousText: string): boolean => { + if (!text) { + return true; + } + const stripped = normalizeOdometerText(text); + const parts = stripped.split('.'); if (parts.length > 2) { - cleaned = `${parts.at(0) ?? ''}.${parts.slice(1).join('')}`; + return false; } - // Don't allow decimal point at the start - if (cleaned.startsWith('.')) { - cleaned = `0${cleaned}`; + if (parts.length === 2 && (parts.at(1) ?? '').length > 1) { + return false; } - return cleaned; + const value = parseFloat(stripped); + // Allow edits that reduce the value (e.g. backspacing a legacy over-max reading), + // but reject keystrokes that would increase beyond the max. + if (!Number.isNaN(value) && value > CONST.IOU.ODOMETER_MAX_VALUE) { + const previousValue = parseFloat(normalizeOdometerText(previousText)); + if (Number.isNaN(previousValue) || value >= previousValue) { + return false; + } + } + return true; }; const handleStartReadingChange = (text: string) => { - const cleaned = cleanOdometerReading(text); - setStartReading(cleaned); - startReadingRef.current = cleaned; + if (!isOdometerInputValid(text, startReading)) { + return; + } + setStartReading(text); + startReadingRef.current = text; if (formError) { setFormError(''); } }; const handleEndReadingChange = (text: string) => { - const cleaned = cleanOdometerReading(text); - setEndReading(cleaned); - endReadingRef.current = cleaned; + if (!isOdometerInputValid(text, endReading)) { + return; + } + setEndReading(text); + endReadingRef.current = text; if (formError) { setFormError(''); } @@ -336,8 +369,8 @@ function IOURequestStepDistanceOdometer({ const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); // Navigate to next page following Manual tab pattern const navigateToNextPage = () => { - const start = parseFloat(startReading); - const end = parseFloat(endReading); + const start = parseOdometerReading(startReading); + const end = parseOdometerReading(endReading); // Store odometer readings in transaction.comment.odometerStart/odometerEnd setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); @@ -435,21 +468,31 @@ function IOURequestStepDistanceOdometer({ return; } - const start = parseFloat(startReading); - const end = parseFloat(endReading); + const start = parseOdometerReading(startReading); + const end = parseOdometerReading(endReading); if (Number.isNaN(start) || Number.isNaN(end)) { setFormError(translate('iou.error.invalidReadings')); return; } - // Validation: Calculated distance (end - start) must be > 0 + if (start > CONST.IOU.ODOMETER_MAX_VALUE || end > CONST.IOU.ODOMETER_MAX_VALUE) { + setFormError(translate('iou.error.odometerReadingTooLarge', numberFormat(CONST.IOU.ODOMETER_MAX_VALUE, {maximumFractionDigits: 1}))); + return; + } + const distance = end - start; if (distance <= 0) { setFormError(translate('iou.error.negativeDistanceNotAllowed')); return; } + // Validation: Check that distance * rate doesn't exceed the backend's safe amount limit + if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distance, rate)) { + setFormError(translate('iou.error.distanceAmountTooLargeReduceDistance')); + return; + } + // When validation passes, call navigateToNextPage navigateToNextPage(); }; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 107d470f540b..f536ec4e2b0b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -1,5 +1,6 @@ -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import FormHelpMessage from '@components/FormHelpMessage'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import Text from '@components/Text'; @@ -76,6 +77,11 @@ function IOURequestStepDistanceRate({ const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const [formError, setFormError] = useState(''); + + // Track the rate the user last selected visually, even if it failed validation. + // This keeps the problematic rate shown as selected so the user understands what they need to change. + const [pendingRateID, setPendingRateID] = useState(); const rates = DistanceRequestUtils.getMileageRates(policy, false, currentRateID); const sortedRates = useMemo(() => Object.values(rates).sort((a, b) => localeCompare(a.name ?? '', b.name ?? '')), [rates, localeCompare]); @@ -86,7 +92,8 @@ function IOURequestStepDistanceRate({ const options = sortedRates.map((rate) => { const unit = transaction?.comment?.customUnit?.customUnitRateID === rate.customUnitRateID ? DistanceRequestUtils.getDistanceUnit(transaction, rate) : rate.unit; - const isSelected = currentRateID ? currentRateID === rate.customUnitRateID : DistanceRequestUtils.getDefaultMileageRate(policy)?.customUnitRateID === rate.customUnitRateID; + const effectiveRateID = pendingRateID ?? currentRateID; + const isSelected = effectiveRateID ? effectiveRateID === rate.customUnitRateID : DistanceRequestUtils.getDefaultMileageRate(policy)?.customUnitRateID === rate.customUnitRateID; const rateForDisplay = DistanceRequestUtils.getRateForDisplay(unit, rate.rate, isSelected ? transactionCurrency : rate.currency, translate, toLocaleDigit, getCurrencySymbol); return { text: rate.name ?? rateForDisplay, @@ -103,6 +110,26 @@ function IOURequestStepDistanceRate({ const initiallyFocusedOption = options.find((item) => item.isSelected)?.keyForList; function selectDistanceRate(customUnitRateID: string) { + // Validate that the new rate combined with the existing distance doesn't exceed the backend limit. + // This check runs before any state updates so that an invalid rate doesn't modify tax or rate state. + const newRate = rates[customUnitRateID]?.rate ?? 0; + // Use the newly selected rate's unit directly rather than getDistanceUnit(), which + // prefers the transaction's stored unit. When a user switches from a miles-based + // rate to a km-based rate, validation must use the *new* rate's unit so the + // distance-to-amount calculation is correct. + const selectedRateUnit = rates[customUnitRateID]?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + // Read distance in meters using the *transaction's current* unit (so the raw + // quantity stored on the transaction is interpreted correctly), then convert to + // the selected rate's unit for the limit check. + const currentUnit = DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); + const distanceInMeters = getDistanceInMeters(transaction, currentUnit); + const distanceInUnits = DistanceRequestUtils.convertDistanceUnit(distanceInMeters, selectedRateUnit); + if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distanceInUnits, newRate)) { + setPendingRateID(customUnitRateID); + setFormError(translate('iou.error.distanceAmountTooLargeReduceRate')); + return; + } + let taxAmount; let taxRateExternalID; if (shouldShowTax) { @@ -110,8 +137,7 @@ function IOURequestStepDistanceRate({ const defaultTaxCode = getDefaultTaxCode(policy, transaction) ?? ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing taxRateExternalID = policyCustomUnitRate?.attributes?.taxRateExternalID || defaultTaxCode; - const unit = DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); - const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(transaction, unit)); + const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(transaction, currentUnit)); const taxPercentage = taxRateExternalID ? getTaxValue(policy, transaction, taxRateExternalID) : undefined; taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, taxableAmount, getCurrencyDecimals(rates[customUnitRateID].currency))); setMoneyRequestTaxAmount(transactionID, taxAmount, shouldUseTransactionDraft(action)); @@ -140,6 +166,8 @@ function IOURequestStepDistanceRate({ } } + setPendingRateID(undefined); + setFormError(''); navigateBack(); } @@ -152,6 +180,12 @@ function IOURequestStepDistanceRate({ shouldShowNotFoundPage={shouldShowNotFoundPage} > {translate('iou.chooseARate')} + {!!formError && ( + + )}