diff --git a/src/languages/de.ts b/src/languages/de.ts index f44b6e48f3a4..54f06d22b0d6 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1131,6 +1131,7 @@ const translations: TranslationDeepObject = { splitExpense: 'Ausgabe aufteilen', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} von ${merchant}`, addSplit: 'Split hinzufügen', + makeSplitsEven: 'Aufteilungen angleichen', editSplits: 'Splits bearbeiten', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Der Gesamtbetrag ist ${amount} höher als die ursprüngliche Ausgabe.`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Der Gesamtbetrag ist ${amount} weniger als die ursprüngliche Ausgabe.`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 3a4c74071e21..1e8e20979330 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1117,6 +1117,7 @@ const translations = { splitExpense: 'Split expense', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} from ${merchant}`, addSplit: 'Add split', + makeSplitsEven: 'Make splits even', editSplits: 'Edit splits', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Total amount is ${amount} greater than the original expense.`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Total amount is ${amount} less than the original expense.`, diff --git a/src/languages/es.ts b/src/languages/es.ts index d47b85817568..22484b6e808b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -796,6 +796,7 @@ const translations: TranslationDeepObject = { splitExpense: 'Dividir gasto', splitExpenseSubtitle: ({amount, merchant}) => `${amount} de ${merchant}`, addSplit: 'Añadir división', + makeSplitsEven: 'Igualar divisiones', editSplits: 'Editar divisiones', totalAmountGreaterThanOriginal: ({amount}) => `El importe total es ${amount} mayor que el gasto original.`, totalAmountLessThanOriginal: ({amount}) => `El importe total es ${amount} menor que el gasto original.`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 39b44681bc29..b5d3239d7a65 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1133,6 +1133,7 @@ const translations: TranslationDeepObject = { splitExpense: 'Fractionner la dépense', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} de ${merchant}`, addSplit: 'Ajouter une répartition', + makeSplitsEven: 'Uniformiser les répartitions', editSplits: 'Modifier les répartitions', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Le montant total est de ${amount} supérieur à la dépense initiale.`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Le montant total est de ${amount} inférieur à la dépense originale.`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 6abfebeb02d4..1f53c4a2fce1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1128,6 +1128,7 @@ const translations: TranslationDeepObject = { splitExpense: 'Dividi spesa', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} da ${merchant}`, addSplit: 'Aggiungi divisione', + makeSplitsEven: 'Uniformare le suddivisioni', editSplits: 'Modifica suddivisioni', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `L'importo totale è ${amount} maggiore della spesa originale.`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `L'importo totale è ${amount} inferiore alla spesa originale.`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1032e148ef8d..233c20c305ae 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1130,6 +1130,7 @@ const translations: TranslationDeepObject = { splitExpense: '経費を分割', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${merchant}から${amount}`, addSplit: '分割を追加', + makeSplitsEven: '分割を均等にする', editSplits: '分割を編集', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `合計金額は元の経費よりも${amount}多いです。`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `合計金額は元の経費よりも${amount}少なくなっています。`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 964e23dd9e9a..475dfe60c899 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1128,6 +1128,7 @@ const translations: TranslationDeepObject = { splitExpense: 'Uitgave splitsen', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} van ${merchant}`, addSplit: 'Splits toevoegen', + makeSplitsEven: 'Verdelingen gelijk maken', editSplits: 'Splits bewerken', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Het totale bedrag is ${amount} meer dan de oorspronkelijke uitgave.`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Het totale bedrag is ${amount} minder dan de oorspronkelijke uitgave.`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index f43dc505309e..020d9128a1ae 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1127,6 +1127,7 @@ const translations: TranslationDeepObject = { splitExpense: 'Podziel wydatek', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} od ${merchant}`, addSplit: 'Dodaj podział', + makeSplitsEven: 'Wyrównaj podziały', editSplits: 'Edytuj podziały', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Całkowita kwota jest o ${amount} większa niż pierwotny wydatek.`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Całkowita kwota jest o ${amount} mniejsza niż pierwotny wydatek.`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 99c19fa3a7ec..556c2ab598e3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1128,6 +1128,7 @@ const translations: TranslationDeepObject = { splitExpense: 'Dividir despesa', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} de ${merchant}`, addSplit: 'Adicionar divisão', + makeSplitsEven: 'Tornar as divisões iguais', editSplits: 'Editar divisões', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `O valor total é ${amount} maior que a despesa original.`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `O valor total é ${amount} a menos que a despesa original.`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 037dc75ba429..4f929fb03309 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1116,6 +1116,7 @@ const translations: TranslationDeepObject = { splitExpense: '拆分费用', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `来自${merchant}的${amount}`, addSplit: '添加分账', + makeSplitsEven: '使拆分均等', editSplits: '编辑拆分', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `总金额比原始费用多${amount}。`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `总金额比原始费用少 ${amount}。`, diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 3547b10ba9ff..b4639e4fe93a 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -53,19 +53,33 @@ function navigateToParticipantPage(iouType: ValueOf, tran } /** - * Calculates the amount per user given a list of participants + * Calculates the amount per split. * - * @param numberOfParticipants - Number of participants in the chat. It should not include the current user. + * @param numberOfSplits - Number of splits EXCLUDING the remainder holder ("default user"). * @param total - IOU total amount in backend format (cents, no matter the currency) - * @param currency - This is used to know how many decimal places are valid to use when splitting the total - * @param isDefaultUser - Whether we are calculating the amount for the current user + * @param currency - Used to know how many decimal places are valid when splitting the total + * @param isDefaultUser - Whether we are calculating the amount for the remainder holder + * @param useFloorToLastRounding - `false` (default, legacy behavior) or `true` to floor all and put full remainder on the default user */ -function calculateAmount(numberOfParticipants: number, total: number, currency: string, isDefaultUser = false): number { +function calculateAmount(numberOfSplits: number, total: number, currency: string, isDefaultUser = false, useFloorToLastRounding = false): number { // Since the backend can maximum store 2 decimal places, any currency with more than 2 decimals // has to be capped to 2 decimal places const currencyUnit = Math.min(100, getCurrencyUnit(currency)); const totalInCurrencySubunit = (total / 100) * currencyUnit; - const totalParticipants = numberOfParticipants + 1; + const totalParticipants = numberOfSplits + 1; + + // New optional mode + if (useFloorToLastRounding) { + // For positive totals, floor for everyone and add the full remainder to the default user + // For negative totals, do the inverse of above and round up using Math.ceil to calculate the base share + const baseShareSubunit = totalInCurrencySubunit >= 0 ? Math.floor(totalInCurrencySubunit / totalParticipants) : Math.ceil(totalInCurrencySubunit / totalParticipants); + const remainderSubunit = totalInCurrencySubunit - baseShareSubunit * totalParticipants; + + const subunitAmount = baseShareSubunit + (isDefaultUser ? remainderSubunit : 0); + return Math.round((subunitAmount * 100) / currencyUnit); + } + + // Legacy behavior (backwards compatible): round equally and adjust default user by +/- difference const amountPerPerson = Math.round(totalInCurrencySubunit / totalParticipants); let finalAmount = amountPerPerson; if (isDefaultUser) { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index cb8303cde292..907536704979 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -13612,6 +13612,47 @@ function addSplitExpenseField(transaction: OnyxEntry, dra }); } +/** + * Evenly distribute the draft split expense amounts across all split items. + * Remainders are added to the first or last item to ensure the total matches the original amount. + * + * Notes: + * - Works entirely on the provided `draftTransaction` to avoid direct Onyx reads. + * - Uses `calculateAmount` utility to handle currency subunits and rounding consistently with existing logic. + */ +function evenlyDistributeSplitExpenseAmounts(draftTransaction: OnyxEntry) { + if (!draftTransaction) { + return; + } + + const originalTransactionID = draftTransaction?.comment?.originalTransactionID; + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + const currency = getCurrency(draftTransaction); + + // Use allowNegative=true and disableOppositeConversion=true to preserve original amount sign + const total = getAmount(draftTransaction, undefined, undefined, true, true); + + // Guard clause for missing data + if (!originalTransactionID || splitExpenses.length === 0) { + return; + } + + // Floor-allocation with full remainder added to the last split so the last is always the largest + const splitCount = splitExpenses.length; + const lastIndex = splitCount - 1; + + const updatedSplitExpenses = splitExpenses.map((splitExpense, index) => ({ + ...splitExpense, + amount: calculateIOUAmount(splitCount - 1, total, currency, index === lastIndex, true), + })); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { + comment: { + splitExpenses: updatedSplitExpenses, + }, + }); +} + function removeSplitExpenseField(draftTransaction: OnyxEntry, splitExpenseTransactionID: string) { if (!draftTransaction || !splitExpenseTransactionID) { return; @@ -14360,6 +14401,7 @@ export { initSplitExpense, initSplitExpenseItemData, addSplitExpenseField, + evenlyDistributeSplitExpenseAmounts, updateSplitExpenseAmountField, updateSplitTransactions, updateSplitTransactionsFromSplitExpensesFlow, diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 0b65a4cd53b5..6211119dddc1 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -8,6 +8,7 @@ import ConfirmModal from '@components/ConfirmModal'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchContext} from '@components/Search/SearchContext'; import SelectionList from '@components/SelectionListWithSections'; @@ -22,6 +23,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import { addSplitExpenseField, clearSplitTransactionDraftErrors, + evenlyDistributeSplitExpenseAmounts, getIOUActionForTransactions, getIOURequestPolicyID, initDraftSplitExpenseDataForEdit, @@ -115,6 +117,13 @@ function SplitExpensePage({route}: SplitExpensePageProps) { addSplitExpenseField(transaction, draftTransaction); }, [draftTransaction, transaction, transactionID]); + const onMakeSplitsEven = useCallback(() => { + if (!draftTransaction) { + return; + } + evenlyDistributeSplitExpenseAmounts(draftTransaction); + }, [draftTransaction]); + const onSaveSplitExpense = useCallback(() => { if (splitExpenses.length <= 1 && !childTransactions.length) { const splitFieldDataFromOriginalTransactionWithoutID = {...splitFieldDataFromOriginalTransaction, transactionID: ''}; @@ -279,20 +288,27 @@ function SplitExpensePage({route}: SplitExpensePageProps) { getTranslatedText, ]); - const headerContent = useMemo( - () => ( - -