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
24 changes: 24 additions & 0 deletions packages/backend/app/db/migrations/005_accounts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- Migration 005: Multi-account financial overview
-- Issue #132
-- Apply with: psql $DATABASE_URL -f migrations/005_accounts.sql

CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
account_type VARCHAR(20) NOT NULL DEFAULT 'BANK',
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
initial_balance NUMERIC(12,2) NOT NULL DEFAULT 0,
color VARCHAR(20) NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_accounts_user ON accounts (user_id, active);

-- Add account_id to expenses (nullable, backward-compatible)
ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS account_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL;

CREATE INDEX IF NOT EXISTS idx_expenses_account ON expenses (account_id)
WHERE account_id IS NOT NULL;
27 changes: 27 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class Expense(db.Model):
db.Integer, db.ForeignKey("recurring_expenses.id"), nullable=True
)
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
account_id = db.Column(db.Integer, db.ForeignKey("accounts.id"), nullable=True)


class RecurringCadence(str, Enum):
Expand Down Expand Up @@ -133,3 +134,29 @@ class AuditLog(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
action = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


# ── Accounts ───────────────────────────────────────────────────────────────────

class AccountType(str, Enum):
BANK = "BANK"
CREDIT = "CREDIT"
CASH = "CASH"
INVESTMENT = "INVESTMENT"
WALLET = "WALLET"
OTHER = "OTHER"


class Account(db.Model):
"""A financial account (bank, credit card, cash, etc.)."""

__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(db.String(20), default=AccountType.BANK.value, nullable=False)
currency = db.Column(db.String(10), default="INR", nullable=False)
initial_balance = db.Column(db.Numeric(12, 2), default=0, nullable=False)
color = db.Column(db.String(20), nullable=True) # hex colour for UI
active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
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")
248 changes: 248 additions & 0 deletions packages/backend/app/routes/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""
Multi-account financial overview (Issue #132).

Endpoints:
GET /accounts → list accounts
POST /accounts → create account
GET /accounts/<id> → get account detail
PATCH /accounts/<id> → update account
DELETE /accounts/<id> → delete account (soft: deactivate)
GET /accounts/overview → aggregate view across all accounts
"""

import logging
from datetime import date, datetime
from decimal import Decimal, InvalidOperation

from flask import Blueprint, jsonify, request
from flask_jwt_extended import get_jwt_identity, jwt_required
from sqlalchemy import func

from ..extensions import db
from ..models import Account, AccountType, Expense

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

_VALID_TYPES = {t.value for t in AccountType}


# ─────────────────────────────────────────────────────────────────────────────
# CRUD
# ─────────────────────────────────────────────────────────────────────────────


@bp.get("")
@jwt_required()
def list_accounts():
uid = int(get_jwt_identity())
accounts = (
db.session.query(Account)
.filter_by(user_id=uid, active=True)
.order_by(Account.created_at.asc())
.all()
)
return jsonify([_account_to_dict(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") or AccountType.BANK.value).upper()
if account_type not in _VALID_TYPES:
return jsonify(error=f"account_type must be one of: {sorted(_VALID_TYPES)}"), 400

initial = _parse_amount(data.get("initial_balance", 0))
if initial is None:
return jsonify(error="invalid initial_balance"), 400

account = Account(
user_id=uid,
name=name,
account_type=account_type,
currency=(data.get("currency") or "INR").upper()[:10],
initial_balance=initial,
color=data.get("color") or None,
)
db.session.add(account)
db.session.commit()
logger.info("Created account id=%s user=%s name=%s type=%s", account.id, uid, name, account_type)
return jsonify(_account_to_dict(account)), 201


@bp.get("/overview")
@jwt_required()
def overview():
"""
Aggregate financial overview across all active accounts.
Returns per-account balance + totals.
"""
uid = int(get_jwt_identity())
accounts = (
db.session.query(Account)
.filter_by(user_id=uid, active=True)
.order_by(Account.created_at.asc())
.all()
)

account_summaries = []
total_assets = Decimal("0")
total_liabilities = Decimal("0")

for acc in accounts:
# Income (INCOME type expenses) linked to this account
income_row = (
db.session.query(func.coalesce(func.sum(Expense.amount), 0))
.filter_by(user_id=uid, account_id=acc.id, expense_type="INCOME")
.scalar()
)
# Expenses linked to this account
expense_row = (
db.session.query(func.coalesce(func.sum(Expense.amount), 0))
.filter_by(user_id=uid, account_id=acc.id, expense_type="EXPENSE")
.scalar()
)

income = Decimal(str(income_row))
expenses = Decimal(str(expense_row))
balance = acc.initial_balance + income - expenses

is_credit = acc.account_type == AccountType.CREDIT.value
if is_credit:
total_liabilities += balance
else:
total_assets += balance

account_summaries.append({
**_account_to_dict(acc),
"income": float(income),
"expenses": float(expenses),
"balance": float(balance),
})

# Unassigned expenses (no account)
unassigned_income = Decimal(str(
db.session.query(func.coalesce(func.sum(Expense.amount), 0))
.filter_by(user_id=uid, expense_type="INCOME")
.filter(Expense.account_id.is_(None))
.scalar()
))
unassigned_expenses = Decimal(str(
db.session.query(func.coalesce(func.sum(Expense.amount), 0))
.filter_by(user_id=uid, expense_type="EXPENSE")
.filter(Expense.account_id.is_(None))
.scalar()
))

return jsonify({
"accounts": account_summaries,
"summary": {
"total_assets": float(total_assets),
"total_liabilities": float(total_liabilities),
"net_worth": float(total_assets - total_liabilities),
"unassigned_income": float(unassigned_income),
"unassigned_expenses": float(unassigned_expenses),
"account_count": len(accounts),
},
"generated_at": datetime.utcnow().isoformat() + "Z",
})


@bp.get("/<int:account_id>")
@jwt_required()
def get_account(account_id: int):
uid = int(get_jwt_identity())
acc = _get_or_404(account_id, uid)
if acc is None:
return jsonify(error="not found"), 404
return jsonify(_account_to_dict(acc))


@bp.patch("/<int:account_id>")
@jwt_required()
def update_account(account_id: int):
uid = int(get_jwt_identity())
acc = _get_or_404(account_id, uid)
if acc is None:
return jsonify(error="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
acc.name = name

if "account_type" in data:
at = (data["account_type"] or "").upper()
if at not in _VALID_TYPES:
return jsonify(error=f"account_type must be one of: {sorted(_VALID_TYPES)}"), 400
acc.account_type = at

if "currency" in data:
acc.currency = (data["currency"] or "INR").upper()[:10]

if "initial_balance" in data:
v = _parse_amount(data["initial_balance"])
if v is None:
return jsonify(error="invalid initial_balance"), 400
acc.initial_balance = v

if "color" in data:
acc.color = data["color"] or None

db.session.commit()
return jsonify(_account_to_dict(acc))


@bp.delete("/<int:account_id>")
@jwt_required()
def delete_account(account_id: int):
uid = int(get_jwt_identity())
acc = _get_or_404(account_id, uid)
if acc is None:
return jsonify(error="not found"), 404
# Soft delete — preserve historical expense links
acc.active = False
db.session.commit()
return jsonify(message="account deactivated")


# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────


def _get_or_404(account_id: int, user_id: int):
acc = db.session.get(Account, account_id)
if not acc or acc.user_id != user_id:
return None
return acc


def _parse_amount(raw) -> Decimal | None:
try:
return Decimal(str(raw))
except (InvalidOperation, ValueError, TypeError):
return None


def _account_to_dict(acc: Account) -> dict:
return {
"id": acc.id,
"name": acc.name,
"account_type": acc.account_type,
"currency": acc.currency,
"initial_balance": float(acc.initial_balance),
"color": acc.color,
"active": acc.active,
"created_at": acc.created_at.isoformat(),
}
Loading