From 030d614c9f50dd09d33c614c16c78fac5afe3ad0 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 5 Nov 2025 15:44:56 -0800 Subject: [PATCH 1/8] Add Make splits even functionality - Rework (negative amount) --- src/libs/actions/IOU.ts | 3 +- tests/actions/IOUTest.ts | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index a334629e5e49..5e35b3f3e47d 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -13650,7 +13650,8 @@ function evenlyDistributeSplitExpenseAmounts(draftTransaction: OnyxEntry { expect(amounts).toEqual([33366, 33366, 33368]); expect(amounts.reduce((a, b) => a + b, 0)).toBe(100100); }); + + it('preserves negative sign and evenly distributes with remainder on last for 3-way split', async () => { + const originalTransactionID = 'orig-neg-3'; + const draftTransaction: Transaction = { + transactionID: 'draft-neg-3', + amount: -100, // in cents = -$1.00 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Negative amount test', + originalTransactionID, + splitExpenses: [ + {transactionID: 'n1', amount: 0, description: 'N1', created: DateUtils.getDBTime()}, + {transactionID: 'n2', amount: 0, description: 'N2', created: DateUtils.getDBTime()}, + {transactionID: 'n3', amount: 0, description: 'N3', created: DateUtils.getDBTime()}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: 'rep-neg-3', + }; + + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); + + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([-34, -34, -32]); + expect(amounts.reduce((a, b) => a + b, 0)).toBe(-100); + }); + + it('preserves negative sign for 2-way odd-cent split -> [-$0.51, -$0.50]', async () => { + const originalTransactionID = 'orig-neg-2'; + const draftTransaction: Transaction = { + transactionID: 'draft-neg-2', + amount: -101, // in cents = -$1.01 + currency: 'USD', + merchant: 'Test Merchant', + comment: { + comment: 'Negative amount test 2-way', + originalTransactionID, + splitExpenses: [ + {transactionID: 'nA', amount: 0, description: 'NA', created: DateUtils.getDBTime()}, + {transactionID: 'nB', amount: 0, description: 'NB', created: DateUtils.getDBTime()}, + ], + attendees: [], + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + }, + created: DateUtils.getDBTime(), + reportID: 'rep-neg-2', + }; + + evenlyDistributeSplitExpenseAmounts(draftTransaction); + await waitForBatchedUpdates(); + + const updatedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`); + const amounts = (updatedDraft?.comment?.splitExpenses ?? []).map((x) => x.amount); + expect(amounts).toEqual([-51, -50]); + expect(amounts.reduce((a, b) => a + b, 0)).toBe(-101); + }); }); describe('updateSplitExpenseAmountField', () => { From d2ee09d6717a6d409e7014a9ade994311d738e42 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 5 Nov 2025 15:54:16 -0800 Subject: [PATCH 2/8] submodule --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 6bc6d49d38b4..0405b4aa2c69 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 6bc6d49d38b4be2eb1b9f80ccf932e410b45d8b2 +Subproject commit 0405b4aa2c693959993a601ff92d9f36037b6c39 From 3c231f3ae04240fec2f8998d0809dc03c58e16cb Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 5 Nov 2025 16:14:59 -0800 Subject: [PATCH 3/8] rework completed - ready for review --- src/languages/de.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/IOUUtils.ts | 23 +++++++++++---- src/libs/actions/IOU.ts | 1 + src/pages/iou/SplitExpensePage.tsx | 47 +++++++++++++++++++----------- tests/actions/IOUTest.ts | 1 + 14 files changed, 59 insertions(+), 23 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index fa55bc87d78b..89d33ae8b29a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1140,6 +1140,7 @@ const translations = { 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 58bced4bb71b..078fc2539416 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 df6bda3d8403..dd23bc058898 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1107,6 +1107,7 @@ const translations = { splitExpense: 'Dividir gasto', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} de ${merchant}`, addSplit: 'Añadir división', + makeSplitsEven: 'Igualar divisiones', editSplits: 'Editar divisiones', totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `El importe total es ${amount} mayor que el gasto original.`, totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `El importe total es ${amount} menor que el gasto original.`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ded7be6512ab..a83f7f2dda2d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1137,6 +1137,7 @@ const translations = { 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 4d02de1932b4..c16518a46f67 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1132,6 +1132,7 @@ const translations = { 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 5f6d09c5cdaf..3ddf3198672d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1134,6 +1134,7 @@ const translations = { 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 471322b3cc10..a637ea92ec48 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1132,6 +1132,7 @@ const translations = { 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 b2754edfd0c8..87a2acf29c2e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1132,6 +1132,7 @@ const translations = { 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 1228d28a1e51..059669f719ae 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1131,6 +1131,7 @@ const translations = { 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 5797fb036805..d8e05db42b89 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1122,6 +1122,7 @@ const translations = { 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..9b7824261b21 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -53,19 +53,30 @@ 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 roundingMode - 'round' (default, legacy behavior) or 'floorToLast' 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, roundingMode: 'round' | 'floorToLast' = 'round'): 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: floor for everyone and add the full remainder to the default user. + if (roundingMode === 'floorToLast') { + const baseShareSubunit = Math.floor(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 851f47dc5181..d67235d1eb22 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -14399,6 +14399,7 @@ export { initSplitExpense, initSplitExpenseItemData, addSplitExpenseField, + evenlyDistributeSplitExpenseAmounts, updateSplitExpenseAmountField, updateSplitTransactions, updateSplitTransactionsFromSplitExpensesFlow, diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 7632a88aa5ec..44656b12038d 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, @@ -116,6 +118,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: ''}; @@ -280,20 +289,27 @@ function SplitExpensePage({route}: SplitExpensePageProps) { getTranslatedText, ]); - const headerContent = useMemo( - () => ( - -