Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/components/MoneyRequestAmountInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -161,6 +164,7 @@ function MoneyRequestAmountInput({
shouldWrapInputInContainer = true,
isNegative = false,
allowFlippingAmount = false,
allowNegativeInput = false,
toggleNegative,
clearNegative,
ref,
Expand Down Expand Up @@ -255,6 +259,7 @@ function MoneyRequestAmountInput({
autoGrowExtraSpace={autoGrowExtraSpace}
submitBehavior={submitBehavior}
allowFlippingAmount={allowFlippingAmount}
allowNegativeInput={allowNegativeInput}
toggleNegative={toggleNegative}
clearNegative={clearNegative}
onFocus={props.onFocus}
Expand Down
29 changes: 19 additions & 10 deletions src/components/NumberWithSymbolForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -144,6 +147,7 @@ function NumberWithSymbolForm({
shouldWrapInputInContainer = true,
isNegative = false,
allowFlippingAmount = false,
allowNegativeInput = false,
toggleNegative,
clearNegative,
ref,
Expand Down Expand Up @@ -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;
}
Expand All @@ -242,7 +248,7 @@ function NumberWithSymbolForm({
});
onInputChange?.(strippedNumber);
},
[decimals, maxLength, onInputChange, allowFlippingAmount, toggleNegative],
[decimals, maxLength, onInputChange, allowFlippingAmount, toggleNegative, allowNegativeInput],
);

/**
Expand All @@ -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;
}
Expand All @@ -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;
}

Expand All @@ -305,14 +314,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],
);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function SplitAmountInput({splitItem, formattedOriginalAmount, contentWidth, onS
shouldWrapInputInContainer={false}
onFocus={focusHandler}
onBlur={onInputBlur}
allowNegativeInput
/>
);
}
Expand Down
115 changes: 99 additions & 16 deletions src/libs/actions/IOU/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@
};

let allPersonalDetails: OnyxTypes.PersonalDetailsList = {};
Onyx.connect({

Check warning on line 768 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
allPersonalDetails = value ?? {};
Expand Down Expand Up @@ -868,7 +868,7 @@
};

let allTransactions: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({

Check warning on line 871 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.TRANSACTION,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -882,7 +882,7 @@
});

let allTransactionDrafts: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({

Check warning on line 885 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -891,7 +891,7 @@
});

let allTransactionViolations: NonNullable<OnyxCollection<OnyxTypes.TransactionViolations>> = {};
Onyx.connect({

Check warning on line 894 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -905,7 +905,7 @@
});

let allReports: OnyxCollection<OnyxTypes.Report>;
Onyx.connect({

Check warning on line 908 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -914,7 +914,7 @@
});

let allReportNameValuePairs: OnyxCollection<OnyxTypes.ReportNameValuePairs>;
Onyx.connect({

Check warning on line 917 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -924,7 +924,7 @@

let userAccountID = -1;
let currentUserEmail = '';
Onyx.connect({

Check warning on line 927 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.SESSION,
callback: (value) => {
currentUserEmail = value?.email ?? '';
Expand All @@ -933,7 +933,7 @@
});

let deprecatedCurrentUserPersonalDetails: OnyxEntry<OnyxTypes.PersonalDetails>;
Onyx.connect({

Check warning on line 936 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => {
deprecatedCurrentUserPersonalDetails = value?.[userAccountID] ?? undefined;
Expand All @@ -941,7 +941,7 @@
});

let allReportActions: OnyxCollection<OnyxTypes.ReportActions>;
Onyx.connect({

Check warning on line 944 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
waitForCollectionCallback: true,
callback: (actions) => {
Expand All @@ -953,7 +953,7 @@
});

let personalDetailsList: OnyxEntry<OnyxTypes.PersonalDetailsList>;
Onyx.connect({

Check warning on line 956 in src/libs/actions/IOU/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
callback: (value) => (personalDetailsList = value),
});
Expand Down Expand Up @@ -13346,7 +13346,7 @@

function initSplitExpenseItemData(
transaction: OnyxEntry<OnyxTypes.Transaction>,
{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}`];
Expand All @@ -13362,6 +13362,7 @@
statusNum: currentReport?.statusNum ?? 0,
reportID: reportID ?? transaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID),
reimbursable: transactionDetails?.reimbursable,
isManuallyEdited: isManuallyEdited ?? false,
};
}

Expand All @@ -13381,7 +13382,8 @@
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: {
Expand Down Expand Up @@ -13409,9 +13411,18 @@
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({
Expand Down Expand Up @@ -13475,22 +13486,54 @@

/**
* 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<OnyxTypes.Transaction>, draftTransaction: OnyxEntry<OnyxTypes.Transaction>) {
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,
},
Expand Down Expand Up @@ -13529,6 +13572,8 @@
const updatedSplitExpenses = splitExpenses.map((splitExpense, index) => ({
...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}`, {
Expand Down Expand Up @@ -13643,19 +13688,57 @@
return;
}

const updatedSplitExpenses = draftTransaction.comment?.splitExpenses?.map((splitExpense) => {
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);
Comment thread
ikevin127 marked this conversation as resolved.
uneditedIndex += 1;
return {...split, amount: newAmount};
});

Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, {
comment: {
splitExpenses: updatedSplitExpenses,
splitExpenses: redistributedSplitExpenses,
},
});
}
Expand Down
3 changes: 3 additions & 0 deletions src/types/onyx/IOU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
6 changes: 4 additions & 2 deletions tests/actions/IOUTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8737,6 +8737,7 @@ describe('actions/IOU', () => {
category: 'Food',
tags: ['lunch'],
created: DateUtils.getDBTime(),
isManuallyEdited: true, // Lock the existing split so new split gets remaining amount
},
],
attendees: [],
Expand All @@ -8756,7 +8757,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']);
Expand Down Expand Up @@ -8798,6 +8799,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: [],
Expand All @@ -8822,7 +8824,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']);
Expand Down
Loading
Loading