Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c2b08c3
Add frontend validation for distance expense amounts exceeding backen…
MelvinBot Feb 19, 2026
44f1f84
Fix: Apply Prettier formatting to Italian translations
MelvinBot Feb 19, 2026
06602cb
Add distance amount validation to rate edit flow
MelvinBot Feb 20, 2026
c13e12c
Fix: Format IOURequestStepDistanceRate.tsx to pass Prettier check
MelvinBot Feb 20, 2026
2b701d7
Fix rate selection UX: keep problematic rate visually selected on val…
MelvinBot Feb 21, 2026
cc6427f
Use context-specific error messages for distance amount validation
MelvinBot Feb 21, 2026
9e200d6
Add maxLength limit to odometer reading inputs
MelvinBot Feb 21, 2026
00fec93
Add numeric max value validation for odometer readings
neil-marcellini Feb 21, 2026
7f7af9b
Fix ODOMETER_MAX_VALUE to 999999.99 to match maxLength
neil-marcellini Feb 22, 2026
622bdbf
Increase odometer max to 9,999,999.9 and allow commas in input
neil-marcellini Feb 22, 2026
e0e5233
Add locale-aware number input for odometer readings
neil-marcellini Feb 22, 2026
1984b15
Fix: Sort imports to match Prettier configuration
MelvinBot Feb 22, 2026
f6854f8
Auto-format odometer readings with thousand separators
neil-marcellini Feb 22, 2026
5153dfd
Fix: Use replaceAll instead of replace to satisfy ESLint unicorn/pref…
MelvinBot Feb 22, 2026
778bc66
Stop modifying odometer input, validate on submit instead
neil-marcellini Feb 22, 2026
72531d6
Fix: Move parseOdometerReading definition before its usage
MelvinBot Feb 22, 2026
7ffcc5c
Reject odometer input that exceeds max value or decimal precision
neil-marcellini Feb 22, 2026
68125c0
Use locale-formatted number in odometer max error message
neil-marcellini Feb 22, 2026
1645101
fix style, blank line above comment
neil-marcellini Feb 22, 2026
6d7a7ac
Consolidate odometer input normalization to fix parsing/validation mi…
MelvinBot Feb 22, 2026
f0a1c14
Fix rate unit mismatch in distance validation and allow editing legac…
MelvinBot Feb 22, 2026
a657bc9
Align isDistanceAmountWithinLimit rounding with getDistanceRequestAmount
neil-marcellini Feb 22, 2026
10e1718
Reconcile duplicate Melvin commit differences
neil-marcellini Feb 22, 2026
7d070ba
Fix locale decimal fallback in odometer normalization and revert Mobi…
neil-marcellini Feb 22, 2026
54e21d1
Revert comma-decimal fallback and add locale normalization test
neil-marcellini Feb 25, 2026
ba51331
Merge main into neil-distanceAmountValidation-v2
neil-marcellini Feb 25, 2026
244cd7c
Reset Mobile-Expensify submodule pointer to match main
neil-marcellini Feb 25, 2026
e88991f
Extract normalizeOdometerText to DistanceRequestUtils and test the re…
neil-marcellini Feb 25, 2026
75775c0
Remove unnecessary wrapper functions in odometer component
neil-marcellini Feb 25, 2026
8c02f7d
Extract roundDistanceAmount to share rounding logic
neil-marcellini Feb 25, 2026
651c98f
Add blank lines above comments per style convention
neil-marcellini Feb 25, 2026
4224957
Remove unused useCallback dependencies in submitAndNavigateToNextPage
neil-marcellini Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/CONST/index.ts
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -3153,6 +3153,8 @@ const CONST = {
},
AMOUNT_MAX_LENGTH: 10,
DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14,
ODOMETER_MAX_VALUE: 9999999.9,
MAX_SAFE_AMOUNT: 999999999999,
RECEIPT_STATE: {
SCAN_READY: 'SCANREADY',
OPEN: 'OPEN',
Expand Down
5 changes: 5 additions & 0 deletions src/components/MoneyRequestConfirmationList.tsx
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,11 @@ function MoneyRequestConfirmationList({
return;
}

if (isDistanceRequest && Math.abs(iouAmount) > CONST.IOU.MAX_SAFE_AMOUNT) {
setFormError('iou.error.distanceAmountTooLarge');
return;
}

if (isTimeRequest && !isValidTimeExpenseAmount(iouAmount, iouCurrencyCode, decimals)) {
setFormError('iou.timeTracking.amountTooLargeError');
return;
Expand Down
4 changes: 4 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,10 @@ const translations: TranslationDeepObject<typeof en> = {
invalidDistance: 'Bitte gib eine gültige Entfernung ein, bevor du fortfährst',
invalidReadings: 'Bitte geben Sie sowohl Start- als auch Endstand ein',
negativeDistanceNotAllowed: 'Endstand muss größer als Anfangsstand sein',
distanceAmountTooLarge: 'Der Gesamtbetrag ist zu hoch. Verringere die Entfernung oder reduziere den Satz.',
distanceAmountTooLargeReduceDistance: 'Der Gesamtbetrag ist zu hoch. Verringere die Entfernung.',
distanceAmountTooLargeReduceRate: 'Der Gesamtbetrag ist zu hoch. Reduziere den Satz.',
odometerReadingTooLarge: (formattedMax: string) => `Kilometerstände dürfen ${formattedMax} nicht überschreiten.`,
invalidIntegerAmount: 'Bitte gib einen vollen Dollarbetrag ein, bevor du fortfährst',
invalidTaxAmount: (amount: string) => `Der maximale Steuerbetrag ist ${amount}`,
invalidSplit: 'Die Summe der Aufteilungen muss dem Gesamtbetrag entsprechen',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1335,6 +1335,10 @@ const translations = {
invalidDistance: 'Please enter a valid distance before continuing',
invalidReadings: 'Please enter both start and end readings',
negativeDistanceNotAllowed: 'End reading must be greater than start reading',
distanceAmountTooLarge: 'The total amount is too large. Reduce the distance or lower the rate.',
distanceAmountTooLargeReduceDistance: 'The total amount is too large. Reduce the distance.',
distanceAmountTooLargeReduceRate: 'The total amount is too large. Lower the rate.',
odometerReadingTooLarge: (formattedMax: string) => `Odometer readings cannot exceed ${formattedMax}.`,
invalidIntegerAmount: 'Please enter a whole dollar amount before continuing',
invalidTaxAmount: (amount: string) => `Maximum tax amount is ${amount}`,
invalidSplit: 'The sum of splits must equal the total amount',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,10 @@ const translations: TranslationDeepObject<typeof en> = {
invalidDistance: 'Por favor, ingresa una distancia válida antes de continuar',
invalidReadings: 'Por favor ingrese ambas lecturas de inicio y fin',
negativeDistanceNotAllowed: 'La lectura final debe ser mayor que la lectura inicial',
distanceAmountTooLarge: 'El importe total es demasiado alto. Reduce la distancia o disminuye la tarifa.',
distanceAmountTooLargeReduceDistance: 'El importe total es demasiado alto. Reduce la distancia.',
distanceAmountTooLargeReduceRate: 'El importe total es demasiado alto. Disminuye la tarifa.',
odometerReadingTooLarge: (formattedMax: string) => `Las lecturas del odómetro no pueden superar ${formattedMax}.`,
invalidIntegerAmount: 'Por favor, introduce una cantidad entera en dólares antes de continuar',
invalidTaxAmount: (amount) => `El importe máximo del impuesto es ${amount}`,
invalidSplit: 'La suma de las partes debe ser igual al importe total',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,10 @@ const translations: TranslationDeepObject<typeof en> = {
invalidDistance: 'Veuillez saisir une distance valide avant de continuer',
invalidReadings: 'Veuillez saisir les relevés de début et de fin',
negativeDistanceNotAllowed: 'Le relevé de fin doit être supérieur au relevé de début',
distanceAmountTooLarge: 'Le montant total est trop élevé. Réduisez la distance ou diminuez le taux.',
distanceAmountTooLargeReduceDistance: 'Le montant total est trop élevé. Réduisez la distance.',
distanceAmountTooLargeReduceRate: 'Le montant total est trop élevé. Diminuez le taux.',
odometerReadingTooLarge: (formattedMax: string) => `Les lectures du compteur kilométrique ne peuvent pas dépasser ${formattedMax}.`,
invalidIntegerAmount: 'Veuillez saisir un montant entier en dollars avant de continuer',
invalidTaxAmount: (amount: string) => `Le montant maximal de taxe est de ${amount}`,
invalidSplit: 'La somme des répartitions doit être égale au montant total',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1316,6 +1316,10 @@ const translations: TranslationDeepObject<typeof en> = {
invalidDistance: 'Inserisci una distanza valida prima di continuare',
invalidReadings: 'Inserisci sia la lettura iniziale che quella finale',
negativeDistanceNotAllowed: 'La lettura finale deve essere maggiore della lettura iniziale',
distanceAmountTooLarge: "L'importo totale è troppo alto. Riduci la distanza o abbassa la tariffa.",
distanceAmountTooLargeReduceDistance: "L'importo totale è troppo alto. Riduci la distanza.",
distanceAmountTooLargeReduceRate: "L'importo totale è troppo alto. Abbassa la tariffa.",
odometerReadingTooLarge: (formattedMax: string) => `Le letture del contachilometri non possono superare ${formattedMax}.`,
invalidIntegerAmount: 'Inserisci un importo in dollari intero prima di continuare',
invalidTaxAmount: (amount: string) => `L’importo massimo dell’imposta è ${amount}`,
invalidSplit: 'La somma delle suddivisioni deve essere uguale all’importo totale',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,10 @@ const translations: TranslationDeepObject<typeof en> = {
invalidDistance: '続行する前に有効な距離を入力してください',
invalidReadings: '開始値と終了値の両方を入力してください',
negativeDistanceNotAllowed: '終了値は開始値より大きくなければなりません',
distanceAmountTooLarge: '合計金額が大きすぎます。距離を減らすか、レートを下げてください。',
distanceAmountTooLargeReduceDistance: '合計金額が大きすぎます。距離を減らしてください。',
distanceAmountTooLargeReduceRate: '合計金額が大きすぎます。レートを下げてください。',
odometerReadingTooLarge: (formattedMax: string) => `オドメーターの読み取り値は${formattedMax}を超えることはできません。`,
invalidIntegerAmount: '続行する前にドルの整数金額を入力してください',
invalidTaxAmount: (amount: string) => `最大税額は${amount}です`,
invalidSplit: '分割した金額の合計は合計金額と一致している必要があります',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,10 @@ const translations: TranslationDeepObject<typeof en> = {
invalidDistance: 'Voer een geldige afstand in voordat je verdergaat',
invalidReadings: 'Voer zowel de begin- als eindstanden in',
negativeDistanceNotAllowed: 'Eindstand moet hoger zijn dan beginstand',
distanceAmountTooLarge: 'Het totale bedrag is te hoog. Verlaag de afstand of verlaag het tarief.',
distanceAmountTooLargeReduceDistance: 'Het totale bedrag is te hoog. Verlaag de afstand.',
distanceAmountTooLargeReduceRate: 'Het totale bedrag is te hoog. Verlaag het tarief.',
odometerReadingTooLarge: (formattedMax: string) => `Kilometertellerstanden mogen niet hoger zijn dan ${formattedMax}.`,
invalidIntegerAmount: 'Voer een volledig dollarbedrag in voordat je doorgaat',
invalidTaxAmount: (amount: string) => `Maximale belastingbedrag is ${amount}`,
invalidSplit: 'De som van de splitsingen moet gelijk zijn aan het totale bedrag',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,10 @@ const translations: TranslationDeepObject<typeof en> = {
invalidDistance: 'Wprowadź prawidłowy dystans przed kontynuowaniem',
invalidReadings: 'Wprowadź zarówno odczyt początkowy, jak i końcowy',
negativeDistanceNotAllowed: 'Końcowy odczyt musi być większy niż początkowy odczyt',
distanceAmountTooLarge: 'Łączna kwota jest zbyt wysoka. Zmniejsz dystans lub obniż stawkę.',
distanceAmountTooLargeReduceDistance: 'Łączna kwota jest zbyt wysoka. Zmniejsz dystans.',
distanceAmountTooLargeReduceRate: 'Łączna kwota jest zbyt wysoka. Obniż stawkę.',
odometerReadingTooLarge: (formattedMax: string) => `Odczyty licznika nie mogą przekraczać ${formattedMax}.`,
invalidIntegerAmount: 'Przed kontynuowaniem wprowadź kwotę w pełnych dolarach',
invalidTaxAmount: (amount: string) => `Maksymalna kwota podatku to ${amount}`,
invalidSplit: 'Suma podziałów musi być równa całkowitej kwocie',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1312,6 +1312,10 @@ const translations: TranslationDeepObject<typeof en> = {
invalidDistance: 'Insira uma distância válida antes de continuar',
invalidReadings: 'Insira as leituras de início e fim',
negativeDistanceNotAllowed: 'A leitura final deve ser maior que a leitura inicial',
distanceAmountTooLarge: 'O valor total é muito alto. Diminua a distância ou reduza a tarifa.',
distanceAmountTooLargeReduceDistance: 'O valor total é muito alto. Diminua a distância.',
distanceAmountTooLargeReduceRate: 'O valor total é muito alto. Reduza a tarifa.',
odometerReadingTooLarge: (formattedMax: string) => `As leituras do hodômetro não podem exceder ${formattedMax}.`,
invalidIntegerAmount: 'Insira um valor inteiro em dólares antes de continuar',
invalidTaxAmount: (amount: string) => `O valor máximo de imposto é ${amount}`,
invalidSplit: 'A soma das divisões deve ser igual ao valor total',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,10 @@ const translations: TranslationDeepObject<typeof en> = {
invalidDistance: '请在继续之前输入有效的距离',
invalidReadings: '请输入起始读数和结束读数',
negativeDistanceNotAllowed: '结束读数必须大于开始读数',
distanceAmountTooLarge: '总金额过大。请减少距离或降低费率。',
distanceAmountTooLargeReduceDistance: '总金额过大。请减少距离。',
distanceAmountTooLargeReduceRate: '总金额过大。请降低费率。',
odometerReadingTooLarge: (formattedMax: string) => `里程表读数不能超过${formattedMax}。`,
invalidIntegerAmount: '请在继续之前输入一个整数美元金额',
invalidTaxAmount: (amount: string) => `最高税额为 ${amount}`,
invalidSplit: '拆分金额之和必须等于总金额',
Expand Down
40 changes: 37 additions & 3 deletions src/libs/DistanceRequestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -268,6 +269,15 @@ function getRateForP2P(currency: string, transaction: OnyxEntry<Transaction>): 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.
*
Expand All @@ -277,9 +287,7 @@ function getRateForP2P(currency: string, transaction: OnyxEntry<Transaction>): 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);
}

/**
Expand Down Expand Up @@ -418,6 +426,30 @@ function getRateByCustomUnitRateID({customUnitRateID, policy}: {customUnitRateID
return getMileageRates(policy, true, customUnitRateID)[customUnitRateID];
}

/**
* Returns whether the calculated distance expense amount (distance * rate) is within the backend's safe limit.
* The backend WAF rejects amounts exceeding 12 digits (999,999,999,999 cents).
*
* @param distance - The distance in the unit specified (km or mi), NOT meters
* @param rate - The rate in cents per unit
* @returns true if the amount is within limits, false if it would exceed the backend limit
*/
function isDistanceAmountWithinLimit(distance: number, rate: number): boolean {
return Math.abs(roundDistanceAmount(distance, rate)) <= CONST.IOU.MAX_SAFE_AMOUNT;
}

/**
* Normalize odometer text by standardizing locale digits and stripping all
* non-numeric characters except the decimal point. fromLocaleDigit converts
* each locale character to its standard equivalent (e.g. German ',' → '.'
* for decimal, German '.' → ',' for group separator), then we keep only
* digits and the standard decimal point.
*/
function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => string): string {
const standardized = replaceAllDigits(text, fromLocaleDigit);
return standardized.replaceAll(/[^0-9.]/g, '');
}

export default {
getDefaultMileageRate,
getDistanceMerchant,
Expand All @@ -436,6 +468,8 @@ export default {
getRateByCustomUnitRateID,
getDistanceForDisplayLabel,
convertDistanceUnit,
isDistanceAmountWithinLimit,
normalizeOdometerText,
};

export type {MileageRate};
14 changes: 11 additions & 3 deletions src/pages/iou/request/step/IOURequestStepDistanceManual.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,13 @@ function IOURequestStepDistanceManual({
// to make sure the correct distance amount and unit will be shown we use distance unit
// from defaultExpensePolicy or current report's policy instead of from transaction and
// then we use transaction data (distanceUnit and quantity) for conversions
const unit = DistanceRequestUtils.getRate({
const mileageRate = DistanceRequestUtils.getRate({
transaction,
policy: shouldUseDefaultExpensePolicy ? defaultExpensePolicy : policy,
useTransactionDistanceUnit: false,
}).unit;
});
const unit = mileageRate.unit;
const rate = mileageRate.rate ?? 0;
const distanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit ? transaction.comment.customUnit.distanceUnit : unit);
const distance = typeof transaction?.comment?.customUnit?.quantity === 'number' ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit)) : undefined;
const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT);
Expand Down Expand Up @@ -290,8 +292,14 @@ function IOURequestStepDistanceManual({
return;
}

// Validation: Check that distance * rate doesn't exceed the backend's safe amount limit
if (!DistanceRequestUtils.isDistanceAmountWithinLimit(parseFloat(value), rate)) {
setFormError(translate('iou.error.distanceAmountTooLargeReduceDistance'));
return;
}

navigateToNextPage(value);
}, [navigateToNextPage, translate, report, iouType, currentUserAccountIDParam]);
}, [navigateToNextPage, translate, rate]);

useEffect(() => {
if (isLoadingSelectedTab) {
Expand Down
Loading
Loading