diff --git a/src/CONST/index.ts b/src/CONST/index.ts old mode 100755 new mode 100644 index 41e00122aebbc..6a46aa0907e9a --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3153,6 +3153,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 13eb788d7e926..63c00c4ca4c47 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1035,6 +1035,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 cdc2e5526b000..86168c749f169 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1319,6 +1319,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 fe10986c45d59..af2c727138545 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1335,6 +1335,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 308af6fe1d71c..6d93ea85c2bee 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1168,6 +1168,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 e2f830bb90bd5..9bd513a733b53 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1323,6 +1323,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 71a693672a3a4..376d3b7dd2c95 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1316,6 +1316,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 78da63366d2ca..34b0f429e7ea1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1307,6 +1307,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 fb7319713ee0e..f57c63a8cd065 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1315,6 +1315,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 78200c1d5e0b4..953b4ef4a33b5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1314,6 +1314,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 caba5c45cf8a7..0ffc6e9d751ce 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1312,6 +1312,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 ebb52f8a4bafe..5a44a26e1ab98 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1289,6 +1289,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 b54bb9136048f..b0702689a78e9 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -6,6 +6,7 @@ import type {LastSelectedDistanceRates, OnyxInputOrEntry, Transaction} from '@sr import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {replaceAllDigits} from './MoneyRequestUtils'; // This will be fixed as part of https://github.com/Expensify/App/issues/66397 // eslint-disable-next-line @typescript-eslint/no-deprecated import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getPersonalPolicy, getUnitRateValue} from './PolicyUtils'; @@ -268,6 +269,15 @@ function getRateForP2P(currency: string, transaction: OnyxEntry): M }; } +/** + * Rounds a distance (already in the target unit) to 2 decimal places, + * multiplies by the rate, and rounds to the nearest integer (cents). + */ +function roundDistanceAmount(distanceInUnits: number, rate: number): number { + const roundedDistance = parseFloat(distanceInUnits.toFixed(2)); + return Math.round(roundedDistance * rate); +} + /** * Calculates the expense amount based on distance, unit, and rate. * @@ -277,9 +287,7 @@ function getRateForP2P(currency: string, transaction: OnyxEntry): M * @returns The computed expense amount (rounded) in "cents". */ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): number { - const convertedDistance = convertDistanceUnit(distance, unit); - const roundedDistance = parseFloat(convertedDistance.toFixed(2)); - return Math.round(roundedDistance * rate); + return roundDistanceAmount(convertDistanceUnit(distance, unit), rate); } /** @@ -418,6 +426,30 @@ 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 { + return Math.abs(roundDistanceAmount(distance, rate)) <= CONST.IOU.MAX_SAFE_AMOUNT; +} + +/** + * Normalize odometer text by standardizing locale digits and stripping all + * non-numeric characters except the decimal point. fromLocaleDigit converts + * each locale character to its standard equivalent (e.g. German ',' → '.' + * for decimal, German '.' → ',' for group separator), then we keep only + * digits and the standard decimal point. + */ +function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => string): string { + const standardized = replaceAllDigits(text, fromLocaleDigit); + return standardized.replaceAll(/[^0-9.]/g, ''); +} + export default { getDefaultMileageRate, getDistanceMerchant, @@ -436,6 +468,8 @@ export default { getRateByCustomUnitRateID, getDistanceForDisplayLabel, convertDistanceUnit, + isDistanceAmountWithinLimit, + normalizeOdometerText, }; export type {MileageRate}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index f30c73d741cad..1f38473b1ceec 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -109,11 +109,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); @@ -290,8 +292,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, rate]); useEffect(() => { if (isLoadingSelectedTab) { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index cf8f8e6cc9922..24b2a4937d496 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -65,7 +65,7 @@ function IOURequestStepDistanceOdometer({ transaction, currentUserPersonalDetails, }: IOURequestStepDistanceOdometerProps) { - const {translate} = useLocalize(); + const {translate, fromLocaleDigit, numberFormat} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -129,7 +129,9 @@ function IOURequestStepDistanceOdometer({ const shouldUseDefaultExpensePolicy = useMemo(() => shouldUseDefaultExpensePolicyUtil(iouType, defaultExpensePolicy), [iouType, defaultExpensePolicy]); const customUnitRateID = getRateID(transaction); - const unit = DistanceRequestUtils.getRate({transaction: currentTransaction, policy: shouldUseDefaultExpensePolicy ? defaultExpensePolicy : policy}).unit; + const mileageRate = DistanceRequestUtils.getRate({transaction: currentTransaction, policy: shouldUseDefaultExpensePolicy ? defaultExpensePolicy : policy}); + const unit = mileageRate.unit; + const rate = mileageRate.rate ?? 0; const shouldSkipConfirmation: boolean = !skipConfirmation || !report?.reportID ? false : !(isArchived || isPolicyExpenseChatUtils(report)); @@ -221,8 +223,8 @@ function IOURequestStepDistanceOdometer({ // Calculate total distance - updated live after every input change const totalDistance = (() => { - const start = parseFloat(startReading); - const end = parseFloat(endReading); + const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); + const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); if (Number.isNaN(start) || Number.isNaN(end) || !startReading || !endReading) { return null; } @@ -281,37 +283,53 @@ 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 = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); + const parts = stripped.split('.'); if (parts.length > 2) { - cleaned = `${parts.at(0) ?? ''}.${parts.slice(1).join('')}`; + return false; + } + if (parts.length === 2 && (parts.at(1) ?? '').length > 1) { + return false; } - // Don't allow decimal point at the start - if (cleaned.startsWith('.')) { - cleaned = `0${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(DistanceRequestUtils.normalizeOdometerText(previousText, fromLocaleDigit)); + if (Number.isNaN(previousValue) || value >= previousValue) { + return false; + } } - return cleaned; + 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(''); } @@ -347,8 +365,8 @@ function IOURequestStepDistanceOdometer({ const icons = useMemoizedLazyExpensifyIcons(['GalleryPlus'] as const); // Navigate to next page following Manual tab pattern const navigateToNextPage = () => { - const start = parseFloat(startReading); - const end = parseFloat(endReading); + const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); + const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); // Store odometer readings in transaction.comment.odometerStart/odometerEnd setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); @@ -463,21 +481,31 @@ function IOURequestStepDistanceOdometer({ return; } - const start = parseFloat(startReading); - const end = parseFloat(endReading); + const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); + const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); 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 a777dd8cf7ea9..1734d8553d5e2 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -1,6 +1,7 @@ import lodashIsEmpty from 'lodash/isEmpty'; -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'; @@ -82,6 +83,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]); @@ -92,7 +98,8 @@ function IOURequestStepDistanceRate({ const options = sortedRates.map((rate) => { const unit = currentTransaction?.comment?.customUnit?.customUnitRateID === rate.customUnitRateID ? DistanceRequestUtils.getDistanceUnit(currentTransaction, 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, @@ -109,6 +116,28 @@ 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(currentTransaction, rates[customUnitRateID]); + const distanceInMeters = getDistanceInMeters(currentTransaction, 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) { @@ -116,8 +145,7 @@ function IOURequestStepDistanceRate({ const defaultTaxCode = getDefaultTaxCode(policy, currentTransaction) ?? ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing taxRateExternalID = policyCustomUnitRate?.attributes?.taxRateExternalID || defaultTaxCode; - const unit = DistanceRequestUtils.getDistanceUnit(currentTransaction, rates[customUnitRateID]); - const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(currentTransaction, unit)); + const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(currentTransaction, currentUnit)); const taxPercentage = taxRateExternalID ? getTaxValue(policy, currentTransaction, taxRateExternalID) : undefined; taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, taxableAmount, getCurrencyDecimals(rates[customUnitRateID].currency))); setMoneyRequestTaxAmount(transactionID, taxAmount, shouldUseTransactionDraft(action)); @@ -153,6 +181,8 @@ function IOURequestStepDistanceRate({ } } + setPendingRateID(undefined); + setFormError(''); navigateBack(); } @@ -165,6 +195,12 @@ function IOURequestStepDistanceRate({ shouldShowNotFoundPage={shouldShowNotFoundPage} > {translate('iou.chooseARate')} + {!!formError && ( + + )} { + describe('German locale (comma-decimal, dot-group)', () => { + const germanFromLocaleDigit = (char: string) => fromLocaleDigit('de', char); + + it("German '1,5' (one and a half) normalizes to '1.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1,5', germanFromLocaleDigit)).toBe('1.5'); + }); + + it("German '1.5' (fifteen, dot is group separator) normalizes to '15'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1.5', germanFromLocaleDigit)).toBe('15'); + }); + + it("German '1.234,5' (1234.5) normalizes to '1234.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1.234,5', germanFromLocaleDigit)).toBe('1234.5'); + }); + + it("German '9999999' with no separators normalizes correctly", () => { + expect(DistanceRequestUtils.normalizeOdometerText('9999999', germanFromLocaleDigit)).toBe('9999999'); + }); + }); + + describe('English locale (dot-decimal, comma-group)', () => { + const englishFromLocaleDigit = (char: string) => fromLocaleDigit('en', char); + + it("English '1.5' (one and a half) normalizes to '1.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1.5', englishFromLocaleDigit)).toBe('1.5'); + }); + + it("English '1,234.5' normalizes to '1234.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1,234.5', englishFromLocaleDigit)).toBe('1234.5'); + }); + + it("English '9999999' with no separators normalizes correctly", () => { + expect(DistanceRequestUtils.normalizeOdometerText('9999999', englishFromLocaleDigit)).toBe('9999999'); + }); + }); +});