import { useEffect, useState } from "react"; import { AlertTriangle, CandlestickChart, CheckCircle2, Landmark, PiggyBank, } from "lucide-react"; import type { Wealth, WealthAccount } from "./api"; import { money, request } from "./api"; import { Empty, ErrorMessage } from "./ui"; // The report is recomputed from the journal, so it is keyed on the revision and // never cached: it exists to be compared with a bank or broker's own screen. // Renaming a security lives in the Instruments registry, beside every other // registry entity, rather than being a second editor here. export default function WealthPage({ revision }: { revision: string }) { const [wealth, setWealth] = useState(null); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); const [retry, setRetry] = useState(0); useEffect(() => { const controller = new AbortController(); setLoading(true); setError(""); request("/api/wealth", undefined, controller.signal) .then((value) => { for (const key of ["accounts", "totals"] as const) { if (!(key in value)) throw new Error(`Wealth response is missing ${key}.`); if (value[key] === null) Object.assign(value, { [key]: [] }); } for (const account of value.accounts) { account.holdings ??= []; account.checks ??= []; } setWealth(value); }) .catch((err) => { if (!controller.signal.aborted) { setError(err instanceof Error ? err.message : String(err)); setWealth(null); } }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); }, [revision, retry]); const failures = wealth?.accounts.reduce( (total, account) => total + account.checks.filter((c) => c.failed).length, 0, ) || 0; const failingAccounts = wealth?.accounts.filter((account) => account.checks.some((c) => c.failed)) .length || 0; return ( <>

Wealth

Cash and positions recomputed from your journal, with the checks that decide whether the figures can be trusted.

{loading ? (
Recomputing cash and positions…
) : ( wealth && ( <> {failures > 0 && (
{failures} check{failures === 1 ? "" : "s"} failed across{" "} {failingAccounts} account {failingAccounts === 1 ? "" : "s"}.

A failed check means the journal disagrees with itself, so the balance below will not match your bank or broker. The details sit with the account that failed.

)} {wealth.totals.length > 0 && (

Total cash

Every recorded movement summed per currency, across all{" "} {wealth.accounts.length} account {wealth.accounts.length === 1 ? "" : "s"}.

{wealth.totals.map((total) => ( {money(total.cash, total.currency)} {" "} in cash ))}
)} {wealth.accounts.length === 0 ? (
Add an account and import a statement or broker export to see its cash balance, positions and checks here.
) : ( wealth.accounts.map((account) => ( )) )}

Reading these figures

The three rules that decide what a broker export does and does not move.

Tax on broker cash
A broker cash amount is already net of tax. The tax is recorded on the transaction and deliberately not subtracted a second time.
Position-only events
Corporate actions and position transfers move a position and settle zero cash, so they change a holding without touching the balance.
Investment transactions
Transactions classified as investment are excluded from every spending and income figure, exactly like transfers.
Completeness
Cash equals the real balance only when the journal holds that account's full history: a broker export does, a date-windowed bank statement does not.
) )} ); } function AccountReport({ account }: { account: WealthAccount }) { const range = account.first_booking && account.last_booking ? `${account.first_booking} – ${account.last_booking}` : account.first_booking || account.last_booking || ""; const failed = account.checks.filter((check) => check.failed); const notes = account.checks.filter((check) => !check.failed); const investing = account.kind === "investment"; return (

{investing ? ( ) : ( )} {account.display_name}

{account.institution} · {investing ? "Investment" : "Cash"} account · {account.records} record{account.records === 1 ? "" : "s"} {range ? ` · ${range}` : " · no bookings"} {!account.active && " · archived"}

Cash balance {money(account.cash, account.currency)}
{account.holdings.length > 0 && (
{account.holdings.map((holding) => { // A quantity is an exact decimal string and stays one: the sign // is its first character and a digit above zero is what makes // the position non-empty, with no number parsing in between. // A negative holding means more units left the account than // entered it, which is always worth seeing. const negative = holding.quantity.startsWith("-"); const empty = !/[1-9]/.test(holding.quantity); return ( ); })}
Instrument ISIN Quantity Invested Received Records
{holding.name} {holding.isin} {holding.quantity} {negative && ( more units left than entered )} {money(holding.invested, account.currency)} {money(holding.received, account.currency)} {holding.records}
)} {failed.length > 0 && (
{failed.map((check) => (
{check.name}

{check.detail}

))}
)} {notes.length > 0 && (
{notes.map((check) => (
{check.name}

{check.detail}

))}
)}
); }