From f6445bf3850e932c28c5afde820c3564f1c28479 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Thu, 19 Feb 2026 17:16:22 +0000 Subject: [PATCH 01/25] Add frontend validation for distance expense amounts exceeding backend limit Extreme odometer/distance values can produce amounts exceeding the backend WAF's 12-digit limit (999,999,999,999 cents). When this happens, the WAF silently strips the amount parameter, causing null amount exceptions during transaction merge. This adds client-side validation to all distance expense entry points: - Odometer page (IOURequestStepDistanceOdometer) - Manual distance page (IOURequestStepDistanceManual) - Confirmation list (MoneyRequestConfirmationList) for map/GPS/all types Also adds a new isDistanceAmountWithinLimit utility in DistanceRequestUtils and localized error messages in all 10 language files. --- src/CONST/index.ts | 1 + src/components/MoneyRequestConfirmationList.tsx | 5 +++++ src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/DistanceRequestUtils.ts | 14 ++++++++++++++ .../request/step/IOURequestStepDistanceManual.tsx | 14 +++++++++++--- .../step/IOURequestStepDistanceOdometer.tsx | 10 +++++++++- 15 files changed, 50 insertions(+), 4 deletions(-) mode change 100755 => 100644 src/CONST/index.ts mode change 100755 => 100644 src/components/MoneyRequestConfirmationList.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts old mode 100755 new mode 100644 index d428b495ffce..d2d3e17863bd --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3106,6 +3106,7 @@ const CONST = { }, AMOUNT_MAX_LENGTH: 10, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, + 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 c40a7d686c9e..183d238c2ebc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1315,6 +1315,7 @@ 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.', 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 b9ca3bf2ae74..7b76d6410fdc 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1324,6 +1324,7 @@ 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.', 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 cb9effeaf2d0..372c0e3c1c6f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1160,6 +1160,7 @@ 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.', 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 8aefa394b1d7..1a2c24dedfdb 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1319,6 +1319,7 @@ 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.', 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 af95665bb77c..f19dfe67546f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1312,6 +1312,7 @@ 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.', 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 374235af52e8..d997184e58d3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1306,6 +1306,7 @@ const translations: TranslationDeepObject = { invalidDistance: '続行する前に有効な距離を入力してください', invalidReadings: '開始値と終了値の両方を入力してください', negativeDistanceNotAllowed: '終了値は開始値より大きくなければなりません', + distanceAmountTooLarge: '合計金額が大きすぎます。距離を減らすか、レートを下げてください。', invalidIntegerAmount: '続行する前にドルの整数金額を入力してください', invalidTaxAmount: (amount: string) => `最大税額は${amount}です`, invalidSplit: '分割した金額の合計は合計金額と一致している必要があります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 797b519b7edd..5c69b4bd0ea1 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1311,6 +1311,7 @@ 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.', 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 dccb5c5324ab..48d3784dd11d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1310,6 +1310,7 @@ 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ę.', 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 9ba1fc93693b..4126503867bb 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1308,6 +1308,7 @@ 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.', 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 78d70ba5b30f..3b742e0e5a97 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1285,6 +1285,7 @@ const translations: TranslationDeepObject = { invalidDistance: '请在继续之前输入有效的距离', invalidReadings: '请输入起始读数和结束读数', negativeDistanceNotAllowed: '结束读数必须大于开始读数', + distanceAmountTooLarge: '总金额过大。请减少距离或降低费率。', invalidIntegerAmount: '请在继续之前输入一个整数美元金额', invalidTaxAmount: (amount: string) => `最高税额为 ${amount}`, invalidSplit: '拆分金额之和必须等于总金额', diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index b54bb9136048..16e5aa29aec4 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -418,6 +418,19 @@ 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 { + const amount = Math.abs(Math.round(distance * rate)); + return amount <= CONST.IOU.MAX_SAFE_AMOUNT; +} + export default { getDefaultMileageRate, getDistanceMerchant, @@ -436,6 +449,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 7fe39c1264bf..7ccc2b980dfd 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.distanceAmountTooLarge')); + 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 9287d20bab22..fcddcaac63a0 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -124,7 +124,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)); @@ -450,6 +452,12 @@ function IOURequestStepDistanceOdometer({ return; } + // Validation: Check that distance * rate doesn't exceed the backend's safe amount limit + if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distance, rate)) { + setFormError(translate('iou.error.distanceAmountTooLarge')); + return; + } + // When validation passes, call navigateToNextPage navigateToNextPage(); }; From b21e2a52038fda9e5385dbfc2324cc2b0239a388 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Thu, 19 Feb 2026 17:20:26 +0000 Subject: [PATCH 02/25] Fix: Apply Prettier formatting to Italian translations Changes single-quoted string with escaped apostrophe to double-quoted string in the distanceAmountTooLarge translation, matching Prettier's formatting rules. Co-authored-by: Neil Marcellini --- src/languages/it.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languages/it.ts b/src/languages/it.ts index f19dfe67546f..71b7cff281b6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1312,7 +1312,7 @@ 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.', + distanceAmountTooLarge: "L'importo totale è troppo alto. Riduci la distanza o abbassa la tariffa.", 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', From af8f3cd3d03626b8a4a453618d9d6e351e82b63b Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Fri, 20 Feb 2026 23:57:32 +0000 Subject: [PATCH 03/25] Add distance amount validation to rate edit flow When editing the rate on an existing distance expense, validate that the new rate combined with the existing distance does not exceed the backend WAF's 12-digit limit (999,999,999,999 cents). Shows the same distanceAmountTooLarge error message used in the creation flow. Co-authored-by: Neil Marcellini --- .../request/step/IOURequestStepDistanceRate.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 107d470f540b..9e6431ee24b8 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'; @@ -77,6 +78,8 @@ function IOURequestStepDistanceRate({ const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const [formError, setFormError] = useState(''); + const rates = DistanceRequestUtils.getMileageRates(policy, false, currentRateID); const sortedRates = useMemo(() => Object.values(rates).sort((a, b) => localeCompare(a.name ?? '', b.name ?? '')), [rates, localeCompare]); @@ -118,6 +121,16 @@ function IOURequestStepDistanceRate({ setMoneyRequestTaxRate(transactionID, taxRateExternalID ?? null, shouldUseTransactionDraft(action)); } + // Validate that the new rate combined with the existing distance doesn't exceed the backend limit + const newRate = rates[customUnitRateID]?.rate ?? 0; + const unit = DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); + const distanceInMeters = getDistanceInMeters(transaction, unit); + const distanceInUnits = DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit); + if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distanceInUnits, newRate)) { + setFormError(translate('iou.error.distanceAmountTooLarge')); + return; + } + if (currentRateID !== customUnitRateID) { setMoneyRequestDistanceRate(transactionID, customUnitRateID, policy, shouldUseTransactionDraft(action)); @@ -140,6 +153,7 @@ function IOURequestStepDistanceRate({ } } + setFormError(''); navigateBack(); } @@ -152,6 +166,7 @@ function IOURequestStepDistanceRate({ shouldShowNotFoundPage={shouldShowNotFoundPage} > {translate('iou.chooseARate')} + {!!formError && } Date: Fri, 20 Feb 2026 23:59:48 +0000 Subject: [PATCH 04/25] Fix: Format IOURequestStepDistanceRate.tsx to pass Prettier check Co-authored-by: Neil Marcellini --- src/pages/iou/request/step/IOURequestStepDistanceRate.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 9e6431ee24b8..a7d2a16d3129 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -166,7 +166,12 @@ function IOURequestStepDistanceRate({ shouldShowNotFoundPage={shouldShowNotFoundPage} > {translate('iou.chooseARate')} - {!!formError && } + {!!formError && ( + + )} Date: Sat, 21 Feb 2026 00:17:51 +0000 Subject: [PATCH 05/25] Fix rate selection UX: keep problematic rate visually selected on validation error When selecting a rate that causes the distance amount to exceed the backend limit, the page now keeps the problematic rate shown as selected (via local pendingRateID state) instead of reverting to the previously stored rate. This makes the error context clear to the user. Selecting a valid rate clears the error and navigates back normally. Hitting back without fixing leaves the server-stored rate unchanged. Also moved the validation check before tax state updates so that an invalid rate selection does not produce side effects on tax amount/code. Co-authored-by: Neil Marcellini --- .../step/IOURequestStepDistanceRate.tsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index a7d2a16d3129..2112b3261a44 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -79,6 +79,9 @@ function IOURequestStepDistanceRate({ 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]); @@ -89,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, @@ -106,6 +110,18 @@ 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; + const unit = DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); + const distanceInMeters = getDistanceInMeters(transaction, unit); + const distanceInUnits = DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit); + if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distanceInUnits, newRate)) { + setPendingRateID(customUnitRateID); + setFormError(translate('iou.error.distanceAmountTooLarge')); + return; + } + let taxAmount; let taxRateExternalID; if (shouldShowTax) { @@ -113,7 +129,6 @@ 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 taxPercentage = taxRateExternalID ? getTaxValue(policy, transaction, taxRateExternalID) : undefined; taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, taxableAmount, getCurrencyDecimals(rates[customUnitRateID].currency))); @@ -121,16 +136,6 @@ function IOURequestStepDistanceRate({ setMoneyRequestTaxRate(transactionID, taxRateExternalID ?? null, shouldUseTransactionDraft(action)); } - // Validate that the new rate combined with the existing distance doesn't exceed the backend limit - const newRate = rates[customUnitRateID]?.rate ?? 0; - const unit = DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); - const distanceInMeters = getDistanceInMeters(transaction, unit); - const distanceInUnits = DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit); - if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distanceInUnits, newRate)) { - setFormError(translate('iou.error.distanceAmountTooLarge')); - return; - } - if (currentRateID !== customUnitRateID) { setMoneyRequestDistanceRate(transactionID, customUnitRateID, policy, shouldUseTransactionDraft(action)); @@ -153,6 +158,7 @@ function IOURequestStepDistanceRate({ } } + setPendingRateID(undefined); setFormError(''); navigateBack(); } From a0d07794a8ff9340433f6ccb5d72c5af375be332 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sat, 21 Feb 2026 01:33:24 +0000 Subject: [PATCH 06/25] Use context-specific error messages for distance amount validation On distance pages (odometer/manual), show "Reduce the distance" only. On the rate page, show "Lower the rate" only. The confirmation page keeps the general message mentioning both. Co-authored-by: Neil Marcellini --- src/languages/de.ts | 2 ++ src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 2 ++ src/languages/it.ts | 2 ++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 2 ++ src/languages/pl.ts | 2 ++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 2 ++ src/pages/iou/request/step/IOURequestStepDistanceManual.tsx | 2 +- src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 2 +- src/pages/iou/request/step/IOURequestStepDistanceRate.tsx | 2 +- 13 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 183d238c2ebc..1c82fb4ce1ca 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1316,6 +1316,8 @@ const translations: TranslationDeepObject = { 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.', 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 7b76d6410fdc..f37240687c3f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1325,6 +1325,8 @@ const translations = { 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.', 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 372c0e3c1c6f..315f4aba6fe9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1161,6 +1161,8 @@ const translations: TranslationDeepObject = { 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.', 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 1a2c24dedfdb..7876dfd05937 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1320,6 +1320,8 @@ const translations: TranslationDeepObject = { 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.', 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 71b7cff281b6..bfbe63697dc4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1313,6 +1313,8 @@ const translations: TranslationDeepObject = { 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.", 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 d997184e58d3..7f131c2bfcc3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1307,6 +1307,8 @@ const translations: TranslationDeepObject = { invalidReadings: '開始値と終了値の両方を入力してください', negativeDistanceNotAllowed: '終了値は開始値より大きくなければなりません', distanceAmountTooLarge: '合計金額が大きすぎます。距離を減らすか、レートを下げてください。', + distanceAmountTooLargeReduceDistance: '合計金額が大きすぎます。距離を減らしてください。', + distanceAmountTooLargeReduceRate: '合計金額が大きすぎます。レートを下げてください。', invalidIntegerAmount: '続行する前にドルの整数金額を入力してください', invalidTaxAmount: (amount: string) => `最大税額は${amount}です`, invalidSplit: '分割した金額の合計は合計金額と一致している必要があります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5c69b4bd0ea1..5f9dbd788b0c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1312,6 +1312,8 @@ const translations: TranslationDeepObject = { 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.', 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 48d3784dd11d..f5ff3130eb14 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1311,6 +1311,8 @@ const translations: TranslationDeepObject = { 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ę.', 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 4126503867bb..8140005eca13 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1309,6 +1309,8 @@ const translations: TranslationDeepObject = { 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.', 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 3b742e0e5a97..f9dcabb9f096 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1286,6 +1286,8 @@ const translations: TranslationDeepObject = { invalidReadings: '请输入起始读数和结束读数', negativeDistanceNotAllowed: '结束读数必须大于开始读数', distanceAmountTooLarge: '总金额过大。请减少距离或降低费率。', + distanceAmountTooLargeReduceDistance: '总金额过大。请减少距离。', + distanceAmountTooLargeReduceRate: '总金额过大。请降低费率。', invalidIntegerAmount: '请在继续之前输入一个整数美元金额', invalidTaxAmount: (amount: string) => `最高税额为 ${amount}`, invalidSplit: '拆分金额之和必须等于总金额', diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 7ccc2b980dfd..17234360352d 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -278,7 +278,7 @@ function IOURequestStepDistanceManual({ // Validation: Check that distance * rate doesn't exceed the backend's safe amount limit if (!DistanceRequestUtils.isDistanceAmountWithinLimit(parseFloat(value), rate)) { - setFormError(translate('iou.error.distanceAmountTooLarge')); + setFormError(translate('iou.error.distanceAmountTooLargeReduceDistance')); return; } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index fcddcaac63a0..75788ae339cc 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -454,7 +454,7 @@ function IOURequestStepDistanceOdometer({ // Validation: Check that distance * rate doesn't exceed the backend's safe amount limit if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distance, rate)) { - setFormError(translate('iou.error.distanceAmountTooLarge')); + setFormError(translate('iou.error.distanceAmountTooLargeReduceDistance')); return; } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 2112b3261a44..9ca0f8f319d2 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -118,7 +118,7 @@ function IOURequestStepDistanceRate({ const distanceInUnits = DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit); if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distanceInUnits, newRate)) { setPendingRateID(customUnitRateID); - setFormError(translate('iou.error.distanceAmountTooLarge')); + setFormError(translate('iou.error.distanceAmountTooLargeReduceRate')); return; } From 95d35f984f69f124ffcfbd2d9fda74d43eb65767 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sat, 21 Feb 2026 02:34:22 +0000 Subject: [PATCH 07/25] Add maxLength limit to odometer reading inputs The odometer start/end TextInput fields had no character length limit, allowing users to enter unreasonably large values. Add CONST.IOU.ODOMETER_MAX_LENGTH (10 chars, supporting up to 9999999.99) and apply it to both odometer TextInput fields. Co-authored-by: Neil Marcellini --- src/CONST/index.ts | 1 + src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2cadda584115..549b3659ae60 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3156,6 +3156,7 @@ const CONST = { }, AMOUNT_MAX_LENGTH: 10, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, + ODOMETER_MAX_LENGTH: 10, MAX_SAFE_AMOUNT: 999999999999, RECEIPT_STATE: { SCAN_READY: 'SCANREADY', diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 1c8eb48f6bfc..4cb1d4e48151 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -485,6 +485,7 @@ function IOURequestStepDistanceOdometer({ onChangeText={handleStartReadingChange} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} + maxLength={CONST.IOU.ODOMETER_MAX_LENGTH} /> {!isEditing && ( @@ -533,6 +534,7 @@ function IOURequestStepDistanceOdometer({ onChangeText={handleEndReadingChange} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} + maxLength={CONST.IOU.ODOMETER_MAX_LENGTH} /> {!isEditing && ( From fd10da4469eaaa4cb2ff63077b158c105dfbde9f Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Fri, 20 Feb 2026 19:31:42 -0800 Subject: [PATCH 08/25] Add numeric max value validation for odometer readings maxLength alone is a character count limit, not a numeric limit. 9999999.99 is 10 characters but far exceeds 999,999. Add an explicit numeric check against ODOMETER_MAX_VALUE (999,999) in handleNext() and reduce ODOMETER_MAX_LENGTH from 10 to 9 as a UX guardrail. Co-authored-by: Cursor --- src/CONST/index.ts | 3 ++- src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + .../iou/request/step/IOURequestStepDistanceOdometer.tsx | 6 +++++- 12 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 549b3659ae60..2fbd80b027a8 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3156,7 +3156,8 @@ const CONST = { }, AMOUNT_MAX_LENGTH: 10, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, - ODOMETER_MAX_LENGTH: 10, + ODOMETER_MAX_LENGTH: 9, + ODOMETER_MAX_VALUE: 999999, MAX_SAFE_AMOUNT: 999999999999, RECEIPT_STATE: { SCAN_READY: 'SCANREADY', diff --git a/src/languages/de.ts b/src/languages/de.ts index b429990e6688..36f807bfb707 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1315,6 +1315,7 @@ const translations: TranslationDeepObject = { 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: 'Kilometerstände dürfen 999.999 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 9f54ea83392f..ed3b60a985dd 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1331,6 +1331,7 @@ const translations = { 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: 'Odometer readings cannot exceed 999,999.', 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 744958e1d07d..bca6c6b97ec3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1167,6 +1167,7 @@ const translations: TranslationDeepObject = { 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: 'Las lecturas del odómetro no pueden superar 999.999.', 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 0bfe3b548175..c76b0d0bf1a4 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1319,6 +1319,7 @@ const translations: TranslationDeepObject = { 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: 'Les lectures du compteur kilométrique ne peuvent pas dépasser 999 999.', 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 46cc16975882..38ed72cf50f2 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1312,6 +1312,7 @@ const translations: TranslationDeepObject = { 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: 'Le letture del contachilometri non possono superare 999.999.', 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 7232988b6f67..ae6db432cdd1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1306,6 +1306,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLarge: '合計金額が大きすぎます。距離を減らすか、レートを下げてください。', distanceAmountTooLargeReduceDistance: '合計金額が大きすぎます。距離を減らしてください。', distanceAmountTooLargeReduceRate: '合計金額が大きすぎます。レートを下げてください。', + odometerReadingTooLarge: 'オドメーターの読み取り値は999,999を超えることはできません。', invalidIntegerAmount: '続行する前にドルの整数金額を入力してください', invalidTaxAmount: (amount: string) => `最大税額は${amount}です`, invalidSplit: '分割した金額の合計は合計金額と一致している必要があります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e9507df937e0..5426d7039639 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1311,6 +1311,7 @@ const translations: TranslationDeepObject = { 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: 'Kilometertellerstanden mogen niet hoger zijn dan 999.999.', 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 87bad916df66..7a795f33513a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1310,6 +1310,7 @@ const translations: TranslationDeepObject = { 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: 'Odczyty licznika nie mogą przekraczać 999 999.', 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 ca2e98fbbc46..b5093e112e84 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1308,6 +1308,7 @@ const translations: TranslationDeepObject = { 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: 'As leituras do hodômetro não podem exceder 999.999.', 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 517516ef3a9c..f3eff125dce3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1285,6 +1285,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLarge: '总金额过大。请减少距离或降低费率。', distanceAmountTooLargeReduceDistance: '总金额过大。请减少距离。', distanceAmountTooLargeReduceRate: '总金额过大。请降低费率。', + odometerReadingTooLarge: '里程表读数不能超过999,999。', invalidIntegerAmount: '请在继续之前输入一个整数美元金额', invalidTaxAmount: (amount: string) => `最高税额为 ${amount}`, invalidSplit: '拆分金额之和必须等于总金额', diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 4cb1d4e48151..9ca49ac14d5a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -445,7 +445,11 @@ function IOURequestStepDistanceOdometer({ 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')); + return; + } + const distance = end - start; if (distance <= 0) { setFormError(translate('iou.error.negativeDistanceNotAllowed')); From 38e41cfa7907b60543ecba087ca0f0b1205f299a Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 06:42:49 -0800 Subject: [PATCH 09/25] Fix ODOMETER_MAX_VALUE to 999999.99 to match maxLength Co-authored-by: Cursor --- src/CONST/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2fbd80b027a8..a6fbd659c317 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3157,7 +3157,7 @@ const CONST = { AMOUNT_MAX_LENGTH: 10, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, ODOMETER_MAX_LENGTH: 9, - ODOMETER_MAX_VALUE: 999999, + ODOMETER_MAX_VALUE: 999999.99, MAX_SAFE_AMOUNT: 999999999999, RECEIPT_STATE: { SCAN_READY: 'SCANREADY', From 7d4d685d5930101631d8e480efab5be09cbbaa9a Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 06:56:25 -0800 Subject: [PATCH 10/25] Increase odometer max to 9,999,999.9 and allow commas in input Commercial vehicles can exceed 1M miles, so raise ODOMETER_MAX_VALUE to 9,999,999.9 with 1 decimal place. Commas are now stripped as thousand separators instead of converted to decimal points. ODOMETER_MAX_LENGTH raised to 11 (7 digits + 2 commas + dot + 1 decimal). Co-authored-by: Cursor --- src/CONST/index.ts | 4 ++-- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- .../request/step/IOURequestStepDistanceOdometer.tsx | 11 ++++++----- 12 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a6fbd659c317..5e634754b5d3 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3156,8 +3156,8 @@ const CONST = { }, AMOUNT_MAX_LENGTH: 10, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, - ODOMETER_MAX_LENGTH: 9, - ODOMETER_MAX_VALUE: 999999.99, + ODOMETER_MAX_LENGTH: 11, + ODOMETER_MAX_VALUE: 9999999.9, MAX_SAFE_AMOUNT: 999999999999, RECEIPT_STATE: { SCAN_READY: 'SCANREADY', diff --git a/src/languages/de.ts b/src/languages/de.ts index 36f807bfb707..810420ef2d76 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1315,7 +1315,7 @@ const translations: TranslationDeepObject = { 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: 'Kilometerstände dürfen 999.999 nicht überschreiten.', + odometerReadingTooLarge: 'Kilometerstände dürfen 9.999.999 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 ed3b60a985dd..451dd36a4a6c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1331,7 +1331,7 @@ const translations = { 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: 'Odometer readings cannot exceed 999,999.', + odometerReadingTooLarge: 'Odometer readings cannot exceed 9,999,999.', 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 bca6c6b97ec3..12675a994133 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1167,7 +1167,7 @@ const translations: TranslationDeepObject = { 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: 'Las lecturas del odómetro no pueden superar 999.999.', + odometerReadingTooLarge: 'Las lecturas del odómetro no pueden superar 9.999.999.', 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 c76b0d0bf1a4..5fc7fb95918c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1319,7 +1319,7 @@ const translations: TranslationDeepObject = { 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: 'Les lectures du compteur kilométrique ne peuvent pas dépasser 999 999.', + odometerReadingTooLarge: 'Les lectures du compteur kilométrique ne peuvent pas dépasser 9 999 999.', 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 38ed72cf50f2..10fa9a3ba2e5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1312,7 +1312,7 @@ const translations: TranslationDeepObject = { 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: 'Le letture del contachilometri non possono superare 999.999.', + odometerReadingTooLarge: 'Le letture del contachilometri non possono superare 9.999.999.', 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 ae6db432cdd1..fe26ecae3ff1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1306,7 +1306,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLarge: '合計金額が大きすぎます。距離を減らすか、レートを下げてください。', distanceAmountTooLargeReduceDistance: '合計金額が大きすぎます。距離を減らしてください。', distanceAmountTooLargeReduceRate: '合計金額が大きすぎます。レートを下げてください。', - odometerReadingTooLarge: 'オドメーターの読み取り値は999,999を超えることはできません。', + odometerReadingTooLarge: 'オドメーターの読み取り値は9,999,999を超えることはできません。', invalidIntegerAmount: '続行する前にドルの整数金額を入力してください', invalidTaxAmount: (amount: string) => `最大税額は${amount}です`, invalidSplit: '分割した金額の合計は合計金額と一致している必要があります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5426d7039639..d1c6691e0193 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1311,7 +1311,7 @@ const translations: TranslationDeepObject = { 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: 'Kilometertellerstanden mogen niet hoger zijn dan 999.999.', + odometerReadingTooLarge: 'Kilometertellerstanden mogen niet hoger zijn dan 9.999.999.', 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 7a795f33513a..5542c3e4cc92 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1310,7 +1310,7 @@ const translations: TranslationDeepObject = { 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: 'Odczyty licznika nie mogą przekraczać 999 999.', + odometerReadingTooLarge: 'Odczyty licznika nie mogą przekraczać 9 999 999.', 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 b5093e112e84..07559b5d3c23 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1308,7 +1308,7 @@ const translations: TranslationDeepObject = { 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: 'As leituras do hodômetro não podem exceder 999.999.', + odometerReadingTooLarge: 'As leituras do hodômetro não podem exceder 9.999.999.', 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 f3eff125dce3..c907331d5b31 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1285,7 +1285,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLarge: '总金额过大。请减少距离或降低费率。', distanceAmountTooLargeReduceDistance: '总金额过大。请减少距离。', distanceAmountTooLargeReduceRate: '总金额过大。请降低费率。', - odometerReadingTooLarge: '里程表读数不能超过999,999。', + odometerReadingTooLarge: '里程表读数不能超过9,999,999。', invalidIntegerAmount: '请在继续之前输入一个整数美元金额', invalidTaxAmount: (amount: string) => `最高税额为 ${amount}`, invalidSplit: '拆分金额之和必须等于总金额', diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 9ca49ac14d5a..7170d700ffd9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -274,17 +274,18 @@ function IOURequestStepDistanceOdometer({ })(); 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(',', '.'); + // Strip commas (thousand separators) for the stored value + cleaned = cleaned.replaceAll(',', ''); // Allow only one decimal point const parts = cleaned.split('.'); if (parts.length > 2) { cleaned = `${parts.at(0) ?? ''}.${parts.slice(1).join('')}`; } - // Don't allow decimal point at the start + // Limit to 1 decimal place + if (parts.length === 2 && parts.at(1) && (parts.at(1) ?? '').length > 1) { + cleaned = `${parts.at(0) ?? ''}.${(parts.at(1) ?? '').slice(0, 1)}`; + } if (cleaned.startsWith('.')) { cleaned = `0${cleaned}`; } From 6f568029ccf1f09483bb45698ea31d77c1cb8c4c Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 07:03:17 -0800 Subject: [PATCH 11/25] Add locale-aware number input for odometer readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use replaceAllDigits + fromLocaleDigit to convert locale-specific input to standard format (e.g., European "1.234,5" → "1234.5"). Update error messages to show the actual max of 9,999,999.9 with locale-appropriate decimal separators in all 10 languages. Co-authored-by: Cursor --- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- .../request/step/IOURequestStepDistanceOdometer.tsx | 13 +++++++------ 11 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 810420ef2d76..5eed4a7be218 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1315,7 +1315,7 @@ const translations: TranslationDeepObject = { 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: 'Kilometerstände dürfen 9.999.999 nicht überschreiten.', + odometerReadingTooLarge: 'Kilometerstände dürfen 9.999.999,9 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 451dd36a4a6c..3a73260c8455 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1331,7 +1331,7 @@ const translations = { 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: 'Odometer readings cannot exceed 9,999,999.', + odometerReadingTooLarge: 'Odometer readings cannot exceed 9,999,999.9.', 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 12675a994133..abd6ab708bd7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1167,7 +1167,7 @@ const translations: TranslationDeepObject = { 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: 'Las lecturas del odómetro no pueden superar 9.999.999.', + odometerReadingTooLarge: 'Las lecturas del odómetro no pueden superar 9.999.999,9.', 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 5fc7fb95918c..92e6bbc22f79 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1319,7 +1319,7 @@ const translations: TranslationDeepObject = { 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: 'Les lectures du compteur kilométrique ne peuvent pas dépasser 9 999 999.', + odometerReadingTooLarge: 'Les lectures du compteur kilométrique ne peuvent pas dépasser 9 999 999,9.', 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 10fa9a3ba2e5..a1bc91725a90 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1312,7 +1312,7 @@ const translations: TranslationDeepObject = { 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: 'Le letture del contachilometri non possono superare 9.999.999.', + odometerReadingTooLarge: 'Le letture del contachilometri non possono superare 9.999.999,9.', 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 fe26ecae3ff1..30740958e7ff 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1306,7 +1306,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLarge: '合計金額が大きすぎます。距離を減らすか、レートを下げてください。', distanceAmountTooLargeReduceDistance: '合計金額が大きすぎます。距離を減らしてください。', distanceAmountTooLargeReduceRate: '合計金額が大きすぎます。レートを下げてください。', - odometerReadingTooLarge: 'オドメーターの読み取り値は9,999,999を超えることはできません。', + odometerReadingTooLarge: 'オドメーターの読み取り値は9,999,999.9を超えることはできません。', invalidIntegerAmount: '続行する前にドルの整数金額を入力してください', invalidTaxAmount: (amount: string) => `最大税額は${amount}です`, invalidSplit: '分割した金額の合計は合計金額と一致している必要があります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d1c6691e0193..fa6c2ad4f7d5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1311,7 +1311,7 @@ const translations: TranslationDeepObject = { 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: 'Kilometertellerstanden mogen niet hoger zijn dan 9.999.999.', + odometerReadingTooLarge: 'Kilometertellerstanden mogen niet hoger zijn dan 9.999.999,9.', 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 5542c3e4cc92..7b1730489602 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1310,7 +1310,7 @@ const translations: TranslationDeepObject = { 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: 'Odczyty licznika nie mogą przekraczać 9 999 999.', + odometerReadingTooLarge: 'Odczyty licznika nie mogą przekraczać 9 999 999,9.', 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 07559b5d3c23..c240bbbb3eb4 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1308,7 +1308,7 @@ const translations: TranslationDeepObject = { 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: 'As leituras do hodômetro não podem exceder 9.999.999.', + odometerReadingTooLarge: 'As leituras do hodômetro não podem exceder 9.999.999,9.', 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 c907331d5b31..ddb682cddd2a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1285,7 +1285,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLarge: '总金额过大。请减少距离或降低费率。', distanceAmountTooLargeReduceDistance: '总金额过大。请减少距离。', distanceAmountTooLargeReduceRate: '总金额过大。请降低费率。', - odometerReadingTooLarge: '里程表读数不能超过9,999,999。', + odometerReadingTooLarge: '里程表读数不能超过9,999,999.9。', invalidIntegerAmount: '请在继续之前输入一个整数美元金额', invalidTaxAmount: (amount: string) => `最高税额为 ${amount}`, invalidSplit: '拆分金额之和必须等于总金额', diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 7170d700ffd9..fbe870d6e696 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -13,6 +13,7 @@ import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentU import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useLocalize from '@hooks/useLocalize'; +import {replaceAllDigits} from '@libs/MoneyRequestUtils'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; @@ -63,7 +64,7 @@ function IOURequestStepDistanceOdometer({ transaction, currentUserPersonalDetails, }: IOURequestStepDistanceOdometerProps) { - const {translate} = useLocalize(); + const {translate, fromLocaleDigit} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -274,16 +275,16 @@ function IOURequestStepDistanceOdometer({ })(); const cleanOdometerReading = (text: string): string => { - let cleaned = text.replaceAll(/[^0-9.,]/g, ''); - // Strip commas (thousand separators) for the stored value + // Convert locale-specific digits/separators to standard format (e.g., European "1.234,5" → "1,234.5") + let cleaned = replaceAllDigits(text, fromLocaleDigit); + cleaned = cleaned.replaceAll(/[^0-9.,]/g, ''); + // Strip group separators (commas in standard format) cleaned = cleaned.replaceAll(',', ''); - // Allow only one decimal point const parts = cleaned.split('.'); if (parts.length > 2) { cleaned = `${parts.at(0) ?? ''}.${parts.slice(1).join('')}`; } - // Limit to 1 decimal place - if (parts.length === 2 && parts.at(1) && (parts.at(1) ?? '').length > 1) { + if (parts.length === 2 && (parts.at(1) ?? '').length > 1) { cleaned = `${parts.at(0) ?? ''}.${(parts.at(1) ?? '').slice(0, 1)}`; } if (cleaned.startsWith('.')) { From e7dc018d31115d01ace96de6bcad8fead2d2c5c3 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 15:05:48 +0000 Subject: [PATCH 12/25] Fix: Sort imports to match Prettier configuration Co-authored-by: Neil Marcellini --- src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index fbe870d6e696..5a8a8a4a9517 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -13,7 +13,6 @@ import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentU import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useLocalize from '@hooks/useLocalize'; -import {replaceAllDigits} from '@libs/MoneyRequestUtils'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; @@ -29,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'; From dc5af8ba6cae091a4298888aa05773cdfa0ff14c Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 07:29:58 -0800 Subject: [PATCH 13/25] Auto-format odometer readings with thousand separators Display values with commas (e.g., 9,999,999.9) as the user types. Strip commas before all parseFloat calls so numeric operations work correctly. Fixes amount=0 bug caused by parseFloat stopping at commas. Co-authored-by: Cursor --- .../step/IOURequestStepDistanceOdometer.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 5a8a8a4a9517..f684ec614355 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -214,8 +214,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(startReading.replaceAll(',', '')); + const end = parseFloat(endReading.replaceAll(',', '')); if (Number.isNaN(start) || Number.isNaN(end) || !startReading || !endReading) { return null; } @@ -274,11 +274,9 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); - const cleanOdometerReading = (text: string): string => { - // Convert locale-specific digits/separators to standard format (e.g., European "1.234,5" → "1,234.5") + const stripToNumeric = (text: string): string => { let cleaned = replaceAllDigits(text, fromLocaleDigit); cleaned = cleaned.replaceAll(/[^0-9.,]/g, ''); - // Strip group separators (commas in standard format) cleaned = cleaned.replaceAll(',', ''); const parts = cleaned.split('.'); if (parts.length > 2) { @@ -293,6 +291,21 @@ function IOURequestStepDistanceOdometer({ return cleaned; }; + const formatWithCommas = (numericStr: string): string => { + if (!numericStr) { + return ''; + } + const parts = numericStr.split('.'); + const intPart = parts.at(0) ?? ''; + const decPart = parts.at(1); + const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return decPart !== undefined ? `${formatted}.${decPart}` : formatted; + }; + + const cleanOdometerReading = (text: string): string => { + return formatWithCommas(stripToNumeric(text)); + }; + const handleStartReadingChange = (text: string) => { const cleaned = cleanOdometerReading(text); setStartReading(cleaned); @@ -340,8 +353,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 = parseFloat(startReading.replaceAll(',', '')); + const end = parseFloat(endReading.replaceAll(',', '')); // Store odometer readings in transaction.comment.odometerStart/odometerEnd setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); @@ -439,8 +452,8 @@ function IOURequestStepDistanceOdometer({ return; } - const start = parseFloat(startReading); - const end = parseFloat(endReading); + const start = parseFloat(startReading.replaceAll(',', '')); + const end = parseFloat(endReading.replaceAll(',', '')); if (Number.isNaN(start) || Number.isNaN(end)) { setFormError(translate('iou.error.invalidReadings')); From 061ae929b39bc29c85ea3b11dca377329ecf906f Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 15:34:37 +0000 Subject: [PATCH 14/25] Fix: Use replaceAll instead of replace to satisfy ESLint unicorn/prefer-string-replace-all rule Co-authored-by: Neil Marcellini --- src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index f684ec614355..ccfc02569d0a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -298,7 +298,7 @@ function IOURequestStepDistanceOdometer({ const parts = numericStr.split('.'); const intPart = parts.at(0) ?? ''; const decPart = parts.at(1); - const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + const formatted = intPart.replaceAll(/\B(?=(\d{3})+(?!\d))/g, ','); return decPart !== undefined ? `${formatted}.${decPart}` : formatted; }; From 9b9cfddb0a89ed464106a098cc5be5b8dc25c9c7 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 07:38:10 -0800 Subject: [PATCH 15/25] Stop modifying odometer input, validate on submit instead Don't auto-format or restrict what the user types. Accept numbers, commas, and periods freely. Parse using locale-aware digit conversion on submit and validate the numeric value against ODOMETER_MAX_VALUE. Remove maxLength and ODOMETER_MAX_LENGTH since validation is now purely numeric. Co-authored-by: Cursor --- src/CONST/index.ts | 1 - .../step/IOURequestStepDistanceOdometer.tsx | 58 +++++-------------- 2 files changed, 14 insertions(+), 45 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5e634754b5d3..b68a39817599 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3156,7 +3156,6 @@ const CONST = { }, AMOUNT_MAX_LENGTH: 10, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, - ODOMETER_MAX_LENGTH: 11, ODOMETER_MAX_VALUE: 9999999.9, MAX_SAFE_AMOUNT: 999999999999, RECEIPT_STATE: { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index ccfc02569d0a..442234f92441 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -214,8 +214,8 @@ function IOURequestStepDistanceOdometer({ // Calculate total distance - updated live after every input change const totalDistance = (() => { - const start = parseFloat(startReading.replaceAll(',', '')); - const end = parseFloat(endReading.replaceAll(',', '')); + const start = parseOdometerReading(startReading); + const end = parseOdometerReading(endReading); if (Number.isNaN(start) || Number.isNaN(end) || !startReading || !endReading) { return null; } @@ -274,51 +274,23 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); - const stripToNumeric = (text: string): string => { - let cleaned = replaceAllDigits(text, fromLocaleDigit); - cleaned = cleaned.replaceAll(/[^0-9.,]/g, ''); - cleaned = cleaned.replaceAll(',', ''); - const parts = cleaned.split('.'); - if (parts.length > 2) { - cleaned = `${parts.at(0) ?? ''}.${parts.slice(1).join('')}`; - } - if (parts.length === 2 && (parts.at(1) ?? '').length > 1) { - cleaned = `${parts.at(0) ?? ''}.${(parts.at(1) ?? '').slice(0, 1)}`; - } - if (cleaned.startsWith('.')) { - cleaned = `0${cleaned}`; - } - return cleaned; - }; - - const formatWithCommas = (numericStr: string): string => { - if (!numericStr) { - return ''; - } - const parts = numericStr.split('.'); - const intPart = parts.at(0) ?? ''; - const decPart = parts.at(1); - const formatted = intPart.replaceAll(/\B(?=(\d{3})+(?!\d))/g, ','); - return decPart !== undefined ? `${formatted}.${decPart}` : formatted; - }; - - const cleanOdometerReading = (text: string): string => { - return formatWithCommas(stripToNumeric(text)); + const parseOdometerReading = (text: string): number => { + const standardized = replaceAllDigits(text, fromLocaleDigit); + const stripped = standardized.replaceAll(',', ''); + return parseFloat(stripped); }; const handleStartReadingChange = (text: string) => { - const cleaned = cleanOdometerReading(text); - setStartReading(cleaned); - startReadingRef.current = cleaned; + setStartReading(text); + startReadingRef.current = text; if (formError) { setFormError(''); } }; const handleEndReadingChange = (text: string) => { - const cleaned = cleanOdometerReading(text); - setEndReading(cleaned); - endReadingRef.current = cleaned; + setEndReading(text); + endReadingRef.current = text; if (formError) { setFormError(''); } @@ -353,8 +325,8 @@ function IOURequestStepDistanceOdometer({ const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); // Navigate to next page following Manual tab pattern const navigateToNextPage = () => { - const start = parseFloat(startReading.replaceAll(',', '')); - const end = parseFloat(endReading.replaceAll(',', '')); + const start = parseOdometerReading(startReading); + const end = parseOdometerReading(endReading); // Store odometer readings in transaction.comment.odometerStart/odometerEnd setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); @@ -452,8 +424,8 @@ function IOURequestStepDistanceOdometer({ return; } - const start = parseFloat(startReading.replaceAll(',', '')); - const end = parseFloat(endReading.replaceAll(',', '')); + const start = parseOdometerReading(startReading); + const end = parseOdometerReading(endReading); if (Number.isNaN(start) || Number.isNaN(end)) { setFormError(translate('iou.error.invalidReadings')); @@ -504,7 +476,6 @@ function IOURequestStepDistanceOdometer({ onChangeText={handleStartReadingChange} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} - maxLength={CONST.IOU.ODOMETER_MAX_LENGTH} /> {!isEditing && ( @@ -553,7 +524,6 @@ function IOURequestStepDistanceOdometer({ onChangeText={handleEndReadingChange} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} - maxLength={CONST.IOU.ODOMETER_MAX_LENGTH} /> {!isEditing && ( From 2872755abd0c00167dcd24864523728c03fe0fa3 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 15:43:42 +0000 Subject: [PATCH 16/25] Fix: Move parseOdometerReading definition before its usage The function was defined after it was used in the totalDistance calculation, causing TypeScript and ESLint errors (TS2448, TS2454, no-use-before-define). Co-authored-by: Neil Marcellini --- .../request/step/IOURequestStepDistanceOdometer.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 442234f92441..41df3f882e18 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -212,6 +212,12 @@ function IOURequestStepDistanceOdometer({ } }, [transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd, isEditing]); + const parseOdometerReading = (text: string): number => { + const standardized = replaceAllDigits(text, fromLocaleDigit); + const stripped = standardized.replaceAll(',', ''); + return parseFloat(stripped); + }; + // Calculate total distance - updated live after every input change const totalDistance = (() => { const start = parseOdometerReading(startReading); @@ -274,12 +280,6 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); - const parseOdometerReading = (text: string): number => { - const standardized = replaceAllDigits(text, fromLocaleDigit); - const stripped = standardized.replaceAll(',', ''); - return parseFloat(stripped); - }; - const handleStartReadingChange = (text: string) => { setStartReading(text); startReadingRef.current = text; From fa5d7036eb3457616020b7a3bd1ab1ef703b92e1 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 08:33:16 -0800 Subject: [PATCH 17/25] Reject odometer input that exceeds max value or decimal precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silently reject keystrokes that would make the parsed value exceed 9,999,999.9 or add more than 1 decimal place. The input stays unchanged — no modification of what the user typed, just refusal to accept characters that would produce an invalid value. Co-authored-by: Cursor --- .../step/IOURequestStepDistanceOdometer.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 41df3f882e18..121aafcff0ce 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -280,7 +280,30 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); + const isOdometerInputValid = (text: string): boolean => { + if (!text) { + return true; + } + const standardized = replaceAllDigits(text, fromLocaleDigit); + const stripped = standardized.replaceAll(/[^0-9.]/g, ''); + const parts = stripped.split('.'); + if (parts.length > 2) { + return false; + } + if (parts.length === 2 && (parts.at(1) ?? '').length > 1) { + return false; + } + const value = parseFloat(stripped); + if (!Number.isNaN(value) && value > CONST.IOU.ODOMETER_MAX_VALUE) { + return false; + } + return true; + }; + const handleStartReadingChange = (text: string) => { + if (!isOdometerInputValid(text)) { + return; + } setStartReading(text); startReadingRef.current = text; if (formError) { @@ -289,6 +312,9 @@ function IOURequestStepDistanceOdometer({ }; const handleEndReadingChange = (text: string) => { + if (!isOdometerInputValid(text)) { + return; + } setEndReading(text); endReadingRef.current = text; if (formError) { From f5f9d36aff0546b404bb9ca47d95440000fec4f1 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 08:56:51 -0800 Subject: [PATCH 18/25] Use locale-formatted number in odometer max error message Replace hardcoded "9,999,999.9" in all translations with a function that receives the formatted max value. The call site uses numberFormat from useLocalize to format ODOMETER_MAX_VALUE with the user's locale. Co-authored-by: Cursor --- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 4 ++-- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 5eed4a7be218..b3f46b5f223a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1315,7 +1315,7 @@ const translations: TranslationDeepObject = { 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: 'Kilometerstände dürfen 9.999.999,9 nicht überschreiten.', + 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 3a73260c8455..573b08fc2540 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1331,7 +1331,7 @@ const translations = { 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: 'Odometer readings cannot exceed 9,999,999.9.', + 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 abd6ab708bd7..e6d7dbb5a805 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1167,7 +1167,7 @@ const translations: TranslationDeepObject = { 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: 'Las lecturas del odómetro no pueden superar 9.999.999,9.', + 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 92e6bbc22f79..d63753b44a51 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1319,7 +1319,7 @@ const translations: TranslationDeepObject = { 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: 'Les lectures du compteur kilométrique ne peuvent pas dépasser 9 999 999,9.', + 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 a1bc91725a90..714ebdbb4fae 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1312,7 +1312,7 @@ const translations: TranslationDeepObject = { 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: 'Le letture del contachilometri non possono superare 9.999.999,9.', + 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 30740958e7ff..d675b53ee3e4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1306,7 +1306,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLarge: '合計金額が大きすぎます。距離を減らすか、レートを下げてください。', distanceAmountTooLargeReduceDistance: '合計金額が大きすぎます。距離を減らしてください。', distanceAmountTooLargeReduceRate: '合計金額が大きすぎます。レートを下げてください。', - odometerReadingTooLarge: 'オドメーターの読み取り値は9,999,999.9を超えることはできません。', + odometerReadingTooLarge: (formattedMax: string) => `オドメーターの読み取り値は${formattedMax}を超えることはできません。`, invalidIntegerAmount: '続行する前にドルの整数金額を入力してください', invalidTaxAmount: (amount: string) => `最大税額は${amount}です`, invalidSplit: '分割した金額の合計は合計金額と一致している必要があります', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fa6c2ad4f7d5..ce2300844773 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1311,7 +1311,7 @@ const translations: TranslationDeepObject = { 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: 'Kilometertellerstanden mogen niet hoger zijn dan 9.999.999,9.', + 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 7b1730489602..106929ac50ee 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1310,7 +1310,7 @@ const translations: TranslationDeepObject = { 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: 'Odczyty licznika nie mogą przekraczać 9 999 999,9.', + 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 c240bbbb3eb4..b55d8a69a9cb 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1308,7 +1308,7 @@ const translations: TranslationDeepObject = { 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: 'As leituras do hodômetro não podem exceder 9.999.999,9.', + 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 ddb682cddd2a..6f2163103458 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1285,7 +1285,7 @@ const translations: TranslationDeepObject = { distanceAmountTooLarge: '总金额过大。请减少距离或降低费率。', distanceAmountTooLargeReduceDistance: '总金额过大。请减少距离。', distanceAmountTooLargeReduceRate: '总金额过大。请降低费率。', - odometerReadingTooLarge: '里程表读数不能超过9,999,999.9。', + odometerReadingTooLarge: (formattedMax: string) => `里程表读数不能超过${formattedMax}。`, invalidIntegerAmount: '请在继续之前输入一个整数美元金额', invalidTaxAmount: (amount: string) => `最高税额为 ${amount}`, invalidSplit: '拆分金额之和必须等于总金额', diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 121aafcff0ce..80c4c9439566 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -64,7 +64,7 @@ function IOURequestStepDistanceOdometer({ transaction, currentUserPersonalDetails, }: IOURequestStepDistanceOdometerProps) { - const {translate, fromLocaleDigit} = useLocalize(); + const {translate, fromLocaleDigit, numberFormat} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -459,7 +459,7 @@ function IOURequestStepDistanceOdometer({ } if (start > CONST.IOU.ODOMETER_MAX_VALUE || end > CONST.IOU.ODOMETER_MAX_VALUE) { - setFormError(translate('iou.error.odometerReadingTooLarge')); + setFormError(translate('iou.error.odometerReadingTooLarge', numberFormat(CONST.IOU.ODOMETER_MAX_VALUE, {maximumFractionDigits: 1}))); return; } From 21d980cd35252955e97277c627c289abfea70f0c Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 09:07:22 -0800 Subject: [PATCH 19/25] fix style, blank line above comment --- src/pages/iou/request/step/IOURequestStepDistanceRate.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 9ca0f8f319d2..cbc55e41fab8 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -77,8 +77,8 @@ 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(); From aec809baef5e150b319326ec13cdeaf7dd52eec1 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 18:33:34 +0000 Subject: [PATCH 20/25] Consolidate odometer input normalization to fix parsing/validation mismatch Co-authored-by: Neil Marcellini --- .../step/IOURequestStepDistanceOdometer.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 80c4c9439566..554a018febd5 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -212,10 +212,19 @@ function IOURequestStepDistanceOdometer({ } }, [transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd, isEditing]); - const parseOdometerReading = (text: string): number => { + /** + * 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); - const stripped = standardized.replaceAll(',', ''); - return parseFloat(stripped); + return standardized.replaceAll(/[^0-9.]/g, ''); + }; + + const parseOdometerReading = (text: string): number => { + return parseFloat(normalizeOdometerText(text)); }; // Calculate total distance - updated live after every input change @@ -284,8 +293,7 @@ function IOURequestStepDistanceOdometer({ if (!text) { return true; } - const standardized = replaceAllDigits(text, fromLocaleDigit); - const stripped = standardized.replaceAll(/[^0-9.]/g, ''); + const stripped = normalizeOdometerText(text); const parts = stripped.split('.'); if (parts.length > 2) { return false; From 4cf43d9d1180eeefc2114b9a6ee5f7435bbef38e Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 18:34:01 +0000 Subject: [PATCH 21/25] Consolidate odometer text stripping into shared stripOdometerText helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isOdometerInputValid and parseOdometerReading previously used different stripping logic — the validator stripped all non-numeric characters while the parser only stripped commas. This meant pasted text like "1 234.5" would pass validation but parseFloat would return 1 instead of 1234.5. Extract a single stripOdometerText function that both use, so the stripping logic can never diverge. Co-authored-by: Neil Marcellini --- .../step/IOURequestStepDistanceOdometer.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 80c4c9439566..dcdc24df0548 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -212,10 +212,20 @@ function IOURequestStepDistanceOdometer({ } }, [transaction?.comment?.odometerStart, transaction?.comment?.odometerEnd, isEditing]); - const parseOdometerReading = (text: string): number => { + /** + * Converts locale-specific odometer text to a plain numeric string by + * standardizing digits and stripping every character that isn't a digit or + * decimal point. This is the single source of truth used by both input + * validation (`isOdometerInputValid`) and numeric parsing + * (`parseOdometerReading`) so the two can never diverge. + */ + const stripOdometerText = (text: string): string => { const standardized = replaceAllDigits(text, fromLocaleDigit); - const stripped = standardized.replaceAll(',', ''); - return parseFloat(stripped); + return standardized.replaceAll(/[^0-9.]/g, ''); + }; + + const parseOdometerReading = (text: string): number => { + return parseFloat(stripOdometerText(text)); }; // Calculate total distance - updated live after every input change @@ -284,8 +294,7 @@ function IOURequestStepDistanceOdometer({ if (!text) { return true; } - const standardized = replaceAllDigits(text, fromLocaleDigit); - const stripped = standardized.replaceAll(/[^0-9.]/g, ''); + const stripped = stripOdometerText(text); const parts = stripped.split('.'); if (parts.length > 2) { return false; From 187d021c40c65d1c9a82cd62593ab45ec5856185 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 20:16:49 +0000 Subject: [PATCH 22/25] Fix rate unit mismatch in distance validation and allow editing legacy over-max odometer values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the selected rate's unit (not the transaction's stale stored unit) when validating that distance × rate stays within the backend limit. Also allow users to edit down pre-populated odometer readings that already exceed the max value, while still preventing new input that increases beyond the limit. Co-authored-by: Neil Marcellini --- .../request/step/IOURequestStepDistanceOdometer.tsx | 13 +++++++++---- .../iou/request/step/IOURequestStepDistanceRate.tsx | 9 +++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 554a018febd5..df8c0befe136 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -289,7 +289,7 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); - const isOdometerInputValid = (text: string): boolean => { + const isOdometerInputValid = (text: string, previousText: string): boolean => { if (!text) { return true; } @@ -302,14 +302,19 @@ function IOURequestStepDistanceOdometer({ return false; } 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) { - return false; + const previousValue = parseFloat(normalizeOdometerText(previousText)); + if (Number.isNaN(previousValue) || value >= previousValue) { + return false; + } } return true; }; const handleStartReadingChange = (text: string) => { - if (!isOdometerInputValid(text)) { + if (!isOdometerInputValid(text, startReading)) { return; } setStartReading(text); @@ -320,7 +325,7 @@ function IOURequestStepDistanceOdometer({ }; const handleEndReadingChange = (text: string) => { - if (!isOdometerInputValid(text)) { + if (!isOdometerInputValid(text, endReading)) { return; } setEndReading(text); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index cbc55e41fab8..ff922bb56c11 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -113,9 +113,10 @@ function IOURequestStepDistanceRate({ // 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; - const unit = DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); - const distanceInMeters = getDistanceInMeters(transaction, unit); - const distanceInUnits = DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit); + const selectedRateUnit = rates[customUnitRateID]?.unit ?? DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); + const transactionUnit = DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); + const distanceInMeters = getDistanceInMeters(transaction, transactionUnit); + const distanceInUnits = DistanceRequestUtils.convertDistanceUnit(distanceInMeters, selectedRateUnit); if (!DistanceRequestUtils.isDistanceAmountWithinLimit(distanceInUnits, newRate)) { setPendingRateID(customUnitRateID); setFormError(translate('iou.error.distanceAmountTooLargeReduceRate')); @@ -129,7 +130,7 @@ function IOURequestStepDistanceRate({ const defaultTaxCode = getDefaultTaxCode(policy, transaction) ?? ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing taxRateExternalID = policyCustomUnitRate?.attributes?.taxRateExternalID || defaultTaxCode; - const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(transaction, unit)); + const taxableAmount = DistanceRequestUtils.getTaxableAmount(policy, customUnitRateID, getDistanceInMeters(transaction, transactionUnit)); const taxPercentage = taxRateExternalID ? getTaxValue(policy, transaction, taxRateExternalID) : undefined; taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, taxableAmount, getCurrencyDecimals(rates[customUnitRateID].currency))); setMoneyRequestTaxAmount(transactionID, taxAmount, shouldUseTransactionDraft(action)); From 07ed7b24cc35ecaf395904321a26c25ce3e43a92 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 20:19:30 +0000 Subject: [PATCH 23/25] Use selected rate unit for limit validation and allow editing legacy odometer values above max Co-authored-by: Neil Marcellini --- .../step/IOURequestStepDistanceOdometer.tsx | 9 +++++---- .../request/step/IOURequestStepDistanceRate.tsx | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 554a018febd5..4db6227e2037 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -289,6 +289,11 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); + // Per-keystroke validation: only enforce *format* constraints (number of + // decimal places, single decimal point, etc.). The max-value limit is + // intentionally NOT checked here so that legacy transactions pre-populated + // with a value above ODOMETER_MAX_VALUE can still be edited down via + // backspace. The max-value check is enforced at submit time in handleNext. const isOdometerInputValid = (text: string): boolean => { if (!text) { return true; @@ -301,10 +306,6 @@ function IOURequestStepDistanceOdometer({ if (parts.length === 2 && (parts.at(1) ?? '').length > 1) { return false; } - const value = parseFloat(stripped); - if (!Number.isNaN(value) && value > CONST.IOU.ODOMETER_MAX_VALUE) { - return false; - } return true; }; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index cbc55e41fab8..f536ec4e2b0b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -113,9 +113,17 @@ function IOURequestStepDistanceRate({ // 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; - const unit = DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); - const distanceInMeters = getDistanceInMeters(transaction, unit); - const distanceInUnits = DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit); + // 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')); @@ -129,7 +137,7 @@ function IOURequestStepDistanceRate({ const defaultTaxCode = getDefaultTaxCode(policy, transaction) ?? ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing taxRateExternalID = policyCustomUnitRate?.attributes?.taxRateExternalID || defaultTaxCode; - 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)); From e54e67813194717dbfe117ce30d6eb953518b543 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 14:31:02 -0800 Subject: [PATCH 24/25] Align isDistanceAmountWithinLimit rounding with getDistanceRequestAmount The limit check was using Math.round(distance * rate) directly, but getDistanceRequestAmount rounds the converted distance to 2 decimal places before multiplying by rate. This mismatch could produce off-by-one boundary cases where the limit check passes but the actual amount exceeds MAX_SAFE_AMOUNT, or vice versa. Co-authored-by: Cursor --- src/libs/DistanceRequestUtils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 16e5aa29aec4..0f80eaf92693 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -427,7 +427,11 @@ function getRateByCustomUnitRateID({customUnitRateID, policy}: {customUnitRateID * @returns true if the amount is within limits, false if it would exceed the backend limit */ function isDistanceAmountWithinLimit(distance: number, rate: number): boolean { - const amount = Math.abs(Math.round(distance * rate)); + // 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(distance.toFixed(2)); + const amount = Math.abs(Math.round(roundedDistance * rate)); return amount <= CONST.IOU.MAX_SAFE_AMOUNT; } From 608ecf7c6ccd6be48e8c3a9e8e6e85f11d353974 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 22:31:46 +0000 Subject: [PATCH 25/25] Fix: Align isDistanceAmountWithinLimit rounding with getDistanceRequestAmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round distance to 2 decimal places before multiplying by rate in the limit check, matching the rounding path in getDistanceRequestAmount(). Without this, unit-converted distances with more than 2 decimals could produce off-by-one boundary results — either passing an over-limit amount or rejecting a valid one near MAX_SAFE_AMOUNT. Co-authored-by: Neil Marcellini --- src/libs/DistanceRequestUtils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 16e5aa29aec4..1b0468e3dedc 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -427,7 +427,9 @@ function getRateByCustomUnitRateID({customUnitRateID, policy}: {customUnitRateID * @returns true if the amount is within limits, false if it would exceed the backend limit */ function isDistanceAmountWithinLimit(distance: number, rate: number): boolean { - const amount = Math.abs(Math.round(distance * rate)); + // Round distance to 2 decimals before multiplying, matching getDistanceRequestAmount() + const roundedDistance = parseFloat(Math.abs(distance).toFixed(2)); + const amount = Math.round(roundedDistance * rate); return amount <= CONST.IOU.MAX_SAFE_AMOUNT; }