From 9645a644d9d53eadfd030014cc6fc8147addd0bb Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 9 Jan 2026 16:39:23 -0800 Subject: [PATCH 1/3] feat: update amounts/percentages split logic to match OD --- src/libs/actions/IOU/index.ts | 115 +++++++-- src/types/onyx/IOU.ts | 3 + tests/unit/SplitExpenseAutoAdjustmentTest.ts | 258 +++++++++++++++++++ 3 files changed, 360 insertions(+), 16 deletions(-) create mode 100644 tests/unit/SplitExpenseAutoAdjustmentTest.ts diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index c1edfd8a9d6d5..205bd6775ec17 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -13609,7 +13609,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}`]; @@ -13625,6 +13625,7 @@ function initSplitExpenseItemData( statusNum: currentReport?.statusNum ?? 0, reportID: reportID ?? transaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), reimbursable: transactionDetails?.reimbursable, + isManuallyEdited: isManuallyEdited ?? false, }; } @@ -13644,7 +13645,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: { @@ -13672,9 +13674,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({ @@ -13738,22 +13749,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, }, @@ -13792,6 +13835,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}`, { @@ -13906,19 +13951,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 redistriutedSplitExpenses = 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: redistriutedSplitExpenses, }, }); } diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts index 5858e25da3b6c..d10145463b104 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/unit/SplitExpenseAutoAdjustmentTest.ts b/tests/unit/SplitExpenseAutoAdjustmentTest.ts new file mode 100644 index 0000000000000..318276aac1262 --- /dev/null +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -0,0 +1,258 @@ +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, updateSplitExpenseAmountField} from '@libs/actions/IOU'; +import CONST from '@src/CONST'; +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): OnyxEntry => + ({ + 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 as Transaction); + 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 as Transaction); + 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 as Transaction); + 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 as Transaction); + 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 as Transaction); + 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 c8e695fe74726a9440f363b53526b91334cdbd4c Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 9 Jan 2026 18:13:45 -0800 Subject: [PATCH 2/3] fix: lint, tests and spellcheck --- src/libs/actions/IOU/index.ts | 4 ++-- tests/actions/IOUTest.ts | 6 ++++-- tests/unit/SplitExpenseAutoAdjustmentTest.ts | 13 ++++++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 205bd6775ec17..2966b747d5a0b 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -13989,7 +13989,7 @@ function updateSplitExpenseAmountField(draftTransaction: OnyxEntry { + const redistributedSplitExpenses = splitWithUpdatedAmount.map((split) => { if (split.isManuallyEdited) { return split; } @@ -14001,7 +14001,7 @@ function updateSplitExpenseAmountField(draftTransaction: OnyxEntry { category: 'Food', tags: ['lunch'], created: DateUtils.getDBTime(), + isManuallyEdited: true, // Lock the existing split so new split gets remaining amount }, ], attendees: [], @@ -8516,7 +8517,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']); @@ -8558,6 +8559,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: [], @@ -8582,7 +8584,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 index 318276aac1262..257d186a4fb6e 100644 --- a/tests/unit/SplitExpenseAutoAdjustmentTest.ts +++ b/tests/unit/SplitExpenseAutoAdjustmentTest.ts @@ -1,7 +1,6 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {addSplitExpenseField, evenlyDistributeSplitExpenseAmounts, updateSplitExpenseAmountField} from '@libs/actions/IOU'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; import type {SplitExpense} from '@src/types/onyx/IOU'; @@ -21,7 +20,7 @@ describe('Split Expense Auto-Adjustment', () => { const TOTAL_AMOUNT = 1000; // $10.00 in cents // Helper to create a mock draft transaction - const createMockDraftTransaction = (splitExpenses: SplitExpense[], amount = TOTAL_AMOUNT): OnyxEntry => + const createMockDraftTransaction = (splitExpenses: SplitExpense[], amount = TOTAL_AMOUNT): Transaction => ({ transactionID: ORIGINAL_TRANSACTION_ID, reportID: REPORT_ID, @@ -62,7 +61,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Add a third split @@ -100,7 +99,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Add a third split @@ -145,7 +144,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Edit split1 to $3.00 @@ -187,7 +186,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Edit split1 to $5.00 @@ -223,7 +222,7 @@ describe('Split Expense Auto-Adjustment', () => { const mockTransaction = createMockDraftTransaction(initialSplits); - await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction as Transaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${ORIGINAL_TRANSACTION_ID}`, mockTransaction); await waitForBatchedUpdates(); // Action: Make splits even From 323ca9c9e1b381258fbaba132e4a2275349a36d0 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 10 Jan 2026 15:08:55 -0800 Subject: [PATCH 3/3] fix: allow editing negative amount splits --- src/components/MoneyRequestAmountInput.tsx | 5 +++++ src/components/NumberWithSymbolForm.tsx | 21 +++++++++++++------ .../SplitListItem/SplitAmountInput.tsx | 1 + 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 55a8192f985bc..cb02e8af488c7 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; @@ -161,6 +164,7 @@ function MoneyRequestAmountInput({ shouldWrapInputInContainer = true, isNegative = false, allowFlippingAmount = false, + allowNegativeInput = false, toggleNegative, clearNegative, ref, @@ -256,6 +260,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 bc41aa9045b42..912386290077b 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; @@ -144,6 +147,7 @@ function NumberWithSymbolForm({ shouldWrapInputInContainer = true, isNegative = false, allowFlippingAmount = false, + allowNegativeInput = false, toggleNegative, clearNegative, ref, @@ -218,11 +222,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; } @@ -253,11 +259,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; } @@ -280,7 +289,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; } diff --git a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx index adf09095df74e..94de8bc750a80 100644 --- a/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx +++ b/src/components/SelectionList/ListItem/SplitListItem/SplitAmountInput.tsx @@ -54,6 +54,7 @@ function SplitAmountInput({splitItem, formattedOriginalAmount, contentWidth, onS shouldWrapInputInContainer={false} onFocus={focusHandler} onBlur={onInputBlur} + allowNegativeInput /> ); }