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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue