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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,52 @@ OpenAPI: `backend/app/openapi.yaml`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
- Insights: `/insights/monthly`, `/insights/budget-suggestion`
- GDPR / Privacy:
- `GET /user/export` — Download all personal data as a ZIP (JSON inside)
- `DELETE /user` — Permanently delete account and all associated data
- Weekly Digest: `/weekly-summary` — smart weekly financial summary (see below)

## GDPR: Data Export & Account Deletion

FinMind supports GDPR-compliant data portability and right to erasure:

### Export Your Data (`GET /user/export`)
- Returns a ZIP file containing all user data (profile, expenses, bills, categories, reminders, subscriptions, audit logs) as JSON
- Password hashes are excluded from the export
- An audit log entry is created for each export request
- Requires authentication (JWT)

### Delete Your Account (`DELETE /user`)
- Permanently and irreversibly deletes the user account and **all** associated data
- Cascade deletes: expenses, recurring expenses, bills, reminders, categories, ad impressions, subscriptions, and audit logs
- Invalidates all active Redis sessions
- Creates an anonymized audit log entry recording the deletion
- Requires authentication (JWT)

### Frontend
The Account Settings page includes:
- **"Export My Data"** button — downloads the ZIP immediately
- **"Delete Account"** button — opens a confirmation dialog before permanent deletion

## Weekly Digest (Smart Summary)

The **Weekly Digest** provides an at-a-glance financial summary for any given week:

- **Totals**: income, expenses, net flow, and transaction count for the week
- **Daily breakdown**: spending by day with visual bar chart
- **Category breakdown**: where your money went, with percentage shares
- **Top expenses**: the 5 largest purchases of the week
- **Upcoming bills**: bills due within the current and following week
- **Week-over-week trends**: percentage change in income and expenses vs. the previous week

### Backend
- `GET /weekly-summary?week_of=YYYY-MM-DD` — returns the digest for the week containing the given date (defaults to current week)
- Authenticated via JWT; results are cached (1 hour) for completed weeks via Redis

### Frontend
- Navigate to `/digest` or click **Weekly Digest** in the navbar
- Use arrow buttons to browse previous weeks
- Cards, charts, and lists render the digest data

## MVP UI/UX Plan
- Auth screens: register/login.
Expand Down
9 changes: 9 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound";
import { Landing } from "./pages/Landing";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import Account from "./pages/Account";
import WeeklyDigest from "./pages/WeeklyDigest";

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -83,6 +84,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="digest"
element={
<ProtectedRoute>
<WeeklyDigest />
</ProtectedRoute>
}
/>
<Route
path="account"
element={
Expand Down
100 changes: 100 additions & 0 deletions app/src/__tests__/WeeklyDigest.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import WeeklyDigest from '../pages/WeeklyDigest';

const mockSummary = {
week: { start: '2026-03-02', end: '2026-03-08' },
totals: { income: 5000, expenses: 1200, net: 3800, transaction_count: 12 },
daily_breakdown: [
{ date: '2026-03-02', income: 0, expenses: 100 },
{ date: '2026-03-03', income: 5000, expenses: 200 },
{ date: '2026-03-04', income: 0, expenses: 300 },
{ date: '2026-03-05', income: 0, expenses: 150 },
{ date: '2026-03-06', income: 0, expenses: 250 },
{ date: '2026-03-07', income: 0, expenses: 100 },
{ date: '2026-03-08', income: 0, expenses: 100 },
],
category_breakdown: [
{ category_id: 1, category_name: 'Food', amount: 600, count: 5, share_pct: 50 },
{ category_id: 2, category_name: 'Transport', amount: 600, count: 7, share_pct: 50 },
],
top_expenses: [
{ id: 1, description: 'Big Purchase', amount: 300, date: '2026-03-04', category_id: 1, currency: 'INR' },
],
upcoming_bills: [
{ id: 1, name: 'Internet', amount: 999, currency: 'INR', next_due_date: '2026-03-10', cadence: 'MONTHLY' },
],
trends: {
expense_change_pct: -15.2,
income_change_pct: 10.0,
previous_week_expenses: 1414,
previous_week_income: 4545,
},
};

vi.mock('../api/weekly-summary', () => ({
getWeeklySummary: vi.fn(() => Promise.resolve(mockSummary)),
}));

function renderPage() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<BrowserRouter>
<WeeklyDigest />
</BrowserRouter>
</QueryClientProvider>,
);
}

describe('WeeklyDigest page', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders the page title', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Weekly Digest')).toBeTruthy();
});
});

it('shows summary cards with correct data', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Weekly Income')).toBeTruthy();
expect(screen.getByText('Weekly Expenses')).toBeTruthy();
expect(screen.getByText('Net Flow')).toBeTruthy();
expect(screen.getByText('Transactions')).toBeTruthy();
});
});

