diff --git a/README.md b/README.md index 49592bffc..b7aaee732 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ OpenAPI: `backend/app/openapi.yaml` - Auth: `/auth/register`, `/auth/login`, `/auth/refresh` - Expenses: CRUD `/expenses` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` +- Accounts: CRUD `/accounts`, consolidated net-worth overview `/accounts/overview` - Reminders: CRUD `/reminders`, trigger `/reminders/run` - Insights: `/insights/monthly`, `/insights/budget-suggestion` @@ -71,6 +72,7 @@ OpenAPI: `backend/app/openapi.yaml` - Auth screens: register/login. - Dashboard: - Monthly spend chart, category breakdown donut. + - Multi-account net worth overview with assets, liabilities, and balances by account type. - Upcoming bills list with due dates and pay status. - AI budget suggestion card. - Expenses page: add expense (amount, category, notes, date), list & filter. diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..b16b40abc 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,36 @@ 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) + + +# --------------------------------------------------------------------------- +# Financial Accounts (multi-account support) +# --------------------------------------------------------------------------- + +class AccountType(str, Enum): + CHECKING = "CHECKING" + SAVINGS = "SAVINGS" + CREDIT = "CREDIT" + INVESTMENT = "INVESTMENT" + CASH = "CASH" + OTHER = "OTHER" + + +class FinancialAccount(db.Model): + """A named financial account belonging to a user (bank, wallet, credit card, …).""" + __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(20), default=AccountType.CHECKING.value, nullable=False + ) + balance = db.Column(db.Numeric(14, 2), default=0, nullable=False) + currency = db.Column(db.String(10), default="INR", nullable=False) + institution = db.Column(db.String(200), nullable=True) # e.g. "HDFC Bank" + is_default = db.Column(db.Boolean, default=False, nullable=False) + notes = db.Column(db.String(500), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column( + db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0f..7a3b14f33 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -10,6 +10,7 @@ tags: - name: Categories - name: Expenses - name: Bills + - name: Accounts - name: Reminders - name: Insights paths: @@ -329,6 +330,157 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /accounts: + get: + summary: List financial accounts + tags: [Accounts] + security: [{ bearerAuth: [] }] + responses: + '200': + description: User-owned financial accounts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FinancialAccount' + example: + - { id: 1, name: Everyday Checking, account_type: CHECKING, balance: 1250.75, currency: USD, institution: FinBank, is_default: true, notes: Primary account } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + post: + summary: Create financial account + tags: [Accounts] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewFinancialAccount' + example: + name: Brokerage + account_type: INVESTMENT + balance: 5000 + currency: USD + institution: FinInvest + is_default: false + notes: Long-term investments + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/FinancialAccount' + '400': + description: Validation error + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /accounts/overview: + get: + summary: Get consolidated financial overview + tags: [Accounts] + security: [{ bearerAuth: [] }] + responses: + '200': + description: Net worth and account totals for the authenticated user + content: + application/json: + schema: + $ref: '#/components/schemas/FinancialAccountsOverview' + example: + account_count: 3 + total_assets: 8250.75 + total_liabilities: 900.0 + net_worth: 7350.75 + by_type: + CHECKING: 1250.75 + INVESTMENT: 5000 + CREDIT: 900 + accounts: [] + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /accounts/{accountId}: + get: + summary: Get financial account + tags: [Accounts] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: accountId + required: true + schema: { type: integer } + responses: + '200': + description: Financial account + content: + application/json: + schema: + $ref: '#/components/schemas/FinancialAccount' + '404': + description: Not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + patch: + summary: Update financial account + tags: [Accounts] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: accountId + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewFinancialAccount' + responses: + '200': + description: Updated financial account + content: + application/json: + schema: + $ref: '#/components/schemas/FinancialAccount' + '400': + description: Validation error + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '404': + description: Not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + delete: + summary: Delete financial account + tags: [Accounts] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: accountId + required: true + schema: { type: integer } + responses: + '204': { description: Deleted } + '404': + description: Not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /reminders: get: summary: List reminders @@ -572,6 +724,46 @@ components: autopay_enabled: { type: boolean, default: false } channel_email: { type: boolean, default: true } channel_whatsapp: { type: boolean, default: false } + FinancialAccount: + type: object + properties: + id: { type: integer } + name: { type: string } + account_type: { type: string, enum: [CHECKING, SAVINGS, CREDIT, INVESTMENT, CASH, OTHER] } + balance: { type: number, format: float } + currency: { type: string } + institution: { type: string, nullable: true } + is_default: { type: boolean } + notes: { type: string, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + NewFinancialAccount: + type: object + required: [name] + properties: + name: { type: string } + account_type: { type: string, enum: [CHECKING, SAVINGS, CREDIT, INVESTMENT, CASH, OTHER], default: CHECKING } + balance: { type: number, format: float, default: 0 } + currency: { type: string } + institution: { type: string, nullable: true } + is_default: { type: boolean, default: false } + notes: { type: string, nullable: true } + FinancialAccountsOverview: + type: object + properties: + account_count: { type: integer } + total_assets: { type: number, format: float } + total_liabilities: { type: number, format: float } + net_worth: { type: number, format: float } + by_type: + type: object + additionalProperties: + type: number + format: float + accounts: + type: array + items: + $ref: '#/components/schemas/FinancialAccount' Reminder: type: object properties: 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..59d9cee00 --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,80 @@ +"""Financial accounts routes (issue #132). + +GET /accounts list all accounts +POST /accounts create account +GET /accounts/overview net-worth summary across all accounts +GET /accounts/ get one account +PATCH /accounts/ update account +DELETE /accounts/ delete account (204) +""" +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +import logging + +from ..services.accounts import ( + account_to_dict, + create_account, + delete_account, + get_account, + get_accounts, + get_overview, + update_account, +) + +bp = Blueprint("accounts", __name__) +logger = logging.getLogger("finmind.accounts.routes") + + +@bp.get("") +@jwt_required() +def list_accounts(): + uid = int(get_jwt_identity()) + return jsonify([account_to_dict(a) for a in get_accounts(uid)]) + + +@bp.post("") +@jwt_required() +def create_account_endpoint(): + uid = int(get_jwt_identity()) + acc, err = create_account(uid, request.get_json() or {}) + if err: + return jsonify(error=err), 400 + return jsonify(account_to_dict(acc)), 201 + + +@bp.get("/overview") +@jwt_required() +def overview(): + uid = int(get_jwt_identity()) + return jsonify(get_overview(uid)) + + +@bp.get("/") +@jwt_required() +def get_account_endpoint(account_id: int): + uid = int(get_jwt_identity()) + acc = get_account(uid, account_id) + if not acc: + return jsonify(error="not found"), 404 + return jsonify(account_to_dict(acc)) + + +@bp.patch("/") +@jwt_required() +def update_account_endpoint(account_id: int): + uid = int(get_jwt_identity()) + acc, err = update_account(uid, account_id, request.get_json() or {}) + if err == "not found": + return jsonify(error=err), 404 + if err: + return jsonify(error=err), 400 + return jsonify(account_to_dict(acc)) + + +@bp.delete("/") +@jwt_required() +def delete_account_endpoint(account_id: int): + uid = int(get_jwt_identity()) + if not delete_account(uid, account_id): + return jsonify(error="not found"), 404 + return "", 204 diff --git a/packages/backend/app/services/accounts.py b/packages/backend/app/services/accounts.py new file mode 100644 index 000000000..93b057336 --- /dev/null +++ b/packages/backend/app/services/accounts.py @@ -0,0 +1,194 @@ +"""Multi-account financial overview service (issue #132). + +Public API +---------- +create_account(uid, data) -> (FinancialAccount, error) +get_accounts(uid) -> list[FinancialAccount] +get_account(uid, account_id) -> FinancialAccount | None +update_account(uid, account_id, data) -> (FinancialAccount | None, error) +delete_account(uid, account_id) -> bool +get_overview(uid) -> dict +account_to_dict(acc) -> dict +""" +from __future__ import annotations + +import logging +from datetime import datetime +from decimal import Decimal, InvalidOperation +from typing import Any + +from ..extensions import db +from ..models import AccountType, FinancialAccount + +logger = logging.getLogger("finmind.accounts") + +_ASSET_TYPES = {AccountType.CHECKING.value, AccountType.SAVINGS.value, + AccountType.INVESTMENT.value, AccountType.CASH.value, AccountType.OTHER.value} +_LIABILITY_TYPES = {AccountType.CREDIT.value} + + +def _parse_balance(value: Any) -> Decimal | None: + try: + return Decimal(str(value)) + except (InvalidOperation, TypeError): + return None + + +def account_to_dict(acc: FinancialAccount) -> dict: + return { + "id": acc.id, + "name": acc.name, + "account_type": acc.account_type, + "balance": float(acc.balance), + "currency": acc.currency, + "institution": acc.institution, + "is_default": acc.is_default, + "notes": acc.notes, + "created_at": acc.created_at.isoformat(), + "updated_at": acc.updated_at.isoformat(), + } + + +def create_account(uid: int, data: dict) -> tuple[FinancialAccount | None, str | None]: + name = (data.get("name") or "").strip() + if not name: + return None, "name required" + + acc_type = str(data.get("account_type") or AccountType.CHECKING.value).upper() + valid_types = {e.value for e in AccountType} + if acc_type not in valid_types: + return None, f"account_type must be one of {sorted(valid_types)}" + + balance = _parse_balance(data.get("balance", 0)) + if balance is None: + return None, "balance must be a number" + + from ..models import User + user = db.session.get(User, uid) + currency = data.get("currency") or (user.preferred_currency if user else "INR") + + # If this is the first account or explicitly set as default, mark it + existing_count = db.session.query(FinancialAccount).filter_by(user_id=uid).count() + is_default = bool(data.get("is_default", existing_count == 0)) + + if is_default: + # Clear existing default + db.session.query(FinancialAccount).filter_by( + user_id=uid, is_default=True + ).update({"is_default": False}) + + acc = FinancialAccount( + user_id=uid, + name=name, + account_type=acc_type, + balance=balance, + currency=currency, + institution=(data.get("institution") or "").strip() or None, + is_default=is_default, + notes=(data.get("notes") or "").strip() or None, + ) + db.session.add(acc) + db.session.commit() + logger.info("Created account id=%s user=%s type=%s", acc.id, uid, acc_type) + return acc, None + + +def get_accounts(uid: int) -> list[FinancialAccount]: + return ( + db.session.query(FinancialAccount) + .filter_by(user_id=uid) + .order_by(FinancialAccount.is_default.desc(), FinancialAccount.created_at) + .all() + ) + + +def get_account(uid: int, account_id: int) -> FinancialAccount | None: + return db.session.query(FinancialAccount).filter_by( + id=account_id, user_id=uid + ).first() + + +def update_account( + uid: int, account_id: int, data: dict +) -> tuple[FinancialAccount | None, str | None]: + acc = get_account(uid, account_id) + if not acc: + return None, "not found" + + if "name" in data: + name = (data["name"] or "").strip() + if not name: + return None, "name must not be empty" + acc.name = name + + if "account_type" in data: + t = str(data["account_type"] or "").upper() + valid = {e.value for e in AccountType} + if t not in valid: + return None, f"account_type must be one of {sorted(valid)}" + acc.account_type = t + + if "balance" in data: + bal = _parse_balance(data["balance"]) + if bal is None: + return None, "balance must be a number" + acc.balance = bal + + if "currency" in data and data["currency"]: + acc.currency = str(data["currency"]).upper() + + if "institution" in data: + acc.institution = (data["institution"] or "").strip() or None + + if "notes" in data: + acc.notes = (data["notes"] or "").strip() or None + + if data.get("is_default"): + db.session.query(FinancialAccount).filter_by( + user_id=uid, is_default=True + ).update({"is_default": False}) + acc.is_default = True + + acc.updated_at = datetime.utcnow() + db.session.commit() + logger.info("Updated account id=%s user=%s", acc.id, uid) + return acc, None + + +def delete_account(uid: int, account_id: int) -> bool: + acc = get_account(uid, account_id) + if not acc: + return False + db.session.delete(acc) + db.session.commit() + logger.info("Deleted account id=%s user=%s", account_id, uid) + return True + + +def get_overview(uid: int) -> dict: + """Aggregate all accounts into a financial overview with net worth.""" + accounts = get_accounts(uid) + + total_assets = Decimal("0") + total_liabilities = Decimal("0") + by_type: dict[str, float] = {} + + for acc in accounts: + t = acc.account_type + bal = acc.balance or Decimal("0") + by_type[t] = by_type.get(t, 0.0) + float(bal) + if t in _LIABILITY_TYPES: + total_liabilities += bal + else: + total_assets += bal + + net_worth = total_assets - total_liabilities + + return { + "account_count": len(accounts), + "total_assets": float(total_assets), + "total_liabilities": float(total_liabilities), + "net_worth": float(net_worth), + "by_type": by_type, + "accounts": [account_to_dict(a) for a in accounts], + } diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 000000000..e93ee71eb --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -0,0 +1,418 @@ +"""Tests for /accounts endpoints (issue #132). + +Covers: +- Auth gates (401 without JWT) +- Create / list / get / update / delete +- Overview aggregation (assets vs liabilities, net_worth) +- Default account logic (first account auto-default; explicit is_default clears old) +- Validation errors (missing name, bad account_type, non-numeric balance) +- Cross-user isolation (user A cannot read/modify user B's accounts) +""" +from __future__ import annotations + +import os +import pytest + +os.environ.setdefault("TESTING", "true") +os.environ.setdefault("DISABLE_SCHEDULER", "true") + +# --------------------------------------------------------------------------- +# Patch Redis before app import +# --------------------------------------------------------------------------- +import app.extensions as _ext # noqa: E402 + +_rc = _ext.redis_client +_rc.ping = lambda: True +_rc.get = lambda *a, **kw: None +_rc.set = lambda *a, **kw: True +_rc.setex = lambda *a, **kw: True +_rc.delete = lambda *a, **kw: 0 +_rc.scan = lambda cursor=0, **kw: (0, []) +_rc.keys = lambda *a, **kw: [] +_rc.expire = lambda *a, **kw: 1 + +from app import create_app # noqa: E402 +from app.config import Settings # noqa: E402 +from app.extensions import db as _db # noqa: E402 + + +class TestSettings(Settings): + database_url: str = "sqlite+pysqlite:///:memory:" + jwt_secret: str = "test-secret-key-32chars-padding!!" + jwt_access_minutes: int = 60 + + +@pytest.fixture(scope="module") +def app(): + flask_app = create_app(TestSettings()) + flask_app.config["TESTING"] = True + with flask_app.app_context(): + _db.create_all() + yield flask_app + _db.drop_all() + + +@pytest.fixture(scope="module") +def client(app): + return app.test_client() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _register_and_login(client, email: str, password: str = "Pass1234!") -> str: + client.post("/auth/register", json={"email": email, "password": password}) + r = client.post("/auth/login", json={"email": email, "password": password}) + return r.get_json()["access_token"] + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# Auth gates +# --------------------------------------------------------------------------- + +class TestAuthGates: + def test_list_requires_auth(self, client): + r = client.get("/accounts") + assert r.status_code == 401 + + def test_create_requires_auth(self, client): + r = client.post("/accounts", json={"name": "Wallet"}) + assert r.status_code == 401 + + def test_overview_requires_auth(self, client): + r = client.get("/accounts/overview") + assert r.status_code == 401 + + def test_get_requires_auth(self, client): + r = client.get("/accounts/1") + assert r.status_code == 401 + + def test_patch_requires_auth(self, client): + r = client.patch("/accounts/1", json={"name": "X"}) + assert r.status_code == 401 + + def test_delete_requires_auth(self, client): + r = client.delete("/accounts/1") + assert r.status_code == 401 + + +# --------------------------------------------------------------------------- +# Create +# --------------------------------------------------------------------------- + +class TestCreateAccount: + @pytest.fixture(autouse=True) + def setup(self, client): + self.token = _register_and_login(client, "create_acc@test.com") + self.h = _auth(self.token) + + def test_create_minimal(self, client): + r = client.post("/accounts", json={"name": "Main Wallet"}, headers=self.h) + assert r.status_code == 201 + data = r.get_json() + assert data["name"] == "Main Wallet" + assert data["account_type"] == "CHECKING" + assert data["balance"] == 0.0 + assert data["is_default"] is True # first account → auto default + assert "id" in data + assert "created_at" in data + assert "updated_at" in data + + def test_create_full_fields(self, client): + r = client.post("/accounts", json={ + "name": "HDFC Savings", + "account_type": "savings", # lowercase should be normalised + "balance": "15000.50", + "currency": "INR", + "institution": "HDFC Bank", + "notes": "Primary savings", + }, headers=self.h) + assert r.status_code == 201 + d = r.get_json() + assert d["account_type"] == "SAVINGS" + assert d["balance"] == pytest.approx(15000.50) + assert d["institution"] == "HDFC Bank" + assert d["notes"] == "Primary savings" + + def test_create_missing_name_400(self, client): + r = client.post("/accounts", json={}, headers=self.h) + assert r.status_code == 400 + assert "name" in r.get_json()["error"] + + def test_create_bad_type_400(self, client): + r = client.post("/accounts", json={"name": "X", "account_type": "DEBIT"}, headers=self.h) + assert r.status_code == 400 + assert "account_type" in r.get_json()["error"] + + def test_create_bad_balance_400(self, client): + r = client.post("/accounts", json={"name": "X", "balance": "oops"}, headers=self.h) + assert r.status_code == 400 + assert "balance" in r.get_json()["error"] + + def test_second_account_not_default(self, client): + client.post("/accounts", json={"name": "First"}, headers=self.h) + r = client.post("/accounts", json={"name": "Second"}, headers=self.h) + assert r.status_code == 201 + assert r.get_json()["is_default"] is False + + def test_explicit_is_default_clears_old(self, client): + r1 = client.post("/accounts", json={"name": "Alpha"}, headers=self.h) + r2 = client.post("/accounts", json={"name": "Beta", "is_default": True}, headers=self.h) + assert r2.get_json()["is_default"] is True + # Alpha should no longer be default + alpha_id = r1.get_json()["id"] + r_alpha = client.get(f"/accounts/{alpha_id}", headers=self.h) + assert r_alpha.get_json()["is_default"] is False + + +# --------------------------------------------------------------------------- +# List +# --------------------------------------------------------------------------- + +class TestListAccounts: + @pytest.fixture(autouse=True) + def setup(self, client): + self.token = _register_and_login(client, "list_acc@test.com") + self.h = _auth(self.token) + + def test_empty_list(self, client): + r = client.get("/accounts", headers=self.h) + assert r.status_code == 200 + assert r.get_json() == [] + + def test_returns_created_accounts(self, client): + client.post("/accounts", json={"name": "A"}, headers=self.h) + client.post("/accounts", json={"name": "B"}, headers=self.h) + r = client.get("/accounts", headers=self.h) + names = [a["name"] for a in r.get_json()] + assert "A" in names + assert "B" in names + + def test_default_account_first(self, client): + token2 = _register_and_login(client, "list_order@test.com") + h2 = _auth(token2) + client.post("/accounts", json={"name": "Non-default"}, headers=h2) + r2 = client.post("/accounts", json={"name": "MakeDefault", "is_default": True}, headers=h2) + assert r2.get_json()["is_default"] is True + listing = client.get("/accounts", headers=h2).get_json() + assert listing[0]["name"] == "MakeDefault" + + +# --------------------------------------------------------------------------- +# Get single +# --------------------------------------------------------------------------- + +class TestGetAccount: + @pytest.fixture(autouse=True) + def setup(self, client): + self.token = _register_and_login(client, "get_acc@test.com") + self.h = _auth(self.token) + r = client.post("/accounts", json={"name": "My Account"}, headers=self.h) + self.acc_id = r.get_json()["id"] + + def test_get_existing(self, client): + r = client.get(f"/accounts/{self.acc_id}", headers=self.h) + assert r.status_code == 200 + assert r.get_json()["id"] == self.acc_id + + def test_get_missing_404(self, client): + r = client.get("/accounts/999999", headers=self.h) + assert r.status_code == 404 + + def test_get_other_user_404(self, client): + other = _register_and_login(client, "other_get@test.com") + r = client.get(f"/accounts/{self.acc_id}", headers=_auth(other)) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Update +# --------------------------------------------------------------------------- + +class TestUpdateAccount: + @pytest.fixture(autouse=True) + def setup(self, client): + self.token = _register_and_login(client, "update_acc@test.com") + self.h = _auth(self.token) + r = client.post("/accounts", json={"name": "Old Name", "balance": 100}, headers=self.h) + self.acc_id = r.get_json()["id"] + + def test_update_name(self, client): + r = client.patch(f"/accounts/{self.acc_id}", json={"name": "New Name"}, headers=self.h) + assert r.status_code == 200 + assert r.get_json()["name"] == "New Name" + + def test_update_balance(self, client): + r = client.patch(f"/accounts/{self.acc_id}", json={"balance": 500}, headers=self.h) + assert r.status_code == 200 + assert r.get_json()["balance"] == pytest.approx(500.0) + + def test_update_type(self, client): + r = client.patch(f"/accounts/{self.acc_id}", json={"account_type": "SAVINGS"}, headers=self.h) + assert r.status_code == 200 + assert r.get_json()["account_type"] == "SAVINGS" + + def test_update_bad_type_400(self, client): + r = client.patch(f"/accounts/{self.acc_id}", json={"account_type": "INVALID"}, headers=self.h) + assert r.status_code == 400 + + def test_update_empty_name_400(self, client): + r = client.patch(f"/accounts/{self.acc_id}", json={"name": " "}, headers=self.h) + assert r.status_code == 400 + + def test_update_bad_balance_400(self, client): + r = client.patch(f"/accounts/{self.acc_id}", json={"balance": "abc"}, headers=self.h) + assert r.status_code == 400 + + def test_update_missing_404(self, client): + r = client.patch("/accounts/999999", json={"name": "X"}, headers=self.h) + assert r.status_code == 404 + + def test_update_other_user_404(self, client): + other = _register_and_login(client, "other_upd@test.com") + r = client.patch(f"/accounts/{self.acc_id}", json={"name": "X"}, headers=_auth(other)) + assert r.status_code == 404 + + def test_set_default_via_patch(self, client): + r2 = client.post("/accounts", json={"name": "Second"}, headers=self.h) + id2 = r2.get_json()["id"] + r = client.patch(f"/accounts/{id2}", json={"is_default": True}, headers=self.h) + assert r.status_code == 200 + assert r.get_json()["is_default"] is True + # Original should no longer be default + r_orig = client.get(f"/accounts/{self.acc_id}", headers=self.h) + assert r_orig.get_json()["is_default"] is False + + +# --------------------------------------------------------------------------- +# Delete +# --------------------------------------------------------------------------- + +class TestDeleteAccount: + @pytest.fixture(autouse=True) + def setup(self, client): + self.token = _register_and_login(client, "del_acc@test.com") + self.h = _auth(self.token) + r = client.post("/accounts", json={"name": "To Delete"}, headers=self.h) + self.acc_id = r.get_json()["id"] + + def test_delete_existing(self, client): + r = client.delete(f"/accounts/{self.acc_id}", headers=self.h) + assert r.status_code == 204 + assert r.data == b"" + + def test_delete_missing_404(self, client): + r = client.delete("/accounts/999999", headers=self.h) + assert r.status_code == 404 + + def test_delete_other_user_404(self, client): + token2 = _register_and_login(client, "del_other@test.com") + r = client.post("/accounts", json={"name": "Mine"}, headers=self.h) + id_ = r.get_json()["id"] + r2 = client.delete(f"/accounts/{id_}", headers=_auth(token2)) + assert r2.status_code == 404 + + def test_deleted_account_no_longer_visible(self, client): + token = _register_and_login(client, "del_visible@test.com") + h = _auth(token) + r = client.post("/accounts", json={"name": "Gone"}, headers=h) + aid = r.get_json()["id"] + client.delete(f"/accounts/{aid}", headers=h) + r2 = client.get(f"/accounts/{aid}", headers=h) + assert r2.status_code == 404 + + +# --------------------------------------------------------------------------- +# Overview +# --------------------------------------------------------------------------- + +class TestOverview: + @pytest.fixture(autouse=True) + def setup(self, client): + self.token = _register_and_login(client, "overview_acc@test.com") + self.h = _auth(self.token) + + def test_overview_empty(self, client): + r = client.get("/accounts/overview", headers=self.h) + assert r.status_code == 200 + d = r.get_json() + assert d["account_count"] == 0 + assert d["total_assets"] == 0.0 + assert d["total_liabilities"] == 0.0 + assert d["net_worth"] == 0.0 + assert d["by_type"] == {} + assert d["accounts"] == [] + + def test_overview_assets_only(self, client): + client.post("/accounts", json={"name": "Checking", "account_type": "CHECKING", "balance": 1000}, headers=self.h) + client.post("/accounts", json={"name": "Savings", "account_type": "SAVINGS", "balance": 2000}, headers=self.h) + r = client.get("/accounts/overview", headers=self.h) + d = r.get_json() + assert d["total_assets"] == pytest.approx(3000.0) + assert d["total_liabilities"] == pytest.approx(0.0) + assert d["net_worth"] == pytest.approx(3000.0) + assert d["account_count"] == 2 + + def test_overview_credit_as_liability(self, client): + token = _register_and_login(client, "ov_credit@test.com") + h = _auth(token) + client.post("/accounts", json={"name": "Bank", "account_type": "CHECKING", "balance": 5000}, headers=h) + client.post("/accounts", json={"name": "Card", "account_type": "CREDIT", "balance": 1500}, headers=h) + r = client.get("/accounts/overview", headers=h) + d = r.get_json() + assert d["total_assets"] == pytest.approx(5000.0) + assert d["total_liabilities"] == pytest.approx(1500.0) + assert d["net_worth"] == pytest.approx(3500.0) + + def test_overview_by_type(self, client): + token = _register_and_login(client, "ov_bytype@test.com") + h = _auth(token) + client.post("/accounts", json={"name": "C", "account_type": "CHECKING", "balance": 100}, headers=h) + client.post("/accounts", json={"name": "S", "account_type": "SAVINGS", "balance": 200}, headers=h) + r = client.get("/accounts/overview", headers=h) + bt = r.get_json()["by_type"] + assert bt["CHECKING"] == pytest.approx(100.0) + assert bt["SAVINGS"] == pytest.approx(200.0) + + def test_overview_accounts_list_present(self, client): + token = _register_and_login(client, "ov_list@test.com") + h = _auth(token) + client.post("/accounts", json={"name": "One"}, headers=h) + r = client.get("/accounts/overview", headers=h) + d = r.get_json() + assert len(d["accounts"]) == 1 + assert d["accounts"][0]["name"] == "One" + + def test_overview_isolated_per_user(self, client): + """User A's accounts don't appear in user B's overview.""" + ta = _register_and_login(client, "ov_userA@test.com") + tb = _register_and_login(client, "ov_userB@test.com") + client.post("/accounts", json={"name": "A's acc", "balance": 9999}, headers=_auth(ta)) + r = client.get("/accounts/overview", headers=_auth(tb)) + d = r.get_json() + assert d["account_count"] == 0 + assert d["net_worth"] == 0.0 + + +# --------------------------------------------------------------------------- +# All account types accepted +# --------------------------------------------------------------------------- + +class TestAccountTypes: + VALID_TYPES = ["CHECKING", "SAVINGS", "CREDIT", "INVESTMENT", "CASH", "OTHER"] + + @pytest.fixture(autouse=True) + def setup(self, client): + self.token = _register_and_login(client, "types_acc@test.com") + self.h = _auth(self.token) + + @pytest.mark.parametrize("atype", VALID_TYPES) + def test_valid_type(self, client, atype): + r = client.post("/accounts", json={"name": f"Acc {atype}", "account_type": atype}, headers=self.h) + assert r.status_code == 201 + assert r.get_json()["account_type"] == atype