diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..16e838da 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import Accounts from "./pages/Accounts"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> { it('shows Account/Logout when signed in (token present)', () => { localStorage.setItem('fm_token', 'token'); renderNav(); - expect(screen.getByRole('link', { name: /account/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /^account$/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /finmind/i })).toHaveAttribute('href', '/dashboard'); }); diff --git a/app/src/api/accounts.ts b/app/src/api/accounts.ts new file mode 100644 index 00000000..b7693598 --- /dev/null +++ b/app/src/api/accounts.ts @@ -0,0 +1,55 @@ +import { api } from './client'; + +export type AccountType = 'checking' | 'savings' | 'credit' | 'cash' | 'investment'; + +export type Account = { + id: number; + name: string; + account_type: AccountType; + currency: string; + balance: number; + is_active: boolean; + color: string | null; + created_at: string; +}; + +export type AccountCreate = { + name: string; + account_type: AccountType; + currency?: string; + balance?: number; + color?: string; +}; + +export type AccountOverview = { + total_balance: number; + net_worth: number; + account_count: number; + by_type: Record; + accounts: Account[]; +}; + +export async function listAccounts(includeInactive = false): Promise { + const qs = includeInactive ? '?include_inactive=true' : ''; + return api(`/accounts${qs}`); +} + +export async function createAccount(payload: AccountCreate): Promise { + return api('/accounts', { method: 'POST', body: payload }); +} + +export async function getAccount(id: number): Promise { + return api(`/accounts/${id}`); +} + +export async function updateAccount(id: number, payload: Partial): Promise { + return api(`/accounts/${id}`, { method: 'PATCH', body: payload }); +} + +export async function deleteAccount(id: number): Promise<{ message: string }> { + return api(`/accounts/${id}`, { method: 'DELETE' }); +} + +export async function getOverview(): Promise { + return api('/accounts/overview'); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..09ebc590 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -13,6 +13,7 @@ const navigation = [ { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, { name: 'Analytics', href: '/analytics' }, + { name: 'Accounts', href: '/accounts' }, ]; export function Navbar() { diff --git a/app/src/pages/Accounts.tsx b/app/src/pages/Accounts.tsx new file mode 100644 index 00000000..d329f2c3 --- /dev/null +++ b/app/src/pages/Accounts.tsx @@ -0,0 +1,166 @@ +import { useState, useEffect, useCallback } from 'react'; +import { FinancialCard, FinancialCardContent, FinancialCardHeader, FinancialCardTitle } from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Wallet, Plus, Building2, PiggyBank, CreditCard, Banknote, TrendingUp, Trash2 } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { listAccounts, createAccount, deleteAccount, getOverview, type Account, type AccountOverview, type AccountType } from '@/api/accounts'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { formatMoney } from '@/lib/currency'; + +const ACCOUNT_TYPE_ICONS: Record = { + checking: Building2, + savings: PiggyBank, + credit: CreditCard, + cash: Banknote, + investment: TrendingUp, +}; + +const ACCOUNT_TYPE_LABELS: Record = { + checking: 'Checking', + savings: 'Savings', + credit: 'Credit Card', + cash: 'Cash', + investment: 'Investment', +}; + +export default function Accounts() { + const { toast } = useToast(); + const [accounts, setAccounts] = useState([]); + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [newAccount, setNewAccount] = useState<{ name: string; account_type: AccountType; balance: string; currency: string }>({ + name: '', account_type: 'checking', balance: '0', currency: 'INR', + }); + + const loadData = useCallback(async () => { + try { + const [accs, ov] = await Promise.all([listAccounts(), getOverview()]); + setAccounts(accs); + setOverview(ov); + } catch (e: unknown) { + toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to load accounts', variant: 'destructive' }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { loadData(); }, [loadData]); + + const handleCreate = async () => { + try { + await createAccount({ + name: newAccount.name, + account_type: newAccount.account_type, + balance: parseFloat(newAccount.balance) || 0, + currency: newAccount.currency, + }); + toast({ title: 'Account created' }); + setDialogOpen(false); + setNewAccount({ name: '', account_type: 'checking', balance: '0', currency: 'INR' }); + loadData(); + } catch (e: unknown) { + toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to create account', variant: 'destructive' }); + } + }; + + const handleDelete = async (id: number) => { + try { + await deleteAccount(id); + toast({ title: 'Account deactivated' }); + loadData(); + } catch (e: unknown) { + toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to deactivate', variant: 'destructive' }); + } + }; + + if (loading) return
Loading...
; + + return ( +
+
+
+

Accounts

+

Manage your financial accounts

+
+ + + + + + + Add Account + Add a new financial account to track. + +
+ setNewAccount(p => ({ ...p, name: e.target.value }))} /> + + setNewAccount(p => ({ ...p, balance: e.target.value }))} /> + setNewAccount(p => ({ ...p, currency: e.target.value }))} /> +
+ + + +
+
+
+ + {/* Overview Cards */} + {overview && ( +
+ + Total Balance +

{formatMoney(overview.total_balance)}

+
+ + Net Worth +

{formatMoney(overview.net_worth)}

+
+ + Active Accounts +

{overview.account_count}

+
+
+ )} + + {/* Account List */} + {accounts.length === 0 ? ( + + + +

No accounts yet. Add your first account to get started.

+
+
+ ) : ( +
+ {accounts.map(account => { + const Icon = ACCOUNT_TYPE_ICONS[account.account_type] || Wallet; + return ( + + +
+ + {account.name} +
+
+ {ACCOUNT_TYPE_LABELS[account.account_type]} + +
+
+ +

{formatMoney(account.balance, account.currency)}

+

{account.currency}

+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..990182d8 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -117,6 +117,20 @@ CREATE TABLE IF NOT EXISTS user_subscriptions ( started_at TIMESTAMP NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS financial_accounts ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + account_type VARCHAR(50) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + balance NUMERIC(12,2) NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + color VARCHAR(7), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_financial_accounts_user ON financial_accounts(user_id, is_active); + CREATE TABLE IF NOT EXISTS audit_logs ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE SET NULL, diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..119377df 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -127,6 +127,21 @@ class UserSubscription(db.Model): started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +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, investment + 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) + color = db.Column(db.String(7), nullable=True) # hex color for UI + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + class AuditLog(db.Model): __tablename__ = "audit_logs" id = db.Column(db.Integer, primary_key=True) diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0..97788ee9 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Bills - name: Reminders - name: Insights + - name: Accounts paths: /auth/register: post: @@ -251,6 +252,85 @@ paths: properties: inserted: { type: integer } + /accounts: + get: + summary: List accounts + tags: [Accounts] + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: include_inactive + schema: { type: boolean, default: false } + responses: + '200': + description: OK + content: + application/json: + schema: { type: array, items: { $ref: '#/components/schemas/Account' } } + post: + summary: Create account + tags: [Accounts] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/NewAccount' } + responses: + '201': { description: Created } + '400': { description: Invalid input } + /accounts/overview: + get: + summary: Get aggregated overview with net worth + tags: [Accounts] + security: [{ bearerAuth: [] }] + responses: + '200': + description: OK + content: + application/json: + schema: { $ref: '#/components/schemas/AccountOverview' } + /accounts/{accountId}: + get: + summary: Get account + tags: [Accounts] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: accountId + required: true + schema: { type: integer } + responses: + '200': { description: OK } + '404': { description: Not found } + put: + summary: Update account + tags: [Accounts] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: accountId + required: true + schema: { type: integer } + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/NewAccount' } + responses: + '200': { description: Updated } + '404': { description: Not found } + delete: + summary: Deactivate account (soft-delete) + tags: [Accounts] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: accountId + required: true + schema: { type: integer } + responses: + '200': { description: Deactivated } + '404': { description: Not found } /bills: get: summary: List bills @@ -587,3 +667,31 @@ components: message: { type: string } send_at: { type: string, format: date-time } channel: { type: string, enum: [email, whatsapp], default: email } + Account: + type: object + properties: + id: { type: integer } + name: { type: string } + account_type: { type: string, enum: [checking, savings, credit, cash, investment] } + currency: { type: string } + balance: { type: number, format: float } + is_active: { type: boolean } + color: { type: string, nullable: true } + created_at: { type: string, format: date-time } + NewAccount: + type: object + required: [name, account_type] + properties: + name: { type: string } + account_type: { type: string, enum: [checking, savings, credit, cash, investment] } + currency: { type: string, default: INR } + balance: { type: number, format: float, default: 0 } + color: { type: string } + AccountOverview: + type: object + properties: + total_balance: { type: number, format: float } + net_worth: { type: number, format: float } + account_count: { type: integer } + by_type: { type: object } + accounts: { type: array, items: { $ref: '#/components/schemas/Account' } } diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..f1fe6164 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 00000000..4c69df9a --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,80 @@ +"""Financial accounts endpoints.""" + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..services import accounts as account_service + +bp = Blueprint("accounts", __name__) + + +@bp.get("/") +@jwt_required() +def list_accounts(): + user_id = int(get_jwt_identity()) + include_inactive = request.args.get("include_inactive", "false").lower() == "true" + items = account_service.get_accounts(user_id, active_only=not include_inactive) + return jsonify([account_service.serialize_account(a) for a in items]), 200 + + +@bp.post("/") +@jwt_required() +def create_account(): + user_id = int(get_jwt_identity()) + data = request.get_json() + + if not data or not data.get("name") or not data.get("account_type"): + return jsonify(error="name and account_type are required"), 400 + + account, error = account_service.create_account( + user_id=user_id, + name=data["name"], + account_type=data["account_type"], + currency=data.get("currency", "INR"), + balance=data.get("balance", 0), + color=data.get("color"), + ) + if error: + return jsonify(error=error), 400 + + return jsonify(account_service.serialize_account(account)), 201 + + +@bp.get("/overview") +@jwt_required() +def get_overview(): + user_id = int(get_jwt_identity()) + overview = account_service.get_overview(user_id) + return jsonify(overview), 200 + + +@bp.get("/") +@jwt_required() +def get_account(account_id): + user_id = int(get_jwt_identity()) + account = account_service.get_account(account_id, user_id) + if not account: + return jsonify(error="Account not found"), 404 + return jsonify(account_service.serialize_account(account)), 200 + + +@bp.put("/") +@jwt_required() +def update_account(account_id): + user_id = int(get_jwt_identity()) + data = request.get_json() or {} + account, error = account_service.update_account(account_id, user_id, **data) + if error: + status_code = 404 if "not found" in error.lower() else 400 + return jsonify(error=error), status_code + return jsonify(account_service.serialize_account(account)), 200 + + +@bp.delete("/") +@jwt_required() +def deactivate_account(account_id): + user_id = int(get_jwt_identity()) + success = account_service.delete_account(account_id, user_id) + if not success: + return jsonify(error="Account not found"), 404 + return jsonify(message="Account deactivated"), 200 diff --git a/packages/backend/app/services/accounts.py b/packages/backend/app/services/accounts.py new file mode 100644 index 00000000..38ede7cf --- /dev/null +++ b/packages/backend/app/services/accounts.py @@ -0,0 +1,119 @@ +"""Financial accounts management service.""" + +from ..extensions import db +from ..models import FinancialAccount + + +VALID_ACCOUNT_TYPES = {"checking", "savings", "credit", "cash", "investment"} + + +def create_account(user_id, name, account_type, currency="INR", balance=0, color=None): + name = (name or "").strip() + account_type = (account_type or "").strip().lower() + + if not name: + return None, "Name is required" + if account_type not in VALID_ACCOUNT_TYPES: + return ( + None, + f"Invalid account type. Must be one of: {', '.join(sorted(VALID_ACCOUNT_TYPES))}", + ) + + account = FinancialAccount( + user_id=user_id, + name=name, + account_type=account_type, + currency=currency, + balance=balance, + color=color, + ) + db.session.add(account) + db.session.commit() + return account, None + + +def get_accounts(user_id, active_only=True): + query = FinancialAccount.query.filter_by(user_id=user_id) + if active_only: + query = query.filter_by(is_active=True) + return query.order_by(FinancialAccount.name).all() + + +def get_account(account_id, user_id): + return FinancialAccount.query.filter_by(id=account_id, user_id=user_id).first() + + +def update_account(account_id, user_id, **kwargs): + account = get_account(account_id, user_id) + if not account: + return None, "Account not found" + + if "account_type" in kwargs: + at = (kwargs["account_type"] or "").strip().lower() + if at not in VALID_ACCOUNT_TYPES: + return ( + None, + f"Invalid account type. Must be one of: {', '.join(sorted(VALID_ACCOUNT_TYPES))}", + ) + kwargs["account_type"] = at + + if "name" in kwargs: + kwargs["name"] = (kwargs["name"] or "").strip() + + for key in ("name", "account_type", "currency", "balance", "is_active", "color"): + if key in kwargs and kwargs[key] is not None: + setattr(account, key, kwargs[key]) + + db.session.commit() + return account, None + + +def delete_account(account_id, user_id): + account = get_account(account_id, user_id) + if not account: + return False + account.is_active = False + db.session.commit() + return True + + +def get_overview(user_id): + """Get aggregated overview across all active accounts.""" + accounts = get_accounts(user_id, active_only=True) + + total_balance = sum(float(a.balance) for a in accounts) + by_type = {} + for a in accounts: + by_type.setdefault(a.account_type, {"count": 0, "total": 0}) + by_type[a.account_type]["count"] += 1 + by_type[a.account_type]["total"] += float(a.balance) + + # Net worth: assets minus liabilities. + # Credit account balances represent amounts owed (stored as positive numbers), + # so they are subtracted from total assets to compute net worth. + assets = sum(float(a.balance) for a in accounts if a.account_type != "credit") + liabilities = sum(float(a.balance) for a in accounts if a.account_type == "credit") + + return { + "total_balance": round(total_balance, 2), + "net_worth": round(assets - liabilities, 2), + "account_count": len(accounts), + "by_type": { + k: {"count": v["count"], "total": round(v["total"], 2)} + for k, v in by_type.items() + }, + "accounts": [serialize_account(a) for a in accounts], + } + + +def serialize_account(account): + return { + "id": account.id, + "name": account.name, + "account_type": account.account_type, + "currency": account.currency, + "balance": float(account.balance), + "is_active": account.is_active, + "color": account.color, + "created_at": account.created_at.isoformat(), + } diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 00000000..0af9c50d --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -0,0 +1,153 @@ +"""Tests for multi-account financial overview.""" + + +class TestAccountsCRUD: + def test_create_account(self, 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 + assert data["is_active"] + + def test_create_invalid_type(self, client, auth_header): + r = client.post( + "/accounts/", + json={"name": "Bad", "account_type": "invalid"}, + headers=auth_header, + ) + assert r.status_code == 400 + + def test_create_missing_fields(self, client, auth_header): + r = client.post("/accounts/", json={"name": "No Type"}, headers=auth_header) + assert r.status_code == 400 + + def test_list_accounts(self, 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(self, client, auth_header): + r = client.post( + "/accounts/", + json={"name": "Test", "account_type": "cash"}, + 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()["account_type"] == "cash" + + def test_update_account(self, client, auth_header): + r = client.post( + "/accounts/", + json={"name": "Old", "account_type": "checking"}, + headers=auth_header, + ) + aid = r.get_json()["id"] + + r = client.put( + f"/accounts/{aid}", + json={"name": "New", "balance": 999}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["name"] == "New" + assert r.get_json()["balance"] == 999 + + def test_deactivate_account(self, client, auth_header): + r = client.post( + "/accounts/", + json={"name": "Bye", "account_type": "savings"}, + headers=auth_header, + ) + aid = r.get_json()["id"] + + r = client.delete(f"/accounts/{aid}", headers=auth_header) + assert r.status_code == 200 + + # Should not appear in active-only list + r = client.get("/accounts/", headers=auth_header) + assert len(r.get_json()) == 0 + + # But appears with include_inactive + r = client.get("/accounts/?include_inactive=true", headers=auth_header) + assert len(r.get_json()) == 1 + + +class TestOverview: + def test_empty_overview(self, client, auth_header): + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["total_balance"] == 0 + assert data["account_count"] == 0 + + def test_overview_with_accounts(self, client, auth_header): + client.post( + "/accounts/", + json={"name": "Checking", "account_type": "checking", "balance": 3000}, + headers=auth_header, + ) + client.post( + "/accounts/", + json={"name": "Savings", "account_type": "savings", "balance": 10000}, + headers=auth_header, + ) + client.post( + "/accounts/", + json={"name": "Credit Card", "account_type": "credit", "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"] == 15000 + assert data["net_worth"] == 11000 # (3000 + 10000) - 2000 + assert data["account_count"] == 3 + assert "checking" in data["by_type"] + assert "savings" in data["by_type"] + assert "credit" in data["by_type"] + assert len(data["accounts"]) == 3 + + def test_overview_excludes_inactive(self, client, auth_header): + r = client.post( + "/accounts/", + json={"name": "Active", "account_type": "checking", "balance": 1000}, + headers=auth_header, + ) + active_id = r.get_json()["id"] + + r = client.post( + "/accounts/", + json={"name": "Inactive", "account_type": "savings", "balance": 5000}, + headers=auth_header, + ) + inactive_id = r.get_json()["id"] + client.delete(f"/accounts/{inactive_id}", headers=auth_header) + + r = client.get("/accounts/overview", headers=auth_header) + data = r.get_json() + assert data["total_balance"] == 1000 + assert data["account_count"] == 1