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
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,7 @@ const translations: TranslationDeepObject<typeof en> = {
splitExpense: 'Ausgabe aufteilen',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} von ${merchant}`,
addSplit: 'Split hinzufügen',
makeSplitsEven: 'Aufteilungen angleichen',
editSplits: 'Splits bearbeiten',
totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Der Gesamtbetrag ist ${amount} höher als die ursprüngliche Ausgabe.`,
totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Der Gesamtbetrag ist ${amount} weniger als die ursprüngliche Ausgabe.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,7 @@ const translations = {
splitExpense: 'Split expense',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} from ${merchant}`,
addSplit: 'Add split',
makeSplitsEven: 'Make splits even',
editSplits: 'Edit splits',
totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Total amount is ${amount} greater than the original expense.`,
totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Total amount is ${amount} less than the original expense.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ const translations: TranslationDeepObject<typeof en> = {
splitExpense: 'Dividir gasto',
splitExpenseSubtitle: ({amount, merchant}) => `${amount} de ${merchant}`,
addSplit: 'Añadir división',
makeSplitsEven: 'Igualar divisiones',
editSplits: 'Editar divisiones',
totalAmountGreaterThanOriginal: ({amount}) => `El importe total es ${amount} mayor que el gasto original.`,
totalAmountLessThanOriginal: ({amount}) => `El importe total es ${amount} menor que el gasto original.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,7 @@ const translations: TranslationDeepObject<typeof en> = {
splitExpense: 'Fractionner la dépense',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} de ${merchant}`,
addSplit: 'Ajouter une répartition',
makeSplitsEven: 'Uniformiser les répartitions',
editSplits: 'Modifier les répartitions',
totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Le montant total est de ${amount} supérieur à la dépense initiale.`,
totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Le montant total est de ${amount} inférieur à la dépense originale.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,7 @@ const translations: TranslationDeepObject<typeof en> = {
splitExpense: 'Dividi spesa',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} da ${merchant}`,
addSplit: 'Aggiungi divisione',
makeSplitsEven: 'Uniformare le suddivisioni',
editSplits: 'Modifica suddivisioni',
totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `L'importo totale è ${amount} maggiore della spesa originale.`,
totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `L'importo totale è ${amount} inferiore alla spesa originale.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,7 @@ const translations: TranslationDeepObject<typeof en> = {
splitExpense: '経費を分割',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${merchant}から${amount}`,
addSplit: '分割を追加',
makeSplitsEven: '分割を均等にする',
editSplits: '分割を編集',
totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `合計金額は元の経費よりも${amount}多いです。`,
totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `合計金額は元の経費よりも${amount}少なくなっています。`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,7 @@ const translations: TranslationDeepObject<typeof en> = {
splitExpense: 'Uitgave splitsen',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} van ${merchant}`,
addSplit: 'Splits toevoegen',
makeSplitsEven: 'Verdelingen gelijk maken',
editSplits: 'Splits bewerken',
totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Het totale bedrag is ${amount} meer dan de oorspronkelijke uitgave.`,
totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Het totale bedrag is ${amount} minder dan de oorspronkelijke uitgave.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,7 @@ const translations: TranslationDeepObject<typeof en> = {
splitExpense: 'Podziel wydatek',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} od ${merchant}`,
addSplit: 'Dodaj podział',
makeSplitsEven: 'Wyrównaj podziały',
editSplits: 'Edytuj podziały',
totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Całkowita kwota jest o ${amount} większa niż pierwotny wydatek.`,
totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Całkowita kwota jest o ${amount} mniejsza niż pierwotny wydatek.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,7 @@ const translations: TranslationDeepObject<typeof en> = {
splitExpense: 'Dividir despesa',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} de ${merchant}`,
addSplit: 'Adicionar divisão',
makeSplitsEven: 'Tornar as divisões iguais',
editSplits: 'Editar divisões',
totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `O valor total é ${amount} maior que a despesa original.`,
totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `O valor total é ${amount} a menos que a despesa original.`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,7 @@ const translations: TranslationDeepObject<typeof en> = {
splitExpense: '拆分费用',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `来自${merchant}的${amount}`,
addSplit: '添加分账',
makeSplitsEven: '使拆分均等',
editSplits: '编辑拆分',
totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `总金额比原始费用多${amount}。`,
totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `总金额比原始费用少 ${amount}。`,
Expand Down
26 changes: 20 additions & 6 deletions src/libs/IOUUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,33 @@ function navigateToParticipantPage(iouType: ValueOf<typeof CONST.IOU.TYPE>, tran
}

/**
* Calculates the amount per user given a list of participants
* Calculates the amount per split.
*
* @param numberOfParticipants - Number of participants in the chat. It should not include the current user.
* @param numberOfSplits - Number of splits EXCLUDING the remainder holder ("default user").
* @param total - IOU total amount in backend format (cents, no matter the currency)
* @param currency - This is used to know how many decimal places are valid to use when splitting the total
* @param isDefaultUser - Whether we are calculating the amount for the current user
* @param currency - Used to know how many decimal places are valid when splitting the total
* @param isDefaultUser - Whether we are calculating the amount for the remainder holder
* @param useFloorToLastRounding - `false` (default, legacy behavior) or `true` to floor all and put full remainder on the default user
*/
function calculateAmount(numberOfParticipants: number, total: number, currency: string, isDefaultUser = false): number {
function calculateAmount(numberOfSplits: number, total: number, currency: string, isDefaultUser = false, useFloorToLastRounding = false): number {
// Since the backend can maximum store 2 decimal places, any currency with more than 2 decimals
// has to be capped to 2 decimal places
const currencyUnit = Math.min(100, getCurrencyUnit(currency));
const totalInCurrencySubunit = (total / 100) * currencyUnit;
const totalParticipants = numberOfParticipants + 1;
const totalParticipants = numberOfSplits + 1;

// New optional mode
if (useFloorToLastRounding) {
// For positive totals, floor for everyone and add the full remainder to the default user
// For negative totals, do the inverse of above and round up using Math.ceil to calculate the base share
const baseShareSubunit = totalInCurrencySubunit >= 0 ? Math.floor(totalInCurrencySubunit / totalParticipants) : Math.ceil(totalInCurrencySubunit / totalParticipants);
const remainderSubunit = totalInCurrencySubunit - baseShareSubunit * totalParticipants;

const subunitAmount = baseShareSubunit + (isDefaultUser ? remainderSubunit : 0);
return Math.round((subunitAmount * 100) / currencyUnit);
}

// Legacy behavior (backwards compatible): round equally and adjust default user by +/- difference
const amountPerPerson = Math.round(totalInCurrencySubunit / totalParticipants);
let finalAmount = amountPerPerson;
if (isDefaultUser) {
Expand Down
42 changes: 42 additions & 0 deletions src/libs/actions/IOU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@
};

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

Check warning on line 697 in src/libs/actions/IOU.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 @@ -771,13 +771,13 @@
};

let allBetas: OnyxEntry<OnyxTypes.Beta[]>;
Onyx.connect({

Check warning on line 774 in src/libs/actions/IOU.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.BETAS,
callback: (value) => (allBetas = value),
});

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

Check warning on line 780 in src/libs/actions/IOU.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 @@ -791,7 +791,7 @@
});

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

Check warning on line 794 in src/libs/actions/IOU.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 @@ -800,7 +800,7 @@
});

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

Check warning on line 803 in src/libs/actions/IOU.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 @@ -814,7 +814,7 @@
});

let allNextSteps: NonNullable<OnyxCollection<OnyxTypes.ReportNextStep>> = {};
Onyx.connect({

Check warning on line 817 in src/libs/actions/IOU.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.NEXT_STEP,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -823,7 +823,7 @@
});

const allPolicies: OnyxCollection<OnyxTypes.Policy> = {};
Onyx.connect({

Check warning on line 826 in src/libs/actions/IOU.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.POLICY,
callback: (val, key) => {
if (!key) {
Expand All @@ -842,14 +842,14 @@
// `allRecentlyUsedTags` was moved here temporarily from `src/libs/actions/Policy/Tag.ts` during the `Deprecate Onyx.connect` refactor.
// All uses of this variable should be replaced with `useOnyx`.
let allRecentlyUsedTags: OnyxCollection<RecentlyUsedTags> = {};
Onyx.connect({

Check warning on line 845 in src/libs/actions/IOU.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.POLICY_RECENTLY_USED_TAGS,
waitForCollectionCallback: true,
callback: (val) => (allRecentlyUsedTags = val),
});

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

Check warning on line 852 in src/libs/actions/IOU.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 @@ -858,7 +858,7 @@
});

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

Check warning on line 861 in src/libs/actions/IOU.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 Down Expand Up @@ -13612,6 +13612,47 @@
});
}

/**
* Evenly distribute the draft split expense amounts across all split items.
* Remainders are added to the first or last item to ensure the total matches the original amount.
*
* Notes:
* - Works entirely on the provided `draftTransaction` to avoid direct Onyx reads.
* - Uses `calculateAmount` utility to handle currency subunits and rounding consistently with existing logic.
*/
function evenlyDistributeSplitExpenseAmounts(draftTransaction: OnyxEntry<OnyxTypes.Transaction>) {
if (!draftTransaction) {
return;
}

const originalTransactionID = draftTransaction?.comment?.originalTransactionID;
const splitExpenses = draftTransaction?.comment?.splitExpenses ?? [];
const currency = getCurrency(draftTransaction);

// Use allowNegative=true and disableOppositeConversion=true to preserve original amount sign
const total = getAmount(draftTransaction, undefined, undefined, true, true);
Comment thread
ikevin127 marked this conversation as resolved.

// Guard clause for missing data
if (!originalTransactionID || splitExpenses.length === 0) {
return;
}
Comment thread
ikevin127 marked this conversation as resolved.

// Floor-allocation with full remainder added to the last split so the last is always the largest
const splitCount = splitExpenses.length;
const lastIndex = splitCount - 1;

const updatedSplitExpenses = splitExpenses.map((splitExpense, index) => ({
...splitExpense,
amount: calculateIOUAmount(splitCount - 1, total, currency, index === lastIndex, true),
}));

Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, {
comment: {
splitExpenses: updatedSplitExpenses,
},
});
}

function removeSplitExpenseField(draftTransaction: OnyxEntry<OnyxTypes.Transaction>, splitExpenseTransactionID: string) {
if (!draftTransaction || !splitExpenseTransactionID) {
return;
Expand Down Expand Up @@ -14360,6 +14401,7 @@
initSplitExpense,
initSplitExpenseItemData,
addSplitExpenseField,
evenlyDistributeSplitExpenseAmounts,
updateSplitExpenseAmountField,
updateSplitTransactions,
updateSplitTransactionsFromSplitExpensesFlow,
Expand Down
47 changes: 30 additions & 17 deletions src/pages/iou/SplitExpensePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ConfirmModal from '@components/ConfirmModal';
import FormHelpMessage from '@components/FormHelpMessage';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
import ScreenWrapper from '@components/ScreenWrapper';
import {useSearchContext} from '@components/Search/SearchContext';
import SelectionList from '@components/SelectionListWithSections';
Expand All @@ -22,6 +23,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {
addSplitExpenseField,
clearSplitTransactionDraftErrors,
evenlyDistributeSplitExpenseAmounts,
getIOUActionForTransactions,
getIOURequestPolicyID,
initDraftSplitExpenseDataForEdit,
Expand Down Expand Up @@ -115,6 +117,13 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
addSplitExpenseField(transaction, draftTransaction);
}, [draftTransaction, transaction, transactionID]);

const onMakeSplitsEven = useCallback(() => {
if (!draftTransaction) {
return;
}
evenlyDistributeSplitExpenseAmounts(draftTransaction);
}, [draftTransaction]);

const onSaveSplitExpense = useCallback(() => {
if (splitExpenses.length <= 1 && !childTransactions.length) {
const splitFieldDataFromOriginalTransactionWithoutID = {...splitFieldDataFromOriginalTransaction, transactionID: ''};
Expand Down Expand Up @@ -279,31 +288,35 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
getTranslatedText,
]);

const headerContent = useMemo(
() => (
<View style={[styles.w100, styles.ph5, styles.flexRow, styles.gap2, shouldUseNarrowLayout && styles.mb3]}>
<Button
success
const listFooterContent = useMemo(() => {
const shouldShowMakeSplitsEven = childTransactions.length === 0;
return (
<View style={[styles.w100, styles.flexColumn, styles.mt1, shouldUseNarrowLayout && styles.mb3]}>
<MenuItem
onPress={onAddSplitExpense}
title={translate('iou.addSplit')}
icon={Expensicons.Plus}
text={translate('iou.addSplit')}
style={[shouldUseNarrowLayout && styles.flex1]}
style={[styles.ph4]}
/>
{shouldShowMakeSplitsEven && (
<MenuItem
onPress={onMakeSplitsEven}
title={translate('iou.makeSplitsEven')}
icon={Expensicons.ArrowsLeftRight}
style={[styles.ph4]}
/>
)}
</View>
),
[onAddSplitExpense, shouldUseNarrowLayout, styles.flex1, styles.flexRow, styles.gap2, styles.mb3, styles.ph5, styles.w100, translate],
);
);
}, [onAddSplitExpense, onMakeSplitsEven, translate, childTransactions, shouldUseNarrowLayout, styles.w100, styles.ph4, styles.flexColumn, styles.mt1, styles.mb3]);

const footerContent = useMemo(() => {
const shouldShowWarningMessage = sumOfSplitExpenses < transactionDetailsAmount;
const warningMessage = shouldShowWarningMessage
? translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(transactionDetailsAmount - sumOfSplitExpenses, transactionDetails.currency)})
: '';
return (
<View
ref={footerRef}
style={styles.pt3}
>
<View ref={footerRef}>
{(!!errorMessage || !!warningMessage) && (
<FormHelpMessage
style={[styles.ph1, styles.mb2]}
Expand All @@ -323,7 +336,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
/>
</View>
);
}, [sumOfSplitExpenses, transactionDetailsAmount, translate, transactionDetails.currency, errorMessage, styles.ph1, styles.mb2, styles.w100, onSaveSplitExpense, styles.pt3, footerRef]);
}, [sumOfSplitExpenses, transactionDetailsAmount, translate, transactionDetails.currency, errorMessage, styles.ph1, styles.mb2, styles.w100, onSaveSplitExpense, footerRef]);

const initiallyFocusedOptionKey = useMemo(
() => sections.at(0)?.data.find((option) => option.transactionID === splitExpenseTransactionID)?.keyForList,
Expand Down Expand Up @@ -375,12 +388,12 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
});
}}
ref={listRef}
headerContent={headerContent}
sections={sections}
initiallyFocusedOptionKey={initiallyFocusedOptionKey}
ListItem={SplitListItem}
containerStyle={[styles.flexBasisAuto, styles.pt1]}
containerStyle={[styles.flexBasisAuto]}
footerContent={footerContent}
listFooterContent={listFooterContent}
disableKeyboardShortcuts
shouldSingleExecuteRowSelect
canSelectMultiple={false}
Expand Down
Loading
Loading