Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -83,6 +84,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="accounts"
element={
<ProtectedRoute>
<Accounts />
</ProtectedRoute>
}
/>
<Route
path="account"
element={
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/Navbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('Navbar auth state', () => {
it('shows Account/Logout when signed in (token present)', () => {
localStorage.setItem('fm_token', 'token');
renderNav();
expect(screen.getByRole('link', { name: /account/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /^account$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /finmind/i })).toHaveAttribute('href', '/dashboard');
});
Expand Down
55 changes: 55 additions & 0 deletions app/src/api/accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { api } from './client';

export type AccountType = 'checking' | 'savings' | 'credit' | 'cash' | 'investment';

export type Account = {
id: number;
name: string;
account_type: AccountType;
currency: string;
balance: number;
is_active: boolean;
color: string | null;
created_at: string;
};

export type AccountCreate = {
name: string;
account_type: AccountType;
currency?: string;
balance?: number;
color?: string;
};

export type AccountOverview = {
total_balance: number;
net_worth: number;
account_count: number;
by_type: Record<string, { count: number; total: number }>;
accounts: Account[];
};

export async function listAccounts(includeInactive = false): Promise<Account[]> {
const qs = includeInactive ? '?include_inactive=true' : '';
return api<Account[]>(`/accounts${qs}`);
}

export async function createAccount(payload: AccountCreate): Promise<Account> {
return api<Account>('/accounts', { method: 'POST', body: payload });
}

export async function getAccount(id: number): Promise<Account> {
return api<Account>(`/accounts/${id}`);
}

export async function updateAccount(id: number, payload: Partial<AccountCreate>): Promise<Account> {
return api<Account>(`/accounts/${id}`, { method: 'PATCH', body: payload });
}

export async function deleteAccount(id: number): Promise<{ message: string }> {
return api(`/accounts/${id}`, { method: 'DELETE' });
}

export async function getOverview(): Promise<AccountOverview> {
return api<AccountOverview>('/accounts/overview');
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
166 changes: 166 additions & 0 deletions app/src/pages/Accounts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { useState, useEffect, useCallback } from 'react';
import { FinancialCard, FinancialCardContent, FinancialCardHeader, FinancialCardTitle } from '@/components/ui/financial-card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Wallet, Plus, Building2, PiggyBank, CreditCard, Banknote, TrendingUp, Trash2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { listAccounts, createAccount, deleteAccount, getOverview, type Account, type AccountOverview, type AccountType } from '@/api/accounts';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { formatMoney } from '@/lib/currency';

const ACCOUNT_TYPE_ICONS: Record<AccountType, typeof Wallet> = {
checking: Building2,
savings: PiggyBank,
credit: CreditCard,
cash: Banknote,
investment: TrendingUp,
};

const ACCOUNT_TYPE_LABELS: Record<AccountType, string> = {
checking: 'Checking',
savings: 'Savings',
credit: 'Credit Card',
cash: 'Cash',
investment: 'Investment',
};

export default function Accounts() {
const { toast } = useToast();
const [accounts, setAccounts] = useState<Account[]>([]);
const [overview, setOverview] = useState<AccountOverview | null>(null);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [newAccount, setNewAccount] = useState<{ name: string; account_type: AccountType; balance: string; currency: string }>({
name: '', account_type: 'checking', balance: '0', currency: 'INR',
});

const loadData = useCallback(async () => {
try {
const [accs, ov] = await Promise.all([listAccounts(), getOverview()]);
setAccounts(accs);
setOverview(ov);
} catch (e: unknown) {
toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to load accounts', variant: 'destructive' });
} finally {
setLoading(false);
}
}, [toast]);

useEffect(() => { loadData(); }, [loadData]);

const handleCreate = async () => {
try {
await createAccount({
name: newAccount.name,
account_type: newAccount.account_type,
balance: parseFloat(newAccount.balance) || 0,
currency: newAccount.currency,
});
toast({ title: 'Account created' });
setDialogOpen(false);
setNewAccount({ name: '', account_type: 'checking', balance: '0', currency: 'INR' });
loadData();
} catch (e: unknown) {
toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to create account', variant: 'destructive' });
}
};

const handleDelete = async (id: number) => {
try {
await deleteAccount(id);
toast({ title: 'Account deactivated' });
loadData();
} catch (e: unknown) {
toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to deactivate', variant: 'destructive' });
}
};

if (loading) return <div className="flex justify-center p-8">Loading...</div>;

return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Accounts</h1>
<p className="text-muted-foreground">Manage your financial accounts</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Add Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Account</DialogTitle>
<DialogDescription>Add a new financial account to track.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<input className="w-full rounded-md border px-3 py-2" placeholder="Account name" value={newAccount.name} onChange={e => setNewAccount(p => ({ ...p, name: e.target.value }))} />
<select className="w-full rounded-md border px-3 py-2" value={newAccount.account_type} onChange={e => setNewAccount(p => ({ ...p, account_type: e.target.value as AccountType }))}>
{Object.entries(ACCOUNT_TYPE_LABELS).map(([k, v]) => (<option key={k} value={k}>{v}</option>))}
</select>
<input className="w-full rounded-md border px-3 py-2" type="number" placeholder="Balance" value={newAccount.balance} onChange={e => setNewAccount(p => ({ ...p, balance: e.target.value }))} />
<input className="w-full rounded-md border px-3 py-2" placeholder="Currency (e.g. INR)" value={newAccount.currency} onChange={e => setNewAccount(p => ({ ...p, currency: e.target.value }))} />
</div>
<DialogFooter>
<Button onClick={handleCreate} disabled={!newAccount.name}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>

{/* Overview Cards */}
{overview && (
<div className="grid gap-4 md:grid-cols-3">
<FinancialCard>
<FinancialCardHeader><FinancialCardTitle>Total Balance</FinancialCardTitle></FinancialCardHeader>
<FinancialCardContent><p className="text-2xl font-bold">{formatMoney(overview.total_balance)}</p></FinancialCardContent>
</FinancialCard>
<FinancialCard>
<FinancialCardHeader><FinancialCardTitle>Net Worth</FinancialCardTitle></FinancialCardHeader>
<FinancialCardContent><p className="text-2xl font-bold">{formatMoney(overview.net_worth)}</p></FinancialCardContent>
</FinancialCard>
<FinancialCard>
<FinancialCardHeader><FinancialCardTitle>Active Accounts</FinancialCardTitle></FinancialCardHeader>
<FinancialCardContent><p className="text-2xl font-bold">{overview.account_count}</p></FinancialCardContent>
</FinancialCard>
</div>
)}

{/* Account List */}
{accounts.length === 0 ? (
<FinancialCard>
<FinancialCardContent className="flex flex-col items-center justify-center py-12">
<Wallet className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No accounts yet. Add your first account to get started.</p>
</FinancialCardContent>
</FinancialCard>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{accounts.map(account => {
const Icon = ACCOUNT_TYPE_ICONS[account.account_type] || Wallet;
return (
<FinancialCard key={account.id}>
<FinancialCardHeader className="flex flex-row items-center justify-between pb-2">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-muted-foreground" />
<FinancialCardTitle className="text-base">{account.name}</FinancialCardTitle>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{ACCOUNT_TYPE_LABELS[account.account_type]}</Badge>
<Button variant="ghost" size="icon" onClick={() => handleDelete(account.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</FinancialCardHeader>
<FinancialCardContent>
<p className="text-2xl font-bold">{formatMoney(account.balance, account.currency)}</p>
<p className="text-xs text-muted-foreground mt-1">{account.currency}</p>
</FinancialCardContent>
</FinancialCard>
);
})}
</div>
)}
</div>
);
}
14 changes: 14 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ 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(50) NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
balance NUMERIC(12,2) NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
color VARCHAR(7),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Comment on lines +120 to +130
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new financial_accounts table is added to schema.sql, but existing Postgres deployments that rely on _ensure_schema_compatibility() won't get this table created automatically (and will 500 when hitting /accounts/*). If this repo’s intended upgrade path is to apply compatibility ALTERs in-app, consider adding a CREATE TABLE IF NOT EXISTS financial_accounts ... there as well, or otherwise document/run a migration step for upgrades.

Copilot uses AI. Check for mistakes.

CREATE INDEX IF NOT EXISTS idx_financial_accounts_user ON financial_accounts(user_id, is_active);

CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,21 @@ class UserSubscription(db.Model):
started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


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(50), nullable=False
) # checking, savings, credit, cash, investment
currency = db.Column(db.String(10), default="INR", nullable=False)
balance = db.Column(db.Numeric(12, 2), default=0, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
color = db.Column(db.String(7), nullable=True) # hex color for UI
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class AuditLog(db.Model):
__tablename__ = "audit_logs"
id = db.Column(db.Integer, primary_key=True)
Expand Down
Loading