From 9a938220122e12e0b2fed63d6fa7c400d9aa8b1e Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Fri, 2 Oct 2026 12:59:34 -0500 Subject: [PATCH 01/15] Allow $0 workspace distance rates and treat a $0 rate as a real rate --- .../hooks/useDistanceRequestState.ts | 4 +- .../sections/RateField.tsx | 2 +- .../ReportActionItem/MoneyRequestView.tsx | 4 +- src/languages/de.ts | 2 +- src/languages/el.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/libs/DistanceRequestUtils.ts | 6 +-- src/libs/PolicyDistanceRatesUtils.ts | 5 ++- .../TransactionUtils/getUpdatedTransaction.ts | 2 +- src/libs/TransactionUtils/index.ts | 2 +- tests/unit/DistanceRequestUtilsTest.ts | 40 +++++++++++++++++++ tests/unit/PolicyDistanceRatesUtilsTest.ts | 23 +++++++++++ .../hooks/useDistanceRequestState.test.ts | 34 ++++++++++++++++ 21 files changed, 121 insertions(+), 23 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts index a5c195338539..b53b0b22256f 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useDistanceRequestState.ts @@ -88,7 +88,7 @@ function useDistanceRequestState({ }); const distanceRate = mileageRate.rate; const distanceUnit = mileageRate.unit; - const calculateFromTransactionData = isMovingTransactionFromTrackExpense && !distanceRate; + const calculateFromTransactionData = isMovingTransactionFromTrackExpense && distanceRate === undefined; const customUnit = transaction?.comment?.customUnit; const unit = calculateFromTransactionData ? customUnit?.distanceUnit : distanceUnit; const backCalculationQuantity = customUnit?.quantity; @@ -124,7 +124,7 @@ function useDistanceRequestState({ !transaction?.errorFields?.route && !DistanceRequestUtils.hasCommuterExclusionPreviewForPolicy(transaction, policy); - const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate || isCommuterExclusionPreviewPending) && !isMovingTransactionFromTrackExpense; + const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || rate === undefined || isCommuterExclusionPreviewPending) && !isMovingTransactionFromTrackExpense; const distanceRequestAmount = DistanceRequestUtils.getDistanceRequestAmount(reimbursableDistanceInMeters, amountUnit, rate ?? 0); diff --git a/src/components/MoneyRequestConfirmationList/sections/RateField.tsx b/src/components/MoneyRequestConfirmationList/sections/RateField.tsx index de397749b9d5..183f421e0f83 100644 --- a/src/components/MoneyRequestConfirmationList/sections/RateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/RateField.tsx @@ -85,7 +85,7 @@ function RateField({ const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; const rate = mileageRate.rate; - const isRateInteractive = !!rate && !isReadOnly && iouType !== CONST.IOU.TYPE.SPLIT; + const isRateInteractive = rate !== undefined && !isReadOnly && iouType !== CONST.IOU.TYPE.SPLIT; const {isSearchRouterDisplayed} = useSearchRouterState(); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3fff41e1f9d3..66ff24612791 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -637,8 +637,8 @@ function MoneyRequestView({ const currency = transactionCurrency ?? CONST.CURRENCY.USD; const hasRequiredCompanyCardViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.COMPANY_CARD_REQUIRED); const isCustomUnitOutOfPolicy = - (transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY) || (isDistanceRequest && !rate)) && !isTrackExpense; - const calculateFromTransactionData = isTrackExpense && !rate; + (transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY) || (isDistanceRequest && rate === undefined)) && !isTrackExpense; + const calculateFromTransactionData = isTrackExpense && rate === undefined; const distanceUnit = calculateFromTransactionData ? transaction?.comment?.customUnit?.distanceUnit : unit; const backCalculationQuantity = transaction?.comment?.customUnit?.quantity; const distanceRate = calculateFromTransactionData && !!backCalculationQuantity ? (transactionAmount ?? 0) / backCalculationQuantity : rate; diff --git a/src/languages/de.ts b/src/languages/de.ts index 1618ad99f79f..1bf45f313419 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -235,7 +235,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: 'Bitte fülle das obige Formular aus, um fortzufahren', pleaseSelectOne: 'Bitte wähle oben eine Option aus', invalidRateError: 'Bitte einen gültigen Satz eingeben', - lowRateError: 'Der Satz muss größer als 0 sein', + lowRateError: 'Der Satz darf nicht negativ sein', email: 'Bitte gib eine gültige E-Mail-Adresse ein', login: 'Beim Anmelden ist ein Fehler aufgetreten. Bitte versuche es erneut.', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index 9eee3e850658..6ada172a6c30 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -240,7 +240,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: 'Συμπληρώστε τη φόρμα παραπάνω για να συνεχίσετε', pleaseSelectOne: 'Παρακαλώ επιλέξτε μια από τις παραπάνω επιλογές', invalidRateError: 'Παρακαλούμε εισαγάγετε έγκυρο συντελεστή', - lowRateError: 'Το ποσοστό πρέπει να είναι μεγαλύτερο από 0', + lowRateError: 'Το ποσοστό δεν μπορεί να είναι αρνητικό', email: 'Παρακαλούμε εισαγάγετε μια έγκυρη διεύθυνση email', login: 'Προέκυψε σφάλμα κατά τη σύνδεση. Παρακαλούμε δοκιμάστε ξανά.', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 260dfa5c93a8..a54100a01c42 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -272,7 +272,7 @@ const translations = { pleaseCompleteForm: 'Please complete the form above to continue', pleaseSelectOne: 'Please select an option above', invalidRateError: 'Please enter a valid rate', - lowRateError: 'Rate must be greater than 0', + lowRateError: "Rate can't be negative", email: 'Please enter a valid email address', login: 'An error occurred while logging in. Please try again.', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 6994384dd473..832942178dce 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -227,7 +227,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: 'Por favor complete el formulario de arriba para continuar', pleaseSelectOne: 'Seleccione una de las opciones', invalidRateError: 'Por favor, introduce una tarifa válida', - lowRateError: 'La tarifa debe ser mayor que 0', + lowRateError: 'La tarifa no puede ser negativa', email: 'Por favor, introduzca una dirección de correo electrónico válida', login: 'Se produjo un error al iniciar sesión. Por favor intente nuevamente.', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 088edff9becb..0e30d04d92a1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -235,7 +235,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: 'Veuillez remplir le formulaire ci-dessus pour continuer', pleaseSelectOne: 'Veuillez sélectionner une option ci-dessus', invalidRateError: 'Veuillez saisir un taux valide', - lowRateError: 'Le taux doit être supérieur à 0', + lowRateError: 'Le taux ne peut pas être négatif', email: 'Veuillez saisir une adresse e-mail valide', login: 'Une erreur s’est produite lors de la connexion. Veuillez réessayer.', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index ebe2850df008..f1915568e5aa 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -235,7 +235,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: 'Completa il modulo sopra per continuare', pleaseSelectOne: 'Seleziona un’opzione sopra', invalidRateError: 'Inserisci un tasso valido', - lowRateError: 'La tariffa deve essere maggiore di 0', + lowRateError: 'La tariffa non può essere negativa', email: 'Inserisci un indirizzo email valido', login: 'Si è verificato un errore durante l’accesso. Riprova.', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 71bdbc7914ce..b93314050286 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -235,7 +235,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: '続行するには上のフォームに入力してください', pleaseSelectOne: '上からオプションを選択してください', invalidRateError: '有効なレートを入力してください', - lowRateError: 'レートは0より大きくなければなりません', + lowRateError: 'レートを負の値にすることはできません', email: '有効なメールアドレスを入力してください', login: 'ログイン中にエラーが発生しました。もう一度お試しください。', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index acfb92d26778..f0c82b8081f2 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -235,7 +235,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: 'Vul het bovenstaande formulier in om door te gaan', pleaseSelectOne: 'Selecteer hierboven een optie', invalidRateError: 'Voer een geldige koers in', - lowRateError: 'Tarief moet groter zijn dan 0', + lowRateError: 'Tarief mag niet negatief zijn', email: 'Voer een geldig e-mailadres in', login: 'Er is een fout opgetreden tijdens het inloggen. Probeer het opnieuw.', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 09f65f9c4307..cf795be6b746 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -239,7 +239,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: 'Aby kontynuować, wypełnij formularz powyżej', pleaseSelectOne: 'Wybierz jedną z powyższych opcji', invalidRateError: 'Wprowadź prawidłową stawkę', - lowRateError: 'Stawka musi być większa niż 0', + lowRateError: 'Stawka nie może być ujemna', email: 'Wpisz prawidłowy adres e‑mail', login: 'Wystąpił błąd podczas logowania. Spróbuj ponownie.', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d4b6ace81a50..e2f6350a679a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -235,7 +235,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: 'Preencha o formulário acima para continuar', pleaseSelectOne: 'Selecione uma opção acima', invalidRateError: 'Insira uma taxa válida', - lowRateError: 'A taxa deve ser maior que 0', + lowRateError: 'A taxa não pode ser negativa', email: 'Insira um endereço de e-mail válido', login: 'Ocorreu um erro ao fazer login. Tente novamente.', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b5bd91dea51c..8f5eb637f8cd 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -235,7 +235,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: '请填写上面的表单以继续', pleaseSelectOne: '请在上方选择一个选项', invalidRateError: '请输入有效的费率', - lowRateError: '费率必须大于 0', + lowRateError: '费率不能为负数', email: '请输入有效的邮箱地址', login: '登录时发生错误。请重试。', }, diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 90de2b0481ea..1b0481e1ba53 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -164,10 +164,10 @@ function getFormattedRateValue( isOffline?: boolean, useShortFormUnit?: boolean, ): string { - if (isOffline && !rate) { + if (isOffline && rate === undefined) { return translate('iou.defaultRate'); } - if (!rate || !currency || !unit) { + if (rate === undefined || !currency || !unit) { return translate('iou.fieldPending'); } @@ -267,7 +267,7 @@ function getDistanceMerchant( isZeroDistanceAllowed?: boolean, commuterExclusionData?: CommuterExclusionData | null, ): string { - if (!hasRoute || !rate) { + if (!hasRoute || rate === undefined) { return translate('iou.fieldPending'); } diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 7908e509ffa8..629466192869 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -30,9 +30,10 @@ function validateRateValue(values: FormOnyxValues, toLocaleDigit: // Allow one more decimal place for accuracy const rateValueRegex = RegExp(String.raw`^-?\d{0,${CONST.IOU.AMOUNT_MAX_LENGTH}}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{0,${CONST.MAX_TAX_RATE_DECIMAL_PLACES}})?$`, 'i'); - if (!rateValueRegex.test(parsedRate) || parsedRate === '') { + const rateValue = parseFloatAnyLocale(parsedRate); + if (!rateValueRegex.test(parsedRate) || parsedRate === '' || Number.isNaN(rateValue)) { errors.rate = translate('common.error.invalidRateError'); - } else if (parseFloatAnyLocale(parsedRate) <= 0) { + } else if (rateValue < 0) { errors.rate = translate('common.error.lowRateError'); } return errors; diff --git a/src/libs/TransactionUtils/getUpdatedTransaction.ts b/src/libs/TransactionUtils/getUpdatedTransaction.ts index 8fe492c2469c..f801b7bed00a 100644 --- a/src/libs/TransactionUtils/getUpdatedTransaction.ts +++ b/src/libs/TransactionUtils/getUpdatedTransaction.ts @@ -483,7 +483,7 @@ function getUpdatedTransaction({ // amount by the distance ratio so the optimistic value isn't 0. `modifiedAmount` is `""` for // unedited transactions, so coerce via Number() and fall through to `amount`. const previousAmount = Number(transaction?.modifiedAmount) || transaction?.amount || 0; - const useFallback = !rate && !!previousDistanceInMeters && !!previousAmount && !!distanceInMeters; + const useFallback = rate === undefined && !!previousDistanceInMeters && !!previousAmount && !!distanceInMeters; if (useFallback) { updatedTransaction.modifiedAmount = Math.round(previousAmount * (distanceInMeters / previousDistanceInMeters)); updatedTransaction.modifiedMerchant = updatedMerchant; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 08ecf87e20e0..f8f2ba7c8ca1 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -181,7 +181,7 @@ function getDisplayTransactionWithoutInvalidCommuterExclusion({ const mileageRate = DistanceRequestUtils.getRateByCustomUnitRateIDAcrossPolicies({customUnitRateID: customUnit?.customUnitRateID, policy, policies}); const rate = mileageRate?.rate; const unit = customUnit?.distanceUnit ?? mileageRate?.unit; - if (!unit || !rate) { + if (!unit || rate === undefined) { return transaction; } diff --git a/tests/unit/DistanceRequestUtilsTest.ts b/tests/unit/DistanceRequestUtilsTest.ts index a949e0dc4aa8..8a9aa61ae179 100644 --- a/tests/unit/DistanceRequestUtilsTest.ts +++ b/tests/unit/DistanceRequestUtilsTest.ts @@ -865,6 +865,36 @@ describe('DistanceRequestUtils', () => { expect(result).toBe('3.49 mi @ $0.67 / mi'); }); + it('formats a $0 rate instead of treating it as pending', () => { + const result = DistanceRequestUtils.getDistanceMerchant( + true, + DistanceRequestUtils.convertToDistanceInMeters(12, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), + CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + 0, + 'USD', + translateLocal, + toLocaleDigitMock, + getCurrencySymbolMock, + ); + + expect(result).toBe('12.00 mi @ $0.00 / mi'); + }); + + it('returns pending when there is no rate', () => { + const result = DistanceRequestUtils.getDistanceMerchant( + true, + DistanceRequestUtils.convertToDistanceInMeters(12, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES), + CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + undefined, + 'USD', + translateLocal, + toLocaleDigitMock, + getCurrencySymbolMock, + ); + + expect(result).toBe(translateLocal('iou.fieldPending')); + }); + it('formats zero reimbursable commuter distance', () => { const result = DistanceRequestUtils.getDistanceMerchant( true, @@ -924,6 +954,16 @@ describe('DistanceRequestUtils', () => { const result = DistanceRequestUtils.getRateForExpenseDisplay('Default Rate', true, ...rateParams); expect(result).toBe(translateLocal('common.rateOutOfPolicy')); }); + + it('should format a $0 rate for P2P expenses', () => { + const result = DistanceRequestUtils.getRateForExpenseDisplay(undefined, false, 'mi', 0, 'USD', translateLocal, toLocaleDigitMock, getCurrencySymbolMock, false); + expect(result).toBe(`$0 / ${translateLocal('common.mile')}`); + }); + + it('should show the actual $0 rate rather than the default rate label while offline', () => { + const result = DistanceRequestUtils.getRateForExpenseDisplay(undefined, false, 'mi', 0, 'USD', translateLocal, toLocaleDigitMock, getCurrencySymbolMock, true); + expect(result).toBe(`$0 / ${translateLocal('common.mile')}`); + }); }); describe('isRateEligibleForDate', () => { diff --git a/tests/unit/PolicyDistanceRatesUtilsTest.ts b/tests/unit/PolicyDistanceRatesUtilsTest.ts index ca20d1adc981..fe55971f9331 100644 --- a/tests/unit/PolicyDistanceRatesUtilsTest.ts +++ b/tests/unit/PolicyDistanceRatesUtilsTest.ts @@ -6,6 +6,7 @@ import { isCurrencySupportedForAutoUpdate, isGovernmentRateUnmodified, isMapOrGPSRequired, + validateRateValue, validateTaxClaimableValue, } from '@libs/PolicyDistanceRatesUtils'; @@ -17,6 +18,28 @@ import createRandomPolicy from '../utils/collections/policies'; import {translateLocal} from '../utils/TestHelper'; describe('PolicyDistanceRatesUtils', () => { + describe('validateRateValue', () => { + const toLocaleDigit = (digit: string) => digit; + + it('should allow a rate of 0', () => { + expect(validateRateValue({rate: '0'}, toLocaleDigit, translateLocal).rate).toBeUndefined(); + expect(validateRateValue({rate: '0.00'}, toLocaleDigit, translateLocal).rate).toBeUndefined(); + }); + + it('should allow a positive rate', () => { + expect(validateRateValue({rate: '0.725'}, toLocaleDigit, translateLocal).rate).toBeUndefined(); + }); + + it('should reject a negative rate', () => { + expect(validateRateValue({rate: '-1'}, toLocaleDigit, translateLocal).rate).toBe(translateLocal('common.error.lowRateError')); + }); + + it('should reject a value that is not a number', () => { + expect(validateRateValue({rate: '.'}, toLocaleDigit, translateLocal).rate).toBe(translateLocal('common.error.invalidRateError')); + expect(validateRateValue({rate: 'abc'}, toLocaleDigit, translateLocal).rate).toBe(translateLocal('common.error.invalidRateError')); + }); + }); + describe('validateTaxClaimableValue', () => { it('should return an error when taxClaimableValue is equal to tax rate', () => { // Given a tax claimable value inserted for a distance rate diff --git a/tests/unit/hooks/useDistanceRequestState.test.ts b/tests/unit/hooks/useDistanceRequestState.test.ts index 17ce90b5993c..deb10d627574 100644 --- a/tests/unit/hooks/useDistanceRequestState.test.ts +++ b/tests/unit/hooks/useDistanceRequestState.test.ts @@ -146,6 +146,40 @@ describe('useDistanceRequestState', () => { expect(result.current.shouldCalculateDistanceAmount).toBe(false); }); + it('does not treat a $0 rate as a pending route', () => { + const getRateSpy = jest.spyOn(DistanceRequestUtils, 'getRate').mockReturnValue({rate: 0, unit: 'mi', currency: 'USD'}); + const {result} = renderHook(() => useDistanceRequestState(baseParams)); + + expect(result.current.rate).toBe(0); + expect(result.current.isDistanceRequestWithPendingRoute).toBe(false); + expect(result.current.distanceRequestAmount).toBe(0); + getRateSpy.mockRestore(); + }); + + it('treats a missing rate as a pending route', () => { + const getRateSpy = jest.spyOn(DistanceRequestUtils, 'getRate').mockReturnValue({unit: 'mi', currency: 'USD'}); + const {result} = renderHook(() => useDistanceRequestState(baseParams)); + + expect(result.current.isDistanceRequestWithPendingRoute).toBe(true); + getRateSpy.mockRestore(); + }); + + it('uses a $0 workspace rate instead of back-calculating one when moving a tracked expense', () => { + const getRateSpy = jest.spyOn(DistanceRequestUtils, 'getRate').mockReturnValue({rate: 0, unit: 'mi', currency: 'USD'}); + const {result} = renderHook(() => + useDistanceRequestState({ + ...baseParams, + isMovingTransactionFromTrackExpense: true, + iouAmount: 500, + transaction: createMock({transactionID: 'txn1', comment: {customUnit: {quantity: 10, routeDistanceMeters: 10}}}), + }), + ); + + expect(result.current.rate).toBe(0); + expect(result.current.distanceRequestAmount).toBe(0); + getRateSpy.mockRestore(); + }); + it('isDistanceRequestWithPendingRoute is true when transaction has no route', () => { const {result} = renderHook(() => useDistanceRequestState({ From f0474e487a6a68e6f4c43e9cd006e6ef065c71bd Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Fri, 2 Oct 2026 12:59:37 -0500 Subject: [PATCH 02/15] Support splitting $0 distance expenses by distance --- src/libs/ReportSecondaryActionUtils.ts | 12 +- src/libs/SplitExpenseUtils.ts | 3 +- src/libs/actions/IOU/SplitExpenseItems.ts | 101 ++++++++-- src/libs/actions/SplitExpenses.ts | 22 +- src/pages/iou/DynamicSplitExpenseEditPage.tsx | 10 +- src/pages/iou/DynamicSplitExpensePage.tsx | 2 +- tests/actions/IOU/SplitExpenseItemsTest.ts | 59 +++++- tests/actions/IOUTest/SplitTest.ts | 190 ++++++++++++++++++ tests/unit/ReportSecondaryActionUtilsTest.ts | 75 +++++++ tests/unit/libs/SplitExpenseUtils.test.ts | 24 +++ tests/unit/resolveSplitMileageRateTest.ts | 12 ++ 11 files changed, 486 insertions(+), 24 deletions(-) diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index ebdbe35797ce..36b9d38ae391 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -24,6 +24,7 @@ import { canMemberWrite, getConnectedIntegration, getCorrectedAutoReportingFrequency, + getDistanceRateCustomUnitRate, getManagerAccountID, getReimbursementChoice, getSubmitToAccountID, @@ -97,6 +98,7 @@ import { hasReceipt as hasReceiptTransactionUtils, hasSmartScanFailedWithMissingFields, hasSubmissionBlockingViolations, + isDistanceRequest as isDistanceRequestTransactionUtils, isDuplicate, isManagedCardTransaction as isManagedCardTransactionTransactionUtils, isOnHold as isOnHoldTransactionUtils, @@ -148,7 +150,15 @@ function isSplitAction( const {isBillSplit, isExpenseSplit} = getOriginalTransactionWithSplitInfo(reportTransaction, originalTransaction); - if (!amount && !isExpenseSplit) { + // A distance expense on a $0 rate is $0 but still has a distance to split + const distanceCustomUnit = reportTransaction?.comment?.customUnit; + const isZeroRateDistanceExpense = + isDistanceRequestTransactionUtils(reportTransaction) && + !!distanceCustomUnit?.quantity && + !!distanceCustomUnit.customUnitRateID && + getDistanceRateCustomUnitRate(policy, distanceCustomUnit.customUnitRateID)?.rate === 0; + + if (!amount && !isExpenseSplit && !isZeroRateDistanceExpense) { return false; } diff --git a/src/libs/SplitExpenseUtils.ts b/src/libs/SplitExpenseUtils.ts index e5fbd9c133ab..7c8f4aa902f4 100644 --- a/src/libs/SplitExpenseUtils.ts +++ b/src/libs/SplitExpenseUtils.ts @@ -107,7 +107,8 @@ function computeSplitSaveErrorMessage({splitExpenses, transactionDetailsAmount, return translate(errorKey, convertToDisplayString(Math.abs(lessThanDifference), currency)); } - if (splitExpenses.find((item) => item.amount === 0)) { + // A distance split on a $0 rate is always $0, so for distance splits only a split without any distance is invalid + if (splitExpenses.find((item) => item.amount === 0 && (!isDistance || !item.customUnit?.quantity))) { return translate('iou.splitExpenseZeroAmount'); } diff --git a/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts index 673025b85888..336edb31ac9b 100644 --- a/src/libs/actions/IOU/SplitExpenseItems.ts +++ b/src/libs/actions/IOU/SplitExpenseItems.ts @@ -37,6 +37,25 @@ import Onyx from 'react-native-onyx'; import {getAllTransactions} from './index'; +/** + * Whether a resolved mileage rate can be used to calculate split amounts and distances. A $0 rate is valid. + */ +function isUsableSplitRate(rate: number | undefined): rate is number { + return rate !== undefined && rate >= 0; +} + +/** + * The distance of one of `splitCount` even splits of `totalQuantity`, rounded to the distance precision, + * with whatever the rounding leaves over added to the split at `remainderIndex`. + */ +function getEvenSplitQuantity(totalQuantity: number, splitCount: number, index: number, remainderIndex: number): number { + const precision = 10 ** CONST.DISTANCE_DECIMAL_PLACES; + const totalUnits = Math.round(totalQuantity * precision); + const baseUnits = Math.floor(totalUnits / splitCount); + const remainderUnits = totalUnits - baseUnits * splitCount; + return (baseUnits + (index === remainderIndex ? remainderUnits : 0)) / precision; +} + /** * Calculate merchant for distance transactions based on distance and rate */ @@ -47,7 +66,7 @@ function getDistanceMerchantFromDistance( currency: string, getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], ): string { - if (!rate || rate <= 0 || !unit) { + if (!isUsableSplitRate(rate) || !unit) { return ''; } @@ -95,7 +114,8 @@ function getSplitReimbursable(policy: OnyxEntry, parentReimbur /** * Update split expense distance and merchant based on amount and rate - * Calculates distance from amount (distance = amount / rate) and updates customUnit quantity and merchant + * Calculates distance from amount (distance = amount / rate) and updates customUnit quantity and merchant. + * A $0 rate makes every amount 0, so the amount can't determine a distance: the split keeps its current distance. */ function updateSplitExpenseDistanceFromAmount( amount: number, @@ -106,13 +126,13 @@ function updateSplitExpenseDistanceFromAmount( getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], transactionCurrency?: string, ): {customUnit: TransactionCustomUnit | undefined; merchant: string} { - if (!rate || rate <= 0 || !unit || !existingCustomUnit) { + if (!isUsableSplitRate(rate) || !unit || !existingCustomUnit) { return {customUnit: existingCustomUnit, merchant: ''}; } // Calculate distance from amount: distance = amount / rate // Both amount and rate are in cents, so the result is in distance units - const distanceInUnits = Math.abs(amount) / rate; + const distanceInUnits = rate === 0 ? (existingCustomUnit.quantity ?? 0) : Math.abs(amount) / rate; const quantity = Number(distanceInUnits.toFixed(CONST.DISTANCE_DECIMAL_PLACES)); const customUnit: TransactionCustomUnit = { @@ -160,14 +180,14 @@ function resolveSplitMileageRate({ (!rawPolicyRate || rawPolicyRate.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || rawPolicyRate.enabled === false); const baseMileageRate = DistanceRequestUtils.getRate({transaction, policy: policy ?? undefined, personalPolicyOutputCurrency}); - if (baseMileageRate.rate && !isOriginalRateDeleted) { + if (baseMileageRate.rate !== undefined && !isOriginalRateDeleted) { return baseMileageRate; } // Policy is present but the originally-stored rate was deleted/disabled — pick the policy's // current default mileage rate so split surfaces use a real (enabled) rate. if (policy) { const fallbackMileageRate = DistanceRequestUtils.getDefaultMileageRate(policy); - if (fallbackMileageRate?.rate) { + if (fallbackMileageRate?.rate !== undefined) { return fallbackMileageRate; } } @@ -175,7 +195,7 @@ function resolveSplitMileageRate({ // policy-driven lookup above produced nothing useful: reconstruct a rate from the transaction // itself (amount / quantity) so distance splits render an accurate "X mi @ rate" merchant // string instead of falling back to the original-merchant string. - if (!baseMileageRate.rate && !isP2PRate) { + if (baseMileageRate.rate === undefined && !isP2PRate) { const quantity = transaction?.comment?.customUnit?.quantity; const transactionAmount = transaction?.amount; if (typeof quantity === 'number' && quantity > 0 && typeof transactionAmount === 'number' && transactionAmount !== 0) { @@ -216,7 +236,7 @@ function resolveSplitItemRate({ } const selectedRate = DistanceRequestUtils.getRateByCustomUnitRateIDAcrossPolicies({policy, customUnitRateID, policies}); - if (!selectedRate?.rate || selectedRate.rate <= 0 || selectedRate.enabled === false) { + if (!selectedRate || !isUsableSplitRate(selectedRate.rate) || selectedRate.enabled === false) { return {rate: fallbackMileageRate.rate, unit}; } @@ -476,7 +496,7 @@ function addSplitExpenseField( const mileageRate = resolveSplitMileageRate({transaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); const {unit, rate} = resolveSplitItemRate({customUnit, fallbackMileageRate: mileageRate, policy, policies}); - if (rate && rate > 0 && customUnit) { + if (isUsableSplitRate(rate) && customUnit) { // For amount = 0, distance = 0, but we still calculate merchant format const {customUnit: updatedCustomUnit, merchant: calculatedMerchant} = updateSplitExpenseDistanceFromAmount( 0, @@ -591,9 +611,29 @@ function evenlyDistributeSplitExpenseAmounts( }; // Update distance for distance transactions based on new amount and rate - if (isDistanceRequest && transaction && splitExpense.customUnit && amount !== 0) { + if (isDistanceRequest && transaction && splitExpense.customUnit) { const {unit, rate} = resolveSplitItemRate({customUnit: splitExpense.customUnit, fallbackMileageRate: mileageRate, policy, policies}); - if (rate && rate > 0) { + if (rate === 0) { + // A $0 rate split is always $0, so even out the distance instead of the amount + const quantity = getEvenSplitQuantity(transaction.comment?.customUnit?.quantity ?? 0, splitCount, index, 0); + const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( + 0, + rate, + unit, + {...splitExpense.customUnit, quantity}, + mileageRate, + getCurrencySymbol, + transaction.currency, + ); + + updatedSplitExpense = { + ...updatedSplitExpense, + amount: 0, + taxAmount: 0, + customUnit: updatedCustomUnit, + merchant, + }; + } else if (amount !== 0 && isUsableSplitRate(rate)) { const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( amount, rate, @@ -708,9 +748,29 @@ function resetSplitExpensesByDateRange({ }); // Update distance for distance transactions based on new amount and rate - if (isDistanceRequest && splitExpense.customUnit && amount !== 0) { + if (isDistanceRequest && splitExpense.customUnit) { const {unit, rate} = resolveSplitItemRate({customUnit: splitExpense.customUnit, fallbackMileageRate: mileageRate, policy, policies}); - if (rate && rate > 0) { + if (rate === 0) { + // A $0 rate split is always $0, so spread the distance across the dates instead of the amount + const quantity = getEvenSplitQuantity(transaction.comment?.customUnit?.quantity ?? 0, dates.length, index, 0); + const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( + 0, + rate, + unit, + {...splitExpense.customUnit, quantity}, + mileageRate, + getCurrencySymbol, + transaction.currency, + ); + + splitExpense = { + ...splitExpense, + amount: 0, + taxAmount: 0, + customUnit: updatedCustomUnit, + merchant, + }; + } else if (amount !== 0 && isUsableSplitRate(rate)) { const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( amount, rate, @@ -842,7 +902,7 @@ function updateSplitExpenseField( const mileageRate = resolveSplitMileageRate({transaction: splitExpenseDraftTransaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); const {unit, rate} = resolveSplitItemRate({customUnit: splitExpenseDraftTransaction?.comment?.customUnit, fallbackMileageRate: mileageRate, policy, policies}); - if (rate && rate > 0) { + if (isUsableSplitRate(rate)) { // Calculate amount from the same distance `quantity` resolved to, so the amount and merchant can't // drift from the stored distance: amount = distance * rate. // Both amount and rate are in cents, distance is in units @@ -908,7 +968,7 @@ function updateSplitExpenseAmountField( const mileageRate = resolveSplitMileageRate({transaction: originalTransaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); const {unit, rate} = resolveSplitItemRate({customUnit: splitExpense.customUnit, fallbackMileageRate: mileageRate, policy, policies}); - if (rate && rate > 0) { + if (isUsableSplitRate(rate)) { const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( amount, rate, @@ -925,6 +985,15 @@ function updateSplitExpenseAmountField( merchant, }; } + + // A $0 rate split is always $0, whatever amount was typed + if (rate === 0) { + updatedSplitExpense = { + ...updatedSplitExpense, + amount: 0, + taxAmount: 0, + }; + } } return updatedSplitExpense; @@ -964,6 +1033,8 @@ function updateSplitExpenseDraftField(fields: Partial) { } export { + getEvenSplitQuantity, + isUsableSplitRate, updateSplitExpenseDistanceFromAmount, initSplitExpenseItemData, getSplitReimbursable, diff --git a/src/libs/actions/SplitExpenses.ts b/src/libs/actions/SplitExpenses.ts index 6b80d6862500..7f26de47bf5a 100644 --- a/src/libs/actions/SplitExpenses.ts +++ b/src/libs/actions/SplitExpenses.ts @@ -20,9 +20,11 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import { + getEvenSplitQuantity, getSplitReimbursable, initDraftSplitExpenseDataForEdit, initSplitExpenseItemData, + isUsableSplitRate, resolveSplitItemReportID, resolveSplitMileageRate, updateSplitExpenseDistanceFromAmount, @@ -168,7 +170,25 @@ function initSplitExpense( const mileageRate = resolveSplitMileageRate({transaction, policy: effectivePolicy ?? undefined, isSelfDMSplit: isSelfDMReport, personalPolicyOutputCurrency}); const {rate, unit, currency} = mileageRate; - if (rate && rate > 0 && transaction?.comment?.customUnit) { + if (rate === 0 && transaction?.comment?.customUnit) { + // Every split of a $0 expense is $0, so split the distance evenly instead, with the remainder on the last split + const transactionCustomUnit = transaction.comment.customUnit; + for (let i = 0; i < splitAmounts.length; i++) { + const quantity = getEvenSplitQuantity(transactionCustomUnit.quantity ?? 0, splitAmounts.length, i, splitAmounts.length - 1); + const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( + 0, + rate, + unit, + {...transactionCustomUnit, quantity}, + {currency}, + getCurrencySymbol, + transactionDetails?.currency, + ); + + splitCustomUnits[i] = updatedCustomUnit; + splitMerchants[i] = merchant; + } + } else if (isUsableSplitRate(rate) && transaction?.comment?.customUnit) { for (let i = 0; i < splitAmounts.length; i++) { if (splitAmounts.at(i)) { const splitAmount = splitAmounts.at(i) ?? 0; diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 9b6b6c9aed94..923f1248ee9f 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -262,7 +262,7 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) }); const distance = getDistanceInMeters(splitExpenseDraftTransaction, unit); const currentAmount = - isDistance && distance && rate + isDistance && distance && rate !== undefined ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) * originalSign : Math.abs(Number(splitExpenseDraftTransaction?.amount)) * originalSign; const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(true, distance, unit, translate, false, isManualDistance); @@ -275,21 +275,23 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) // so that a stale stored merchant (e.g. "Pending..." set before the MAP route was calculated) // does not appear in the title. Falls back to the stored merchant otherwise. const merchantToDisplay = - isDistance && distance && rate && unit + isDistance && distance && rate !== undefined && unit ? DistanceRequestUtils.getDistanceMerchant(true, distance, unit, rate, currency, translate, toLocaleDigit, getCurrencySymbol, true) : (splitExpenseDraftTransactionDetails?.merchant ?? ''); const isP2PRate = currentRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID; const rawPolicyRate = !isP2PRate && currentRateID && effectivePolicy ? getDistanceRateCustomUnitRate(effectivePolicy, currentRateID) : undefined; const isRateBroken = - isDistance && !isP2PRate && (!rates[currentRateID] || !rate || rawPolicyRate?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || rawPolicyRate?.enabled === false); + isDistance && + !isP2PRate && + (!rates[currentRateID] || rate === undefined || rawPolicyRate?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || rawPolicyRate?.enabled === false); const policyWithAvailableRates = effectivePolicy ?? policyForMovingExpenses; const hasAvailableEnabledRates = Object.keys(DistanceRequestUtils.getMileageRates(policyWithAvailableRates)).length > 0; // `shouldSelectPolicy` means rates exist across workspaces but none is resolved yet — keep it flagged out-of-policy. const isCustomUnitOutOfPolicy = isSelfDMSplit ? isRateBroken || (isDistance && isP2PRate && (hasAvailableEnabledRates || shouldSelectPolicy)) - : !rates[currentRateID] || (isDistance && !rate); + : !rates[currentRateID] || (isDistance && rate === undefined); const rateToDisplay = DistanceRequestUtils.getRateForExpenseDisplay(rateName, isCustomUnitOutOfPolicy, unit, rate, currency, translate, toLocaleDigit, getCurrencySymbol, isOffline); const getErrorForField = (field: ViolationField) => { diff --git a/src/pages/iou/DynamicSplitExpensePage.tsx b/src/pages/iou/DynamicSplitExpensePage.tsx index 36dcdb3acee3..deac0eba5ecf 100644 --- a/src/pages/iou/DynamicSplitExpensePage.tsx +++ b/src/pages/iou/DynamicSplitExpensePage.tsx @@ -282,7 +282,7 @@ function DynamicSplitExpensePage({route}: DynamicSplitExpensePageProps) { const splitSelectedRate = (effectivePolicy ? DistanceRequestUtils.getMileageRates(effectivePolicy, false, currentRateID)[currentRateID] : undefined) ?? DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(currentRateID, allPolicies); - if (!splitSelectedRate?.rate) { + if (splitSelectedRate?.rate === undefined) { isUnitRateIDOutOfPolicy = true; } } diff --git a/tests/actions/IOU/SplitExpenseItemsTest.ts b/tests/actions/IOU/SplitExpenseItemsTest.ts index 7b7446467a1e..14919ab2037e 100644 --- a/tests/actions/IOU/SplitExpenseItemsTest.ts +++ b/tests/actions/IOU/SplitExpenseItemsTest.ts @@ -1,4 +1,4 @@ -import {getSplitReimbursable, initSplitExpenseItemData} from '@libs/actions/IOU/SplitExpenseItems'; +import {getEvenSplitQuantity, getSplitReimbursable, initSplitExpenseItemData, isUsableSplitRate, updateSplitExpenseDistanceFromAmount} from '@libs/actions/IOU/SplitExpenseItems'; import CONST from '@src/CONST'; import type {Policy, Report, Transaction} from '@src/types/onyx'; @@ -396,3 +396,60 @@ describe('getSplitReimbursable', () => { }); }); }); + +describe('isUsableSplitRate', () => { + it('accepts a $0 rate and positive rates', () => { + expect(isUsableSplitRate(0)).toBe(true); + expect(isUsableSplitRate(67)).toBe(true); + }); + + it('rejects a missing or negative rate', () => { + expect(isUsableSplitRate(undefined)).toBe(false); + expect(isUsableSplitRate(-1)).toBe(false); + }); +}); + +describe('updateSplitExpenseDistanceFromAmount', () => { + const getCurrencySymbol = (currency: string) => (currency === CONST.CURRENCY.USD ? '$' : currency); + const customUnit = { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID: 'distance-unit', + customUnitRateID: 'rate-1', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 12, + }; + + it('calculates the distance from the amount for a positive rate', () => { + const result = updateSplitExpenseDistanceFromAmount(1000, 100, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, customUnit, {currency: CONST.CURRENCY.USD}, getCurrencySymbol); + + expect(result.customUnit?.quantity).toBe(10); + }); + + it('keeps the current distance for a $0 rate instead of dividing by 0', () => { + const result = updateSplitExpenseDistanceFromAmount(0, 0, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, customUnit, {currency: CONST.CURRENCY.USD}, getCurrencySymbol); + + expect(result.customUnit?.quantity).toBe(12); + expect(result.merchant).toContain('12.00'); + expect(result.merchant).toContain('$0.00'); + }); + + it('leaves the split untouched for a negative rate', () => { + const result = updateSplitExpenseDistanceFromAmount(1000, -1, CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, customUnit, {currency: CONST.CURRENCY.USD}, getCurrencySymbol); + + expect(result.customUnit).toBe(customUnit); + expect(result.merchant).toBe(''); + }); +}); + +describe('getEvenSplitQuantity', () => { + it('splits a distance evenly', () => { + expect([0, 1].map((index) => getEvenSplitQuantity(201, 2, index, 1))).toEqual([100.5, 100.5]); + }); + + it('puts the rounding remainder on the given split so the parts add up to the total', () => { + const quantities = [0, 1, 2].map((index) => getEvenSplitQuantity(10, 3, index, 0)); + + expect(quantities).toEqual([3.34, 3.33, 3.33]); + expect(Number(quantities.reduce((sum, quantity) => sum + quantity, 0).toFixed(2))).toBe(10); + }); +}); diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index dc171db60ef0..87e50c4407ae 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -7333,6 +7333,71 @@ describe('initSplitExpense', () => { expect(splitExpenses?.[1].merchant).toContain('50'); }); + it('splits the distance evenly for a $0 rate distance expense', async () => { + const customUnitRateID = 'rate-zero'; + const customUnitID = 'distance-unit'; + const effectivePolicy: Policy = { + ...createRandomPolicy(3), + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + [customUnitRateID]: { + customUnitRateID, + currency: CONST.CURRENCY.USD, + rate: 0, + enabled: true, + name: 'Untracked', + subRates: [], + }, + }, + }, + }, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${effectivePolicy.id}`, effectivePolicy); + await waitForBatchedUpdates(); + + const transaction: Transaction = { + transactionID: 'distance-zero-rate', + amount: 0, + currency: 'USD', + merchant: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + comment: 'Distance expense', + splitExpenses: [], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 201, + }, + }, + category: 'Car', + created: DateUtils.getDBTime(), + reportID: '456', + }; + + initSplitExpense(transaction, undefined, effectivePolicy, undefined, undefined, undefined, getCurrencyDecimalsLocal, getCurrencySymbolLocal); + await waitForBatchedUpdates(); + + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`); + const splitExpenses = draftTransaction?.comment?.splitExpenses; + expect(splitExpenses).toHaveLength(2); + expect(splitExpenses?.[0].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[0].customUnit?.quantity).toBe(100.5); + expect(splitExpenses?.[1].customUnit?.quantity).toBe(100.5); + expect(splitExpenses?.[0].merchant).toContain('100.50'); + expect(splitExpenses?.[0].merchant).toContain('$0.00'); + }); + it('should thread personalPolicyOutputCurrency into the split mileage rate for a P2P distance expense', async () => { // A P2P distance expense (FAKE_P2P_ID) has no policy rate, so the rate comes from getRateForP2P, // which only honors the transaction's defaultP2PRate when the resolved currency matches the @@ -8674,6 +8739,131 @@ describe('updateSplitExpenseAmountField', () => { expect(splitExpenses?.[0].merchant).toContain('150'); }); + it('keeps the split distance when the amount changes on a $0 rate', async () => { + const customUnitRateID = 'rate-zero-update'; + const customUnitID = 'distance-unit'; + const originalTransactionID = 'orig-zero-rate'; + const currentTransactionID = 'split-zero-rate'; + const policy: Policy = { + ...createRandomPolicy(1), + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: { + unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + rates: { + [customUnitRateID]: { + customUnitRateID, + currency: CONST.CURRENCY.USD, + rate: 0, + enabled: true, + name: 'Untracked', + subRates: [], + }, + }, + }, + }, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`, { + transactionID: originalTransactionID, + amount: 0, + currency: 'USD', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 200, + }, + }, + }); + await waitForBatchedUpdates(); + + const draftTransaction: Transaction = { + transactionID: '235', + amount: 0, + currency: 'USD', + merchant: 'Test Merchant', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + comment: 'Test comment', + originalTransactionID, + splitExpenses: [ + { + transactionID: currentTransactionID, + amount: 0, + description: 'Test comment', + category: 'Car', + tags: [], + created: DateUtils.getDBTime(), + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 100, + }, + }, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + category: 'Car', + created: DateUtils.getDBTime(), + reportID: '456', + }; + + updateSplitExpenseAmountField(draftTransaction, currentTransactionID, 500, policy, false, undefined, getCurrencySymbolLocal, getCurrencyDecimalsLocal); + await waitForBatchedUpdates(); + + const updatedDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; + expect(splitExpenses?.[0].amount).toBe(0); + expect(splitExpenses?.[0].taxAmount).toBe(0); + expect(splitExpenses?.[0].customUnit?.quantity).toBe(100); + expect(Number.isFinite(splitExpenses?.[0].customUnit?.quantity)).toBe(true); + expect(splitExpenses?.[0].merchant).toContain('100.00'); + + // Making the splits even spreads the distance instead, since every split stays $0 + const draftWithTwoSplits: Transaction = { + ...draftTransaction, + comment: { + ...draftTransaction.comment, + splitExpenses: [ + ...(draftTransaction.comment?.splitExpenses ?? []), + { + transactionID: 'split-zero-rate-2', + amount: 0, + created: DateUtils.getDBTime(), + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 30, + }, + }, + ], + }, + }; + const originalZeroRateTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`); + evenlyDistributeSplitExpenseAmounts(draftWithTwoSplits, originalZeroRateTransaction, policy, false, undefined, getCurrencySymbolLocal, getCurrencyDecimalsLocal); + await waitForBatchedUpdates(); + + const evenDraftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const evenSplits = evenDraftTransaction?.comment?.splitExpenses; + expect(evenSplits?.map((split) => split.amount)).toEqual([0, 0]); + expect(evenSplits?.map((split) => split.customUnit?.quantity)).toEqual([100, 100]); + }); + it('should keep the unit the expense is stored with when the workspace distance unit changed', async () => { // Given a distance split created in miles, whose workspace unit was later switched to kilometers const customUnitRateID = 'rate-unit-change'; diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index be4c293946df..4d38df182fe4 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -4002,6 +4002,81 @@ describe('getSecondaryAction', () => { expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.DELETE)).toBe(true); }); + it.each([ + ['includes SPLIT for a $0 distance expense on a $0 rate', 0, true], + ['does not include SPLIT for a $0 distance expense on a rate above $0', 67, false], + ])('%s', async (_caseName, rateValue, expected) => { + const customUnitID = 'distance-unit'; + const customUnitRateID = 'distance-rate'; + const report = createMock({ + reportID: REPORT_ID, + policyID: POLICY_ID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: EMPLOYEE_ACCOUNT_ID, + managerID: EMPLOYEE_ACCOUNT_ID, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + }); + const transaction = createMock({ + transactionID: 'zero-distance-transaction', + reportID: REPORT_ID, + status: CONST.TRANSACTION.STATUS.POSTED, + amount: 0, + merchant: '12.00 mi @ $0.00 / mi', + created: '2025-01-01', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitID, + customUnitRateID, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + quantity: 12, + }, + }, + }); + const policy = createMock({ + id: POLICY_ID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [ADMIN_EMAIL]: {email: ADMIN_EMAIL, role: CONST.POLICY.ROLE.ADMIN}, + [EMPLOYEE_EMAIL]: {email: EMPLOYEE_EMAIL, role: CONST.POLICY.ROLE.USER}, + }, + customUnits: { + [customUnitID]: { + customUnitID, + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + enabled: true, + attributes: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + rates: { + [customUnitRateID]: {customUnitRateID, currency: CONST.CURRENCY.USD, rate: rateValue, enabled: true, name: 'Rate'}, + }, + }, + }, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); + + const result = getSecondaryReportActions({ + currentUserLogin: ADMIN_EMAIL, + currentUserAccountID: ADMIN_ACCOUNT_ID, + submitterLogin: EMPLOYEE_EMAIL, + report, + chatReport, + reportTransactions: [transaction], + originalTransaction: undefined, + violations: {}, + bankAccountList: {}, + policy, + rules: undefined, + }); + + expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.SPLIT)).toBe(expected); + }); + it('includes DELETE option for invoice report submitter when total is zero', async () => { const report = createMock({ reportID: REPORT_ID, diff --git a/tests/unit/libs/SplitExpenseUtils.test.ts b/tests/unit/libs/SplitExpenseUtils.test.ts index c8dbc4005252..dd84ac6ad984 100644 --- a/tests/unit/libs/SplitExpenseUtils.test.ts +++ b/tests/unit/libs/SplitExpenseUtils.test.ts @@ -243,6 +243,30 @@ describe('computeSplitSaveErrorMessage', () => { expect(result).toBe('iou.splitExpenseZeroAmount'); }); + it('allows $0 distance splits that have a distance, like the splits of a $0 rate expense', () => { + const withDistance = (split: SplitExpense, quantity: number): SplitExpense => ({...split, customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity}}); + const result = computeSplitSaveErrorMessage({ + ...baseParams, + transactionDetailsAmount: 0, + isDistance: true, + splitExpenses: [withDistance(makeSplit(0, 'a'), 6), withDistance(makeSplit(0, 'b'), 4)], + }); + expect(result).toBe(''); + }); + + it('still rejects a $0 distance split without any distance', () => { + const result = computeSplitSaveErrorMessage({ + ...baseParams, + transactionDetailsAmount: 0, + isDistance: true, + splitExpenses: [ + {...makeSplit(0, 'a'), customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 10}}, + {...makeSplit(0, 'b'), customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 0}}, + ], + }); + expect(result).toBe('iou.splitExpenseZeroAmount'); + }); + describe('invalidSplit errors (one split exceeds total)', () => { it('returns "greater" error when invalidSplit exists and sum > total', () => { const result = computeSplitSaveErrorMessage({ diff --git a/tests/unit/resolveSplitMileageRateTest.ts b/tests/unit/resolveSplitMileageRateTest.ts index 268a7106c336..b07ef56ebe73 100644 --- a/tests/unit/resolveSplitMileageRateTest.ts +++ b/tests/unit/resolveSplitMileageRateTest.ts @@ -93,6 +93,18 @@ describe('resolveSplitMileageRate', () => { expect(result.rate).toBe(DELETED_RATE_VALUE); }); + + it('returns a $0 rate as a real rate instead of reconstructing one from the transaction', () => { + const policy = buildPolicyWithRates({ + [ACTIVE_RATE_ID]: buildRate(ACTIVE_RATE_ID, {rate: 0, name: ACTIVE_RATE_NAME, index: 0}), + }); + const transaction = buildDistanceTransaction(ACTIVE_RATE_ID, {amount: 0}); + + const result = resolveSplitMileageRate({transaction, policy, isSelfDMSplit: false, personalPolicyOutputCurrency: undefined}); + + expect(result.rate).toBe(0); + expect(result.customUnitRateID).toBe(ACTIVE_RATE_ID); + }); }); describe('selfDM context (isSelfDMSplit=true)', () => { From c2fa2421d9c6df29fb4b855f4ead8f7fe622e2e7 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Fri, 2 Oct 2026 13:56:50 -0500 Subject: [PATCH 03/15] Pass the full rate form values in the validateRateValue tests --- tests/unit/PolicyDistanceRatesUtilsTest.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/unit/PolicyDistanceRatesUtilsTest.ts b/tests/unit/PolicyDistanceRatesUtilsTest.ts index fe55971f9331..c3499154c020 100644 --- a/tests/unit/PolicyDistanceRatesUtilsTest.ts +++ b/tests/unit/PolicyDistanceRatesUtilsTest.ts @@ -20,23 +20,24 @@ import {translateLocal} from '../utils/TestHelper'; describe('PolicyDistanceRatesUtils', () => { describe('validateRateValue', () => { const toLocaleDigit = (digit: string) => digit; + const validate = (rate: string) => validateRateValue({rate, startDate: '', endDate: ''}, toLocaleDigit, translateLocal).rate; it('should allow a rate of 0', () => { - expect(validateRateValue({rate: '0'}, toLocaleDigit, translateLocal).rate).toBeUndefined(); - expect(validateRateValue({rate: '0.00'}, toLocaleDigit, translateLocal).rate).toBeUndefined(); + expect(validate('0')).toBeUndefined(); + expect(validate('0.00')).toBeUndefined(); }); it('should allow a positive rate', () => { - expect(validateRateValue({rate: '0.725'}, toLocaleDigit, translateLocal).rate).toBeUndefined(); + expect(validate('0.725')).toBeUndefined(); }); it('should reject a negative rate', () => { - expect(validateRateValue({rate: '-1'}, toLocaleDigit, translateLocal).rate).toBe(translateLocal('common.error.lowRateError')); + expect(validate('-1')).toBe(translateLocal('common.error.lowRateError')); }); it('should reject a value that is not a number', () => { - expect(validateRateValue({rate: '.'}, toLocaleDigit, translateLocal).rate).toBe(translateLocal('common.error.invalidRateError')); - expect(validateRateValue({rate: 'abc'}, toLocaleDigit, translateLocal).rate).toBe(translateLocal('common.error.invalidRateError')); + expect(validate('.')).toBe(translateLocal('common.error.invalidRateError')); + expect(validate('abc')).toBe(translateLocal('common.error.invalidRateError')); }); }); From c4b37e1135ee63df52e3aea6889f2b59ccd933b9 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Fri, 2 Oct 2026 17:07:29 -0500 Subject: [PATCH 04/15] Make split amounts read-only for $0 distance expenses and explain why --- .../SelectionList/ListItem/SplitListItem.tsx | 2 +- .../SplitListItem/SplitAmountInput.tsx | 2 +- .../SplitListItem/SplitPercentageInput.tsx | 2 +- .../SelectionList/ListItem/types.ts | 6 ++ src/languages/de.ts | 1 + src/languages/el.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/pages/iou/DynamicSplitExpensePage.tsx | 15 +++- .../ui/SplitListItemInputEditabilityTest.tsx | 88 +++++++++++++++++++ tests/ui/SplitListItemTest.tsx | 15 ++++ 18 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 tests/ui/SplitListItemInputEditabilityTest.tsx diff --git a/src/components/SelectionList/ListItem/SplitListItem.tsx b/src/components/SelectionList/ListItem/SplitListItem.tsx index 62ab0ed01845..8d8f0eef9347 100644 --- a/src/components/SelectionList/ListItem/SplitListItem.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem.tsx @@ -85,7 +85,7 @@ function SplitListItem({ // cleanup can cancel any pending focus task when the screen starts closing, preventing // the focused input from interfering with the close animation. const inputCallbackRef: (ref: BaseTextInputRef | null) => void = (ref) => { - if (!splitItem.isSelected || !splitItem.isEditable) { + if (!splitItem.isSelected || !splitItem.isEditable || splitItem.isAmountEditable === false) { return; } (autoFocusCallbackRef as unknown as (ref: BaseTextInputRef | null) => void)(ref); diff --git a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx index 54f028ffd0ad..7179fab08fe7 100644 --- a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx @@ -40,7 +40,7 @@ function SplitAmountInput({splitItem, formattedOriginalAmount, contentWidth, onS return convertToFrontendAmountAsString(amountAsInt, decimals); }; - if (splitItem.isEditable) { + if (splitItem.isEditable && splitItem.isAmountEditable !== false) { return ( { diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts index e298ac6c1d58..83a010fce359 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -303,6 +303,12 @@ type SplitListItemType = ListItem & /** Indicates whether a split wasn't approved, paid etc. when report.statusNum < CONST.REPORT.STATUS_NUM.CLOSED */ isEditable: boolean; + /** + * Whether the split's amount or percentage can be typed in. The split can still be opened to edit its other fields + * when this is false, e.g. a split of a $0 rate distance expense is always $0 but its distance can change. + */ + isAmountEditable?: boolean; + /** Current mode for the split editor: amount or percentage */ mode: ValueOf; diff --git a/src/languages/de.ts b/src/languages/de.ts index 1bf45f313419..8130ecebae24 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1370,6 +1370,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `Der Gesamtbetrag ist um ${amount} höher als die ursprüngliche Ausgabe.`, totalAmountLessThanOriginal: (amount: string) => `Der Gesamtbetrag ist um ${amount} geringer als die ursprüngliche Ausgabe.`, splitExpenseZeroAmount: 'Bitte gib einen gültigen Betrag ein, bevor du fortfährst.', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `Der Betrag kann nicht bearbeitet werden, wenn die ursprüngliche Summe ${amount} beträgt.`, splitExpenseOneMoreSplit: 'Keine Aufteilungen hinzugefügt. Füge mindestens eine hinzu, um zu speichern.', splitExpenseEditTitle: (amount: string, merchant: string) => `${amount} für ${merchant} bearbeiten`, removeSplit: 'Aufteilung entfernen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 6ada172a6c30..7cbb272840ac 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1415,6 +1415,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `Το συνολικό ποσό είναι κατά ${amount} μεγαλύτερο από την αρχική δαπάνη.`, totalAmountLessThanOriginal: (amount: string) => `Το συνολικό ποσό είναι κατά ${amount} λιγότερο από την αρχική δαπάνη.`, splitExpenseZeroAmount: 'Παρακαλώ εισαγάγετε ένα έγκυρο ποσό πριν συνεχίσετε.', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `Το ποσό δεν μπορεί να επεξεργαστεί όταν το αρχικό σύνολο είναι ${amount}.`, splitExpenseOneMoreSplit: 'Δεν προστέθηκαν κατανομές. Προσθέστε τουλάχιστον μία για να αποθηκεύσετε.', splitExpenseEditTitle: (amount: string, merchant: string) => `Επεξεργαστείτε ${amount} για ${merchant}`, removeSplit: 'Αφαίρεση διαχωρισμού', diff --git a/src/languages/en.ts b/src/languages/en.ts index a54100a01c42..56b26ca92253 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1472,6 +1472,7 @@ const translations = { totalAmountGreaterThanOriginal: (amount: string) => `Total amount is ${amount} greater than the original expense.`, totalAmountLessThanOriginal: (amount: string) => `Total amount is ${amount} less than the original expense.`, splitExpenseZeroAmount: 'Please enter a valid amount before continuing.', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `Amount can't be edited when the original total is ${amount}.`, splitExpenseOneMoreSplit: 'No splits added. Add at least one to save.', splitExpenseEditTitle: (amount: string, merchant: string) => `Edit ${amount} for ${merchant}`, removeSplit: 'Remove split', diff --git a/src/languages/es.ts b/src/languages/es.ts index 832942178dce..dd07c44960d0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1360,6 +1360,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount) => `El importe total es ${amount} mayor que el gasto original.`, totalAmountLessThanOriginal: (amount) => `El importe total es ${amount} menor que el gasto original.`, splitExpenseZeroAmount: 'Por favor, introduce un importe válido antes de continuar.', + splitExpenseZeroTotalAmountNotEditable: (amount) => `El importe no se puede editar cuando el total original es ${amount}.`, splitExpenseOneMoreSplit: 'No se han añadido divisiones. Añade al menos una para guardar.', splitExpenseEditTitle: (amount, merchant) => `Editar ${amount} para ${merchant}`, removeSplit: 'Eliminar división', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0e30d04d92a1..ff595cf5b10e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1375,6 +1375,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `Le montant total est supérieur de ${amount} à la dépense initiale.`, totalAmountLessThanOriginal: (amount: string) => `Le montant total est inférieur de ${amount} à la dépense initiale.`, splitExpenseZeroAmount: 'Veuillez saisir un montant valide avant de continuer.', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `Le montant ne peut pas être modifié lorsque le total initial est de ${amount}.`, splitExpenseOneMoreSplit: 'Aucune répartition ajoutée. Ajoutez-en au moins une pour enregistrer.', splitExpenseEditTitle: (amount: string, merchant: string) => `Modifier ${amount} pour ${merchant}`, removeSplit: 'Supprimer la répartition', diff --git a/src/languages/it.ts b/src/languages/it.ts index f1915568e5aa..b3c60fa510b0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1367,6 +1367,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `L’importo totale è ${amount} superiore rispetto alla spesa originale.`, totalAmountLessThanOriginal: (amount: string) => `L’importo totale è di ${amount} inferiore rispetto alla spesa originale.`, splitExpenseZeroAmount: 'Inserisci un importo valido prima di continuare.', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `L'importo non può essere modificato quando il totale originale è ${amount}.`, splitExpenseOneMoreSplit: 'Nessuna suddivisione aggiunta. Aggiungine almeno una per salvare.', splitExpenseEditTitle: (amount: string, merchant: string) => `Modifica ${amount} per ${merchant}`, removeSplit: 'Rimuovi suddivisione', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b93314050286..4df699f7ecec 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1352,6 +1352,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `合計金額が元の経費よりも${amount}多くなっています。`, totalAmountLessThanOriginal: (amount: string) => `合計金額は元の経費より${amount}少なくなっています。`, splitExpenseZeroAmount: '続行する前に有効な金額を入力してください。', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `元の合計が${amount}の場合、金額は編集できません。`, splitExpenseOneMoreSplit: '分割が追加されていません。保存するには少なくとも 1 件追加してください。', splitExpenseEditTitle: (amount: string, merchant: string) => `${merchant} の ${amount} を編集`, removeSplit: '分割を削除', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f0c82b8081f2..a35ac2d97973 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1368,6 +1368,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `Het totale bedrag is ${amount} hoger dan de oorspronkelijke uitgave.`, totalAmountLessThanOriginal: (amount: string) => `Het totale bedrag is ${amount} minder dan de oorspronkelijke uitgave.`, splitExpenseZeroAmount: 'Voer een geldig bedrag in voordat je verdergaat.', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `Het bedrag kan niet worden bewerkt als het oorspronkelijke totaal ${amount} is.`, splitExpenseOneMoreSplit: 'Geen splitsingen toegevoegd. Voeg er minimaal één toe om op te slaan.', splitExpenseEditTitle: (amount: string, merchant: string) => `${amount} voor ${merchant} bewerken`, removeSplit: 'Splits verwijderen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index cf795be6b746..93442435b77d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1401,6 +1401,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `Kwota łączna jest o ${amount} wyższa niż pierwotny wydatek.`, totalAmountLessThanOriginal: (amount: string) => `Kwota całkowita jest o ${amount} mniejsza niż pierwotny wydatek.`, splitExpenseZeroAmount: 'Wprowadź prawidłową kwotę przed kontynuowaniem.', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `Kwoty nie można edytować, gdy pierwotna suma wynosi ${amount}.`, splitExpenseOneMoreSplit: 'Nie dodano podziałów. Dodaj co najmniej jeden, aby zapisać.', splitExpenseEditTitle: (amount: string, merchant: string) => `Edytuj ${amount} dla ${merchant}`, removeSplit: 'Usuń podział', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e2f6350a679a..54211681f21b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1367,6 +1367,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `O valor total é ${amount} maior do que a despesa original.`, totalAmountLessThanOriginal: (amount: string) => `O valor total é ${amount} menor que a despesa original.`, splitExpenseZeroAmount: 'Insira um valor válido antes de continuar.', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `O valor não pode ser editado quando o total original é ${amount}.`, splitExpenseOneMoreSplit: 'Nenhuma divisão adicionada. Adicione pelo menos uma para salvar.', splitExpenseEditTitle: (amount: string, merchant: string) => `Editar ${amount} para ${merchant}`, removeSplit: 'Remover divisão', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8f5eb637f8cd..80c7c6076e36 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1315,6 +1315,7 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `总金额比原始报销多出 ${amount}。`, totalAmountLessThanOriginal: (amount: string) => `总金额比原始报销少 ${amount}。`, splitExpenseZeroAmount: '请在继续之前输入有效金额。', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `原始总额为 ${amount} 时,无法编辑金额。`, splitExpenseOneMoreSplit: '尚未添加拆分。请至少添加一项以保存。', splitExpenseEditTitle: (amount: string, merchant: string) => `编辑 ${merchant} 的 ${amount}`, removeSplit: '移除拆分', diff --git a/src/pages/iou/DynamicSplitExpensePage.tsx b/src/pages/iou/DynamicSplitExpensePage.tsx index deac0eba5ecf..887eb94b1263 100644 --- a/src/pages/iou/DynamicSplitExpensePage.tsx +++ b/src/pages/iou/DynamicSplitExpensePage.tsx @@ -475,6 +475,9 @@ function DynamicSplitExpensePage({route}: DynamicSplitExpensePageProps) { const splitAmounts = splitExpensesArray.map((item) => Number(item.amount ?? 0)); const adjustedPercentages = calculateSplitPercentagesFromAmounts(splitAmounts, transactionDetailsAmount); + // A $0 distance expense is on a $0 rate, so every split stays $0 and only its distance can change + const isZeroTotalDistanceSplit = isDistance && transactionDetailsAmount === 0; + const options: SplitListItemType[] = splitExpensesArray.map((item, index): SplitListItemType => { const previewHeaderText: TranslationPathOrText[] = [transactionTypeTranslationPath]; const currentTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${item?.transactionID}`]; @@ -515,6 +518,7 @@ function DynamicSplitExpensePage({route}: DynamicSplitExpensePageProps) { isSelected: splitExpenseTransactionID === item.transactionID, keyForList: item?.transactionID, isEditable: isSplitExpenseEditable(item), + isAmountEditable: !isZeroTotalDistanceSplit, }; }); @@ -526,14 +530,19 @@ function DynamicSplitExpensePage({route}: DynamicSplitExpensePageProps) { convertToDisplayString, }); + const infoMessage = isZeroTotalDistanceSplit + ? translate('iou.splitExpenseZeroTotalAmountNotEditable', convertToDisplayString(0, transactionDetails?.currency ?? CONST.CURRENCY.USD)) + : ''; + const helpMessage = errorMessage || warningMessage || infoMessage; + const footerContent = ( - {(!!errorMessage || !!warningMessage) && ( + {!!helpMessage && ( )}