Skip to content
Open
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 @@ -256,6 +260,7 @@ function MoneyRequestAmountInput({
autoGrowExtraSpace={autoGrowExtraSpace}
submitBehavior={submitBehavior}
allowFlippingAmount={allowFlippingAmount}
allowNegativeInput={allowNegativeInput}
toggleNegative={toggleNegative}
clearNegative={clearNegative}
onFocus={props.onFocus}
Expand Down
21 changes: 15 additions & 6 deletions src/components/NumberWithSymbolForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
/** 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 @@
shouldWrapInputInContainer = true,
isNegative = false,
allowFlippingAmount = false,
allowNegativeInput = false,
toggleNegative,
clearNegative,
ref,
Expand Down Expand Up @@ -218,11 +222,13 @@
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 @@
});
onInputChange?.(strippedNumber);
},
[decimals, maxLength, onInputChange, allowFlippingAmount, toggleNegative],

Check warning on line 251 in src/components/NumberWithSymbolForm.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

React Hook useCallback has a missing dependency: 'allowNegativeInput'. Either include it or remove the dependency array

Check warning on line 251 in src/components/NumberWithSymbolForm.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

React Hook useCallback has a missing dependency: 'allowNegativeInput'. Either include it or remove the dependency array
);

/**
Expand All @@ -253,11 +259,14 @@
// 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 @@
// 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 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 @@ -743,7 +743,7 @@

let allPersonalDetails: OnyxTypes.PersonalDetailsList = {};
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,

Check warning on line 746 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
callback: (value) => {
allPersonalDetails = value ?? {};
},
Expand Down Expand Up @@ -846,7 +846,7 @@

let allTransactions: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.TRANSACTION,

Check warning on line 849 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
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {
Expand All @@ -860,7 +860,7 @@

let allTransactionDrafts: NonNullable<OnyxCollection<OnyxTypes.Transaction>> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.TRANSACTION_DRAFT,

Check warning on line 863 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
waitForCollectionCallback: true,
callback: (value) => {
allTransactionDrafts = value ?? {};
Expand All @@ -869,7 +869,7 @@

let allTransactionViolations: NonNullable<OnyxCollection<OnyxTypes.TransactionViolations>> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,

Check warning on line 872 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
waitForCollectionCallback: true,
callback: (value) => {
if (!value) {
Expand All @@ -883,7 +883,7 @@

let allNextSteps: NonNullable<OnyxCollection<OnyxTypes.ReportNextStepDeprecated>> = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.NEXT_STEP,

Check warning on line 886 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
waitForCollectionCallback: true,
callback: (value) => {
allNextSteps = value ?? {};
Expand All @@ -892,7 +892,7 @@

let allReports: OnyxCollection<OnyxTypes.Report>;
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,

Check warning on line 895 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
waitForCollectionCallback: true,
callback: (value) => {
allReports = value;
Expand All @@ -901,7 +901,7 @@

let allReportNameValuePairs: OnyxCollection<OnyxTypes.ReportNameValuePairs>;
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS,

Check warning on line 904 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
waitForCollectionCallback: true,
callback: (value) => {
allReportNameValuePairs = value;
Expand All @@ -911,7 +911,7 @@
let userAccountID = -1;
let currentUserEmail = '';
Onyx.connect({
key: ONYXKEYS.SESSION,

Check warning on line 914 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
callback: (value) => {
currentUserEmail = value?.email ?? '';
userAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID;
Expand All @@ -920,7 +920,7 @@

let deprecatedCurrentUserPersonalDetails: OnyxEntry<OnyxTypes.PersonalDetails>;
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,

Check warning on line 923 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
callback: (value) => {
deprecatedCurrentUserPersonalDetails = value?.[userAccountID] ?? undefined;
},
Expand Down Expand Up @@ -13754,7 +13754,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 @@ -13770,6 +13770,7 @@
statusNum: currentReport?.statusNum ?? 0,
reportID: reportID ?? transaction?.reportID ?? String(CONST.DEFAULT_NUMBER_ID),
reimbursable: transactionDetails?.reimbursable,
isManuallyEdited: isManuallyEdited ?? false,
};
}

Expand All @@ -13789,7 +13790,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 @@ -13817,9 +13819,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 @@ -13883,22 +13894,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 @@ -13937,6 +13980,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 @@ -14051,19 +14096,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);
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 @@ -8511,6 +8511,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 @@ -8530,7 +8531,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 @@ -8572,6 +8573,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 @@ -8596,7 +8598,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