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 = () => ( } /> + + + + } + /> ({ + useToast: () => ({ toast: jest.fn() }), +})); + +const mockAccounts: accountsApi.FinancialAccount[] = [ + { + id: 1, + name: 'Chase Checking', + type: 'CHECKING', + balance: 5000, + currency: 'USD', + institution: 'Chase', + last_synced: '2026-03-22T10:00:00Z', + is_active: true, + created_at: '2026-01-01T00:00:00Z', + }, + { + id: 2, + name: 'Emergency Fund', + type: 'SAVINGS', + balance: 15000, + currency: 'USD', + institution: 'Ally Bank', + last_synced: null, + is_active: true, + created_at: '2026-01-15T00:00:00Z', + }, + { + id: 3, + name: 'Amex Gold', + type: 'CREDIT_CARD', + balance: 2500, + currency: 'USD', + institution: 'American Express', + last_synced: '2026-03-20T08:00:00Z', + is_active: true, + created_at: '2026-02-01T00:00:00Z', + }, + { + id: 4, + name: 'Fidelity 401k', + type: 'INVESTMENT', + balance: 85000, + currency: 'USD', + institution: 'Fidelity', + last_synced: '2026-03-21T00:00:00Z', + is_active: true, + created_at: '2026-01-01T00:00:00Z', + }, +]; + +const mockSummary: accountsApi.AccountSummary = { + total_assets: 105000, + total_liabilities: 2500, + net_worth: 102500, + currency: 'USD', + accounts: mockAccounts, +}; + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}); +} + +describe('Accounts Page', () => { + beforeEach(() => { + jest.clearAllMocks(); + (accountsApi.getAccounts as jest.Mock).mockResolvedValue(mockAccounts); + (accountsApi.getAccountSummary as jest.Mock).mockResolvedValue(mockSummary); + }); + + it('renders the page title', async () => { + renderWithRouter(); + expect(screen.getByText('Financial Accounts')).toBeInTheDocument(); + }); + + it('displays summary cards after loading', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText('Total Assets')).toBeInTheDocument(); + expect(screen.getByText('Total Liabilities')).toBeInTheDocument(); + expect(screen.getByText('Net Worth')).toBeInTheDocument(); + }); + }); + + it('groups accounts by type', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText('Checking Accounts')).toBeInTheDocument(); + expect(screen.getByText('Savings Accounts')).toBeInTheDocument(); + expect(screen.getByText('Credit Card Accounts')).toBeInTheDocument(); + expect(screen.getByText('Investment Accounts')).toBeInTheDocument(); + }); + }); + + it('displays individual account cards', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText('Chase Checking')).toBeInTheDocument(); + expect(screen.getByText('Emergency Fund')).toBeInTheDocument(); + expect(screen.getByText('Amex Gold')).toBeInTheDocument(); + expect(screen.getByText('Fidelity 401k')).toBeInTheDocument(); + }); + }); + + it('shows institution names', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText('Chase')).toBeInTheDocument(); + expect(screen.getByText('Ally Bank')).toBeInTheDocument(); + expect(screen.getByText('American Express')).toBeInTheDocument(); + expect(screen.getByText('Fidelity')).toBeInTheDocument(); + }); + }); + + it('opens add account form when button clicked', async () => { + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText('Chase Checking')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Add Account')); + expect(screen.getByText('New Account')).toBeInTheDocument(); + expect(screen.getByLabelText('Account Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Type')).toBeInTheDocument(); + expect(screen.getByLabelText('Current Balance')).toBeInTheDocument(); + }); + + it('creates a new account', async () => { + (accountsApi.createAccount as jest.Mock).mockResolvedValue({ + id: 5, + name: 'Test Account', + type: 'CASH', + balance: 100, + currency: 'USD', + institution: '', + last_synced: null, + is_active: true, + created_at: '2026-03-22T15:00:00Z', + }); + + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText('Chase Checking')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Add Account')); + fireEvent.change(screen.getByLabelText('Account Name'), { + target: { value: 'Test Account' }, + }); + fireEvent.change(screen.getByLabelText('Type'), { + target: { value: 'CASH' }, + }); + fireEvent.change(screen.getByLabelText('Current Balance'), { + target: { value: '100' }, + }); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(accountsApi.createAccount).toHaveBeenCalledWith({ + name: 'Test Account', + type: 'CASH', + balance: 100, + }); + }); + }); + + it('shows empty state when no accounts', async () => { + (accountsApi.getAccounts as jest.Mock).mockResolvedValue([]); + (accountsApi.getAccountSummary as jest.Mock).mockResolvedValue({ + total_assets: 0, + total_liabilities: 0, + net_worth: 0, + currency: 'USD', + accounts: [], + }); + + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText('No accounts yet')).toBeInTheDocument(); + expect(screen.getByText('Add Your First Account')).toBeInTheDocument(); + }); + }); + + it('shows error when API fails', async () => { + (accountsApi.getAccounts as jest.Mock).mockRejectedValue(new Error('Network error')); + + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText(/Network error/)).toBeInTheDocument(); + }); + }); + + it('handles delete account', async () => { + (accountsApi.deleteAccount as jest.Mock).mockResolvedValue(undefined); + + renderWithRouter(); + await waitFor(() => { + expect(screen.getByText('Chase Checking')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/app/src/api/accounts.ts b/app/src/api/accounts.ts new file mode 100644 index 00000000..33df380d --- /dev/null +++ b/app/src/api/accounts.ts @@ -0,0 +1,57 @@ +import { api } from './client'; + +export type FinancialAccount = { + id: number; + name: string; + type: 'CHECKING' | 'SAVINGS' | 'CREDIT_CARD' | 'INVESTMENT' | 'LOAN' | 'CASH'; + balance: number; + currency: string; + institution: string; + last_synced: string | null; + is_active: boolean; + created_at: string; +}; + +export type AccountSummary = { + total_assets: number; + total_liabilities: number; + net_worth: number; + currency: string; + accounts: FinancialAccount[]; +}; + +export type CreateAccountPayload = { + name: string; + type: FinancialAccount['type']; + balance: number; + currency?: string; + institution?: string; +}; + +export type UpdateAccountPayload = Partial & { + is_active?: boolean; +}; + +export async function getAccounts(): Promise { + return api('/accounts'); +} + +export async function getAccountSummary(): Promise { + return api('/accounts/summary'); +} + +export async function getAccount(id: number): Promise { + return api("/accounts/${id}"); +} + +export async function createAccount(payload: CreateAccountPayload): Promise { + return api('/accounts', { method: 'POST', body: payload }); +} + +export async function updateAccount(id: number, payload: UpdateAccountPayload): Promise { + return api("/accounts/${id}", { method: 'PATCH', body: payload }); +} + +export async function deleteAccount(id: number): Promise { + return api("/accounts/${id}", { method: 'DELETE' }); +} \ No newline at end of file diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..09ebc590 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -13,6 +13,7 @@ const navigation = [ { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, { name: 'Analytics', href: '/analytics' }, + { name: 'Accounts', href: '/accounts' }, ]; export function Navbar() { diff --git a/app/src/pages/Accounts.tsx b/app/src/pages/Accounts.tsx new file mode 100644 index 00000000..6eaf0315 --- /dev/null +++ b/app/src/pages/Accounts.tsx @@ -0,0 +1,363 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardDescription, + FinancialCardFooter, + FinancialCardHeader, + FinancialCardTitle, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { + Wallet, + PiggyBank, + CreditCard, + TrendingUp, + Landmark, + Banknote, + Plus, + Pencil, + Trash2, + RefreshCw, + X, +} from 'lucide-react'; +import { + getAccounts, + getAccountSummary, + createAccount, + updateAccount, + deleteAccount, + type FinancialAccount, + type AccountSummary, + type CreateAccountPayload, +} from '@/api/accounts'; +import { formatMoney } from '@/lib/currency'; + +const ACCOUNT_TYPE_META: Record< + FinancialAccount['type'], + { label: string; icon: typeof Wallet; color: string; bg: string; isLiability: boolean } +> = { + CHECKING: { label: 'Checking', icon: Wallet, color: 'text-primary', bg: 'bg-primary/10', isLiability: false }, + SAVINGS: { label: 'Savings', icon: PiggyBank, color: 'text-success', bg: 'bg-success/10', isLiability: false }, + CREDIT_CARD: { label: 'Credit Card', icon: CreditCard, color: 'text-destructive', bg: 'bg-destructive/10', isLiability: true }, + INVESTMENT: { label: 'Investment', icon: TrendingUp, color: 'text-accent-foreground', bg: 'bg-accent/10', isLiability: false }, + LOAN: { label: 'Loan', icon: Landmark, color: 'text-warning', bg: 'bg-warning/10', isLiability: true }, + CASH: { label: 'Cash', icon: Banknote, color: 'text-foreground', bg: 'bg-muted', isLiability: false }, +}; + +const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_META) as FinancialAccount['type'][]; + +function currency(n: number, code?: string) { + return formatMoney(Number(n || 0), code); +} + +type FormState = CreateAccountPayload & { id?: number }; +const EMPTY_FORM: FormState = { name: '', type: 'CHECKING', balance: 0, currency: '', institution: '' }; + +export function Accounts() { + const { toast } = useToast(); + const [accounts, setAccounts] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [accts, summ] = await Promise.all([getAccounts(), getAccountSummary()]); + setAccounts(accts); + setSummary(summ); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to load accounts'; + setError(msg); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + const handleSubmit = async () => { + if (!form.name.trim()) { + toast({ title: 'Validation', description: 'Account name is required.' }); + return; + } + setSaving(true); + try { + const payload: CreateAccountPayload = { + name: form.name.trim(), + type: form.type, + balance: Number(form.balance) || 0, + ...(form.institution ? { institution: form.institution.trim() } : {}), + ...(form.currency ? { currency: form.currency } : {}), + }; + if (form.id) { + await updateAccount(form.id, payload); + toast({ title: 'Account updated' }); + } else { + await createAccount(payload); + toast({ title: 'Account created' }); + } + setShowForm(false); + setForm(EMPTY_FORM); + await load(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to save account'; + toast({ title: 'Error', description: msg }); + } finally { + setSaving(false); + } + }; + + const handleEdit = (acct: FinancialAccount) => { + setForm({ + id: acct.id, + name: acct.name, + type: acct.type, + balance: acct.balance, + currency: acct.currency, + institution: acct.institution, + }); + setShowForm(true); + }; + + const handleDelete = async (acct: FinancialAccount) => { + try { + await deleteAccount(acct.id); + toast({ title: 'Account deleted', description: acct.name }); + await load(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to delete account'; + toast({ title: 'Error', description: msg }); + } + }; + + const grouped = ACCOUNT_TYPES.reduce>((acc, type) => { + const matching = accounts.filter((a) => a.type === type); + if (matching.length > 0) acc[type] = matching; + return acc; + }, {}); + + return ( +
+
+
+
+

Financial Accounts

+

+ Unified view of all your accounts across institutions. +

+
+
+ + +
+
+
+ + {error &&
{error}. Showing empty fallback state.
} + + {/* Summary cards */} + {summary && ( +
+ + + + Total Assets + + + +
+ {loading ? '...' : currency(summary.total_assets, summary.currency)} +
+

Checking + Savings + Investments + Cash

+
+
+ + + + + Total Liabilities + + + +
+ {loading ? '...' : currency(summary.total_liabilities, summary.currency)} +
+

Credit Cards + Loans

+
+
+ + + + + Net Worth + + + +
= 0 ? 'text-foreground' : 'text-destructive')}> + {loading ? '...' : currency(summary.net_worth, summary.currency)} +
+

Assets minus Liabilities

+
+
+
+ )} + + {/* Add/Edit form */} + {showForm && ( +
+
+

{form.id ? 'Edit Account' : 'New Account'}

+ +
+
+
+ + setForm((p) => ({ ...p, name: e.target.value }))} + placeholder="e.g. Chase Checking" + /> +
+
+ + +
+
+ + setForm((p) => ({ ...p, balance: parseFloat(e.target.value) || 0 }))} + /> +
+
+ + setForm((p) => ({ ...p, institution: e.target.value }))} + placeholder="e.g. Chase, Fidelity" + /> +
+
+
+ + +
+
+ )} + + {/* Grouped account cards */} + {Object.entries(grouped).length === 0 && !loading && ( +
+ +

No accounts yet

+

+ Add your financial accounts to get a complete overview of your finances. +

+ +
+ )} + + {Object.entries(grouped).map(([type, accts]) => { + const meta = ACCOUNT_TYPE_META[type as FinancialAccount['type']]; + const Icon = meta.icon; + const groupTotal = accts.reduce((s, a) => s + a.balance, 0); + + return ( +
+
+
+ +
+

{meta.label} Accounts

+ + Total: {currency(groupTotal, accts[0]?.currency)} + +
+
+ {accts.map((acct) => ( + + +
+ + {acct.name} + +
+ + +
+
+ {acct.institution && ( + {acct.institution} + )} +
+ +
+ {meta.isLiability ? '-' : ''}{currency(Math.abs(acct.balance), acct.currency)} +
+
+ + {acct.last_synced + ? 'Synced ' + new Date(acct.last_synced).toLocaleDateString() + : 'Manual entry'} + +
+ ))} +
+
+ ); + })} +
+ ); +} \ No newline at end of file