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

@ -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<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 = () => {
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<string, number>;
muted?: boolean;
}) {
return (
@ -243,14 +261,31 @@ function AccountGroup({
</div>
<div className="text-right shrink-0">
<p className={cn("font-semibold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
{formatCurrency(account.current_balance, account.currency)}
</p>
{account.credit_limit != null && (
<p className="text-xs text-muted-foreground">
limit {formatCurrency(account.credit_limit, account.currency)}
</p>
)}
{(() => {
const holdingVal = INVESTMENT_TYPES.has(account.type) ? (holdingValueByAccount[account.id] ?? 0) : 0;
const cashBal = Number(account.current_balance);
const total = cashBal + holdingVal;
return (
<>
<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 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 { 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)
/>
</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>}
<div className="flex gap-3 pt-1">