diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py index f0e093f..1fbf0e8 100644 --- a/backend/app/api/v1/reports.py +++ b/backend/app/api/v1/reports.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db from app.db.models.user import User from app.schemas.report import ( + BalanceSheetReport, BudgetVsActualReport, CashFlowReport, CategoryBreakdownReport, @@ -80,3 +81,11 @@ async def spending_trends( current_user: User = Depends(get_current_user), ): return await report_service.get_spending_trends(db, current_user.id, months) + + +@router.get("/balance-sheet", response_model=BalanceSheetReport) +async def balance_sheet( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return await report_service.get_balance_sheet(db, current_user.id, current_user.base_currency) diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index 265028b..0b97d4a 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -94,3 +94,27 @@ class SpendingTrendsReport(BaseModel): points: list[SpendingTrendPoint] categories: list[str] currency: str + + +class BalanceSheetAccount(BaseModel): + id: str + name: str + type: str + balance: Decimal + currency: str + + +class BalanceSheetGroup(BaseModel): + label: str + type_keys: list[str] + accounts: list[BalanceSheetAccount] + subtotal: Decimal + + +class BalanceSheetReport(BaseModel): + asset_groups: list[BalanceSheetGroup] + liability_groups: list[BalanceSheetGroup] + total_assets: Decimal + total_liabilities: Decimal + net_worth: Decimal + currency: str diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 1f94d56..aeb6f09 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -11,7 +11,11 @@ from app.db.models.budget import Budget from app.db.models.category import Category from app.db.models.net_worth_snapshot import NetWorthSnapshot from app.db.models.transaction import Transaction +from app.core.security import decrypt_field from app.schemas.report import ( + BalanceSheetAccount, + BalanceSheetGroup, + BalanceSheetReport, BudgetVsActualItem, BudgetVsActualReport, CashFlowPoint, @@ -329,6 +333,75 @@ async def get_spending_trends( return SpendingTrendsReport(points=points, categories=categories, currency="GBP") +async def get_balance_sheet( + db: AsyncSession, user_id: uuid.UUID, base_currency: str +) -> BalanceSheetReport: + result = await db.execute( + select(Account).where( + Account.user_id == user_id, + Account.is_active == True, # noqa: E712 + Account.deleted_at.is_(None), + ) + ) + accounts = result.scalars().all() + + ASSET_GROUPS = [ + ("Cash & Current Accounts", ["checking", "cash"]), + ("Savings", ["savings"]), + ("ISAs", ["cash_isa", "stocks_shares_isa"]), + ("Investments & Pension", ["investment", "pension"]), + ("Crypto", ["crypto_wallet"]), + ("Other Assets", ["other"]), + ] + LIABILITY_GROUPS = [ + ("Credit Cards", ["credit_card"]), + ("Loans", ["loan"]), + ("Mortgages", ["mortgage"]), + ] + + def build_groups(group_defs: list) -> list[BalanceSheetGroup]: + groups = [] + covered: set[str] = set() + for label, type_keys in group_defs: + covered.update(type_keys) + members = [a for a in accounts if a.type in type_keys] + if not members: + continue + acct_items = [] + for a in members: + name = decrypt_field(bytes(a.name_enc)) if a.name_enc else "Account" + bal = abs(a.current_balance or Decimal("0")) + acct_items.append(BalanceSheetAccount( + id=str(a.id), + name=name, + type=a.type, + balance=bal, + currency=a.currency or base_currency, + )) + groups.append(BalanceSheetGroup( + label=label, + type_keys=type_keys, + accounts=acct_items, + subtotal=sum(i.balance for i in acct_items), + )) + return groups + + asset_groups = build_groups(ASSET_GROUPS) + liability_groups = build_groups(LIABILITY_GROUPS) + + total_assets = sum(g.subtotal for g in asset_groups) + total_liabilities = sum(g.subtotal for g in liability_groups) + + return BalanceSheetReport( + asset_groups=asset_groups, + liability_groups=liability_groups, + total_assets=total_assets, + total_liabilities=total_liabilities, + net_worth=total_assets - total_liabilities, + currency=base_currency, + ) + + async def take_net_worth_snapshot(db: AsyncSession, user_id: uuid.UUID, base_currency: str) -> None: today = date.today() existing = await db.execute( diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts index b854d81..eb33b73 100644 --- a/frontend/src/api/reports.ts +++ b/frontend/src/api/reports.ts @@ -121,3 +121,32 @@ export async function getSpendingTrends(months = 6): Promise { + const r = await api.get("/api/v1/reports/balance-sheet"); + return r.data; +} diff --git a/frontend/src/pages/reports/ReportsPage.tsx b/frontend/src/pages/reports/ReportsPage.tsx index 2cc25e0..479595e 100644 --- a/frontend/src/pages/reports/ReportsPage.tsx +++ b/frontend/src/pages/reports/ReportsPage.tsx @@ -6,7 +6,9 @@ import { getCategoryBreakdown, getBudgetVsActual, getSpendingTrends, + getBalanceSheet, } from "@/api/reports"; +import type { BalanceSheetGroup } from "@/api/reports"; import { formatCurrency } from "@/utils/currency"; import { cn } from "@/utils/cn"; import { @@ -14,9 +16,9 @@ import { PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"; -import { TrendingUp, TrendingDown, Minus } from "lucide-react"; +import { TrendingUp, TrendingDown, Minus, Landmark, CreditCard } from "lucide-react"; -const TABS = ["Net Worth", "Income vs Expense", "Categories", "Budget vs Actual", "Spending Trends"] as const; +const TABS = ["Balance Sheet", "Net Worth", "Income vs Expense", "Categories", "Budget vs Actual", "Spending Trends"] as const; type Tab = typeof TABS[number]; const COLORS = [ @@ -24,6 +26,9 @@ const COLORS = [ "#f59e0b", "#8b5cf6", "#06b6d4", "#84cc16", "#ef4444", ]; +const ASSET_COLORS = ["#22c55e", "#14b8a6", "#6366f1", "#8b5cf6", "#06b6d4", "#84cc16"]; +const LIABILITY_COLORS = ["#ef4444", "#f97316", "#ec4899"]; + function StatCard({ label, value, change, currency }: { label: string; value: number; change?: number; currency: string; }) { @@ -42,6 +47,197 @@ function StatCard({ label, value, change, currency }: { ); } +function GroupRow({ account, color }: { account: { name: string; type: string; balance: number; currency: string }; color: string }) { + return ( +
+
+
+

{account.name}

+

{account.type.replace(/_/g, " ")}

+
+

+ {formatCurrency(account.balance, account.currency)} +

+
+ ); +} + +function BalanceSheetTab() { + const { data, isLoading } = useQuery({ queryKey: ["report-balance-sheet"], queryFn: getBalanceSheet }); + if (isLoading) return ; + if (!data) return null; + + const noAccounts = data.asset_groups.length === 0 && data.liability_groups.length === 0; + if (noAccounts) return ; + + // Build stacked bar data: one bar for assets, one for liabilities + const assetBarData = data.asset_groups.map((g, i) => ({ + name: g.label, + value: Number(g.subtotal), + color: ASSET_COLORS[i % ASSET_COLORS.length], + })); + const liabilityBarData = data.liability_groups.map((g, i) => ({ + name: g.label, + value: Number(g.subtotal), + color: LIABILITY_COLORS[i % LIABILITY_COLORS.length], + })); + + // Single stacked bar chart showing asset composition vs liability composition + const maxVal = Math.max(Number(data.total_assets), Number(data.total_liabilities), 1); + + return ( +
+ {/* Summary KPIs */} +
+
+

Total Assets

+

{formatCurrency(Number(data.total_assets), data.currency)}

+
+
+

Total Liabilities

+

{formatCurrency(Number(data.total_liabilities), data.currency)}

+
+
+

Net Worth

+

= 0 ? "text-primary" : "text-destructive")}> + {formatCurrency(Number(data.net_worth), data.currency)} +

+
+
+ + {/* Visual proportion bars */} +
+

Asset & Liability Composition

+ + {/* Assets bar */} + {assetBarData.length > 0 && ( +
+
+ + Assets + + {formatCurrency(Number(data.total_assets), data.currency)} +
+
+ {assetBarData.map((seg) => ( +
0 ? "2px" : "0", + }} + title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} + /> + ))} +
+
+ {assetBarData.map((seg) => ( +
+
+ {seg.name} +
+ ))} +
+
+ )} + + {/* Liabilities bar */} + {liabilityBarData.length > 0 && ( +
+
+ + Liabilities + + {formatCurrency(Number(data.total_liabilities), data.currency)} +
+
+ {liabilityBarData.map((seg) => ( +
0 ? "2px" : "0", + }} + title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`} + /> + ))} +
+
+ {liabilityBarData.map((seg) => ( +
+
+ {seg.name} +
+ ))} +
+
+ )} +
+ + {/* Side-by-side account breakdown */} +
+ {/* Assets */} +
+
+

+ Assets +

+ + {formatCurrency(Number(data.total_assets), data.currency)} + +
+
+ {data.asset_groups.map((group: BalanceSheetGroup, gi: number) => ( +
+
+

{group.label}

+

{formatCurrency(Number(group.subtotal), data.currency)}

+
+ {group.accounts.map((acc) => ( + + ))} +
+ ))} +
+
+ + {/* Liabilities */} +
+
+

+ Liabilities +

+ + {formatCurrency(Number(data.total_liabilities), data.currency)} + +
+ {data.liability_groups.length === 0 ? ( +

No liabilities — great work!

+ ) : ( +
+ {data.liability_groups.map((group: BalanceSheetGroup, gi: number) => ( +
+
+

{group.label}

+

{formatCurrency(Number(group.subtotal), data.currency)}

+
+ {group.accounts.map((acc) => ( + + ))} +
+ ))} +
+ )} +
+
+
+ ); +} + function NetWorthTab() { const { data, isLoading } = useQuery({ queryKey: ["report-net-worth"], queryFn: () => getNetWorthReport(12) }); if (isLoading) return ; @@ -244,7 +440,7 @@ function EmptyChart({ message = "No data for this period" }: { message?: string } export default function ReportsPage() { - const [activeTab, setActiveTab] = useState("Net Worth"); + const [activeTab, setActiveTab] = useState("Balance Sheet"); return (
@@ -253,13 +449,13 @@ export default function ReportsPage() {

Financial insights and analysis

-
+
{TABS.map((tab) => (
+ {activeTab === "Balance Sheet" && } {activeTab === "Net Worth" && } {activeTab === "Income vs Expense" && } {activeTab === "Categories" && }