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
46 changes: 46 additions & 0 deletions app/src/api/accounts.ts
Original file line number Diff line number Diff line change
@@ -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<FinancialAccount[]>('/accounts/'),
});
};

export const useCreateAccount = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<FinancialAccount>) => api<FinancialAccount>('/accounts/', { method: 'POST', body: data }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['accounts'] }),
});
};

export const useUpdateAccount = (accountId: number) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<FinancialAccount>) => api<FinancialAccount>(`/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'] }),
});
};
39 changes: 39 additions & 0 deletions app/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@ 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);
}

export function Dashboard() {
const navigate = useNavigate();
const { data: accounts, isLoading: accountsLoading } = useAccounts();
const [data, setData] = useState<DashboardSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -166,6 +170,41 @@ export function Dashboard() {
))}
</div>

{/* Financial Accounts Section */}
<div className="mb-8 fade-in-up">
<div className="flex items-center justify-between mb-4">
<h2 className="section-title">My Accounts</h2>
<Button variant="outline" size="sm" onClick={() => navigate('/account')}>
Manage
</Button>
</div>
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{accountsLoading ? (
<div className="col-span-full flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
) : accounts && accounts.length > 0 ? (
accounts.map((account) => (
<div key={account.id} className="bg-card border rounded-xl p-3 shadow-sm hover:shadow-md transition-all cursor-pointer group">
<div className="flex items-center justify-between mb-2">
<Badge variant="outline" className="text-[10px] uppercase">{account.account_type}</Badge>
<Wallet className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
</div>
<div className="text-sm font-medium truncate mb-1">{account.name}</div>
<div className="text-lg font-bold">{currency(account.balance, account.currency)}</div>
</div>
))
) : (
<div className="col-span-full bg-muted/30 border border-dashed rounded-xl p-6 text-center">
<p className="text-sm text-muted-foreground mb-3">No accounts connected yet.</p>
<Button size="sm" onClick={() => navigate('/account')}>
<Plus className="w-3 h-3 mr-2" /> Add Your First Account
</Button>
</div>
)}
</div>
</div>

<div className="grid lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<FinancialCard variant="financial" className="fade-in-up">
Expand Down
38 changes: 38 additions & 0 deletions app/src/pages/Expenses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Expense[]>([]);
Expand Down Expand Up @@ -554,6 +584,14 @@ export default function Expenses() {
<div className="flex items-end gap-2">
<Button variant="outline" onClick={() => { setFrom(''); setTo(''); setFilterCategoryId(''); setSearch(''); setPage(1); }}>Reset</Button>
<Button onClick={() => { setPage(1); refresh(); }}>Apply</Button>
<div className="flex gap-1 ml-auto">
<Button variant="outline" size="sm" title="Export as JSON" onClick={onExportJSON}>
<Download className="w-4 h-4 mr-1" /> JSON
</Button>
<Button variant="outline" size="sm" title="Export as CSV" onClick={onExportCSV}>
<Download className="w-4 h-4 mr-1" /> CSV
</Button>
</div>
</div>
</div>

Expand Down
29 changes: 29 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
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")
94 changes: 94 additions & 0 deletions packages/backend/app/routes/accounts.py
Original file line number Diff line number Diff line change
@@ -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("/<int:account_id>")
@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("/<int:account_id>")
@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("/<int:account_id>")
@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()
}