From df14a14cc836b9bcafa725868236dea03954e723 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Fri, 27 Mar 2026 02:38:11 +0800 Subject: [PATCH] feat: multi-account financial overview dashboard (#132) Add comprehensive multi-account support allowing users to link and manage multiple bank/financial accounts with a unified overview dashboard. Backend: - FinancialAccount model with 7 account types (CHECKING, SAVINGS, CREDIT_CARD, INVESTMENT, LOAN, CASH, OTHER) - Full CRUD API at /accounts with soft-delete support - Aggregated overview endpoint at /accounts/overview computing net worth, total assets, total liabilities, per-account monthly income/expenses, and combined recent transactions - Per-account transaction listing at /accounts/{id}/transactions - Expenses now support account_id foreign key for account linking - SQL schema migration for financial_accounts table and expenses.account_id - OpenAPI spec updated with all new endpoints and schemas - Cache key helper for accounts overview Frontend: - New /accounts page with net worth, assets, liabilities, monthly net summary cards - Accounts grouped by type with per-account income/expense stats - Create/edit account dialog with type, institution, balance, currency - Soft-delete (deactivate) accounts with confirmation - Cross-account recent transactions sidebar - Navigation updated with Accounts link - Route added to App.tsx Tests: - 18 new backend pytest tests covering CRUD, overview aggregation, per-account transactions, edge cases, all account types - 8 frontend integration tests for the Accounts page - All 40 backend tests passing (18 new + 22 existing) Documentation: - README updated with accounts endpoints, schema, and UI plan Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 9 + app/src/App.tsx | 9 + .../__tests__/Accounts.integration.test.tsx | 195 ++++++ app/src/api/accounts.ts | 125 ++++ app/src/components/layout/Navbar.tsx | 1 + app/src/pages/Accounts.tsx | 593 ++++++++++++++++++ packages/backend/app/db/schema.sql | 19 + packages/backend/app/models.py | 30 + packages/backend/app/openapi.yaml | 53 ++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/accounts.py | 356 +++++++++++ packages/backend/app/routes/expenses.py | 7 + packages/backend/app/services/cache.py | 4 + packages/backend/tests/test_accounts.py | 299 +++++++++ 14 files changed, 1702 insertions(+) create mode 100644 app/src/__tests__/Accounts.integration.test.tsx create mode 100644 app/src/api/accounts.ts create mode 100644 app/src/pages/Accounts.tsx create mode 100644 packages/backend/app/routes/accounts.py create mode 100644 packages/backend/tests/test_accounts.py diff --git a/README.md b/README.md index 49592bff..cd599b34 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ flowchart LR ## PostgreSQL Schema (DDL) See `backend/app/db/schema.sql`. Key tables: - users, categories, expenses, bills, reminders +- financial_accounts (multi-account support: checking, savings, credit cards, investments, loans, cash) - ad_impressions, subscription_plans, user_subscriptions - refresh_tokens (optional if rotating), audit_logs @@ -66,6 +67,7 @@ OpenAPI: `backend/app/openapi.yaml` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` - Insights: `/insights/monthly`, `/insights/budget-suggestion` +- Accounts: CRUD `/accounts`, overview `/accounts/overview`, per-account transactions `/accounts/{id}/transactions` ## MVP UI/UX Plan - Auth screens: register/login. @@ -73,6 +75,11 @@ OpenAPI: `backend/app/openapi.yaml` - Monthly spend chart, category breakdown donut. - Upcoming bills list with due dates and pay status. - AI budget suggestion card. +- Accounts: + - Multi-account overview with net worth, total assets, total liabilities. + - Per-account cards grouped by type (checking, savings, credit card, investment, loan, cash). + - Aggregate income/expenses across all accounts with monthly breakdown. + - CRUD operations for adding, editing, and deactivating accounts. - Expenses page: add expense (amount, category, notes, date), list & filter. - Bills page: create bill (name, amount, cadence, due date, channel), toggle WhatsApp/email. - Settings: profile, categories, reminders default channel, export (premium). @@ -104,6 +111,7 @@ finmind/ bills.py reminders.py insights.py + accounts.py services/ __init__.py ai.py @@ -127,6 +135,7 @@ finmind/ Dashboard.tsx Expenses.tsx Bills.tsx + Accounts.tsx Settings.tsx package.json tsconfig.json diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..bfd3e43f 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 = () => ( } /> + + + + } + /> ({ + listAccounts: jest.fn(), + createAccount: jest.fn(), + updateAccount: jest.fn(), + deleteAccount: jest.fn(), + getAccountsOverview: jest.fn(), + getAccountTransactions: jest.fn(), +})); + +jest.mock('../lib/auth', () => ({ + getToken: () => 'mock-token', + getRefreshToken: () => 'mock-refresh', + clearToken: jest.fn(), + clearRefreshToken: jest.fn(), + setToken: jest.fn(), + getCurrency: () => 'INR', +})); + +const mockAccounts = [ + { + id: 1, + name: 'HDFC Checking', + account_type: 'CHECKING', + institution: 'HDFC Bank', + balance: 25000.5, + currency: 'INR', + is_active: true, + notes: null, + created_at: '2026-01-01T00:00:00', + updated_at: '2026-01-01T00:00:00', + }, + { + id: 2, + name: 'SBI Savings', + account_type: 'SAVINGS', + institution: 'SBI', + balance: 100000, + currency: 'INR', + is_active: true, + notes: null, + created_at: '2026-01-02T00:00:00', + updated_at: '2026-01-02T00:00:00', + }, + { + id: 3, + name: 'Visa Gold', + account_type: 'CREDIT_CARD', + institution: 'ICICI', + balance: 15000, + currency: 'INR', + is_active: true, + notes: null, + created_at: '2026-01-03T00:00:00', + updated_at: '2026-01-03T00:00:00', + }, +]; + +const mockOverview = { + period: { month: '2026-03' }, + net_worth: 110000.5, + total_assets: 125000.5, + total_liabilities: 15000, + aggregate: { + monthly_income: 50000, + monthly_expenses: 20000, + monthly_net: 30000, + }, + accounts: mockAccounts.map((a) => ({ + ...a, + monthly_income: a.account_type === 'CHECKING' ? 50000 : 0, + monthly_expenses: a.account_type === 'CHECKING' ? 12000 : a.account_type === 'CREDIT_CARD' ? 8000 : 0, + monthly_net: a.account_type === 'CHECKING' ? 38000 : a.account_type === 'CREDIT_CARD' ? -8000 : 0, + transaction_count: a.account_type === 'CHECKING' ? 15 : a.account_type === 'CREDIT_CARD' ? 5 : 0, + })), + account_count: 3, + recent_transactions: [ + { + id: 101, + description: 'Salary', + amount: 50000, + date: '2026-03-01', + type: 'INCOME', + currency: 'INR', + account_id: 1, + }, + { + id: 102, + description: 'Groceries', + amount: 2500, + date: '2026-03-15', + type: 'EXPENSE', + currency: 'INR', + account_id: 1, + }, + ], +}; + +function renderWithProviders(ui: React.ReactElement) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {ui} + , + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + const accountsApi = require('../api/accounts'); + accountsApi.listAccounts.mockResolvedValue(mockAccounts); + accountsApi.getAccountsOverview.mockResolvedValue(mockOverview); +}); + +describe('Accounts page', () => { + it('renders page title', async () => { + renderWithProviders(); + expect(screen.getByText('Financial Accounts')).toBeInTheDocument(); + }); + + it('displays summary cards after loading', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Net Worth')).toBeInTheDocument(); + }); + expect(screen.getByText('Total Assets')).toBeInTheDocument(); + expect(screen.getByText('Total Liabilities')).toBeInTheDocument(); + expect(screen.getByText('Monthly Net')).toBeInTheDocument(); + }); + + it('groups accounts by type', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Checking Accounts')).toBeInTheDocument(); + }); + expect(screen.getByText('Savings Accounts')).toBeInTheDocument(); + expect(screen.getByText('Credit Card Accounts')).toBeInTheDocument(); + }); + + it('shows account names and institutions', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('HDFC Checking')).toBeInTheDocument(); + }); + expect(screen.getByText('SBI Savings')).toBeInTheDocument(); + expect(screen.getByText('Visa Gold')).toBeInTheDocument(); + expect(screen.getByText('HDFC Bank')).toBeInTheDocument(); + }); + + it('displays recent transactions', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Salary')).toBeInTheDocument(); + }); + expect(screen.getByText('Groceries')).toBeInTheDocument(); + }); + + it('shows add account button', async () => { + renderWithProviders(); + expect(screen.getByText('Add Account')).toBeInTheDocument(); + }); + + it('shows empty state when no accounts', async () => { + const accountsApi = require('../api/accounts'); + accountsApi.listAccounts.mockResolvedValue([]); + accountsApi.getAccountsOverview.mockResolvedValue({ + ...mockOverview, + accounts: [], + account_count: 0, + net_worth: 0, + total_assets: 0, + total_liabilities: 0, + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/No accounts yet/)).toBeInTheDocument(); + }); + }); + + it('handles API errors gracefully', async () => { + const accountsApi = require('../api/accounts'); + accountsApi.listAccounts.mockRejectedValue(new Error('Network error')); + accountsApi.getAccountsOverview.mockRejectedValue(new Error('Network error')); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/api/accounts.ts b/app/src/api/accounts.ts new file mode 100644 index 00000000..69347d5c --- /dev/null +++ b/app/src/api/accounts.ts @@ -0,0 +1,125 @@ +import { api } from './client'; + +export type AccountType = + | 'CHECKING' + | 'SAVINGS' + | 'CREDIT_CARD' + | 'INVESTMENT' + | 'LOAN' + | 'CASH' + | 'OTHER'; + +export type FinancialAccount = { + id: number; + name: string; + account_type: AccountType; + institution: string | null; + balance: number; + currency: string; + is_active: boolean; + notes: string | null; + created_at: string | null; + updated_at: string | null; +}; + +export type AccountCreate = { + name: string; + account_type?: AccountType; + institution?: string; + balance?: number; + currency?: string; + notes?: string; +}; + +export type AccountUpdate = Partial & { + is_active?: boolean; +}; + +export type AccountWithStats = FinancialAccount & { + monthly_income: number; + monthly_expenses: number; + monthly_net: number; + transaction_count: number; +}; + +export type AccountsOverview = { + period: { month: string }; + net_worth: number; + total_assets: number; + total_liabilities: number; + aggregate: { + monthly_income: number; + monthly_expenses: number; + monthly_net: number; + }; + accounts: AccountWithStats[]; + account_count: number; + recent_transactions: Array<{ + id: number; + description: string; + amount: number; + date: string; + type: 'INCOME' | 'EXPENSE' | string; + currency: string; + account_id: number | null; + }>; +}; + +export type AccountTransaction = { + id: number; + description: string; + amount: number; + date: string; + type: string; + currency: string; + category_id: number | null; + account_id: number | null; +}; + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +export async function listAccounts(includeInactive = false): Promise { + const qs = includeInactive ? '?include_inactive=true' : ''; + return api(`/accounts${qs}`); +} + +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' }); +} + +// --------------------------------------------------------------------------- +// Overview & transactions +// --------------------------------------------------------------------------- + +export async function getAccountsOverview(month?: string): Promise { + const qs = month ? `?month=${encodeURIComponent(month)}` : ''; + return api(`/accounts/overview${qs}`); +} + +export async function getAccountTransactions( + accountId: number, + params?: { page?: number; page_size?: number }, +): Promise { + const qs = new URLSearchParams(); + if (params?.page) qs.set('page', String(params.page)); + if (params?.page_size) qs.set('page_size', String(params.page_size)); + const query = qs.toString() ? `?${qs.toString()}` : ''; + return api(`/accounts/${accountId}/transactions${query}`); +} 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/Accounts.tsx b/app/src/pages/Accounts.tsx new file mode 100644 index 00000000..1197ab14 --- /dev/null +++ b/app/src/pages/Accounts.tsx @@ -0,0 +1,593 @@ +import { useEffect, useState, useMemo, useCallback } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardDescription, + FinancialCardFooter, + FinancialCardHeader, + FinancialCardTitle, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { + ArrowDownRight, + ArrowUpRight, + Building2, + CreditCard, + DollarSign, + Landmark, + LineChart, + Plus, + TrendingDown, + TrendingUp, + Wallet, + Banknote, + PiggyBank, + RefreshCw, + Trash2, + Edit3, +} from 'lucide-react'; +import { + listAccounts, + createAccount, + updateAccount, + deleteAccount, + getAccountsOverview, + type FinancialAccount, + type AccountCreate, + type AccountsOverview, + type AccountType, +} from '@/api/accounts'; +import { formatMoney } from '@/lib/currency'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +const ACCOUNT_TYPE_LABELS: Record = { + CHECKING: 'Checking', + SAVINGS: 'Savings', + CREDIT_CARD: 'Credit Card', + INVESTMENT: 'Investment', + LOAN: 'Loan', + CASH: 'Cash', + OTHER: 'Other', +}; + +const ACCOUNT_TYPE_ICONS: Record = { + CHECKING: Landmark, + SAVINGS: PiggyBank, + CREDIT_CARD: CreditCard, + INVESTMENT: LineChart, + LOAN: Banknote, + CASH: DollarSign, + OTHER: Wallet, +}; + +function currency(n: number, code?: string) { + return formatMoney(Number(n || 0), code); +} + +export function Accounts() { + const [accounts, setAccounts] = useState([]); + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7)); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingAccount, setEditingAccount] = useState(null); + + // Form state + const [formName, setFormName] = useState(''); + const [formType, setFormType] = useState('CHECKING'); + const [formInstitution, setFormInstitution] = useState(''); + const [formBalance, setFormBalance] = useState(''); + const [formCurrency, setFormCurrency] = useState('INR'); + const [formNotes, setFormNotes] = useState(''); + const [formSubmitting, setFormSubmitting] = useState(false); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [accts, ov] = await Promise.all([ + listAccounts(), + getAccountsOverview(month), + ]); + setAccounts(accts); + setOverview(ov); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to load accounts'); + } finally { + setLoading(false); + } + }, [month]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + const resetForm = () => { + setFormName(''); + setFormType('CHECKING'); + setFormInstitution(''); + setFormBalance(''); + setFormCurrency('INR'); + setFormNotes(''); + setEditingAccount(null); + }; + + const openCreateDialog = () => { + resetForm(); + setDialogOpen(true); + }; + + const openEditDialog = (acct: FinancialAccount) => { + setEditingAccount(acct); + setFormName(acct.name); + setFormType(acct.account_type); + setFormInstitution(acct.institution || ''); + setFormBalance(String(acct.balance)); + setFormCurrency(acct.currency); + setFormNotes(acct.notes || ''); + setDialogOpen(true); + }; + + const handleSubmit = async () => { + if (!formName.trim()) return; + setFormSubmitting(true); + try { + if (editingAccount) { + await updateAccount(editingAccount.id, { + name: formName, + account_type: formType, + institution: formInstitution || undefined, + balance: parseFloat(formBalance) || 0, + currency: formCurrency, + notes: formNotes || undefined, + }); + } else { + const payload: AccountCreate = { + name: formName, + account_type: formType, + institution: formInstitution || undefined, + balance: parseFloat(formBalance) || 0, + currency: formCurrency, + notes: formNotes || undefined, + }; + await createAccount(payload); + } + setDialogOpen(false); + resetForm(); + await loadData(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to save account'); + } finally { + setFormSubmitting(false); + } + }; + + const handleDelete = async (id: number) => { + try { + await deleteAccount(id); + await loadData(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to delete account'); + } + }; + + const summaryCards = useMemo(() => { + if (!overview) return []; + return [ + { + title: 'Net Worth', + amount: currency(overview.net_worth), + trend: overview.net_worth >= 0 ? 'up' : 'down', + icon: Wallet, + description: `${overview.account_count} account(s)`, + }, + { + title: 'Total Assets', + amount: currency(overview.total_assets), + trend: 'up', + icon: TrendingUp, + description: 'Active accounts', + }, + { + title: 'Total Liabilities', + amount: currency(overview.total_liabilities), + trend: 'down', + icon: TrendingDown, + description: 'Credit cards & loans', + }, + { + title: 'Monthly Net', + amount: currency(overview.aggregate.monthly_net), + trend: overview.aggregate.monthly_net >= 0 ? 'up' : 'down', + icon: DollarSign, + description: `Income: ${currency(overview.aggregate.monthly_income)}`, + }, + ] as const; + }, [overview]); + + const recentTx = overview?.recent_transactions ?? []; + + // Group accounts by type + const groupedAccounts = useMemo(() => { + const groups: Record = {}; + for (const acct of accounts) { + const key = acct.account_type; + if (!groups[key]) groups[key] = []; + groups[key].push(acct); + } + return groups; + }, [accounts]); + + return ( +
+
+
+
+

Financial Accounts

+

+ Multi-account overview for {overview?.period?.month || month}. +

+
+
+ + setMonth(e.target.value)} + /> + + + + + + + + + {editingAccount ? 'Edit Account' : 'Add Financial Account'} + + + {editingAccount + ? 'Update account details.' + : 'Link a new bank or financial account.'} + + +
+
+ + setFormName(e.target.value)} + /> +
+
+
+ + +
+
+ + setFormCurrency(e.target.value.toUpperCase())} + maxLength={10} + /> +
+
+
+
+ + setFormInstitution(e.target.value)} + /> +
+
+ + setFormBalance(e.target.value)} + /> +
+
+
+ + setFormNotes(e.target.value)} + /> +
+
+ + + +
+
+
+
+
+ + {error &&
{error}
} + + {/* Summary Cards */} + {!loading && overview && ( +
+ {summaryCards.map((card, index) => ( + + +
+ + {card.title} + + +
+
+ +
{card.amount}
+
+ {card.trend === 'up' ? ( + + ) : ( + + )} + {card.description} +
+
+
+ ))} +
+ )} + + {loading && ( +
Loading accounts...
+ )} + + {/* Accounts grouped by type */} + {!loading && ( +
+
+ {Object.keys(groupedAccounts).length === 0 ? ( + + + +

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

+ +
+
+ ) : ( + Object.entries(groupedAccounts).map(([type, accts]) => { + const Icon = ACCOUNT_TYPE_ICONS[type as AccountType] || Wallet; + const label = ACCOUNT_TYPE_LABELS[type as AccountType] || type; + return ( + + +
+ + + {label} Accounts + +
+ + {accts.length} account{accts.length !== 1 ? 's' : ''} + +
+ +
+ {accts.map((acct) => { + // Find per-account stats from overview + const stats = overview?.accounts.find((a) => a.id === acct.id); + return ( +
+
+
+ + {acct.name} + + {acct.institution && ( + + {acct.institution} + + )} +
+ {stats && ( +
+ + Income: {currency(stats.monthly_income, acct.currency)} + + + Expenses: {currency(stats.monthly_expenses, acct.currency)} + + {stats.transaction_count} txn(s) +
+ )} +
+
+ + {currency(acct.balance, acct.currency)} + + + +
+
+ ); + })} +
+
+
+ ); + }) + )} +
+ + {/* Right sidebar: recent transactions */} +
+ + + + Recent Transactions + + + Across all accounts + + + + {recentTx.length === 0 ? ( +
+ No transactions yet. +
+ ) : ( +
+ {recentTx.map((tx) => { + const isIncome = tx.type === 'INCOME'; + const acctName = accounts.find( + (a) => a.id === tx.account_id, + )?.name; + return ( +
+
+
+ {isIncome ? ( + + ) : ( + + )} +
+
+
+ {tx.description} +
+
+ {new Date(tx.date).toLocaleDateString()} + {acctName && ` - ${acctName}`} +
+
+
+
+ {isIncome ? '+' : '-'} + {currency(Math.abs(tx.amount), tx.currency)} +
+
+ ); + })} +
+ )} +
+ +
+ Showing last {recentTx.length} transactions +
+
+
+
+
+ )} +
+ ); +} + +export default Accounts; diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..6b57f533 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -117,6 +117,25 @@ 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(30) NOT NULL DEFAULT 'CHECKING', + institution VARCHAR(200), + balance NUMERIC(14,2) NOT NULL DEFAULT 0, + currency VARCHAR(10) NOT NULL DEFAULT 'INR', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + notes VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_financial_accounts_user ON financial_accounts(user_id, is_active); + +ALTER TABLE expenses + ADD COLUMN IF NOT EXISTS account_id INT REFERENCES financial_accounts(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_expenses_account ON expenses(account_id); + 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..9583e3b5 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -37,6 +37,9 @@ 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("financial_accounts.id"), nullable=True + ) source_recurring_id = db.Column( db.Integer, db.ForeignKey("recurring_expenses.id"), nullable=True ) @@ -127,6 +130,33 @@ class UserSubscription(db.Model): started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +class AccountType(str, Enum): + CHECKING = "CHECKING" + SAVINGS = "SAVINGS" + CREDIT_CARD = "CREDIT_CARD" + INVESTMENT = "INVESTMENT" + LOAN = "LOAN" + CASH = "CASH" + OTHER = "OTHER" + + +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(30), nullable=False, default=AccountType.CHECKING.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) + is_active = db.Column(db.Boolean, default=True, 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 + ) + + 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..148c3887 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: @@ -510,6 +511,7 @@ components: amount: { type: number, format: float } currency: { type: string } category_id: { type: integer } + account_id: { type: integer, nullable: true } expense_type: { type: string } description: { type: string, nullable: true } date: { type: string, format: date } @@ -587,3 +589,54 @@ components: message: { type: string } send_at: { type: string, format: date-time } channel: { type: string, enum: [email, whatsapp], default: email } + FinancialAccount: + type: object + properties: + id: { type: integer } + name: { type: string } + account_type: { type: string, enum: [CHECKING, SAVINGS, CREDIT_CARD, INVESTMENT, LOAN, CASH, OTHER] } + institution: { type: string, nullable: true } + balance: { type: number, format: float } + currency: { type: string } + is_active: { 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_CARD, INVESTMENT, LOAN, CASH, OTHER], default: CHECKING } + institution: { type: string, nullable: true } + balance: { type: number, format: float, default: 0 } + currency: { type: string, default: INR } + notes: { type: string, nullable: true } + AccountsOverview: + type: object + properties: + period: { type: object, properties: { month: { type: string } } } + net_worth: { type: number, format: float } + total_assets: { type: number, format: float } + total_liabilities: { type: number, format: float } + aggregate: + type: object + properties: + monthly_income: { type: number, format: float } + monthly_expenses: { type: number, format: float } + monthly_net: { type: number, format: float } + accounts: + type: array + items: + allOf: + - $ref: '#/components/schemas/FinancialAccount' + - type: object + properties: + monthly_income: { type: number } + monthly_expenses: { type: number } + monthly_net: { type: number } + transaction_count: { type: integer } + account_count: { type: integer } + recent_transactions: + type: array + items: { $ref: '#/components/schemas/Expense' } 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..a301ad2e --- /dev/null +++ b/packages/backend/app/routes/accounts.py @@ -0,0 +1,356 @@ +"""CRUD endpoints for financial accounts and multi-account overview.""" + +from datetime import date +from decimal import Decimal, InvalidOperation + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +from sqlalchemy import extract, func + +from ..extensions import db +from ..models import AccountType, Expense, FinancialAccount + +bp = Blueprint("accounts", __name__) + +VALID_ACCOUNT_TYPES = {t.value for t in AccountType} + +# Accounts whose balances represent liabilities (subtracted from net worth) +LIABILITY_TYPES = {AccountType.CREDIT_CARD.value, AccountType.LOAN.value} + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + + +@bp.get("") +@jwt_required() +def list_accounts(): + """Return all financial accounts for the authenticated user.""" + uid = int(get_jwt_identity()) + include_inactive = request.args.get("include_inactive", "").lower() == "true" + + q = db.session.query(FinancialAccount).filter_by(user_id=uid) + if not include_inactive: + q = q.filter_by(is_active=True) + + accounts = q.order_by(FinancialAccount.created_at.desc()).all() + return jsonify([_account_to_dict(a) for a in accounts]) + + +@bp.post("") +@jwt_required() +def create_account(): + """Create a new financial account.""" + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + name = (data.get("name") or "").strip() + if not name: + return jsonify(error="name is required"), 400 + + account_type = (data.get("account_type") or "CHECKING").upper() + if account_type not in VALID_ACCOUNT_TYPES: + return jsonify(error=f"invalid account_type, must be one of {sorted(VALID_ACCOUNT_TYPES)}"), 400 + + balance = _parse_decimal(data.get("balance", 0)) + if balance is None: + return jsonify(error="invalid balance"), 400 + + account = FinancialAccount( + user_id=uid, + name=name, + account_type=account_type, + institution=(data.get("institution") or "").strip() or None, + balance=balance, + currency=(data.get("currency") or "INR").upper()[:10], + notes=(data.get("notes") or "").strip() or None, + ) + db.session.add(account) + db.session.commit() + return jsonify(_account_to_dict(account)), 201 + + +@bp.get("/") +@jwt_required() +def get_account(account_id: int): + """Return a single account.""" + 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): + """Update an existing financial account.""" + 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: + name = (data["name"] or "").strip() + if not name: + return jsonify(error="name cannot be empty"), 400 + account.name = name + if "account_type" in data: + at = (data["account_type"] or "").upper() + if at not in VALID_ACCOUNT_TYPES: + return jsonify(error=f"invalid account_type"), 400 + account.account_type = at + if "institution" in data: + account.institution = (data["institution"] or "").strip() or None + if "balance" in data: + balance = _parse_decimal(data["balance"]) + if balance is None: + return jsonify(error="invalid balance"), 400 + account.balance = balance + if "currency" in data: + account.currency = (data["currency"] or "INR").upper()[:10] + if "is_active" in data: + account.is_active = bool(data["is_active"]) + if "notes" in data: + account.notes = (data["notes"] or "").strip() or None + + db.session.commit() + return jsonify(_account_to_dict(account)) + + +@bp.delete("/") +@jwt_required() +def delete_account(account_id: int): + """Soft-delete an account (set is_active=False).""" + 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 + account.is_active = False + db.session.commit() + return jsonify(message="account deactivated") + + +# --------------------------------------------------------------------------- +# Multi-account overview +# --------------------------------------------------------------------------- + + +@bp.get("/overview") +@jwt_required() +def accounts_overview(): + """Aggregate financial overview across all active accounts. + + Returns net worth, total assets, total liabilities, per-account + summaries with income/expense totals for the requested month, + and a combined recent-transactions feed. + """ + uid = int(get_jwt_identity()) + ym = (request.args.get("month") or date.today().strftime("%Y-%m")).strip() + if not _is_valid_month(ym): + return jsonify(error="invalid month, expected YYYY-MM"), 400 + + year, month = map(int, ym.split("-")) + + # Fetch all active accounts + accounts = ( + db.session.query(FinancialAccount) + .filter_by(user_id=uid, is_active=True) + .order_by(FinancialAccount.created_at.asc()) + .all() + ) + + total_assets = Decimal(0) + total_liabilities = Decimal(0) + per_account = [] + + for acct in accounts: + bal = acct.balance or Decimal(0) + if acct.account_type in LIABILITY_TYPES: + total_liabilities += abs(bal) + else: + total_assets += bal + + # Per-account income/expenses for the month + income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.account_id == acct.id, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + expenses = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.account_id == acct.id, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + tx_count = ( + db.session.query(func.count(Expense.id)) + .filter( + Expense.user_id == uid, + Expense.account_id == acct.id, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + ) + .scalar() + ) + + per_account.append( + { + **_account_to_dict(acct), + "monthly_income": float(income or 0), + "monthly_expenses": float(expenses or 0), + "monthly_net": round(float(income or 0) - float(expenses or 0), 2), + "transaction_count": tx_count or 0, + } + ) + + # Aggregate income/expenses across ALL accounts (including unlinked) + agg_income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + agg_expenses = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + extract("year", Expense.spent_at) == year, + extract("month", Expense.spent_at) == month, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + + # Recent transactions across all accounts + recent = ( + db.session.query(Expense) + .filter(Expense.user_id == uid) + .order_by(Expense.spent_at.desc(), Expense.id.desc()) + .limit(15) + .all() + ) + + payload = { + "period": {"month": ym}, + "net_worth": float(total_assets - total_liabilities), + "total_assets": float(total_assets), + "total_liabilities": float(total_liabilities), + "aggregate": { + "monthly_income": float(agg_income or 0), + "monthly_expenses": float(agg_expenses or 0), + "monthly_net": round(float(agg_income or 0) - float(agg_expenses or 0), 2), + }, + "accounts": per_account, + "account_count": len(accounts), + "recent_transactions": [ + { + "id": e.id, + "description": e.notes or "Transaction", + "amount": float(e.amount), + "date": e.spent_at.isoformat(), + "type": e.expense_type, + "currency": e.currency, + "account_id": e.account_id, + } + for e in recent + ], + } + return jsonify(payload) + + +# --------------------------------------------------------------------------- +# Per-account detail +# --------------------------------------------------------------------------- + + +@bp.get("//transactions") +@jwt_required() +def account_transactions(account_id: int): + """List transactions linked to a specific account.""" + 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 + + try: + page = max(1, int(request.args.get("page", "1"))) + page_size = min(200, max(1, int(request.args.get("page_size", "50")))) + except ValueError: + return jsonify(error="invalid pagination"), 400 + + q = ( + db.session.query(Expense) + .filter_by(user_id=uid, account_id=account_id) + .order_by(Expense.spent_at.desc(), Expense.id.desc()) + ) + items = q.offset((page - 1) * page_size).limit(page_size).all() + return jsonify( + [ + { + "id": e.id, + "description": e.notes or "Transaction", + "amount": float(e.amount), + "date": e.spent_at.isoformat(), + "type": e.expense_type, + "currency": e.currency, + "category_id": e.category_id, + "account_id": e.account_id, + } + for e in items + ] + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _account_to_dict(a: FinancialAccount) -> dict: + return { + "id": a.id, + "name": a.name, + "account_type": a.account_type, + "institution": a.institution, + "balance": float(a.balance or 0), + "currency": a.currency, + "is_active": a.is_active, + "notes": a.notes, + "created_at": a.created_at.isoformat() if a.created_at else None, + "updated_at": a.updated_at.isoformat() if a.updated_at else None, + } + + +def _parse_decimal(raw) -> Decimal | None: + try: + return Decimal(str(raw)).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _is_valid_month(ym: str) -> bool: + if len(ym) != 7 or ym[4] != "-": + return False + year_s, month_s = ym.split("-") + if not (year_s.isdigit() and month_s.isdigit()): + return False + m = int(month_s) + return 1 <= m <= 12 diff --git a/packages/backend/app/routes/expenses.py b/packages/backend/app/routes/expenses.py index 1376d46f..1e6ff5f0 100644 --- a/packages/backend/app/routes/expenses.py +++ b/packages/backend/app/routes/expenses.py @@ -65,12 +65,16 @@ def create_expense(): description = (data.get("description") or data.get("notes") or "").strip() if not description: return jsonify(error="description required"), 400 + account_id = data.get("account_id") + if account_id is not None: + account_id = int(account_id) e = Expense( user_id=uid, amount=amount, currency=(data.get("currency") or (user.preferred_currency if user else "INR")), expense_type=str(data.get("expense_type") or "EXPENSE").upper(), category_id=data.get("category_id"), + account_id=account_id, notes=description, spent_at=date.fromisoformat(raw_date) if raw_date else date.today(), ) @@ -226,6 +230,8 @@ def update_expense(expense_id: int): if not description: return jsonify(error="description required"), 400 e.notes = description + if "account_id" in data: + e.account_id = int(data["account_id"]) if data["account_id"] is not None else None if "date" in data or "spent_at" in data: raw_date = data.get("date") or data.get("spent_at") e.spent_at = date.fromisoformat(raw_date) @@ -317,6 +323,7 @@ def _expense_to_dict(e: Expense) -> dict: "amount": float(e.amount), "currency": e.currency, "category_id": e.category_id, + "account_id": e.account_id, "expense_type": e.expense_type, "description": e.notes or "", "date": e.spent_at.isoformat(), diff --git a/packages/backend/app/services/cache.py b/packages/backend/app/services/cache.py index cc5eb9a1..331ed2b3 100644 --- a/packages/backend/app/services/cache.py +++ b/packages/backend/app/services/cache.py @@ -23,6 +23,10 @@ def dashboard_summary_key(user_id: int, ym: str) -> str: return f"user:{user_id}:dashboard_summary:{ym}" +def accounts_overview_key(user_id: int, ym: str) -> str: + return f"user:{user_id}:accounts_overview:{ym}" + + def cache_set(key: str, value, ttl_seconds: int | None = None): payload = json.dumps(value) if ttl_seconds: diff --git a/packages/backend/tests/test_accounts.py b/packages/backend/tests/test_accounts.py new file mode 100644 index 00000000..46502881 --- /dev/null +++ b/packages/backend/tests/test_accounts.py @@ -0,0 +1,299 @@ +"""Tests for the multi-account financial overview feature.""" + +from datetime import date + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + + +def test_create_account(client, auth_header): + r = client.post( + "/accounts", + json={ + "name": "Main Checking", + "account_type": "CHECKING", + "institution": "HDFC Bank", + "balance": 25000.50, + "currency": "INR", + }, + headers=auth_header, + ) + assert r.status_code == 201 + data = r.get_json() + assert data["name"] == "Main Checking" + assert data["account_type"] == "CHECKING" + assert data["institution"] == "HDFC Bank" + assert data["balance"] == 25000.50 + assert data["is_active"] is True + + +def test_create_account_name_required(client, auth_header): + r = client.post( + "/accounts", + json={"account_type": "SAVINGS"}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "name" in r.get_json()["error"] + + +def test_create_account_invalid_type(client, auth_header): + r = client.post( + "/accounts", + json={"name": "Bad", "account_type": "INVALID"}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "account_type" in r.get_json()["error"] + + +def test_list_accounts(client, auth_header): + # Create two accounts + client.post( + "/accounts", + json={"name": "Checking", "balance": 1000}, + headers=auth_header, + ) + client.post( + "/accounts", + json={"name": "Savings", "account_type": "SAVINGS", "balance": 5000}, + headers=auth_header, + ) + r = client.get("/accounts", headers=auth_header) + assert r.status_code == 200 + accounts = r.get_json() + assert len(accounts) == 2 + + +def test_get_account(client, auth_header): + r = client.post( + "/accounts", + json={"name": "Test", "balance": 100}, + 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()["name"] == "Test" + + +def test_update_account(client, auth_header): + r = client.post( + "/accounts", + json={"name": "Old Name", "balance": 100}, + headers=auth_header, + ) + aid = r.get_json()["id"] + r = client.patch( + f"/accounts/{aid}", + json={"name": "New Name", "balance": 200}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["name"] == "New Name" + assert r.get_json()["balance"] == 200.0 + + +def test_delete_account_soft_deletes(client, auth_header): + r = client.post( + "/accounts", + json={"name": "To Deactivate", "balance": 100}, + 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 default listing + r = client.get("/accounts", headers=auth_header) + assert all(a["id"] != aid for a in r.get_json()) + + # Should appear when include_inactive + r = client.get("/accounts?include_inactive=true", headers=auth_header) + accts = r.get_json() + deactivated = [a for a in accts if a["id"] == aid] + assert len(deactivated) == 1 + assert deactivated[0]["is_active"] is False + + +# --------------------------------------------------------------------------- +# Overview +# --------------------------------------------------------------------------- + + +def test_accounts_overview_empty(client, auth_header): + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["net_worth"] == 0 + assert data["total_assets"] == 0 + assert data["total_liabilities"] == 0 + assert data["account_count"] == 0 + assert isinstance(data["accounts"], list) + + +def test_accounts_overview_with_data(client, auth_header): + # Create accounts: checking (asset), savings (asset), credit card (liability) + client.post( + "/accounts", + json={"name": "Checking", "account_type": "CHECKING", "balance": 10000}, + headers=auth_header, + ) + client.post( + "/accounts", + json={"name": "Savings", "account_type": "SAVINGS", "balance": 50000}, + headers=auth_header, + ) + client.post( + "/accounts", + json={"name": "Visa Card", "account_type": "CREDIT_CARD", "balance": 5000}, + headers=auth_header, + ) + + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + + assert data["total_assets"] == 60000.0 + assert data["total_liabilities"] == 5000.0 + assert data["net_worth"] == 55000.0 + assert data["account_count"] == 3 + assert len(data["accounts"]) == 3 + + +def test_accounts_overview_with_transactions(client, auth_header): + # Create account + r = client.post( + "/accounts", + json={"name": "Primary", "balance": 10000}, + headers=auth_header, + ) + account_id = r.get_json()["id"] + + # Create income transaction linked to account + r = client.post( + "/expenses", + json={ + "amount": 5000, + "description": "Salary", + "date": date.today().isoformat(), + "expense_type": "INCOME", + "account_id": account_id, + }, + headers=auth_header, + ) + assert r.status_code == 201 + + # Create expense transaction linked to account + r = client.post( + "/expenses", + json={ + "amount": 1500, + "description": "Groceries", + "date": date.today().isoformat(), + "expense_type": "EXPENSE", + "account_id": account_id, + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.get("/accounts/overview", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + + # Check aggregate + assert data["aggregate"]["monthly_income"] >= 5000 + assert data["aggregate"]["monthly_expenses"] >= 1500 + + # Check per-account breakdown + primary = [a for a in data["accounts"] if a["name"] == "Primary"][0] + assert primary["monthly_income"] == 5000.0 + assert primary["monthly_expenses"] == 1500.0 + assert primary["monthly_net"] == 3500.0 + assert primary["transaction_count"] == 2 + + # Recent transactions included + assert len(data["recent_transactions"]) >= 2 + + +def test_accounts_overview_month_filter(client, auth_header): + r = client.get("/accounts/overview?month=2025-01", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["period"]["month"] == "2025-01" + + +def test_accounts_overview_invalid_month(client, auth_header): + r = client.get("/accounts/overview?month=invalid", headers=auth_header) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# Per-account transactions +# --------------------------------------------------------------------------- + + +def test_account_transactions(client, auth_header): + # Create account and linked transaction + r = client.post( + "/accounts", + json={"name": "Transaction Test", "balance": 1000}, + headers=auth_header, + ) + account_id = r.get_json()["id"] + + client.post( + "/expenses", + json={ + "amount": 200, + "description": "Coffee", + "date": date.today().isoformat(), + "account_id": account_id, + }, + headers=auth_header, + ) + + r = client.get(f"/accounts/{account_id}/transactions", headers=auth_header) + assert r.status_code == 200 + txns = r.get_json() + assert len(txns) == 1 + assert txns[0]["description"] == "Coffee" + assert txns[0]["account_id"] == account_id + + +def test_account_not_found(client, auth_header): + r = client.get("/accounts/99999", headers=auth_header) + assert r.status_code == 404 + + +def test_update_nonexistent_account(client, auth_header): + r = client.patch( + "/accounts/99999", + json={"name": "X"}, + headers=auth_header, + ) + assert r.status_code == 404 + + +def test_delete_nonexistent_account(client, auth_header): + r = client.delete("/accounts/99999", headers=auth_header) + assert r.status_code == 404 + + +def test_all_account_types(client, auth_header): + types = ["CHECKING", "SAVINGS", "CREDIT_CARD", "INVESTMENT", "LOAN", "CASH", "OTHER"] + for t in types: + r = client.post( + "/accounts", + json={"name": f"{t} Account", "account_type": t, "balance": 1000}, + headers=auth_header, + ) + assert r.status_code == 201, f"Failed for type {t}" + assert r.get_json()["account_type"] == t + + +def test_account_transactions_not_found(client, auth_header): + r = client.get("/accounts/99999/transactions", headers=auth_header) + assert r.status_code == 404