diff --git a/README.md b/README.md index 49592bffc..2454b7c3c 100644 --- a/README.md +++ b/README.md @@ -193,3 +193,38 @@ finmind/ --- MIT Licensed. Built with ❤️. + +## Multi-Account Financial Overview + +FinMind supports multiple financial accounts per user, providing a unified dashboard view. + +### Account Types +- `CHECKING` — Bank checking accounts +- `SAVINGS` — Savings accounts +- `CREDIT_CARD` — Credit cards (negative balance = owed) +- `CASH` — Cash on hand +- `INVESTMENT` — Investment accounts +- `OTHER` — Any other account type + +### API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/accounts` | List all active accounts | +| POST | `/accounts` | Create a new account | +| GET | `/accounts/` | Get single account details | +| PUT | `/accounts/` | Update an account | +| DELETE | `/accounts/` | Soft-delete an account | +| GET | `/accounts/overview` | Aggregated multi-account dashboard | + +### Overview Dashboard Response +The `/accounts/overview` endpoint returns: +- `total_balance` — Sum of all account balances +- `total_assets` — Sum of positive balances +- `total_liabilities` — Sum of negative balances (credit cards, etc.) +- `net_worth` — Assets + liabilities +- `accounts` — Per-account details with 30-day spending/income summary +- `by_type` — Balance totals grouped by account type + +### Linking Expenses to Accounts +Expenses now support an optional `account_id` field. When set, the overview dashboard includes per-account spending and income summaries for the last 30 days. diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..1f3894263 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -32,6 +32,7 @@ class Expense(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=True) + account_id = db.Column(db.Integer, db.ForeignKey("accounts.id"), nullable=True) amount = db.Column(db.Numeric(12, 2), nullable=False) currency = db.Column(db.String(10), default="INR", nullable=False) expense_type = db.Column(db.String(20), default="EXPENSE", nullable=False) @@ -127,6 +128,28 @@ 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" + CASH = "CASH" + INVESTMENT = "INVESTMENT" + OTHER = "OTHER" + + +class Account(db.Model): + __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(SAEnum(AccountType), nullable=False, default=AccountType.CHECKING) + balance = db.Column(db.Numeric(14, 2), nullable=False, default=0) + currency = db.Column(db.String(10), default="INR", nullable=False) + institution = db.Column(db.String(200), nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=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 f13b0f897..f1fe61647 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 000000000..ee3435e7c --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,190 @@ +from decimal import Decimal, InvalidOperation + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from sqlalchemy import func + +from ..extensions import db +from ..models import Account, AccountType, Expense + +bp = Blueprint("accounts", __name__) + + +@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(Account).filter_by(user_id=uid) + if not include_inactive: + q = q.filter_by(is_active=True) + accounts = q.order_by(Account.created_at.desc()).all() + return jsonify([_serialize(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", "CHECKING").upper() + if account_type not in AccountType.__members__: + return jsonify(error=f"invalid account_type, must be one of: {', '.join(AccountType.__members__)}"), 400 + + try: + balance = Decimal(str(data.get("balance", 0))) + except (InvalidOperation, TypeError, ValueError): + return jsonify(error="invalid balance"), 400 + + account = Account( + user_id=uid, + name=name, + account_type=AccountType(account_type), + balance=balance, + currency=data.get("currency", "INR"), + institution=(data.get("institution") or "").strip() or None, + ) + db.session.add(account) + db.session.commit() + return jsonify(_serialize(account)), 201 + + +@bp.get("/") +@jwt_required() +def get_account(account_id): + uid = int(get_jwt_identity()) + account = db.session.query(Account).filter_by(id=account_id, user_id=uid).first() + if not account: + return jsonify(error="account not found"), 404 + return jsonify(_serialize(account)) + + +@bp.put("/") +@jwt_required() +def update_account(account_id): + uid = int(get_jwt_identity()) + account = db.session.query(Account).filter_by(id=account_id, user_id=uid).first() + if not account: + return jsonify(error="account 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 + account.name = name + if "account_type" in data: + at = data["account_type"].upper() + if at not in AccountType.__members__: + return jsonify(error="invalid account_type"), 400 + account.account_type = AccountType(at) + if "balance" in data: + try: + account.balance = Decimal(str(data["balance"])) + except (InvalidOperation, TypeError, ValueError): + return jsonify(error="invalid balance"), 400 + if "currency" in data: + account.currency = data["currency"] + if "institution" in data: + account.institution = (data["institution"] or "").strip() or None + if "is_active" in data: + account.is_active = bool(data["is_active"]) + + db.session.commit() + return jsonify(_serialize(account)) + + +@bp.delete("/") +@jwt_required() +def delete_account(account_id): + uid = int(get_jwt_identity()) + account = db.session.query(Account).filter_by(id=account_id, user_id=uid).first() + if not account: + return jsonify(error="account not found"), 404 + account.is_active = False + db.session.commit() + return "", 204 + + +@bp.get("/overview") +@jwt_required() +def accounts_overview(): + """Aggregated multi-account financial overview dashboard.""" + uid = int(get_jwt_identity()) + accounts = ( + db.session.query(Account) + .filter_by(user_id=uid, is_active=True) + .order_by(Account.created_at.asc()) + .all() + ) + + total_balance = sum(float(a.balance) for a in accounts) + total_assets = sum(float(a.balance) for a in accounts if float(a.balance) >= 0) + total_liabilities = sum(float(a.balance) for a in accounts if float(a.balance) < 0) + net_worth = total_assets + total_liabilities + + # Per-account spending summary (last 30 days) + from datetime import date, timedelta + thirty_days_ago = date.today() - timedelta(days=30) + + account_summaries = [] + for a in accounts: + recent_spending = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.account_id == a.id, + Expense.expense_type != "INCOME", + Expense.spent_at >= thirty_days_ago, + ) + .scalar() + ) + recent_income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.account_id == a.id, + Expense.expense_type == "INCOME", + Expense.spent_at >= thirty_days_ago, + ) + .scalar() + ) + account_summaries.append({ + **_serialize(a), + "recent_spending_30d": float(recent_spending or 0), + "recent_income_30d": float(recent_income or 0), + "recent_net_30d": round(float(recent_income or 0) - float(recent_spending or 0), 2), + }) + + # Breakdown by account type + type_totals = {} + for a in accounts: + t = a.account_type.value + type_totals[t] = type_totals.get(t, 0) + float(a.balance) + + return jsonify({ + "total_balance": round(total_balance, 2), + "total_assets": round(total_assets, 2), + "total_liabilities": round(total_liabilities, 2), + "net_worth": round(net_worth, 2), + "account_count": len(accounts), + "accounts": account_summaries, + "by_type": [{"type": k, "total": round(v, 2)} for k, v in type_totals.items()], + }) + + +def _serialize(a: Account) -> dict: + return { + "id": a.id, + "name": a.name, + "account_type": a.account_type.value, + "balance": float(a.balance), + "currency": a.currency, + "institution": a.institution, + "is_active": a.is_active, + "created_at": a.created_at.isoformat() if a.created_at else None, + } diff --git a/packages/backend/migrations/add_accounts_table.sql b/packages/backend/migrations/add_accounts_table.sql new file mode 100644 index 000000000..3b5215d90 --- /dev/null +++ b/packages/backend/migrations/add_accounts_table.sql @@ -0,0 +1,21 @@ +-- Migration: Add accounts table and account_id to expenses +-- Issue: #132 - Multi-account financial overview dashboard + +CREATE TABLE IF NOT EXISTS accounts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + name VARCHAR(200) NOT NULL, + account_type VARCHAR(20) NOT NULL DEFAULT 'CHECKING', + balance NUMERIC(14, 2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + institution VARCHAR(200), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_accounts_user_id ON accounts(user_id); +CREATE INDEX IF NOT EXISTS idx_accounts_user_active ON accounts(user_id, is_active); + +-- Add account_id to expenses table (nullable for backward compatibility) +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS account_id INTEGER REFERENCES accounts(id); +CREATE INDEX IF NOT EXISTS idx_expenses_account_id ON expenses(account_id); diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 000000000..728a97914 --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -0,0 +1,137 @@ +from datetime import date + + +def test_accounts_crud(client, auth_header): + # Initially empty + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Create checking account + payload = { + "name": "Main Checking", + "account_type": "CHECKING", + "balance": 5000.00, + "currency": "USD", + "institution": "Chase Bank", + } + r = client.post("/accounts", json=payload, headers=auth_header) + assert r.status_code == 201 + data = r.get_json() + account_id = data["id"] + assert data["name"] == "Main Checking" + assert data["account_type"] == "CHECKING" + assert data["balance"] == 5000.00 + assert data["currency"] == "USD" + assert data["institution"] == "Chase Bank" + assert data["is_active"] is True + + # Create savings account + r = client.post("/accounts", json={ + "name": "Emergency Fund", + "account_type": "SAVINGS", + "balance": 10000.00, + "currency": "USD", + }, headers=auth_header) + assert r.status_code == 201 + savings_id = r.get_json()["id"] + + # Create credit card (negative balance) + r = client.post("/accounts", json={ + "name": "Visa Card", + "account_type": "CREDIT_CARD", + "balance": -1500.00, + "currency": "USD", + }, headers=auth_header) + assert r.status_code == 201 + + # List should have 3 + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 3 + + # Get single account + r = client.get(f"/accounts/{account_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "Main Checking" + + # Update account + r = client.put(f"/accounts/{account_id}", json={ + "name": "Primary Checking", + "balance": 5500.00, + }, headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "Primary Checking" + assert r.get_json()["balance"] == 5500.00 + + # Delete (soft delete) + r = client.delete(f"/accounts/{account_id}", headers=auth_header) + assert r.status_code == 204 + + # List should have 2 (active only) + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 2 + + # Include inactive shows 3 + r = client.get("/accounts?include_inactive=true", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) == 3 + + +def test_accounts_validation(client, auth_header): + # Missing name + r = client.post("/accounts", json={"balance": 100}, headers=auth_header) + assert r.status_code == 400 + + # Invalid account type + r = client.post("/accounts", json={"name": "Test", "account_type": "INVALID"}, headers=auth_header) + assert r.status_code == 400 + + # Invalid balance + r = client.post("/accounts", json={"name": "Test", "balance": "not_a_number"}, headers=auth_header) + assert r.status_code == 400 + + +def test_accounts_overview(client, auth_header): + # Create multiple accounts + client.post("/accounts", json={ + "name": "Checking", + "account_type": "CHECKING", + "balance": 3000.00, + "currency": "USD", + }, headers=auth_header) + client.post("/accounts", json={ + "name": "Savings", + "account_type": "SAVINGS", + "balance": 8000.00, + "currency": "USD", + }, headers=auth_header) + client.post("/accounts", json={ + "name": "Credit Card", + "account_type": "CREDIT_CARD", + "balance": -2000.00, + "currency": "USD", + }, headers=auth_header) + + # Get overview + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["account_count"] == 3 + assert data["total_assets"] == 11000.00 + assert data["total_liabilities"] == -2000.00 + assert data["net_worth"] == 9000.00 + assert len(data["accounts"]) == 3 + assert len(data["by_type"]) >= 2 + + +def test_account_not_found(client, auth_header): + r = client.get("/accounts/99999", headers=auth_header) + assert r.status_code == 404 + + r = client.put("/accounts/99999", json={"name": "Test"}, headers=auth_header) + assert r.status_code == 404 + + r = client.delete("/accounts/99999", headers=auth_header) + assert r.status_code == 404