From b49b50a3025875490b5cbce163cc33198498aeb0 Mon Sep 17 00:00:00 2001 From: DeekRoumy Date: Sun, 22 Mar 2026 20:34:33 -0700 Subject: [PATCH] feat: multi-account financial overview dashboard (#132) Add support for viewing multiple financial accounts in one dashboard view. ## Backend - New SQLAlchemy model with fields: name, account_type (CHECKING/SAVINGS/CREDIT_CARD/INVESTMENT/LOAN/CASH/OTHER), balance, currency, institution, last_four, color, include_in_overview, active - CRUD REST API at /accounts (list, create, get, patch, delete/deactivate) - Aggregate overview endpoint GET /accounts/overview with: - net_worth, total_assets, total_liabilities across all accounts - monthly income/expenses/net_flow for selected month - upcoming bills and category breakdown - Updated db/schema.sql with financial_accounts table + index - 31 new backend tests covering CRUD + overview logic (all passing) - Updated conftest.py to use fakeredis, fixing test isolation for all existing tests (no Redis server required) ## Frontend - New /api/accounts.ts client with full TypeScript types - New MultiAccountDashboard page (/accounts route): - Net worth summary cards (assets, liabilities, net worth) - Monthly cash flow summary (income, expenses, net flow) - Account cards grid with color-coded type icons - Create/edit account dialog with all fields - Soft-delete (deactivate) with confirm prompt - Upcoming bills list - Category breakdown progress bars - Month picker and refresh button - Added 'Accounts' link to Navbar - Registered /accounts route in App.tsx router Closes #132 --- app/src/App.tsx | 9 + app/src/api/accounts.ts | 105 ++++ app/src/components/layout/Navbar.tsx | 1 + app/src/pages/MultiAccountDashboard.tsx | 718 ++++++++++++++++++++++++ packages/backend/app/db/schema.sql | 27 + packages/backend/app/models.py | 39 ++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/accounts.py | 408 ++++++++++++++ packages/backend/tests/conftest.py | 31 +- packages/backend/tests/test_accounts.py | 350 ++++++++++++ 10 files changed, 1680 insertions(+), 10 deletions(-) create mode 100644 app/src/api/accounts.ts create mode 100644 app/src/pages/MultiAccountDashboard.tsx create mode 100644 packages/backend/app/routes/accounts.py create mode 100644 packages/backend/tests/test_accounts.py diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..3097247d 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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 { MultiAccountDashboard } from "./pages/MultiAccountDashboard"; const queryClient = new QueryClient({ defaultOptions: { @@ -91,6 +92,14 @@ const App = () => ( } /> + + + + } + /> } /> } /> diff --git a/app/src/api/accounts.ts b/app/src/api/accounts.ts new file mode 100644 index 00000000..c84f283c --- /dev/null +++ b/app/src/api/accounts.ts @@ -0,0 +1,105 @@ +import { api } from './client'; + +export type AccountType = + | 'CHECKING' + | 'SAVINGS' + | 'CREDIT_CARD' + | 'INVESTMENT' + | 'LOAN' + | 'CASH' + | 'OTHER'; + +export type FinancialAccount = { + id: number; + name: string; + account_type: AccountType; + balance: number; + currency: string; + institution: string | null; + last_four: string | null; + color: string | null; + include_in_overview: boolean; + active: boolean; + created_at: string; + updated_at: string; +}; + +export type MultiAccountOverview = { + period: { month: string }; + accounts: FinancialAccount[]; + totals: { + net_worth: number; + total_assets: number; + total_liabilities: number; + }; + monthly_summary: { + income: number; + expenses: number; + net_flow: number; + }; + upcoming_bills: Array<{ + id: number; + name: string; + amount: number; + currency: string; + next_due_date: string; + cadence: string; + }>; + category_breakdown: Array<{ + category_id: number | null; + category_name: string; + amount: number; + share_pct: number; + }>; + errors?: string[]; +}; + +export type CreateAccountPayload = { + name: string; + account_type?: AccountType; + balance?: number; + currency?: string; + institution?: string; + last_four?: string; + color?: string; + include_in_overview?: boolean; +}; + +export type UpdateAccountPayload = Partial; + +export async function listAccounts(includeInactive = false): Promise { + const q = includeInactive ? '?include_inactive=true' : ''; + return api(`/accounts${q}`); +} + +export async function getAccount(id: number): Promise { + return api(`/accounts/${id}`); +} + +export async function createAccount(payload: CreateAccountPayload): Promise { + return api('/accounts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +export async function updateAccount( + id: number, + payload: UpdateAccountPayload +): Promise { + return api(`/accounts/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +export async function deleteAccount(id: number): Promise<{ message: string }> { + return api<{ message: string }>(`/accounts/${id}`, { method: 'DELETE' }); +} + +export async function getMultiAccountOverview(month?: string): Promise { + const q = month ? `?month=${encodeURIComponent(month)}` : ''; + return api(`/accounts/overview${q}`); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..b4bb558d 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -8,6 +8,7 @@ import { logout as logoutApi } from '@/api/auth'; const navigation = [ { name: 'Dashboard', href: '/dashboard' }, + { name: 'Accounts', href: '/accounts' }, { name: 'Budgets', href: '/budgets' }, { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, diff --git a/app/src/pages/MultiAccountDashboard.tsx b/app/src/pages/MultiAccountDashboard.tsx new file mode 100644 index 00000000..d2e8ed88 --- /dev/null +++ b/app/src/pages/MultiAccountDashboard.tsx @@ -0,0 +1,718 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardHeader, + FinancialCardTitle, + FinancialCardDescription, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useToast } from '@/hooks/use-toast'; +import { formatMoney } from '@/lib/currency'; +import { + type FinancialAccount, + type AccountType, + type MultiAccountOverview, + type CreateAccountPayload, + listAccounts, + createAccount, + updateAccount, + deleteAccount, + getMultiAccountOverview, +} from '@/api/accounts'; +import { + Wallet, + TrendingUp, + TrendingDown, + CreditCard, + PiggyBank, + Banknote, + BarChart3, + Plus, + Pencil, + Trash2, + RefreshCw, + Building2, + Eye, + EyeOff, +} from 'lucide-react'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [ + { value: 'CHECKING', label: 'Checking' }, + { value: 'SAVINGS', label: 'Savings' }, + { value: 'CREDIT_CARD', label: 'Credit Card' }, + { value: 'INVESTMENT', label: 'Investment' }, + { value: 'LOAN', label: 'Loan' }, + { value: 'CASH', label: 'Cash' }, + { value: 'OTHER', label: 'Other' }, +]; + +const SUPPORTED_CURRENCIES = [ + 'INR', 'USD', 'EUR', 'GBP', 'AED', 'SGD', 'AUD', 'CAD', 'JPY', +]; + +function accountTypeIcon(type: AccountType) { + switch (type) { + case 'CHECKING': return ; + case 'SAVINGS': return ; + case 'CREDIT_CARD': return ; + case 'INVESTMENT': return ; + case 'LOAN': return ; + case 'CASH': return ; + default: return ; + } +} + +const LIABILITY_TYPES: AccountType[] = ['CREDIT_CARD', 'LOAN']; + +function isLiability(type: AccountType) { + return LIABILITY_TYPES.includes(type); +} + +// --------------------------------------------------------------------------- +// Account Form (create / edit) +// --------------------------------------------------------------------------- + +type AccountFormState = { + name: string; + account_type: AccountType; + balance: string; + currency: string; + institution: string; + last_four: string; + color: string; + include_in_overview: boolean; +}; + +const DEFAULT_FORM: AccountFormState = { + name: '', + account_type: 'CHECKING', + balance: '0', + currency: 'INR', + institution: '', + last_four: '', + color: '#4f46e5', + include_in_overview: true, +}; + +function AccountFormDialog({ + open, + onClose, + initial, + onSave, +}: { + open: boolean; + onClose: () => void; + initial?: AccountFormState; + onSave: (data: AccountFormState) => Promise; +}) { + const [form, setForm] = useState(initial ?? DEFAULT_FORM); + const [saving, setSaving] = useState(false); + + // Sync form when initial changes (editing different account) + useEffect(() => { + setForm(initial ?? DEFAULT_FORM); + }, [initial, open]); + + const set = (key: keyof AccountFormState, value: string | boolean) => + setForm((f) => ({ ...f, [key]: value })); + + const handleSave = async () => { + setSaving(true); + try { + await onSave(form); + onClose(); + } finally { + setSaving(false); + } + }; + + return ( + !v && onClose()}> + + + {initial ? 'Edit Account' : 'Add Account'} + + {initial ? 'Update your financial account details.' : 'Add a new financial account to track.'} + + + +
+ {/* Name */} +
+ + set('name', e.target.value)} + /> +
+ + {/* Type */} +
+ + +
+ + {/* Balance + Currency row */} +
+
+ + set('balance', e.target.value)} + /> +
+
+ + +
+
+ + {/* Institution */} +
+ + set('institution', e.target.value)} + /> +
+ + {/* Last four */} +
+ + set('last_four', e.target.value.replace(/\D/g, ''))} + /> +
+ + {/* Color + Include in overview row */} +
+
+ +
+ set('color', e.target.value)} + className="h-9 w-9 rounded cursor-pointer border border-input" + /> + {form.color} +
+
+
+ +
+ +
+
+
+
+ + + + + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Account card +// --------------------------------------------------------------------------- + +function AccountCard({ + account, + onEdit, + onDelete, +}: { + account: FinancialAccount; + onEdit: (a: FinancialAccount) => void; + onDelete: (a: FinancialAccount) => void; +}) { + const liability = isLiability(account.account_type); + const colorStyle = account.color ? { borderLeftColor: account.color } : {}; + + return ( +
+
+
+
+ {accountTypeIcon(account.account_type)} +
+
+

