diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..9c781334 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 { AccountsOverview } from "./pages/AccountsOverview"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> ({ + Button: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes) => ( + + ), +})); + +const listAccountsMock = jest.fn(); +const getAccountsOverviewMock = jest.fn(); +const createAccountMock = jest.fn(); +const updateAccountMock = jest.fn(); +const deleteAccountMock = jest.fn(); + +jest.mock('@/api/accounts', () => ({ + listAccounts: (...args: unknown[]) => listAccountsMock(...args), + getAccountsOverview: (...args: unknown[]) => getAccountsOverviewMock(...args), + createAccount: (...args: unknown[]) => createAccountMock(...args), + updateAccount: (...args: unknown[]) => updateAccountMock(...args), + deleteAccount: (...args: unknown[]) => deleteAccountMock(...args), +})); + +const mockAccounts = [ + { + id: 1, + name: 'Chase Checking', + account_type: 'CHECKING', + institution: 'Chase', + balance: 5000, + currency: 'USD', + is_active: true, + created_at: '2026-01-01T00:00:00', + updated_at: '2026-01-01T00:00:00', + }, + { + id: 2, + name: 'Vanguard 401k', + account_type: 'INVESTMENT', + institution: 'Vanguard', + balance: 50000, + currency: 'USD', + is_active: true, + created_at: '2026-01-01T00:00:00', + updated_at: '2026-01-01T00:00:00', + }, +]; + +const mockOverview = { + total_accounts: 2, + total_balance: 55000, + by_type: [ + { type: 'CHECKING', count: 1, total_balance: 5000, accounts: [mockAccounts[0]] }, + { type: 'INVESTMENT', count: 1, total_balance: 50000, accounts: [mockAccounts[1]] }, + ], + by_currency: [{ currency: 'USD', balance: 55000 }], + by_institution: [ + { institution: 'Chase', count: 1, total_balance: 5000 }, + { institution: 'Vanguard', count: 1, total_balance: 50000 }, + ], +}; + +function renderPage() { + return render( + + + } /> + + , + ); +} + +describe('AccountsOverview integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders accounts overview with accounts and summary data', async () => { + listAccountsMock.mockResolvedValue(mockAccounts); + getAccountsOverviewMock.mockResolvedValue(mockOverview); + + renderPage(); + + await waitFor(() => expect(listAccountsMock).toHaveBeenCalled()); + await waitFor(() => expect(getAccountsOverviewMock).toHaveBeenCalled()); + + expect(screen.getByText(/accounts overview/i)).toBeInTheDocument(); + expect(screen.getByText(/total net worth/i)).toBeInTheDocument(); + expect(screen.getByText(/chase checking/i)).toBeInTheDocument(); + expect(screen.getByText(/vanguard 401k/i)).toBeInTheDocument(); + expect(screen.getByText(/by account type/i)).toBeInTheDocument(); + expect(screen.getByText(/by institution/i)).toBeInTheDocument(); + }); + + it('renders empty state when no accounts exist', async () => { + listAccountsMock.mockResolvedValue([]); + getAccountsOverviewMock.mockResolvedValue({ + total_accounts: 0, + total_balance: 0, + by_type: [], + by_currency: [], + by_institution: [], + }); + + renderPage(); + + await waitFor(() => expect(listAccountsMock).toHaveBeenCalled()); + + expect(screen.getByText(/no accounts yet/i)).toBeInTheDocument(); + expect(screen.getByText(/add your first account/i)).toBeInTheDocument(); + }); + + it('opens add account dialog and submits', async () => { + const user = userEvent.setup(); + listAccountsMock.mockResolvedValue([]); + getAccountsOverviewMock.mockResolvedValue({ + total_accounts: 0, + total_balance: 0, + by_type: [], + by_currency: [], + by_institution: [], + }); + createAccountMock.mockResolvedValue({ id: 1, name: 'New Account', account_type: 'CHECKING', balance: 0 }); + + renderPage(); + + await waitFor(() => expect(listAccountsMock).toHaveBeenCalled()); + + // Click "Add Account" button in the header + const addButtons = screen.getAllByRole('button', { name: /add account/i }); + await user.click(addButtons[0]); + + // Dialog should be open + expect(screen.getByText(/add account/i)).toBeInTheDocument(); + + // Fill form + await user.clear(screen.getByLabelText(/name/i)); + await user.type(screen.getByLabelText(/name/i), 'New Checking'); + + // Submit + await user.click(screen.getByRole('button', { name: /add account$/i })); + + await waitFor(() => expect(createAccountMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'New Checking', account_type: 'CHECKING' }), + )); + }); + + it('shows error state on API failure', async () => { + listAccountsMock.mockRejectedValue(new Error('Network error')); + getAccountsOverviewMock.mockRejectedValue(new Error('Network error')); + + renderPage(); + + await waitFor(() => expect(screen.getByText(/network error/i)).toBeInTheDocument()); + }); +}); diff --git a/app/src/api/accounts.ts b/app/src/api/accounts.ts new file mode 100644 index 00000000..5d01fa0e --- /dev/null +++ b/app/src/api/accounts.ts @@ -0,0 +1,66 @@ +import { api } from './client'; + +export type AccountType = 'CHECKING' | 'SAVINGS' | 'CREDIT_CARD' | 'INVESTMENT' | 'LOAN' | 'OTHER'; + +export type FinancialAccount = { + id: number; + name: string; + account_type: AccountType; + institution: string | null; + balance: number; + currency: string; + is_active: boolean; + created_at: string; + updated_at: string; +}; + +export type AccountCreate = { + name: string; + account_type: AccountType; + institution?: string; + balance?: number; + currency?: string; + is_active?: boolean; +}; + +export type AccountUpdate = Partial; + +export type AccountsByType = { + type: AccountType; + count: number; + total_balance: number; + accounts: FinancialAccount[]; +}; + +export type AccountOverview = { + total_accounts: number; + total_balance: number; + by_type: AccountsByType[]; + by_currency: { currency: string; balance: number }[]; + by_institution: { institution: string; count: number; total_balance: number }[]; +}; + +export async function listAccounts(includeInactive = false): Promise { + const qs = includeInactive ? '?include_inactive=true' : ''; + return api(`/accounts${qs}`); +} + +export async function getAccount(id: number): Promise { + return api(`/accounts/${id}`); +} + +export async function createAccount(payload: AccountCreate): Promise { + return api('/accounts', { method: 'POST', body: payload }); +} + +export async function updateAccount(id: number, payload: AccountUpdate): Promise { + return api(`/accounts/${id}`, { method: 'PATCH', body: payload }); +} + +export async function deleteAccount(id: number): Promise<{ message: string }> { + return api<{ message: string }>(`/accounts/${id}`, { method: 'DELETE' }); +} + +export async function getAccountsOverview(): Promise { + return api('/accounts/overview'); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..47168406 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -12,6 +12,7 @@ const navigation = [ { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, + { name: 'Accounts', href: '/accounts' }, { name: 'Analytics', href: '/analytics' }, ]; diff --git a/app/src/pages/AccountsOverview.tsx b/app/src/pages/AccountsOverview.tsx new file mode 100644 index 00000000..492cdecf --- /dev/null +++ b/app/src/pages/AccountsOverview.tsx @@ -0,0 +1,384 @@ +import { useEffect, useState } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardDescription, + FinancialCardHeader, + FinancialCardTitle, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { + Landmark, + Plus, + Wallet, + CreditCard, + PiggyBank, + TrendingUp, + Building2, + MoreHorizontal, + Pencil, + Trash2, +} from 'lucide-react'; +import { + listAccounts, + createAccount, + updateAccount, + deleteAccount, + getAccountsOverview, + type FinancialAccount, + type AccountOverview, + type AccountCreate, + type AccountType, +} from '@/api/accounts'; +import { formatMoney } from '@/lib/currency'; +import { useToast } from '@/components/ui/use-toast'; + +const ACCOUNT_TYPE_LABELS: Record = { + CHECKING: 'Checking', + SAVINGS: 'Savings', + CREDIT_CARD: 'Credit Card', + INVESTMENT: 'Investment', + LOAN: 'Loan', + OTHER: 'Other', +}; + +const ACCOUNT_TYPE_ICONS: Record = { + CHECKING: Wallet, + SAVINGS: PiggyBank, + CREDIT_CARD: CreditCard, + INVESTMENT: TrendingUp, + LOAN: Landmark, + OTHER: MoreHorizontal, +}; + +const ACCOUNT_TYPES: AccountType[] = ['CHECKING', 'SAVINGS', 'CREDIT_CARD', 'INVESTMENT', 'LOAN', 'OTHER']; + +type FormState = { + name: string; + account_type: AccountType; + institution: string; + balance: string; + currency: string; +}; + +const EMPTY_FORM: FormState = { + name: '', + account_type: 'CHECKING', + institution: '', + balance: '0', + currency: '', +}; + +export function AccountsOverview() { + const { toast } = useToast(); + const [accounts, setAccounts] = useState([]); + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Dialog state + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [submitting, setSubmitting] = useState(false); + + const fetchData = async () => { + setLoading(true); + setError(null); + try { + const [accts, ov] = await Promise.all([listAccounts(), getAccountsOverview()]); + setAccounts(accts); + setOverview(ov); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to load accounts'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void fetchData(); + }, []); + + const openCreate = () => { + setEditingId(null); + setForm(EMPTY_FORM); + setDialogOpen(true); + }; + + const openEdit = (account: FinancialAccount) => { + setEditingId(account.id); + setForm({ + name: account.name, + account_type: account.account_type, + institution: account.institution || '', + balance: String(account.balance), + currency: account.currency, + }); + setDialogOpen(true); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!form.name.trim()) { + toast({ title: 'Validation error', description: 'Account name is required.', variant: 'destructive' }); + return; + } + setSubmitting(true); + try { + const payload: AccountCreate = { + name: form.name.trim(), + account_type: form.account_type, + institution: form.institution.trim() || undefined, + balance: parseFloat(form.balance) || 0, + currency: form.currency || undefined, + }; + if (editingId) { + await updateAccount(editingId, payload); + toast({ title: 'Account updated' }); + } else { + await createAccount(payload); + toast({ title: 'Account created' }); + } + setDialogOpen(false); + await fetchData(); + } catch (err: unknown) { + toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to save account', variant: 'destructive' }); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (id: number) => { + try { + await deleteAccount(id); + toast({ title: 'Account deleted' }); + await fetchData(); + } catch (err: unknown) { + toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to delete account', variant: 'destructive' }); + } + }; + + const totalBalance = overview?.total_balance ?? 0; + + // Group accounts by type for display + const accountsByType = ACCOUNT_TYPES.map((type) => ({ + type, + label: ACCOUNT_TYPE_LABELS[type], + Icon: ACCOUNT_TYPE_ICONS[type], + accounts: accounts.filter((a) => a.account_type === type), + })).filter((g) => g.accounts.length > 0); + + return ( +
+
+
+
+

Accounts Overview

+

+ {overview ? `${overview.total_accounts} account(s) across all institutions` : 'Manage your financial accounts'} +

+
+ +
+
+ + {error &&
{error}
} + + {/* Net Worth Card */} +
+ + +
+ Total Net Worth + +
+
+ +
{loading ? '...' : formatMoney(totalBalance)}
+

Across all active accounts

+
+
+ + {/* Balance by Type Chart */} + + + By Account Type + + + {!overview || overview.by_type.length === 0 ? ( +
No accounts yet.
+ ) : ( +
+ {overview.by_type.map((group) => { + const pct = totalBalance !== 0 ? Math.abs(group.total_balance / totalBalance) * 100 : 0; + return ( +
+
+ {ACCOUNT_TYPE_LABELS[group.type as AccountType]} + {formatMoney(group.total_balance)} ({group.count}) +
+
+
+
+
+ ); + })} +
+ )} + + + + {/* By Institution */} + + + By Institution + + + {!overview || overview.by_institution.length === 0 ? ( +
No accounts yet.
+ ) : ( +
+ {overview.by_institution.map((inst) => ( +
+
+ + {inst.institution} +
+ {formatMoney(inst.total_balance)} ({inst.count}) +
+ ))} +
+ )} +
+
+
+ + {/* Account Cards grouped by type */} + {accounts.length === 0 && !loading && ( + + + +

No accounts yet

+

Add your bank accounts, credit cards, and investments to see a unified view.

+ +
+
+ )} + + {accountsByType.map((group) => ( +
+
+ +

{group.label}

+ ({group.accounts.length}) +
+
+ {group.accounts.map((account) => ( + + +
+ {account.name} +
+ + +
+
+ {account.institution && ( + {account.institution} + )} +
+ +
{formatMoney(account.balance, account.currency)}
+
{account.currency}
+
+
+ ))} +
+
+ ))} + + {/* Add/Edit Dialog - using native dialog for simplicity following existing patterns */} + {dialogOpen && ( +
setDialogOpen(false)}> +
e.stopPropagation()}> +

{editingId ? 'Edit Account' : 'Add Account'}

+
void handleSubmit(e)} className="space-y-4"> +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="e.g. Chase Checking" + required + /> +
+
+ + +
+
+ + setForm((f) => ({ ...f, institution: e.target.value }))} + placeholder="e.g. Chase, Vanguard" + /> +
+
+ + setForm((f) => ({ ...f, balance: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, currency: e.target.value }))} + placeholder="Defaults to your preference" + /> +
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..d078508b 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -117,6 +117,26 @@ CREATE TABLE IF NOT EXISTS user_subscriptions ( started_at TIMESTAMP NOT NULL DEFAULT NOW() ); +DO $$ BEGIN + CREATE TYPE account_type AS ENUM ('CHECKING','SAVINGS','CREDIT_CARD','INVESTMENT','LOAN','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, + institution VARCHAR(200), + balance NUMERIC(14,2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + is_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, is_active); + CREATE TABLE IF NOT EXISTS audit_logs ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE SET NULL, diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..30b85632 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,29 @@ class UserSubscription(db.Model): started_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" + OTHER = "OTHER" + + +class FinancialAccount(db.Model): + __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) + institution = db.Column(db.String(200), nullable=True) + balance = db.Column(db.Numeric(14, 2), default=0, nullable=False) + currency = db.Column(db.String(10), default="INR", nullable=False) + is_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) + + class AuditLog(db.Model): __tablename__ = "audit_logs" id = db.Column(db.Integer, primary_key=True) 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..db31237c --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,213 @@ +from decimal import Decimal, InvalidOperation +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import FinancialAccount, AccountType, User +from ..services.cache import cache_delete_patterns, cache_get, cache_set +import logging + +bp = Blueprint("accounts", __name__) +logger = logging.getLogger("finmind.accounts") + +VALID_ACCOUNT_TYPES = {t.value for t in AccountType} + + +def _serialize(a: FinancialAccount) -> dict: + return { + "id": a.id, + "name": a.name, + "account_type": a.account_type.value, + "institution": a.institution, + "balance": float(a.balance), + "currency": a.currency, + "is_active": a.is_active, + "created_at": a.created_at.isoformat(), + "updated_at": a.updated_at.isoformat(), + } + + +def _invalidate_cache(uid: int): + cache_delete_patterns( + [f"user:{uid}:accounts*", f"user:{uid}:accounts_overview*"] + ) + + +def _validate_create(data: dict) -> str | None: + if not data.get("name") or not str(data["name"]).strip(): + return "name is required" + if not data.get("account_type"): + return "account_type is required" + if data["account_type"] not in VALID_ACCOUNT_TYPES: + return f"account_type must be one of: {', '.join(sorted(VALID_ACCOUNT_TYPES))}" + if "balance" in data: + try: + Decimal(str(data["balance"])) + except (InvalidOperation, TypeError, ValueError): + return "balance must be a valid number" + return None + + +@bp.get("") +@jwt_required() +def list_accounts(): + uid = int(get_jwt_identity()) + show_inactive = request.args.get("include_inactive", "").lower() == "true" + + q = db.session.query(FinancialAccount).filter_by(user_id=uid) + if not show_inactive: + q = q.filter_by(is_active=True) + items = q.order_by(FinancialAccount.account_type, FinancialAccount.name).all() + + logger.info("List accounts user=%s count=%s", uid, len(items)) + return jsonify([_serialize(a) for a in items]) + + +@bp.post("") +@jwt_required() +def create_account(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + data = request.get_json() or {} + + err = _validate_create(data) + if err: + return jsonify(error=err), 400 + + account = FinancialAccount( + user_id=uid, + name=str(data["name"]).strip(), + account_type=AccountType(data["account_type"]), + institution=str(data.get("institution", "")).strip() or None, + balance=Decimal(str(data.get("balance", 0))), + currency=data.get("currency") or (user.preferred_currency if user else "INR"), + is_active=bool(data.get("is_active", True)), + ) + db.session.add(account) + db.session.commit() + logger.info("Created account id=%s user=%s name=%s", account.id, uid, account.name) + _invalidate_cache(uid) + return jsonify(_serialize(account)), 201 + + +@bp.get("/") +@jwt_required() +def get_account(account_id: int): + uid = int(get_jwt_identity()) + account = db.session.get(FinancialAccount, account_id) + if not account or account.user_id != uid: + return jsonify(error="not found"), 404 + return jsonify(_serialize(account)) + + +@bp.patch("/") +@jwt_required() +def update_account(account_id: int): + uid = int(get_jwt_identity()) + account = db.session.get(FinancialAccount, account_id) + if not account or account.user_id != uid: + return jsonify(error="not found"), 404 + + data = request.get_json() or {} + + if "name" in data: + name = str(data["name"]).strip() + if not name: + return jsonify(error="name cannot be empty"), 400 + account.name = name + + if "account_type" in data: + if data["account_type"] not in VALID_ACCOUNT_TYPES: + return jsonify(error=f"account_type must be one of: {', '.join(sorted(VALID_ACCOUNT_TYPES))}"), 400 + account.account_type = AccountType(data["account_type"]) + + if "institution" in data: + account.institution = str(data["institution"]).strip() or None + + if "balance" in data: + try: + account.balance = Decimal(str(data["balance"])) + except (InvalidOperation, TypeError, ValueError): + return jsonify(error="balance must be a valid number"), 400 + + if "currency" in data: + account.currency = data["currency"] + + if "is_active" in data: + account.is_active = bool(data["is_active"]) + + db.session.commit() + logger.info("Updated account id=%s user=%s", account.id, uid) + _invalidate_cache(uid) + return jsonify(_serialize(account)) + + +@bp.delete("/") +@jwt_required() +def delete_account(account_id: int): + uid = int(get_jwt_identity()) + account = db.session.get(FinancialAccount, account_id) + if not account or account.user_id != uid: + return jsonify(error="not found"), 404 + + db.session.delete(account) + db.session.commit() + logger.info("Deleted account id=%s user=%s", account.id, uid) + _invalidate_cache(uid) + return jsonify(message="deleted") + + +@bp.get("/overview") +@jwt_required() +def accounts_overview(): + uid = int(get_jwt_identity()) + + cache_key = f"user:{uid}:accounts_overview" + cached = cache_get(cache_key) + if cached: + return jsonify(cached) + + accounts = ( + db.session.query(FinancialAccount) + .filter_by(user_id=uid, is_active=True) + .all() + ) + + total_balance = float(sum(a.balance for a in accounts)) + + # Group by type + by_type: dict[str, dict] = {} + for a in accounts: + t = a.account_type.value + if t not in by_type: + by_type[t] = {"type": t, "count": 0, "total_balance": 0.0, "accounts": []} + by_type[t]["count"] += 1 + by_type[t]["total_balance"] += float(a.balance) + by_type[t]["accounts"].append(_serialize(a)) + + # Group by currency + by_currency: dict[str, float] = {} + for a in accounts: + by_currency[a.currency] = by_currency.get(a.currency, 0.0) + float(a.balance) + + # Group by institution + by_institution: dict[str, dict] = {} + for a in accounts: + inst = a.institution or "Other" + if inst not in by_institution: + by_institution[inst] = {"institution": inst, "count": 0, "total_balance": 0.0} + by_institution[inst]["count"] += 1 + by_institution[inst]["total_balance"] += float(a.balance) + + result = { + "total_accounts": len(accounts), + "total_balance": total_balance, + "by_type": list(by_type.values()), + "by_currency": [ + {"currency": c, "balance": b} for c, b in sorted(by_currency.items()) + ], + "by_institution": list(by_institution.values()), + } + + cache_set(cache_key, result, ttl=300) + logger.info("Overview user=%s accounts=%s total=%.2f", uid, len(accounts), total_balance) + return jsonify(result) diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 00000000..63d5369a --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -0,0 +1,237 @@ +def test_accounts_crud(client, auth_header): + # List empty + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Create + payload = { + "name": "Chase Checking", + "account_type": "CHECKING", + "institution": "Chase", + "balance": 5000.50, + "currency": "USD", + } + r = client.post("/accounts", json=payload, headers=auth_header) + assert r.status_code == 201 + created = r.get_json() + account_id = created["id"] + assert created["name"] == "Chase Checking" + assert created["account_type"] == "CHECKING" + assert created["institution"] == "Chase" + assert created["balance"] == 5000.50 + assert created["currency"] == "USD" + assert created["is_active"] is True + + # Get single + r = client.get(f"/accounts/{account_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "Chase Checking" + + # Update + r = client.patch( + f"/accounts/{account_id}", + json={"name": "Chase Primary", "balance": 6000}, + headers=auth_header, + ) + assert r.status_code == 200 + updated = r.get_json() + assert updated["name"] == "Chase Primary" + assert updated["balance"] == 6000.0 + + # List should have 1 + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 1 + + # Delete + r = client.delete(f"/accounts/{account_id}", headers=auth_header) + assert r.status_code == 200 + + # List should be empty again + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_account_create_validation(client, auth_header): + # Missing name + r = client.post("/accounts", json={"account_type": "CHECKING"}, headers=auth_header) + assert r.status_code == 400 + assert "name" in r.get_json()["error"] + + # Missing account_type + r = client.post("/accounts", json={"name": "Test"}, headers=auth_header) + assert r.status_code == 400 + assert "account_type" in r.get_json()["error"] + + # Invalid account_type + r = client.post( + "/accounts", + json={"name": "Test", "account_type": "INVALID"}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "account_type" in r.get_json()["error"] + + # Invalid balance + r = client.post( + "/accounts", + json={"name": "Test", "account_type": "CHECKING", "balance": "not_a_number"}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "balance" in r.get_json()["error"] + + +def test_account_not_found(client, auth_header): + r = client.get("/accounts/99999", headers=auth_header) + assert r.status_code == 404 + + r = client.patch("/accounts/99999", json={"name": "X"}, headers=auth_header) + assert r.status_code == 404 + + r = client.delete("/accounts/99999", headers=auth_header) + assert r.status_code == 404 + + +def test_account_update_validation(client, auth_header): + # Create an account first + r = client.post( + "/accounts", + json={"name": "Test", "account_type": "SAVINGS", "balance": 100}, + headers=auth_header, + ) + assert r.status_code == 201 + account_id = r.get_json()["id"] + + # Empty name + r = client.patch( + f"/accounts/{account_id}", + json={"name": " "}, + headers=auth_header, + ) + assert r.status_code == 400 + + # Invalid type + r = client.patch( + f"/accounts/{account_id}", + json={"account_type": "INVALID"}, + headers=auth_header, + ) + assert r.status_code == 400 + + # Invalid balance + r = client.patch( + f"/accounts/{account_id}", + json={"balance": "abc"}, + headers=auth_header, + ) + assert r.status_code == 400 + + +def test_accounts_overview(client, auth_header): + # Create multiple accounts + accounts = [ + {"name": "Checking 1", "account_type": "CHECKING", "institution": "Chase", "balance": 5000, "currency": "USD"}, + {"name": "Savings 1", "account_type": "SAVINGS", "institution": "Chase", "balance": 10000, "currency": "USD"}, + {"name": "Credit Card", "account_type": "CREDIT_CARD", "institution": "Amex", "balance": -2000, "currency": "USD"}, + {"name": "INR Savings", "account_type": "SAVINGS", "institution": "SBI", "balance": 50000, "currency": "INR"}, + ] + for a in accounts: + r = client.post("/accounts", json=a, headers=auth_header) + assert r.status_code == 201 + + # Get overview + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + overview = r.get_json() + + assert overview["total_accounts"] == 4 + assert overview["total_balance"] == 63000.0 # 5000 + 10000 - 2000 + 50000 + + # Check by_type + type_map = {t["type"]: t for t in overview["by_type"]} + assert "CHECKING" in type_map + assert type_map["CHECKING"]["count"] == 1 + assert type_map["SAVINGS"]["count"] == 2 + assert type_map["SAVINGS"]["total_balance"] == 60000.0 + assert type_map["CREDIT_CARD"]["count"] == 1 + + # Check by_currency + currency_map = {c["currency"]: c["balance"] for c in overview["by_currency"]} + assert currency_map["USD"] == 13000.0 # 5000 + 10000 - 2000 + assert currency_map["INR"] == 50000.0 + + # Check by_institution + inst_map = {i["institution"]: i for i in overview["by_institution"]} + assert inst_map["Chase"]["count"] == 2 + assert inst_map["Amex"]["count"] == 1 + assert inst_map["SBI"]["count"] == 1 + + +def test_accounts_overview_empty(client, auth_header): + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + overview = r.get_json() + assert overview["total_accounts"] == 0 + assert overview["total_balance"] == 0 + assert overview["by_type"] == [] + assert overview["by_currency"] == [] + assert overview["by_institution"] == [] + + +def test_account_defaults_to_user_currency(client, auth_header): + # Set user preferred currency + r = client.patch( + "/auth/me", json={"preferred_currency": "EUR"}, headers=auth_header + ) + assert r.status_code == 200 + + r = client.post( + "/accounts", + json={"name": "Euro Account", "account_type": "CHECKING"}, + headers=auth_header, + ) + assert r.status_code == 201 + assert r.get_json()["currency"] == "EUR" + + +def test_account_types(client, auth_header): + valid_types = ["CHECKING", "SAVINGS", "CREDIT_CARD", "INVESTMENT", "LOAN", "OTHER"] + for account_type in valid_types: + r = client.post( + "/accounts", + json={"name": f"Test {account_type}", "account_type": account_type}, + headers=auth_header, + ) + assert r.status_code == 201, f"Failed for type {account_type}" + assert r.get_json()["account_type"] == account_type + + +def test_inactive_accounts_filtered(client, auth_header): + # Create and deactivate + r = client.post( + "/accounts", + json={"name": "Old Account", "account_type": "CHECKING", "balance": 100}, + headers=auth_header, + ) + assert r.status_code == 201 + account_id = r.get_json()["id"] + + r = client.patch( + f"/accounts/{account_id}", + json={"is_active": False}, + headers=auth_header, + ) + assert r.status_code == 200 + + # Default list excludes inactive + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 0 + + # include_inactive shows it + r = client.get("/accounts?include_inactive=true", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 1