Include investment holding values in net worth and account balances

- Net worth report, balance sheet, and daily snapshots now add holding
  market values (falling back to cost basis) to investment-type account
  balances (investment, pension, stocks_shares_isa, crypto_wallet)
- Accounts list shows total value for investment accounts with a
  breakdown line ("£X cash + £Y holdings") when both are non-zero
- Add Holding modal gains a "Debit account for this purchase" toggle
  that creates a matching withdrawal transaction, enabling proper cash
  flow tracking for users who fund their brokerage via transfer first
- Both simple (holdings-only) and full cash-flow workflows produce
  correct net worth figures without double-counting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-23 10:10:19 +00:00
parent cdc1e67321
commit 312594f3d2
5 changed files with 139 additions and 11 deletions

View file

@ -11,6 +11,7 @@ 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.services.investment_service import get_portfolio_value_by_account
from app.core.security import decrypt_field
from app.schemas.report import (
BalanceSheetAccount,
@ -35,7 +36,12 @@ from app.schemas.report import (
LIABILITY_TYPES = {"credit_card", "loan", "mortgage"}
async def _current_net_worth(db: AsyncSession, user_id: uuid.UUID) -> tuple[Decimal, Decimal]:
INVESTMENT_ACCOUNT_TYPES = {"investment", "pension", "stocks_shares_isa", "crypto_wallet"}
async def _current_net_worth(
db: AsyncSession, user_id: uuid.UUID, base_currency: str = "GBP"
) -> tuple[Decimal, Decimal]:
result = await db.execute(
select(Account).where(
Account.user_id == user_id,
@ -45,10 +51,15 @@ async def _current_net_worth(db: AsyncSession, user_id: uuid.UUID) -> tuple[Deci
)
)
accounts = result.scalars().all()
holding_values = await get_portfolio_value_by_account(db, user_id, base_currency)
assets = Decimal("0")
liabilities = Decimal("0")
for acc in accounts:
bal = acc.current_balance or Decimal("0")
if acc.type in INVESTMENT_ACCOUNT_TYPES:
bal += holding_values.get(acc.id, Decimal("0"))
if acc.type in LIABILITY_TYPES:
liabilities += bal
else:
@ -78,7 +89,7 @@ async def get_net_worth_report(
for s in snapshots
]
assets, liabilities = await _current_net_worth(db, user_id)
assets, liabilities = await _current_net_worth(db, user_id, base_currency)
current_nw = assets - liabilities
change_30d = Decimal("0")
@ -346,6 +357,7 @@ async def get_balance_sheet(
)
)
accounts = result.scalars().all()
holding_values = await get_portfolio_value_by_account(db, user_id, base_currency)
ASSET_GROUPS = [
("Cash & Current Accounts", ["checking", "cash"]),
@ -373,6 +385,8 @@ async def get_balance_sheet(
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"))
if a.type in INVESTMENT_ACCOUNT_TYPES:
bal += holding_values.get(a.id, Decimal("0"))
acct_items.append(BalanceSheetAccount(
id=str(a.id),
name=name,
@ -459,7 +473,7 @@ async def take_net_worth_snapshot(db: AsyncSession, user_id: uuid.UUID, base_cur
if existing.scalar_one_or_none():
return
assets, liabilities = await _current_net_worth(db, user_id)
assets, liabilities = await _current_net_worth(db, user_id, base_currency)
snapshot = NetWorthSnapshot(
id=uuid.uuid4(),
user_id=user_id,