Add Balance Sheet report
New first-tab report showing a full breakdown of where money sits: - Three KPI cards: total assets, total liabilities, net worth - Proportional stacked bars showing asset and liability composition - Side-by-side account lists grouped by type (Cash, Savings, ISAs, Investments, Pension, Crypto vs Credit Cards, Loans, Mortgages) - Backend endpoint GET /api/v1/reports/balance-sheet with typed schema - Balance Sheet is now the default tab on the Reports page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
70db18e89f
commit
dd66b2d5fe
5 changed files with 337 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -121,3 +121,32 @@ export async function getSpendingTrends(months = 6): Promise<SpendingTrendsRepor
|
|||
const r = await api.get("/api/v1/reports/spending-trends", { params: { months } });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export interface BalanceSheetAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
balance: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface BalanceSheetGroup {
|
||||
label: string;
|
||||
type_keys: string[];
|
||||
accounts: BalanceSheetAccount[];
|
||||
subtotal: number;
|
||||
}
|
||||
|
||||
export interface BalanceSheetReport {
|
||||
asset_groups: BalanceSheetGroup[];
|
||||
liability_groups: BalanceSheetGroup[];
|
||||
total_assets: number;
|
||||
total_liabilities: number;
|
||||
net_worth: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export async function getBalanceSheet(): Promise<BalanceSheetReport> {
|
||||
const r = await api.get("/api/v1/reports/balance-sheet");
|
||||
return r.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex items-center gap-3 py-2 border-b border-border/40 last:border-0">
|
||||
<div className="w-1.5 h-6 rounded-full shrink-0" style={{ background: color }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{account.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{account.type.replace(/_/g, " ")}</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold tabular-nums shrink-0">
|
||||
{formatCurrency(account.balance, account.currency)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BalanceSheetTab() {
|
||||
const { data, isLoading } = useQuery({ queryKey: ["report-balance-sheet"], queryFn: getBalanceSheet });
|
||||
if (isLoading) return <ChartSkeleton />;
|
||||
if (!data) return null;
|
||||
|
||||
const noAccounts = data.asset_groups.length === 0 && data.liability_groups.length === 0;
|
||||
if (noAccounts) return <EmptyChart message="No accounts found" />;
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-6">
|
||||
{/* Summary KPIs */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Assets</p>
|
||||
<p className="text-xl font-bold tabular-nums text-success">{formatCurrency(Number(data.total_assets), data.currency)}</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Total Liabilities</p>
|
||||
<p className="text-xl font-bold tabular-nums text-destructive">{formatCurrency(Number(data.total_liabilities), data.currency)}</p>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-xl p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Net Worth</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", Number(data.net_worth) >= 0 ? "text-primary" : "text-destructive")}>
|
||||
{formatCurrency(Number(data.net_worth), data.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual proportion bars */}
|
||||
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
|
||||
<p className="text-sm font-medium">Asset & Liability Composition</p>
|
||||
|
||||
{/* Assets bar */}
|
||||
{assetBarData.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground flex items-center gap-1.5">
|
||||
<Landmark className="w-3.5 h-3.5" /> Assets
|
||||
</span>
|
||||
<span>{formatCurrency(Number(data.total_assets), data.currency)}</span>
|
||||
</div>
|
||||
<div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary">
|
||||
{assetBarData.map((seg) => (
|
||||
<div
|
||||
key={seg.name}
|
||||
className="transition-all duration-500"
|
||||
style={{
|
||||
width: `${(seg.value / maxVal) * 100}%`,
|
||||
background: seg.color,
|
||||
minWidth: seg.value > 0 ? "2px" : "0",
|
||||
}}
|
||||
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{assetBarData.map((seg) => (
|
||||
<div key={seg.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: seg.color }} />
|
||||
{seg.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liabilities bar */}
|
||||
{liabilityBarData.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground flex items-center gap-1.5">
|
||||
<CreditCard className="w-3.5 h-3.5" /> Liabilities
|
||||
</span>
|
||||
<span>{formatCurrency(Number(data.total_liabilities), data.currency)}</span>
|
||||
</div>
|
||||
<div className="flex h-7 rounded-lg overflow-hidden gap-px bg-secondary">
|
||||
{liabilityBarData.map((seg) => (
|
||||
<div
|
||||
key={seg.name}
|
||||
className="transition-all duration-500"
|
||||
style={{
|
||||
width: `${(seg.value / maxVal) * 100}%`,
|
||||
background: seg.color,
|
||||
minWidth: seg.value > 0 ? "2px" : "0",
|
||||
}}
|
||||
title={`${seg.name}: ${formatCurrency(seg.value, data.currency)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{liabilityBarData.map((seg) => (
|
||||
<div key={seg.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: seg.color }} />
|
||||
{seg.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side-by-side account breakdown */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Assets */}
|
||||
<div className="bg-card border border-border rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Landmark className="w-4 h-4 text-success" /> Assets
|
||||
</h3>
|
||||
<span className="text-sm font-bold text-success tabular-nums">
|
||||
{formatCurrency(Number(data.total_assets), data.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{data.asset_groups.map((group: BalanceSheetGroup, gi: number) => (
|
||||
<div key={group.label}>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{group.label}</p>
|
||||
<p className="text-xs font-semibold tabular-nums">{formatCurrency(Number(group.subtotal), data.currency)}</p>
|
||||
</div>
|
||||
{group.accounts.map((acc) => (
|
||||
<GroupRow key={acc.id} account={acc} color={ASSET_COLORS[gi % ASSET_COLORS.length]} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liabilities */}
|
||||
<div className="bg-card border border-border rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-destructive" /> Liabilities
|
||||
</h3>
|
||||
<span className="text-sm font-bold text-destructive tabular-nums">
|
||||
{formatCurrency(Number(data.total_liabilities), data.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{data.liability_groups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No liabilities — great work!</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.liability_groups.map((group: BalanceSheetGroup, gi: number) => (
|
||||
<div key={group.label}>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{group.label}</p>
|
||||
<p className="text-xs font-semibold tabular-nums">{formatCurrency(Number(group.subtotal), data.currency)}</p>
|
||||
</div>
|
||||
{group.accounts.map((acc) => (
|
||||
<GroupRow key={acc.id} account={acc} color={LIABILITY_COLORS[gi % LIABILITY_COLORS.length]} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetWorthTab() {
|
||||
const { data, isLoading } = useQuery({ queryKey: ["report-net-worth"], queryFn: () => getNetWorthReport(12) });
|
||||
if (isLoading) return <ChartSkeleton />;
|
||||
|
|
@ -244,7 +440,7 @@ function EmptyChart({ message = "No data for this period" }: { message?: string
|
|||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("Net Worth");
|
||||
const [activeTab, setActiveTab] = useState<Tab>("Balance Sheet");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -253,13 +449,13 @@ export default function ReportsPage() {
|
|||
<p className="text-sm text-muted-foreground mt-1">Financial insights and analysis</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
<div className="flex gap-1 border-b border-border overflow-x-auto scrollbar-none">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
|
||||
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
|
||||
activeTab === tab
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
|
|
@ -271,6 +467,7 @@ export default function ReportsPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
{activeTab === "Balance Sheet" && <BalanceSheetTab />}
|
||||
{activeTab === "Net Worth" && <NetWorthTab />}
|
||||
{activeTab === "Income vs Expense" && <IncomeExpenseTab />}
|
||||
{activeTab === "Categories" && <CategoriesTab />}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue