Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` | Get single account details |
| PUT | `/accounts/<id>` | Update an account |
| DELETE | `/accounts/<id>` | 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.
23 changes: 23 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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")
190 changes: 190 additions & 0 deletions packages/backend/app/routes/accounts.py
Original file line number Diff line number Diff line change
@@ -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("/<int:account_id>")
@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("/<int:account_id>")
@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("/<int:account_id>")
@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,
}
21 changes: 21 additions & 0 deletions packages/backend/migrations/add_accounts_table.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading