diff --git a/src/app/groups/[groupId]/expenses/export/csv/route.ts b/src/app/groups/[groupId]/expenses/export/csv/route.ts index dfb92c7b5..102e55e19 100644 --- a/src/app/groups/[groupId]/expenses/export/csv/route.ts +++ b/src/app/groups/[groupId]/expenses/export/csv/route.ts @@ -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) @@ -36,6 +36,7 @@ export async function GET( currencyCode: true, expenses: { select: { + createdAt: true, expenseDate: true, title: true, category: { select: { name: true } }, @@ -48,6 +49,7 @@ export async function GET( isReimbursement: true, splitMode: true, }, + orderBy: [{ expenseDate: 'asc' }, { createdAt: 'asc' }], }, participants: { select: { id: true, name: true } }, }, @@ -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 | + +------------+------------------+----------+----------+----------+---------------+-------------------+-----------------+----------------------+----------+--------+-----------+ */ @@ -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, @@ -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 + + 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 + // Normalize shares based on split mode to mirror app logic + const isEvenly = expense.splitMode === 'EVENLY' + const normalizedSharesByParticipant: Record = {} + 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)