Skip to content

Commit 589436f

Browse files
feat(web): add balances csv export flow
1 parent 09cd433 commit 589436f

4 files changed

Lines changed: 172 additions & 40 deletions

File tree

apps/backend/src/balances/balances.controller.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
1+
import { Controller, Get, Param, Res, UseGuards } from '@nestjs/common';
2+
import { Response } from 'express';
23
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
4+
import { CurrentUser } from '../common/decorators/current-user.decorator';
5+
import { JwtPayload } from '../auth/types/auth.types';
36
import { BalancesService } from './balances.service';
47

58
@Controller('groups/:id/balances')
@@ -11,4 +14,16 @@ export class BalancesController {
1114
getGroupBalances(@Param('id') id: string) {
1215
return this.balancesService.getGroupBalances(id);
1316
}
17+
18+
@Get('export.csv')
19+
async exportGroupBalances(
20+
@Param('id') id: string,
21+
@CurrentUser() user: JwtPayload,
22+
@Res({ passthrough: true }) res: Response,
23+
) {
24+
const csv = await this.balancesService.exportCsv(id, user.sub);
25+
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
26+
res.setHeader('Content-Disposition', `attachment; filename="fairshare-${id}-balances.csv"`);
27+
return csv;
28+
}
1429
}

apps/backend/src/balances/balances.service.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { ForbiddenException, Injectable } from '@nestjs/common';
22
import { Prisma } from '@prisma/client';
33
import { BalanceDto } from '@fairshare/shared-types';
44
import { PrismaService } from '../common/prisma.service';
@@ -35,6 +35,39 @@ export class BalancesService {
3535
return response;
3636
}
3737

38+
async exportCsv(groupId: string, actorUserId: string): Promise<string> {
39+
await this.assertGroupMember(groupId, actorUserId);
40+
41+
const [balances, group] = await Promise.all([
42+
this.prisma.balance.findMany({
43+
where: { groupId },
44+
include: {
45+
user: { select: { name: true, email: true } },
46+
counterpartyUser: { select: { name: true, email: true } },
47+
},
48+
orderBy: [{ userId: 'asc' }, { counterpartyUserId: 'asc' }],
49+
}),
50+
this.prisma.group.findUnique({
51+
where: { id: groupId },
52+
select: { currency: true },
53+
}),
54+
]);
55+
56+
const rows = [
57+
['Debtor', 'Creditor', 'Amount', 'Currency'],
58+
...balances
59+
.filter((balance) => balance.amountCents < 0n)
60+
.map((balance) => [
61+
balance.user.name || balance.user.email,
62+
balance.counterpartyUser.name || balance.counterpartyUser.email,
63+
(Number(balance.amountCents * -1n) / 100).toFixed(2),
64+
group?.currency ?? 'USD',
65+
]),
66+
];
67+
68+
return rows.map((row) => row.map((value) => this.escapeCsv(value)).join(',')).join('\n');
69+
}
70+
3871
async adjustBalance(
3972
tx: Prisma.TransactionClient,
4073
groupId: string,
@@ -81,4 +114,24 @@ export class BalancesService {
81114
},
82115
});
83116
}
117+
118+
private async assertGroupMember(groupId: string, userId: string): Promise<void> {
119+
const membership = await this.prisma.groupMember.findUnique({
120+
where: {
121+
groupId_userId: {
122+
groupId,
123+
userId,
124+
},
125+
},
126+
select: { userId: true },
127+
});
128+
129+
if (!membership) {
130+
throw new ForbiddenException('Actor is not a group member');
131+
}
132+
}
133+
134+
private escapeCsv(value: string): string {
135+
return `"${value.replace(/\r?\n/g, ' ').replace(/"/g, '""')}"`;
136+
}
84137
}

apps/web/src/components/groups/GroupActions.tsx

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { useState } from 'react';
44
import { useRouter } from 'next/navigation';
55
import { CurrencyCode, GroupDefaultSplitDto, GroupMemberSummaryDto } from '@fairshare/shared-types';
66
import Link from 'next/link';
7-
import { Download, PlusCircle, Wallet } from 'lucide-react';
7+
import { Download, PlusCircle, Scale, Wallet } from 'lucide-react';
88
import dynamic from 'next/dynamic';
9-
import { exportExpensesCsvAction } from '../../lib/actions';
9+
import { exportBalancesCsvAction, exportExpensesCsvAction } from '../../lib/actions';
1010
import { useToast } from '../ui/Toaster';
1111

