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
9 changes: 9 additions & 0 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"warning": "Warning: Don't use send invite if it's invalid email. use add to Split Pro instead. Your account will be blocked if this feature is misused"
},
"split_type_section": {
"direction": {
"no_money_flow": "No one else owes anything",
"owes_payer": "{{debtor}} owes {{payer}}",
"owes_you": "{{debtor}} owes you",
"you_owe": "You owe {{payer}}"
},
"split_equally": "split equally",
"split_unequally": "split unequally",
"types": {
Expand All @@ -213,6 +219,9 @@
"title": "Share",
"total_shares": "Total shares"
}
},
"validation": {
"invalid_split": "Adjust who owes this expense before saving."
}
},
"sponsor_us": "Sponsor us",
Expand Down
83 changes: 74 additions & 9 deletions src/components/AddExpense/AddExpensePage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SplitType } from '@prisma/client';
import { HeartHandshakeIcon, Landmark, RefreshCcwDot, X } from 'lucide-react';
import { useTranslation } from 'next-i18next';
import Link from 'next/link';
Expand Down Expand Up @@ -55,8 +56,73 @@ export const AddOrEditExpensePage: React.FC<{
const cronExpression = useAddExpenseStore((s) => s.cronExpression);
const multipleTransactions = useAddExpenseStore((s) => s.multipleTransactions);

const { t, displayName, generateSplitDescription, getCurrencyHelpersCached } =
useTranslationWithUtils();
const { t, displayName, getCurrencyHelpersCached } = useTranslationWithUtils();
const splitValidationMessage = t(
'expense_details.add_expense_details.split_type_section.validation.invalid_split',
);
const splitDescription = React.useMemo(() => {
const splitEquallyText = t(
'expense_details.add_expense_details.split_type_section.split_equally',
);

if (SplitType.EQUAL !== splitType) {
return t('expense_details.add_expense_details.split_type_section.split_unequally');
}

if (!paidBy || !currentUser) {
return splitEquallyText;
}

const selectedParticipants = participants.filter((participant) => {
const share = splitShares[participant.id]?.[SplitType.EQUAL];
return share === undefined || 0n !== share;
});

if (0 === selectedParticipants.length) {
return splitValidationMessage;
}

const splitParticipant = selectedParticipants[0];
if (1 === selectedParticipants.length && splitParticipant) {
if (splitParticipant.id === paidBy.id) {
return t('expense_details.add_expense_details.split_type_section.direction.no_money_flow');
}

const debtor = isNegative ? paidBy : splitParticipant;
const payer = isNegative ? splitParticipant : paidBy;
const debtorName = displayName(debtor, currentUser.id);
const payerName = displayName(payer, currentUser.id);

if (payer.id === currentUser.id) {
return t('expense_details.add_expense_details.split_type_section.direction.owes_you', {
debtor: debtorName,
});
}

if (debtor.id === currentUser.id) {
return t('expense_details.add_expense_details.split_type_section.direction.you_owe', {
payer: payerName,
});
}

return t('expense_details.add_expense_details.split_type_section.direction.owes_payer', {
debtor: debtorName,
payer: payerName,
});
}

return `${splitEquallyText} (${selectedParticipants.length})`;
}, [
currentUser,
displayName,
isNegative,
paidBy,
participants,
splitShares,
splitType,
splitValidationMessage,
t,
]);

const {
setCurrency,
Expand Down Expand Up @@ -111,6 +177,7 @@ export const AddOrEditExpensePage: React.FC<{
}

if (!isExpenseSettled) {
toast.error(splitValidationMessage);
setSplitScreenOpen(true);
return;
}
Expand Down Expand Up @@ -220,6 +287,7 @@ export const AddOrEditExpensePage: React.FC<{
multipleTransactions,
setSingleTransaction,
update,
splitValidationMessage,
]);

const handleDescriptionChange = useCallback(
Expand Down Expand Up @@ -345,16 +413,13 @@ export const AddOrEditExpensePage: React.FC<{
<p>{t('ui.and')} </p>
<SplitExpenseForm>
<Button variant="ghost" className="text-primary h-8 px-1.5 py-0 text-base">
{generateSplitDescription(
splitType,
participants,
splitShares,
paidBy,
currentUser,
)}
{splitDescription}
</Button>
</SplitExpenseForm>
</div>
{!isExpenseSettled ? (
<p className="mt-2 text-center text-xs text-red-500">{splitValidationMessage}</p>
) : null}

<div className="mt-4 flex items-start justify-between sm:mt-10">
<DateSelector
Expand Down
5 changes: 5 additions & 0 deletions src/components/AddExpense/SplitTypeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ const SplitSection: React.FC<SplitSectionProps> = (props) => {
>
{fmtSummartyText(amount, totalShares, toUIString)}
</p>
{!canSplitScreenClosed ? (
<p className="text-center text-xs text-red-500">
{t('expense_details.add_expense_details.split_type_section.validation.invalid_split')}
</p>
) : null}
{isBoolean && (
<Button
variant="outline"
Expand Down
26 changes: 21 additions & 5 deletions src/store/addStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,12 @@ export function calculateParticipantSplit(
const totalParticipants = participants.filter((p) => 0n !== getSplitShare(p)).length;
updatedParticipants = participants.map((p) => ({
...p,
amount: 0n === getSplitShare(p) ? 0n : amount / BigInt(totalParticipants),
amount:
0 === totalParticipants || 0n === getSplitShare(p)
? 0n
: amount / BigInt(totalParticipants),
}));
canSplitScreenClosed = Boolean(
Object.values(splitShares).find((p) => 0n !== p[SplitType.EQUAL]),
);
canSplitScreenClosed = 0 < totalParticipants;
break;
case SplitType.PERCENTAGE:
updatedParticipants = participants.map((p) => ({
Expand Down Expand Up @@ -350,7 +351,14 @@ export function calculateParticipantSplit(

if (canSplitScreenClosed) {
let penniesLeft = updatedParticipants.reduce((acc, p) => acc + (p.amount ?? 0n), 0n);
const participantsToPick = updatedParticipants.filter((p) => p.amount);
const roundedToZeroParticipants =
SplitType.EQUAL === splitType
? updatedParticipants.filter((p) => 0n === (p.amount ?? 0n) && 0n !== getSplitShare(p))
: [];
const participantsToPick =
0 < roundedToZeroParticipants.length
? roundedToZeroParticipants
: updatedParticipants.filter((p) => p.amount);
const seed =
cyrb128(
`${participantsToPick
Expand All @@ -371,6 +379,14 @@ export function calculateParticipantSplit(
}
}
}

if (
canSplitScreenClosed &&
1 < participants.length &&
updatedParticipants.every((p) => 0n === (p.amount ?? 0n))
) {
canSplitScreenClosed = false;
}
}

return { ...state, participants: updatedParticipants, canSplitScreenClosed };
Expand Down
78 changes: 78 additions & 0 deletions src/tests/addStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,84 @@ describe('calculateParticipantSplit', () => {
expect(result.participants[2]?.amount).toBe(0n); // Excluded from split
});

it('should allow one non-payer to owe the full amount', () => {
const participants = createParticipants([user1, user2]);
const splitShares = createSplitShares(participants, SplitType.EQUAL, [0n, 1n]);

const state: Partial<AddExpenseState> = {
amount: 10000n,
participants,
splitType: SplitType.EQUAL,
splitShares,
paidBy: user1,
expenseDate: new Date('2024-01-01'),
};

const result = calculateParticipantSplit(state as AddExpenseState);

expect(result.participants[0]?.amount).toBe(10000n);
expect(result.participants[1]?.amount).toBe(-10000n);
expect(result.canSplitScreenClosed).toBe(true);
});

it('should mark a multi-person self-only split as incomplete', () => {
const participants = createParticipants([user1, user2]);
const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 0n]);

const state: Partial<AddExpenseState> = {
amount: 10000n,
participants,
splitType: SplitType.EQUAL,
splitShares,
paidBy: user1,
expenseDate: new Date('2024-01-01'),
};

const result = calculateParticipantSplit(state as AddExpenseState);

expect(result.participants[0]?.amount).toBe(0n);
expect(result.participants[1]?.amount).toBe(0n);
expect(result.canSplitScreenClosed).toBe(false);
});

it('should keep a penny-only equal split valid when rounding zeroes every share', () => {
const participants = createParticipants([user1, user2]);
const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n]);

const state: Partial<AddExpenseState> = {
amount: 1n,
participants,
splitType: SplitType.EQUAL,
splitShares,
paidBy: user1,
expenseDate: new Date('2024-01-01'),
};

const result = calculateParticipantSplit(state as AddExpenseState);

expect(result.participants[0]?.amount).toBe(1n);
expect(result.participants[1]?.amount).toBe(-1n);
expect(result.canSplitScreenClosed).toBe(true);
});

it('should mark equal split incomplete when every share is disabled', () => {
const participants = createParticipants([user1, user2]);
const splitShares = createSplitShares(participants, SplitType.EQUAL, [0n, 0n]);

const state: Partial<AddExpenseState> = {
amount: 10000n,
participants,
splitType: SplitType.EQUAL,
splitShares,
paidBy: user1,
expenseDate: new Date('2024-01-01'),
};

const result = calculateParticipantSplit(state as AddExpenseState);

expect(result.canSplitScreenClosed).toBe(false);
});

it('should handle uneven division with penny adjustment', () => {
const participants = createParticipants([user1, user2, user3]);
const splitShares = createSplitShares(participants, SplitType.EQUAL, [1n, 1n, 1n]);
Expand Down
Loading