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