diff --git a/app/src/api/accounts.ts b/app/src/api/accounts.ts new file mode 100644 index 000000000..a8ee3faf8 --- /dev/null +++ b/app/src/api/accounts.ts @@ -0,0 +1,46 @@ +import { api } from './client'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +export type AccountType = 'BANK' | 'CASH' | 'CREDIT_CARD' | 'INVESTMENT'; + +export type FinancialAccount = { + id: number; + name: string; + account_type: AccountType; + balance: number; + currency: string; + created_at: string; +}; + +export const useAccounts = () => { + return useQuery({ + queryKey: ['accounts'], + queryFn: () => api('/accounts/'), + }); +}; + +export const useCreateAccount = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => api('/accounts/', { method: 'POST', body: data }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['accounts'] }), + }); +}; + +export const useUpdateAccount = (accountId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Partial) => api(`/accounts/${accountId}`, { method: 'PATCH', body: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + }, + }); +}; + +export const useDeleteAccount = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (accountId: number) => api(`/accounts/${accountId}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['accounts'] }), + }); +}; diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx index b2d4e7aa8..083858df6 100644 --- a/app/src/pages/Dashboard.tsx +++ b/app/src/pages/Dashboard.tsx @@ -20,8 +20,11 @@ import { Plus, } from 'lucide-react'; import { getDashboardSummary, type DashboardSummary } from '@/api/dashboard'; +import { useAccounts } from '@/api/accounts'; import { useNavigate } from 'react-router-dom'; import { formatMoney } from '@/lib/currency'; +import { Badge } from '@/components/ui/badge'; +import { Loader2 } from 'lucide-react'; function currency(n: number, code?: string) { return formatMoney(Number(n || 0), code); @@ -29,6 +32,7 @@ function currency(n: number, code?: string) { export function Dashboard() { const navigate = useNavigate(); + const { data: accounts, isLoading: accountsLoading } = useAccounts(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -166,6 +170,41 @@ export function Dashboard() { ))} + {/* Financial Accounts Section */} +
+
+

My Accounts

+ +
+
+ {accountsLoading ? ( +
+ +
+ ) : accounts && accounts.length > 0 ? ( + accounts.map((account) => ( +
+
+ {account.account_type} + +
+
{account.name}
+
{currency(account.balance, account.currency)}
+
+ )) + ) : ( +
+

No accounts connected yet.

+ +
+ )} +
+
+
diff --git a/app/src/pages/Expenses.tsx b/app/src/pages/Expenses.tsx index e06058b6e..cf8435df8 100644 --- a/app/src/pages/Expenses.tsx +++ b/app/src/pages/Expenses.tsx @@ -48,8 +48,38 @@ import { import { listCategories, type Category } from '@/api/categories'; import { formatMoney } from '@/lib/currency'; +import { Calendar, Download, Plus, Search, Trash2 } from 'lucide-react'; + export default function Expenses() { const { toast } = useToast(); + + const onExportJSON = () => { + const blob = new Blob([JSON.stringify(allItems, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `expenses-${new Date().toISOString().slice(0, 10)}.json`; + link.click(); + }; + + const onExportCSV = () => { + if (allItems.length === 0) return; + const headers = ['Date', 'Description', 'Category', 'Amount', 'Currency']; + const rows = allItems.map((e) => [ + e.date.slice(0, 10), + `"${e.description.replace(/"/g, '""')}"`, + `"${categoryMap.get(e.category_id as number) || '—'}"`, + e.amount, + e.currency + ]); + const csvContent = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `expenses-${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + }; const getErrorMessage = (error: unknown, fallback: string) => error instanceof Error ? error.message : fallback; const [items, setItems] = useState([]); @@ -554,6 +584,14 @@ export default function Expenses() {
+
+ + +
diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..f074a1fd5 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -19,6 +19,26 @@ class User(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +class AccountType(str, Enum): + BANK = "BANK" + CASH = "CASH" + CREDIT_CARD = "CREDIT_CARD" + INVESTMENT = "INVESTMENT" + + +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(100), nullable=False) + account_type = db.Column( + SAEnum(AccountType), default=AccountType.BANK, nullable=False + ) + balance = db.Column(db.Numeric(12, 2), default=0, nullable=False) + currency = db.Column(db.String(10), default="INR", nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + class Category(db.Model): __tablename__ = "categories" id = db.Column(db.Integer, primary_key=True) @@ -31,6 +51,9 @@ class Expense(db.Model): __tablename__ = "expenses" id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + account_id = db.Column( + db.Integer, db.ForeignKey("financial_accounts.id"), nullable=True + ) category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=True) amount = db.Column(db.Numeric(12, 2), nullable=False) currency = db.Column(db.String(10), default="INR", nullable=False) @@ -54,6 +77,9 @@ class RecurringExpense(db.Model): __tablename__ = "recurring_expenses" id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + account_id = db.Column( + db.Integer, db.ForeignKey("financial_accounts.id"), nullable=True + ) category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=True) amount = db.Column(db.Numeric(12, 2), nullable=False) currency = db.Column(db.String(10), default="INR", nullable=False) @@ -77,6 +103,9 @@ class Bill(db.Model): __tablename__ = "bills" id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + account_id = db.Column( + db.Integer, db.ForeignKey("financial_accounts.id"), nullable=True + ) name = db.Column(db.String(200), nullable=False) amount = db.Column(db.Numeric(12, 2), nullable=False) currency = db.Column(db.String(10), default="INR", nullable=False) 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..c220b00a0 --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,94 @@ +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import FinancialAccount, AccountType, User +from decimal import Decimal, InvalidOperation +import logging + +bp = Blueprint("accounts", __name__) +logger = logging.getLogger("finmind.accounts") + + +@bp.get("/") +@jwt_required() +def list_accounts(): + uid = int(get_jwt_identity()) + accounts = db.session.query(FinancialAccount).filter_by(user_id=uid).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() or {} + + name = str(data.get("name") or "").strip() + if not name: + return jsonify(error="name required"), 400 + + account = FinancialAccount( + user_id=uid, + name=name, + account_type=AccountType(data.get("account_type", "BANK")), + balance=Decimal(str(data.get("balance", 0))), + currency=data.get("currency", "INR") + ) + + db.session.add(account) + db.session.commit() + + return jsonify(_account_to_dict(account)), 201 + + +@bp.get("/") +@jwt_required() +def get_account(account_id: int): + uid = int(get_jwt_identity()) + account = db.session.get(FinancialAccount, account_id) + if not account or account.user_id != uid: + return jsonify(error="not found"), 404 + return jsonify(_account_to_dict(account)) + + +@bp.patch("/") +@jwt_required() +def update_account(account_id: int): + uid = int(get_jwt_identity()) + account = db.session.get(FinancialAccount, account_id) + if not account or account.user_id != uid: + return jsonify(error="not found"), 404 + + data = request.get_json() or {} + if "name" in data: + account.name = data["name"] + if "balance" in data: + account.balance = Decimal(str(data["balance"])) + if "account_type" in data: + account.account_type = AccountType(data["account_type"]) + + db.session.commit() + return jsonify(_account_to_dict(account)) + + +@bp.delete("/") +@jwt_required() +def delete_account(account_id: int): + uid = int(get_jwt_identity()) + account = db.session.get(FinancialAccount, account_id) + if not account or account.user_id != uid: + return jsonify(error="not found"), 404 + db.session.delete(account) + db.session.commit() + return jsonify(message="deleted") + + +def _account_to_dict(a: FinancialAccount) -> dict: + return { + "id": a.id, + "name": a.name, + "account_type": a.account_type.value, + "balance": float(a.balance), + "currency": a.currency, + "created_at": a.created_at.isoformat() + }