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/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index a380f80fb72c..bdd64a02cd95 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -29,7 +29,7 @@ import useReportTransactionsCollection from '@hooks/useReportTransactionsCollect import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useRestrictedActionPolicyID from '@hooks/useRestrictedActionPolicyID'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; -import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; +import useSplitEffectivePolicy, {findSplitPolicyForCustomUnit} from '@hooks/useSplitEffectivePolicy'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; @@ -84,8 +84,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Transaction} from '@src/types/onyx'; +import type {Policy, Transaction} from '@src/types/onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {useRoute} from '@react-navigation/native'; @@ -94,7 +95,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import {loginSelector} from '@selectors/PersonalDetails'; import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; -import React, {useRef} from 'react'; +import React, {useCallback, useRef} from 'react'; import type {ButtonWithDropdownMenuRef, DropdownOption} from './ButtonWithDropdownMenu/types'; @@ -188,6 +189,15 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); const splitEffectivePolicy = useSplitEffectivePolicy(report, undefined, transaction); + // Only the workspace that owns the expense's distance rate is needed to tell whether the rate is $0 + const distanceRatePoliciesSelector = useCallback( + (allPolicies: OnyxCollection) => { + const distanceRatePolicy = findSplitPolicyForCustomUnit(allPolicies, transaction); + return distanceRatePolicy ? {[`${ONYXKEYS.COLLECTION.POLICY}${distanceRatePolicy.id}`]: distanceRatePolicy} : undefined; + }, + [transaction], + ); + const [distanceRatePolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: distanceRatePoliciesSelector}); const personalPolicy = usePersonalPolicy(); const restrictedActionPolicyID = useRestrictedActionPolicyID(policy); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); @@ -395,6 +405,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money isRestrictedToPreferredPolicy, rules, cardList, + policies: distanceRatePolicies, }); })(); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index a1b6557cef33..2019c96ad20e 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -646,8 +646,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/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index cf9ad99b6b86..40767213f162 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -154,6 +154,7 @@ function useReconcileSelectionWithData({ const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected} = useSearchSelectionContext(); const {applySelection} = useSearchSelectionActions(); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); useEffect(() => { const shouldReconcileMovedExcludedTransaction = @@ -279,6 +280,7 @@ function useReconcileSelectionWithData({ allowNegativeAmount: true, parentReport: itemParentReport, rules, + policies, }); const liveSelectionEntry: SelectedTransactionInfo = { @@ -364,6 +366,7 @@ function useReconcileSelectionWithData({ allowNegativeAmount: true, parentReport: itemParentReport, rules, + policies, }); const liveSelectionEntry: SelectedTransactionInfo = { @@ -539,6 +542,7 @@ function SearchWriteActionsProvider({ const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {applySelection, getSelectedTransactions, getExcludedTransactions, getAreAllMatchingItemsSelected} = useSearchSelectionActions(); const {openGroupKeys, shiftRangeGroupsActions} = useOpenGroupsRegistry(searchHash); @@ -586,6 +590,7 @@ function SearchWriteActionsProvider({ allowNegativeAmount: true, parentReport, rules, + policies, }); }; @@ -741,6 +746,7 @@ function SearchWriteActionsProvider({ selfDMReport, parentReport: itemParentReport, rules, + policies, }); if (areItemsGrouped && isGroupedItemArray(filteredData)) { diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts index e5690c1e36c7..9e602d27b48a 100644 --- a/src/components/Search/selectionBuilders.ts +++ b/src/components/Search/selectionBuilders.ts @@ -5,7 +5,7 @@ import type {ShiftRangeBatch} from '@libs/shiftRangeSelection'; import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isExpenseUnreported, isOnHold, isTransactionPendingDelete} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; -import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportNameValuePairs, Rule, Transaction} from '@src/types/onyx'; +import type {OutstandingReportsByPolicyIDDerivedValue, Policy, Report, ReportNameValuePairs, Rule, Transaction} from '@src/types/onyx'; import type {SearchGroupBase, SearchResultDataType} from '@src/types/onyx/SearchResults'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -133,6 +133,9 @@ type MapTransactionItemToSelectedEntryParams = { /** Approval workflow rules, used for split eligibility */ rules: OnyxCollection; + + /** Workspaces to look up a distance rate in for split eligibility, when the row's policy doesn't own it */ + policies?: OnyxCollection; }; /** @@ -152,6 +155,7 @@ function mapTransactionItemToSelectedEntry({ allowNegativeAmount, parentReport, rules, + policies, }: MapTransactionItemToSelectedEntryParams): [string, SelectedTransactionInfo] { const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy, currentUserAccountID, rules); const canRejectRequest = item.report ? canRejectReportAction(item.report, currentUserAccountID, item.policy) : false; @@ -170,7 +174,18 @@ function mapTransactionItemToSelectedEntry({ canUnhold: canUnholdRequest, // TODO: Pass reportOwnerLogin in Part 8. This runs once per search result, so it needs the personal details list and a per row lookup rather than a hook. // isAwaitingFirstLevelApproval falls back to the personal details store until then. See https://github.com/Expensify/App/issues/66413. - canSplit: isSplitAction(reportForSplit, [itemTransaction], originalItemTransaction, currentUserLogin, currentUserAccountID, rules, undefined, item.policy, parentReport), + canSplit: isSplitAction( + reportForSplit, + [itemTransaction], + originalItemTransaction, + currentUserLogin, + currentUserAccountID, + rules, + undefined, + item.policy, + parentReport, + policies, + ), hasBeenSplit: getOriginalTransactionWithSplitInfo(itemTransaction, originalItemTransaction).isExpenseSplit, canChangeReport: canEditFieldOfMoneyRequest({ reportAction: item.reportAction, @@ -283,6 +298,9 @@ type PrepareTransactionsListParams = { /** Approval workflow rules, used for split eligibility */ rules: OnyxCollection; + + /** Workspaces to look up a distance rate in for split eligibility, when the row's policy doesn't own it */ + policies?: OnyxCollection; }; /** @@ -301,6 +319,7 @@ function prepareTransactionsList({ selfDMReport, parentReport, rules, + policies, }: PrepareTransactionsListParams) { if (selectedTransactions[item.keyForList]?.isSelected) { const {[item.keyForList]: omittedTransaction, ...transactions} = selectedTransactions; @@ -320,6 +339,7 @@ function prepareTransactionsList({ allowNegativeAmount: false, parentReport, rules, + policies, }); return { diff --git a/src/components/SelectionList/ListItem/SplitListItem.tsx b/src/components/SelectionList/ListItem/SplitListItem.tsx index f082159686a3..e005fa4a8b20 100644 --- a/src/components/SelectionList/ListItem/SplitListItem.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem.tsx @@ -71,7 +71,7 @@ function SplitListItem({item, isFocused, showTo // 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 (!item.isSelected || !item.isEditable) { + if (!item.isSelected || !item.isEditable || item.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 cb94d2b9ba4f..6200c13d79c8 100644 --- a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx @@ -36,7 +36,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 b6362fe09f4c..9e195bb4d3f0 100644 --- a/src/components/SelectionList/ListItem/types.ts +++ b/src/components/SelectionList/ListItem/types.ts @@ -295,6 +295,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 be updated. + */ + isAmountEditable?: boolean; + /** Current mode for the split editor: amount or percentage */ mode: ValueOf; diff --git a/src/hooks/useFrozenSplitTransactionIDs.ts b/src/hooks/useFrozenSplitTransactionIDs.ts index b9eea179ff42..da18fa1d362d 100644 --- a/src/hooks/useFrozenSplitTransactionIDs.ts +++ b/src/hooks/useFrozenSplitTransactionIDs.ts @@ -64,6 +64,7 @@ function useFrozenSplitTransactionIDs({ getLoginByAccountID(itemReport?.ownerAccountID, personalDetails), itemPolicy, parentReport, + allPolicies, ); if (isSplitExpenseFrozen(itemReport) || !isEditable) { frozenIDs.add(item.transactionID); diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 6393e207fbb0..c06c1e5ccdef 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -573,6 +573,7 @@ function useSelectedTransactionsActions({ getLoginByAccountID(report?.ownerAccountID, personalDetails), policy, parentReport, + allPolicies, ); if (canSplitTransaction) { diff --git a/src/languages/de.ts b/src/languages/de.ts index c7ea8d0d5807..2486c9d8952d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -243,7 +243,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.', }, @@ -1437,6 +1437,8 @@ 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.', + splitExpenseZeroDistance: 'Bitte gib eine gültige Entfernung 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 05a4b0d891aa..dab39ec0e0be 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -248,7 +248,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: 'Συμπληρώστε τη φόρμα παραπάνω για να συνεχίσετε', pleaseSelectOne: 'Παρακαλώ επιλέξτε μια από τις παραπάνω επιλογές', invalidRateError: 'Παρακαλούμε εισαγάγετε έγκυρο συντελεστή', - lowRateError: 'Το ποσοστό πρέπει να είναι μεγαλύτερο από 0', + lowRateError: 'Το ποσοστό δεν μπορεί να είναι αρνητικό', email: 'Παρακαλούμε εισαγάγετε μια έγκυρη διεύθυνση email', login: 'Προέκυψε σφάλμα κατά τη σύνδεση. Παρακαλούμε δοκιμάστε ξανά.', }, @@ -1481,6 +1481,8 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `Το συνολικό ποσό είναι κατά ${amount} μεγαλύτερο από την αρχική δαπάνη.`, totalAmountLessThanOriginal: (amount: string) => `Το συνολικό ποσό είναι κατά ${amount} λιγότερο από την αρχική δαπάνη.`, splitExpenseZeroAmount: 'Παρακαλώ εισαγάγετε ένα έγκυρο ποσό πριν συνεχίσετε.', + splitExpenseZeroDistance: 'Παρακαλώ εισαγάγετε μια έγκυρη απόσταση πριν συνεχίσετε.', + 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 6d8208ee233c..e61cbd04db78 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -312,7 +312,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.', }, @@ -1573,6 +1573,8 @@ 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.', + splitExpenseZeroDistance: 'Please enter a valid distance 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 e2997c44b545..98eabc75aec5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -235,7 +235,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.', }, @@ -1424,6 +1424,8 @@ 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.', + splitExpenseZeroDistance: 'Por favor, introduce una distancia válida 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 50358123378a..8203c4298d19 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -243,7 +243,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.', }, @@ -1442,6 +1442,8 @@ 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.', + splitExpenseZeroDistance: 'Veuillez saisir une distance 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 c9a293f0a292..3fbd3077e80a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -243,7 +243,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.', }, @@ -1434,6 +1434,8 @@ 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.', + splitExpenseZeroDistance: 'Inserisci una distanza valida 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 a2f6cc6054d2..0d4444998ef5 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -243,7 +243,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: '続行するには上のフォームに入力してください', pleaseSelectOne: '上からオプションを選択してください', invalidRateError: '有効なレートを入力してください', - lowRateError: 'レートは0より大きくなければなりません', + lowRateError: 'レートを負の値にすることはできません', email: '有効なメールアドレスを入力してください', login: 'ログイン中にエラーが発生しました。もう一度お試しください。', }, @@ -1420,6 +1420,8 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `合計金額が元の経費よりも${amount}多くなっています。`, totalAmountLessThanOriginal: (amount: string) => `合計金額は元の経費より${amount}少なくなっています。`, splitExpenseZeroAmount: '続行する前に有効な金額を入力してください。', + splitExpenseZeroDistance: '続行する前に有効な距離を入力してください。', + 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 9723637e0ad0..d0526440a11e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -243,7 +243,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.', }, @@ -1435,6 +1435,8 @@ 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.', + splitExpenseZeroDistance: 'Voer een geldige afstand 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 acac974e8a83..1c14d26ff9f5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -249,7 +249,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.', }, @@ -1471,6 +1471,8 @@ 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.', + splitExpenseZeroDistance: 'Wprowadź prawidłową odległość 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 72d4ca619210..2e10119cc1aa 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -243,7 +243,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.', }, @@ -1434,6 +1434,8 @@ 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.', + splitExpenseZeroDistance: 'Insira uma distância válida 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 d853af9d3c81..3ab372827d72 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -243,7 +243,7 @@ const translations: TranslationDeepObject = { pleaseCompleteForm: '请填写上面的表单以继续', pleaseSelectOne: '请在上方选择一个选项', invalidRateError: '请输入有效的费率', - lowRateError: '费率必须大于 0', + lowRateError: '费率不能为负数', email: '请输入有效的邮箱地址', login: '登录时发生错误。请重试。', }, @@ -1382,6 +1382,8 @@ const translations: TranslationDeepObject = { totalAmountGreaterThanOriginal: (amount: string) => `总金额比原始报销多出 ${amount}。`, totalAmountLessThanOriginal: (amount: string) => `总金额比原始报销少 ${amount}。`, splitExpenseZeroAmount: '请在继续之前输入有效金额。', + splitExpenseZeroDistance: '请在继续之前输入有效距离。', + splitExpenseZeroTotalAmountNotEditable: (amount: string) => `原始总额为 ${amount} 时,无法编辑金额。`, splitExpenseOneMoreSplit: '尚未添加拆分。请至少添加一项以保存。', splitExpenseEditTitle: (amount: string, merchant: string) => `编辑 ${merchant} 的 ${amount}`, removeSplit: '移除拆分', diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index dc2cf14333b2..f0e3ebc6f8f2 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -21,7 +21,7 @@ import {convertDistanceUnit, convertToDistanceInMeters} from './DistanceUnitConv import getStoredDefaultP2PMileageRate from './getStoredDefaultP2PMileageRate'; import {getDistanceRateCustomUnit, getDistanceRateCustomUnitRate, getUnitRateValue} from './PolicyUtils'; import replaceAllDigits from './replaceAllDigits'; -import {getCurrency, getFormattedCreated, getRateID, isCustomUnitRateIDForP2P, isExpenseUnreported} from './TransactionUtils'; +import {getAmount, getCurrency, getFormattedCreated, getRateID, isCustomUnitRateIDForP2P, isExpenseUnreported} from './TransactionUtils'; type MileageRate = { customUnitRateID?: string; @@ -156,10 +156,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'); } @@ -258,7 +258,7 @@ function getDistanceMerchant( isZeroDistanceAllowed?: boolean, commuterExclusionData?: CommuterExclusionData | null, ): string { - if (!hasRoute || !rate) { + if (!hasRoute || rate === undefined) { return translate('iou.fieldPending'); } @@ -824,6 +824,26 @@ function getDistanceUnit(transaction: OnyxEntry, mileageRate: OnyxE return transaction?.comment?.customUnit?.distanceUnit ?? mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; } +/** + * The rate a distance expense was calculated with, when its rate has since been changed to or from $0. A $0 rate can't have + * produced a nonzero amount, and a positive rate only produces a $0 amount when the distance is short enough to round to $0. + * Returns undefined when `currentRate` can have produced the expense's amount. + */ +function getRateBeforeZeroRateChange(transaction: OnyxEntry, currentRate: number | undefined): number | undefined { + const quantity = transaction?.comment?.customUnit?.quantity; + if (typeof quantity !== 'number' || quantity <= 0 || currentRate === undefined || currentRate < 0) { + return undefined; + } + const amount = getAmount(transaction); + if (currentRate === 0 && amount !== 0) { + return amount / quantity; + } + if (currentRate > 0 && amount === 0 && Math.round(quantity * currentRate) !== 0) { + return 0; + } + return undefined; +} + /** * Get the selected rate for a transaction, from the policy or P2P default rate. * Use the distanceUnit stored on the transaction by default to prevent policy changes modifying existing transactions. Otherwise, get the unit from the rate. @@ -1097,6 +1117,7 @@ export default { getDistanceUnitLabel, getUpdatedDistanceUnit, getRate, + getRateBeforeZeroRateChange, getRateByCustomUnitRateID, getRateByCustomUnitRateIDAcrossPolicies, getEnabledRateByCustomUnitRateIDFromAnyPolicy, diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index 1b4d89bdd1d7..04a638c07d30 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -83,7 +83,7 @@ function getDistanceRateNameErrorMessage(translate: LocalizedTranslate, error: D } /** - * Validates a distance rate amount against the same rules as the RHP edit form (format and > 0). + * Validates a distance rate amount against the same rules as the RHP edit form (format and >= 0). * Returns an error code, or undefined when the value is valid. */ function getDistanceRateValueError(rate: string, toLocaleDigit: (arg: string) => string): DistanceRateValueError | undefined { @@ -91,11 +91,12 @@ function getDistanceRateValueError(rate: string, toLocaleDigit: (arg: string) => const decimalSeparator = 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'); + const rateValue = parseFloatAnyLocale(parsedRate); - if (!rateValueRegex.test(parsedRate) || parsedRate === '') { + if (!rateValueRegex.test(parsedRate) || parsedRate === '' || Number.isNaN(rateValue)) { return CONST.INPUT_VALIDATION_ERRORS.INVALID; } - if (parseFloatAnyLocale(parsedRate) <= 0) { + if (rateValue < 0) { return CONST.INPUT_VALIDATION_ERRORS.TOO_LOW; } diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 26e38dc7566e..97d509e991e6 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -19,6 +19,7 @@ import type { import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import DistanceRequestUtils from './DistanceRequestUtils'; import {areTransactionsEligibleForMerge} from './MergeTransactionUtils'; import {getLoginByAccountID} from './PersonalDetailsUtils'; import { @@ -27,6 +28,7 @@ import { canMemberWrite, getConnectedIntegration, getCorrectedAutoReportingFrequency, + getDistanceRateCustomUnitRate, getManagerAccountID, getReimbursementChoice, getSubmitToAccountID, @@ -100,6 +102,7 @@ import { hasReceipt as hasReceiptTransactionUtils, hasSmartScanFailedWithMissingFields, hasSubmissionBlockingViolations, + isDistanceRequest as isDistanceRequestTransactionUtils, isDuplicate, isManagedCardTransaction as isManagedCardTransactionTransactionUtils, isOnHold as isOnHoldTransactionUtils, @@ -132,6 +135,7 @@ function isSplitAction( reportOwnerLogin: string | undefined, policy?: OnyxEntry, parentReport?: OnyxEntry, + policies?: OnyxCollection, ): boolean { if (Number(reportTransactions?.length) !== 1 || !report) { return false; @@ -152,7 +156,23 @@ 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. The rate can belong to a workspace + // other than `policy`, e.g. an unreported expense on another workspace's rate, so it's looked up in `policies` too. + // A rate changed from $0 after the expense was created doesn't change its amount, so it's still split on the $0 rate. + // When no loaded workspace owns it, a $0 amount for a positive distance can only have come from a $0 rate. + const distanceCustomUnit = reportTransaction?.comment?.customUnit; + const distanceCustomUnitRateID = distanceCustomUnit?.customUnitRateID; + const policyDistanceRate = distanceCustomUnitRateID + ? (getDistanceRateCustomUnitRate(policy, distanceCustomUnitRateID) ?? + Object.values(policies ?? {}) + .map((policyItem) => getDistanceRateCustomUnitRate(policyItem, distanceCustomUnitRateID)) + .find((rate) => !!rate)) + : undefined; + const splitDistanceRate = policyDistanceRate ? (DistanceRequestUtils.getRateBeforeZeroRateChange(reportTransaction, policyDistanceRate.rate) ?? policyDistanceRate.rate) : undefined; + const isZeroRateDistanceExpense = + isDistanceRequestTransactionUtils(reportTransaction) && !!distanceCustomUnit?.quantity && !!distanceCustomUnitRateID && (policyDistanceRate ? splitDistanceRate === 0 : !amount); + + if (!amount && !isExpenseSplit && !isZeroRateDistanceExpense) { return false; } @@ -1164,7 +1184,7 @@ function getSecondaryReportActions({ } if ( - isSplitAction(report, reportTransactions, originalTransaction, currentUserLogin, currentUserAccountID, rules, submitterLogin, policy, parentReport) && + isSplitAction(report, reportTransactions, originalTransaction, currentUserLogin, currentUserAccountID, rules, submitterLogin, policy, parentReport, policies) && !shouldShowEditSplitInDeleteAction(report, reportTransactions, reportActions, originalTransaction, currentUserAccountID, policy, rules, submitterLogin, cardList) ) { options.push(CONST.REPORT.SECONDARY_ACTIONS.SPLIT); @@ -1280,6 +1300,7 @@ function getSecondaryTransactionThreadActions({ isRestrictedToPreferredPolicy = false, rules, cardList, + policies, }: { currentUserLogin: string; currentUserAccountID: number; @@ -1308,6 +1329,9 @@ function getSecondaryTransactionThreadActions({ isRestrictedToPreferredPolicy?: boolean; rules: OnyxCollection; cardList: OnyxEntry; + + /** Workspaces to look up a distance rate in when `policy` doesn't own it */ + policies?: OnyxCollection; }): Array> { const options: Array> = []; @@ -1328,7 +1352,7 @@ function getSecondaryTransactionThreadActions({ const parentReportActionsList = Object.values(parentReportActions ?? {}); if ( - isSplitAction(parentReport, [reportTransaction], originalTransaction, currentUserLogin, currentUserAccountID, rules, parentReportOwnerLogin, policy, grandParentReport) && + isSplitAction(parentReport, [reportTransaction], originalTransaction, currentUserLogin, currentUserAccountID, rules, parentReportOwnerLogin, policy, grandParentReport, policies) && !shouldShowEditSplitInDeleteAction( parentReport, [reportTransaction], diff --git a/src/libs/SplitExpenseUtils.ts b/src/libs/SplitExpenseUtils.ts index 9928ef689f5f..641b508db0ce 100644 --- a/src/libs/SplitExpenseUtils.ts +++ b/src/libs/SplitExpenseUtils.ts @@ -27,6 +27,9 @@ type SplitSaveErrorParams = { isCard: boolean; translate: LocalizedTranslate; convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; + + /** The distance splits calculated with a $0 rate, which are always $0 */ + zeroRateSplitTransactionIDs?: Set; }; /** @@ -84,7 +87,17 @@ function computeSplitWarningMessage({splitExpenses, transactionDetailsAmount, cu * * Returns an empty string when there is no amount-based error. */ -function computeSplitSaveErrorMessage({splitExpenses, transactionDetailsAmount, currency, isDistance, isPerDiem, isCard, translate, convertToDisplayString}: SplitSaveErrorParams): string { +function computeSplitSaveErrorMessage({ + splitExpenses, + transactionDetailsAmount, + currency, + isDistance, + isPerDiem, + isCard, + translate, + convertToDisplayString, + zeroRateSplitTransactionIDs, +}: SplitSaveErrorParams): string { if (splitExpenses.length > CONST.IOU.SPLITS_LIMIT) { return translate('iou.error.manySplitsProvided'); } @@ -119,8 +132,15 @@ function computeSplitSaveErrorMessage({splitExpenses, transactionDetailsAmount, return translate(errorKey, convertToDisplayString(Math.abs(lessThanDifference), currency)); } - if (splitExpenses.find((item) => item.amount === 0)) { - return translate('iou.splitExpenseZeroAmount'); + // A distance split on a $0 rate is always $0, so it's only invalid when it has no distance either + const isValidZeroRateSplit = (item: SplitExpense) => isDistance && !!item.customUnit?.quantity && !!zeroRateSplitTransactionIDs?.has(item.transactionID); + const invalidZeroAmountSplits = splitExpenses.filter((item) => item.amount === 0 && !isValidZeroRateSplit(item)); + if (invalidZeroAmountSplits.length > 0) { + // A distance split can have a real, positive distance and still round to $0 on a low (but nonzero) rate — + // that's an amount problem, not a distance one, so only blame the distance when one of the invalid splits + // genuinely has none, regardless of which invalid split happens to come first. + const hasGenuinelyInvalidDistance = isDistance && invalidZeroAmountSplits.some((item) => !item.customUnit?.quantity); + return translate(hasGenuinelyInvalidDistance ? 'iou.splitExpenseZeroDistance' : 'iou.splitExpenseZeroAmount'); } return ''; diff --git a/src/libs/TransactionUtils/getUpdatedTransaction.ts b/src/libs/TransactionUtils/getUpdatedTransaction.ts index 36c51421678b..bf0e57a0058b 100644 --- a/src/libs/TransactionUtils/getUpdatedTransaction.ts +++ b/src/libs/TransactionUtils/getUpdatedTransaction.ts @@ -319,11 +319,12 @@ function getUpdatedTransaction({ // recalculate the updated amount. Otherwise, recalculate the fields based on the new rate. let updatedMileageRate = DistanceRequestUtils.getRate({transaction: updatedTransaction, policy, useTransactionDistanceUnit: false, personalPolicyOutputCurrency}); - // The provided `policy` may not own the new rate, leaving the amount at 0. Fall back to - // resolving the rate across every policy the user belongs to. - if (!updatedMileageRate.rate && transactionChanges.customUnitRateID) { + // The provided `policy` may not own the new rate, leaving the rate unresolved. Fall back to + // resolving the rate across every policy the user belongs to. `=== undefined`/`!== undefined` + // (not truthiness) because a resolved $0 rate is a valid rate, not a missing one. + if (updatedMileageRate.rate === undefined && transactionChanges.customUnitRateID) { const rateFromAnyPolicy = DistanceRequestUtils.getEnabledRateByCustomUnitRateIDFromAnyPolicy(transactionChanges.customUnitRateID, policies); - if (rateFromAnyPolicy?.rate) { + if (rateFromAnyPolicy?.rate !== undefined) { updatedMileageRate = rateFromAnyPolicy; // The fallback rate wasn't known when the distance unit/quantity were set above from the @@ -489,7 +490,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 d6585c8dfa27..920e579bd563 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -183,7 +183,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/src/libs/actions/IOU/SplitExpenseItems.ts b/src/libs/actions/IOU/SplitExpenseItems.ts index e759af50ec89..d9b40157913e 100644 --- a/src/libs/actions/IOU/SplitExpenseItems.ts +++ b/src/libs/actions/IOU/SplitExpenseItems.ts @@ -40,6 +40,38 @@ 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 nonzero rate a distance expense was calculated with, derived from its amount and distance. + */ +function getHistoricalDistanceRate(transaction: OnyxEntry): number | undefined { + const quantity = transaction?.comment?.customUnit?.quantity; + // The edited amount, when there is one, is the amount the current distance was calculated with + const transactionAmount = getAmount(transaction); + if (typeof quantity !== 'number' || quantity <= 0 || transactionAmount === 0) { + return undefined; + } + return transactionAmount / quantity; +} + +/** + * 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 */ @@ -50,7 +82,7 @@ function getDistanceMerchantFromDistance( currency: string, getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'], ): string { - if (!rate || rate <= 0 || !unit) { + if (!isUsableSplitRate(rate) || !unit) { return ''; } @@ -98,7 +130,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, @@ -109,13 +142,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 = { @@ -146,11 +179,15 @@ function resolveSplitMileageRate({ policy, isSelfDMSplit, personalPolicyOutputCurrency, + expenseTransaction, }: { transaction: OnyxEntry; policy: OnyxEntry; isSelfDMSplit?: boolean; personalPolicyOutputCurrency: string | undefined; + + /** The expense being split, when `transaction` is the draft of one of its splits */ + expenseTransaction?: OnyxEntry; }): ReturnType { const customUnitRateID = transaction?.comment?.customUnit?.customUnitRateID; const isP2PRate = customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID; @@ -163,14 +200,31 @@ 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) { + const historicalRate = getHistoricalDistanceRate(transaction); + + // When the rate was changed to or from $0 after the expense was created, split with the rate the expense was calculated + // with, so its amount and distance are divided consistently. A split draft's amount was calculated before its distance or + // rate was edited, so the expense's rate is used, and only while the split is still on the expense's rate. + let rateBeforeZeroRateChange = DistanceRequestUtils.getRateBeforeZeroRateChange(transaction, baseMileageRate.rate); + if (expenseTransaction) { + rateBeforeZeroRateChange = + expenseTransaction.comment?.customUnit?.customUnitRateID === customUnitRateID + ? DistanceRequestUtils.getRateBeforeZeroRateChange(expenseTransaction, baseMileageRate.rate) + : undefined; + } + if (rateBeforeZeroRateChange !== undefined && !isOriginalRateDeleted) { + return {...baseMileageRate, rate: rateBeforeZeroRateChange}; + } + 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. + // current default mileage rate so split surfaces use a real (enabled) rate. A $0 default rate + // can't reproduce a nonzero expense's amount, so it's only used as the fallback for a $0 expense. if (policy) { const fallbackMileageRate = DistanceRequestUtils.getDefaultMileageRate(policy); - if (fallbackMileageRate?.rate) { + const isZeroRateFallbackForNonZeroExpense = fallbackMileageRate?.rate === 0 && !!getAmount(transaction); + if (fallbackMileageRate?.rate !== undefined && !isZeroRateFallbackForNonZeroExpense) { return fallbackMileageRate; } } @@ -178,18 +232,13 @@ 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) { - const quantity = transaction?.comment?.customUnit?.quantity; - const transactionAmount = transaction?.amount; - if (typeof quantity === 'number' && quantity > 0 && typeof transactionAmount === 'number' && transactionAmount !== 0) { - const derivedRate = Math.abs(transactionAmount) / quantity; - return { - ...baseMileageRate, - customUnitRateID: baseMileageRate.customUnitRateID ?? customUnitRateID, - rate: derivedRate, - currency: baseMileageRate.currency ?? transaction?.currency ?? CONST.CURRENCY.USD, - }; - } + if (baseMileageRate.rate === undefined && !isP2PRate && historicalRate !== undefined) { + return { + ...baseMileageRate, + customUnitRateID: baseMileageRate.customUnitRateID ?? customUnitRateID, + rate: historicalRate, + currency: baseMileageRate.currency ?? transaction?.currency ?? CONST.CURRENCY.USD, + }; } return baseMileageRate; } @@ -219,13 +268,46 @@ 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}; + } + + // The split is still on the expense's own rate, which `resolveSplitMileageRate` replaced with the rate the expense was + // calculated with because it was changed to or from $0 after the expense was created + const isRateBeforeZeroRateChange = selectedRate.rate !== fallbackMileageRate.rate && (selectedRate.rate === 0 || fallbackMileageRate.rate === 0); + if (isRateBeforeZeroRateChange && customUnitRateID === fallbackMileageRate.customUnitRateID && isUsableSplitRate(fallbackMileageRate.rate)) { return {rate: fallbackMileageRate.rate, unit}; } return {rate: selectedRate.rate, unit}; } +/** + * Whether a distance split is calculated with a $0 rate, which makes its amount always $0 whatever is typed in. + */ +function isZeroRateDistanceSplit({ + splitExpense, + transaction, + policy, + isSelfDMSplit, + personalPolicyOutputCurrency, + policies, +}: { + splitExpense: SplitExpense; + transaction: OnyxEntry; + policy: OnyxEntry; + isSelfDMSplit?: boolean; + personalPolicyOutputCurrency: string | undefined; + policies?: OnyxCollection; +}): boolean { + if (!transaction || !isDistanceRequestTransactionUtils(transaction) || !splitExpense.customUnit) { + return false; + } + const mileageRate = resolveSplitMileageRate({transaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); + const {rate} = resolveSplitItemRate({customUnit: splitExpense.customUnit, fallbackMileageRate: mileageRate, policy, policies}); + return rate === 0; +} + function resolveSplitItemReportID({ childTransaction, allReports, @@ -533,7 +615,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, @@ -648,9 +730,23 @@ 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 || (total === 0 && isUsableSplitRate(rate))) { + // A $0 rate split is always $0 and a $0 expense has no amount to even out, so even out the distance instead. + // Each split's amount then comes from its own share of the distance, which is only nonzero for a split + // the user moved to a positive rate. + const quantity = getEvenSplitQuantity(transaction.comment?.customUnit?.quantity ?? 0, splitCount, index, 0); + const splitAmount = Math.round(quantity * rate); + + updatedSplitExpense = { + ...updatedSplitExpense, + amount: splitAmount, + taxAmount: convertToBackendAmount(calculateTaxAmount(splitExpense.taxValue, splitAmount, getCurrencyDecimals(currency))), + customUnit: {...splitExpense.customUnit, quantity, distanceUnit: unit ?? splitExpense.customUnit.distanceUnit}, + merchant: getDistanceMerchantFromDistance(quantity, unit, rate, transaction.currency ?? mileageRate.currency ?? CONST.CURRENCY.USD, getCurrencySymbol), + }; + } else if (amount !== 0 && isUsableSplitRate(rate)) { const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( amount, rate, @@ -765,9 +861,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, @@ -905,10 +1021,16 @@ function updateSplitExpenseField( // Recalculate amount for distance transactions when rate or distance changes if (isDistanceRequest && originalTransaction) { - const mileageRate = resolveSplitMileageRate({transaction: splitExpenseDraftTransaction, policy, isSelfDMSplit, personalPolicyOutputCurrency}); + const mileageRate = resolveSplitMileageRate({ + transaction: splitExpenseDraftTransaction, + policy, + isSelfDMSplit, + personalPolicyOutputCurrency, + expenseTransaction: originalTransaction, + }); 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 @@ -981,7 +1103,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, @@ -998,6 +1120,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; @@ -1037,6 +1168,9 @@ function updateSplitExpenseDraftField(fields: Partial) { } export { + getEvenSplitQuantity, + isUsableSplitRate, + isZeroRateDistanceSplit, updateSplitExpenseDistanceFromAmount, initSplitExpenseItemData, getSplitReimbursable, diff --git a/src/libs/actions/SplitExpenses.ts b/src/libs/actions/SplitExpenses.ts index 9b5af002cc9e..3c7b87afcc7d 100644 --- a/src/libs/actions/SplitExpenses.ts +++ b/src/libs/actions/SplitExpenses.ts @@ -19,6 +19,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import { + getEvenSplitQuantity, getSplitReimbursable, initDraftSplitExpenseDataForEdit, initSplitExpenseItemData, @@ -172,23 +173,40 @@ 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 first split + // like "Make splits even" does + const transactionCustomUnit = transaction.comment.customUnit; for (let i = 0; i < splitAmounts.length; i++) { - if (splitAmounts.at(i)) { - const splitAmount = splitAmounts.at(i) ?? 0; - const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( - splitAmount, - rate, - unit, - transaction.comment.customUnit, - {currency}, - getCurrencySymbol, - transactionDetails?.currency, - ); - - splitCustomUnits[i] = updatedCustomUnit; - splitMerchants[i] = merchant; - } + const quantity = getEvenSplitQuantity(transactionCustomUnit.quantity ?? 0, splitAmounts.length, i, 0); + const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( + 0, + rate, + unit, + {...transactionCustomUnit, quantity}, + {currency}, + getCurrencySymbol, + transactionDetails?.currency, + ); + + splitCustomUnits[i] = updatedCustomUnit; + splitMerchants[i] = merchant; + } + } else if (rate && rate > 0 && transaction?.comment?.customUnit) { + // Each split's distance is derived from its amount, so a $0 split (e.g. half of a $0.01 expense) has no distance + for (let i = 0; i < splitAmounts.length; i++) { + const {customUnit: updatedCustomUnit, merchant} = updateSplitExpenseDistanceFromAmount( + splitAmounts.at(i) ?? 0, + rate, + unit, + transaction.comment.customUnit, + {currency}, + getCurrencySymbol, + transactionDetails?.currency, + ); + + splitCustomUnits[i] = updatedCustomUnit; + splitMerchants[i] = merchant; } } } diff --git a/src/pages/iou/DynamicSplitExpenseCreateDateRangePage.tsx b/src/pages/iou/DynamicSplitExpenseCreateDateRangePage.tsx index 59281e627ea3..1e363308f938 100644 --- a/src/pages/iou/DynamicSplitExpenseCreateDateRangePage.tsx +++ b/src/pages/iou/DynamicSplitExpenseCreateDateRangePage.tsx @@ -89,7 +89,7 @@ function DynamicSplitExpenseCreateDateRangePage({route}: DynamicSplitExpenseCrea const isSplitAvailable = report && transaction && - isSplitAction(currentReport, [transaction], originalTransaction, login ?? '', currentUserAccountID, rules, currentReportOwnerLogin, effectivePolicy, parentReport); + isSplitAction(currentReport, [transaction], originalTransaction, login ?? '', currentUserAccountID, rules, currentReportOwnerLogin, effectivePolicy, parentReport, allPolicies); const validate = (values: FormOnyxValues) => { const errors: FormInputErrors = {}; diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 637e05e0ceb8..15aa8a405e1b 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -224,7 +224,7 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const isSplitAvailable = report && transaction && - isSplitAction(currentReport, [transaction], originalTransaction, login ?? '', currentUserAccountID, rules, currentReportOwnerLogin, effectivePolicy, parentReport); + isSplitAction(currentReport, [transaction], originalTransaction, login ?? '', currentUserAccountID, rules, currentReportOwnerLogin, effectivePolicy, parentReport, allPolicies); const isCategoryRequired = !!effectivePolicy?.requiresCategory && !isSelfDMSplit; const categoryValue = getDecodedLeafCategoryName(splitExpenseDraftTransactionDetails?.category ?? ''); @@ -289,12 +289,14 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) personalPolicyOutputCurrency: personalPolicy?.outputCurrency, }); const distance = getDistanceInMeters(splitExpenseDraftTransaction, unit); + const currentRateID = getRateID(splitExpenseDraftTransaction); + // A split still on the expense's rate, changed to or from $0 after the expense was created, is calculated with the rate the expense was + const amountRate = currentRateID === getRateID(transaction) ? (DistanceRequestUtils.getRateBeforeZeroRateChange(transaction, rate) ?? rate) : rate; const currentAmount = - isDistance && distance && rate - ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) * originalSign + isDistance && distance && amountRate !== undefined + ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, amountRate) * originalSign : Math.abs(Number(splitExpenseDraftTransaction?.amount)) * originalSign; const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(true, distance, unit, translate, false, isManualDistance); - const currentRateID = getRateID(splitExpenseDraftTransaction); const rates = DistanceRequestUtils.getMileageRates(effectivePolicy, false, currentRateID); const currency = splitExpenseDraftTransactionDetails.currency ?? CONST.CURRENCY.USD; @@ -303,21 +305,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 - ? DistanceRequestUtils.getDistanceMerchant(true, distance, unit, rate, currency, translate, toLocaleDigit, getCurrencySymbol, true) + isDistance && distance && amountRate !== undefined && unit + ? DistanceRequestUtils.getDistanceMerchant(true, distance, unit, amountRate, 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 f9c5415a1457..45200ddc5855 100644 --- a/src/pages/iou/DynamicSplitExpensePage.tsx +++ b/src/pages/iou/DynamicSplitExpensePage.tsx @@ -40,6 +40,7 @@ import { evenlyDistributeSplitExpenseAmounts, initDraftSplitExpenseDataForEdit, initSplitExpenseItemData, + isZeroRateDistanceSplit, resolveSplitItemReportID, updateSplitExpenseAmountField, } from '@libs/actions/IOU/SplitExpenseItems'; @@ -172,6 +173,7 @@ function DynamicSplitExpensePage({route}: DynamicSplitExpensePageProps) { getLoginByAccountID(currentReport?.ownerAccountID, personalDetails), effectivePolicy, parentReport, + allPolicies, ); const transactionDetails: Partial = getTransactionDetails(transaction, undefined, effectivePolicy, true) ?? {}; @@ -228,6 +230,22 @@ function DynamicSplitExpensePage({route}: DynamicSplitExpensePageProps) { const isDateMode = (selectedTab as string) === CONST.TAB.SPLIT.DATE; const childTransactions = getChildTransactions(allTransactions, transactionID); const isDraftSelfDMContext = isSelfDM(draftTransactionReport); + + // A split on a $0 rate is always $0, so its amount can't be typed in and it's valid as long as it has a distance + const zeroRateSplitTransactionIDs = new Set( + splitExpenses + .filter((splitExpense) => + isZeroRateDistanceSplit({ + splitExpense, + transaction, + policy: effectivePolicy, + isSelfDMSplit: isDraftSelfDMContext, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, + policies: allPolicies, + }), + ) + .map((splitExpense) => splitExpense.transactionID), + ); const splitFieldDataFromChildTransactions = childTransactions.map((childTransaction) => { const childTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; const itemReportID = resolveSplitItemReportID({ @@ -277,7 +295,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; } } @@ -381,6 +399,7 @@ function DynamicSplitExpensePage({route}: DynamicSplitExpensePageProps) { isCard, translate, convertToDisplayString, + zeroRateSplitTransactionIDs, }); if (saveError) { setErrorMessage(saveError); @@ -479,6 +498,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 its splits stay $0 and only their distance can change, unless a split is moved to a positive rate + 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}`]; @@ -519,6 +541,7 @@ function DynamicSplitExpensePage({route}: DynamicSplitExpensePageProps) { isSelected: splitExpenseTransactionID === item.transactionID, keyForList: item?.transactionID, isEditable: !frozenSplitTransactionIDs.has(item.transactionID), + isAmountEditable: !zeroRateSplitTransactionIDs.has(item.transactionID), }; }); @@ -530,14 +553,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 && ( )}