From 886675977300d0d54ca9d37a0c4ed9cefc69c3cd Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 5 Feb 2026 17:59:06 -0800 Subject: [PATCH 01/17] feat: update amounts/percentages split logic to match OD --- Mobile-Expensify | 2 +- src/components/MoneyRequestAmountInput.tsx | 5 + src/components/NumberWithSymbolForm.tsx | 29 +- .../SplitListItem/SplitAmountInput.tsx | 1 + src/libs/actions/IOU/index.ts | 115 ++++++-- src/types/onyx/IOU.ts | 3 + tests/actions/IOUTest.ts | 6 +- tests/unit/SplitExpenseAutoAdjustmentTest.ts | 257 ++++++++++++++++++ 8 files changed, 389 insertions(+), 29 deletions(-) create mode 100644 tests/unit/SplitExpenseAutoAdjustmentTest.ts diff --git a/Mobile-Expensify b/Mobile-Expensify index b1f564ef4de8..574c396c1731 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit b1f564ef4de82d58f3f92ebe4d268601d9856eaf +Subproject commit 574c396c1731467091f7865a3e460343d25e2397 diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 44d374d3793b..f5ee0f0643e5 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -95,6 +95,9 @@ type MoneyRequestAmountInputProps = { /** Whether to allow flipping amount */ allowFlippingAmount?: boolean; + /** Whether to allow direct negative input (for split amounts where value is already negative) */ + allowNegativeInput?: boolean; + /** The testID of the input. Used to locate this view in end-to-end tests. */ testID?: string; @@ -164,6 +167,7 @@ function MoneyRequestAmountInput({ shouldWrapInputInContainer = true, isNegative = false, allowFlippingAmount = false, + allowNegativeInput = false, toggleNegative, clearNegative, ref, @@ -258,6 +262,7 @@ function MoneyRequestAmountInput({ autoGrowExtraSpace={autoGrowExtraSpace} submitBehavior={submitBehavior} allowFlippingAmount={allowFlippingAmount} + allowNegativeInput={allowNegativeInput} toggleNegative={toggleNegative} clearNegative={clearNegative} onFocus={props.onFocus} diff --git a/src/components/NumberWithSymbolForm.tsx b/src/components/NumberWithSymbolForm.tsx index 87b562c5a0cf..53e90c37cddf 100644 --- a/src/components/NumberWithSymbolForm.tsx +++ b/src/components/NumberWithSymbolForm.tsx @@ -79,6 +79,9 @@ type NumberWithSymbolFormProps = { /** Whether to allow flipping amount */ allowFlippingAmount?: boolean; + /** Whether to allow direct negative input (for split amounts where value is already negative) */ + allowNegativeInput?: boolean; + /** Whether the input is disabled or not */ disabled?: boolean; @@ -147,6 +150,7 @@ function NumberWithSymbolForm({ shouldWrapInputInContainer = true, isNegative = false, allowFlippingAmount = false, + allowNegativeInput = false, toggleNegative, clearNegative, ref, @@ -221,11 +225,13 @@ function NumberWithSymbolForm({ const newNumberWithoutSpaces = stripSpacesFromAmount(newNumber); const rawFinalNumber = newNumberWithoutSpaces.includes('.') ? stripCommaFromAmount(newNumberWithoutSpaces) : replaceCommasWithPeriod(newNumberWithoutSpaces); - const finalNumber = handleNegativeAmountFlipping(rawFinalNumber, allowFlippingAmount, toggleNegative); + // When allowNegativeInput is true, keep negative sign as-is (for split amounts) + // When allowFlippingAmount is true, strip the negative sign and call toggleNegative + const finalNumber = allowNegativeInput ? rawFinalNumber : handleNegativeAmountFlipping(rawFinalNumber, allowFlippingAmount, toggleNegative); // Use a shallow copy of selection to trigger setSelection // More info: https://github.com/Expensify/App/issues/16385 - if (!validateAmount(finalNumber, decimals, maxLength)) { + if (!validateAmount(finalNumber, decimals, maxLength, allowNegativeInput)) { setSelection((prevSelection) => ({...prevSelection})); return; } @@ -245,7 +251,7 @@ function NumberWithSymbolForm({ }); onInputChange?.(strippedNumber); }, - [decimals, maxLength, onInputChange, allowFlippingAmount, toggleNegative], + [decimals, maxLength, onInputChange, allowFlippingAmount, toggleNegative, allowNegativeInput], ); /** @@ -256,11 +262,14 @@ function NumberWithSymbolForm({ // Remove spaces from the new number because Safari on iOS adds spaces when pasting a copied number // More info: https://github.com/Expensify/App/issues/16974 const newNumberWithoutSpaces = stripSpacesFromAmount(text); - const replacedCommasNumber = handleNegativeAmountFlipping(replaceCommasWithPeriod(newNumberWithoutSpaces), allowFlippingAmount, toggleNegative); + // When allowNegativeInput is true, keep negative sign as-is + const replacedCommasNumber = allowNegativeInput + ? replaceCommasWithPeriod(newNumberWithoutSpaces) + : handleNegativeAmountFlipping(replaceCommasWithPeriod(newNumberWithoutSpaces), allowFlippingAmount, toggleNegative); - const withLeadingZero = addLeadingZero(replacedCommasNumber); + const withLeadingZero = addLeadingZero(replacedCommasNumber, allowNegativeInput); - if (!validateAmount(withLeadingZero, decimals, maxLength)) { + if (!validateAmount(withLeadingZero, decimals, maxLength, allowNegativeInput)) { setSelection((prevSelection) => ({...prevSelection})); return; } @@ -283,7 +292,7 @@ function NumberWithSymbolForm({ // Modifies the number to match changed decimals. useEffect(() => { // If the number supports decimals, we can return - if (validateAmount(currentNumber, decimals, maxLength, allowFlippingAmount)) { + if (validateAmount(currentNumber, decimals, maxLength, allowNegativeInput || allowFlippingAmount)) { return; } @@ -308,14 +317,14 @@ function NumberWithSymbolForm({ if (currentNumber.length > 0) { const selectionStart = selection.start === selection.end ? selection.start - 1 : selection.start; const newNumber = `${currentNumber.substring(0, selectionStart)}${currentNumber.substring(selection.end)}`; - setNewNumber(addLeadingZero(newNumber)); + setNewNumber(addLeadingZero(newNumber, allowNegativeInput)); } return; } - const newNumber = addLeadingZero(`${currentNumber.substring(0, selection.start)}${key}${currentNumber.substring(selection.end)}`); + const newNumber = addLeadingZero(`${currentNumber.substring(0, selection.start)}${key}${currentNumber.substring(selection.end)}`, allowNegativeInput); setNewNumber(newNumber); }, - [currentNumber, selection.start, selection.end, shouldUpdateSelection, setNewNumber], + [currentNumber, selection.start, selection.end, shouldUpdateSelection, setNewNumber, allowNegativeInput], ); /** diff --git a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx index 0d6c1dc6bdaf..cdadce1ad16d 100644 --- a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx @@ -55,6 +55,7 @@ function SplitAmountInput({splitItem, formattedOriginalAmount, contentWidth, onS shouldWrapInputInContainer={false} onFocus={focusHandler} onBlur={onInputBlur} + allowNegativeInput keyboardType={CONST.KEYBOARD_TYPE.NUMBERS_AND_PUNCTUATION} /> ); diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index aa8e3f2e9b27..81b9b5eee193 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -12957,7 +12957,7 @@ function markRejectViolationAsResolved(transactionID: string, reportID?: string) function initSplitExpenseItemData( transaction: OnyxEntry, - {amount, transactionID, reportID, created}: {amount?: number; transactionID?: string; reportID?: string; created?: string} = {}, + {amount, transactionID, reportID, created, isManuallyEdited}: {amount?: number; transactionID?: string; reportID?: string; created?: string; isManuallyEdited?: boolean} = {}, ): SplitExpense { const transactionDetails = getTransactionDetails(transaction); const currentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; @@ -12973,6 +12973,7 @@ function initSplitExpenseItemData( statusNum: currentReport?.statusNum ?? 0, reportID: reportID ?? transaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), reimbursable: transactionDetails?.reimbursable, + isManuallyEdited: isManuallyEdited ?? false, }; } @@ -12992,7 +12993,8 @@ function initSplitExpense(transactions: OnyxCollection, r if (isExpenseSplit) { const relatedTransactions = getChildTransactions(transactions, reports, originalTransactionID); const transactionDetails = getTransactionDetails(originalTransaction); - const splitExpenses = relatedTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction)); + // Mark existing child transactions as manually edited (locked) since we're editing existing splits + const splitExpenses = relatedTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction, {isManuallyEdited: true})); const draftTransaction = buildOptimisticTransaction({ originalTransactionID, transactionParams: { @@ -13020,9 +13022,18 @@ function initSplitExpense(transactions: OnyxCollection, r const transactionDetails = getTransactionDetails(transaction); const transactionDetailsAmount = transactionDetails?.amount ?? 0; + // New splits start as unedited (isManuallyEdited: false) so they participate in auto-redistribution const splitExpenses = [ - initSplitExpenseItemData(transaction, {amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', false), transactionID: NumberUtils.rand64()}), - initSplitExpenseItemData(transaction, {amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', true), transactionID: NumberUtils.rand64()}), + initSplitExpenseItemData(transaction, { + amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', false), + transactionID: NumberUtils.rand64(), + isManuallyEdited: false, + }), + initSplitExpenseItemData(transaction, { + amount: calculateIOUAmount(1, transactionDetailsAmount, transactionDetails?.currency ?? '', true), + transactionID: NumberUtils.rand64(), + isManuallyEdited: false, + }), ]; const draftTransaction = buildOptimisticTransaction({ @@ -13086,22 +13097,54 @@ function initDraftSplitExpenseDataForEdit(draftTransaction: OnyxEntry, draftTransaction: OnyxEntry) { if (!transaction || !draftTransaction) { return; } - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`, { + const newTransactionID = NumberUtils.rand64(); + const newSplit = initSplitExpenseItemData(transaction, { + amount: 0, + transactionID: newTransactionID, + reportID: draftTransaction?.reportID, + isManuallyEdited: false, + }); + + const existingSplits = draftTransaction.comment?.splitExpenses ?? []; + const updatedSplitExpenses = [...existingSplits, newSplit]; + + // Get total amount and currency for redistribution + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); + const originalTransactionID = draftTransaction.comment?.originalTransactionID ?? transaction.transactionID; + + // Calculate sum of manually edited splits + const editedSum = updatedSplitExpenses.filter((split) => split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); + + // Find all unedited splits (including the new one) + const uneditedSplits = updatedSplitExpenses.filter((split) => !split.isManuallyEdited); + const uneditedCount = uneditedSplits.length; + + // Redistribute remaining amount among unedited splits + const remaining = total - editedSum; + const lastUneditedIndex = uneditedCount - 1; + let uneditedIndex = 0; + + const redistributedSplitExpenses = updatedSplitExpenses.map((split) => { + if (split.isManuallyEdited) { + return split; + } + const isLast = uneditedIndex === lastUneditedIndex; + const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); + uneditedIndex += 1; + return {...split, amount: newAmount}; + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { - splitExpenses: [ - ...(draftTransaction.comment?.splitExpenses ?? []), - initSplitExpenseItemData(transaction, { - amount: 0, - transactionID: NumberUtils.rand64(), - reportID: draftTransaction?.reportID, - }), - ], + splitExpenses: redistributedSplitExpenses, splitsStartDate: null, splitsEndDate: null, }, @@ -13140,6 +13183,8 @@ function evenlyDistributeSplitExpenseAmounts(draftTransaction: OnyxEntry ({ ...splitExpense, amount: calculateIOUAmount(splitCount - 1, total, currency, index === lastIndex, true), + // Reset isManuallyEdited since user explicitly requested even distribution + isManuallyEdited: false, })); Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { @@ -13254,19 +13299,57 @@ function updateSplitExpenseAmountField(draftTransaction: OnyxEntry { + const splitExpenses = draftTransaction.comment?.splitExpenses ?? []; + const originalTransactionID = draftTransaction.comment?.originalTransactionID; + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); + + // Mark the edited split and update its amount + const splitWithUpdatedAmount = splitExpenses.map((splitExpense) => { if (splitExpense.transactionID === currentItemTransactionID) { return { ...splitExpense, amount, + isManuallyEdited: true, }; } return splitExpense; }); - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${draftTransaction?.comment?.originalTransactionID}`, { + // Find unedited splits (excluding the one being edited) + const uneditedSplits = splitWithUpdatedAmount.filter((split) => !split.isManuallyEdited); + + // If no unedited splits remain, just save the updated amounts without redistribution + if (uneditedSplits.length === 0) { + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { + comment: { + splitExpenses: splitWithUpdatedAmount, + }, + }); + return; + } + + // Sum amounts of manually edited splits (the updated split is already marked as edited) + const editedSum = splitWithUpdatedAmount.filter((split) => split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); + + // Redistribute remaining amount among unedited splits + const remaining = total - editedSum; + const lastUneditedIndex = uneditedSplits.length - 1; + let uneditedIndex = 0; + + const redistributedSplitExpenses = splitWithUpdatedAmount.map((split) => { + if (split.isManuallyEdited) { + return split; + } + const isLast = uneditedIndex === lastUneditedIndex; + const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); + uneditedIndex += 1; + return {...split, amount: newAmount}; + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { - splitExpenses: updatedSplitExpenses, + splitExpenses: redistributedSplitExpenses, }, }); } diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts index 5858e25da3b6..d10145463b10 100644 --- a/src/types/onyx/IOU.ts +++ b/src/types/onyx/IOU.ts @@ -159,6 +159,9 @@ type SplitExpense = { /** Whether the split expense is reimbursable (out-of-pocket) or non-reimbursable (company spend) */ reimbursable?: boolean; + + /** Whether this split has been manually edited by the user (locks the value from auto-adjustment) */ + isManuallyEdited?: boolean; }; /** Model of IOU request */ diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index 5950a415ff4a..e1b12624e766 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -10904,6 +10904,7 @@ describe('actions/IOU', () => { category: 'Food', tags: ['lunch'], created: DateUtils.getDBTime(), + isManuallyEdited: true, // Lock the existing split so new split gets remaining amount }, ], attendees: [], @@ -10923,7 +10924,7 @@ describe('actions/IOU', () => { const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; expect(splitExpenses).toHaveLength(2); - expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(50); // New split gets remaining 50 from total 100 - 50 locked expect(splitExpenses?.[1].description).toBe('Test comment'); expect(splitExpenses?.[1].category).toBe('Food'); expect(splitExpenses?.[1].tags).toEqual(['lunch']); @@ -10965,6 +10966,7 @@ describe('actions/IOU', () => { tags: ['lunch'], created: DateUtils.getDBTime(), reimbursable: false, // Existing split - not reimbursable + isManuallyEdited: true, // Lock the existing split so new split gets remaining amount }, ], attendees: [], @@ -10989,7 +10991,7 @@ describe('actions/IOU', () => { // Verify: The new split should have reimbursable: false (not counted as out-of-pocket) expect(splitExpenses?.[1].reimbursable).toBe(false); - expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(50); // New split gets remaining 50 from total 100 - 50 locked expect(splitExpenses?.[1].description).toBe('Card transaction'); expect(splitExpenses?.[1].category).toBe('Food'); expect(splitExpenses?.[1].tags).toEqual(['lunch']); diff --git a/tests/unit/SplitExpenseAutoAdjustmentTest.ts b/tests/unit/SplitExpenseAutoAdjustmentTest.ts new file mode 100644 index 000000000000..257d186a4fb6 --- /dev/null +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -0,0 +1,257 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, updateSplitExpenseAmountField} from '@libs/actions/IOU'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; +import type {SplitExpense} from '@src/types/onyx/IOU'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +/** + * Tests for the split expense auto-adjustment feature. + * When splitting an expense: + * - Unedited splits auto-adjust to sum to 100%/total amount + * - Manually edited splits are "locked" and preserved + * - Adding a new split redistributes among unedited splits + */ +describe('Split Expense Auto-Adjustment', () => { + const ORIGINAL_TRANSACTION_ID = 'originalTx123'; + const REPORT_ID = 'report123'; + const CURRENCY = 'USD'; + const TOTAL_AMOUNT = 1000; // $10.00 in cents + + // Helper to create a mock draft transaction + const createMockDraftTransaction = (splitExpenses: SplitExpense[], amount = TOTAL_AMOUNT): Transaction => + ({ + transactionID: ORIGINAL_TRANSACTION_ID, + reportID: REPORT_ID, + amount, + currency: CURRENCY, + comment: { + originalTransactionID: ORIGINAL_TRANSACTION_ID, + splitExpenses, + }, + }) as unknown as Transaction; + + // Helper to create a split expense + const createSplitExpense = (transactionID: string, amount: number, isManuallyEdited = false): SplitExpense => ({ + transactionID, + amount, + created: '2024-01-01', + isManuallyEdited, + }); + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('addSplitExpenseField', () => { + it('should redistribute evenly when adding a split to 2 unedited splits', async () => { + // Setup: 2 splits at $5/$5 (50/50) + const initialSplits = [createSplitExpense('split1', 500, false), createSplitExpense('split2', 500, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Add a third split + addSplitExpenseField(mockTransaction, mockTransaction); + await waitForBatchedUpdates(); + + // Verify: Should be 3 splits at ~$3.33/$3.33/$3.34 (33/33/34%) + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.length).toBe(3); + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + + // All splits should be unedited + expect(splitExpenses.every((split) => !split.isManuallyEdited)).toBe(true); + }); + + it('should preserve edited splits when adding a new split', async () => { + // Setup: 2 splits - one edited at $3, one unedited at $7 + const initialSplits = [ + createSplitExpense('split1', 300, true), // Edited/locked + createSplitExpense('split2', 700, false), // Unedited + ]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Add a third split + addSplitExpenseField(mockTransaction, mockTransaction); + await waitForBatchedUpdates(); + + // Verify + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.length).toBe(3); + + // Edited split should remain at $3 and locked + const editedSplit = splitExpenses.find((s) => s.transactionID === 'split1'); + expect(editedSplit?.amount).toBe(300); + expect(editedSplit?.isManuallyEdited).toBe(true); + + // Remaining $7 should be split between 2 unedited splits + const uneditedSplits = splitExpenses.filter((s) => !s.isManuallyEdited); + expect(uneditedSplits.length).toBe(2); + const uneditedTotal = uneditedSplits.reduce((sum, s) => sum + s.amount, 0); + expect(uneditedTotal).toBe(700); // $7 total + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); + }); + + describe('updateSplitExpenseAmountField', () => { + it('should mark edited split and redistribute remaining to unedited splits', async () => { + // Setup: 3 unedited splits at $3.33/$3.33/$3.34 + const initialSplits = [createSplitExpense('split1', 333, false), createSplitExpense('split2', 333, false), createSplitExpense('split3', 334, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Edit split1 to $3.00 + updateSplitExpenseAmountField(mockTransaction, 'split1', 300); + await waitForBatchedUpdates(); + + // Verify + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + + // Edited split should be locked at $3 + const editedSplit = splitExpenses.find((s) => s.transactionID === 'split1'); + expect(editedSplit?.amount).toBe(300); + expect(editedSplit?.isManuallyEdited).toBe(true); + + // Remaining $7 should be split between 2 unedited splits + const uneditedSplits = splitExpenses.filter((s) => !s.isManuallyEdited); + expect(uneditedSplits.length).toBe(2); + const uneditedTotal = uneditedSplits.reduce((sum, s) => sum + s.amount, 0); + expect(uneditedTotal).toBe(700); + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); + + it('should not redistribute when all splits are manually edited', async () => { + // Setup: 2 manually edited splits + const initialSplits = [createSplitExpense('split1', 400, true), createSplitExpense('split2', 600, true)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Edit split1 to $5.00 + updateSplitExpenseAmountField(mockTransaction, 'split1', 500); + await waitForBatchedUpdates(); + + // Verify: split2 should remain unchanged + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + + expect(splitExpenses.find((s) => s.transactionID === 'split1')?.amount).toBe(500); + expect(splitExpenses.find((s) => s.transactionID === 'split2')?.amount).toBe(600); + + // Note: Total now exceeds original amount (user error case) + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(1100); + }); + }); + + describe('evenlyDistributeSplitExpenseAmounts', () => { + it('should reset isManuallyEdited and distribute evenly', async () => { + // Setup: 3 splits with some manually edited + const initialSplits = [createSplitExpense('split1', 300, true), createSplitExpense('split2', 400, true), createSplitExpense('split3', 300, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Make splits even + evenlyDistributeSplitExpenseAmounts(mockTransaction); + await waitForBatchedUpdates(); + + // Verify + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + + // All splits should now be unedited + expect(splitExpenses.every((split) => !split.isManuallyEdited)).toBe(true); + + // Total should equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + + // Should be distributed as $3.33/$3.33/$3.34 + const amounts = splitExpenses.map((s) => s.amount).sort((a, b) => a - b); + expect(amounts).toEqual([333, 333, 334]); + }); + }); +}); From 29decf162492eb7cb70ae316dd1ac78ea8d86e45 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 5 Feb 2026 18:13:23 -0800 Subject: [PATCH 02/17] fix: prevent NaN in split amount fields when clearing input to negative sign --- .../SelectionList/ListItem/SplitListItem.tsx | 12 ++++++- src/libs/actions/IOU/index.ts | 2 +- tests/unit/SplitExpenseAutoAdjustmentTest.ts | 33 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionList/ListItem/SplitListItem.tsx b/src/components/SelectionList/ListItem/SplitListItem.tsx index cdbd721a1609..2e38dde49393 100644 --- a/src/components/SelectionList/ListItem/SplitListItem.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem.tsx @@ -40,7 +40,17 @@ function SplitListItem({ const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(splitItem.originalAmount, splitItem.currency); - const onSplitExpenseValueChange = useCallback((value: string) => splitItem.onSplitExpenseValueChange(splitItem.transactionID, Number(value), splitItem.mode), [splitItem]); + const onSplitExpenseValueChange = useCallback( + (value: string) => { + const numericValue = Number(value); + // Skip update if value is just "-" or produces NaN (intermediate input state) + if (Number.isNaN(numericValue)) { + return; + } + splitItem.onSplitExpenseValueChange(splitItem.transactionID, numericValue, splitItem.mode); + }, + [splitItem], + ); const inputRef = useRef(null); diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 81b9b5eee193..d7d8b5907973 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -13295,7 +13295,7 @@ function updateSplitExpenseField( } function updateSplitExpenseAmountField(draftTransaction: OnyxEntry, currentItemTransactionID: string, amount: number) { - if (!draftTransaction?.transactionID || !currentItemTransactionID) { + if (!draftTransaction?.transactionID || !currentItemTransactionID || Number.isNaN(amount)) { return; } diff --git a/tests/unit/SplitExpenseAutoAdjustmentTest.ts b/tests/unit/SplitExpenseAutoAdjustmentTest.ts index 257d186a4fb6..2d034f6527b3 100644 --- a/tests/unit/SplitExpenseAutoAdjustmentTest.ts +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -213,6 +213,39 @@ describe('Split Expense Auto-Adjustment', () => { const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); expect(totalAmount).toBe(1100); }); + + it('should ignore NaN amount and preserve existing split values', async () => { + // Setup: 2 unedited splits at $5/$5 + const initialSplits = [createSplitExpense('split1', 500, false), createSplitExpense('split2', 500, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Try to update with NaN amount (simulates user entering just "-") + updateSplitExpenseAmountField(mockTransaction, 'split1', NaN); + await waitForBatchedUpdates(); + + // Verify: Splits should remain unchanged + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.find((s) => s.transactionID === 'split1')?.amount).toBe(500); + expect(splitExpenses.find((s) => s.transactionID === 'split2')?.amount).toBe(500); + + // Total should still equal original amount + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); }); describe('evenlyDistributeSplitExpenseAmounts', () => { From bed3b8bfee1d3b3ee358d31bfc011c68b1765b5f Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 5 Feb 2026 18:32:34 -0800 Subject: [PATCH 03/17] fix: split amounts do not auto redistribute after removing split --- src/libs/actions/IOU/index.ts | 68 ++++++--- tests/unit/SplitExpenseAutoAdjustmentTest.ts | 142 +++++++++++++++++-- 2 files changed, 175 insertions(+), 35 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index d7d8b5907973..e3cfd806db14 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -13095,6 +13095,44 @@ function initDraftSplitExpenseDataForEdit(draftTransaction: OnyxEntry split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); + + // Find all unedited splits + const uneditedSplits = splitExpenses.filter((split) => !split.isManuallyEdited); + const uneditedCount = uneditedSplits.length; + + // If no unedited splits, return as-is + if (uneditedCount === 0) { + return splitExpenses; + } + + // Redistribute remaining amount among unedited splits + const remaining = total - editedSum; + const lastUneditedIndex = uneditedCount - 1; + let uneditedIndex = 0; + + return splitExpenses.map((split) => { + if (split.isManuallyEdited) { + return split; + } + const isLast = uneditedIndex === lastUneditedIndex; + const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); + uneditedIndex += 1; + return {...split, amount: newAmount}; + }); +} + /** * Append a new split expense entry to the draft transaction's splitExpenses array * and auto-redistribute amounts among all unedited splits. @@ -13120,27 +13158,7 @@ function addSplitExpenseField(transaction: OnyxEntry, dra const currency = getCurrency(draftTransaction); const originalTransactionID = draftTransaction.comment?.originalTransactionID ?? transaction.transactionID; - // Calculate sum of manually edited splits - const editedSum = updatedSplitExpenses.filter((split) => split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); - - // Find all unedited splits (including the new one) - const uneditedSplits = updatedSplitExpenses.filter((split) => !split.isManuallyEdited); - const uneditedCount = uneditedSplits.length; - - // Redistribute remaining amount among unedited splits - const remaining = total - editedSum; - const lastUneditedIndex = uneditedCount - 1; - let uneditedIndex = 0; - - const redistributedSplitExpenses = updatedSplitExpenses.map((split) => { - if (split.isManuallyEdited) { - return split; - } - const isLast = uneditedIndex === lastUneditedIndex; - const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); - uneditedIndex += 1; - return {...split, amount: newAmount}; - }); + const redistributedSplitExpenses = redistributeSplitExpenseAmounts(updatedSplitExpenses, total, currency); Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { @@ -13244,11 +13262,15 @@ function removeSplitExpenseField(draftTransaction: OnyxEntry item.transactionID !== splitExpenseTransactionID); + const splitExpenses = draftTransaction.comment?.splitExpenses?.filter((item) => item.transactionID !== splitExpenseTransactionID) ?? []; + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); + + const redistributedSplitExpenses = redistributeSplitExpenseAmounts(splitExpenses, total, currency); Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { - splitExpenses, + splitExpenses: redistributedSplitExpenses, splitsStartDate: null, splitsEndDate: null, }, diff --git a/tests/unit/SplitExpenseAutoAdjustmentTest.ts b/tests/unit/SplitExpenseAutoAdjustmentTest.ts index 2d034f6527b3..fb08467c49a9 100644 --- a/tests/unit/SplitExpenseAutoAdjustmentTest.ts +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, updateSplitExpenseAmountField} from '@libs/actions/IOU'; +import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, removeSplitExpenseField, updateSplitExpenseAmountField} from '@libs/actions/IOU'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; import type {SplitExpense} from '@src/types/onyx/IOU'; @@ -102,11 +102,13 @@ describe('Split Expense Auto-Adjustment', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); - // Action: Add a third split + // Action: Add a new split addSplitExpenseField(mockTransaction, mockTransaction); await waitForBatchedUpdates(); - // Verify + // Verify: 3 splits + // Split 1: locked at 300 + // New split + Split 2: share remaining 700 -> 350 each const draftTransaction = await new Promise>((resolve) => { const connection = Onyx.connect({ key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, @@ -120,21 +122,137 @@ describe('Split Expense Auto-Adjustment', () => { const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; expect(splitExpenses.length).toBe(3); - // Edited split should remain at $3 and locked - const editedSplit = splitExpenses.find((s) => s.transactionID === 'split1'); - expect(editedSplit?.amount).toBe(300); - expect(editedSplit?.isManuallyEdited).toBe(true); + // Verify split 1 is still 300 and marked as edited + const split1 = splitExpenses.find((s) => s.transactionID === 'split1'); + expect(split1?.amount).toBe(300); + expect(split1?.isManuallyEdited).toBe(true); - // Remaining $7 should be split between 2 unedited splits - const uneditedSplits = splitExpenses.filter((s) => !s.isManuallyEdited); - expect(uneditedSplits.length).toBe(2); - const uneditedTotal = uneditedSplits.reduce((sum, s) => sum + s.amount, 0); - expect(uneditedTotal).toBe(700); // $7 total + // Verify total matches + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); + }); + + describe('removeSplitExpenseField', () => { + it('should redistribute evenly when removing a split from 3 unedited splits', async () => { + // Setup: 3 splits at ~$3.33/$3.33/$3.34 + const initialSplits = [createSplitExpense('split1', 333, false), createSplitExpense('split2', 333, false), createSplitExpense('split3', 334, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Remove split 3 + removeSplitExpenseField(mockTransaction, 'split3'); + await waitForBatchedUpdates(); + + // Verify: Should be 2 splits at $5/$5 (50/50) + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.length).toBe(2); + expect(splitExpenses.find((s) => s.transactionID === 'split3')).toBeUndefined(); + + // Each remaining split should be 500 + expect(splitExpenses.every((s) => s.amount === 500)).toBe(true); // Total should equal original amount const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); expect(totalAmount).toBe(TOTAL_AMOUNT); }); + + it('should preserve edited splits and redistribute remaining to unedited when removing', async () => { + // Setup: 3 splits: + // split1: 300 (edited/locked) + // split2: 350 (unedited) + // split3: 350 (unedited) + // Total: 1000 + const initialSplits = [createSplitExpense('split1', 300, true), createSplitExpense('split2', 350, false), createSplitExpense('split3', 350, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Remove split 3 + removeSplitExpenseField(mockTransaction, 'split3'); + await waitForBatchedUpdates(); + + // Verify: 2 splits + // split1 preserved at 300 + // split2 takes remaining 700 (1000 - 300) + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.length).toBe(2); + + const split1 = splitExpenses.find((s) => s.transactionID === 'split1'); + const split2 = splitExpenses.find((s) => s.transactionID === 'split2'); + + expect(split1?.amount).toBe(300); + expect(split1?.isManuallyEdited).toBe(true); + + expect(split2?.amount).toBe(700); + expect(split2?.isManuallyEdited).toBe(false); + + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); + + it('should handle removing a manually edited split', async () => { + // Setup: 3 splits: + // split1: 300 (edited/locked) + // split2: 350 (unedited) + // split3: 350 (unedited) + const initialSplits = [createSplitExpense('split1', 300, true), createSplitExpense('split2', 350, false), createSplitExpense('split3', 350, false)]; + + const mockTransaction = createMockDraftTransaction(initialSplits); + + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); + await waitForBatchedUpdates(); + + // Action: Remove split 1 (the edited one) + removeSplitExpenseField(mockTransaction, 'split1'); + await waitForBatchedUpdates(); + + // Verify: 2 unedited splits remain + // They should redistribute the full 1000 -> 500 each + const draftTransaction = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; + expect(splitExpenses.length).toBe(2); + expect(splitExpenses.find((s) => s.transactionID === 'split1')).toBeUndefined(); + + expect(splitExpenses.every((s) => s.amount === 500)).toBe(true); + + const totalAmount = splitExpenses.reduce((sum, split) => sum + split.amount, 0); + expect(totalAmount).toBe(TOTAL_AMOUNT); + }); }); describe('updateSplitExpenseAmountField', () => { From 98ef3ae2d9953fcb969c7ec083512ad267b48b8c Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 5 Feb 2026 19:25:14 -0800 Subject: [PATCH 04/17] fix: expense amount can be changed to higher than original and negative expense appears --- src/pages/iou/SplitExpensePage.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 4c0d7c7a9d4b..6a2efa8a562e 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -194,6 +194,14 @@ function SplitExpensePage({route}: SplitExpensePageProps) { if (draftTransaction?.errors) { clearSplitTransactionDraftErrors(transactionID); } + + const invalidSplit = splitExpenses.find((split) => Math.abs(split.amount) > Math.abs(transactionDetailsAmount)); + if (invalidSplit) { + const difference = Math.abs(invalidSplit.amount) - Math.abs(transactionDetailsAmount); + setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)})); + return; + } + if (sumOfSplitExpenses > transactionDetailsAmount) { const difference = sumOfSplitExpenses - transactionDetailsAmount; setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)})); @@ -334,7 +342,12 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const displayError = errorMessage || draftTransactionError; const difference = sumOfSplitExpenses - transactionDetailsAmount; let warningMessage = ''; - if (difference < 0) { + + const invalidSplit = splitExpenses.find((split) => Math.abs(split.amount) > Math.abs(transactionDetailsAmount)); + if (invalidSplit) { + // If there is an invalid split, we want to show the error message for that split + warningMessage = ''; + } else if (difference < 0) { warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(-difference, transactionDetails.currency)}); } else if (difference > 0) { warningMessage = translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)}); From ff03d3b4c574fb48f59c5db18a0e2e5adc0df2c1 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 5 Feb 2026 19:42:44 -0800 Subject: [PATCH 05/17] chore: submodule sync - ready for review --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 574c396c1731..b1f564ef4de8 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 574c396c1731467091f7865a3e460343d25e2397 +Subproject commit b1f564ef4de82d58f3f92ebe4d268601d9856eaf From 15fa4a56b5c41a2454ec1284c590b7f1cceb0971 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 5 Feb 2026 20:38:45 -0800 Subject: [PATCH 06/17] fix: agent review refactoring - ready for review --- src/libs/actions/IOU/index.ts | 31 +----------------------------- src/pages/iou/SplitExpensePage.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 36 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index e3cfd806db14..26b390c6db5e 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -13338,36 +13338,7 @@ function updateSplitExpenseAmountField(draftTransaction: OnyxEntry !split.isManuallyEdited); - - // If no unedited splits remain, just save the updated amounts without redistribution - if (uneditedSplits.length === 0) { - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { - comment: { - splitExpenses: splitWithUpdatedAmount, - }, - }); - return; - } - - // Sum amounts of manually edited splits (the updated split is already marked as edited) - const editedSum = splitWithUpdatedAmount.filter((split) => split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); - - // Redistribute remaining amount among unedited splits - const remaining = total - editedSum; - const lastUneditedIndex = uneditedSplits.length - 1; - let uneditedIndex = 0; - - const redistributedSplitExpenses = splitWithUpdatedAmount.map((split) => { - if (split.isManuallyEdited) { - return split; - } - const isLast = uneditedIndex === lastUneditedIndex; - const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); - uneditedIndex += 1; - return {...split, amount: newAmount}; - }); + const redistributedSplitExpenses = redistributeSplitExpenseAmounts(splitWithUpdatedAmount, total, currency); Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 6a2efa8a562e..7b073f6fc541 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -119,6 +119,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const sumOfSplitExpenses = (draftTransaction?.comment?.splitExpenses ?? []).reduce((acc, item) => acc + (item.amount ?? 0), 0); const splitExpenses = useMemo(() => draftTransaction?.comment?.splitExpenses ?? [], [draftTransaction?.comment?.splitExpenses]); + const invalidSplit = splitExpenses.find((split) => Math.abs(split.amount) > Math.abs(transactionDetailsAmount)); const currencySymbol = getCurrencySymbol(transactionDetails.currency ?? '') ?? transactionDetails.currency ?? CONST.CURRENCY.USD; @@ -195,7 +196,6 @@ function SplitExpensePage({route}: SplitExpensePageProps) { clearSplitTransactionDraftErrors(transactionID); } - const invalidSplit = splitExpenses.find((split) => Math.abs(split.amount) > Math.abs(transactionDetailsAmount)); if (invalidSplit) { const difference = Math.abs(invalidSplit.amount) - Math.abs(transactionDetailsAmount); setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)})); @@ -340,16 +340,18 @@ function SplitExpensePage({route}: SplitExpensePageProps) { ); const displayError = errorMessage || draftTransactionError; - const difference = sumOfSplitExpenses - transactionDetailsAmount; + const absSumOfSplitExpenses = Math.abs(sumOfSplitExpenses); + const absTransactionDetailsAmount = Math.abs(transactionDetailsAmount); let warningMessage = ''; - const invalidSplit = splitExpenses.find((split) => Math.abs(split.amount) > Math.abs(transactionDetailsAmount)); if (invalidSplit) { // If there is an invalid split, we want to show the error message for that split warningMessage = ''; - } else if (difference < 0) { - warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(-difference, transactionDetails.currency)}); - } else if (difference > 0) { + } else if (absSumOfSplitExpenses < absTransactionDetailsAmount) { + const difference = absTransactionDetailsAmount - absSumOfSplitExpenses; + warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(difference, transactionDetails.currency)}); + } else if (absSumOfSplitExpenses > absTransactionDetailsAmount) { + const difference = absSumOfSplitExpenses - absTransactionDetailsAmount; warningMessage = translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)}); } From 0b46b65b4caf8d7f73d7de3f09a489e6759223d3 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 6 Feb 2026 15:18:02 -0800 Subject: [PATCH 07/17] fix: address review comments --- src/pages/iou/SplitExpensePage.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 7b073f6fc541..8fdc1d166440 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -340,18 +340,15 @@ function SplitExpensePage({route}: SplitExpensePageProps) { ); const displayError = errorMessage || draftTransactionError; - const absSumOfSplitExpenses = Math.abs(sumOfSplitExpenses); - const absTransactionDetailsAmount = Math.abs(transactionDetailsAmount); + const difference = sumOfSplitExpenses - transactionDetailsAmount; let warningMessage = ''; if (invalidSplit) { - // If there is an invalid split, we want to show the error message for that split - warningMessage = ''; - } else if (absSumOfSplitExpenses < absTransactionDetailsAmount) { - const difference = absTransactionDetailsAmount - absSumOfSplitExpenses; - warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(difference, transactionDetails.currency)}); - } else if (absSumOfSplitExpenses > absTransactionDetailsAmount) { - const difference = absSumOfSplitExpenses - absTransactionDetailsAmount; + const absDifference = Math.abs(invalidSplit.amount) - Math.abs(transactionDetailsAmount); + warningMessage = translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(absDifference, transactionDetails?.currency)}); + } else if (difference < 0) { + warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(-difference, transactionDetails.currency)}); + } else if (difference > 0) { warningMessage = translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)}); } From 069eb3785ba89c24333cbdc6bcf2a6c3c8a4af5b Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 6 Feb 2026 16:04:17 -0800 Subject: [PATCH 08/17] chore: prettier --- src/libs/actions/IOU/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index c781fc154792..a871c1788fcb 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -13034,7 +13034,7 @@ function markRejectViolationAsResolved(transactionID: string, reportID?: string) function initSplitExpenseItemData( transaction: OnyxEntry, - transactionReport: OnyxEntry, + transactionReport: OnyxEntry, {amount, transactionID, reportID, created, isManuallyEdited}: {amount?: number; transactionID?: string; reportID?: string; created?: string; isManuallyEdited?: boolean} = {}, ): SplitExpense { const transactionDetails = getTransactionDetails(transaction); @@ -13065,7 +13065,7 @@ function initSplitExpense(transactions: OnyxCollection, r const reportID = transaction.reportID ?? String(CONST.DEFAULT_NUMBER_ID); const originalTransactionID = transaction?.comment?.originalTransactionID; const originalTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`]; - + const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); if (isExpenseSplit) { From cdeac7b69171a12ad66ef154a368aa4dff7268c5 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 6 Feb 2026 16:06:36 -0800 Subject: [PATCH 09/17] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index b1f564ef4de8..1e1c2da1546b 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit b1f564ef4de82d58f3f92ebe4d268601d9856eaf +Subproject commit 1e1c2da1546ba1cf8d13d3b112978b14fef607fb From 0ba5f119734aa70aaf1a9300f6a61257efd59a13 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 6 Feb 2026 16:17:36 -0800 Subject: [PATCH 10/17] chore: typecheck - ready for review --- tests/unit/SplitExpenseAutoAdjustmentTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/SplitExpenseAutoAdjustmentTest.ts b/tests/unit/SplitExpenseAutoAdjustmentTest.ts index fb08467c49a9..676d0c5f55c4 100644 --- a/tests/unit/SplitExpenseAutoAdjustmentTest.ts +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -65,7 +65,7 @@ describe('Split Expense Auto-Adjustment', () => { await waitForBatchedUpdates(); // Action: Add a third split - addSplitExpenseField(mockTransaction, mockTransaction); + addSplitExpenseField(mockTransaction, mockTransaction, undefined); await waitForBatchedUpdates(); // Verify: Should be 3 splits at ~$3.33/$3.33/$3.34 (33/33/34%) @@ -103,7 +103,7 @@ describe('Split Expense Auto-Adjustment', () => { await waitForBatchedUpdates(); // Action: Add a new split - addSplitExpenseField(mockTransaction, mockTransaction); + addSplitExpenseField(mockTransaction, mockTransaction, undefined); await waitForBatchedUpdates(); // Verify: 3 splits From 104c37f3014b25c2d2922dc0c4e650addf712b82 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 9 Feb 2026 22:02:49 -0800 Subject: [PATCH 11/17] fix: split warning / error logic --- Mobile-Expensify | 2 +- src/pages/iou/SplitExpensePage.tsx | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 1e1c2da1546b..87d1b8d53cee 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 1e1c2da1546ba1cf8d13d3b112978b14fef607fb +Subproject commit 87d1b8d53cee77d705e7fd579f11c00576572d78 diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 657b25118924..d654a625569a 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -110,7 +110,12 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const transactionDetailsAmount = transactionDetails?.amount ?? 0; const sumOfSplitExpenses = (draftTransaction?.comment?.splitExpenses ?? []).reduce((acc, item) => acc + (item.amount ?? 0), 0); const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; - const invalidSplit = splitExpenses.find((split) => Math.abs(split.amount) > Math.abs(transactionDetailsAmount)); + const invalidSplit = splitExpenses.find((split) => { + // A split is only invalid if it has the same sign as the total and its magnitude exceeds the total + const sameSign = (split.amount >= 0 && transactionDetailsAmount >= 0) || (split.amount < 0 && transactionDetailsAmount < 0); + return sameSign && Math.abs(split.amount) > Math.abs(transactionDetailsAmount); + }); + const absDifference = Math.abs(sumOfSplitExpenses) - Math.abs(transactionDetailsAmount); const currencySymbol = getCurrencySymbol(transactionDetails.currency ?? '') ?? transactionDetails.currency ?? CONST.CURRENCY.USD; @@ -202,9 +207,12 @@ function SplitExpensePage({route}: SplitExpensePageProps) { clearSplitTransactionDraftErrors(transactionID); } - if (invalidSplit) { - const difference = Math.abs(invalidSplit.amount) - Math.abs(transactionDetailsAmount); - setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)})); + if (invalidSplit && Math.abs(sumOfSplitExpenses) !== Math.abs(transactionDetailsAmount)) { + if (absDifference > 0) { + setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(absDifference, transactionDetails?.currency)})); + } else if (absDifference < 0) { + setErrorMessage(translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(-absDifference, transactionDetails?.currency)})); + } return; } @@ -347,9 +355,10 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const difference = sumOfSplitExpenses - transactionDetailsAmount; let warningMessage = ''; - if (invalidSplit) { - const absDifference = Math.abs(invalidSplit.amount) - Math.abs(transactionDetailsAmount); + if (invalidSplit && absDifference > 0) { warningMessage = translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(absDifference, transactionDetails?.currency)}); + } else if (invalidSplit && absDifference < 0) { + warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(-absDifference, transactionDetails?.currency)}); } else if (difference < 0) { warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(-difference, transactionDetails.currency)}); } else if (difference > 0) { From 247f69a9c8ac0552151db58971a3014405036777 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 9 Feb 2026 22:03:54 -0800 Subject: [PATCH 12/17] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 87d1b8d53cee..1e1c2da1546b 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 87d1b8d53cee77d705e7fd579f11c00576572d78 +Subproject commit 1e1c2da1546ba1cf8d13d3b112978b14fef607fb From 627657a88e112ff3b6cf94d1d074814bbf33a182 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 10 Feb 2026 17:48:59 -0800 Subject: [PATCH 13/17] chore: merge w/ main adjustments --- src/libs/actions/IOU/Split.ts | 89 +++++++++++++++++--- src/types/onyx/IOU.ts | 2 +- tests/actions/IOUTest/SplitTest.ts | 6 +- tests/unit/SplitExpenseAutoAdjustmentTest.ts | 2 +- 4 files changed, 83 insertions(+), 16 deletions(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 6d4de776a3b5..94d0785500aa 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1814,7 +1814,8 @@ function initSplitExpenseItemData( created, merchant, customUnit, - }: {amount?: number; transactionID?: string; reportID?: string; created?: string; merchant?: string; customUnit?: TransactionCustomUnit} = {}, + isManuallyEdited, + }: {amount?: number; transactionID?: string; reportID?: string; created?: string; merchant?: string; customUnit?: TransactionCustomUnit; isManuallyEdited?: boolean} = {}, ): SplitExpense { const transactionDetails = getTransactionDetails(transaction); @@ -1833,6 +1834,7 @@ function initSplitExpenseItemData( waypoints: transaction?.comment?.waypoints ?? undefined, odometerStart: transaction?.comment?.odometerStart ?? undefined, odometerEnd: transaction?.comment?.odometerEnd ?? undefined, + isManuallyEdited: isManuallyEdited ?? false, }; } @@ -1859,7 +1861,7 @@ function initSplitExpense( const transactionDetails = getTransactionDetails(originalTransaction); const splitExpenses = relatedTransactions.map((currentTransaction) => { const currentTransactionReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${currentTransaction?.reportID}`]; - return initSplitExpenseItemData(currentTransaction, currentTransactionReport); + return initSplitExpenseItemData(currentTransaction, currentTransactionReport, {isManuallyEdited: true}); }); const draftTransaction = buildOptimisticTransaction({ originalTransactionID, @@ -1926,12 +1928,14 @@ function initSplitExpense( transactionID: NumberUtils.rand64(), customUnit: splitCustomUnits.at(0), merchant: splitMerchants.at(0), + isManuallyEdited: false, }), initSplitExpenseItemData(transaction, transactionReport, { amount: splitAmounts.at(1) ?? 0, transactionID: NumberUtils.rand64(), customUnit: splitCustomUnits.at(1), merchant: splitMerchants.at(1), + isManuallyEdited: false, }), ]; @@ -2002,8 +2006,47 @@ function initDraftSplitExpenseDataForEdit(draftTransaction: OnyxEntry split.isManuallyEdited).reduce((sum, split) => sum + split.amount, 0); + + // Find all unedited splits + const uneditedSplits = splitExpenses.filter((split) => !split.isManuallyEdited); + const uneditedCount = uneditedSplits.length; + + // If no unedited splits, return as-is + if (uneditedCount === 0) { + return splitExpenses; + } + + // Redistribute remaining amount among unedited splits + const remaining = total - editedSum; + const lastUneditedIndex = uneditedCount - 1; + let uneditedIndex = 0; + + return splitExpenses.map((split) => { + if (split.isManuallyEdited) { + return split; + } + const isLast = uneditedIndex === lastUneditedIndex; + const newAmount = calculateIOUAmount(lastUneditedIndex, remaining, currency, isLast, true); + uneditedIndex += 1; + return {...split, amount: newAmount}; + }); +} + /** * Append a new split expense entry to the draft transaction's splitExpenses array + * and auto-redistribute amounts among all unedited splits. */ function addSplitExpenseField( transaction: OnyxEntry, @@ -2046,11 +2089,22 @@ function addSplitExpenseField( reportID: draftTransaction?.reportID, customUnit, merchant, + isManuallyEdited: false, }); - Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`, { + const existingSplits = draftTransaction.comment?.splitExpenses ?? []; + const updatedSplitExpenses = [...existingSplits, newSplitExpense]; + + // Get total amount and currency for redistribution + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); + const originalTransactionID = draftTransaction.comment?.originalTransactionID ?? transaction.transactionID; + + const redistributedSplitExpenses = redistributeSplitExpenseAmounts(updatedSplitExpenses, total, currency); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { - splitExpenses: [...(draftTransaction.comment?.splitExpenses ?? []), newSplitExpense], + splitExpenses: redistributedSplitExpenses, splitsStartDate: null, splitsEndDate: null, }, @@ -2093,6 +2147,8 @@ function evenlyDistributeSplitExpenseAmounts(draftTransaction: OnyxEntry item.transactionID !== splitExpenseTransactionID); + const splitExpenses = draftTransaction.comment?.splitExpenses?.filter((item) => item.transactionID !== splitExpenseTransactionID) ?? []; + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); + + const redistributedSplitExpenses = redistributeSplitExpenseAmounts(splitExpenses, total, currency); Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { comment: { - splitExpenses, + splitExpenses: redistributedSplitExpenses, splitsStartDate: null, splitsEndDate: null, }, @@ -2322,19 +2382,24 @@ function updateSplitExpenseField( } function updateSplitExpenseAmountField(draftTransaction: OnyxEntry, currentItemTransactionID: string, amount: number, policy?: OnyxEntry) { - if (!draftTransaction?.transactionID || !currentItemTransactionID) { + if (!draftTransaction?.transactionID || !currentItemTransactionID || Number.isNaN(amount)) { return; } const originalTransactionID = draftTransaction?.comment?.originalTransactionID; const originalTransaction = getAllTransactions()?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${originalTransactionID}`]; const isDistanceRequest = originalTransaction && isDistanceRequestTransactionUtils(originalTransaction); + const splitExpenses = draftTransaction.comment?.splitExpenses ?? []; + const total = getAmount(draftTransaction, undefined, undefined, true, true); + const currency = getCurrency(draftTransaction); - const updatedSplitExpenses = draftTransaction.comment?.splitExpenses?.map((splitExpense) => { + // Mark the edited split and update its amount + const splitWithUpdatedAmount = splitExpenses.map((splitExpense) => { if (splitExpense.transactionID === currentItemTransactionID) { let updatedSplitExpense: SplitExpense = { ...splitExpense, amount, + isManuallyEdited: true, }; // Update distance for distance transactions based on new amount and rate @@ -2357,8 +2422,8 @@ function updateSplitExpenseAmountField(draftTransaction: OnyxEntry { const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; expect(splitExpenses).toHaveLength(2); - expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(50); expect(splitExpenses?.[1].description).toBe('Test comment'); expect(splitExpenses?.[1].category).toBe('Food'); expect(splitExpenses?.[1].tags).toEqual(['lunch']); @@ -2720,7 +2720,7 @@ describe('addSplitExpenseField', () => { // Verify: The new split should have reimbursable: false (not counted as out-of-pocket) expect(splitExpenses?.[1].reimbursable).toBe(false); - expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(50); expect(splitExpenses?.[1].description).toBe('Card transaction'); expect(splitExpenses?.[1].category).toBe('Food'); expect(splitExpenses?.[1].tags).toEqual(['lunch']); @@ -2831,7 +2831,7 @@ describe('addSplitExpenseField', () => { const splitExpenses = updatedDraftTransaction?.comment?.splitExpenses; expect(splitExpenses).toHaveLength(2); - expect(splitExpenses?.[1].amount).toBe(0); + expect(splitExpenses?.[1].amount).toBe(10000); expect(splitExpenses?.[1].customUnit).toBeTruthy(); expect(splitExpenses?.[1].customUnit?.quantity).toBe(0); expect(splitExpenses?.[1].merchant).toBeDefined(); diff --git a/tests/unit/SplitExpenseAutoAdjustmentTest.ts b/tests/unit/SplitExpenseAutoAdjustmentTest.ts index 676d0c5f55c4..c9d75521c2c9 100644 --- a/tests/unit/SplitExpenseAutoAdjustmentTest.ts +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, removeSplitExpenseField, updateSplitExpenseAmountField} from '@libs/actions/IOU'; +import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, removeSplitExpenseField, updateSplitExpenseAmountField} from '@libs/actions/IOU/Split'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; import type {SplitExpense} from '@src/types/onyx/IOU'; From e3d221542d8a77f9400e2f5565dc3803ce7d2da4 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 10 Feb 2026 17:49:59 -0800 Subject: [PATCH 14/17] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 1e1c2da1546b..65b94e474b53 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 1e1c2da1546ba1cf8d13d3b112978b14fef607fb +Subproject commit 65b94e474b531385ac1cd2cccb206113f96d6cf5 From 4d9f95898820305ed12c9a50d992a37172edc7e1 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 11 Feb 2026 13:50:11 -0800 Subject: [PATCH 15/17] fix: resolve split differences --- src/libs/actions/IOU/Split.ts | 20 ++++++----------- src/pages/iou/SplitExpensePage.tsx | 36 +++++++++++++++--------------- src/types/onyx/IOU.ts | 2 +- 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 20e168ab0012..012ec43b6f0f 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -7,10 +7,13 @@ import type {SearchContextProps} from '@components/Search/types'; import * as API from '@libs/API'; import type {CompleteSplitBillParams, RevertSplitTransactionParams, SplitBillParams, SplitTransactionParams, SplitTransactionSplitsParam, StartSplitBillParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; +import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {calculateAmount as calculateIOUAmount, updateIOUOwnerAndTotal} from '@libs/IOUUtils'; +import {toLocaleDigit} from '@libs/LocaleDigitUtils'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; +import * as Localize from '@libs/Localize'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import * as NumberUtils from '@libs/NumberUtils'; @@ -53,6 +56,8 @@ import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {notifyNewAction} from '@userActions/Report'; import {removeDraftSplitTransaction, removeDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import DistanceRequestUtils from '@src/libs/DistanceRequestUtils'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -60,6 +65,7 @@ import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee, Participant, Split, SplitExpense} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; +import type {Unit} from '@src/types/onyx/Policy'; import type RecentlyUsedTags from '@src/types/onyx/RecentlyUsedTags'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -86,12 +92,6 @@ import { mergePolicyRecentlyUsedCurrencies, } from './index'; import type {BuildOnyxDataForMoneyRequestKeys, MoneyRequestInformationParams, OneOnOneIOUReport, StartSplitBilActionParams} from './index'; -import DistanceRequestUtils from '@src/libs/DistanceRequestUtils'; -import {toLocaleDigit} from '@libs/LocaleDigitUtils'; -import IntlStore from '@src/languages/IntlStore'; -import {Unit} from '@src/types/onyx/Policy'; -import * as Localize from '@libs/Localize'; -import {getCurrencySymbol} from '@libs/CurrencyUtils'; type IOURequestType = ValueOf; @@ -1759,13 +1759,7 @@ function getDistanceMerchantForSplitExpense(distanceInUnits: number, unit: Unit function initSplitExpenseItemData( transaction: OnyxEntry, transactionReport: OnyxEntry, - { - amount, - transactionID, - reportID, - created, - isManuallyEdited, - }: {amount?: number; transactionID?: string; reportID?: string; created?: string; isManuallyEdited?: boolean} = {}, + {amount, transactionID, reportID, created, isManuallyEdited}: {amount?: number; transactionID?: string; reportID?: string; created?: string; isManuallyEdited?: boolean} = {}, ): SplitExpense { const transactionDetails = getTransactionDetails(transaction); diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 97f71660722f..549530a3bac2 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -115,8 +115,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const sameSign = (split.amount >= 0 && transactionDetailsAmount >= 0) || (split.amount < 0 && transactionDetailsAmount < 0); return sameSign && Math.abs(split.amount) > Math.abs(transactionDetailsAmount); }); - const absDifference = Math.abs(sumOfSplitExpenses) - Math.abs(transactionDetailsAmount); - + const difference = sumOfSplitExpenses - transactionDetailsAmount; const currencySymbol = getCurrencySymbol(transactionDetails.currency ?? '') ?? transactionDetails.currency ?? CONST.CURRENCY.USD; const isPerDiem = isPerDiemRequest(transaction); @@ -207,23 +206,23 @@ function SplitExpensePage({route}: SplitExpensePageProps) { clearSplitTransactionDraftErrors(transactionID); } - if (invalidSplit && Math.abs(sumOfSplitExpenses) !== Math.abs(transactionDetailsAmount)) { - if (absDifference > 0) { - setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(absDifference, transactionDetails?.currency)})); - } else if (absDifference < 0) { - setErrorMessage(translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(-absDifference, transactionDetails?.currency)})); + if (invalidSplit && sumOfSplitExpenses !== transactionDetailsAmount) { + if (difference > 0) { + setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(Math.abs(difference), transactionDetails?.currency)})); + } else { + setErrorMessage(translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(Math.abs(difference), transactionDetails?.currency)})); } return; } if (sumOfSplitExpenses > transactionDetailsAmount) { - const difference = sumOfSplitExpenses - transactionDetailsAmount; - setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)})); + const greaterThanDifference = sumOfSplitExpenses - transactionDetailsAmount; + setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(greaterThanDifference, transactionDetails?.currency)})); return; } if (sumOfSplitExpenses < transactionDetailsAmount && (isPerDiem || isCard)) { - const difference = transactionDetailsAmount - sumOfSplitExpenses; - setErrorMessage(translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)})); + const lessThanDifference = transactionDetailsAmount - sumOfSplitExpenses; + setErrorMessage(translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(lessThanDifference, transactionDetails?.currency)})); return; } @@ -354,17 +353,18 @@ function SplitExpensePage({route}: SplitExpensePageProps) { ); - const difference = sumOfSplitExpenses - transactionDetailsAmount; let warningMessage = ''; - if (invalidSplit && absDifference > 0) { - warningMessage = translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(absDifference, transactionDetails?.currency)}); - } else if (invalidSplit && absDifference < 0) { - warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(-absDifference, transactionDetails?.currency)}); + if (invalidSplit && sumOfSplitExpenses !== transactionDetailsAmount) { + if (difference > 0) { + warningMessage = translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(Math.abs(difference), transactionDetails?.currency)}); + } else { + warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(Math.abs(difference), transactionDetails?.currency)}); + } } else if (difference < 0) { - warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(-difference, transactionDetails.currency)}); + warningMessage = translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(Math.abs(difference), transactionDetails.currency)}); } else if (difference > 0) { - warningMessage = translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)}); + warningMessage = translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(Math.abs(difference), transactionDetails?.currency)}); } const footerContent = ( diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts index de6f42683a9a..dba204e427b9 100644 --- a/src/types/onyx/IOU.ts +++ b/src/types/onyx/IOU.ts @@ -4,7 +4,7 @@ import type CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import type {Icon} from './OnyxCommon'; import type Report from './Report'; -import {TransactionCustomUnit} from './Transaction'; +import type {TransactionCustomUnit} from './Transaction'; /** Model of IOU participant */ type Participant = { From d971518f26c65b5a12bf472b6edcd8d0abbff5d9 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 11 Feb 2026 13:52:04 -0800 Subject: [PATCH 16/17] chore: submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 65b94e474b53..0f7f1d822f92 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 65b94e474b531385ac1cd2cccb206113f96d6cf5 +Subproject commit 0f7f1d822f922c8d3962cfbfcd3eeb2e1868a9b1 From f4b4caf30ef1bf51a10bb1a8e0c76ffbd13bd6e0 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 12 Feb 2026 08:27:26 -0800 Subject: [PATCH 17/17] fix: isManuallyEdited edit splits --- src/pages/iou/SplitExpensePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 549530a3bac2..7e6c7579e88a 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -130,10 +130,10 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const childTransactions = getChildTransactions(allTransactions, allReports, transactionID); const splitFieldDataFromChildTransactions = childTransactions.map((childTransaction) => { const childTransactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${childTransaction?.reportID}`]; - return initSplitExpenseItemData(childTransaction, childTransactionReport); + return initSplitExpenseItemData(childTransaction, childTransactionReport, {isManuallyEdited: true}); }); const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`]; - const splitFieldDataFromOriginalTransaction = initSplitExpenseItemData(transaction, transactionReport); + const splitFieldDataFromOriginalTransaction = initSplitExpenseItemData(transaction, transactionReport, {isManuallyEdited: true}); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, {canBeMissing: true}); const icons = useMemoizedLazyExpensifyIcons(['ArrowsLeftRight', 'Plus'] as const);