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:
megaproxy 2026-04-21 15:33:34 +00:00
parent 70db18e89f
commit dd66b2d5fe
5 changed files with 337 additions and 5 deletions

View file

@ -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)

View file

@ -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

View file

@ -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(