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
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")
76 changes: 76 additions & 0 deletions packages/backend/app/routes/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..services.accounts import (
create_account, list_accounts, get_account,
update_account, delete_account, overview,
)
import logging

bp = Blueprint("accounts", __name__)
logger = logging.getLogger("finmind.accounts")


@bp.post("")
@jwt_required()
def create():
uid = int(get_jwt_identity())
data = request.get_json() or {}
name = data.get("name", "").strip()
acct_type = data.get("account_type", "").strip()
if not name or not acct_type:
return jsonify({"error": "name and account_type are required"}), 400
try:
result = create_account(
uid, name, acct_type,
currency=data.get("currency", "INR"),
balance=float(data.get("balance", 0)),
)
logger.info("Account created id=%s user=%s", result["id"], uid)
return jsonify(result), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400


@bp.get("")
@jwt_required()
def list_all():
uid = int(get_jwt_identity())
return jsonify(list_accounts(uid))


@bp.get("/overview")
@jwt_required()
def get_overview():
uid = int(get_jwt_identity())
return jsonify(overview(uid))


@bp.get("/<int:aid>")
@jwt_required()
def detail(aid):
uid = int(get_jwt_identity())
acct = get_account(uid, aid)
if not acct:
return jsonify({"error": "Account not found"}), 404
return jsonify(acct)


@bp.patch("/<int:aid>")
@jwt_required()
def update(aid):
uid = int(get_jwt_identity())
data = request.get_json() or {}
try:
result = update_account(uid, aid, **data)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400


@bp.delete("/<int:aid>")
@jwt_required()
def delete(aid):
uid = int(get_jwt_identity())
if delete_account(uid, aid):
return jsonify({"deleted": True})
return jsonify({"error": "Account not found"}), 404
103 changes: 103 additions & 0 deletions packages/backend/app/services/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Multi-account financial overview service.

