From 33155aecc21737aa233f093f4500d981e1e46127 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:14:11 +0100 Subject: [PATCH 1/7] feat(totals): rework share calculation using Decimal with fractional-part remainder distribution and deterministic tie-break --- src/lib/totals.ts | 173 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 136 insertions(+), 37 deletions(-) diff --git a/src/lib/totals.ts b/src/lib/totals.ts index 3eca7ef7..6860be4e 100644 --- a/src/lib/totals.ts +++ b/src/lib/totals.ts @@ -1,3 +1,5 @@ +import Decimal from 'decimal.js' + import { getGroupExpenses } from '@/lib/api' export function getTotalGroupSpending( @@ -25,54 +27,151 @@ export function getTotalActiveUserPaidFor( type Expense = NonNullable>>[number] -export function calculateShare( - participantId: string | null, - expense: Pick< - Expense, - 'amount' | 'paidFor' | 'splitMode' | 'isReimbursement' - >, -): number { - if (expense.isReimbursement) return 0 +type ExpenseForShares = Pick< + Expense, + 'amount' | 'paidFor' | 'splitMode' | 'isReimbursement' | 'paidBy' +> & { + expenseDate?: Expense['expenseDate'] +} + +export function calculateShares( + expense: ExpenseForShares, +): Record { + // Algorithm outline: + // 1. Compute every participant's exact share in minor units (`part`). + // 2. Truncate toward zero so that the sum never exceeds the available amount. + // 3. Track how many cents remain (`diff`) after truncation. + // 4. Distribute the remaining cents one by one to participants based on tie-break rules. + const result: Record = {} + const amount = new Decimal(expense.amount) - const paidFors = expense.paidFor - const userPaidFor = paidFors.find( - (paidFor) => paidFor.participant.id === participantId, + const totalShares = expense.paidFor.reduce( + (sum, pf) => sum.add(new Decimal(pf.shares ?? 0)), + new Decimal(0), ) - if (!userPaidFor) return 0 - - const shares = Number(userPaidFor.shares) - - switch (expense.splitMode) { - case 'EVENLY': - // Divide the total expense evenly among all participants - return expense.amount / paidFors.length - case 'BY_AMOUNT': - // Directly add the user's share if the split mode is BY_AMOUNT - return shares - case 'BY_PERCENTAGE': - // Calculate the user's share based on their percentage of the total expense - return (expense.amount * shares) / 10000 // Assuming shares are out of 10000 for percentage - case 'BY_SHARES': - // Calculate the user's share based on their shares relative to the total shares - const totalShares = paidFors.reduce( - (sum, paidFor) => sum + Number(paidFor.shares), - 0, - ) - return (expense.amount * shares) / totalShares - default: - return 0 + let sumRounded = new Decimal(0) + const participantOrder: string[] = [] + + expense.paidFor.forEach((pf) => { + const shares = new Decimal(pf.shares ?? 0) + let part = new Decimal(0) + switch (expense.splitMode) { + case 'EVENLY': + if (expense.paidFor.length > 0) { + part = amount.div(expense.paidFor.length) + } + break + case 'BY_AMOUNT': + part = shares + break + case 'BY_PERCENTAGE': + part = amount.mul(shares).div(10000) + break + case 'BY_SHARES': + if (totalShares.gt(0)) { + part = amount.mul(shares).div(totalShares) + } + break + default: + part = new Decimal(0) + } + const rounded = part.gte(0) ? part.floor() : part.ceil() + result[pf.participant.id] = rounded.toNumber() + sumRounded = sumRounded.add(rounded) + participantOrder.push(pf.participant.id) + }) + + let diff = amount.minus(sumRounded) + if (diff.isZero()) { + return result + } + + if (expense.splitMode === 'BY_AMOUNT') { + const payerId = + expense.paidBy?.id ?? expense.paidFor[0]?.participant.id + if (payerId) { + result[payerId] = (result[payerId] ?? 0) + diff.toNumber() + } + return result } + + if (participantOrder.length === 0) { + const payerId = + expense.paidBy?.id ?? expense.paidFor[0]?.participant.id + if (payerId) { + result[payerId] = (result[payerId] ?? 0) + diff.toNumber() + } + return result + } + + // Distribute leftover cents by descending fractional parts + // Compute fractional remainders for each participant based on the exact part + const fractions: Array<{ id: string; frac: Decimal }> = [] + expense.paidFor.forEach((pf) => { + const shares = new Decimal(pf.shares ?? 0) + let part = new Decimal(0) + switch (expense.splitMode) { + case 'EVENLY': + if (expense.paidFor.length > 0) part = amount.div(expense.paidFor.length) + break + case 'BY_AMOUNT': + part = shares + break + case 'BY_PERCENTAGE': + part = amount.mul(shares).div(10000) + break + case 'BY_SHARES': + if (totalShares.gt(0)) part = amount.mul(shares).div(totalShares) + break + default: + part = new Decimal(0) + } + const rounded = part.gte(0) ? part.floor() : part.ceil() + const frac = part.minus(rounded).abs() // magnitude of the truncated fractional part + fractions.push({ id: pf.participant.id, frac }) + }) + + if (!diff.isZero() && participantOrder.length > 0) { + const direction = diff.gt(0) ? 1 : -1 + let remaining = diff.abs().toNumber() + + // Sort by fractional magnitude desc; tie-breaker by stable participant order + const orderIndex = new Map( + participantOrder.map((id, idx) => [id, idx]), + ) + fractions.sort((a, b) => { + const cmp = b.frac.comparedTo(a.frac) + if (cmp !== 0) return cmp + // tie-break by original order: prefer later participants first ("last" gets remainder) + return (orderIndex.get(b.id) ?? 0) - (orderIndex.get(a.id) ?? 0) + }) + + for (let i = 0; i < remaining; i++) { + const target = fractions[i % fractions.length]?.id + if (!target) break + result[target] = (result[target] ?? 0) + direction + } + diff = new Decimal(0) + } + + return result +} + +export function calculateShare( + participantId: string | null, + expense: ExpenseForShares, +): number { + if (!participantId) return 0 + return calculateShares(expense)[participantId] ?? 0 } export function getTotalActiveUserShare( activeUserId: string | null, expenses: NonNullable>>, ): number { - const total = expenses.reduce( + return expenses.reduce( (sum, expense) => sum + calculateShare(activeUserId, expense), 0, ) - - return parseFloat(total.toFixed(2)) } From b0705528282162d13335fc9e55deb9bd5a3449f2 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:14:11 +0100 Subject: [PATCH 2/7] test(totals): add comprehensive suite for remainder distribution and edge cases --- src/lib/totals.test.ts | 385 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 src/lib/totals.test.ts diff --git a/src/lib/totals.test.ts b/src/lib/totals.test.ts new file mode 100644 index 00000000..01f4c3e8 --- /dev/null +++ b/src/lib/totals.test.ts @@ -0,0 +1,385 @@ +import { calculateShare, calculateShares } from './totals'; + +const p1 = { id: 'p1', name: 'Participant 1' }; +const p2 = { id: 'p2', name: 'Participant 2' }; +const p3 = { id: 'p3', name: 'Participant 3' }; + +const expenseBase = { + id: 'expense-1', + amount: 10000, + isReimbursement: false, + paidBy: p1, + expenseDate: new Date('2024-01-01T00:00:00.000Z'), +}; + +describe('calculateShares', () => { + it('should split evenly among all participants', () => { + const expense = { + ...expenseBase, + splitMode: 'EVENLY', + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(Object.values(shares).sort()).toEqual([3333, 3333, 3334]); + expect( + Object.entries(shares).reduce((sum, [, value]) => sum + value, 0), + ).toBe(10000); + }); + + it('should split by amount', () => { + const expense = { + ...expenseBase, + splitMode: 'BY_AMOUNT', + paidFor: [ + { participant: p1, shares: 5000 }, + { participant: p2, shares: 2500 }, + { participant: p3, shares: 2500 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1']).toBe(5000); + expect(shares['p2']).toBe(2500); + expect(shares['p3']).toBe(2500); + }); + + it('should split by shares', () => { + const expense = { + ...expenseBase, + splitMode: 'BY_SHARES', + paidFor: [ + { participant: p1, shares: 2 }, + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1']).toBe(5000); + expect(shares['p2']).toBe(2500); + expect(shares['p3']).toBe(2500); + }); + + it('should split by percentage', () => { + const expense = { + ...expenseBase, + splitMode: 'BY_PERCENTAGE', + paidFor: [ + { participant: p1, shares: 5000 }, // 50% + { participant: p2, shares: 2500 }, // 25% + { participant: p3, shares: 2500 }, // 25% + ], + }; + const shares = calculateShares(expense); + expect(shares['p1']).toBe(5000); + expect(shares['p2']).toBe(2500); + expect(shares['p3']).toBe(2500); + }); + + it('should handle rounding differences by assigning the remainder to trailing participants', () => { + const expense = { + ...expenseBase, + amount: 100, + splitMode: 'EVENLY', + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p3']).toBe(34); + expect(shares['p1']).toBe(33); + expect(shares['p2']).toBe(33); + expect( + Object.entries(shares).reduce((sum, [, value]) => sum + value, 0), + ).toBe(100); + }); + + it('should apply the same split logic for reimbursements', () => { + const expense = { + ...expenseBase, + isReimbursement: true, + splitMode: 'EVENLY', + paidFor: [ + { participant: p2, shares: 1 }, + ], + paidBy: p1, + }; + const shares = calculateShares(expense); + expect(shares['p2']).toBe(10000); + expect(shares['p1'] ?? 0).toBe(0); + }); + + it('should include reimbursements when requesting a single participant share', () => { + const expense = { + ...expenseBase, + isReimbursement: true, + splitMode: 'EVENLY', + paidFor: [{ participant: p2, shares: 1 }], + paidBy: p1, + }; + expect(calculateShare('p2', expense)).toBe(10000); + expect(calculateShare('p1', expense)).toBe(0); + }); + + it('should handle the payer not being in the paidFor list', () => { + const expense = { + ...expenseBase, + paidBy: p1, + splitMode: 'EVENLY', + paidFor: [ + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1'] ?? 0).toBe(0); + expect(shares['p2']).toBe(5000); + expect(shares['p3']).toBe(5000); + }); + + it('should distribute rounding differences deterministically even if payer changes', () => { + const expense = { + ...expenseBase, + amount: 100, + paidBy: p2, + splitMode: 'EVENLY', + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(Object.values(shares).sort()).toEqual([33, 33, 34]); + expect( + Object.entries(shares).reduce((sum, [, value]) => sum + value, 0), + ).toBe(100); + }); + + it('should handle percentages not summing to 100%', () => { + const expense = { + ...expenseBase, + splitMode: 'BY_PERCENTAGE', + paidFor: [ + { participant: p1, shares: 4000 }, // 40% + { participant: p2, shares: 4000 }, // 40% + ], + }; + const shares = calculateShares(expense); + // 80% of 10000 is 8000. Remainder is 2000. Payer (p1) gets it. + expect(shares['p1'] + shares['p2']).toBe(10000); + expect(shares['p1']).toBeGreaterThanOrEqual(4000); + expect(shares['p2']).toBeGreaterThanOrEqual(4000); + }); + + it('should handle an empty paidFor list', () => { + const expense = { + ...expenseBase, + splitMode: 'EVENLY', + paidFor: [], + }; + const shares = calculateShares(expense); + expect(shares['p1']).toBe(10000); + }); + + it('should handle 0 total shares in BY_SHARES mode', () => { + const expense = { + ...expenseBase, + splitMode: 'BY_SHARES', + paidFor: [ + { participant: p1, shares: 0 }, + { participant: p2, shares: 0 }, + ], + }; + const shares = calculateShares(expense); + expect( + Object.entries(shares).reduce((sum, [, value]) => sum + value, 0), + ).toBe(10000); + expect(Object.values(shares).every((value) => value >= 0)).toBe(true); + }); + + it('should handle a zero amount expense', () => { + const expense = { + ...expenseBase, + amount: 0, + splitMode: 'EVENLY', + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1'] + 0).toBe(0); // avoid -0 vs 0 + expect(shares['p2']).toBe(0); + }); + + it('should give any leftover cents to the last participants first', () => { + const expense = { + ...expenseBase, + amount: 101, + splitMode: 'EVENLY' as const, + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1']).toBe(33); + expect(shares['p2']).toBe(34); + expect(shares['p3']).toBe(34); + expect(Object.values(shares).reduce((sum, value) => sum + value, 0)).toBe( + 101, + ); + }); + + it('should subtract leftover cents from the end for negative expenses', () => { + const expense = { + ...expenseBase, + amount: -101, + splitMode: 'EVENLY' as const, + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1']).toBe(-33); + expect(shares['p2']).toBe(-34); + expect(shares['p3']).toBe(-34); + expect(Object.values(shares).reduce((sum, value) => sum + value, 0)).toBe( + -101, + ); + }); + + it('should distribute remainder in BY_AMOUNT mode if amounts do not sum up', () => { + const expense = { + ...expenseBase, + amount: 10000, + splitMode: 'BY_AMOUNT', + paidFor: [ + { participant: p1, shares: 4000 }, + { participant: p2, shares: 4000 }, + ], + }; + const shares = calculateShares(expense); + const totalShares = Object.values(shares).reduce((sum, v) => sum + v, 0); + expect(totalShares).toBe(10000); + // p1 is the payer and should get the remainder + expect(shares['p1']).toBe(6000); + expect(shares['p2']).toBe(4000); + }); + + it('should handle negative expense amounts (refunds)', () => { + const expense = { + ...expenseBase, + amount: -10000, + splitMode: 'EVENLY', + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1']).toBe(-5000); + expect(shares['p2']).toBe(-5000); + const totalShares = Object.values(shares).reduce((sum, v) => sum + v, 0); + expect(totalShares).toBe(-10000); + }); + + it('should handle an undefined payer by falling back to the first participant', () => { + const expense = { + ...expenseBase, + amount: 100, + paidBy: undefined as any, + splitMode: 'EVENLY', + paidFor: [ + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + const totalShares = Object.values(shares).reduce((sum, v) => sum + v, 0); + expect(totalShares).toBe(100); + expect(shares['p2']).toBe(50); + expect(shares['p3']).toBe(50); + }); + + it('should break ties by giving remainders to later participants (EVENLY, amount=2, 3 participants)', () => { + const expense = { + ...expenseBase, + amount: 2, + splitMode: 'EVENLY' as const, + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + // fractions are equal; remainder (2) should go to the last two participants + expect(shares['p1'] + 0).toBe(0); + expect(shares['p2']).toBe(1); + expect(shares['p3']).toBe(1); + expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(2); + }); + + it('should break ties for negative amounts by subtracting from later participants first (EVENLY, amount=-2, 3 participants)', () => { + const expense = { + ...expenseBase, + amount: -2, + splitMode: 'EVENLY' as const, + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1'] + 0).toBe(0); + expect(shares['p2']).toBe(-1); + expect(shares['p3']).toBe(-1); + expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(-2); + }); + + it('should break ties in BY_SHARES by giving remainders to later participants (amount=2, shares equal)', () => { + const expense = { + ...expenseBase, + amount: 2, + splitMode: 'BY_SHARES' as const, + paidFor: [ + { participant: p1, shares: 1 }, + { participant: p2, shares: 1 }, + { participant: p3, shares: 1 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1']).toBe(0); + expect(shares['p2']).toBe(1); + expect(shares['p3']).toBe(1); + expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(2); + }); + + it('should break ties in BY_PERCENTAGE by giving remainders to later participants (amount=2, equal percentages)', () => { + const expense = { + ...expenseBase, + amount: 2, + splitMode: 'BY_PERCENTAGE' as const, + paidFor: [ + { participant: p1, shares: 3333 }, + { participant: p2, shares: 3333 }, + { participant: p3, shares: 3333 }, + ], + }; + const shares = calculateShares(expense); + expect(shares['p1']).toBe(0); + expect(shares['p2']).toBe(1); + expect(shares['p3']).toBe(1); + expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(2); + }); +}); From 45ac4e257b1c37474a0def117f6d9c375f4162a1 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:14:11 +0100 Subject: [PATCH 3/7] feat(csv): use centralized calculateShares for deterministic export values --- .../[groupId]/expenses/export/csv/route.ts | 102 ++++++++++-------- 1 file changed, 56 insertions(+), 46 deletions(-) diff --git a/src/app/groups/[groupId]/expenses/export/csv/route.ts b/src/app/groups/[groupId]/expenses/export/csv/route.ts index dfb92c7b..69ae9063 100644 --- a/src/app/groups/[groupId]/expenses/export/csv/route.ts +++ b/src/app/groups/[groupId]/expenses/export/csv/route.ts @@ -1,4 +1,5 @@ import { getCurrency } from '@/lib/currency' +import { calculateShares } from '@/lib/totals' import { formatAmountAsDecimal, getCurrencyFromGroup } from '@/lib/utils' import { Parser } from '@json2csv/plainjs' import { PrismaClient } from '@prisma/client' @@ -43,8 +44,13 @@ export async function GET( originalAmount: true, originalCurrency: true, conversionRate: true, - paidById: true, - paidFor: { select: { participantId: true, shares: true } }, + paidBy: { select: { id: true, name: true } }, + paidFor: { + select: { + participant: { select: { id: true, name: true } }, + shares: true, + }, + }, isReimbursement: true, splitMode: true, }, @@ -102,50 +108,54 @@ export async function GET( const currency = getCurrencyFromGroup(group) - const expenses = group.expenses.map((expense) => ({ - date: formatDate(expense.expenseDate), - title: expense.title, - categoryName: expense.category?.name || '', - currency: group.currencyCode ?? group.currency, - amount: formatAmountAsDecimal(expense.amount, currency), - originalAmount: expense.originalAmount - ? formatAmountAsDecimal( - expense.originalAmount, - getCurrency(expense.originalCurrency), - ) - : null, - originalCurrency: expense.originalCurrency, - conversionRate: expense.conversionRate - ? expense.conversionRate.toString() - : null, - isReimbursement: expense.isReimbursement ? 'Yes' : 'No', - splitMode: splitModeLabel[expense.splitMode], - ...Object.fromEntries( - group.participants.map((participant) => { - const { totalShares, participantShare } = expense.paidFor.reduce( - (acc, { participantId, shares }) => { - acc.totalShares += shares - if (participantId === participant.id) { - acc.participantShare = shares - } - return acc - }, - { totalShares: 0, participantShare: 0 }, - ) - - const isPaidByParticipant = expense.paidById === participant.id - const participantAmountShare = +formatAmountAsDecimal( - (expense.amount / totalShares) * participantShare, - currency, - ) - - return [ - participant.name, - participantAmountShare * (isPaidByParticipant ? 1 : -1), - ] - }), - ), - })) + const expenses = group.expenses.map((expense) => { + const shares = calculateShares({ + amount: expense.amount, + paidFor: expense.paidFor, + splitMode: expense.splitMode, + isReimbursement: expense.isReimbursement, + paidBy: expense.paidBy, + expenseDate: expense.expenseDate, + }) + + const payerId = + expense.paidBy?.id ?? expense.paidFor[0]?.participant.id ?? null + + return { + date: formatDate(expense.expenseDate), + title: expense.title, + categoryName: expense.category?.name || '', + currency: group.currencyCode ?? group.currency, + amount: formatAmountAsDecimal(expense.amount, currency), + originalAmount: expense.originalAmount + ? formatAmountAsDecimal( + expense.originalAmount, + getCurrency(expense.originalCurrency), + ) + : null, + originalCurrency: expense.originalCurrency, + conversionRate: expense.conversionRate + ? expense.conversionRate.toString() + : null, + isReimbursement: expense.isReimbursement ? 'Yes' : 'No', + splitMode: splitModeLabel[expense.splitMode], + ...Object.fromEntries( + group.participants.map((participant) => { + const participantShare = shares[participant.id] ?? 0 + const isPaidByParticipant = payerId === participant.id + const netAmount = isPaidByParticipant + ? expense.amount - participantShare + : -participantShare + + return [ + participant.name, + // keep as formatted string to preserve trailing zeros + formatAmountAsDecimal(netAmount, currency), + ] + }), + ), + } + }) const json2csvParser = new Parser({ fields }) const csv = json2csvParser.parse(expenses) From 097d2ac4062fcdb90d9a27d1614947a3174bcfdc Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:14:11 +0100 Subject: [PATCH 4/7] refactor(balances): align with centralized calculateShares to remove inconsistencies --- src/lib/balances.ts | 42 +++++++++++------------------------------- 1 file changed, 11 insertions(+), 31 deletions(-) diff --git a/src/lib/balances.ts b/src/lib/balances.ts index 70fea3f1..978056c2 100644 --- a/src/lib/balances.ts +++ b/src/lib/balances.ts @@ -1,6 +1,6 @@ import { getGroupExpenses } from '@/lib/api' import { Participant } from '@prisma/client' -import { match } from 'ts-pattern' +import { calculateShares } from '@/lib/totals' export type Balances = Record< Participant['id'], @@ -20,43 +20,23 @@ export function getBalances( for (const expense of expenses) { const paidBy = expense.paidBy.id - const paidFors = expense.paidFor if (!balances[paidBy]) balances[paidBy] = { paid: 0, paidFor: 0, total: 0 } balances[paidBy].paid += expense.amount - const totalPaidForShares = paidFors.reduce( - (sum, paidFor) => sum + paidFor.shares, - 0, - ) - let remaining = expense.amount - paidFors.forEach((paidFor, index) => { - if (!balances[paidFor.participant.id]) - balances[paidFor.participant.id] = { paid: 0, paidFor: 0, total: 0 } - - const isLast = index === paidFors.length - 1 - - const [shares, totalShares] = match(expense.splitMode) - .with('EVENLY', () => [1, paidFors.length]) - .with('BY_SHARES', () => [paidFor.shares, totalPaidForShares]) - .with('BY_PERCENTAGE', () => [paidFor.shares, totalPaidForShares]) - .with('BY_AMOUNT', () => [paidFor.shares, totalPaidForShares]) - .exhaustive() - - const dividedAmount = isLast - ? remaining - : (expense.amount * shares) / totalShares - remaining -= dividedAmount - balances[paidFor.participant.id].paidFor += dividedAmount - }) + const shares = calculateShares(expense) + for (const participantId in shares) { + if (!balances[participantId]) + balances[participantId] = { paid: 0, paidFor: 0, total: 0 } + balances[participantId].paidFor += shares[participantId] + } } - // rounding and add total + // add totals (shares are already integers) for (const participantId in balances) { - // add +0 to avoid negative zeros - balances[participantId].paidFor = - Math.round(balances[participantId].paidFor) + 0 - balances[participantId].paid = Math.round(balances[participantId].paid) + 0 + // add +0 to avoid negative zeros (keep values as integers) + balances[participantId].paidFor = balances[participantId].paidFor + 0 + balances[participantId].paid = balances[participantId].paid + 0 balances[participantId].total = balances[participantId].paid - balances[participantId].paidFor From 00980e6e4fd49f2757a5aecc82415c8676f0d375 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:14:11 +0100 Subject: [PATCH 5/7] feat(expense-form): update share preview to use new calculation logic --- src/app/groups/[groupId]/expenses/expense-form.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index ebf2c883..d02fb995 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -915,6 +915,8 @@ export function ExpenseForm({ Number(form.watch('amount')), groupCurrency, ), // Convert to cents + expenseDate: + form.watch('expenseDate') ?? new Date(), paidFor: field.value.map( ({ participant, shares }) => ({ participant: { @@ -940,8 +942,15 @@ export function ExpenseForm({ splitMode: form.watch('splitMode'), isReimbursement: form.watch('isReimbursement'), + paidBy: { + id: + form.watch('paidBy') ?? + field.value[0]?.participant, + // name is required for the helper but irrelevant here + name: '', + }, }), - locale, + locale )} ) From 89627a6176b5b79cf300763c85c10fe267ea42f7 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:18:22 +0100 Subject: [PATCH 6/7] Fix totals tests: use Prisma SplitMode enum --- src/lib/totals.test.ts | 45 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/lib/totals.test.ts b/src/lib/totals.test.ts index 01f4c3e8..b44191db 100644 --- a/src/lib/totals.test.ts +++ b/src/lib/totals.test.ts @@ -1,3 +1,4 @@ +import { SplitMode } from '@prisma/client' import { calculateShare, calculateShares } from './totals'; const p1 = { id: 'p1', name: 'Participant 1' }; @@ -16,7 +17,7 @@ describe('calculateShares', () => { it('should split evenly among all participants', () => { const expense = { ...expenseBase, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -33,7 +34,7 @@ describe('calculateShares', () => { it('should split by amount', () => { const expense = { ...expenseBase, - splitMode: 'BY_AMOUNT', + splitMode: SplitMode.BY_AMOUNT, paidFor: [ { participant: p1, shares: 5000 }, { participant: p2, shares: 2500 }, @@ -49,7 +50,7 @@ describe('calculateShares', () => { it('should split by shares', () => { const expense = { ...expenseBase, - splitMode: 'BY_SHARES', + splitMode: SplitMode.BY_SHARES, paidFor: [ { participant: p1, shares: 2 }, { participant: p2, shares: 1 }, @@ -65,7 +66,7 @@ describe('calculateShares', () => { it('should split by percentage', () => { const expense = { ...expenseBase, - splitMode: 'BY_PERCENTAGE', + splitMode: SplitMode.BY_PERCENTAGE, paidFor: [ { participant: p1, shares: 5000 }, // 50% { participant: p2, shares: 2500 }, // 25% @@ -82,7 +83,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: 100, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -102,7 +103,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, isReimbursement: true, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p2, shares: 1 }, ], @@ -117,7 +118,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, isReimbursement: true, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [{ participant: p2, shares: 1 }], paidBy: p1, }; @@ -129,7 +130,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, paidBy: p1, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, @@ -146,7 +147,7 @@ describe('calculateShares', () => { ...expenseBase, amount: 100, paidBy: p2, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -163,7 +164,7 @@ describe('calculateShares', () => { it('should handle percentages not summing to 100%', () => { const expense = { ...expenseBase, - splitMode: 'BY_PERCENTAGE', + splitMode: SplitMode.BY_PERCENTAGE, paidFor: [ { participant: p1, shares: 4000 }, // 40% { participant: p2, shares: 4000 }, // 40% @@ -179,7 +180,7 @@ describe('calculateShares', () => { it('should handle an empty paidFor list', () => { const expense = { ...expenseBase, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [], }; const shares = calculateShares(expense); @@ -189,7 +190,7 @@ describe('calculateShares', () => { it('should handle 0 total shares in BY_SHARES mode', () => { const expense = { ...expenseBase, - splitMode: 'BY_SHARES', + splitMode: SplitMode.BY_SHARES, paidFor: [ { participant: p1, shares: 0 }, { participant: p2, shares: 0 }, @@ -206,7 +207,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: 0, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -221,7 +222,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: 101, - splitMode: 'EVENLY' as const, + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -241,7 +242,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: -101, - splitMode: 'EVENLY' as const, + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -261,7 +262,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: 10000, - splitMode: 'BY_AMOUNT', + splitMode: SplitMode.BY_AMOUNT, paidFor: [ { participant: p1, shares: 4000 }, { participant: p2, shares: 4000 }, @@ -279,7 +280,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: -10000, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -297,7 +298,7 @@ describe('calculateShares', () => { ...expenseBase, amount: 100, paidBy: undefined as any, - splitMode: 'EVENLY', + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, @@ -314,7 +315,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: 2, - splitMode: 'EVENLY' as const, + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -333,7 +334,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: -2, - splitMode: 'EVENLY' as const, + splitMode: SplitMode.EVENLY, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -351,7 +352,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: 2, - splitMode: 'BY_SHARES' as const, + splitMode: SplitMode.BY_SHARES, paidFor: [ { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, @@ -369,7 +370,7 @@ describe('calculateShares', () => { const expense = { ...expenseBase, amount: 2, - splitMode: 'BY_PERCENTAGE' as const, + splitMode: SplitMode.BY_PERCENTAGE, paidFor: [ { participant: p1, shares: 3333 }, { participant: p2, shares: 3333 }, From da493dcfd4c0abc8e2634babfdd8a01d2b572fc6 Mon Sep 17 00:00:00 2001 From: Uli-Z <58443149+Uli-Z@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:22:43 +0100 Subject: [PATCH 7/7] chore: format code with Prettier --- .../[groupId]/expenses/expense-form.tsx | 5 +- src/lib/balances.ts | 2 +- src/lib/totals.test.ts | 282 +++++++++--------- src/lib/totals.ts | 9 +- 4 files changed, 148 insertions(+), 150 deletions(-) diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index d02fb995..825dfa52 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -916,7 +916,8 @@ export function ExpenseForm({ groupCurrency, ), // Convert to cents expenseDate: - form.watch('expenseDate') ?? new Date(), + form.watch('expenseDate') ?? + new Date(), paidFor: field.value.map( ({ participant, shares }) => ({ participant: { @@ -950,7 +951,7 @@ export function ExpenseForm({ name: '', }, }), - locale + locale, )} ) diff --git a/src/lib/balances.ts b/src/lib/balances.ts index 978056c2..716316e0 100644 --- a/src/lib/balances.ts +++ b/src/lib/balances.ts @@ -1,6 +1,6 @@ import { getGroupExpenses } from '@/lib/api' -import { Participant } from '@prisma/client' import { calculateShares } from '@/lib/totals' +import { Participant } from '@prisma/client' export type Balances = Record< Participant['id'], diff --git a/src/lib/totals.test.ts b/src/lib/totals.test.ts index b44191db..e74c8ab5 100644 --- a/src/lib/totals.test.ts +++ b/src/lib/totals.test.ts @@ -1,9 +1,9 @@ import { SplitMode } from '@prisma/client' -import { calculateShare, calculateShares } from './totals'; +import { calculateShare, calculateShares } from './totals' -const p1 = { id: 'p1', name: 'Participant 1' }; -const p2 = { id: 'p2', name: 'Participant 2' }; -const p3 = { id: 'p3', name: 'Participant 3' }; +const p1 = { id: 'p1', name: 'Participant 1' } +const p2 = { id: 'p2', name: 'Participant 2' } +const p3 = { id: 'p3', name: 'Participant 3' } const expenseBase = { id: 'expense-1', @@ -11,7 +11,7 @@ const expenseBase = { isReimbursement: false, paidBy: p1, expenseDate: new Date('2024-01-01T00:00:00.000Z'), -}; +} describe('calculateShares', () => { it('should split evenly among all participants', () => { @@ -23,13 +23,13 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(Object.values(shares).sort()).toEqual([3333, 3333, 3334]); + } + const shares = calculateShares(expense) + expect(Object.values(shares).sort()).toEqual([3333, 3333, 3334]) expect( Object.entries(shares).reduce((sum, [, value]) => sum + value, 0), - ).toBe(10000); - }); + ).toBe(10000) + }) it('should split by amount', () => { const expense = { @@ -40,12 +40,12 @@ describe('calculateShares', () => { { participant: p2, shares: 2500 }, { participant: p3, shares: 2500 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1']).toBe(5000); - expect(shares['p2']).toBe(2500); - expect(shares['p3']).toBe(2500); - }); + } + const shares = calculateShares(expense) + expect(shares['p1']).toBe(5000) + expect(shares['p2']).toBe(2500) + expect(shares['p3']).toBe(2500) + }) it('should split by shares', () => { const expense = { @@ -56,12 +56,12 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1']).toBe(5000); - expect(shares['p2']).toBe(2500); - expect(shares['p3']).toBe(2500); - }); + } + const shares = calculateShares(expense) + expect(shares['p1']).toBe(5000) + expect(shares['p2']).toBe(2500) + expect(shares['p3']).toBe(2500) + }) it('should split by percentage', () => { const expense = { @@ -72,12 +72,12 @@ describe('calculateShares', () => { { participant: p2, shares: 2500 }, // 25% { participant: p3, shares: 2500 }, // 25% ], - }; - const shares = calculateShares(expense); - expect(shares['p1']).toBe(5000); - expect(shares['p2']).toBe(2500); - expect(shares['p3']).toBe(2500); - }); + } + const shares = calculateShares(expense) + expect(shares['p1']).toBe(5000) + expect(shares['p2']).toBe(2500) + expect(shares['p3']).toBe(2500) + }) it('should handle rounding differences by assigning the remainder to trailing participants', () => { const expense = { @@ -89,30 +89,28 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p3']).toBe(34); - expect(shares['p1']).toBe(33); - expect(shares['p2']).toBe(33); + } + const shares = calculateShares(expense) + expect(shares['p3']).toBe(34) + expect(shares['p1']).toBe(33) + expect(shares['p2']).toBe(33) expect( Object.entries(shares).reduce((sum, [, value]) => sum + value, 0), - ).toBe(100); - }); + ).toBe(100) + }) it('should apply the same split logic for reimbursements', () => { const expense = { ...expenseBase, isReimbursement: true, splitMode: SplitMode.EVENLY, - paidFor: [ - { participant: p2, shares: 1 }, - ], + paidFor: [{ participant: p2, shares: 1 }], paidBy: p1, - }; - const shares = calculateShares(expense); - expect(shares['p2']).toBe(10000); - expect(shares['p1'] ?? 0).toBe(0); - }); + } + const shares = calculateShares(expense) + expect(shares['p2']).toBe(10000) + expect(shares['p1'] ?? 0).toBe(0) + }) it('should include reimbursements when requesting a single participant share', () => { const expense = { @@ -121,10 +119,10 @@ describe('calculateShares', () => { splitMode: SplitMode.EVENLY, paidFor: [{ participant: p2, shares: 1 }], paidBy: p1, - }; - expect(calculateShare('p2', expense)).toBe(10000); - expect(calculateShare('p1', expense)).toBe(0); - }); + } + expect(calculateShare('p2', expense)).toBe(10000) + expect(calculateShare('p1', expense)).toBe(0) + }) it('should handle the payer not being in the paidFor list', () => { const expense = { @@ -135,12 +133,12 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1'] ?? 0).toBe(0); - expect(shares['p2']).toBe(5000); - expect(shares['p3']).toBe(5000); - }); + } + const shares = calculateShares(expense) + expect(shares['p1'] ?? 0).toBe(0) + expect(shares['p2']).toBe(5000) + expect(shares['p3']).toBe(5000) + }) it('should distribute rounding differences deterministically even if payer changes', () => { const expense = { @@ -153,13 +151,13 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(Object.values(shares).sort()).toEqual([33, 33, 34]); + } + const shares = calculateShares(expense) + expect(Object.values(shares).sort()).toEqual([33, 33, 34]) expect( Object.entries(shares).reduce((sum, [, value]) => sum + value, 0), - ).toBe(100); - }); + ).toBe(100) + }) it('should handle percentages not summing to 100%', () => { const expense = { @@ -169,23 +167,23 @@ describe('calculateShares', () => { { participant: p1, shares: 4000 }, // 40% { participant: p2, shares: 4000 }, // 40% ], - }; - const shares = calculateShares(expense); + } + const shares = calculateShares(expense) // 80% of 10000 is 8000. Remainder is 2000. Payer (p1) gets it. - expect(shares['p1'] + shares['p2']).toBe(10000); - expect(shares['p1']).toBeGreaterThanOrEqual(4000); - expect(shares['p2']).toBeGreaterThanOrEqual(4000); - }); + expect(shares['p1'] + shares['p2']).toBe(10000) + expect(shares['p1']).toBeGreaterThanOrEqual(4000) + expect(shares['p2']).toBeGreaterThanOrEqual(4000) + }) it('should handle an empty paidFor list', () => { const expense = { ...expenseBase, splitMode: SplitMode.EVENLY, paidFor: [], - }; - const shares = calculateShares(expense); - expect(shares['p1']).toBe(10000); - }); + } + const shares = calculateShares(expense) + expect(shares['p1']).toBe(10000) + }) it('should handle 0 total shares in BY_SHARES mode', () => { const expense = { @@ -195,13 +193,13 @@ describe('calculateShares', () => { { participant: p1, shares: 0 }, { participant: p2, shares: 0 }, ], - }; - const shares = calculateShares(expense); + } + const shares = calculateShares(expense) expect( Object.entries(shares).reduce((sum, [, value]) => sum + value, 0), - ).toBe(10000); - expect(Object.values(shares).every((value) => value >= 0)).toBe(true); - }); + ).toBe(10000) + expect(Object.values(shares).every((value) => value >= 0)).toBe(true) + }) it('should handle a zero amount expense', () => { const expense = { @@ -212,11 +210,11 @@ describe('calculateShares', () => { { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1'] + 0).toBe(0); // avoid -0 vs 0 - expect(shares['p2']).toBe(0); - }); + } + const shares = calculateShares(expense) + expect(shares['p1'] + 0).toBe(0) // avoid -0 vs 0 + expect(shares['p2']).toBe(0) + }) it('should give any leftover cents to the last participants first', () => { const expense = { @@ -228,15 +226,15 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1']).toBe(33); - expect(shares['p2']).toBe(34); - expect(shares['p3']).toBe(34); + } + const shares = calculateShares(expense) + expect(shares['p1']).toBe(33) + expect(shares['p2']).toBe(34) + expect(shares['p3']).toBe(34) expect(Object.values(shares).reduce((sum, value) => sum + value, 0)).toBe( 101, - ); - }); + ) + }) it('should subtract leftover cents from the end for negative expenses', () => { const expense = { @@ -248,15 +246,15 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1']).toBe(-33); - expect(shares['p2']).toBe(-34); - expect(shares['p3']).toBe(-34); + } + const shares = calculateShares(expense) + expect(shares['p1']).toBe(-33) + expect(shares['p2']).toBe(-34) + expect(shares['p3']).toBe(-34) expect(Object.values(shares).reduce((sum, value) => sum + value, 0)).toBe( -101, - ); - }); + ) + }) it('should distribute remainder in BY_AMOUNT mode if amounts do not sum up', () => { const expense = { @@ -267,14 +265,14 @@ describe('calculateShares', () => { { participant: p1, shares: 4000 }, { participant: p2, shares: 4000 }, ], - }; - const shares = calculateShares(expense); - const totalShares = Object.values(shares).reduce((sum, v) => sum + v, 0); - expect(totalShares).toBe(10000); + } + const shares = calculateShares(expense) + const totalShares = Object.values(shares).reduce((sum, v) => sum + v, 0) + expect(totalShares).toBe(10000) // p1 is the payer and should get the remainder - expect(shares['p1']).toBe(6000); - expect(shares['p2']).toBe(4000); - }); + expect(shares['p1']).toBe(6000) + expect(shares['p2']).toBe(4000) + }) it('should handle negative expense amounts (refunds)', () => { const expense = { @@ -285,13 +283,13 @@ describe('calculateShares', () => { { participant: p1, shares: 1 }, { participant: p2, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1']).toBe(-5000); - expect(shares['p2']).toBe(-5000); - const totalShares = Object.values(shares).reduce((sum, v) => sum + v, 0); - expect(totalShares).toBe(-10000); - }); + } + const shares = calculateShares(expense) + expect(shares['p1']).toBe(-5000) + expect(shares['p2']).toBe(-5000) + const totalShares = Object.values(shares).reduce((sum, v) => sum + v, 0) + expect(totalShares).toBe(-10000) + }) it('should handle an undefined payer by falling back to the first participant', () => { const expense = { @@ -303,13 +301,13 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - const totalShares = Object.values(shares).reduce((sum, v) => sum + v, 0); - expect(totalShares).toBe(100); - expect(shares['p2']).toBe(50); - expect(shares['p3']).toBe(50); - }); + } + const shares = calculateShares(expense) + const totalShares = Object.values(shares).reduce((sum, v) => sum + v, 0) + expect(totalShares).toBe(100) + expect(shares['p2']).toBe(50) + expect(shares['p3']).toBe(50) + }) it('should break ties by giving remainders to later participants (EVENLY, amount=2, 3 participants)', () => { const expense = { @@ -321,14 +319,14 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); + } + const shares = calculateShares(expense) // fractions are equal; remainder (2) should go to the last two participants - expect(shares['p1'] + 0).toBe(0); - expect(shares['p2']).toBe(1); - expect(shares['p3']).toBe(1); - expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(2); - }); + expect(shares['p1'] + 0).toBe(0) + expect(shares['p2']).toBe(1) + expect(shares['p3']).toBe(1) + expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(2) + }) it('should break ties for negative amounts by subtracting from later participants first (EVENLY, amount=-2, 3 participants)', () => { const expense = { @@ -340,13 +338,13 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1'] + 0).toBe(0); - expect(shares['p2']).toBe(-1); - expect(shares['p3']).toBe(-1); - expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(-2); - }); + } + const shares = calculateShares(expense) + expect(shares['p1'] + 0).toBe(0) + expect(shares['p2']).toBe(-1) + expect(shares['p3']).toBe(-1) + expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(-2) + }) it('should break ties in BY_SHARES by giving remainders to later participants (amount=2, shares equal)', () => { const expense = { @@ -358,13 +356,13 @@ describe('calculateShares', () => { { participant: p2, shares: 1 }, { participant: p3, shares: 1 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1']).toBe(0); - expect(shares['p2']).toBe(1); - expect(shares['p3']).toBe(1); - expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(2); - }); + } + const shares = calculateShares(expense) + expect(shares['p1']).toBe(0) + expect(shares['p2']).toBe(1) + expect(shares['p3']).toBe(1) + expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(2) + }) it('should break ties in BY_PERCENTAGE by giving remainders to later participants (amount=2, equal percentages)', () => { const expense = { @@ -376,11 +374,11 @@ describe('calculateShares', () => { { participant: p2, shares: 3333 }, { participant: p3, shares: 3333 }, ], - }; - const shares = calculateShares(expense); - expect(shares['p1']).toBe(0); - expect(shares['p2']).toBe(1); - expect(shares['p3']).toBe(1); - expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(2); - }); -}); + } + const shares = calculateShares(expense) + expect(shares['p1']).toBe(0) + expect(shares['p2']).toBe(1) + expect(shares['p3']).toBe(1) + expect(Object.values(shares).reduce((s, v) => s + v, 0)).toBe(2) + }) +}) diff --git a/src/lib/totals.ts b/src/lib/totals.ts index 6860be4e..3459fd31 100644 --- a/src/lib/totals.ts +++ b/src/lib/totals.ts @@ -88,8 +88,7 @@ export function calculateShares( } if (expense.splitMode === 'BY_AMOUNT') { - const payerId = - expense.paidBy?.id ?? expense.paidFor[0]?.participant.id + const payerId = expense.paidBy?.id ?? expense.paidFor[0]?.participant.id if (payerId) { result[payerId] = (result[payerId] ?? 0) + diff.toNumber() } @@ -97,8 +96,7 @@ export function calculateShares( } if (participantOrder.length === 0) { - const payerId = - expense.paidBy?.id ?? expense.paidFor[0]?.participant.id + const payerId = expense.paidBy?.id ?? expense.paidFor[0]?.participant.id if (payerId) { result[payerId] = (result[payerId] ?? 0) + diff.toNumber() } @@ -113,7 +111,8 @@ export function calculateShares( let part = new Decimal(0) switch (expense.splitMode) { case 'EVENLY': - if (expense.paidFor.length > 0) part = amount.div(expense.paidFor.length) + if (expense.paidFor.length > 0) + part = amount.div(expense.paidFor.length) break case 'BY_AMOUNT': part = shares