diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..53e7c08d 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 Accounts from "./pages/Accounts"; 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..f51b880c --- /dev/null +++ b/app/src/api/accounts.ts @@ -0,0 +1,74 @@ +import { api } from './client'; + +export type AccountType = 'BANK' | 'CREDIT' | 'CASH' | 'INVESTMENT' | 'WALLET' | 'OTHER'; + +export interface Account { + id: number; + name: string; + account_type: AccountType; + currency: string; + initial_balance: number; + color: string | null; + active: boolean; + created_at: string; + updated_at: string; +} + +export interface AccountWithStats extends Account { + income: number; + expenses: number; + balance: number; +} + +export interface OverviewSummary { + total_assets: number; + total_liabilities: number; + net_worth: number; + unassigned_income: number; + unassigned_expenses: number; + account_count: number; +} + +export interface AccountOverview { + accounts: AccountWithStats[]; + summary: OverviewSummary; +} + +export function listAccounts(): Promise { + return api('/accounts'); +} + +export function getAccount(id: number): Promise { + return api(`/accounts/${id}`); +} + +export function createAccount(payload: { + name: string; + account_type: AccountType; + currency?: string; + initial_balance?: number; + color?: string; +}): Promise { + return api('/accounts', { method: 'POST', body: payload }); +} + +export function updateAccount( + id: number, + payload: Partial<{ + name: string; + account_type: AccountType; + currency: string; + initial_balance: number; + color: string; + }>, +): Promise { + return api(`/accounts/${id}`, { method: 'PATCH', body: payload }); +} + +export function deleteAccount(id: number): Promise { + return api(`/accounts/${id}`, { method: 'DELETE' }); +} + +export function getOverview(): Promise { + return api('/accounts/overview'); +} 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/Accounts.tsx b/app/src/pages/Accounts.tsx new file mode 100644 index 00000000..919e2680 --- /dev/null +++ b/app/src/pages/Accounts.tsx @@ -0,0 +1,467 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { useToast } from '@/hooks/use-toast'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardHeader, + FinancialCardTitle, +} from '@/components/ui/financial-card'; +import { + getOverview, + createAccount, + updateAccount, + deleteAccount, + type AccountType, + type AccountWithStats, + type OverviewSummary, +} from '@/api/accounts'; +import { Plus, Pencil, Trash2, Wallet, TrendingUp, TrendingDown, Hash } from 'lucide-react'; +import { formatMoney } from '@/lib/currency'; + +const ACCOUNT_TYPES: AccountType[] = ['BANK', 'CREDIT', 'CASH', 'INVESTMENT', 'WALLET', 'OTHER']; + +const TYPE_LABELS: Record = { + BANK: 'Bank', + CREDIT: 'Credit', + CASH: 'Cash', + INVESTMENT: 'Investment', + WALLET: 'Wallet', + OTHER: 'Other', +}; + +const TYPE_COLORS: Record = { + BANK: 'bg-blue-100 text-blue-700', + CREDIT: 'bg-red-100 text-red-700', + CASH: 'bg-green-100 text-green-700', + INVESTMENT: 'bg-purple-100 text-purple-700', + WALLET: 'bg-yellow-100 text-yellow-700', + OTHER: 'bg-gray-100 text-gray-700', +}; + +interface AccountFormState { + name: string; + account_type: AccountType; + currency: string; + initial_balance: string; + color: string; +} + +const DEFAULT_FORM: AccountFormState = { + name: '', + account_type: 'BANK', + currency: 'INR', + initial_balance: '0', + color: '', +}; + +function currency(n: number, code?: string) { + return formatMoney(Number(n || 0), code); +} + +export function Accounts() { + const { toast } = useToast(); + const getErrorMessage = (error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback; + + const [accounts, setAccounts] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [open, setOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [form, setForm] = useState(DEFAULT_FORM); + const [saving, setSaving] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getOverview(); + setAccounts(data.accounts); + setSummary(data.summary); + } catch (err: unknown) { + const msg = getErrorMessage(err, 'Failed to load accounts'); + setError(msg); + toast({ title: 'Failed to load accounts', description: msg }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + function openCreate() { + setEditTarget(null); + setForm(DEFAULT_FORM); + setOpen(true); + } + + function openEdit(account: AccountWithStats) { + setEditTarget(account); + setForm({ + name: account.name, + account_type: account.account_type, + currency: account.currency, + initial_balance: String(account.initial_balance), + color: account.color ?? '', + }); + setOpen(true); + } + + function setField(key: K, value: AccountFormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + async function onSave() { + if (!form.name.trim()) return; + setSaving(true); + try { + const payload = { + name: form.name.trim(), + account_type: form.account_type, + currency: form.currency.trim() || 'INR', + initial_balance: parseFloat(form.initial_balance) || 0, + color: form.color.trim() || undefined, + }; + if (editTarget) { + await updateAccount(editTarget.id, payload); + toast({ title: 'Account updated' }); + } else { + await createAccount(payload); + toast({ title: 'Account created' }); + } + setOpen(false); + setForm(DEFAULT_FORM); + setEditTarget(null); + await refresh(); + } catch (err: unknown) { + toast({ title: 'Failed to save account', description: getErrorMessage(err, 'Please try again.') }); + } finally { + setSaving(false); + } + } + + async function onDelete(id: number) { + setSaving(true); + try { + await deleteAccount(id); + toast({ title: 'Account deleted' }); + await refresh(); + } catch (err: unknown) { + toast({ title: 'Failed to delete account', description: getErrorMessage(err, 'Please try again.') }); + } finally { + setSaving(false); + } + } + + const netWorthPositive = (summary?.net_worth ?? 0) >= 0; + + return ( + + + + + Accounts + Manage your financial accounts and see the big picture. + + + + + + New Account + + + + + {editTarget ? 'Edit Account' : 'New Account'} + + {editTarget ? 'Update your account details.' : 'Add a new financial account.'} + + + + + Name * + setField('name', e.target.value)} + placeholder="e.g. HDFC Savings" + /> + + + + Account Type + setField('account_type', e.target.value as AccountType)} + > + {ACCOUNT_TYPES.map((t) => ( + + {TYPE_LABELS[t]} + + ))} + + + + Currency + setField('currency', e.target.value)} + placeholder="INR" + /> + + + + + Initial Balance + setField('initial_balance', e.target.value)} + placeholder="0" + /> + + + Color (optional) + + setField('color', e.target.value)} + /> + setField('color', e.target.value)} + placeholder="#6366f1" + /> + + + + + + setOpen(false)} disabled={saving}> + Cancel + + + {editTarget ? 'Update' : 'Create'} + + + + + + + + {error && {error}} + + {/* Summary Cards */} + + + + + + Total Assets + + + + + + + {loading ? '...' : currency(summary?.total_assets ?? 0)} + + All positive balances + + + + + + + + Total Liabilities + + + + + + + {loading ? '...' : currency(summary?.total_liabilities ?? 0)} + + All negative balances + + + + + + + + Net Worth + + + + + + + {loading ? '...' : currency(summary?.net_worth ?? 0)} + + Assets minus liabilities + + + + + + + + Account Count + + + + + + + {loading ? '...' : (summary?.account_count ?? 0)} + + Active accounts + + + + + {/* Accounts List */} + + + Your Accounts + + + {loading ? ( + + {[1, 2, 3].map((i) => ( + + ))} + + ) : accounts.length === 0 ? ( + + + + No accounts yet — add your first account + + + + Add Account + + + ) : ( + + {accounts.map((account) => ( + + + {account.color && ( + + )} + + + {account.name} + + {TYPE_LABELS[account.account_type]} + + {account.currency} + + + Income: {currency(account.income, account.currency)} · Expenses:{' '} + {currency(account.expenses, account.currency)} + + + + + = 0 ? 'text-success' : 'text-destructive' + }`} + > + {currency(account.balance, account.currency)} + + + openEdit(account)} + aria-label="Edit account" + > + + + + + + + + + + + Delete account? + + "{account.name}" will be deactivated. This cannot be undone. + + + + Cancel + onDelete(account.id)}> + Delete + + + + + + + + ))} + + )} + + + + ); +} + +export default Accounts; diff --git a/packages/backend/app/db/migrations/005_accounts.sql b/packages/backend/app/db/migrations/005_accounts.sql new file mode 100644 index 00000000..b29b9fcb --- /dev/null +++ b/packages/backend/app/db/migrations/005_accounts.sql @@ -0,0 +1,43 @@ +-- Migration 005: Multi-account financial overview +-- Issue #132 +-- Apply with: psql $DATABASE_URL -f migrations/005_accounts.sql + +CREATE TABLE IF NOT EXISTS accounts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + account_type VARCHAR(20) NOT NULL DEFAULT 'BANK', + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + initial_balance NUMERIC(12,2) NOT NULL DEFAULT 0, + color VARCHAR(20) NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Trigger to keep updated_at current on every row update +CREATE OR REPLACE FUNCTION _accounts_set_updated_at() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS accounts_set_updated_at ON accounts; +CREATE TRIGGER accounts_set_updated_at + BEFORE UPDATE ON accounts + FOR EACH ROW EXECUTE FUNCTION _accounts_set_updated_at(); + +-- If the table already exists (re-run scenario), add the column idempotently. +ALTER TABLE accounts + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NOT NULL DEFAULT NOW(); + +CREATE INDEX IF NOT EXISTS idx_accounts_user ON accounts (user_id, active); + +-- Add account_id to expenses (nullable, backward-compatible) +ALTER TABLE expenses + ADD COLUMN IF NOT EXISTS account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_expenses_account ON expenses (account_id) + WHERE account_id IS NOT NULL; diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..cfb37d82 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -41,6 +41,7 @@ class Expense(db.Model): db.Integer, db.ForeignKey("recurring_expenses.id"), nullable=True ) created_at = db.Column(db.DateTime, default=datetime.now, nullable=False) + account_id = db.Column(db.Integer, db.ForeignKey("accounts.id"), nullable=True) class RecurringCadence(str, Enum): @@ -133,3 +134,30 @@ 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) + + +# ── Accounts ─────────────────────────────────────────────────────────────────── + +class AccountType(str, Enum): + BANK = "BANK" + CREDIT = "CREDIT" + CASH = "CASH" + INVESTMENT = "INVESTMENT" + WALLET = "WALLET" + OTHER = "OTHER" + + +class Account(db.Model): + """A financial account (bank, credit card, cash, etc.).""" + + __tablename__ = "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(db.String(20), default=AccountType.BANK.value, nullable=False) + currency = db.Column(db.String(10), default="INR", nullable=False) + initial_balance = db.Column(db.Numeric(12, 2), default=0, nullable=False) + color = db.Column(db.String(20), nullable=True) # hex colour for UI + 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..2d428170 --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,268 @@ +""" +Multi-account financial overview (Issue #132). + +Endpoints: + GET /accounts → list accounts + POST /accounts → create account + GET /accounts/ → get account detail + PATCH /accounts/ → update account + DELETE /accounts/ → delete account (soft: deactivate) + GET /accounts/overview → aggregate view across all accounts +""" + +import logging +from datetime import date, datetime +from decimal import Decimal, InvalidOperation + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required +from sqlalchemy import case, func + +from ..extensions import db +from ..models import Account, AccountType, Expense + +bp = Blueprint("accounts", __name__) +logger = logging.getLogger("finmind.accounts") + +_VALID_TYPES = {t.value for t in AccountType} + + +# ───────────────────────────────────────────────────────────────────────────── +# CRUD +# ───────────────────────────────────────────────────────────────────────────── + + +@bp.get("") +@jwt_required() +def list_accounts(): + uid = int(get_jwt_identity()) + accounts = ( + db.session.query(Account) + .filter_by(user_id=uid, active=True) + .order_by(Account.created_at.asc()) + .all() + ) + return jsonify([_account_to_dict(a) for a in accounts]) + + +@bp.post("") +@jwt_required() +def create_account(): + uid = int(get_jwt_identity()) + data = request.get_json(silent=True) or {} + + name = (data.get("name") or "").strip() + if not name: + return jsonify(error="name is required"), 400 + + account_type = (data.get("account_type") or AccountType.BANK.value).upper() + if account_type not in _VALID_TYPES: + return jsonify(error=f"account_type must be one of: {sorted(_VALID_TYPES)}"), 400 + + initial = _parse_amount(data.get("initial_balance", 0)) + if initial is None: + return jsonify(error="invalid initial_balance"), 400 + + account = Account( + user_id=uid, + name=name, + account_type=account_type, + currency=(data.get("currency") or "INR").upper()[:10], + initial_balance=initial, + color=data.get("color") or None, + ) + db.session.add(account) + db.session.commit() + logger.info("Created account id=%s user=%s name=%s type=%s", account.id, uid, name, account_type) + return jsonify(_account_to_dict(account)), 201 + + +@bp.get("/overview") +@jwt_required() +def overview(): + """ + Aggregate financial overview across all active accounts. + Returns per-account balance + totals. + + Uses a single GROUP BY query to avoid the N+1 problem: instead of issuing + two separate SUM queries per account, all income/expense aggregates are + fetched in one shot and joined back to the account list. + """ + uid = int(get_jwt_identity()) + accounts = ( + db.session.query(Account) + .filter_by(user_id=uid, active=True) + .order_by(Account.created_at.asc()) + .all() + ) + + # Single aggregation query: SUM income and expenses per account_id in one round-trip. + agg_rows = ( + db.session.query( + Expense.account_id, + func.coalesce( + func.sum(case((Expense.expense_type == "INCOME", Expense.amount), else_=0)), 0 + ).label("income"), + func.coalesce( + func.sum(case((Expense.expense_type == "EXPENSE", Expense.amount), else_=0)), 0 + ).label("expenses"), + ) + .filter( + Expense.user_id == uid, + Expense.account_id.isnot(None), + ) + .group_by(Expense.account_id) + .all() + ) + # Map account_id → (income, expenses) + agg: dict[int, tuple[Decimal, Decimal]] = { + row.account_id: (Decimal(str(row.income)), Decimal(str(row.expenses))) + for row in agg_rows + } + + account_summaries = [] + total_assets = Decimal("0") + total_liabilities = Decimal("0") + + for acc in accounts: + income, expenses = agg.get(acc.id, (Decimal("0"), Decimal("0"))) + balance = acc.initial_balance + income - expenses + + if acc.account_type == AccountType.CREDIT.value: + total_liabilities += balance + else: + total_assets += balance + + account_summaries.append({ + **_account_to_dict(acc), + "income": float(income), + "expenses": float(expenses), + "balance": float(balance), + }) + + # Unassigned expenses (no account) — two scalars, not per-account, so no N+1 here. + unassigned_agg = ( + db.session.query( + func.coalesce( + func.sum(case((Expense.expense_type == "INCOME", Expense.amount), else_=0)), 0 + ).label("income"), + func.coalesce( + func.sum(case((Expense.expense_type == "EXPENSE", Expense.amount), else_=0)), 0 + ).label("expenses"), + ) + .filter(Expense.user_id == uid, Expense.account_id.is_(None)) + .one() + ) + unassigned_income = Decimal(str(unassigned_agg.income)) + unassigned_expenses = Decimal(str(unassigned_agg.expenses)) + + return jsonify({ + "accounts": account_summaries, + "summary": { + "total_assets": float(total_assets), + "total_liabilities": float(total_liabilities), + "net_worth": float(total_assets - total_liabilities), + "unassigned_income": float(unassigned_income), + "unassigned_expenses": float(unassigned_expenses), + "account_count": len(accounts), + }, + "generated_at": datetime.utcnow().isoformat() + "Z", + }) + + +@bp.get("/") +@jwt_required() +def get_account(account_id: int): + uid = int(get_jwt_identity()) + acc = _get_or_404(account_id, uid) + if acc is None: + return jsonify(error="not found"), 404 + return jsonify(_account_to_dict(acc)) + + +@bp.patch("/") +@jwt_required() +def update_account(account_id: int): + uid = int(get_jwt_identity()) + acc = _get_or_404(account_id, uid) + if acc is None: + return jsonify(error="not found"), 404 + + data = request.get_json(silent=True) or {} + + if "name" in data: + name = (data["name"] or "").strip() + if not name: + return jsonify(error="name cannot be empty"), 400 + acc.name = name + + if "account_type" in data: + at = (data["account_type"] or "").upper() + if at not in _VALID_TYPES: + return jsonify(error=f"account_type must be one of: {sorted(_VALID_TYPES)}"), 400 + acc.account_type = at + + if "currency" in data: + acc.currency = (data["currency"] or "INR").upper()[:10] + + if "initial_balance" in data: + v = _parse_amount(data["initial_balance"]) + if v is None: + return jsonify(error="invalid initial_balance"), 400 + acc.initial_balance = v + + if "color" in data: + acc.color = data["color"] or None + + db.session.commit() + return jsonify(_account_to_dict(acc)) + + +@bp.delete("/") +@jwt_required() +def delete_account(account_id: int): + uid = int(get_jwt_identity()) + acc = _get_or_404(account_id, uid) + if acc is None: + return jsonify(error="not found"), 404 + # Soft delete — preserve historical expense links + acc.active = False + db.session.commit() + return jsonify(message="account deactivated") + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + + +def _get_or_404(account_id: int, user_id: int): + # Soft-deleted accounts (active=False) are intentionally excluded: a + # deactivated account is treated as gone from the user's perspective. + # Historical expense rows that still reference it are preserved in the DB + # but the account itself is no longer accessible via the API. + acc = db.session.get(Account, account_id) + if not acc or acc.user_id != user_id or not acc.active: + return None + return acc + + +def _parse_amount(raw) -> Decimal | None: + try: + return Decimal(str(raw)) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _account_to_dict(acc: Account) -> dict: + return { + "id": acc.id, + "name": acc.name, + "account_type": acc.account_type, + "currency": acc.currency, + "initial_balance": float(acc.initial_balance), + "color": acc.color, + "active": acc.active, + "created_at": acc.created_at.isoformat(), + "updated_at": acc.updated_at.isoformat(), + } diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 00000000..6104d218 --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -0,0 +1,293 @@ +""" +Tests for Multi-account financial overview (Issue #132). + +Covers: +- Account CRUD (create, list, get, update, delete/deactivate) +- Account types validation +- Overview endpoint: per-account balance, net worth, totals +- Expenses linked to accounts contribute to balance +- Unassigned expenses tracked separately +- Auth required on all endpoints +- Users cannot access each other's accounts +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from app.extensions import db +from app.models import Account, Expense +from datetime import date + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _auth(client, email="acc@test.com", password="pass1234"): + client.post("/auth/register", json={"email": email, "password": password}) + r = client.post("/auth/login", json={"email": email, "password": password}) + return {"Authorization": f"Bearer {r.get_json()['access_token']}"} + + +def _create_account(client, headers, **kwargs): + payload = {"name": "Main Bank", "account_type": "BANK", "currency": "INR", **kwargs} + return client.post("/accounts", json=payload, headers=headers) + + +def _get_uid(app_fixture, email): + from app.models import User + with app_fixture.app_context(): + u = db.session.query(User).filter_by(email=email).first() + return u.id if u else None + + +def _seed_expense(app_fixture, user_id, account_id=None, amount=500, expense_type="EXPENSE"): + with app_fixture.app_context(): + db.session.add(Expense( + user_id=user_id, + amount=Decimal(str(amount)), + currency="INR", + notes="test", + spent_at=date.today(), + expense_type=expense_type, + account_id=account_id, + )) + db.session.commit() + + +# ───────────────────────────────────────────────────────────────────────────── +# Account CRUD +# ───────────────────────────────────────────────────────────────────────────── + +class TestAccountCrud: + def test_create_account(self, client, app_fixture): + h = _auth(client, "ac1@test.com") + r = _create_account(client, h, name="Savings", initial_balance=5000) + assert r.status_code == 201 + d = r.get_json() + assert d["name"] == "Savings" + assert d["initial_balance"] == 5000.0 + assert d["active"] is True + + def test_create_account_missing_name(self, client, app_fixture): + h = _auth(client, "ac2@test.com") + r = client.post("/accounts", json={"account_type": "BANK"}, headers=h) + assert r.status_code == 400 + assert "name" in r.get_json()["error"] + + def test_create_account_invalid_type(self, client, app_fixture): + h = _auth(client, "ac3@test.com") + r = _create_account(client, h, account_type="BITCOIN") + assert r.status_code == 400 + + def test_list_accounts_empty(self, client, app_fixture): + h = _auth(client, "ac4@test.com") + r = client.get("/accounts", headers=h) + assert r.status_code == 200 + assert r.get_json() == [] + + def test_list_accounts_own_only(self, client, app_fixture): + h1 = _auth(client, "ac5a@test.com") + h2 = _auth(client, "ac5b@test.com") + _create_account(client, h1, name="A") + _create_account(client, h2, name="B") + accounts = client.get("/accounts", headers=h1).get_json() + assert len(accounts) == 1 + assert accounts[0]["name"] == "A" + + def test_get_account(self, client, app_fixture): + h = _auth(client, "ac6@test.com") + aid = _create_account(client, h).get_json()["id"] + r = client.get(f"/accounts/{aid}", headers=h) + assert r.status_code == 200 + assert r.get_json()["id"] == aid + + def test_get_account_other_user_forbidden(self, client, app_fixture): + h1 = _auth(client, "ac7a@test.com") + h2 = _auth(client, "ac7b@test.com") + aid = _create_account(client, h1).get_json()["id"] + assert client.get(f"/accounts/{aid}", headers=h2).status_code == 404 + + def test_update_account(self, client, app_fixture): + h = _auth(client, "ac8@test.com") + aid = _create_account(client, h, name="Old").get_json()["id"] + r = client.patch(f"/accounts/{aid}", json={"name": "New", "color": "#ff0000"}, headers=h) + assert r.status_code == 200 + assert r.get_json()["name"] == "New" + assert r.get_json()["color"] == "#ff0000" + + def test_update_account_invalid_type(self, client, app_fixture): + h = _auth(client, "ac9@test.com") + aid = _create_account(client, h).get_json()["id"] + r = client.patch(f"/accounts/{aid}", json={"account_type": "INVALID"}, headers=h) + assert r.status_code == 400 + + def test_delete_account_deactivates(self, client, app_fixture): + h = _auth(client, "ac10@test.com") + aid = _create_account(client, h).get_json()["id"] + r = client.delete(f"/accounts/{aid}", headers=h) + assert r.status_code == 200 + # Should not appear in list anymore + accounts = client.get("/accounts", headers=h).get_json() + assert not any(a["id"] == aid for a in accounts) + + def test_all_endpoints_require_auth(self, client, app_fixture): + assert client.get("/accounts").status_code == 401 + assert client.post("/accounts", json={}).status_code == 401 + assert client.get("/accounts/overview").status_code == 401 + assert client.get("/accounts/1").status_code == 401 + + +# ───────────────────────────────────────────────────────────────────────────── +# Overview +# ───────────────────────────────────────────────────────────────────────────── + +class TestAccountOverview: + def test_overview_empty(self, client, app_fixture): + h = _auth(client, "ov1@test.com") + r = client.get("/accounts/overview", headers=h) + assert r.status_code == 200 + d = r.get_json() + assert d["accounts"] == [] + assert d["summary"]["net_worth"] == 0.0 + assert d["summary"]["account_count"] == 0 + + def test_overview_with_initial_balance(self, client, app_fixture): + h = _auth(client, "ov2@test.com") + _create_account(client, h, name="Bank", initial_balance=10000) + r = client.get("/accounts/overview", headers=h) + d = r.get_json() + assert d["summary"]["total_assets"] == 10000.0 + assert d["summary"]["net_worth"] == 10000.0 + + def test_overview_expenses_reduce_balance(self, client, app_fixture): + h = _auth(client, "ov3@test.com") + uid = _get_uid(app_fixture, "ov3@test.com") + aid = _create_account(client, h, name="Bank", initial_balance=10000).get_json()["id"] + _seed_expense(app_fixture, uid, account_id=aid, amount=2000, expense_type="EXPENSE") + + r = client.get("/accounts/overview", headers=h) + acc = r.get_json()["accounts"][0] + assert acc["balance"] == 8000.0 + assert acc["expenses"] == 2000.0 + + def test_overview_income_increases_balance(self, client, app_fixture): + h = _auth(client, "ov4@test.com") + uid = _get_uid(app_fixture, "ov4@test.com") + aid = _create_account(client, h, name="Bank", initial_balance=0).get_json()["id"] + _seed_expense(app_fixture, uid, account_id=aid, amount=5000, expense_type="INCOME") + + r = client.get("/accounts/overview", headers=h) + acc = r.get_json()["accounts"][0] + assert acc["balance"] == 5000.0 + assert acc["income"] == 5000.0 + + def test_overview_credit_is_liability(self, client, app_fixture): + h = _auth(client, "ov5@test.com") + _create_account(client, h, name="CC", account_type="CREDIT", initial_balance=1000) + r = client.get("/accounts/overview", headers=h) + s = r.get_json()["summary"] + assert s["total_liabilities"] == 1000.0 + assert s["total_assets"] == 0.0 + assert s["net_worth"] == -1000.0 + + def test_overview_unassigned_expenses(self, client, app_fixture): + h = _auth(client, "ov6@test.com") + uid = _get_uid(app_fixture, "ov6@test.com") + _seed_expense(app_fixture, uid, account_id=None, amount=300, expense_type="EXPENSE") + + r = client.get("/accounts/overview", headers=h) + s = r.get_json()["summary"] + assert s["unassigned_expenses"] == 300.0 + + def test_overview_multiple_accounts(self, client, app_fixture): + h = _auth(client, "ov7@test.com") + _create_account(client, h, name="Bank", initial_balance=5000) + _create_account(client, h, name="Cash", account_type="CASH", initial_balance=500) + _create_account(client, h, name="CC", account_type="CREDIT", initial_balance=2000) + + r = client.get("/accounts/overview", headers=h) + d = r.get_json() + assert d["summary"]["account_count"] == 3 + assert d["summary"]["total_assets"] == 5500.0 + assert d["summary"]["total_liabilities"] == 2000.0 + assert d["summary"]["net_worth"] == 3500.0 + + def test_overview_no_n_plus_1_with_multiple_accounts(self, client, app_fixture): + """Overview must return correct aggregates for several accounts in a single + round-trip (regression guard — ensures GROUP BY path works end-to-end).""" + h = _auth(client, "ov8@test.com") + uid = _get_uid(app_fixture, "ov8@test.com") + + aid1 = _create_account(client, h, name="A", initial_balance=1000).get_json()["id"] + aid2 = _create_account(client, h, name="B", initial_balance=2000).get_json()["id"] + + # Account A: 500 income, 200 expense → balance = 1000 + 500 - 200 = 1300 + _seed_expense(app_fixture, uid, account_id=aid1, amount=500, expense_type="INCOME") + _seed_expense(app_fixture, uid, account_id=aid1, amount=200, expense_type="EXPENSE") + # Account B: 300 expense → balance = 2000 - 300 = 1700 + _seed_expense(app_fixture, uid, account_id=aid2, amount=300, expense_type="EXPENSE") + + r = client.get("/accounts/overview", headers=h) + assert r.status_code == 200 + accounts = {a["id"]: a for a in r.get_json()["accounts"]} + + assert accounts[aid1]["balance"] == 1300.0 + assert accounts[aid1]["income"] == 500.0 + assert accounts[aid1]["expenses"] == 200.0 + + assert accounts[aid2]["balance"] == 1700.0 + assert accounts[aid2]["income"] == 0.0 + assert accounts[aid2]["expenses"] == 300.0 + + assert r.get_json()["summary"]["net_worth"] == 3000.0 + + +# ───────────────────────────────────────────────────────────────────────────── +# Regression / fix tests +# ───────────────────────────────────────────────────────────────────────────── + +class TestAccountFixes: + def test_get_deactivated_account_returns_404(self, client, app_fixture): + """GET /accounts/ must return 404 for a soft-deleted (active=False) account.""" + h = _auth(client, "fix1@test.com") + aid = _create_account(client, h, name="To Delete").get_json()["id"] + + # Deactivate via DELETE + client.delete(f"/accounts/{aid}", headers=h) + + # Direct GET by ID must now return 404 + r = client.get(f"/accounts/{aid}", headers=h) + assert r.status_code == 404 + + def test_patch_deactivated_account_returns_404(self, client, app_fixture): + """PATCH on a deactivated account must also return 404.""" + h = _auth(client, "fix2@test.com") + aid = _create_account(client, h, name="Dead").get_json()["id"] + client.delete(f"/accounts/{aid}", headers=h) + + r = client.patch(f"/accounts/{aid}", json={"name": "Resurrected"}, headers=h) + assert r.status_code == 404 + + def test_account_has_updated_at_field(self, client, app_fixture): + """Account response must include updated_at.""" + h = _auth(client, "fix3@test.com") + d = _create_account(client, h, name="With Timestamps").get_json() + assert "updated_at" in d + assert d["updated_at"] is not None + + def test_updated_at_changes_on_patch(self, client, app_fixture): + """updated_at must be present both before and after a PATCH.""" + h = _auth(client, "fix4@test.com") + aid = _create_account(client, h, name="Old").get_json()["id"] + created_ts = client.get(f"/accounts/{aid}", headers=h).get_json()["updated_at"] + + r = client.patch(f"/accounts/{aid}", json={"name": "New"}, headers=h) + assert r.status_code == 200 + assert "updated_at" in r.get_json() + # updated_at must be a valid ISO timestamp (SQLite may not auto-update + # but the field must exist and be non-null) + assert r.get_json()["updated_at"] is not None
Manage your financial accounts and see the big picture.
+ No accounts yet — add your first account +