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..e03a0176a --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,199 @@ +from datetime import date +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from sqlalchemy import func, extract +from ..extensions import db +from ..models import Expense, Category, Bill +import logging + +bp = Blueprint("accounts", __name__) +logger = logging.getLogger("finmind.accounts") + +# In-memory store (would be DB table in production) +_accounts_store = {} +_counter = [0] + + +def _next_id(): + _counter[0] += 1 + return _counter[0] + + +@bp.route("/", methods=["GET"]) +@jwt_required() +def list_accounts(): + uid = str(get_jwt_identity()) + accounts = _accounts_store.get(uid, []) + return jsonify({"accounts": accounts}) + + +@bp.route("/", methods=["POST"]) +@jwt_required() +def create_account(): + uid = str(get_jwt_identity()) + data = request.get_json() or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify(error="name required"), 400 + + account = { + "id": _next_id(), + "name": name, + "account_type": data.get("account_type", "checking"), + "currency": data.get("currency", "USD"), + "color": data.get("color", "#3B82F6"), + "icon": data.get("icon", "wallet"), + "is_default": data.get("is_default", False), + "created_at": date.today().isoformat(), + } + + if account["is_default"]: + for a in _accounts_store.get(uid, []): + a["is_default"] = False + + if not _accounts_store.get(uid) and not account["is_default"]: + account["is_default"] = True + + _accounts_store.setdefault(uid, []).append(account) + logger.info("Account created user=%s account_id=%s", uid, account["id"]) + return jsonify(account), 201 + + +@bp.route("/", methods=["PUT"]) +@jwt_required() +def update_account(account_id): + uid = str(get_jwt_identity()) + data = request.get_json() or {} + for a in _accounts_store.get(uid, []): + if a["id"] == account_id: + for key in ["name", "account_type", "currency", "color", "icon"]: + if key in data: + a[key] = data[key] + if data.get("is_default"): + for other in _accounts_store[uid]: + other["is_default"] = False + a["is_default"] = True + return jsonify(a) + return jsonify(error="Account not found"), 404 + + +@bp.route("/", methods=["DELETE"]) +@jwt_required() +def delete_account(account_id): + uid = str(get_jwt_identity()) + accounts = _accounts_store.get(uid, []) + was_default = any(a["id"] == account_id and a.get("is_default") for a in accounts) + _accounts_store[uid] = [a for a in accounts if a["id"] != account_id] + if was_default and _accounts_store[uid]: + _accounts_store[uid][0]["is_default"] = True + return jsonify(status="deleted") + + +@bp.route("/overview") +@jwt_required() +def multi_account_overview(): + uid = int(get_jwt_identity()) + uid_str = str(uid) + ym = (request.args.get("month") or date.today().strftime("%Y-%m")).strip() + year, month = map(int, ym.split("-")) + accounts = _accounts_store.get(uid_str, []) + + if not accounts: + # Auto-create default account + default = { + "id": _next_id(), + "name": "Main Account", + "account_type": "checking", + "currency": "USD", + "color": "#3B82F6", + "icon": "wallet", + "is_default": True, + "created_at": date.today().isoformat(), + } + accounts = [default] + _accounts_store[uid_str] = accounts + + # Get monthly stats + income = float( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + expenses = float( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + + # Category breakdown + cat_rows = ( + db.session.query( + Expense.category_id, + func.coalesce(Category.name, "Uncategorized").label("name"), + func.sum(Expense.amount).label("total"), + ) + .outerjoin( + Category, + (Category.id == Expense.category_id) & (Category.user_id == uid), + ) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.category_id, Category.name) + .order_by(func.sum(Expense.amount).desc()) + .all() + ) + + # Upcoming bills + bills = Bill.query.filter( + Bill.user_id == uid, + Bill.active.is_(True), + Bill.next_due_date >= date.today(), + ).order_by(Bill.next_due_date.asc()).limit(5).all() + + # Build per-account view (distribute evenly for now) + per_account = [] + n = len(accounts) + for acc in accounts: + share = 1.0 / n if n > 0 else 1.0 + per_account.append({ + "account": acc, + "income": round(income * share, 2), + "expenses": round(expenses * share, 2), + "net_flow": round((income - expenses) * share, 2), + }) + + return jsonify({ + "period": ym, + "accounts": per_account, + "combined": { + "total_income": round(income, 2), + "total_expenses": round(expenses, 2), + "net_flow": round(income - expenses, 2), + }, + "category_breakdown": [ + {"category_id": r.category_id, "name": r.name, "total": float(r.total)} + for r in cat_rows + ], + "upcoming_bills": [ + { + "name": b.name, + "amount": float(b.amount), + "due_date": b.next_due_date.isoformat(), + } + for b in bills + ], + }) diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 000000000..d2bd0a257 --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -0,0 +1,27 @@ +import pytest + + +class TestAccounts: + def test_accounts_require_auth(self, client): + resp = client.get('/accounts/') + assert resp.status_code == 401 + + def test_create_and_list(self, client, auth_headers): + resp = client.post('/accounts/', headers=auth_headers, json={ + 'name': 'Checking', 'account_type': 'checking', 'currency': 'USD' + }) + assert resp.status_code == 201 + acc = resp.get_json() + assert acc['name'] == 'Checking' + + resp = client.get('/accounts/', headers=auth_headers) + assert resp.status_code == 200 + assert len(resp.get_json()['accounts']) >= 1 + + def test_overview(self, client, auth_headers): + resp = client.get('/accounts/overview', headers=auth_headers) + assert resp.status_code == 200 + data = resp.get_json() + assert 'accounts' in data + assert 'combined' in data + assert 'total_income' in data['combined']