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:
parent
cdc1e67321
commit
312594f3d2
5 changed files with 139 additions and 11 deletions
|
|
@ -142,6 +142,51 @@ async def get_portfolio(db: AsyncSession, user_id: uuid.UUID, base_currency: str
|
|||
)
|
||||
|
||||
|
||||
async def get_portfolio_value_by_account(
|
||||
db: AsyncSession, user_id: uuid.UUID, base_currency: str
|
||||
) -> dict[uuid.UUID, Decimal]:
|
||||
"""Return total holding value (in base_currency) keyed by account_id."""
|
||||
result = await db.execute(
|
||||
select(InvestmentHolding).where(
|
||||
InvestmentHolding.user_id == user_id,
|
||||
InvestmentHolding.quantity > 0,
|
||||
)
|
||||
)
|
||||
holdings = result.scalars().all()
|
||||
|
||||
assets: dict[uuid.UUID, Asset] = {}
|
||||
for h in holdings:
|
||||
if h.asset_id not in assets:
|
||||
asset = await _get_asset(db, h.asset_id)
|
||||
if asset:
|
||||
assets[h.asset_id] = asset
|
||||
|
||||
pairs_needed: set[tuple[str, str]] = set()
|
||||
for h in holdings:
|
||||
asset = assets.get(h.asset_id)
|
||||
if not asset:
|
||||
continue
|
||||
if asset.currency != h.currency:
|
||||
pairs_needed.add((asset.currency, h.currency))
|
||||
if h.currency != base_currency:
|
||||
pairs_needed.add((h.currency, base_currency))
|
||||
|
||||
fx_rates: dict[tuple[str, str], Decimal] = {}
|
||||
for from_curr, to_curr in pairs_needed:
|
||||
fx_rates[(from_curr, to_curr)] = await _fetch_fx_rate(db, from_curr, to_curr)
|
||||
|
||||
totals: dict[uuid.UUID, Decimal] = {}
|
||||
for h in holdings:
|
||||
asset = assets.get(h.asset_id)
|
||||
if not asset:
|
||||
continue
|
||||
r = _holding_to_response(h, asset, fx_rates)
|
||||
value = r.current_value if r.current_value is not None else r.cost_basis_total
|
||||
to_base = fx_rates.get((h.currency, base_currency), Decimal("1")) if h.currency != base_currency else Decimal("1")
|
||||
totals[h.account_id] = totals.get(h.account_id, Decimal("0")) + value * to_base
|
||||
return totals
|
||||
|
||||
|
||||
async def get_holding(db: AsyncSession, user_id: uuid.UUID, holding_id: uuid.UUID) -> InvestmentHolding | None:
|
||||
result = await db.execute(
|
||||
select(InvestmentHolding).where(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue