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
10 changes: 10 additions & 0 deletions src/app/groups/[groupId]/expenses/expense-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@

useEffect(() => {
setManuallyEditedParticipants(new Set())
}, [form.watch('splitMode'), form.watch('amount')])

Check warning on line 321 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked

Check warning on line 321 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked

useEffect(() => {
const splitMode = form.getValues().splitMode
Expand Down Expand Up @@ -368,10 +368,10 @@
}
form.setValue('paidFor', newPaidFor, { shouldValidate: true })
}
}, [

Check warning on line 371 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has missing dependencies: 'form' and 'groupCurrency.decimal_digits'. Either include them or remove the dependency array
manuallyEditedParticipants,
form.watch('amount'),

Check warning on line 373 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked
form.watch('splitMode'),

Check warning on line 374 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked
])

const [usingCustomConversionRate, setUsingCustomConversionRate] = useState(
Expand All @@ -382,7 +382,7 @@
if (!usingCustomConversionRate && exchangeRate.data) {
form.setValue('conversionRate', exchangeRate.data)
}
}, [exchangeRate.data, usingCustomConversionRate])

Check warning on line 385 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has a missing dependency: 'form'. Either include it or remove the dependency array

useEffect(() => {
if (!form.getFieldState('originalAmount').isTouched) return
Expand All @@ -402,10 +402,10 @@
form.setValue('amount', Number(v))
}
}
}, [

Check warning on line 405 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has missing dependencies: 'form' and 'groupCurrency.decimal_digits'. Either include them or remove the dependency array
form.watch('originalAmount'),

Check warning on line 406 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked
form.watch('conversionRate'),

Check warning on line 407 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked
form.getFieldState('originalAmount').isTouched,

Check warning on line 408 in src/app/groups/[groupId]/expenses/expense-form.tsx

View workflow job for this annotation

GitHub Actions / checks

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked
])

let conversionRateMessage = ''
Expand Down Expand Up @@ -915,6 +915,9 @@
Number(form.watch('amount')),
groupCurrency,
), // Convert to cents
expenseDate:
form.watch('expenseDate') ??
new Date(),
paidFor: field.value.map(
({ participant, shares }) => ({
participant: {
Expand All @@ -940,6 +943,13 @@
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,
)}
Expand Down
102 changes: 56 additions & 46 deletions src/app/groups/[groupId]/expenses/export/csv/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 11 additions & 31 deletions src/lib/balances.ts
Original file line number Diff line number Diff line change
@@ -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'],
Expand All @@ -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
Expand Down
Loading