Skip to content
Open
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
164 changes: 111 additions & 53 deletions src/app/groups/[groupId]/expenses/export/csv/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const splitModeLabel = {
BY_SHARES: 'Unevenly – By shares',
BY_PERCENTAGE: 'Unevenly – By percentage',
BY_AMOUNT: 'Unevenly – By amount',
}
} as const

function formatDate(isoDateString: Date): string {
const date = new Date(isoDateString)
Expand All @@ -36,6 +36,7 @@ export async function GET(
currencyCode: true,
expenses: {
select: {
createdAt: true,
expenseDate: true,
title: true,
category: { select: { name: true } },
Expand All @@ -48,6 +49,7 @@ export async function GET(
isReimbursement: true,
splitMode: true,
},
orderBy: [{ expenseDate: 'asc' }, { createdAt: 'asc' }],
},
participants: { select: { id: true, name: true } },
},
Expand All @@ -68,18 +70,18 @@ export async function GET(
- Original cost: The amount spent in the original currency.
- Original currency: The currency the amount was originally spent in.
- Conversion rate: The rate used to convert the amount.
- Is Reimbursement: Whether the expense is a reimbursement or not.
- Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount).
- UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user).
- Paid By: The paying user
- UserA, UserB: Per-participant saldo for this expense (payer advances vs owed amount). Saldos per row sum to 0.

Example Table:
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Is reinbursement | Split mode | User A | User B |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | No | Evenly | 2500 | -2500 |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
| 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | No | Unevenly - By amount | -80000 | -17264.09 |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+------------------+----------------------+--------+-----------+
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+----------------------+----------+--------+-----------+
| Date | Description | Category | Currency | Cost | Original cost | Original currency | Conversion rate | Split mode | Paid By | User A | User B |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+----------------------+----------+--------+-----------+
| 2025-01-06 | Dinner with team | Food | INR | 5000 | | | | Evenly | User A | 2500 | -2500 |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+----------------------+----------+--------+-----------+
| 2025-02-07 | Plane tickets | Travel | INR | 97264.09 | 1000 | EUR | 97.2641 | Unevenly - By amount | User B | -80000 | -17264.09 |
+------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+----------------------+----------+--------+-----------+

*/

Expand All @@ -92,8 +94,8 @@ export async function GET(
{ label: 'Original cost', value: 'originalAmount' },
{ label: 'Original currency', value: 'originalCurrency' },
{ label: 'Conversion rate', value: 'conversionRate' },
{ label: 'Is Reimbursement', value: 'isReimbursement' },
{ label: 'Split mode', value: 'splitMode' },
{ label: 'Paid By', value: 'paidBy' },
...group.participants.map((participant) => ({
label: participant.name,
value: participant.name,
Expand All @@ -102,50 +104,106 @@ 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),
const participantIdNameMap = Object.fromEntries(
group.participants.map((p) => [p.id, p.name]),
) as Record<string, string>

const expenses = group.expenses.map((expense) => {
const normalizedAmount = Number(expense.amount)
const normalizedOriginalAmount = expense.originalAmount
? Number(expense.originalAmount)
: null
const normalizedConversionRate = expense.conversionRate
? Number(expense.conversionRate)
: null

// Total amount of the expense in major currency units (e.g. cents -> dollars)
// This is used to compute the payer's net saldo for this single expense.
const totalAmount = +formatAmountAsDecimal(normalizedAmount, currency)
// Map of participantId -> shares for quick lookups when building saldos.
const shareByParticipant = Object.fromEntries(
expense.paidFor.map(({ participantId, shares }) => [
participantId,
shares,
]),
) as Record<string, number>
// Normalize shares based on split mode to mirror app logic
const isEvenly = expense.splitMode === 'EVENLY'
const normalizedSharesByParticipant: Record<string, number> = {}
for (const p of group.participants) {
if (isEvenly) {
normalizedSharesByParticipant[p.id] = expense.paidFor.some(
(pf) => pf.participantId === p.id,
)
: 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
? 1
: 0
} else {
normalizedSharesByParticipant[p.id] = shareByParticipant[p.id] ?? 0
}
}
const totalShares = Object.values(normalizedSharesByParticipant).reduce(
(sum, v) => sum + v,
0,
)

return {
date: formatDate(expense.expenseDate),
title: expense.title,
categoryName: expense.category?.name || '',
currency: group.currencyCode ?? group.currency,
// Costs should not count reimbursements as spending in CSV totals
amount: formatAmountAsDecimal(
expense.isReimbursement ? 0 : normalizedAmount,
currency,
),
originalAmount: normalizedOriginalAmount
? formatAmountAsDecimal(
normalizedOriginalAmount,
getCurrency(expense.originalCurrency),
)
: null,
originalCurrency: expense.originalCurrency,
conversionRate: normalizedConversionRate
? normalizedConversionRate.toString()
: null,
paidBy: participantIdNameMap[expense.paidById],
splitMode: splitModeLabel[expense.splitMode],
// For every participant we export the saldo (net effect) of this single expense.
// Compute participant shares in minor units first to avoid rounding drift.
...(() => {
const entries: [string, number][] = []
// Determine ordered list of participants that actually have shares
const participantsWithShares = group.participants
.map((p, idx) => ({ p, idx }))
.filter(({ p }) => (normalizedSharesByParticipant[p.id] ?? 0) > 0)
.map(({ p }) => p.id)

let remaining = normalizedAmount // minor units remaining to allocate
group.participants.forEach((participant) => {
const shares = normalizedSharesByParticipant[participant.id] ?? 0
let shareMinor = 0
if (totalShares > 0 && shares > 0) {
const isLast =
participant.id ===
participantsWithShares[participantsWithShares.length - 1]
if (isLast) {
shareMinor = remaining
} else {
shareMinor = Math.floor((normalizedAmount * shares) / totalShares)
remaining -= shareMinor
}
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 shareMajor = +formatAmountAsDecimal(shareMinor, currency)
const isPaidByParticipant = expense.paidById === participant.id
const saldo = isPaidByParticipant
? totalAmount - shareMajor
: -shareMajor
entries.push([participant.name, saldo])
})
return Object.fromEntries(entries)
})(),
}
})

const json2csvParser = new Parser({ fields })
const csv = json2csvParser.parse(expenses)
Expand Down
Loading