it('shows top expenses', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Big Purchase')).toBeTruthy();
});
});

it('shows upcoming bills', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Internet')).toBeTruthy();
});
});

it('shows category breakdown', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Food')).toBeTruthy();
expect(screen.getByText('Transport')).toBeTruthy();
});
});

it('shows week navigation buttons', () => {
renderPage();
expect(screen.getByText('This Week')).toBeTruthy();
});
});
25 changes: 25 additions & 0 deletions app/src/api/gdpr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { baseURL } from './client';
import { getToken } from '../lib/auth';

/**
* Download user data export as a ZIP file.
*/
export async function exportUserData(): Promise<Blob> {
const res = await fetch(`${baseURL}/user/export`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error('Export failed');
return res.blob();
}

/**
* Permanently delete the authenticated user's account.
*/
export async function deleteAccount(): Promise<{ message: string }> {
const res = await fetch(`${baseURL}/user`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error('Account deletion failed');
return res.json();
}
62 changes: 62 additions & 0 deletions app/src/api/weekly-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { api } from './client';

export type WeeklyTotals = {
income: number;
expenses: number;
net: number;
transaction_count: number;
};

export type DailyBreakdown = {
date: string;
income: number;
expenses: number;
};

export type CategoryBreakdown = {
category_id: number | null;
category_name: string;
amount: number;
count: number;
share_pct: number;
};

export type TopExpense = {
id: number;
description: string;
amount: number;
date: string;
category_id: number | null;
currency: string;
};

export type UpcomingBill = {
id: number;
name: string;
amount: number;
currency: string;
next_due_date: string;
cadence: string;
};

export type WeeklyTrends = {
expense_change_pct: number | null;
income_change_pct: number | null;
previous_week_expenses: number;
previous_week_income: number;
};

export type WeeklySummary = {
week: { start: string; end: string };
totals: WeeklyTotals;
daily_breakdown: DailyBreakdown[];
category_breakdown: CategoryBreakdown[];
top_expenses: TopExpense[];
upcoming_bills: UpcomingBill[];
trends: WeeklyTrends;
};

export async function getWeeklySummary(weekOf?: string): Promise<WeeklySummary> {
const query = weekOf ? `?week_of=${encodeURIComponent(weekOf)}` : '';
return api<WeeklySummary>(`/weekly-summary${query}`);
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const navigation = [
{ name: 'Reminders', href: '/reminders' },
{ name: 'Expenses', href: '/expenses' },
{ name: 'Analytics', href: '/analytics' },
{ name: 'Weekly Digest', href: '/digest' },
];

export function Navbar() {
Expand Down
97 changes: 96 additions & 1 deletion app/src/pages/Account.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { me, updateMe } from '@/api/auth';
import { setCurrency } from '@/lib/auth';
import { setCurrency, clearToken, clearRefreshToken } from '@/lib/auth';
import { exportUserData, deleteAccount } from '@/api/gdpr';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dailog';

const SUPPORTED_CURRENCIES = [
{ code: 'INR', label: 'Indian Rupee (INR)' },
Expand All @@ -19,10 +32,13 @@ const SUPPORTED_CURRENCIES = [

export default function Account() {
const { toast } = useToast();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [currency, setCurrencyState] = useState('INR');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [deleting, setDeleting] = useState(false);

useEffect(() => {
const load = async () => {
Expand Down Expand Up @@ -108,6 +124,85 @@ export default function Account() {
</>
)}
</div>

{/* GDPR: Data Export & Account Deletion */}
<div className="card card-interactive space-y-5 fade-in-up">
<h2 className="text-lg font-semibold">Your Data (GDPR)</h2>
<p className="text-sm text-muted-foreground">
Export all your personal data as a downloadable ZIP, or permanently
delete your account and all associated data.
</p>

<div className="flex flex-wrap gap-3">
<Button
variant="outline"
disabled={exporting}
onClick={async () => {
setExporting(true);
try {
const blob = await exportUserData();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'finmind_export.zip';
a.click();
URL.revokeObjectURL(url);
toast({ title: 'Export ready', description: 'Your data has been downloaded.' });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Export failed';
toast({ title: 'Export failed', description: msg });
} finally {
setExporting(false);
}
}}
>
{exporting ? 'Exporting...' : '📦 Export My Data'}
</Button>

<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={deleting}>
{deleting ? 'Deleting...' : '🗑️ Delete Account'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action is <strong>permanent and irreversible</strong>. All
your data — expenses, bills, categories, reminders, and
settings — will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setDeleting(true);
try {
await deleteAccount();
clearToken();
clearRefreshToken();
toast({
title: 'Account deleted',
description: 'Your account and all data have been permanently removed.',
});
navigate('/');
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Deletion failed';
toast({ title: 'Deletion failed', description: msg });
} finally {
setDeleting(false);
}
}}
>
Yes, delete everything
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
);
}
Loading