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

@ -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) - Capital gains reporting (short/long-term by tax year)
- OHLCV candlestick charts per asset - 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 - 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 ### Reports
Seven report views: Seven report views:

View file

@ -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: async def get_holding(db: AsyncSession, user_id: uuid.UUID, holding_id: uuid.UUID) -> InvestmentHolding | None:
result = await db.execute( result = await db.execute(
select(InvestmentHolding).where( select(InvestmentHolding).where(

View file

@ -11,6 +11,7 @@ from app.db.models.budget import Budget
from app.db.models.category import Category from app.db.models.category import Category
from app.db.models.net_worth_snapshot import NetWorthSnapshot from app.db.models.net_worth_snapshot import NetWorthSnapshot
from app.db.models.transaction import Transaction 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.core.security import decrypt_field
from app.schemas.report import ( from app.schemas.report import (
BalanceSheetAccount, BalanceSheetAccount,
@ -35,7 +36,12 @@ from app.schemas.report import (
LIABILITY_TYPES = {"credit_card", "loan", "mortgage"} 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( result = await db.execute(
select(Account).where( select(Account).where(
Account.user_id == user_id, 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() accounts = result.scalars().all()
holding_values = await get_portfolio_value_by_account(db, user_id, base_currency)
assets = Decimal("0") assets = Decimal("0")
liabilities = Decimal("0") liabilities = Decimal("0")
for acc in accounts: for acc in accounts:
bal = acc.current_balance or Decimal("0") 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: if acc.type in LIABILITY_TYPES:
liabilities += bal liabilities += bal
else: else:
@ -78,7 +89,7 @@ async def get_net_worth_report(
for s in snapshots 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 current_nw = assets - liabilities
change_30d = Decimal("0") change_30d = Decimal("0")
@ -346,6 +357,7 @@ async def get_balance_sheet(
) )
) )
accounts = result.scalars().all() accounts = result.scalars().all()
holding_values = await get_portfolio_value_by_account(db, user_id, base_currency)
ASSET_GROUPS = [ ASSET_GROUPS = [
("Cash & Current Accounts", ["checking", "cash"]), ("Cash & Current Accounts", ["checking", "cash"]),
@ -373,6 +385,8 @@ async def get_balance_sheet(
for a in members: for a in members:
name = decrypt_field(bytes(a.name_enc)) if a.name_enc else "Account" name = decrypt_field(bytes(a.name_enc)) if a.name_enc else "Account"
bal = abs(a.current_balance or Decimal("0")) 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( acct_items.append(BalanceSheetAccount(
id=str(a.id), id=str(a.id),
name=name, 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(): if existing.scalar_one_or_none():
return return
assets, liabilities = await _current_net_worth(db, user_id) assets, liabilities = await _current_net_worth(db, user_id, base_currency)
snapshot = NetWorthSnapshot( snapshot = NetWorthSnapshot(
id=uuid.uuid4(), id=uuid.uuid4(),
user_id=user_id, user_id=user_id,

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getAccounts, createAccount, updateAccount, deleteAccount, getNetWorth, type Account } from "@/api/accounts"; import { getAccounts, createAccount, updateAccount, deleteAccount, getNetWorth, type Account } from "@/api/accounts";
import { getPortfolio } from "@/api/investments";
import { formatCurrency } from "@/utils/currency"; import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
import { import {
@ -57,6 +58,17 @@ export default function AccountList() {
queryFn: getNetWorth, queryFn: getNetWorth,
}); });
const { data: portfolio } = useQuery({
queryKey: ["portfolio"],
queryFn: getPortfolio,
});
const holdingValueByAccount = (portfolio?.holdings ?? []).reduce<Record<string, number>>((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 = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: ["accounts"] }); qc.invalidateQueries({ queryKey: ["accounts"] });
qc.invalidateQueries({ queryKey: ["net-worth"] }); qc.invalidateQueries({ queryKey: ["net-worth"] });
@ -129,6 +141,7 @@ export default function AccountList() {
accounts={assets} accounts={assets}
onEdit={setEditing} onEdit={setEditing}
onDelete={id => deleteMutation.mutate(id)} onDelete={id => deleteMutation.mutate(id)}
holdingValueByAccount={holdingValueByAccount}
/> />
)} )}
@ -147,6 +160,7 @@ export default function AccountList() {
accounts={inactive} accounts={inactive}
onEdit={setEditing} onEdit={setEditing}
onDelete={id => deleteMutation.mutate(id)} onDelete={id => deleteMutation.mutate(id)}
holdingValueByAccount={holdingValueByAccount}
muted muted
/> />
)} )}
@ -179,17 +193,21 @@ export default function AccountList() {
); );
} }
const INVESTMENT_TYPES = new Set(["investment", "pension", "stocks_shares_isa", "crypto_wallet"]);
function AccountGroup({ function AccountGroup({
title, title,
accounts, accounts,
onEdit, onEdit,
onDelete, onDelete,
holdingValueByAccount = {},
muted = false, muted = false,
}: { }: {
title: string; title: string;
accounts: Account[]; accounts: Account[];
onEdit: (a: Account) => void; onEdit: (a: Account) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
holdingValueByAccount?: Record<string, number>;
muted?: boolean; muted?: boolean;
}) { }) {
return ( return (
@ -243,14 +261,31 @@ function AccountGroup({
</div> </div>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
<p className={cn("font-semibold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}> {(() => {
{formatCurrency(account.current_balance, account.currency)} const holdingVal = INVESTMENT_TYPES.has(account.type) ? (holdingValueByAccount[account.id] ?? 0) : 0;
</p> const cashBal = Number(account.current_balance);
{account.credit_limit != null && ( const total = cashBal + holdingVal;
<p className="text-xs text-muted-foreground"> return (
limit {formatCurrency(account.credit_limit, account.currency)} <>
</p> <p className={cn("font-semibold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
)} {formatCurrency(holdingVal > 0 ? total : cashBal, account.currency)}
</p>
{holdingVal > 0 && cashBal !== 0 && (
<p className="text-xs text-muted-foreground">
{formatCurrency(cashBal, account.currency)} cash + {formatCurrency(holdingVal, account.currency)} holdings
</p>
)}
{holdingVal > 0 && cashBal === 0 && (
<p className="text-xs text-muted-foreground">holdings value</p>
)}
{account.credit_limit != null && (
<p className="text-xs text-muted-foreground">
limit {formatCurrency(account.credit_limit, account.currency)}
</p>
)}
</>
);
})()}
</div> </div>
<div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { X, Search, Loader2 } from "lucide-react"; import { X, Search, Loader2 } from "lucide-react";
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments"; import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
import { createTransaction } from "@/api/transactions";
import { useUiStore } from "@/store/uiStore"; import { useUiStore } from "@/store/uiStore";
import { format } from "date-fns"; 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 [priceMode, setPriceMode] = useState<"per_share" | "total">("per_share");
const [recordCash, setRecordCash] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
account_id: investAccounts[0]?.id ?? "", account_id: investAccounts[0]?.id ?? "",
quantity: "", quantity: "",
@ -76,6 +78,18 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
currency: form.currency, currency: form.currency,
date: form.date, 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(); onSuccess();
} catch (e: any) { } catch (e: any) {
const detail = e?.response?.data?.detail; const detail = e?.response?.data?.detail;
@ -237,6 +251,25 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
/> />
</div> </div>
{/* Cash purchase toggle */}
<label className="flex items-start gap-3 cursor-pointer select-none p-3 rounded-lg border border-border hover:bg-secondary/40 transition-colors">
<div className="relative mt-0.5 shrink-0">
<input
type="checkbox"
checked={recordCash}
onChange={(e) => setRecordCash(e.target.checked)}
className="sr-only"
/>
<div className={`w-9 h-5 rounded-full transition-colors ${recordCash ? "bg-primary" : "bg-secondary border border-input"}`}>
<div className={`w-3.5 h-3.5 rounded-full bg-white shadow transition-transform mt-0.5 ${recordCash ? "translate-x-4.5 ml-0.5" : "translate-x-0.5 ml-0.5"}`} />
</div>
</div>
<div>
<p className="text-sm font-medium">Debit account for this purchase</p>
<p className="text-xs text-muted-foreground mt-0.5">Records a matching withdrawal from the account. Enable this if you transferred cash to this account first and want to track the balance accurately.</p>
</div>
</label>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>} {error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-1"> <div className="flex gap-3 pt-1">