diff --git a/src/app/groups/[groupId]/expenses/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx index ebf2c883..825dfa52 100644 --- a/src/app/groups/[groupId]/expenses/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -915,6 +915,9 @@ 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,6 +943,13 @@ 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, )} 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) diff --git a/src/lib/balances.ts b/src/lib/balances.ts index 70fea3f1..716316e0 100644 --- a/src/lib/balances.ts +++ b/src/lib/balances.ts @@ -1,6 +1,6 @@ import { getGroupExpenses } from '@/lib/api' +import { calculateShares } from '@/lib/totals' import { Participant } from '@prisma/client' -import { match } from 'ts-pattern' 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 diff --git a/src/lib/totals.test.ts b/src/lib/totals.test.ts new file mode 100644 index 00000000..e74c8ab5 --- /dev/null +++ b/src/lib/totals.test.ts @@ -0,0 +1,384 @@ +import { SplitMode } from '@prisma/client' +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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: SplitMode.EVENLY, + 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: SplitMode.EVENLY, + 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: 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: 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: 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: SplitMode.EVENLY, + 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: SplitMode.EVENLY, + 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: SplitMode.BY_SHARES, + 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: SplitMode.BY_PERCENTAGE, + 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) + }) +}) diff --git a/src/lib/totals.ts b/src/lib/totals.ts index 3eca7ef7..3459fd31 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,150 @@ 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)) }