From c2b08c37f03522ded72b7698a39f4d163198b308 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Thu, 19 Feb 2026 17:16:22 +0000 Subject: [PATCH 01/31] 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 e8d303b1b8e18..2cadda584115b --- 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, + 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 5a4b39e7c8946..dcb4c1f7fc613 --- 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 2a8b251db17b1..048a777133012 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1312,6 +1312,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 d5c0aeb138f6e..48bcb91144d51 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1328,6 +1328,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 dede4ccad19e0..7bd5440e25deb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1164,6 +1164,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 624014061be15..7d2257ba16b7e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1316,6 +1316,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 16f35a7e1ba35..61db8b5a689fe 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1309,6 +1309,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 aa873fba0bb70..4f49ed59a95c3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1303,6 +1303,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 d064fdd6e68d9..d68f7bf662737 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1308,6 +1308,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 26711f3d65a54..8944d356cad6a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1307,6 +1307,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 fa25aaeefb9e5..77bbec3c83df0 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1305,6 +1305,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 d9b83ec742c8e..406c47fc664ce 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1282,6 +1282,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 b54bb9136048f..16e5aa29aec48 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 77a93711502e7..d78ee627897ad 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 5362bc5c58710..a9e939b76f7fb 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 44f1f8455b3fb7fdfdf9ba847344fea291fbfe65 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Thu, 19 Feb 2026 17:20:26 +0000 Subject: [PATCH 02/31] 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 61db8b5a689fe..fc4be7747c251 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1309,7 +1309,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 06602cb23ce96e794b7f00b649b7cd05c254b99f Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Fri, 20 Feb 2026 23:57:32 +0000 Subject: [PATCH 03/31] 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 107d470f540b4..9e6431ee24b81 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/31] 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 9e6431ee24b81..a7d2a16d3129e 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/31] 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 a7d2a16d3129e..2112b3261a440 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 cc6427ff19146da9e057cd2990d30f6a01e725f6 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sat, 21 Feb 2026 01:33:24 +0000 Subject: [PATCH 06/31] 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 048a777133012..b429990e66882 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1313,6 +1313,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 48bcb91144d51..9f54ea83392f8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1329,6 +1329,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 7bd5440e25deb..744958e1d07d6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1165,6 +1165,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 7d2257ba16b7e..0bfe3b5481759 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1317,6 +1317,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 fc4be7747c251..46cc169758825 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1310,6 +1310,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 4f49ed59a95c3..7232988b6f671 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1304,6 +1304,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 d68f7bf662737..e9507df937e08 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1309,6 +1309,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 8944d356cad6a..87bad916df66a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1308,6 +1308,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 77bbec3c83df0..ca2e98fbbc46d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1306,6 +1306,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 406c47fc664ce..517516ef3a9cb 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1283,6 +1283,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 d78ee627897ad..a35580d22c72c 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 a9e939b76f7fb..1c8eb48f6bfc7 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 2112b3261a440..9ca0f8f319d23 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 9e200d6a3e894cf91a3374226cbb36669cd22525 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sat, 21 Feb 2026 02:34:22 +0000 Subject: [PATCH 07/31] 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 2cadda584115b..549b3659ae601 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 1c8eb48f6bfc7..4cb1d4e481510 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 00fec932cc0eff71686497e0f2af6c82b5d8857f Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Fri, 20 Feb 2026 19:31:42 -0800 Subject: [PATCH 08/31] 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 549b3659ae601..2fbd80b027a86 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 b429990e66882..36f807bfb7073 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 9f54ea83392f8..ed3b60a985dd1 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 744958e1d07d6..bca6c6b97ec32 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 0bfe3b5481759..c76b0d0bf1a4f 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 46cc169758825..38ed72cf50f22 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 7232988b6f671..ae6db432cdd16 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 e9507df937e08..5426d70396399 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 87bad916df66a..7a795f33513a4 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 ca2e98fbbc46d..b5093e112e845 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 517516ef3a9cb..f3eff125dce3c 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 4cb1d4e481510..9ca49ac14d5ab 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 7f7af9ba64f321aedb08922ffb9117904a780913 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 06:42:49 -0800 Subject: [PATCH 09/31] 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 2fbd80b027a86..a6fbd659c317f 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 622bdbff5f1609e0da0bf6226b9ff199ba4d3985 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 06:56:25 -0800 Subject: [PATCH 10/31] 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 a6fbd659c317f..5e634754b5d36 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 36f807bfb7073..810420ef2d760 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 ed3b60a985dd1..451dd36a4a6ce 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 bca6c6b97ec32..12675a9941338 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 c76b0d0bf1a4f..5fc7fb95918cb 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 38ed72cf50f22..10fa9a3ba2e5e 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 ae6db432cdd16..fe26ecae3ff16 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 5426d70396399..d1c6691e01935 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 7a795f33513a4..5542c3e4cc928 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 b5093e112e845..07559b5d3c23d 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 f3eff125dce3c..c907331d5b310 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 9ca49ac14d5ab..7170d700ffd90 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 e0e5233a6f2f5bc562221296b2835c920f8023b4 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 07:03:17 -0800 Subject: [PATCH 11/31] 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 810420ef2d760..5eed4a7be2181 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 451dd36a4a6ce..3a73260c84556 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 12675a9941338..abd6ab708bd74 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 5fc7fb95918cb..92e6bbc22f79a 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 10fa9a3ba2e5e..a1bc91725a90a 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 fe26ecae3ff16..30740958e7ff3 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 d1c6691e01935..fa6c2ad4f7d52 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 5542c3e4cc928..7b17304896021 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 07559b5d3c23d..c240bbbb3eb4b 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 c907331d5b310..ddb682cddd2a0 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 7170d700ffd90..fbe870d6e6969 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 1984b15af0462ab2da589961e38d3b6eaf5c12fe Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 15:05:48 +0000 Subject: [PATCH 12/31] 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 fbe870d6e6969..5a8a8a4a95179 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 f6854f818941b30800a20d220a6f63afd0e909d7 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 07:29:58 -0800 Subject: [PATCH 13/31] 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 5a8a8a4a95179..f684ec6143559 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 5153dfde26017c9d834ddf6b602e9a7eb77fc852 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 15:34:37 +0000 Subject: [PATCH 14/31] 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 f684ec6143559..ccfc02569d0ae 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 778bc6615c8bc9377743212ce6b95e05d87daeae Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 07:38:10 -0800 Subject: [PATCH 15/31] 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 5e634754b5d36..b68a39817599b 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 ccfc02569d0ae..442234f924417 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 72531d68593e167bdf21eb60cab1e73edec98b19 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 15:43:42 +0000 Subject: [PATCH 16/31] 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 442234f924417..41df3f882e18e 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 7ffcc5cf3b958b5d9231e84c8689b1d8d8303945 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 08:33:16 -0800 Subject: [PATCH 17/31] 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 41df3f882e18e..121aafcff0ce5 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 68125c053e0ac8875188818bde27ddd3f0410203 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 08:56:51 -0800 Subject: [PATCH 18/31] 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 5eed4a7be2181..b3f46b5f223ae 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 3a73260c84556..573b08fc25403 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 abd6ab708bd74..e6d7dbb5a805c 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 92e6bbc22f79a..d63753b44a51a 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 a1bc91725a90a..714ebdbb4faeb 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 30740958e7ff3..d675b53ee3e4c 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 fa6c2ad4f7d52..ce23008447739 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 7b17304896021..106929ac50ee4 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 c240bbbb3eb4b..b55d8a69a9cba 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 ddb682cddd2a0..6f21631034581 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 121aafcff0ce5..80c4c9439566a 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 1645101e49fd5cc687ae2d65817c1b7ce28e6ca5 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 09:07:22 -0800 Subject: [PATCH 19/31] 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 9ca0f8f319d23..cbc55e41fab84 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 6d7a7ac94d488c5e09ff9a953a414f2ae7c2a664 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 18:33:34 +0000 Subject: [PATCH 20/31] 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 80c4c9439566a..554a018febd5e 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 f0a1c149d5899af005e4d3baf78b671b6dc68bf2 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Sun, 22 Feb 2026 20:16:49 +0000 Subject: [PATCH 21/31] 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 554a018febd5e..df8c0befe1360 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 cbc55e41fab84..ff922bb56c117 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 a657bc976bcf4d46ab6ddf10390b21f5ea55554d Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 14:31:02 -0800 Subject: [PATCH 22/31] 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 16e5aa29aec48..0f80eaf926936 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 10e1718f103e3e0de327f24500babd324ab8ea84 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 14:44:35 -0800 Subject: [PATCH 23/31] Reconcile duplicate Melvin commit differences The original branch had two parallel Melvin runs that produced duplicate commits with slightly different implementations, reconciled via merge commits. This brings the code to the same final state after removing those duplicates and merges. Co-authored-by: Cursor --- Mobile-Expensify | 2 +- .../step/IOURequestStepDistanceOdometer.tsx | 5 +++++ .../request/step/IOURequestStepDistanceRate.tsx | 15 +++++++++++---- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 08338ebdf8069..4d48c50328996 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 08338ebdf8069ec3da78de81de7541a8058202a3 +Subproject commit 4d48c50328996a798eb2263e1c13acc83d1be2e1 diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index df8c0befe1360..091a5eefe18a7 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: enforce format constraints and cap the max value. + // The max-value check allows edits that *reduce* the value (e.g. backspacing + // a legacy over-max reading) but rejects keystrokes that would increase + // beyond ODOMETER_MAX_VALUE. Submit-time validation in handleNext is the + // final safety net. const isOdometerInputValid = (text: string, previousText: string): boolean => { if (!text) { return true; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index ff922bb56c117..f536ec4e2b0b5 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -113,9 +113,16 @@ 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 selectedRateUnit = rates[customUnitRateID]?.unit ?? DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); - const transactionUnit = DistanceRequestUtils.getDistanceUnit(transaction, rates[customUnitRateID]); - const distanceInMeters = getDistanceInMeters(transaction, transactionUnit); + // 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); @@ -130,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, transactionUnit)); + 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 7d070baecbc3da5b58437100c7c798cb3b0d569e Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Sun, 22 Feb 2026 15:12:53 -0800 Subject: [PATCH 24/31] Fix locale decimal fallback in odometer normalization and revert Mobile-Expensify In comma-decimal locales (e.g. German), fromLocaleDigit converts '.' to ',' (treating it as a thousands separator). If a web user types '.' as a decimal, it gets stripped and '1.5' becomes '15'. Fix by treating comma as decimal fallback when no dot is present after locale conversion. Also reverts the accidental Mobile-Expensify submodule pointer change. Co-authored-by: Cursor --- Mobile-Expensify | 2 +- .../request/step/IOURequestStepDistanceOdometer.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 4d48c50328996..08338ebdf8069 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 4d48c50328996a798eb2263e1c13acc83d1be2e1 +Subproject commit 08338ebdf8069ec3da78de81de7541a8058202a3 diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 091a5eefe18a7..9b20275bc4586 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -220,7 +220,15 @@ function IOURequestStepDistanceOdometer({ */ const normalizeOdometerText = (text: string): string => { const standardized = replaceAllDigits(text, fromLocaleDigit); - return standardized.replaceAll(/[^0-9.]/g, ''); + const stripped = standardized.replaceAll(/[^0-9.,]/g, ''); + // After locale conversion '.' is decimal and ',' is group separator. + // When no dot is present, treat comma as decimal fallback — handles + // users on web keyboards typing '.' in comma-decimal locales, where + // fromLocaleDigit converts the dot to a comma. + if (!stripped.includes('.') && stripped.includes(',')) { + return stripped.replaceAll(',', '.'); + } + return stripped.replaceAll(',', ''); }; const parseOdometerReading = (text: string): number => { From 54e21d14048c6bc6845e76b5c97694fd401e5a60 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Wed, 25 Feb 2026 14:33:13 -0800 Subject: [PATCH 25/31] Revert comma-decimal fallback and add locale normalization test The comma-as-decimal fallback in normalizeOdometerText (from 7d070ba) was incorrect. In German locale, '.' is the group separator and ',' is the decimal separator, so German "1.5" correctly means 15 (not 1.5). The fallback was misinterpreting the converted group separator as a decimal, breaking locale correctness. Reverts to the original simple regex that strips everything except digits and the standard decimal point after fromLocaleDigit conversion. Adds a unit test confirming correct behavior for both German and English locales. Made-with: Cursor --- .../step/IOURequestStepDistanceOdometer.tsx | 17 ++----- tests/unit/OdometerNormalizationTest.ts | 50 +++++++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 tests/unit/OdometerNormalizationTest.ts diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 9b20275bc4586..2628b05ae1243 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -214,21 +214,14 @@ function IOURequestStepDistanceOdometer({ /** * 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. + * non-numeric characters except the decimal point. fromLocaleDigit converts + * each locale character to its standard equivalent (e.g. German ',' → '.' + * for decimal, German '.' → ',' for group separator), then we keep only + * digits and the standard decimal point. */ const normalizeOdometerText = (text: string): string => { const standardized = replaceAllDigits(text, fromLocaleDigit); - const stripped = standardized.replaceAll(/[^0-9.,]/g, ''); - // After locale conversion '.' is decimal and ',' is group separator. - // When no dot is present, treat comma as decimal fallback — handles - // users on web keyboards typing '.' in comma-decimal locales, where - // fromLocaleDigit converts the dot to a comma. - if (!stripped.includes('.') && stripped.includes(',')) { - return stripped.replaceAll(',', '.'); - } - return stripped.replaceAll(',', ''); + return standardized.replaceAll(/[^0-9.]/g, ''); }; const parseOdometerReading = (text: string): number => { diff --git a/tests/unit/OdometerNormalizationTest.ts b/tests/unit/OdometerNormalizationTest.ts new file mode 100644 index 0000000000000..2f4f56894a9de --- /dev/null +++ b/tests/unit/OdometerNormalizationTest.ts @@ -0,0 +1,50 @@ +import {fromLocaleDigit} from '@libs/LocaleDigitUtils'; +import {replaceAllDigits} from '@libs/MoneyRequestUtils'; + +/** + * Replicates the normalizeOdometerText logic from IOURequestStepDistanceOdometer. + * fromLocaleDigit converts each locale character to its standard equivalent, + * then we strip everything except digits and the standard decimal point '.'. + */ +function normalizeOdometerText(text: string, localeFromLocaleDigit: (char: string) => string): string { + const standardized = replaceAllDigits(text, localeFromLocaleDigit); + return standardized.replaceAll(/[^0-9.]/g, ''); +} + +describe('Odometer normalization respects locale conventions', () => { + describe('German locale (comma-decimal, dot-group)', () => { + const germanFromLocaleDigit = (char: string) => fromLocaleDigit('de', char); + + it("German '1,5' (one and a half) normalizes to '1.5'", () => { + expect(normalizeOdometerText('1,5', germanFromLocaleDigit)).toBe('1.5'); + }); + + it("German '1.5' (fifteen, dot is group separator) normalizes to '15'", () => { + expect(normalizeOdometerText('1.5', germanFromLocaleDigit)).toBe('15'); + }); + + it("German '1.234,5' (1234.5) normalizes to '1234.5'", () => { + expect(normalizeOdometerText('1.234,5', germanFromLocaleDigit)).toBe('1234.5'); + }); + + it("German '9999999' with no separators normalizes correctly", () => { + expect(normalizeOdometerText('9999999', germanFromLocaleDigit)).toBe('9999999'); + }); + }); + + describe('English locale (dot-decimal, comma-group)', () => { + const englishFromLocaleDigit = (char: string) => fromLocaleDigit('en', char); + + it("English '1.5' (one and a half) normalizes to '1.5'", () => { + expect(normalizeOdometerText('1.5', englishFromLocaleDigit)).toBe('1.5'); + }); + + it("English '1,234.5' normalizes to '1234.5'", () => { + expect(normalizeOdometerText('1,234.5', englishFromLocaleDigit)).toBe('1234.5'); + }); + + it("English '9999999' with no separators normalizes correctly", () => { + expect(normalizeOdometerText('9999999', englishFromLocaleDigit)).toBe('9999999'); + }); + }); +}); From 244cd7cddd3f11cf24a0b9eaf954100f913f6555 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Wed, 25 Feb 2026 15:10:29 -0800 Subject: [PATCH 26/31] Reset Mobile-Expensify submodule pointer to match main Made-with: Cursor --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 4d48c50328996..b8486d2e7fcaa 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 4d48c50328996a798eb2263e1c13acc83d1be2e1 +Subproject commit b8486d2e7fcaa704c6dc4093b0f8f8a557be4567 From e88991fee2ed1bd993fcec8db7021e3c9756890d Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Wed, 25 Feb 2026 15:13:02 -0800 Subject: [PATCH 27/31] Extract normalizeOdometerText to DistanceRequestUtils and test the real function Move normalizeOdometerText from a local component function into DistanceRequestUtils so the unit test imports and tests the actual production code instead of a replica. Made-with: Cursor --- src/libs/DistanceRequestUtils.ts | 14 +++++++++ .../step/IOURequestStepDistanceOdometer.tsx | 17 ++--------- tests/unit/OdometerNormalizationTest.ts | 30 +++++++------------ 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 0f80eaf926936..d49c92919f920 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -6,6 +6,7 @@ import type {LastSelectedDistanceRates, OnyxInputOrEntry, Transaction} from '@sr import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {replaceAllDigits} from './MoneyRequestUtils'; // This will be fixed as part of https://github.com/Expensify/App/issues/66397 // eslint-disable-next-line @typescript-eslint/no-deprecated import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getPersonalPolicy, getUnitRateValue} from './PolicyUtils'; @@ -435,6 +436,18 @@ function isDistanceAmountWithinLimit(distance: number, rate: number): boolean { return amount <= CONST.IOU.MAX_SAFE_AMOUNT; } +/** + * Normalize odometer text by standardizing locale digits and stripping all + * non-numeric characters except the decimal point. fromLocaleDigit converts + * each locale character to its standard equivalent (e.g. German ',' → '.' + * for decimal, German '.' → ',' for group separator), then we keep only + * digits and the standard decimal point. + */ +function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => string): string { + const standardized = replaceAllDigits(text, fromLocaleDigit); + return standardized.replaceAll(/[^0-9.]/g, ''); +} + export default { getDefaultMileageRate, getDistanceMerchant, @@ -454,6 +467,7 @@ export default { getDistanceForDisplayLabel, convertDistanceUnit, isDistanceAmountWithinLimit, + normalizeOdometerText, }; export type {MileageRate}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 39f07150e8c35..f92e30ecb041c 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -30,7 +30,6 @@ import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; 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'; @@ -222,21 +221,9 @@ function IOURequestStepDistanceOdometer({ } }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing]); - /** - * Normalize odometer text by standardizing locale digits and stripping all - * non-numeric characters except the decimal point. fromLocaleDigit converts - * each locale character to its standard equivalent (e.g. German ',' → '.' - * for decimal, German '.' → ',' for group separator), then we keep only - * digits and the standard decimal point. - */ - const normalizeOdometerText = (text: string): string => { - const standardized = replaceAllDigits(text, fromLocaleDigit); - return standardized.replaceAll(/[^0-9.]/g, ''); - }; + const normalizeOdometerText = (text: string): string => DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); - const parseOdometerReading = (text: string): number => { - return parseFloat(normalizeOdometerText(text)); - }; + const parseOdometerReading = (text: string): number => parseFloat(normalizeOdometerText(text)); // Calculate total distance - updated live after every input change const totalDistance = (() => { diff --git a/tests/unit/OdometerNormalizationTest.ts b/tests/unit/OdometerNormalizationTest.ts index 2f4f56894a9de..85180cedfa076 100644 --- a/tests/unit/OdometerNormalizationTest.ts +++ b/tests/unit/OdometerNormalizationTest.ts @@ -1,34 +1,24 @@ +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {fromLocaleDigit} from '@libs/LocaleDigitUtils'; -import {replaceAllDigits} from '@libs/MoneyRequestUtils'; - -/** - * Replicates the normalizeOdometerText logic from IOURequestStepDistanceOdometer. - * fromLocaleDigit converts each locale character to its standard equivalent, - * then we strip everything except digits and the standard decimal point '.'. - */ -function normalizeOdometerText(text: string, localeFromLocaleDigit: (char: string) => string): string { - const standardized = replaceAllDigits(text, localeFromLocaleDigit); - return standardized.replaceAll(/[^0-9.]/g, ''); -} - -describe('Odometer normalization respects locale conventions', () => { + +describe('normalizeOdometerText', () => { describe('German locale (comma-decimal, dot-group)', () => { const germanFromLocaleDigit = (char: string) => fromLocaleDigit('de', char); it("German '1,5' (one and a half) normalizes to '1.5'", () => { - expect(normalizeOdometerText('1,5', germanFromLocaleDigit)).toBe('1.5'); + expect(DistanceRequestUtils.normalizeOdometerText('1,5', germanFromLocaleDigit)).toBe('1.5'); }); it("German '1.5' (fifteen, dot is group separator) normalizes to '15'", () => { - expect(normalizeOdometerText('1.5', germanFromLocaleDigit)).toBe('15'); + expect(DistanceRequestUtils.normalizeOdometerText('1.5', germanFromLocaleDigit)).toBe('15'); }); it("German '1.234,5' (1234.5) normalizes to '1234.5'", () => { - expect(normalizeOdometerText('1.234,5', germanFromLocaleDigit)).toBe('1234.5'); + expect(DistanceRequestUtils.normalizeOdometerText('1.234,5', germanFromLocaleDigit)).toBe('1234.5'); }); it("German '9999999' with no separators normalizes correctly", () => { - expect(normalizeOdometerText('9999999', germanFromLocaleDigit)).toBe('9999999'); + expect(DistanceRequestUtils.normalizeOdometerText('9999999', germanFromLocaleDigit)).toBe('9999999'); }); }); @@ -36,15 +26,15 @@ describe('Odometer normalization respects locale conventions', () => { const englishFromLocaleDigit = (char: string) => fromLocaleDigit('en', char); it("English '1.5' (one and a half) normalizes to '1.5'", () => { - expect(normalizeOdometerText('1.5', englishFromLocaleDigit)).toBe('1.5'); + expect(DistanceRequestUtils.normalizeOdometerText('1.5', englishFromLocaleDigit)).toBe('1.5'); }); it("English '1,234.5' normalizes to '1234.5'", () => { - expect(normalizeOdometerText('1,234.5', englishFromLocaleDigit)).toBe('1234.5'); + expect(DistanceRequestUtils.normalizeOdometerText('1,234.5', englishFromLocaleDigit)).toBe('1234.5'); }); it("English '9999999' with no separators normalizes correctly", () => { - expect(normalizeOdometerText('9999999', englishFromLocaleDigit)).toBe('9999999'); + expect(DistanceRequestUtils.normalizeOdometerText('9999999', englishFromLocaleDigit)).toBe('9999999'); }); }); }); From 75775c01deb46f1a09073f0719652555ff2041b2 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Wed, 25 Feb 2026 15:22:10 -0800 Subject: [PATCH 28/31] Remove unnecessary wrapper functions in odometer component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline normalizeOdometerText and parseOdometerReading — they were trivial one-liner wrappers around DistanceRequestUtils calls. Made-with: Cursor --- .../step/IOURequestStepDistanceOdometer.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index f92e30ecb041c..23e8d5b0187a0 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -221,14 +221,10 @@ function IOURequestStepDistanceOdometer({ } }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing]); - const normalizeOdometerText = (text: string): string => DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); - - const parseOdometerReading = (text: string): number => parseFloat(normalizeOdometerText(text)); - // Calculate total distance - updated live after every input change const totalDistance = (() => { - const start = parseOdometerReading(startReading); - const end = parseOdometerReading(endReading); + const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); + const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); if (Number.isNaN(start) || Number.isNaN(end) || !startReading || !endReading) { return null; } @@ -296,7 +292,7 @@ function IOURequestStepDistanceOdometer({ if (!text) { return true; } - const stripped = normalizeOdometerText(text); + const stripped = DistanceRequestUtils.normalizeOdometerText(text, fromLocaleDigit); const parts = stripped.split('.'); if (parts.length > 2) { return false; @@ -308,7 +304,7 @@ function IOURequestStepDistanceOdometer({ // Allow edits that reduce the value (e.g. backspacing a legacy over-max reading), // but reject keystrokes that would increase beyond the max. if (!Number.isNaN(value) && value > CONST.IOU.ODOMETER_MAX_VALUE) { - const previousValue = parseFloat(normalizeOdometerText(previousText)); + const previousValue = parseFloat(DistanceRequestUtils.normalizeOdometerText(previousText, fromLocaleDigit)); if (Number.isNaN(previousValue) || value >= previousValue) { return false; } @@ -368,8 +364,8 @@ function IOURequestStepDistanceOdometer({ const icons = useMemoizedLazyExpensifyIcons(['GalleryPlus'] as const); // Navigate to next page following Manual tab pattern const navigateToNextPage = () => { - const start = parseOdometerReading(startReading); - const end = parseOdometerReading(endReading); + const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); + const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); // Store odometer readings in transaction.comment.odometerStart/odometerEnd setMoneyRequestOdometerReading(transactionID, start, end, isTransactionDraft); @@ -484,8 +480,8 @@ function IOURequestStepDistanceOdometer({ return; } - const start = parseOdometerReading(startReading); - const end = parseOdometerReading(endReading); + const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); + const end = parseFloat(DistanceRequestUtils.normalizeOdometerText(endReading, fromLocaleDigit)); if (Number.isNaN(start) || Number.isNaN(end)) { setFormError(translate('iou.error.invalidReadings')); From 8c02f7d115492375dd31e140407ec13232afde22 Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Wed, 25 Feb 2026 15:26:21 -0800 Subject: [PATCH 29/31] Extract roundDistanceAmount to share rounding logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both getDistanceRequestAmount and isDistanceAmountWithinLimit need identical rounding (2-decimal distance × rate → rounded cents). Extract this into roundDistanceAmount so the logic lives in one place. Made-with: Cursor --- src/libs/DistanceRequestUtils.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index d49c92919f920..b0702689a78e9 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -269,6 +269,15 @@ function getRateForP2P(currency: string, transaction: OnyxEntry): M }; } +/** + * Rounds a distance (already in the target unit) to 2 decimal places, + * multiplies by the rate, and rounds to the nearest integer (cents). + */ +function roundDistanceAmount(distanceInUnits: number, rate: number): number { + const roundedDistance = parseFloat(distanceInUnits.toFixed(2)); + return Math.round(roundedDistance * rate); +} + /** * Calculates the expense amount based on distance, unit, and rate. * @@ -278,9 +287,7 @@ function getRateForP2P(currency: string, transaction: OnyxEntry): M * @returns The computed expense amount (rounded) in "cents". */ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): number { - const convertedDistance = convertDistanceUnit(distance, unit); - const roundedDistance = parseFloat(convertedDistance.toFixed(2)); - return Math.round(roundedDistance * rate); + return roundDistanceAmount(convertDistanceUnit(distance, unit), rate); } /** @@ -428,12 +435,7 @@ 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 { - // 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; + return Math.abs(roundDistanceAmount(distance, rate)) <= CONST.IOU.MAX_SAFE_AMOUNT; } /** From 651c98f1dfad07b4d513da4e3c0792ecf4538d3d Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Wed, 25 Feb 2026 15:33:03 -0800 Subject: [PATCH 30/31] Add blank lines above comments per style convention Made-with: Cursor --- src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 1 + src/pages/iou/request/step/IOURequestStepDistanceRate.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 23e8d5b0187a0..24b2a4937d496 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -301,6 +301,7 @@ 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) { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index b282961436ab8..1734d8553d5e2 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -119,11 +119,13 @@ 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; + // 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. From 4224957adead9787714f45697407b66c76f5f98f Mon Sep 17 00:00:00 2001 From: neil-marcellini Date: Thu, 26 Feb 2026 15:41:59 -0800 Subject: [PATCH 31/31] Remove unused useCallback dependencies in submitAndNavigateToNextPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit report, iouType, and currentUserAccountIDParam are not referenced in the callback body — they were left over from before the refactor moved logic into navigateToNextPage. Made-with: Cursor --- src/pages/iou/request/step/IOURequestStepDistanceManual.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 81edbf51307e4..1f38473b1ceec 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -299,7 +299,7 @@ function IOURequestStepDistanceManual({ } navigateToNextPage(value); - }, [navigateToNextPage, translate, report, iouType, currentUserAccountIDParam, rate]); + }, [navigateToNextPage, translate, rate]); useEffect(() => { if (isLoadingSelectedTab) {