1212
const CreateExpenseModal = dynamic(
@@ -21,62 +21,102 @@ type GroupActionsProps = {
2121
defaultSplitPreference?: GroupDefaultSplitDto | null;
2222
};
2323

24-
export function GroupActions({ groupId, currency, members, defaultSplitPreference }: GroupActionsProps) {
24+
export function GroupActions({
25+
groupId,
26+
currency,
27+
members,
28+
defaultSplitPreference,
29+
}: GroupActionsProps) {
2530
const [open, setOpen] = useState(false);
26-
const [exporting, setExporting] = useState(false);
31+
const [exportingExpenses, setExportingExpenses] = useState(false);
32+
const [exportingBalances, setExportingBalances] = useState(false);
2733
const router = useRouter();
2834
const { toast } = useToast();
2935

30-
const handleExport = async () => {
36+
const downloadCsv = (csv: string, fileName: string) => {
37+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
38+
const url = window.URL.createObjectURL(blob);
39+
const anchor = document.createElement('a');
40+
anchor.href = url;
41+
anchor.download = fileName;
42+
document.body.appendChild(anchor);
43+
anchor.click();
44+
anchor.remove();
45+
window.URL.revokeObjectURL(url);
46+
};
47+
48+
const handleExpenseExport = async () => {
3149
try {
32-
setExporting(true);
50+
setExportingExpenses(true);
3351
const result = await exportExpensesCsvAction(groupId);
34-
const csv = result.success ? result.csv ?? '' : '';
52+
const csv = result.success ? (result.csv ?? '') : '';
3553
if (!csv) {
36-
throw new Error(result.success ? 'Failed to export CSV' : result.message);
54+
throw new Error(result.success ? 'Failed to export expenses CSV' : result.message);
3755
}
3856

39-
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
40-
const url = window.URL.createObjectURL(blob);
41-
const anchor = document.createElement('a');
42-
anchor.href = url;
43-
anchor.download = `fairshare-${groupId}.csv`;
44-
document.body.appendChild(anchor);
45-
anchor.click();
46-
anchor.remove();
47-
window.URL.revokeObjectURL(url);
48-
toast('CSV exported');
57+
downloadCsv(csv, `fairshare-${groupId}.csv`);
58+
toast('Expenses CSV exported');
4959
} catch (error) {
50-
toast((error as Error).message || 'Failed to export CSV', 'error');
60+
toast((error as Error).message || 'Failed to export expenses CSV', 'error');
5161
} finally {
52-
setExporting(false);
62+
setExportingExpenses(false);
63+
}
64+
};
65+
66+
const handleBalanceExport = async () => {
67+
try {
68+
setExportingBalances(true);
69+
const result = await exportBalancesCsvAction(groupId);
70+
const csv = result.success ? (result.csv ?? '') : '';
71+
if (!csv) {
72+
throw new Error(result.success ? 'Failed to export balances CSV' : result.message);
73+
}
74+
75+
downloadCsv(csv, `fairshare-${groupId}-balances.csv`);
76+
toast('Balances CSV exported');
77+
} catch (error) {
78+
toast((error as Error).message || 'Failed to export balances CSV', 'error');
79+
} finally {
80+
setExportingBalances(false);
5381
}
5482
};
5583

5684
return (
5785
<>
58-
<div className="rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-card)] p-4 sm:p-6 shadow-[var(--fs-shadow-soft)]">
59-
<h3 className="text-lg font-bold text-[var(--fs-text-primary)] mb-4">Actions</h3>
86+
<div className="rounded-3xl border border-[var(--fs-border)] bg-[var(--fs-card)] p-4 shadow-[var(--fs-shadow-soft)] sm:p-6">
87+
<h3 className="mb-4 text-lg font-bold text-[var(--fs-text-primary)]">Actions</h3>
6088
<div className="grid gap-3">
61-
<button className="btn-royal w-full inline-flex items-center justify-center gap-2" onClick={() => setOpen(true)}>
62-
<PlusCircle className="w-4 h-4" />
89+
<button
90+
className="btn-royal inline-flex w-full items-center justify-center gap-2"
91+
onClick={() => setOpen(true)}
92+
>
93+
<PlusCircle className="h-4 w-4" />
6394
Record expense
6495
</button>
6596
<Link
6697
href={`/dashboard/groups/${groupId}/settle`}
67-
className="rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] px-4 py-3 text-sm font-bold text-[var(--fs-text-primary)] hover:border-[var(--fs-primary)] flex items-center gap-2 justify-center"
98+
className="flex items-center justify-center gap-2 rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] px-4 py-3 text-sm font-bold text-[var(--fs-text-primary)] hover:border-[var(--fs-primary)]"
6899
>
69-
<Wallet className="w-4 h-4 text-[var(--fs-primary)]" />
100+
<Wallet className="h-4 w-4 text-[var(--fs-primary)]" />
70101
Settle up
71102
</Link>
72103
<button
73104
type="button"
74-
onClick={() => void handleExport()}
75-
disabled={exporting}
76-
className="rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] px-4 py-3 text-sm font-bold text-[var(--fs-text-primary)] hover:border-[var(--fs-primary)] inline-flex items-center justify-center gap-2 disabled:opacity-60"
105+
onClick={() => void handleExpenseExport()}
106+
disabled={exportingExpenses}
107+
className="inline-flex items-center justify-center gap-2 rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] px-4 py-3 text-sm font-bold text-[var(--fs-text-primary)] hover:border-[var(--fs-primary)] disabled:opacity-60"
108+
>
109+
<Download className="h-4 w-4 text-[var(--fs-primary)]" />
110+
{exportingExpenses ? 'Exporting...' : 'Export expenses CSV'}
111+
</button>
112+
<button
113+
type="button"
114+
onClick={() => void handleBalanceExport()}
115+
disabled={exportingBalances}
116+
className="inline-flex items-center justify-center gap-2 rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] px-4 py-3 text-sm font-bold text-[var(--fs-text-primary)] hover:border-[var(--fs-primary)] disabled:opacity-60"
77117
>
78-
<Download className="w-4 h-4 text-[var(--fs-primary)]" />
79-
{exporting ? 'Exporting...' : 'Export CSV'}
118+
<Scale className="h-4 w-4 text-[var(--fs-primary)]" />
119+
{exportingBalances ? 'Exporting...' : 'Export balances CSV'}
80120
</button>
81121
<div className="rounded-xl border border-[var(--fs-border)] bg-[var(--fs-background)] px-4 py-3 text-sm font-medium text-[var(--fs-text-muted)]">
82122
Invite teammates from the member panel to keep your ledger accurate.

apps/web/src/lib/actions.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ export async function inviteMemberAction(groupId: string, email: string) {
3737
if (!response.ok) {
3838
return {
3939
success: false,
40-
message: Array.isArray(data.message) ? data.message[0] : data.message || 'Failed to invite member',
40+
message: Array.isArray(data.message)
41+
? data.message[0]
42+
: data.message || 'Failed to invite member',
4143
};
4244
}
4345

@@ -94,7 +96,10 @@ export async function updateExpenseAction(expenseId: string, payload: UpdateExpe
9496
return { success: true, expense: data as ExpenseDto };
9597
}
9698

97-
export async function updateRecurringExpenseAction(recurringExpenseId: string, payload: UpdateRecurringExpenseRequestDto) {
99+
export async function updateRecurringExpenseAction(
100+
recurringExpenseId: string,
101+
payload: UpdateRecurringExpenseRequestDto,
102+
) {
98103
const token = (await cookies()).get(authCookies.accessToken)?.value;
99104

100105
const response = await fetch(`${getBackendBaseUrl()}/recurring-expenses/${recurringExpenseId}`, {
@@ -119,7 +124,10 @@ export async function updateRecurringExpenseAction(recurringExpenseId: string, p
119124
return { success: true, recurringExpense: data as RecurringExpenseDto };
120125
}
121126

122-
export async function updateGroupDefaultSplitAction(groupId: string, payload: UpdateGroupDefaultSplitRequestDto) {
127+
export async function updateGroupDefaultSplitAction(
128+
groupId: string,
129+
payload: UpdateGroupDefaultSplitRequestDto,
130+
) {
123131
const token = (await cookies()).get(authCookies.accessToken)?.value;
124132

125133
const response = await fetch(`${getBackendBaseUrl()}/groups/${groupId}/default-split`, {
@@ -278,6 +286,26 @@ export async function exportExpensesCsvAction(groupId: string) {
278286
return { success: true, csv: data };
279287
}
280288

289+
export async function exportBalancesCsvAction(groupId: string) {
290+
const token = (await cookies()).get(authCookies.accessToken)?.value;
291+
292+
const response = await fetch(`${getBackendBaseUrl()}/groups/${groupId}/balances/export.csv`, {
293+
method: 'GET',
294+
headers: {
295+
...(token ? { Authorization: `Bearer ${token}` } : {}),
296+
},
297+
cache: 'no-store',
298+
});
299+
300+
const data = await response.text().catch(() => '');
301+
302+
if (!response.ok) {
303+
return { success: false, message: data || 'Failed to export balances CSV' };
304+
}
305+
306+
return { success: true, csv: data };
307+
}
308+
281309
export async function listRecurringExpensesAction(groupId: string) {
282310
const token = (await cookies()).get(authCookies.accessToken)?.value;
283311

@@ -317,7 +345,3 @@ export async function deleteRecurringExpenseAction(recurringExpenseId: string) {
317345

318346
return { success: true };
319347
}
320-
321-
322-
323-

0 commit comments

Comments
 (0)