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:
@@ -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
@@ -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,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
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user