diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f8..f1fe616 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 0000000..ec67954 --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -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("/") +@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("/") +@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("/") +@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 diff --git a/packages/backend/app/services/accounts.py b/packages/backend/app/services/accounts.py new file mode 100644 index 0000000..26e2274 --- /dev/null +++ b/packages/backend/app/services/accounts.py @@ -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], + } diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 0000000..79612d2 --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -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