{account.name}

+ {account.institution && ( +

{account.institution}

+ )} + {account.last_four && ( +

•••• {account.last_four}

+ )} +
+
+
+ + +
+
+ +
+
+

+ {ACCOUNT_TYPE_OPTIONS.find((o) => o.value === account.account_type)?.label ?? account.account_type} +

+

+ {liability ? '-' : ''} + {formatMoney(Math.abs(account.balance), account.currency)} +

+
+ {!account.include_in_overview && ( + + Hidden + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- + +export function MultiAccountDashboard() { + const { toast } = useToast(); + const [overview, setOverview] = useState(null); + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(true); + const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7)); + + // Dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const [ov, accs] = await Promise.all([ + getMultiAccountOverview(month), + listAccounts(), + ]); + setOverview(ov); + setAccounts(accs); + } catch (err) { + toast({ + title: 'Failed to load accounts', + description: err instanceof Error ? err.message : 'Unknown error', + variant: 'destructive', + }); + } finally { + setLoading(false); + } + }, [month, toast]); + + useEffect(() => { + void load(); + }, [load]); + + const handleSave = async (form: AccountFormState) => { + const payload: CreateAccountPayload = { + name: form.name.trim(), + account_type: form.account_type, + balance: parseFloat(form.balance) || 0, + currency: form.currency, + institution: form.institution.trim() || undefined, + last_four: form.last_four.trim() || undefined, + color: form.color || undefined, + include_in_overview: form.include_in_overview, + }; + + try { + if (editTarget) { + await updateAccount(editTarget.id, payload); + toast({ title: 'Account updated' }); + } else { + await createAccount(payload); + toast({ title: 'Account added' }); + } + await load(); + } catch (err) { + toast({ + title: 'Error', + description: err instanceof Error ? err.message : 'Failed to save account', + variant: 'destructive', + }); + throw err; // re-throw so dialog stays open on error + } + }; + + const handleEdit = (account: FinancialAccount) => { + setEditTarget(account); + setDialogOpen(true); + }; + + const handleDelete = async (account: FinancialAccount) => { + if (!window.confirm(`Deactivate "${account.name}"? It will be hidden from your dashboard.`)) return; + try { + await deleteAccount(account.id); + toast({ title: 'Account deactivated', description: account.name }); + await load(); + } catch (err) { + toast({ + title: 'Failed to deactivate', + description: err instanceof Error ? err.message : 'Unknown error', + variant: 'destructive', + }); + } + }; + + const editFormInitial: AccountFormState | undefined = editTarget + ? { + name: editTarget.name, + account_type: editTarget.account_type, + balance: String(editTarget.balance), + currency: editTarget.currency, + institution: editTarget.institution ?? '', + last_four: editTarget.last_four ?? '', + color: editTarget.color ?? '#4f46e5', + include_in_overview: editTarget.include_in_overview, + } + : undefined; + + const totals = overview?.totals; + const ms = overview?.monthly_summary; + + return ( +
+ {/* Header */} +
+
+

Multi-Account Overview

+

+ All your financial accounts in one place +

+
+
+ setMonth(e.target.value)} + className="rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring" + /> + + +
+
+ + {/* Net Worth Summary Cards */} +
+ + + + + Net Worth + + Assets minus liabilities + + +

