From 312594f3d2cc96576b775e7e1e89cc8fdc7adeaf Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 23 Apr 2026 10:10:19 +0000 Subject: [PATCH] Include investment holding values in net worth and account balances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 1 + backend/app/services/investment_service.py | 45 ++++++++++++++++ backend/app/services/report_service.py | 20 ++++++-- frontend/src/pages/accounts/AccountList.tsx | 51 ++++++++++++++++--- .../src/pages/investments/AddHoldingModal.tsx | 33 ++++++++++++ 5 files changed, 139 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2ff0885..5408d7a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w - Capital gains reporting (short/long-term by tax year) - OHLCV candlestick charts per asset - Portfolio visualisations: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar chart, return % per holding bar chart +- Investment account balances include holding market values in net worth, balance sheet, and accounts list — supports both simple (holdings only) and full cash-flow tracking workflows ### Reports Seven report views: diff --git a/backend/app/services/investment_service.py b/backend/app/services/investment_service.py index 7d3edab..c6061bc 100644 --- a/backend/app/services/investment_service.py +++ b/backend/app/services/investment_service.py @@ -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( diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 07eeee1..c490c9d 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -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, diff --git a/frontend/src/pages/accounts/AccountList.tsx b/frontend/src/pages/accounts/AccountList.tsx index 2131463..b5a18e9 100644 --- a/frontend/src/pages/accounts/AccountList.tsx +++ b/frontend/src/pages/accounts/AccountList.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getAccounts, createAccount, updateAccount, deleteAccount, getNetWorth, type Account } from "@/api/accounts"; +import { getPortfolio } from "@/api/investments"; import { formatCurrency } from "@/utils/currency"; import { cn } from "@/utils/cn"; import { @@ -57,6 +58,17 @@ export default function AccountList() { queryFn: getNetWorth, }); + const { data: portfolio } = useQuery({ + queryKey: ["portfolio"], + queryFn: getPortfolio, + }); + + const holdingValueByAccount = (portfolio?.holdings ?? []).reduce>((acc, h) => { + const v = Number(h.current_value || h.cost_basis_total); + acc[h.account_id] = (acc[h.account_id] ?? 0) + v; + return acc; + }, {}); + const invalidate = () => { qc.invalidateQueries({ queryKey: ["accounts"] }); qc.invalidateQueries({ queryKey: ["net-worth"] }); @@ -129,6 +141,7 @@ export default function AccountList() { accounts={assets} onEdit={setEditing} onDelete={id => deleteMutation.mutate(id)} + holdingValueByAccount={holdingValueByAccount} /> )} @@ -147,6 +160,7 @@ export default function AccountList() { accounts={inactive} onEdit={setEditing} onDelete={id => deleteMutation.mutate(id)} + holdingValueByAccount={holdingValueByAccount} muted /> )} @@ -179,17 +193,21 @@ export default function AccountList() { ); } +const INVESTMENT_TYPES = new Set(["investment", "pension", "stocks_shares_isa", "crypto_wallet"]); + function AccountGroup({ title, accounts, onEdit, onDelete, + holdingValueByAccount = {}, muted = false, }: { title: string; accounts: Account[]; onEdit: (a: Account) => void; onDelete: (id: string) => void; + holdingValueByAccount?: Record; muted?: boolean; }) { return ( @@ -243,14 +261,31 @@ function AccountGroup({
-

- {formatCurrency(account.current_balance, account.currency)} -

- {account.credit_limit != null && ( -

- limit {formatCurrency(account.credit_limit, account.currency)} -

- )} + {(() => { + const holdingVal = INVESTMENT_TYPES.has(account.type) ? (holdingValueByAccount[account.id] ?? 0) : 0; + const cashBal = Number(account.current_balance); + const total = cashBal + holdingVal; + return ( + <> +

+ {formatCurrency(holdingVal > 0 ? total : cashBal, account.currency)} +

+ {holdingVal > 0 && cashBal !== 0 && ( +

+ {formatCurrency(cashBal, account.currency)} cash + {formatCurrency(holdingVal, account.currency)} holdings +

+ )} + {holdingVal > 0 && cashBal === 0 && ( +

holdings value

+ )} + {account.credit_limit != null && ( +

+ limit {formatCurrency(account.credit_limit, account.currency)} +

+ )} + + ); + })()}
diff --git a/frontend/src/pages/investments/AddHoldingModal.tsx b/frontend/src/pages/investments/AddHoldingModal.tsx index e187c43..f729bfd 100644 --- a/frontend/src/pages/investments/AddHoldingModal.tsx +++ b/frontend/src/pages/investments/AddHoldingModal.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { X, Search, Loader2 } from "lucide-react"; import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments"; +import { createTransaction } from "@/api/transactions"; import { useUiStore } from "@/store/uiStore"; import { format } from "date-fns"; @@ -28,6 +29,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) ); const [priceMode, setPriceMode] = useState<"per_share" | "total">("per_share"); + const [recordCash, setRecordCash] = useState(false); const [form, setForm] = useState({ account_id: investAccounts[0]?.id ?? "", quantity: "", @@ -76,6 +78,18 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) currency: form.currency, date: form.date, }); + if (recordCash) { + const totalSpent = qty * price + (parseFloat(form.fees) || 0); + await createTransaction({ + account_id: form.account_id, + type: "investment", + amount: -totalSpent, + currency: form.currency, + date: form.date, + description: `Buy ${selected.symbol} × ${qty}`, + status: "cleared", + }); + } onSuccess(); } catch (e: any) { const detail = e?.response?.data?.detail; @@ -237,6 +251,25 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) />
+ {/* Cash purchase toggle */} +