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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -66,13 +67,19 @@ 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.
- Dashboard:
- 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).
Expand Down Expand Up @@ -104,6 +111,7 @@ finmind/
bills.py
reminders.py
insights.py
accounts.py
services/
__init__.py
ai.py
Expand All @@ -127,6 +135,7 @@ finmind/
Dashboard.tsx
Expenses.tsx
Bills.tsx
Accounts.tsx
Settings.tsx
package.json
tsconfig.json
Expand Down
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
195 changes: 195 additions & 0 deletions app/src/__tests__/Accounts.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { Accounts } from '../pages/Accounts';

// Mock API modules
jest.mock('../api/accounts', () => ({
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(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}

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(<Accounts />);
expect(screen.getByText('Financial Accounts')).toBeInTheDocument();
});

it('displays summary cards after loading', async () => {
renderWithProviders(<Accounts />);
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(<Accounts />);
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(<Accounts />);
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(<Accounts />);
await waitFor(() => {
expect(screen.getByText('Salary')).toBeInTheDocument();
});
expect(screen.getByText('Groceries')).toBeInTheDocument();
});

it('shows add account button', async () => {
renderWithProviders(<Accounts />);
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(<Accounts />);
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(<Accounts />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
});
});
125 changes: 125 additions & 0 deletions app/src/api/accounts.ts
Original file line number Diff line number Diff line change
@@ -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<AccountCreate> & {
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<FinancialAccount[]> {
const qs = includeInactive ? '?include_inactive=true' : '';
return api<FinancialAccount[]>(`/accounts${qs}`);
}

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

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

export async function updateAccount(
id: number,
payload: AccountUpdate,
): Promise<FinancialAccount> {
return api<FinancialAccount>(`/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<AccountsOverview> {
const qs = month ? `?month=${encodeURIComponent(month)}` : '';
return api<AccountsOverview>(`/accounts/overview${qs}`);
}

export async function getAccountTransactions(
accountId: number,
params?: { page?: number; page_size?: number },
): Promise<AccountTransaction[]> {
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<AccountTransaction[]>(`/accounts/${accountId}/transactions${query}`);
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading