Files
spliit/src/lib/balances.ts
Lauri Vuorela f7a13a0436 Round totals rather than expense by expense (#88)
* do balance rounding only on full balances rather than on every expense

* use "public balances" calculated from reimbursements to show on balance page

* fixes for totals that did not work as expected

* prettier
2024-02-13 14:35:57 -05:00

117 lines
3.6 KiB
TypeScript

import { getGroupExpenses } from '@/lib/api'
import { Participant } from '@prisma/client'
import { match } from 'ts-pattern'
export type Balances = Record<
Participant['id'],
{ paid: number; paidFor: number; total: number }
>
export type Reimbursement = {
from: Participant['id']
to: Participant['id']
amount: number
}
export function getBalances(
expenses: NonNullable<Awaited<ReturnType<typeof getGroupExpenses>>>,
): Balances {
const balances: Balances = {}
for (const expense of expenses) {
const paidBy = expense.paidById
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.participantId])
balances[paidFor.participantId] = { 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.participantId].paidFor += dividedAmount
})
}
// rounding and add total
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
balances[participantId].total =
balances[participantId].paid - balances[participantId].paidFor
}
return balances
}
export function getPublicBalances(reimbursements: Reimbursement[]): Balances {
const balances: Balances = {}
reimbursements.forEach((reimbursement) => {
if (!balances[reimbursement.from])
balances[reimbursement.from] = { paid: 0, paidFor: 0, total: 0 }
if (!balances[reimbursement.to])
balances[reimbursement.to] = { paid: 0, paidFor: 0, total: 0 }
balances[reimbursement.from].paidFor += reimbursement.amount
balances[reimbursement.from].total -= reimbursement.amount
balances[reimbursement.to].paid += reimbursement.amount
balances[reimbursement.to].total += reimbursement.amount
})
return balances
}
export function getSuggestedReimbursements(
balances: Balances,
): Reimbursement[] {
const balancesArray = Object.entries(balances)
.map(([participantId, { total }]) => ({ participantId, total }))
.filter((b) => b.total !== 0)
balancesArray.sort((b1, b2) => b2.total - b1.total)
const reimbursements: Reimbursement[] = []
while (balancesArray.length > 1) {
const first = balancesArray[0]
const last = balancesArray[balancesArray.length - 1]
const amount = first.total + last.total
if (first.total > -last.total) {
reimbursements.push({
from: last.participantId,
to: first.participantId,
amount: -last.total,
})
first.total = amount
balancesArray.pop()
} else {
reimbursements.push({
from: last.participantId,
to: first.participantId,
amount: first.total,
})
last.total = amount
balancesArray.shift()
}
}
return reimbursements.filter(({ amount }) => Math.round(amount) + 0 !== 0)
}