Count hand-valued assets into the wealth figure

A wealth figure that ignores the house is not a wealth figure. Assets
without a market feed - a house, a car, a private loan - are now added
by hand on the Wealth page with a stated value, a currency and the day
the estimate was made; a negative value records a liability. They are
registry entities in assets.finance like everything else, join the
per-currency totals immediately, and a currency held only in an asset
earns its own line.
This commit is contained in:
Lars Nolden
2026-09-14 09:29:19 +02:00
parent a1480af74d
commit 77f4ea5655
15 changed files with 564 additions and 29 deletions
+2
View File
@@ -617,6 +617,8 @@ function WealthStrip({
{money(total.positions, total.currency)} in positions across{" "}
{positions} investment account
{positions === 1 ? "" : "s"}
{total.assets !== "0.00" &&
` · ${money(total.assets, total.currency)} in other assets`}
{total.unpriced > 0 &&
` · ${total.unpriced} holding${total.unpriced === 1 ? "" : "s"} without a quote, excluded`}
{failing > 0 &&
+315 -5
View File
@@ -3,12 +3,30 @@ import {
AlertTriangle,
CandlestickChart,
CheckCircle2,
Home,
Landmark,
Pencil,
PiggyBank,
Plus,
Trash2,
} from "lucide-react";
import type { QuoteResult, State, Wealth, WealthAccount } from "./api";
import type {
QuoteResult,
State,
Wealth,
WealthAccount,
WealthAsset,
} from "./api";
import { money, request } from "./api";
import { Empty, ErrorMessage } from "./ui";
import {
DateField,
Empty,
ErrorMessage,
Field,
FormActions,
Modal,
type Mutate,
} 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.
@@ -17,9 +35,11 @@ import { Empty, ErrorMessage } from "./ui";
export default function WealthPage({
revision,
acceptState,
mutate,
}: {
revision: string;
acceptState: (state: State, message?: string) => void;
mutate: Mutate;
}) {
const [wealth, setWealth] = useState<Wealth | null>(null);
const [error, setError] = useState("");
@@ -33,7 +53,7 @@ export default function WealthPage({
setError("");
request<Wealth>("/api/wealth", undefined, controller.signal)
.then((value) => {
for (const key of ["accounts", "totals"] as const) {
for (const key of ["accounts", "assets", "totals"] as const) {
if (!(key in value))
throw new Error(`Wealth response is missing ${key}.`);
if (value[key] === null) Object.assign(value, { [key]: [] });
@@ -181,8 +201,9 @@ export default function WealthPage({
Total wealth
</h3>
<p>
Cash plus the market value of every priced holding, per
currency, across all {wealth.accounts.length} account
Cash, the market value of every priced holding, and your
other assets, per currency, across all{" "}
{wealth.accounts.length} account
{wealth.accounts.length === 1 ? "" : "s"}.
</p>
</div>
@@ -212,6 +233,14 @@ export default function WealthPage({
</strong>{" "}
in positions
</span>
{total.assets !== "0.00" && (
<span>
<strong className="money">
{money(total.assets, total.currency)}
</strong>{" "}
in other assets
</span>
)}
{total.unpriced > 0 && (
<span>
<strong>{total.unpriced}</strong> holding
@@ -224,6 +253,11 @@ export default function WealthPage({
</div>
</section>
)}
<AssetsPanel
assets={wealth.assets}
currency={wealth.totals[0]?.currency ?? "EUR"}
mutate={mutate}
/>
{wealth.accounts.length === 0 ? (
<section className="panel">
<Empty title="No accounts to report on yet">
@@ -476,3 +510,279 @@ function AccountReport({ account }: { account: WealthAccount }) {
</section>
);
}
// AssetsPanel lists the hand-valued possessions counted into the total above
// and edits them in place: they live in the journal like any registry entity,
// but this page is where their figure matters, so this page manages them.
function AssetsPanel({
assets,
currency,
mutate,
}: {
assets: WealthAsset[];
currency: string;
mutate: Mutate;
}) {
const blank: WealthAsset = {
asset_id: "",
name: "",
kind: "",
currency,
value: "",
valued_at: new Date().toISOString().slice(0, 10),
};
const [editing, setEditing] = useState<WealthAsset | null>(null);
const [removing, setRemoving] = useState<WealthAsset | null>(null);
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<Home size={17} />
Other assets
</h3>
<p>
Possessions you value by hand a house, a car, a private loan
counted into the total above. A negative value records a liability
such as a mortgage.
</p>
</div>
<div className="row-actions">
<button className="button secondary" onClick={() => setEditing(blank)}>
<Plus size={16} />
Add asset
</button>
</div>
</div>
{assets.length === 0 ? (
<Empty title="No assets recorded yet">
Anything without a market feed goes here at the value you state, and
it joins the wealth figure immediately.
</Empty>
) : (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Asset</th>
<th>Kind</th>
<th className="numeric">Value</th>
<th>Valued on</th>
<th></th>
</tr>
</thead>
<tbody>
{assets.map((asset) => (
<tr key={asset.asset_id}>
<td>{asset.name}</td>
<td className="muted">{asset.kind || "—"}</td>
<td
className={`numeric money ${asset.value.startsWith("-") ? "text-danger" : ""}`}
>
{money(asset.value, asset.currency)}
</td>
<td className="muted">{asset.valued_at}</td>
<td>
<div className="row-actions">
<button
className="icon-button"
aria-label={`Edit ${asset.name}`}
onClick={() => setEditing(asset)}
>
<Pencil size={16} />
</button>
<button
className="icon-button danger"
aria-label={`Delete ${asset.name}`}
onClick={() => setRemoving(asset)}
>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<p className="hint">
A value is what you state it is, dated so a stale estimate is
visible. Re-edit an asset when its worth changes.
</p>
</div>
)}
{editing && (
<AssetEditor
asset={editing}
mutate={mutate}
close={() => setEditing(null)}
/>
)}
{removing && (
<DeleteAsset
asset={removing}
mutate={mutate}
close={() => setRemoving(null)}
/>
)}
</section>
);
}
function AssetEditor({
asset,
mutate,
close,
}: {
asset: WealthAsset;
mutate: Mutate;
close: () => void;
}) {
const [name, setName] = useState(asset.name);
const [kind, setKind] = useState(asset.kind || "");
const [currency, setCurrency] = useState(asset.currency);
const [value, setValue] = useState(asset.value);
const [valuedAt, setValuedAt] = useState(asset.valued_at);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
return (
<Modal title={asset.asset_id ? "Edit asset" : "New asset"} close={close}>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
"/api/assets",
{
asset: {
id: asset.asset_id,
name: name.trim(),
kind: kind.trim(),
currency: currency.toUpperCase(),
value: value.trim(),
valued_at: valuedAt,
},
},
`${name.trim()} saved`,
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<Field label="Name">
<input
required
maxLength={200}
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
placeholder="Family home"
/>
</Field>
<Field label="Kind" hint="Free text: Real estate, Vehicle, Loan…">
<input
maxLength={100}
value={kind}
onChange={(e) => setKind(e.target.value)}
placeholder="Real estate"
/>
</Field>
<Field
label="Value"
hint="Your own estimate. A negative value records a liability such as a mortgage."
>
<input
required
inputMode="decimal"
pattern="-?\d+([.,]\d{1,4})?"
title="A decimal amount with up to four decimal places"
value={value}
onChange={(e) => setValue(e.target.value.replace(",", "."))}
placeholder="250000"
/>
</Field>
<Field label="Currency">
<input
required
maxLength={3}
pattern="[A-Za-z]{3}"
title="Three-letter currency code"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
/>
</Field>
<DateField
label="Valued on"
value={valuedAt}
onChange={setValuedAt}
hint="The day this estimate was made, so a stale figure is visible."
/>
</div>
<FormActions
busy={busy}
close={close}
label={asset.asset_id ? "Save changes" : "Add asset"}
/>
</form>
</Modal>
);
}
function DeleteAsset({
asset,
mutate,
close,
}: {
asset: WealthAsset;
mutate: Mutate;
close: () => void;
}) {
const [confirm, setConfirm] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
return (
<Modal title={`Delete ${asset.name}?`} close={close}>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
"/api/manage",
{ entity: "asset", action: "delete", id: asset.asset_id },
"Asset deleted",
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<p>
Its {money(asset.value, asset.currency)} leaves the wealth figure
immediately. Nothing else references an asset.
</p>
<label className="checkbox">
<input
required
type="checkbox"
checked={confirm}
onChange={(e) => setConfirm(e.target.checked)}
/>
Permanently delete this asset.
</label>
</div>
<FormActions busy={busy} close={close} label="Delete asset" />
</form>
</Modal>
);
}
+27
View File
@@ -27,6 +27,17 @@ export interface Instrument {
quote?: string;
quoted_at?: string;
}
// Asset is a possession valued by hand: a house, a car, anything without a
// market feed. value is what the owner states it is worth and valued_at the
// day that estimate was made. A negative value records a liability.
export interface Asset {
id: string;
name: string;
kind?: string;
currency: string;
value: string;
valued_at: string;
}
// Investment is the broker-native leg of a fact. Cash movement always stays in
// Facts.amount, so a position-only event carries a zero amount. Quantity is an
// exact signed decimal, not money: negative removes from the holding.
@@ -107,6 +118,7 @@ export interface Dataset {
tags: Tag[];
merchants: Merchant[];
instruments: Instrument[];
assets: Asset[];
transactions: Transaction[];
}
export interface Connection {
@@ -384,13 +396,27 @@ export interface WealthTotal {
currency: string;
cash: string;
positions: string;
// assets is the stated value of every hand-valued asset in this currency,
// and wealth is cash, positions and assets together.
assets: string;
wealth: string;
unpriced: number;
}
// WealthAsset is one hand-valued asset as the journal records it: the value is
// stated, never quoted, and carries the day it was stated.
export interface WealthAsset {
asset_id: string;
name: string;
kind?: string;
currency: string;
value: string;
valued_at: string;
}
// Wealth is a reconciliation report computed from the journal rather than the
// analytics index, so it can be checked against a bank or broker's own screen.
export interface Wealth {
accounts: WealthAccount[];
assets: WealthAsset[];
totals: WealthTotal[];
}
export class APIError extends Error {
@@ -461,6 +487,7 @@ export function normalizeState(state: State): State {
"tags",
"merchants",
"instruments",
"assets",
"transactions",
] as const) {
if (!(key in state.data))
+5 -1
View File
@@ -402,7 +402,11 @@ function App() {
/>
)}
{page === "wealth" && (
<Wealth revision={state.revision} acceptState={acceptState} />
<Wealth
revision={state.revision}
acceptState={acceptState}
mutate={mutate}
/>
)}
{page === "classification" && (
<Classification state={state} acceptState={acceptState} />