From c8c17a7b4a7c17ee93eca554a98d0dd37615fb1a Mon Sep 17 00:00:00 2001 From: yuliuyi717-ux <264093635+yuliuyi717-ux@users.noreply.github.com> Date: Tue, 10 Mar 2026 06:54:24 +0800 Subject: [PATCH] feat(dashboard): add multi-account financial overview --- README.md | 3 + .../__tests__/Dashboard.integration.test.tsx | 72 ++++++++- app/src/api/dashboard.ts | 37 +++++ app/src/pages/Dashboard.tsx | 75 ++++++++- packages/backend/app/routes/dashboard.py | 150 ++++++++++++++---- packages/backend/tests/test_dashboard.py | 68 ++++++++ 6 files changed, 366 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 49592bf..ea462c2 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ See `backend/app/db/schema.sql`. Key tables: ## API Endpoints OpenAPI: `backend/app/openapi.yaml` - Auth: `/auth/register`, `/auth/login`, `/auth/refresh` +- Dashboard: `/dashboard/summary`, `/dashboard/multi-account-overview` - Expenses: CRUD `/expenses` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` @@ -70,6 +71,8 @@ OpenAPI: `backend/app/openapi.yaml` ## MVP UI/UX Plan - Auth screens: register/login. - Dashboard: + - Multi-account overview cards with combined totals and per-account net flow. + - Account filter for consolidated or single-account financial view. - Monthly spend chart, category breakdown donut. - Upcoming bills list with due dates and pay status. - AI budget suggestion card. diff --git a/app/src/__tests__/Dashboard.integration.test.tsx b/app/src/__tests__/Dashboard.integration.test.tsx index 845b450..0e9c801 100644 --- a/app/src/__tests__/Dashboard.integration.test.tsx +++ b/app/src/__tests__/Dashboard.integration.test.tsx @@ -11,13 +11,49 @@ jest.mock('@/components/ui/button', () => ({ })); const getDashboardSummaryMock = jest.fn(); +const getMultiAccountOverviewMock = jest.fn(); jest.mock('@/api/dashboard', () => ({ getDashboardSummary: (...args: unknown[]) => getDashboardSummaryMock(...args), + getMultiAccountOverview: (...args: unknown[]) => getMultiAccountOverviewMock(...args), })); describe('Dashboard integration', () => { beforeEach(() => { jest.clearAllMocks(); + getMultiAccountOverviewMock.mockResolvedValue({ + period: { month: '2026-02' }, + aggregated: { + monthly_income: 3200, + monthly_expenses: 550, + net_flow: 2650, + upcoming_bills_total: 49.99, + upcoming_bills_count: 1, + account_count: 2, + }, + accounts: [ + { + account_key: 'USD', + summary: { + net_flow: 1900, + monthly_income: 2200, + monthly_expenses: 300, + upcoming_bills_total: 49.99, + upcoming_bills_count: 1, + }, + }, + { + account_key: 'EUR', + summary: { + net_flow: 750, + monthly_income: 1000, + monthly_expenses: 250, + upcoming_bills_total: 0, + upcoming_bills_count: 0, + }, + }, + ], + errors: [], + }); }); it('renders summary, transactions and upcoming bills from backend payload', async () => { @@ -74,8 +110,8 @@ describe('Dashboard integration', () => { await waitFor(() => expect(getDashboardSummaryMock).toHaveBeenCalled()); expect(screen.getByText(/financial dashboard/i)).toBeInTheDocument(); - expect(screen.getByText(/salary/i)).toBeInTheDocument(); - expect(screen.getByText(/internet/i)).toBeInTheDocument(); + expect(await screen.findByText(/salary/i)).toBeInTheDocument(); + expect(await screen.findByText(/internet/i)).toBeInTheDocument(); expect(screen.getByText(/category breakdown/i)).toBeInTheDocument(); }); @@ -139,4 +175,36 @@ describe('Dashboard integration', () => { fireEvent.change(screen.getByLabelText(/dashboard month/i), { target: { value: '2026-01' } }); await waitFor(() => expect(getDashboardSummaryMock).toHaveBeenLastCalledWith('2026-01')); }); + + it('shows multi-account combined totals and per-account overview', async () => { + const currentMonth = new Date().toISOString().slice(0, 7); + getDashboardSummaryMock.mockResolvedValue({ + period: { month: '2026-02' }, + summary: { + net_flow: 2650, + monthly_income: 3200, + monthly_expenses: 550, + upcoming_bills_total: 49.99, + upcoming_bills_count: 1, + }, + recent_transactions: [], + upcoming_bills: [], + category_breakdown: [], + errors: [], + }); + + render( + + + } /> + + , + ); + + await waitFor(() => expect(getMultiAccountOverviewMock).toHaveBeenCalledWith(currentMonth, undefined)); + expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument(); + expect((await screen.findAllByText('USD')).length).toBeGreaterThan(0); + expect((await screen.findAllByText('EUR')).length).toBeGreaterThan(0); + expect(await screen.findByText(/2 account\(s\)/i)).toBeInTheDocument(); + }); }); diff --git a/app/src/api/dashboard.ts b/app/src/api/dashboard.ts index 218374c..df3eaa1 100644 --- a/app/src/api/dashboard.ts +++ b/app/src/api/dashboard.ts @@ -37,7 +37,44 @@ export type DashboardSummary = { errors?: string[]; }; +export type MultiAccountOverview = { + period: { month: string }; + aggregated: { + monthly_income: number; + monthly_expenses: number; + net_flow: number; + upcoming_bills_total: number; + upcoming_bills_count: number; + account_count: number; + }; + accounts: Array<{ + account_key: string; + summary: { + net_flow: number; + monthly_income: number; + monthly_expenses: number; + upcoming_bills_total: number; + upcoming_bills_count: number; + }; + errors?: string[]; + }>; + errors?: string[]; +}; + export async function getDashboardSummary(month?: string): Promise { const query = month ? `?month=${encodeURIComponent(month)}` : ''; return api(`/dashboard/summary${query}`); } + +export async function getMultiAccountOverview( + month?: string, + accountKeys?: string[], +): Promise { + const params = new URLSearchParams(); + if (month) params.set('month', month); + if (accountKeys && accountKeys.length > 0) { + params.set('account_keys', accountKeys.join(',')); + } + const query = params.toString(); + return api(`/dashboard/multi-account-overview${query ? `?${query}` : ''}`); +} diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx index b2d4e7a..fed2b40 100644 --- a/app/src/pages/Dashboard.tsx +++ b/app/src/pages/Dashboard.tsx @@ -18,8 +18,14 @@ import { AlertTriangle, Calendar, Plus, + Landmark, } from 'lucide-react'; -import { getDashboardSummary, type DashboardSummary } from '@/api/dashboard'; +import { + getDashboardSummary, + getMultiAccountOverview, + type DashboardSummary, + type MultiAccountOverview, +} from '@/api/dashboard'; import { useNavigate } from 'react-router-dom'; import { formatMoney } from '@/lib/currency'; @@ -30,24 +36,33 @@ function currency(n: number, code?: string) { export function Dashboard() { const navigate = useNavigate(); const [data, setData] = useState(null); + const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7)); + const [selectedAccount, setSelectedAccount] = useState('ALL'); useEffect(() => { (async () => { setLoading(true); setError(null); try { - const res = await getDashboardSummary(month); - setData(res); + const [summaryRes, overviewRes] = await Promise.all([ + getDashboardSummary(month), + getMultiAccountOverview( + month, + selectedAccount === 'ALL' ? undefined : [selectedAccount], + ), + ]); + setData(summaryRes); + setOverview(overviewRes); } catch (error: unknown) { setError(error instanceof Error ? error.message : 'Failed to load dashboard'); } finally { setLoading(false); } })(); - }, [month]); + }, [month, selectedAccount]); const summary = useMemo(() => { if (!data) { @@ -100,6 +115,8 @@ export function Dashboard() { const transactions = data?.recent_transactions ?? []; const upcomingBills = data?.upcoming_bills ?? []; const categoryBreakdown = data?.category_breakdown ?? []; + const accountCards = overview?.accounts ?? []; + const accountCount = overview?.aggregated?.account_count ?? 0; return ( @@ -131,6 +148,56 @@ export function Dashboard() { + + + + + Account Overview + + Combined totals across {accountCount} account(s) for {month} + + + + + Dashboard account + setSelectedAccount(event.target.value)} + > + All Accounts + {accountCards.map((account) => ( + + {account.account_key} + + ))} + + + + + + {accountCards.length === 0 ? ( + No account data available for this period. + ) : ( + + {accountCards.map((account) => ( + + {account.account_key} + + Net {currency(account.summary.net_flow, account.account_key)} + + + In {currency(account.summary.monthly_income, account.account_key)} ยท Out {currency(account.summary.monthly_expenses, account.account_key)} + + + ))} + + )} + + + {error && ( {error}. Showing empty fallback state. )} diff --git a/packages/backend/app/routes/dashboard.py b/packages/backend/app/routes/dashboard.py index c310611..b8c712c 100644 --- a/packages/backend/app/routes/dashboard.py +++ b/packages/backend/app/routes/dashboard.py @@ -22,6 +22,89 @@ def dashboard_summary(): if cached: return jsonify(cached) + payload = _build_account_summary(uid=uid, ym=ym, account_key=None) + cache_set(key, payload, ttl_seconds=300) + return jsonify(payload) + + +@bp.get("/multi-account-overview") +@jwt_required() +def multi_account_overview(): + uid = int(get_jwt_identity()) + ym = (request.args.get("month") or date.today().strftime("%Y-%m")).strip() + if not _is_valid_month(ym): + return jsonify(error="invalid month, expected YYYY-MM"), 400 + + account_keys_raw = (request.args.get("account_keys") or "").strip() + account_keys: list[str] = [] + if account_keys_raw: + account_keys = [ + k.strip().upper() for k in account_keys_raw.split(",") if k.strip() + ] + else: + expense_keys = { + (row[0] or "UNKNOWN").upper() + for row in db.session.query(Expense.currency) + .filter(Expense.user_id == uid) + .distinct() + .all() + } + bill_keys = { + (row[0] or "UNKNOWN").upper() + for row in db.session.query(Bill.currency) + .filter(Bill.user_id == uid, Bill.active.is_(True)) + .distinct() + .all() + } + account_keys = sorted(expense_keys | bill_keys) + + accounts = [] + aggregated = { + "monthly_income": 0.0, + "monthly_expenses": 0.0, + "net_flow": 0.0, + "upcoming_bills_total": 0.0, + "upcoming_bills_count": 0, + "account_count": len(account_keys), + } + errors: set[str] = set() + + for account_key in account_keys: + account_payload = _build_account_summary( + uid=uid, ym=ym, account_key=account_key + ) + accounts.append( + { + "account_key": account_key, + "summary": account_payload["summary"], + "errors": account_payload["errors"], + } + ) + summary = account_payload["summary"] + aggregated["monthly_income"] += float(summary["monthly_income"]) + aggregated["monthly_expenses"] += float(summary["monthly_expenses"]) + aggregated["upcoming_bills_total"] += float(summary["upcoming_bills_total"]) + aggregated["upcoming_bills_count"] += int(summary["upcoming_bills_count"]) + errors.update(account_payload.get("errors", [])) + + aggregated["monthly_income"] = round(aggregated["monthly_income"], 2) + aggregated["monthly_expenses"] = round(aggregated["monthly_expenses"], 2) + aggregated["upcoming_bills_total"] = round(aggregated["upcoming_bills_total"], 2) + aggregated["net_flow"] = round( + aggregated["monthly_income"] - aggregated["monthly_expenses"], 2 + ) + + return jsonify( + { + "period": {"month": ym}, + "aggregated": aggregated, + "accounts": accounts, + "errors": sorted(errors), + } + ) + + +def _build_account_summary(uid: int, ym: str, account_key: str | None): payload = { "period": {"month": ym}, "summary": { @@ -41,17 +124,13 @@ def dashboard_summary(): today = date.today() try: - income = ( - db.session.query(func.coalesce(func.sum(Expense.amount), 0)) - .filter( - Expense.user_id == uid, - extract("year", Expense.spent_at) == year, - extract("month", Expense.spent_at) == month, - Expense.expense_type == "INCOME", - ) - .scalar() + income_q = db.session.query(func.coalesce(func.sum(Expense.amount), 0)).filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type == "INCOME", ) - expenses = ( + expenses_q = ( db.session.query(func.coalesce(func.sum(Expense.amount), 0)) .filter( Expense.user_id == uid, @@ -59,8 +138,14 @@ def dashboard_summary(): extract("month", Expense.spent_at) == month, Expense.expense_type != "INCOME", ) - .scalar() ) + if account_key: + income_q = income_q.filter(func.upper(Expense.currency) == account_key) + expenses_q = expenses_q.filter(func.upper(Expense.currency) == account_key) + + income = income_q.scalar() + expenses = expenses_q.scalar() + payload["summary"]["monthly_income"] = float(income or 0) payload["summary"]["monthly_expenses"] = float(expenses or 0) payload["summary"]["net_flow"] = round( @@ -72,13 +157,11 @@ def dashboard_summary(): payload["errors"].append("summary_unavailable") try: - rows = ( - db.session.query(Expense) - .filter(Expense.user_id == uid) - .order_by(Expense.spent_at.desc(), Expense.id.desc()) - .limit(10) - .all() - ) + tx_q = db.session.query(Expense).filter(Expense.user_id == uid) + if account_key: + tx_q = tx_q.filter(func.upper(Expense.currency) == account_key) + + rows = tx_q.order_by(Expense.spent_at.desc(), Expense.id.desc()).limit(10).all() payload["recent_transactions"] = [ { "id": e.id, @@ -95,17 +178,15 @@ def dashboard_summary(): payload["errors"].append("recent_transactions_unavailable") try: - bills = ( - db.session.query(Bill) - .filter( - Bill.user_id == uid, - Bill.active.is_(True), - Bill.next_due_date >= today, - ) - .order_by(Bill.next_due_date.asc()) - .limit(8) - .all() + bills_q = db.session.query(Bill).filter( + Bill.user_id == uid, + Bill.active.is_(True), + Bill.next_due_date >= today, ) + if account_key: + bills_q = bills_q.filter(func.upper(Bill.currency) == account_key) + + bills = bills_q.order_by(Bill.next_due_date.asc()).limit(8).all() payload["upcoming_bills"] = [ { "id": b.id, @@ -127,7 +208,7 @@ def dashboard_summary(): payload["errors"].append("upcoming_bills_unavailable") try: - category_rows = ( + category_q = ( db.session.query( Expense.category_id, func.coalesce(Category.name, "Uncategorized").label("category_name"), @@ -143,7 +224,12 @@ def dashboard_summary(): extract("month", Expense.spent_at) == month, Expense.expense_type != "INCOME", ) - .group_by(Expense.category_id, Category.name) + ) + if account_key: + category_q = category_q.filter(func.upper(Expense.currency) == account_key) + + category_rows = ( + category_q.group_by(Expense.category_id, Category.name) .order_by(func.sum(Expense.amount).desc()) .all() ) @@ -163,9 +249,7 @@ def dashboard_summary(): ] except Exception: payload["errors"].append("category_breakdown_unavailable") - - cache_set(key, payload, ttl_seconds=300) - return jsonify(payload) + return payload def _is_valid_month(ym: str) -> bool: diff --git a/packages/backend/tests/test_dashboard.py b/packages/backend/tests/test_dashboard.py index 9c63ea5..1e61e30 100644 --- a/packages/backend/tests/test_dashboard.py +++ b/packages/backend/tests/test_dashboard.py @@ -108,3 +108,71 @@ def test_dashboard_summary_supports_month_filter(client, auth_header): data_b = r.get_json() assert data_b["period"]["month"] == month_b.strftime("%Y-%m") assert data_b["summary"]["monthly_expenses"] == 999.0 + + +def test_multi_account_overview_aggregates_and_returns_per_account(client, auth_header): + today = date.today().isoformat() + + r = client.post( + "/expenses", + json={ + "amount": 300, + "description": "USD Salary", + "date": today, + "expense_type": "INCOME", + "currency": "USD", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.post( + "/expenses", + json={ + "amount": 50, + "description": "USD Groceries", + "date": today, + "expense_type": "EXPENSE", + "currency": "USD", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.post( + "/expenses", + json={ + "amount": 200, + "description": "INR Salary", + "date": today, + "expense_type": "INCOME", + "currency": "INR", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.post( + "/expenses", + json={ + "amount": 30, + "description": "INR Transport", + "date": today, + "expense_type": "EXPENSE", + "currency": "INR", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/dashboard/multi-account-overview", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + assert payload["aggregated"]["monthly_income"] == 500.0 + assert payload["aggregated"]["monthly_expenses"] == 80.0 + assert payload["aggregated"]["net_flow"] == 420.0 + assert payload["aggregated"]["account_count"] == 2 + + account_keys = sorted([a["account_key"] for a in payload["accounts"]]) + assert account_keys == ["INR", "USD"]