= 0 ? 'text-green-600' : 'text-destructive'}`}> + {loading ? '—' : formatMoney(totals?.net_worth ?? 0)} +

+
+
+ + + + + + Total Assets + + Checking, savings, investments + + +

+ {loading ? '—' : formatMoney(totals?.total_assets ?? 0)} +

+
+
+ + + + + + Total Liabilities + + Credit cards, loans + + +

+ {loading ? '—' : formatMoney(totals?.total_liabilities ?? 0)} +

+
+
+
+ + {/* Monthly Summary */} +
+ + + Monthly Income + {month} + + +

+ {loading ? '—' : formatMoney(ms?.income ?? 0)} +

+
+
+ + + + Monthly Expenses + {month} + + +

+ {loading ? '—' : formatMoney(ms?.expenses ?? 0)} +

+
+
+ + + + Net Cash Flow + {month} + + +

= 0 ? 'text-green-600' : 'text-destructive'}`}> + {loading ? '—' : formatMoney(ms?.net_flow ?? 0)} +

+
+
+
+ + {/* Accounts Grid */} +
+

Your Accounts

+ {loading ? ( +

Loading…

+ ) : accounts.length === 0 ? ( +
+ +

No accounts yet.

+ +
+ ) : ( +
+ {accounts.map((acct) => ( + + ))} +
+ )} +
+ + {/* Upcoming Bills (from overview) */} + {(overview?.upcoming_bills?.length ?? 0) > 0 && ( +
+

Upcoming Bills

+
+ {overview!.upcoming_bills.map((b) => ( +
+
+

{b.name}

+

+ Due {new Date(b.next_due_date).toLocaleDateString()} · {b.cadence} +

+
+

+ {formatMoney(b.amount, b.currency)} +

+
+ ))} +
+
+ )} + + {/* Category Breakdown */} + {(overview?.category_breakdown?.length ?? 0) > 0 && ( +
+

Expense Breakdown — {month}

+ + +
+ {overview!.category_breakdown.map((c) => ( +
+
+ {c.category_name} +
+
+
+
+
+
+
+ {formatMoney(c.amount)} +
+
+ {c.share_pct.toFixed(1)}% +
+
+ ))} +
+ + +
+ )} + + {/* Account form dialog */} + { + setDialogOpen(false); + setEditTarget(null); + }} + initial={editFormInitial} + onSave={handleSave} + /> +
+ ); +} + +export default MultiAccountDashboard; diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..5c1a7b1b 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,30 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +-- Multi-account dashboard: financial accounts +DO $$ BEGIN + CREATE TYPE account_type AS ENUM ( + 'CHECKING', 'SAVINGS', 'CREDIT_CARD', 'INVESTMENT', 'LOAN', 'CASH', 'OTHER' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS financial_accounts ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + account_type account_type NOT NULL DEFAULT 'CHECKING', + balance NUMERIC(15, 2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + institution VARCHAR(200), + last_four CHAR(4), + color VARCHAR(20), + include_in_overview BOOLEAN NOT NULL DEFAULT TRUE, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_financial_accounts_user ON financial_accounts(user_id, active); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..6841f5de 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,42 @@ class AuditLog(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) action = db.Column(db.String(100), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class AccountType(str, Enum): + CHECKING = "CHECKING" + SAVINGS = "SAVINGS" + CREDIT_CARD = "CREDIT_CARD" + INVESTMENT = "INVESTMENT" + LOAN = "LOAN" + CASH = "CASH" + OTHER = "OTHER" + + +class FinancialAccount(db.Model): + """Represents a financial account owned by a user (bank, credit card, etc.).""" + + __tablename__ = "financial_accounts" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + name = db.Column(db.String(200), nullable=False) + account_type = db.Column( + SAEnum(AccountType), nullable=False, default=AccountType.CHECKING + ) + # Current balance (positive = asset, negative = liability e.g. credit card debt) + balance = db.Column(db.Numeric(15, 2), nullable=False, default=0) + currency = db.Column(db.String(10), default="INR", nullable=False) + # Optional institution / bank name + institution = db.Column(db.String(200), nullable=True) + # Optional last-four digits of account / card number + last_four = db.Column(db.String(4), nullable=True) + # Color tag for UI (hex, e.g. "#4f46e5") + color = db.Column(db.String(20), nullable=True) + # Whether to include this account in the multi-account overview + include_in_overview = db.Column(db.Boolean, default=True, nullable=False) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column( + db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..f1fe6164 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,7 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +from .accounts import bp as accounts_bp def register_routes(app: Flask): @@ -18,3 +19,4 @@ def register_routes(app: Flask): app.register_blueprint(categories_bp, url_prefix="/categories") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(dashboard_bp, url_prefix="/dashboard") + app.register_blueprint(accounts_bp, url_prefix="/accounts") diff --git a/packages/backend/app/routes/accounts.py b/packages/backend/app/routes/accounts.py new file mode 100644 index 00000000..88354b60 --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,408 @@ +""" +Routes for managing financial accounts (multi-account dashboard feature). + +Endpoints: + GET /accounts – list all active accounts for the current user + POST /accounts – create a new financial account + GET /accounts/ – get a specific account + PATCH /accounts/ – update a financial account + DELETE /accounts/ – soft-delete (deactivate) an account + GET /accounts/overview – aggregate multi-account dashboard summary +""" + +from datetime import date +from decimal import Decimal, InvalidOperation + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required +from sqlalchemy import extract, func + +from ..extensions import db +from ..models import AccountType, Bill, Expense, FinancialAccount + +bp = Blueprint("accounts", __name__) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_VALID_ACCOUNT_TYPES = {t.value for t in AccountType} + + +def _account_to_dict(acct: FinancialAccount) -> dict: + return { + "id": acct.id, + "name": acct.name, + "account_type": acct.account_type.value if acct.account_type else None, + "balance": float(acct.balance or 0), + "currency": acct.currency, + "institution": acct.institution, + "last_four": acct.last_four, + "color": acct.color, + "include_in_overview": acct.include_in_overview, + "active": acct.active, + "created_at": acct.created_at.isoformat() if acct.created_at else None, + "updated_at": acct.updated_at.isoformat() if acct.updated_at else None, + } + + +def _parse_decimal(value, field: str): + """Return (Decimal, None) on success or (None, error_response) on failure.""" + try: + d = Decimal(str(value)) + return d, None + except (InvalidOperation, TypeError, ValueError): + return None, (jsonify(error=f"invalid {field}"), 400) + + +# --------------------------------------------------------------------------- +# List accounts +# --------------------------------------------------------------------------- + + +@bp.get("") +@jwt_required() +def list_accounts(): + uid = int(get_jwt_identity()) + include_inactive = request.args.get("include_inactive", "false").lower() == "true" + q = db.session.query(FinancialAccount).filter_by(user_id=uid) + if not include_inactive: + q = q.filter(FinancialAccount.active.is_(True)) + accounts = q.order_by(FinancialAccount.created_at.asc()).all() + return jsonify([_account_to_dict(a) for a in accounts]) + + +# --------------------------------------------------------------------------- +# Create account +# --------------------------------------------------------------------------- + + +@bp.post("") +@jwt_required() +def create_account(): + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + name = (data.get("name") or "").strip() + if not name: + return jsonify(error="name is required"), 400 + + acct_type_raw = (data.get("account_type") or "CHECKING").upper() + if acct_type_raw not in _VALID_ACCOUNT_TYPES: + return jsonify( + error=f"account_type must be one of: {', '.join(sorted(_VALID_ACCOUNT_TYPES))}" + ), 400 + + balance_raw = data.get("balance", 0) + balance, err = _parse_decimal(balance_raw, "balance") + if err: + return err + + currency = (data.get("currency") or "INR").upper().strip() + institution = (data.get("institution") or "").strip() or None + last_four = (data.get("last_four") or "").strip() or None + if last_four and (len(last_four) != 4 or not last_four.isdigit()): + return jsonify(error="last_four must be exactly 4 digits"), 400 + color = (data.get("color") or "").strip() or None + include_in_overview = bool(data.get("include_in_overview", True)) + + acct = FinancialAccount( + user_id=uid, + name=name, + account_type=AccountType(acct_type_raw), + balance=balance, + currency=currency, + institution=institution, + last_four=last_four, + color=color, + include_in_overview=include_in_overview, + ) + db.session.add(acct) + db.session.commit() + return jsonify(_account_to_dict(acct)), 201 + + +# --------------------------------------------------------------------------- +# Get single account +# --------------------------------------------------------------------------- + + +@bp.get("/") +@jwt_required() +def get_account(account_id: int): + uid = int(get_jwt_identity()) + acct = db.session.query(FinancialAccount).filter_by( + id=account_id, user_id=uid + ).first() + if not acct: + return jsonify(error="account not found"), 404 + return jsonify(_account_to_dict(acct)) + + +# --------------------------------------------------------------------------- +# Update account +# --------------------------------------------------------------------------- + + +@bp.patch("/") +@jwt_required() +def update_account(account_id: int): + uid = int(get_jwt_identity()) + acct = db.session.query(FinancialAccount).filter_by( + id=account_id, user_id=uid + ).first() + if not acct: + return jsonify(error="account not found"), 404 + + data = request.get_json() or {} + + if "name" in data: + name = (data["name"] or "").strip() + if not name: + return jsonify(error="name cannot be empty"), 400 + acct.name = name + + if "account_type" in data: + acct_type_raw = (data["account_type"] or "").upper() + if acct_type_raw not in _VALID_ACCOUNT_TYPES: + return jsonify( + error=f"account_type must be one of: {', '.join(sorted(_VALID_ACCOUNT_TYPES))}" + ), 400 + acct.account_type = AccountType(acct_type_raw) + + if "balance" in data: + balance, err = _parse_decimal(data["balance"], "balance") + if err: + return err + acct.balance = balance + + if "currency" in data: + acct.currency = (data["currency"] or "INR").upper().strip() + + if "institution" in data: + acct.institution = (data["institution"] or "").strip() or None + + if "last_four" in data: + last_four = (data["last_four"] or "").strip() or None + if last_four and (len(last_four) != 4 or not last_four.isdigit()): + return jsonify(error="last_four must be exactly 4 digits"), 400 + acct.last_four = last_four + + if "color" in data: + acct.color = (data["color"] or "").strip() or None + + if "include_in_overview" in data: + acct.include_in_overview = bool(data["include_in_overview"]) + + db.session.commit() + return jsonify(_account_to_dict(acct)) + + +# --------------------------------------------------------------------------- +# Delete (deactivate) account +# --------------------------------------------------------------------------- + + +@bp.delete("/") +@jwt_required() +def delete_account(account_id: int): + uid = int(get_jwt_identity()) + acct = db.session.query(FinancialAccount).filter_by( + id=account_id, user_id=uid + ).first() + if not acct: + return jsonify(error="account not found"), 404 + acct.active = False + db.session.commit() + return jsonify(message="account deactivated"), 200 + + +# --------------------------------------------------------------------------- +# Multi-account overview +# --------------------------------------------------------------------------- + + +@bp.get("/overview") +@jwt_required() +def multi_account_overview(): + """ + Aggregated financial overview across all active accounts that have + ``include_in_overview=True``. + + Returns: + - accounts: list of account details with individual balances + - totals: net_worth, total_assets, total_liabilities + - monthly_summary: income / expenses for the requested month + - upcoming_bills: next-due bills (across all accounts context) + - category_breakdown: expense breakdown for the requested month + """ + 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 + + year, month = map(int, ym.split("-")) + today = date.today() + + # --- accounts ----------------------------------------------------------- + accounts = ( + db.session.query(FinancialAccount) + .filter_by(user_id=uid, active=True) + .order_by(FinancialAccount.created_at.asc()) + .all() + ) + + overview_accounts = [a for a in accounts if a.include_in_overview] + + # Compute totals + # Liability account types carry negative contribution to net worth + _LIABILITY_TYPES = {AccountType.CREDIT_CARD, AccountType.LOAN} + + total_assets: float = 0.0 + total_liabilities: float = 0.0 + for acct in overview_accounts: + bal = float(acct.balance or 0) + if acct.account_type in _LIABILITY_TYPES: + # A positive balance on a credit card / loan means money owed → liability + total_liabilities += abs(bal) if bal > 0 else 0 + # If balance is negative (paid ahead) treat as 0 liability + else: + if bal > 0: + total_assets += bal + + net_worth = round(total_assets - total_liabilities, 2) + + # --- monthly income / expenses ------------------------------------------ + monthly_income: float = 0.0 + monthly_expenses: float = 0.0 + errors = [] + + try: + 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", + ) + .scalar() + ) + expenses_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", + ) + .scalar() + ) + monthly_income = float(income_q or 0) + monthly_expenses = float(expenses_q or 0) + except Exception: + errors.append("monthly_summary_unavailable") + + # --- upcoming bills ----------------------------------------------------- + upcoming_bills = [] + 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() + ) + upcoming_bills = [ + { + "id": b.id, + "name": b.name, + "amount": float(b.amount), + "currency": b.currency, + "next_due_date": b.next_due_date.isoformat(), + "cadence": b.cadence.value, + } + for b in bills + ] + except Exception: + errors.append("upcoming_bills_unavailable") + + # --- category breakdown ------------------------------------------------- + category_breakdown = [] + try: + from ..models import Category + + cat_rows = ( + db.session.query( + Expense.category_id, + func.coalesce(Category.name, "Uncategorized").label("category_name"), + func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), + ) + .outerjoin( + Category, + (Category.id == Expense.category_id) & (Category.user_id == uid), + ) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.category_id, Category.name) + .order_by(func.sum(Expense.amount).desc()) + .all() + ) + total_cat = sum(float(r.total_amount or 0) for r in cat_rows) + category_breakdown = [ + { + "category_id": r.category_id, + "category_name": r.category_name, + "amount": float(r.total_amount or 0), + "share_pct": ( + round((float(r.total_amount or 0) / total_cat) * 100, 2) + if total_cat > 0 + else 0 + ), + } + for r in cat_rows + ] + except Exception: + errors.append("category_breakdown_unavailable") + + return jsonify( + { + "period": {"month": ym}, + "accounts": [_account_to_dict(a) for a in accounts], + "totals": { + "net_worth": net_worth, + "total_assets": round(total_assets, 2), + "total_liabilities": round(total_liabilities, 2), + }, + "monthly_summary": { + "income": monthly_income, + "expenses": monthly_expenses, + "net_flow": round(monthly_income - monthly_expenses, 2), + }, + "upcoming_bills": upcoming_bills, + "category_breakdown": category_breakdown, + "errors": errors, + } + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _is_valid_month(ym: str) -> bool: + if len(ym) != 7 or ym[4] != "-": + return False + year_part, month_part = ym.split("-") + if not (year_part.isdigit() and month_part.isdigit()): + return False + return 1 <= int(month_part) <= 12 diff --git a/packages/backend/tests/conftest.py b/packages/backend/tests/conftest.py index a7315b8c..b443a012 100644 --- a/packages/backend/tests/conftest.py +++ b/packages/backend/tests/conftest.py @@ -1,9 +1,10 @@ import os import pytest +import fakeredis +import app.extensions as _ext from app import create_app from app.config import Settings from app.extensions import db -from app.extensions import redis_client from app import models # noqa: F401 - ensure models are registered @@ -19,8 +20,24 @@ def _setup_db(app): db.create_all() +@pytest.fixture(autouse=True) +def _fake_redis(monkeypatch): + """Replace the global redis_client with an in-process fake for all tests.""" + fake = fakeredis.FakeRedis(decode_responses=True) + monkeypatch.setattr(_ext, "redis_client", fake) + # Also patch the imported name in every routes module that imported it. + import app.routes.auth as _auth + import app.routes.dashboard as _dashboard + import app.services.cache as _cache + monkeypatch.setattr(_auth, "redis_client", fake) + monkeypatch.setattr(_cache, "redis_client", fake) + # dashboard uses cache module indirectly – patching cache is sufficient + yield fake + fake.flushall() + + @pytest.fixture() -def app_fixture(): +def app_fixture(_fake_redis): # Ensure a clean env for tests os.environ.setdefault("FLASK_ENV", "testing") settings = TestSettings( @@ -31,18 +48,12 @@ def app_fixture(): app = create_app(settings) app.config.update(TESTING=True) _setup_db(app) - try: - redis_client.flushdb() - except Exception: - pass + _fake_redis.flushall() yield app with app.app_context(): db.session.remove() db.drop_all() - try: - redis_client.flushdb() - except Exception: - pass + _fake_redis.flushall() @pytest.fixture() diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 00000000..375376dd --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -0,0 +1,350 @@ +"""Tests for financial accounts (multi-account dashboard) endpoints.""" + +from datetime import date, timedelta + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _create_account(client, auth_header, **kwargs): + payload = { + "name": "Main Checking", + "account_type": "CHECKING", + "balance": 1000.00, + "currency": "INR", + } + payload.update(kwargs) + return client.post("/accounts", json=payload, headers=auth_header) + + +# --------------------------------------------------------------------------- +# CRUD tests +# --------------------------------------------------------------------------- + + +class TestCreateAccount: + def test_create_basic(self, client, auth_header): + r = _create_account(client, auth_header) + assert r.status_code == 201 + data = r.get_json() + assert data["name"] == "Main Checking" + assert data["account_type"] == "CHECKING" + assert data["balance"] == 1000.0 + assert data["currency"] == "INR" + assert data["active"] is True + assert "id" in data + + def test_create_with_all_fields(self, client, auth_header): + r = _create_account( + client, + auth_header, + name="HDFC Savings", + account_type="SAVINGS", + balance=50000, + currency="INR", + institution="HDFC Bank", + last_four="4321", + color="#4f46e5", + include_in_overview=True, + ) + assert r.status_code == 201 + data = r.get_json() + assert data["institution"] == "HDFC Bank" + assert data["last_four"] == "4321" + assert data["color"] == "#4f46e5" + + def test_create_credit_card(self, client, auth_header): + r = _create_account( + client, + auth_header, + name="Axis Credit Card", + account_type="CREDIT_CARD", + balance=5000, # outstanding balance + ) + assert r.status_code == 201 + assert r.get_json()["account_type"] == "CREDIT_CARD" + + def test_create_missing_name_returns_400(self, client, auth_header): + r = client.post( + "/accounts", + json={"account_type": "CHECKING", "balance": 100}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "name" in r.get_json()["error"] + + def test_create_invalid_account_type_returns_400(self, client, auth_header): + r = client.post( + "/accounts", + json={"name": "Test", "account_type": "INVALID"}, + headers=auth_header, + ) + assert r.status_code == 400 + + def test_create_invalid_balance_returns_400(self, client, auth_header): + r = client.post( + "/accounts", + json={"name": "Test", "balance": "not_a_number"}, + headers=auth_header, + ) + assert r.status_code == 400 + + def test_create_invalid_last_four_returns_400(self, client, auth_header): + r = client.post( + "/accounts", + json={"name": "Test", "last_four": "123"}, # only 3 digits + headers=auth_header, + ) + assert r.status_code == 400 + + def test_requires_auth(self, client): + r = client.post("/accounts", json={"name": "Test"}) + assert r.status_code == 401 + + +class TestListAccounts: + def test_list_empty(self, client, auth_header): + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + def test_list_returns_created_accounts(self, client, auth_header): + _create_account(client, auth_header, name="Acc A") + _create_account(client, auth_header, name="Acc B") + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + names = [a["name"] for a in r.get_json()] + assert "Acc A" in names + assert "Acc B" in names + + def test_list_excludes_inactive_by_default(self, client, auth_header): + r = _create_account(client, auth_header, name="Active") + acc_id = r.get_json()["id"] + # Deactivate it + client.delete(f"/accounts/{acc_id}", headers=auth_header) + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + names = [a["name"] for a in r.get_json()] + assert "Active" not in names + + def test_list_includes_inactive_when_flag_set(self, client, auth_header): + r = _create_account(client, auth_header, name="Deactivated") + acc_id = r.get_json()["id"] + client.delete(f"/accounts/{acc_id}", headers=auth_header) + r = client.get("/accounts?include_inactive=true", headers=auth_header) + assert r.status_code == 200 + names = [a["name"] for a in r.get_json()] + assert "Deactivated" in names + + def test_requires_auth(self, client): + r = client.get("/accounts") + assert r.status_code == 401 + + +class TestGetAccount: + def test_get_existing(self, client, auth_header): + r = _create_account(client, auth_header, name="My Account") + acc_id = r.get_json()["id"] + r = client.get(f"/accounts/{acc_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "My Account" + + def test_get_not_found(self, client, auth_header): + r = client.get("/accounts/99999", headers=auth_header) + assert r.status_code == 404 + + def test_cannot_get_other_users_account(self, client, auth_header): + # Create account for user1 then try to access as user2 + r = _create_account(client, auth_header, name="User1 Account") + acc_id = r.get_json()["id"] + + # Register/login as user2 + client.post( + "/auth/register", json={"email": "user2@example.com", "password": "pass2222"} + ) + r2 = client.post( + "/auth/login", json={"email": "user2@example.com", "password": "pass2222"} + ) + token2 = r2.get_json()["access_token"] + h2 = {"Authorization": f"Bearer {token2}"} + + r = client.get(f"/accounts/{acc_id}", headers=h2) + assert r.status_code == 404 + + +class TestUpdateAccount: + def test_update_name(self, client, auth_header): + acc_id = _create_account(client, auth_header).get_json()["id"] + r = client.patch( + f"/accounts/{acc_id}", json={"name": "Updated Name"}, headers=auth_header + ) + assert r.status_code == 200 + assert r.get_json()["name"] == "Updated Name" + + def test_update_balance(self, client, auth_header): + acc_id = _create_account(client, auth_header, balance=500).get_json()["id"] + r = client.patch( + f"/accounts/{acc_id}", json={"balance": 1500}, headers=auth_header + ) + assert r.status_code == 200 + assert r.get_json()["balance"] == 1500.0 + + def test_update_multiple_fields(self, client, auth_header): + acc_id = _create_account(client, auth_header).get_json()["id"] + r = client.patch( + f"/accounts/{acc_id}", + json={ + "name": "Renamed", + "institution": "ICICI", + "color": "#ff0000", + "include_in_overview": False, + }, + headers=auth_header, + ) + assert r.status_code == 200 + data = r.get_json() + assert data["institution"] == "ICICI" + assert data["color"] == "#ff0000" + assert data["include_in_overview"] is False + + def test_update_not_found(self, client, auth_header): + r = client.patch( + "/accounts/99999", json={"name": "Ghost"}, headers=auth_header + ) + assert r.status_code == 404 + + +class TestDeleteAccount: + def test_soft_delete(self, client, auth_header): + acc_id = _create_account(client, auth_header).get_json()["id"] + r = client.delete(f"/accounts/{acc_id}", headers=auth_header) + assert r.status_code == 200 + # Should not appear in default list + r = client.get("/accounts", headers=auth_header) + assert all(a["id"] != acc_id for a in r.get_json()) + # But the record still exists + r = client.get(f"/accounts/{acc_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["active"] is False + + def test_delete_not_found(self, client, auth_header): + r = client.delete("/accounts/99999", headers=auth_header) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Multi-account overview tests +# --------------------------------------------------------------------------- + + +class TestMultiAccountOverview: + def test_overview_empty(self, client, auth_header): + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert "accounts" in data + assert "totals" in data + assert "monthly_summary" in data + assert "upcoming_bills" in data + assert "category_breakdown" in data + + def test_overview_totals_assets(self, client, auth_header): + _create_account(client, auth_header, name="Savings", balance=10000, account_type="SAVINGS") + _create_account(client, auth_header, name="Checking", balance=5000, account_type="CHECKING") + + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + totals = r.get_json()["totals"] + assert totals["total_assets"] == 15000.0 + assert totals["total_liabilities"] == 0.0 + assert totals["net_worth"] == 15000.0 + + def test_overview_totals_with_credit_card_liability(self, client, auth_header): + _create_account( + client, auth_header, name="Savings", balance=20000, account_type="SAVINGS" + ) + _create_account( + client, + auth_header, + name="CC", + balance=5000, # outstanding credit card balance = liability + account_type="CREDIT_CARD", + ) + + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + totals = r.get_json()["totals"] + assert totals["total_assets"] == 20000.0 + assert totals["total_liabilities"] == 5000.0 + assert totals["net_worth"] == 15000.0 + + def test_overview_exclude_account(self, client, auth_header): + _create_account( + client, + auth_header, + name="Hidden", + balance=999999, + include_in_overview=False, + ) + _create_account( + client, auth_header, name="Visible", balance=1000, account_type="CHECKING" + ) + + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + totals = r.get_json()["totals"] + # Hidden account should not contribute to totals + assert totals["total_assets"] == 1000.0 + + def test_overview_monthly_summary(self, client, auth_header): + # Seed income and expense + today = date.today().isoformat() + client.post( + "/expenses", + json={"amount": 5000, "description": "Salary", "date": today, "expense_type": "INCOME"}, + headers=auth_header, + ) + client.post( + "/expenses", + json={"amount": 1000, "description": "Rent", "date": today, "expense_type": "EXPENSE"}, + headers=auth_header, + ) + + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + ms = r.get_json()["monthly_summary"] + assert ms["income"] >= 5000 + assert ms["expenses"] >= 1000 + assert ms["net_flow"] >= 4000 + + def test_overview_upcoming_bills(self, client, auth_header): + due = (date.today() + timedelta(days=5)).isoformat() + client.post( + "/bills", + json={"name": "Netflix", "amount": 199, "next_due_date": due, "cadence": "MONTHLY"}, + headers=auth_header, + ) + + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + bills = r.get_json()["upcoming_bills"] + assert any(b["name"] == "Netflix" for b in bills) + + def test_overview_invalid_month(self, client, auth_header): + r = client.get("/accounts/overview?month=not-a-month", headers=auth_header) + assert r.status_code == 400 + + def test_overview_requires_auth(self, client): + r = client.get("/accounts/overview") + assert r.status_code == 401 + + def test_overview_returns_all_accounts_list(self, client, auth_header): + _create_account(client, auth_header, name="Alpha") + _create_account(client, auth_header, name="Beta") + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + names = [a["name"] for a in r.get_json()["accounts"]] + assert "Alpha" in names + assert "Beta" in names