diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..410d982c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,11 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +<<<<<<< Updated upstream +======= +import SavingsGoals from "./pages/SavingsGoals"; +import AccountsOverview from "./pages/AccountsOverview"; +>>>>>>> Stashed changes const queryClient = new QueryClient({ defaultOptions: { @@ -84,6 +89,25 @@ const App = () => ( } /> + + + } + /> + + + + } + /> + >>>>>> Stashed changes path="account" element={ diff --git a/app/src/__tests__/AccountsOverview.integration.test.tsx b/app/src/__tests__/AccountsOverview.integration.test.tsx new file mode 100644 index 00000000..ef8f9ee4 --- /dev/null +++ b/app/src/__tests__/AccountsOverview.integration.test.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AccountsOverview from '@/pages/AccountsOverview'; + +const toastMock = jest.fn(); +jest.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ toast: toastMock }), +})); + +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes) => ( + + ), +})); +jest.mock('@/components/ui/badge', () => ({ + Badge: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes) => ( + {children} + ), +})); +jest.mock('@/components/ui/progress', () => ({ + Progress: ({ value, ...props }: { value?: number } & React.HTMLAttributes) => ( +
+ ), +})); +jest.mock('@/components/ui/financial-card', () => ({ + FinancialCard: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes) =>
{children}
, + FinancialCardContent: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes) =>
{children}
, + FinancialCardDescription: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes) =>
{children}
, + FinancialCardHeader: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes) =>
{children}
, + FinancialCardTitle: ({ children, ...props }: React.PropsWithChildren & React.HTMLAttributes) =>
{children}
, +})); +jest.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open }: React.PropsWithChildren & { open?: boolean }) => open ?
{children}
: null, + DialogContent: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogHeader: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogTitle: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogDescription: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogTrigger: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogFooter: ({ children }: React.PropsWithChildren) =>
{children}
, +})); +jest.mock('@/components/ui/alert-dailog', () => ({ + AlertDialog: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogTrigger: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogContent: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogHeader: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogTitle: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogDescription: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogFooter: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogCancel: ({ children }: React.PropsWithChildren) => , + AlertDialogAction: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes) => , +})); +jest.mock('@/lib/currency', () => ({ + formatMoney: (amount: number) => `$${Number(amount || 0).toFixed(2)}`, +})); + +const listAccountsMock = jest.fn(); +const createAccountMock = jest.fn(); +const updateAccountMock = jest.fn(); +const deleteAccountMock = jest.fn(); +const getAccountsOverviewMock = jest.fn(); +jest.mock('@/api/accounts', () => ({ + listAccounts: (...args: unknown[]) => listAccountsMock(...args), + createAccount: (...args: unknown[]) => createAccountMock(...args), + updateAccount: (...args: unknown[]) => updateAccountMock(...args), + deleteAccount: (...args: unknown[]) => deleteAccountMock(...args), + getAccountsOverview: (...args: unknown[]) => getAccountsOverviewMock(...args), +})); + +const sampleAccount = { + id: 1, + name: 'Main Checking', + account_type: 'BANK', + institution: 'Chase', + balance: 5000, + currency: 'USD', + color: '#3B82F6', + active: true, + created_at: '2025-01-01T00:00:00', +}; + +const sampleOverview = { + total_balance: 15000, + net_worth: 13000, + total_assets: 15000, + total_liabilities: 2000, + account_count: 2, + type_breakdown: [ + { + type: 'BANK', + total: 13000, + count: 1, + accounts: [sampleAccount], + }, + { + type: 'CREDIT', + total: 2000, + count: 1, + accounts: [{ + ...sampleAccount, + id: 2, + name: 'Visa Card', + account_type: 'CREDIT', + balance: 2000, + color: '#EF4444', + }], + }, + ], +}; + +describe('AccountsOverview integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + listAccountsMock.mockResolvedValue([sampleAccount]); + getAccountsOverviewMock.mockResolvedValue(sampleOverview); + deleteAccountMock.mockResolvedValue({ message: 'deleted' }); + }); + + it('renders accounts list with balance', async () => { + render(); + await waitFor(() => expect(listAccountsMock).toHaveBeenCalled()); + + expect(await screen.findByText('Main Checking')).toBeInTheDocument(); + expect(screen.getByText('$5000.00')).toBeInTheDocument(); + }); + + it('shows empty state when no accounts', async () => { + listAccountsMock.mockResolvedValue([]); + getAccountsOverviewMock.mockResolvedValue({ + total_balance: 0, + net_worth: 0, + total_assets: 0, + total_liabilities: 0, + account_count: 0, + type_breakdown: [], + }); + render(); + await waitFor(() => expect(listAccountsMock).toHaveBeenCalled()); + + expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument(); + expect(screen.getByText(/add your first account/i)).toBeInTheDocument(); + }); + + it('shows net worth and summary stats', async () => { + render(); + await waitFor(() => expect(getAccountsOverviewMock).toHaveBeenCalled()); + + // Net worth + expect(await screen.findByText('$13000.00')).toBeInTheDocument(); + // Total assets + expect(screen.getByText('$15000.00')).toBeInTheDocument(); + // Total liabilities + expect(screen.getByText('$2000.00')).toBeInTheDocument(); + }); + + it('shows type breakdown chart', async () => { + render(); + await waitFor(() => expect(getAccountsOverviewMock).toHaveBeenCalled()); + + // Progress bars in breakdown + const progressBars = await screen.findAllByRole('progressbar'); + expect(progressBars.length).toBeGreaterThan(0); + }); + + it('deletes an account when confirmed', async () => { + listAccountsMock.mockResolvedValue([sampleAccount]); + render(); + await waitFor(() => expect(listAccountsMock).toHaveBeenCalled()); + await screen.findByText('Main Checking'); + + // Click the delete confirmation button + const deleteButtons = screen.getAllByText('Delete'); + await userEvent.click(deleteButtons[deleteButtons.length - 1]); + + await waitFor(() => expect(deleteAccountMock).toHaveBeenCalledWith(1)); + expect(toastMock).toHaveBeenCalledWith({ title: 'Account deleted' }); + }); +}); diff --git a/app/src/api/accounts.ts b/app/src/api/accounts.ts new file mode 100644 index 00000000..428a70e7 --- /dev/null +++ b/app/src/api/accounts.ts @@ -0,0 +1,71 @@ +import { api } from './client'; + +export type AccountType = 'BANK' | 'CREDIT' | 'INVESTMENT' | 'CASH'; + +export type Account = { + id: number; + name: string; + account_type: AccountType; + institution: string | null; + balance: number; + currency: string; + color: string; + active: boolean; + created_at: string | null; +}; + +export type AccountCreate = { + name: string; + account_type?: AccountType; + institution?: string; + balance?: number; + currency?: string; + color?: string; +}; + +export type AccountUpdate = Partial & { active?: boolean }; + +export type TypeBreakdown = { + type: AccountType; + total: number; + count: number; + accounts: Account[]; +}; + +export type AccountsOverview = { + total_balance: number; + net_worth: number; + total_assets: number; + total_liabilities: number; + account_count: number; + type_breakdown: TypeBreakdown[]; +}; + +export async function listAccounts(params?: { + include_inactive?: boolean; +}): Promise { + const qs = new URLSearchParams(); + if (params?.include_inactive) qs.set('include_inactive', 'true'); + const path = '/accounts' + (qs.toString() ? `?${qs.toString()}` : ''); + return api(path); +} + +export async function getAccount(id: number): Promise { + return api(`/accounts/${id}`); +} + +export async function createAccount(payload: AccountCreate): Promise { + return api('/accounts', { method: 'POST', body: payload }); +} + +export async function updateAccount(id: number, payload: AccountUpdate): Promise { + return api(`/accounts/${id}`, { method: 'PATCH', body: payload }); +} + +export async function deleteAccount(id: number): Promise<{ message: string }> { + return api<{ message: string }>(`/accounts/${id}`, { method: 'DELETE' }); +} + +export async function getAccountsOverview(): Promise { + return api('/accounts/overview'); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..b4bb558d 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -8,6 +8,7 @@ import { logout as logoutApi } from '@/api/auth'; const navigation = [ { name: 'Dashboard', href: '/dashboard' }, + { name: 'Accounts', href: '/accounts' }, { name: 'Budgets', href: '/budgets' }, { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, diff --git a/app/src/pages/AccountsOverview.tsx b/app/src/pages/AccountsOverview.tsx new file mode 100644 index 00000000..4195528e --- /dev/null +++ b/app/src/pages/AccountsOverview.tsx @@ -0,0 +1,549 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardDescription, + FinancialCardHeader, + FinancialCardTitle, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { + Plus, + Landmark, + CreditCard, + TrendingUp, + Wallet, + Banknote, + Pencil, + Trash2, + Building2, + DollarSign, + ArrowUpRight, + ArrowDownRight, +} from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { + listAccounts, + createAccount, + updateAccount, + deleteAccount, + getAccountsOverview, + type Account, + type AccountCreate, + type AccountType, + type AccountsOverview as OverviewData, +} from '@/api/accounts'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dailog'; +import { formatMoney } from '@/lib/currency'; + +const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [ + { value: 'BANK', label: 'Bank' }, + { value: 'CREDIT', label: 'Credit Card' }, + { value: 'INVESTMENT', label: 'Investment' }, + { value: 'CASH', label: 'Cash' }, +]; + +const COLOR_OPTIONS = [ + '#3B82F6', // blue + '#10B981', // emerald + '#F59E0B', // amber + '#EF4444', // red + '#8B5CF6', // violet + '#EC4899', // pink + '#06B6D4', // cyan + '#F97316', // orange +]; + +function AccountTypeIcon({ type, className }: { type: string; className?: string }) { + const cls = className || 'w-6 h-6'; + switch (type) { + case 'CREDIT': + return ; + case 'INVESTMENT': + return ; + case 'CASH': + return ; + default: + return ; + } +} + +function AccountTypeLabel({ type }: { type: string }) { + const labels: Record = { + BANK: 'Bank Account', + CREDIT: 'Credit Card', + INVESTMENT: 'Investment', + CASH: 'Cash', + }; + return <>{labels[type] || type}; +} + +export default function AccountsOverview() { + const { toast } = useToast(); + const [accounts, setAccounts] = useState([]); + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(true); + const [showInactive, setShowInactive] = useState(false); + + // Create/Edit modal state + const [modalOpen, setModalOpen] = useState(false); + const [editingAccount, setEditingAccount] = useState(null); + const [formName, setFormName] = useState(''); + const [formType, setFormType] = useState('BANK'); + const [formInstitution, setFormInstitution] = useState(''); + const [formBalance, setFormBalance] = useState(''); + const [formColor, setFormColor] = useState('#3B82F6'); + const [saving, setSaving] = useState(false); + + const getErrorMessage = (error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback; + + const refresh = useCallback(async () => { + setLoading(true); + try { + const [accountsData, overviewData] = await Promise.all([ + listAccounts({ include_inactive: showInactive }), + getAccountsOverview(), + ]); + setAccounts(accountsData); + setOverview(overviewData); + } catch (error: unknown) { + toast({ title: 'Failed to load accounts', description: getErrorMessage(error, 'Please try again.') }); + } finally { + setLoading(false); + } + }, [toast, showInactive]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + function openCreate() { + setEditingAccount(null); + setFormName(''); + setFormType('BANK'); + setFormInstitution(''); + setFormBalance(''); + setFormColor('#3B82F6'); + setModalOpen(true); + } + + function openEdit(account: Account) { + setEditingAccount(account); + setFormName(account.name); + setFormType(account.account_type); + setFormInstitution(account.institution || ''); + setFormBalance(String(account.balance)); + setFormColor(account.color); + setModalOpen(true); + } + + async function onSave() { + if (!formName.trim()) return; + setSaving(true); + try { + if (editingAccount) { + await updateAccount(editingAccount.id, { + name: formName.trim(), + account_type: formType, + institution: formInstitution.trim() || undefined, + balance: formBalance ? Number(formBalance) : undefined, + color: formColor, + }); + toast({ title: 'Account updated' }); + } else { + const payload: AccountCreate = { + name: formName.trim(), + account_type: formType, + color: formColor, + }; + if (formInstitution.trim()) payload.institution = formInstitution.trim(); + if (formBalance) payload.balance = Number(formBalance); + await createAccount(payload); + toast({ title: 'Account added' }); + } + setModalOpen(false); + await refresh(); + } catch (error: unknown) { + toast({ title: 'Failed to save account', description: getErrorMessage(error, 'Please try again.') }); + } finally { + setSaving(false); + } + } + + // Compute type breakdown percentages for chart + const typeBreakdown = overview?.type_breakdown || []; + const totalForChart = typeBreakdown.reduce((s, t) => s + Math.abs(t.total), 0); + + return ( +
+ {/* Header */} +
+
+
+

Accounts Overview

+

View all your financial accounts in one place

+
+
+ + +
+
+
+ + {/* Net Worth & Summary Cards */} +
+ + +
+ + Net Worth + + +
+
+ +
+ {formatMoney(overview?.net_worth || 0)} +
+
+ across {overview?.account_count || 0} account{(overview?.account_count || 0) !== 1 ? 's' : ''} +
+
+
+ + + +
+ + Total Assets + + +
+
+ +
+ {formatMoney(overview?.total_assets || 0)} +
+
+ Bank, Investment & Cash +
+
+
+ + + +
+ + Total Liabilities + + +
+
+ +
+ {formatMoney(overview?.total_liabilities || 0)} +
+
+ Credit cards & loans +
+
+
+ + + +
+ + Total Balance + + +
+
+ +
+ {formatMoney(overview?.total_balance || 0)} +
+
+ All accounts combined +
+
+
+
+ + {/* Type Breakdown */} + {typeBreakdown.length > 0 && ( + + + Balance by Account Type + Distribution of your finances across account types + + +
+ {typeBreakdown.map((item) => { + const pct = totalForChart > 0 ? Math.round((Math.abs(item.total) / totalForChart) * 100) : 0; + const typeColor = item.accounts[0]?.color || '#3B82F6'; + return ( +
+
+
+
+ + + + + + {item.count} account{item.count !== 1 ? 's' : ''} + +
+ + {formatMoney(item.total)} + +
+ +
+ ); + })} +
+ + + )} + + {/* Account Cards */} +
+ {loading ? ( +
Loading...
+ ) : accounts.length === 0 ? ( +
+ + + +

No accounts yet

+

+ Add your bank accounts, credit cards, and investments to get a complete financial overview. +

+ +
+
+
+ ) : ( + accounts.map((account) => ( + + +
+
+
+ +
+
+ {account.name} + + {account.institution ? ( + + + {account.institution} + + ) : ( + + )} + +
+
+
+ + + + + + + + Delete account? + + This will permanently delete "{account.name}". Transactions linked to this account will be unlinked. + + + + Cancel + { + try { + await deleteAccount(account.id); + await refresh(); + toast({ title: 'Account deleted' }); + } catch (error: unknown) { + toast({ + title: 'Delete failed', + description: getErrorMessage(error, 'Please try again.'), + }); + } + }} + > + Delete + + + + +
+
+
+ +
+
+ + {formatMoney(account.balance, account.currency)} + + + {account.account_type === 'CREDIT' ? 'Liability' : 'Asset'} + +
+ {!account.active && ( + + Inactive + + )} +
+
+
+ )) + )} +
+ + {/* Create/Edit Account Dialog */} + + + + {editingAccount ? 'Edit Account' : 'Add Account'} + + {editingAccount + ? 'Update your account details.' + : 'Add a financial account to track your overall finances.'} + + +
+
+ + setFormName(e.target.value)} + placeholder="e.g. Main Checking, Visa Card, Brokerage" + /> +
+
+
+ + +
+
+ + setFormBalance(e.target.value)} + placeholder="0.00" + /> +
+
+
+ + setFormInstitution(e.target.value)} + placeholder="e.g. Chase, Vanguard, PayPal" + /> +
+
+ +
+ {COLOR_OPTIONS.map((c) => ( +
+
+
+ + + + +
+
+
+ ); +} diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..23a168d2 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,47 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); +<<<<<<< Updated upstream +======= + +CREATE TABLE IF NOT EXISTS accounts ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + account_type VARCHAR(20) NOT NULL DEFAULT 'BANK', + institution VARCHAR(200), + balance NUMERIC(14,2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + color VARCHAR(7) NOT NULL DEFAULT '#3B82F6', + 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); + +ALTER TABLE expenses + ADD COLUMN IF NOT EXISTS account_id INT REFERENCES accounts(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS savings_goals ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + target_amount NUMERIC(12,2) NOT NULL, + current_amount NUMERIC(12,2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + target_date DATE, + icon VARCHAR(50) NOT NULL DEFAULT 'piggy-bank', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id, active); + +CREATE TABLE IF NOT EXISTS savings_contributions ( + id SERIAL PRIMARY KEY, + goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE, + amount NUMERIC(12,2) NOT NULL, + notes VARCHAR(500), + contributed_at DATE NOT NULL DEFAULT CURRENT_DATE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_savings_contributions_goal ON savings_contributions(goal_id, contributed_at DESC); +>>>>>>> Stashed changes diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..64458fca 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -37,6 +37,7 @@ class Expense(db.Model): expense_type = db.Column(db.String(20), default="EXPENSE", nullable=False) notes = db.Column(db.String(500), nullable=True) spent_at = db.Column(db.Date, default=date.today, nullable=False) + account_id = db.Column(db.Integer, db.ForeignKey("accounts.id"), nullable=True) source_recurring_id = db.Column( db.Integer, db.ForeignKey("recurring_expenses.id"), nullable=True ) @@ -133,3 +134,53 @@ 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) +<<<<<<< Updated upstream +======= + + +class AccountType(str, Enum): + BANK = "BANK" + CREDIT = "CREDIT" + INVESTMENT = "INVESTMENT" + CASH = "CASH" + + +class Account(db.Model): + __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), nullable=False, default=AccountType.BANK.value) + institution = db.Column(db.String(200), nullable=True) + balance = db.Column(db.Numeric(14, 2), nullable=False, default=0) + currency = db.Column(db.String(10), default="INR", nullable=False) + color = db.Column(db.String(7), default="#3B82F6", nullable=False) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class SavingsGoal(db.Model): + __tablename__ = "savings_goals" + 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) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + current_amount = db.Column(db.Numeric(12, 2), default=0, nullable=False) + currency = db.Column(db.String(10), default="INR", nullable=False) + target_date = db.Column(db.Date, nullable=True) + icon = db.Column(db.String(50), default="piggy-bank", nullable=False) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class SavingsContribution(db.Model): + __tablename__ = "savings_contributions" + id = db.Column(db.Integer, primary_key=True) + goal_id = db.Column( + db.Integer, db.ForeignKey("savings_goals.id"), nullable=False + ) + amount = db.Column(db.Numeric(12, 2), nullable=False) + notes = db.Column(db.String(500), nullable=True) + contributed_at = db.Column(db.Date, default=date.today, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +>>>>>>> Stashed changes diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..7fa246be 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -7,6 +7,11 @@ from .categories import bp as categories_bp from .docs import bp as docs_bp from .dashboard import bp as dashboard_bp +<<<<<<< Updated upstream +======= +from .savings_goals import bp as savings_goals_bp +from .accounts import bp as accounts_bp +>>>>>>> Stashed changes def register_routes(app: Flask): @@ -18,3 +23,8 @@ 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") +<<<<<<< Updated upstream +======= + app.register_blueprint(savings_goals_bp, url_prefix="/savings-goals") + app.register_blueprint(accounts_bp, url_prefix="/accounts") +>>>>>>> Stashed changes diff --git a/packages/backend/app/routes/accounts.py b/packages/backend/app/routes/accounts.py new file mode 100644 index 00000000..e64bc58f --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,165 @@ +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from ..extensions import db +from ..models import Account, AccountType, Expense, User +from ..services.cache import cache_delete_patterns +import logging + +bp = Blueprint("accounts", __name__) +logger = logging.getLogger("finmind.accounts") + +VALID_ACCOUNT_TYPES = {t.value for t in AccountType} + + +def _account_json(a: Account) -> dict: + return { + "id": a.id, + "name": a.name, + "account_type": a.account_type, + "institution": a.institution, + "balance": float(a.balance), + "currency": a.currency, + "color": a.color, + "active": a.active, + "created_at": a.created_at.isoformat() if a.created_at else None, + } + + +@bp.get("") +@jwt_required() +def list_accounts(): + uid = int(get_jwt_identity()) + include_inactive = request.args.get("include_inactive", "false").lower() == "true" + query = db.session.query(Account).filter_by(user_id=uid) + if not include_inactive: + query = query.filter_by(active=True) + items = query.order_by(Account.created_at.desc()).all() + logger.info("List accounts user=%s count=%s", uid, len(items)) + return jsonify([_account_json(a) for a in items]) + + +@bp.post("") +@jwt_required() +def create_account(): + uid = int(get_jwt_identity()) + user = db.session.get(User, uid) + data = request.get_json() or {} + if not data.get("name"): + return jsonify(error="name is required"), 400 + account_type = data.get("account_type", AccountType.BANK.value).upper() + if account_type not in VALID_ACCOUNT_TYPES: + return jsonify(error=f"account_type must be one of: {', '.join(sorted(VALID_ACCOUNT_TYPES))}"), 400 + a = Account( + user_id=uid, + name=data["name"], + account_type=account_type, + institution=data.get("institution"), + balance=float(data.get("balance", 0)), + currency=data.get("currency") or (user.preferred_currency if user else "INR"), + color=data.get("color", "#3B82F6"), + ) + db.session.add(a) + db.session.commit() + logger.info("Created account id=%s user=%s name=%s", a.id, uid, a.name) + cache_delete_patterns([f"user:{uid}:dashboard_summary:*"]) + return jsonify(_account_json(a)), 201 + + +@bp.get("/") +@jwt_required() +def get_account(account_id: int): + uid = int(get_jwt_identity()) + a = db.session.get(Account, account_id) + if not a or a.user_id != uid: + return jsonify(error="not found"), 404 + return jsonify(_account_json(a)) + + +@bp.patch("/") +@jwt_required() +def update_account(account_id: int): + uid = int(get_jwt_identity()) + a = db.session.get(Account, account_id) + if not a or a.user_id != uid: + return jsonify(error="not found"), 404 + data = request.get_json() or {} + if "name" in data: + a.name = data["name"] + if "account_type" in data: + val = data["account_type"].upper() + if val not in VALID_ACCOUNT_TYPES: + return jsonify(error=f"account_type must be one of: {', '.join(sorted(VALID_ACCOUNT_TYPES))}"), 400 + a.account_type = val + if "institution" in data: + a.institution = data["institution"] + if "balance" in data: + a.balance = float(data["balance"]) + if "currency" in data: + a.currency = data["currency"] + if "color" in data: + a.color = data["color"] + if "active" in data: + a.active = bool(data["active"]) + db.session.commit() + logger.info("Updated account id=%s user=%s", a.id, uid) + cache_delete_patterns([f"user:{uid}:dashboard_summary:*"]) + return jsonify(_account_json(a)) + + +@bp.delete("/") +@jwt_required() +def delete_account(account_id: int): + uid = int(get_jwt_identity()) + a = db.session.get(Account, account_id) + if not a or a.user_id != uid: + return jsonify(error="not found"), 404 + db.session.delete(a) + db.session.commit() + logger.info("Deleted account id=%s user=%s", a.id, uid) + cache_delete_patterns([f"user:{uid}:dashboard_summary:*"]) + return jsonify(message="deleted") + + +@bp.get("/overview") +@jwt_required() +def accounts_overview(): + """Aggregated multi-account overview: total balance, net worth, breakdown by type.""" + uid = int(get_jwt_identity()) + accounts = ( + db.session.query(Account) + .filter_by(user_id=uid, active=True) + .all() + ) + + # Group by type + type_breakdown = {} + total_assets = 0.0 + total_liabilities = 0.0 + + for a in accounts: + atype = a.account_type + bal = float(a.balance) + if atype not in type_breakdown: + type_breakdown[atype] = {"type": atype, "total": 0.0, "count": 0, "accounts": []} + type_breakdown[atype]["total"] += bal + type_breakdown[atype]["count"] += 1 + type_breakdown[atype]["accounts"].append(_account_json(a)) + + # Credit accounts are liabilities (negative contribution to net worth) + if atype == AccountType.CREDIT.value: + total_liabilities += abs(bal) + else: + total_assets += bal + + total_balance = sum(float(a.balance) for a in accounts) + net_worth = total_assets - total_liabilities + + logger.info("Accounts overview user=%s total_accounts=%s net_worth=%s", uid, len(accounts), net_worth) + return jsonify( + total_balance=total_balance, + net_worth=net_worth, + total_assets=total_assets, + total_liabilities=total_liabilities, + account_count=len(accounts), + type_breakdown=list(type_breakdown.values()), + ) diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 00000000..a8a69faf --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -0,0 +1,167 @@ +def test_accounts_crud(client, auth_header): + # Initially empty + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + # Create account + payload = { + "name": "Main Checking", + "account_type": "BANK", + "institution": "Chase", + "balance": 5000.00, + "currency": "USD", + "color": "#10B981", + } + r = client.post("/accounts", json=payload, headers=auth_header) + assert r.status_code == 201 + account = r.get_json() + account_id = account["id"] + assert account["name"] == "Main Checking" + assert account["account_type"] == "BANK" + assert account["institution"] == "Chase" + assert account["balance"] == 5000.00 + assert account["currency"] == "USD" + assert account["color"] == "#10B981" + assert account["active"] is True + + # List has 1 + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + items = r.get_json() + assert len(items) == 1 + assert items[0]["id"] == account_id + + # Get single + r = client.get(f"/accounts/{account_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "Main Checking" + + # Update + r = client.patch( + f"/accounts/{account_id}", + json={"name": "Primary Checking", "balance": 5500}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["name"] == "Primary Checking" + assert r.get_json()["balance"] == 5500.0 + + # Delete + r = client.delete(f"/accounts/{account_id}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["message"] == "deleted" + + # List empty again + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_account_validation(client, auth_header): + # Missing required name + r = client.post("/accounts", json={}, headers=auth_header) + assert r.status_code == 400 + + # Invalid account_type + r = client.post( + "/accounts", + json={"name": "Test", "account_type": "INVALID"}, + headers=auth_header, + ) + assert r.status_code == 400 + + # Not found + r = client.get("/accounts/9999", headers=auth_header) + assert r.status_code == 404 + + +def test_accounts_overview(client, auth_header): + # Create multiple accounts of different types + accounts_data = [ + {"name": "Checking", "account_type": "BANK", "balance": 3000, "currency": "USD"}, + {"name": "Savings", "account_type": "BANK", "balance": 10000, "currency": "USD"}, + {"name": "Credit Card", "account_type": "CREDIT", "balance": 2000, "currency": "USD"}, + {"name": "401k", "account_type": "INVESTMENT", "balance": 50000, "currency": "USD"}, + {"name": "Wallet", "account_type": "CASH", "balance": 200, "currency": "USD"}, + ] + for data in accounts_data: + r = client.post("/accounts", json=data, headers=auth_header) + assert r.status_code == 201 + + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + overview = r.get_json() + + assert overview["account_count"] == 5 + # total_balance = 3000 + 10000 + 2000 + 50000 + 200 = 65200 + assert overview["total_balance"] == 65200.0 + # assets = 3000 + 10000 + 50000 + 200 = 63200 + assert overview["total_assets"] == 63200.0 + # liabilities = abs(2000) = 2000 + assert overview["total_liabilities"] == 2000.0 + # net_worth = 63200 - 2000 = 61200 + assert overview["net_worth"] == 61200.0 + + # Check type breakdown + breakdown = {item["type"]: item for item in overview["type_breakdown"]} + assert "BANK" in breakdown + assert breakdown["BANK"]["count"] == 2 + assert breakdown["BANK"]["total"] == 13000.0 + assert "CREDIT" in breakdown + assert breakdown["CREDIT"]["count"] == 1 + assert "INVESTMENT" in breakdown + assert "CASH" in breakdown + + +def test_account_defaults_to_user_preferred_currency(client, auth_header): + r = client.patch( + "/auth/me", json={"preferred_currency": "EUR"}, headers=auth_header + ) + assert r.status_code == 200 + + r = client.post( + "/accounts", + json={"name": "Euro Account"}, + headers=auth_header, + ) + assert r.status_code == 201 + assert r.get_json()["currency"] == "EUR" + + +def test_include_inactive_filter(client, auth_header): + # Create and deactivate an account + r = client.post( + "/accounts", + json={"name": "Old Account", "account_type": "BANK"}, + headers=auth_header, + ) + account_id = r.get_json()["id"] + client.patch( + f"/accounts/{account_id}", + json={"active": False}, + headers=auth_header, + ) + + # Default list excludes inactive + r = client.get("/accounts", headers=auth_header) + assert len(r.get_json()) == 0 + + # With include_inactive + r = client.get("/accounts?include_inactive=true", headers=auth_header) + assert len(r.get_json()) == 1 + + +def test_update_invalid_account_type(client, auth_header): + r = client.post( + "/accounts", + json={"name": "Test", "account_type": "BANK"}, + headers=auth_header, + ) + account_id = r.get_json()["id"] + r = client.patch( + f"/accounts/{account_id}", + json={"account_type": "INVALID"}, + headers=auth_header, + ) + assert r.status_code == 400