Allows users to manage multiple financial accounts and view
an aggregated dashboard across all accounts.
"""

from datetime import datetime, date

from sqlalchemy import extract, func

from ..extensions import db
from ..models import Expense


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(db.String(50), nullable=False) # checking/savings/credit/cash
currency = db.Column(db.String(10), default="INR", nullable=False)
balance = db.Column(db.Numeric(12, 2), default=0, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


VALID_TYPES = {"checking", "savings", "credit", "cash", "investment"}


def _acct_to_dict(a: FinancialAccount) -> dict:
return {
"id": a.id,
"name": a.name,
"account_type": a.account_type,
"currency": a.currency,
"balance": float(a.balance),
"is_active": a.is_active,
}


def create_account(user_id: int, name: str, account_type: str,
currency: str = "INR", balance: float = 0) -> dict:
if account_type not in VALID_TYPES:
raise ValueError(f"Invalid type. Must be one of: {', '.join(VALID_TYPES)}")
a = FinancialAccount(
user_id=user_id, name=name, account_type=account_type,
currency=currency, balance=balance,
)
db.session.add(a)
db.session.commit()
return _acct_to_dict(a)


def list_accounts(user_id: int) -> list[dict]:
accts = FinancialAccount.query.filter_by(user_id=user_id).order_by(
FinancialAccount.created_at.desc()
).all()
return [_acct_to_dict(a) for a in accts]


def get_account(user_id: int, acct_id: int) -> dict | None:
a = FinancialAccount.query.filter_by(id=acct_id, user_id=user_id).first()
return _acct_to_dict(a) if a else None


def update_account(user_id: int, acct_id: int, **kwargs) -> dict:
a = FinancialAccount.query.filter_by(id=acct_id, user_id=user_id).first()
if not a:
raise ValueError("Account not found.")
for field in ("name", "account_type", "currency", "balance", "is_active"):
if field in kwargs and kwargs[field] is not None:
if field == "account_type" and kwargs[field] not in VALID_TYPES:
raise ValueError(f"Invalid type. Must be one of: {', '.join(VALID_TYPES)}")
setattr(a, field, kwargs[field])
db.session.commit()
return _acct_to_dict(a)


def delete_account(user_id: int, acct_id: int) -> bool:
a = FinancialAccount.query.filter_by(id=acct_id, user_id=user_id).first()
if not a:
return False
db.session.delete(a)
db.session.commit()
return True


def overview(user_id: int) -> dict:
"""Aggregated overview across all active accounts."""
accts = FinancialAccount.query.filter_by(user_id=user_id, is_active=True).all()
total_balance = sum(float(a.balance) for a in accts)
by_type = {}
for a in accts:
by_type.setdefault(a.account_type, 0)
by_type[a.account_type] += float(a.balance)
by_type = {k: round(v, 2) for k, v in by_type.items()}

return {
"total_balance": round(total_balance, 2),
"account_count": len(accts),
"by_type": by_type,
"accounts": [_acct_to_dict(a) for a in accts],
}
92 changes: 92 additions & 0 deletions packages/backend/tests/test_accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
def test_create_account(client, auth_header):
r = client.post("/accounts", json={
"name": "Main Checking", "account_type": "checking", "balance": 5000,
}, headers=auth_header)
assert r.status_code == 201
data = r.get_json()
assert data["name"] == "Main Checking"
assert data["balance"] == 5000


def test_create_invalid_type(client, auth_header):
r = client.post("/accounts", json={
"name": "Bad", "account_type": "crypto",
}, headers=auth_header)
assert r.status_code == 400


def test_create_missing_fields(client, auth_header):
r = client.post("/accounts", json={"name": "No Type"}, headers=auth_header)
assert r.status_code == 400


def test_list_accounts(client, auth_header):
client.post("/accounts", json={
"name": "A1", "account_type": "checking",
}, headers=auth_header)
client.post("/accounts", json={
"name": "A2", "account_type": "savings",
}, headers=auth_header)
r = client.get("/accounts", headers=auth_header)
assert r.status_code == 200
assert len(r.get_json()) >= 2


def test_get_account_detail(client, auth_header):
r = client.post("/accounts", json={
"name": "Detail", "account_type": "cash", "balance": 100,
}, headers=auth_header)
aid = r.get_json()["id"]
r = client.get(f"/accounts/{aid}", headers=auth_header)
assert r.status_code == 200
assert r.get_json()["name"] == "Detail"


def test_update_account(client, auth_header):
r = client.post("/accounts", json={
"name": "Old", "account_type": "checking",
}, headers=auth_header)
aid = r.get_json()["id"]
r = client.patch(f"/accounts/{aid}", json={
"name": "Updated", "balance": 999,
}, headers=auth_header)
assert r.status_code == 200
assert r.get_json()["name"] == "Updated"
assert r.get_json()["balance"] == 999


def test_delete_account(client, auth_header):
r = client.post("/accounts", json={
"name": "Delete Me", "account_type": "cash",
}, headers=auth_header)
aid = r.get_json()["id"]
r = client.delete(f"/accounts/{aid}", headers=auth_header)
assert r.status_code == 200
r = client.get(f"/accounts/{aid}", headers=auth_header)
assert r.status_code == 404


def test_overview(client, auth_header):
client.post("/accounts", json={
"name": "Check", "account_type": "checking", "balance": 3000,
}, headers=auth_header)
client.post("/accounts", json={
"name": "Save", "account_type": "savings", "balance": 2000,
}, headers=auth_header)
r = client.get("/accounts/overview", headers=auth_header)
assert r.status_code == 200
data = r.get_json()
assert data["total_balance"] == 5000
assert data["account_count"] == 2
assert "checking" in data["by_type"]
assert "savings" in data["by_type"]


def test_not_found(client, auth_header):
r = client.get("/accounts/99999", headers=auth_header)
assert r.status_code == 404


def test_requires_auth(client):
r = client.get("/accounts")
assert r.status_code == 401