Track investments as broker facts with a position leg

An account now has a kind, and an investment account holds positions as well as
cash. A broker row is not a new entity: it is a bank fact with an optional
position leg, so deduplication, the journal, fact immutability, the DuckDB
projection and the transactions view carry it unchanged. Facts.Amount stays the
cash leg and is zero on the rows that move only a position.

Scalable Capital exports are recognized locally as a fourth format, read by
their own parser because a column mapping cannot describe them: the amount
column is settled cash on a cash row, a gross to be netted on a trade, and a
position valuation that must never touch cash on a corporate action or a depot
transfer. A cash amount is already net of the tax the broker withheld or
refunded, so that tax is recorded on the fact and never subtracted a second
time; treating a corporate action's valuation as money conjures cash, and a
depot switch would do it once per instrument. The share column is signed only
for those two types, so buys and sells take their direction from the type. Every
security row is checked against shares times price at 128-bit width, because a
lost decimal separator survives every other check. An unknown status, type or
assetType, a foreign currency, a missing ISIN, or one failed check rejects the
whole file with the record number.

Instruments live in instruments.finance, keyed by ISIN with an ID derived from
it, so re-importing never registers a security twice. One ISIN appears under
several broker descriptions over the years and sometimes under the ISIN itself:
the most recent real description names it, and an import never renames one that
already exists. A broker also reuses a single reference across every leg of one
event, so transaction identity includes the event and its instrument.

domain.Fallback returns kind "investment" for any fact carrying a position leg,
so no broker row reaches the sign-based branch. That single rule is what stops
an unmatched deposit from counting as income and a broker fee from counting as
household spending; the monthly PRIME fee and its matching credit now cancel in
clearing:investments with no configuration at all. Investment rows are excluded
from spending analytics, from bulk reclassification and from the model, exactly
as transfers are.

Equal competing transfers are paired instead of skipped. Every connected
component of the candidate graph is a complete bipartite graph between two fixed
accounts at one amount and currency, so every pairing produces the same
accounts, kinds and postings and only the displayed counterpart differs.
Refusing to choose was the expensive option: both legs fell through to the
sign-based fallback and appeared as spending and income that never happened.
Pairing follows the nearest booking date, then the transaction ID, so iteration
order decides nothing. POST /api/transactions/{id}/transfer rewrites the old and
the new pair in one commit, because reciprocity is validated and a half-applied
link is an invalid dataset, and the matcher now skips any record classified
manually so a hand-made link or unlink outlives the next import.

Wealth reports each account's cash and positions from the journal rather than
the index, with named checks - row arithmetic, cash never negative, holdings
never negative - because it exists to be compared against the figures a broker
shows on its own screen. A negative holding means the imported history is
partial. Share counts are exact to eight places; a reinvested distribution
quoted to six is rounded to money's four and the residue is reported rather than
hidden. Market prices, market value, net worth over time, FIFO lot accounting,
realised gains and currency conversion are deliberately absent.
This commit is contained in:
Lars Nolden
2026-09-11 21:58:47 +02:00
parent 673cbf917b
commit 922ae507bd
27 changed files with 3071 additions and 157 deletions
+147
View File
@@ -268,8 +268,14 @@ function AccountCard({
<h3>{account.display_name}</h3>
<p>
{account.institution} · {account.currency}
{account.kind === "investment" ? " · Investment" : ""}
</p>
{account.iban && <small className="account-iban">{account.iban}</small>}
{account.reference_iban && (
<small className="account-iban">
Settles against {account.reference_iban}
</small>
)}
<div className="connection-status">
<span
className={`badge ${needsReconnect || connection?.status === "error" || connection?.status === "rate_limited" ? "connection-warning" : "neutral"}`}
@@ -667,6 +673,13 @@ function ImportReview({
const [busy, setBusy] = useState(false);
const classifying =
state.settings.classify_on_import && state.status.ai_configured;
const broker = prepared.broker;
// Broker figures are money in the account's own currency; the export carries
// no second currency and the samples are drawn from the same rows.
const currency =
state.data.accounts.find((a) => a.id === prepared.account_id)?.currency ||
prepared.samples[0]?.currency ||
"EUR";
const discard = () => {
// Free the server's prepared statement; an expiring one is harmless.
void request("/api/import/cancel", { id: prepared.id }).catch(() => {});
@@ -716,6 +729,116 @@ function ImportReview({
))}
</dl>
</details>
{broker && (
<>
<div className="preview-summary">
<span>
<strong>{broker.instruments.length}</strong>{" "}
{broker.instruments.length === 1 ? "security" : "securities"} to
register
</span>
<span>
<strong>{broker.cancelled}</strong> cancelled{" "}
{broker.cancelled === 1 ? "row" : "rows"} skipped
</span>
<span>
<strong>{broker.rounded}</strong>{" "}
{broker.rounded === 1 ? "row" : "rows"} rounded
</span>
</div>
<details open>
<summary>Securities this import registers</summary>
{broker.instruments.length ? (
<dl className="facts">
{broker.instruments.map((instrument) => (
<div key={instrument.isin}>
<dt>{instrument.isin}</dt>
<dd>
{instrument.name} · {instrument.currency}
</dd>
</div>
))}
</dl>
) : (
<p className="muted small">
Every security this export names is already in your registry.
No new instrument is created.
</p>
)}
<p className="muted small">
A security is identified by its ISIN. An import never renames
one you already hold: the broker's description for an ISIN
changes over time, so the name stays yours to correct under
Instruments.
</p>
</details>
<p className="muted small">
{broker.cancelled
? `${broker.cancelled} ${broker.cancelled === 1 ? "row the broker did not execute is" : "rows the broker did not execute are"} skipped: a cancelled row's money and share columns are all zeros, so it would import as a phantom trade that every arithmetic check accepts.`
: "Every row in this export was executed; none were skipped."}
</p>
{broker.rounded > 0 && (
<p className="muted small">
{broker.rounded}{" "}
{broker.rounded === 1 ? "row carried" : "rows carried"} more
than four decimal places and{" "}
{broker.rounded === 1 ? "was" : "were"} rounded to the precision
the journal stores. The exact total adjustment across this
import is {broker.rounding} {currency}.
</p>
)}
{broker.unapplied.length > 0 && (
<details open>
<summary>
Fees and taxes recorded but not subtracted{" "}
<span className="badge neutral">
{broker.unapplied.length}
</span>
</summary>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Booking date</th>
<th>Description</th>
<th className="numeric">Fee</th>
<th className="numeric">Tax</th>
</tr>
</thead>
<tbody>
{broker.unapplied.map((note) => (
<tr key={note.record}>
<td className="nowrap">
{note.date}
<small>record {note.record}</small>
</td>
<td>
{note.description || (
<span className="muted">no description</span>
)}
</td>
<td className="numeric money">
{note.fee ? money(note.fee, currency) : "—"}
</td>
<td className="numeric money">
{note.tax ? money(note.tax, currency) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="muted small">
A broker cash amount is already net of its fee and tax, so
these figures are recorded on the transaction and deliberately
not subtracted a second time. Subtracting them again would
make your cash balance disagree with the broker's by exactly
these amounts.
</p>
</details>
)}
</>
)}
<div className="table-scroll">
<table>
<thead>
@@ -1139,6 +1262,7 @@ function AccountEditor({
display_name: value.display_name.trim(),
institution: value.institution.trim(),
iban: value.iban?.replaceAll(" ", ""),
reference_iban: value.reference_iban?.replaceAll(" ", ""),
},
},
"Account saved",
@@ -1186,6 +1310,18 @@ function AccountEditor({
/>
</Field>
</div>
<Field
label="Account kind"
hint="An investment account holds securities. Its imported rows carry the broker ledger and stay out of spending and income analytics."
>
<select
value={value.kind || "cash"}
onChange={(e) => setValue({ ...value, kind: e.target.value })}
>
<option value="cash">Cash</option>
<option value="investment">Investment</option>
</select>
</Field>
<Field
label="IBAN (optional)"
hint="Used to recognize transfers between your own accounts."
@@ -1195,6 +1331,17 @@ function AccountEditor({
onChange={(e) => setValue({ ...value, iban: e.target.value })}
/>
</Field>
<Field
label="Reference IBAN (optional)"
hint="The account this one settles cash against. A broker export names no counterparty, so this IBAN is what lets a deposit pair with the funding account instead of looking like income."
>
<input
value={value.reference_iban || ""}
onChange={(e) =>
setValue({ ...value, reference_iban: e.target.value })
}
/>
</Field>
<Field
label="External account ID (optional)"
hint="The provider account identifier used for connected-bank sync."
+93 -21
View File
@@ -7,9 +7,10 @@ import {
FolderTree,
Tag as TagIcon,
Store,
CandlestickChart,
ChevronRight,
} from "lucide-react";
import type { Category, Dataset, Merchant, Tag } from "./api";
import type { Category, Dataset, Instrument, Merchant, Tag } from "./api";
import { categoryPath } from "./api";
import {
CategoryOptions,
@@ -21,10 +22,21 @@ import {
TagPicker,
} from "./ui";
import type { Mutate } from "./ui";
type Entity = "category" | "tag" | "merchant";
type Item = Category | Tag | Merchant;
const titles = { category: "Categories", tag: "Tags", merchant: "Merchants" };
const plurals = { category: "categories", tag: "tags", merchant: "merchants" };
type Entity = "category" | "tag" | "merchant" | "instrument";
type Item = Category | Tag | Merchant | Instrument;
const titles = {
category: "Categories",
tag: "Tags",
merchant: "Merchants",
instrument: "Instruments",
};
const plurals = {
category: "categories",
tag: "tags",
merchant: "merchants",
instrument: "instruments",
};
type Plural = "categories" | "tags" | "merchants" | "instruments";
export function Registry({
entity,
data,
@@ -39,8 +51,7 @@ export function Registry({
item: Item;
action: "merge" | "delete";
} | null>(null);
const items: Item[] =
data[plurals[entity] as "categories" | "tags" | "merchants"];
const items: Item[] = data[plurals[entity] as Plural];
const create = () =>
setEditing(
entity === "category"
@@ -53,7 +64,9 @@ export function Registry({
default_tag_ids: [],
use_defaults: false,
}
: { id: "", name: "" },
: entity === "instrument"
? { id: "", isin: "", name: "", currency: "EUR" }
: { id: "", name: "" },
);
const row = (item: Item, depth = 0) => (
<div className="registry-row" key={item.id}>
@@ -65,6 +78,8 @@ export function Registry({
<FolderTree size={18} />
) : entity === "tag" ? (
<TagIcon size={18} />
) : entity === "instrument" ? (
<CandlestickChart size={18} />
) : (
<Store size={18} />
)}
@@ -82,6 +97,11 @@ export function Registry({
{item.use_defaults ? " · Defaults enabled" : ""}
</small>
)}
{"isin" in item && (
<small>
{item.isin} · {item.currency}
</small>
)}
</div>
</div>
{"default_category_id" in item && item.default_category_id && (
@@ -98,14 +118,16 @@ export function Registry({
>
<Pencil size={16} />
</button>
<button
className="icon-button"
title={`Merge ${item.name}`}
aria-label={`Merge ${item.name}`}
onClick={() => setAction({ item, action: "merge" })}
>
<GitMerge size={16} />
</button>
{entity !== "instrument" && (
<button
className="icon-button"
title={`Merge ${item.name}`}
aria-label={`Merge ${item.name}`}
onClick={() => setAction({ item, action: "merge" })}
>
<GitMerge size={16} />
</button>
)}
<button
className="icon-button danger"
title={`Delete ${item.name}`}
@@ -142,7 +164,9 @@ export function Registry({
? "A clear home for every transaction. Parent categories roll up their children."
: entity === "tag"
? "Flexible labels that work across your accounts and categories."
: "Recognize familiar names and choose explicit classification defaults."}
: entity === "instrument"
? "The securities your broker rows trade. The ISIN is the identity; the name is yours to correct."
: "Recognize familiar names and choose explicit classification defaults."}
</p>
</div>
<button className="button primary" onClick={create}>
@@ -209,6 +233,9 @@ function RegistryEditor({
const [category, setCategory] = useState(merchant?.default_category_id || "");
const [tags, setTags] = useState(merchant?.default_tag_ids || []);
const [defaults, setDefaults] = useState(merchant?.use_defaults || false);
const instrument = "isin" in item ? item : null;
const [isin, setIsin] = useState(instrument?.isin || "");
const [currency, setCurrency] = useState(instrument?.currency || "EUR");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const descendants = new Set([item.id]);
@@ -252,7 +279,14 @@ function RegistryEditor({
default_tag_ids: tags,
use_defaults: defaults,
}
: { id: item.id, name: name.trim() };
: entity === "instrument"
? {
id: item.id,
isin: isin.replaceAll(" ", "").toUpperCase(),
name: name.trim(),
currency: currency.toUpperCase(),
}
: { id: item.id, name: name.trim() };
await mutate(
`/api/${plurals[entity]}`,
{ [entity]: result },
@@ -347,6 +381,43 @@ function RegistryEditor({
</p>
</>
)}
{entity === "instrument" && (
<>
<Field
label="ISIN"
hint={
item.id
? "An instrument's ISIN is its identity: the trades were imported under it and the server refuses to change it. Register a different security separately."
: "Twelve characters: two country letters, nine alphanumerics and a check digit."
}
>
<input
required
readOnly={!!item.id}
maxLength={12}
value={isin}
onChange={(e) => setIsin(e.target.value.toUpperCase())}
/>
</Field>
<Field
label="Currency"
hint="The currency the broker prices this security in."
>
<input
required
pattern="[A-Z]{3}"
maxLength={3}
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
/>
</Field>
<p className="muted">
The broker's own description for one ISIN changes over time, so
the name is display text you can correct. Renaming does not
touch a single imported trade.
</p>
</>
)}
</div>
<FormActions busy={busy} close={close} />
</form>
@@ -372,8 +443,7 @@ function ManageDialog({
const [confirm, setConfirm] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const items: Item[] =
data[plurals[entity] as "categories" | "tags" | "merchants"];
const items: Item[] = data[plurals[entity] as Plural];
return (
<Modal
title={`${action === "merge" ? "Merge" : "Delete"} ${item.name}`}
@@ -407,7 +477,9 @@ function ManageDialog({
? "This tag will be removed from every transaction and merchant default. The original bank facts will not change."
: entity === "category"
? "Referenced categories need a replacement. Protected roots and unsafe tree changes cannot be deleted."
: "Remove this merchant from your registry. Referenced merchants may require a merge instead."}
: entity === "instrument"
? "Remove this security from your registry. An instrument any imported trade still references cannot be deleted: the server refuses it and says so."
: "Remove this merchant from your registry. Referenced merchants may require a merge instead."}
</p>
{(action === "merge" || entity === "category") && (
<Field
+316 -36
View File
@@ -6,9 +6,16 @@ import {
ArrowLeftRight,
ChevronLeft,
ChevronRight,
Layers,
SlidersHorizontal,
} from "lucide-react";
import type { Dataset, Enrichment, Filter, Transaction } from "./api";
import type {
Dataset,
Enrichment,
Filter,
Investment,
Transaction,
} from "./api";
import { categoryPath, money } from "./api";
import {
CategoryOptions,
@@ -21,6 +28,56 @@ import {
TagPicker,
} from "./ui";
import type { Mutate } from "./ui";
const EVENTS: Record<string, string> = {
deposit: "Deposit",
withdrawal: "Withdrawal",
fee: "Fee",
interest: "Interest",
distribution: "Distribution",
buy: "Buy",
sell: "Sell",
reinvest: "Reinvestment",
corporate_action: "Corporate action",
position_transfer: "Position transfer",
};
// A corporate action or a position transfer moves shares between holdings and
// settles no money at all, so its zero amount is a fact and not a gap.
function positionOnly(investment?: Investment): boolean {
return (
investment?.event === "corporate_action" ||
investment?.event === "position_transfer"
);
}
// A broker cash movement settles money without moving a position, and it is
// the only broker row the server accepts as one side of a transfer.
function cashOnly(investment: Investment): boolean {
return ["deposit", "withdrawal", "fee", "interest", "distribution"].includes(
investment.event,
);
}
// Quantities are exact decimals, never money: they are shown as the broker
// wrote them, with the sign that says whether the holding grew or shrank.
function signedQuantity(quantity: string): string {
return quantity.startsWith("-") ? quantity : `+${quantity}`;
}
// Transfer candidates are compared as exact decimals: "10.00" and "10" are the
// same money, and a float round-trip is never allowed to decide a link.
function decimalKey(value: string): string {
const negative = value.startsWith("-");
const [whole, fraction = ""] = (negative ? value.slice(1) : value).split(".");
const digits = `${whole.replace(/^0+(?=\d)/, "")}.${fraction.replace(/0+$/, "")}`;
const body = digits.endsWith(".") ? digits.slice(0, -1) : digits;
return body === "0" ? "0" : `${negative ? "-" : ""}${body}`;
}
// Booking dates are calendar days, so the window is counted in whole days from
// the ISO string itself and no browser time zone can widen or narrow it.
function daysApart(a: string, b: string): number {
const left = Date.parse(`${a}T00:00:00Z`);
const right = Date.parse(`${b}T00:00:00Z`);
if (Number.isNaN(left) || Number.isNaN(right))
return Number.POSITIVE_INFINITY;
return Math.abs(left - right) / 86400000;
}
export function Transactions({
data,
filter,
@@ -128,6 +185,11 @@ export function Transactions({
.slice(currentPage * 40, currentPage * 40 + 40)
.map((tx) => {
const { facts: f, enrichment: e } = tx;
const investment = f.investment;
const moves = positionOnly(investment);
const security = data.instruments.find(
(i) => i.id === investment?.instrument_id,
);
return (
<tr key={f.id}>
<td>
@@ -143,7 +205,9 @@ export function Transactions({
onClick={() => setEditing(tx)}
>
<span className={`transaction-icon ${e.kind}`}>
{e.kind === "transfer" ? (
{moves ? (
<Layers size={17} />
) : e.kind === "transfer" ? (
<ArrowLeftRight size={17} />
) : f.amount.startsWith("-") ? (
<ArrowUpRight size={17} />
@@ -157,11 +221,29 @@ export function Transactions({
(m) => m.id === e.merchant_id,
)?.name ||
f.counterparty ||
"Bank transaction"}
(investment
? security?.name || f.raw_description
: "Bank transaction")}
</strong>
<small className="description">
{f.raw_description}
</small>
{(!investment ||
f.raw_description !== security?.name) && (
<small className="description">
{f.raw_description}
</small>
)}
{investment && (
<small className="description">
{EVENTS[investment.event] ||
investment.event}
{investment.quantity
? ` · ${signedQuantity(investment.quantity)} shares`
: ""}
{investment.price
? ` @ ${investment.price} ${f.currency}`
: ""}
{moves ? " · position only, no cash" : ""}
</small>
)}
</span>
</button>
</td>
@@ -169,7 +251,9 @@ export function Transactions({
<span>
{e.kind === "transfer"
? "Own-account transfer"
: categoryPath(data, e.category_id)}
: e.kind === "investment"
? "Investment ledger"
: categoryPath(data, e.category_id)}
</span>
<div className="chips">
{e.tag_ids.map((id) => (
@@ -270,9 +354,10 @@ function TransactionEditor({
});
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const { investment, ...plain } = transaction.facts;
const f = transaction.facts;
const peer = data.transactions.find(
(t) => t.facts.id === value.transfer_peer_id,
const instrument = data.instruments.find(
(i) => i.id === investment?.instrument_id,
);
return (
<Modal title="Transaction details" close={close} wide>
@@ -315,38 +400,40 @@ function TransactionEditor({
<div className="two-columns">
<Field
label="Kind"
hint="Derived from the bank amount and verified transfer links."
hint="Derived from the bank amount, the broker ledger and verified transfer links."
>
<input value={value.kind} readOnly />
</Field>
<Field label="Merchant">
<select
value={value.merchant_id || ""}
onChange={(e) =>
setValue({ ...value, merchant_id: e.target.value })
{value.kind === "transfer" || value.kind === "investment" ? (
<Field
label="Merchant"
hint={
value.kind === "investment"
? "An investment ledger row carries no merchant and no category: it never reaches spending or income analytics."
: "An own-account transfer carries no merchant and no category: it never reaches spending or income analytics."
}
>
<option value="">No merchant</option>
{data.merchants.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</Field>
<input value="Not applicable" readOnly />
</Field>
) : (
<Field label="Merchant">
<select
value={value.merchant_id || ""}
onChange={(e) =>
setValue({ ...value, merchant_id: e.target.value })
}
>
<option value="">No merchant</option>
{data.merchants.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</Field>
)}
</div>
{value.kind === "transfer" ? (
<Field label="Linked opposite transaction">
<input
readOnly
value={
peer
? `${peer.facts.booking_date} · ${money(peer.facts.amount, peer.facts.currency)} · ${peer.facts.raw_description}`
: value.transfer_peer_id || "No counterpart supplied"
}
/>
</Field>
) : (
{value.kind !== "transfer" && value.kind !== "investment" && (
<Field label="Category">
<select
required
@@ -360,6 +447,12 @@ function TransactionEditor({
</select>
</Field>
)}
<TransferLink
data={data}
transaction={transaction}
mutate={mutate}
close={close}
/>
<TagPicker
data={data}
value={value.tag_ids}
@@ -371,7 +464,7 @@ function TransactionEditor({
<span className="badge neutral">Read only</span>
</summary>
<dl className="facts">
{Object.entries(f).map(([key, text]) => (
{Object.entries(plain).map(([key, text]) => (
<div key={key}>
<dt>{key.replaceAll("_", " ")}</dt>
<dd>{text || "—"}</dd>
@@ -379,6 +472,71 @@ function TransactionEditor({
))}
</dl>
</details>
{investment && (
<details open>
<summary>
Broker ledger <span className="badge neutral">Read only</span>
</summary>
<dl className="facts">
<div>
<dt>event</dt>
<dd>{EVENTS[investment.event] || investment.event}</dd>
</div>
<div>
<dt>instrument</dt>
<dd>
{investment.instrument_id
? `${instrument?.name || investment.instrument_id}${instrument ? ` · ${instrument.isin}` : ""}`
: "—"}
</dd>
</div>
<div>
<dt>quantity</dt>
<dd>
{investment.quantity
? `${signedQuantity(investment.quantity)} shares`
: "—"}
</dd>
</div>
<div>
<dt>price</dt>
<dd>
{investment.price
? money(
investment.price,
instrument?.currency || f.currency,
)
: "—"}
</dd>
</div>
<div>
<dt>gross</dt>
<dd>
{investment.gross
? money(investment.gross, f.currency)
: "—"}
</dd>
</div>
<div>
<dt>fee</dt>
<dd>
{investment.fee ? money(investment.fee, f.currency) : "—"}
</dd>
</div>
<div>
<dt>tax</dt>
<dd>
{investment.tax ? money(investment.tax, f.currency) : "—"}
</dd>
</div>
</dl>
<p className="muted small">
{positionOnly(investment)
? "A corporate action and a position transfer move shares only: the amount above is zero because no cash settled."
: "The amount above is the broker's own cash figure, already net of any fee and tax shown here. Those figures are recorded, never subtracted a second time."}
</p>
</details>
)}
<details open>
<summary>Classification provenance</summary>
<dl className="facts">
@@ -402,3 +560,125 @@ function TransactionEditor({
</Modal>
);
}
// TransferLink is its own control because a transfer is a decision about two
// transactions: the plain transaction endpoint refuses a changed kind or peer,
// and the server rewrites both sides of the old and the new pair in one commit.
function TransferLink({
data,
transaction,
mutate,
close,
}: {
data: Dataset;
transaction: Transaction;
mutate: Mutate;
close: () => void;
}) {
const f = transaction.facts;
const linked = transaction.enrichment.transfer_peer_id || "";
const [peerId, setPeerId] = useState(linked);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const candidates = useMemo(() => {
const wanted = decimalKey(
f.amount.startsWith("-") ? f.amount.slice(1) : `-${f.amount}`,
);
return data.transactions
.filter(
({ facts: c }) =>
c.id !== f.id &&
(c.id === linked ||
(c.account_id !== f.account_id &&
c.currency === f.currency &&
decimalKey(c.amount) === wanted &&
daysApart(c.booking_date, f.booking_date) <= 3)) &&
// Only a broker cash movement can be a transfer: a trade or a
// position move is refused by the server, so it is never offered.
(!c.investment || cashOnly(c.investment)),
)
.sort((a, b) => a.facts.booking_date.localeCompare(b.facts.booking_date));
}, [data, f, linked]);
if (f.investment && !cashOnly(f.investment))
return (
<Field
label="Own-account counterpart"
hint="Only a broker cash movement — a deposit, withdrawal, fee, interest or distribution — can be paired with the other side of the transfer."
>
<input
readOnly
value={`A ${(EVENTS[f.investment.event] || f.investment.event).toLowerCase()} row stays in the investment ledger.`}
/>
</Field>
);
return (
<>
<Field
label="Own-account counterpart"
hint="Linking rewrites both sides in one commit: each becomes an own-account transfer and leaves spending and income analytics. Candidates are the exactly opposite amount, in the same currency, in another account, booked within three days."
>
<select
value={peerId}
disabled={busy || (!candidates.length && !linked)}
onChange={(event) => setPeerId(event.target.value)}
>
<option value="">Not a transfer no counterpart</option>
{candidates.map(({ facts: c, enrichment: e }) => (
<option key={c.id} value={c.id}>
{`${data.accounts.find((a) => a.id === c.account_id)?.display_name || c.account_id} · ${c.booking_date} · ${money(c.amount, c.currency)}`}
{e.transfer_peer_id && e.transfer_peer_id !== f.id
? " · already linked elsewhere"
: ""}
</option>
))}
</select>
</Field>
{!candidates.length && !linked && (
<p className="muted small">
No transaction in another account carries exactly{" "}
{money(
f.amount.startsWith("-") ? f.amount.slice(1) : `-${f.amount}`,
f.currency,
)}{" "}
within three days of {f.booking_date}.
</p>
)}
<ErrorMessage error={error} />
<div className="form-actions">
<button
type="button"
className={
!peerId && linked ? "button destructive" : "button secondary"
}
disabled={busy || peerId === linked}
onClick={async () => {
setBusy(true);
setError("");
try {
await mutate(
`/api/transactions/${encodeURIComponent(f.id)}/transfer`,
{ peer_id: peerId },
peerId ? "Transfer linked" : "Transfer link removed",
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
}}
>
<ArrowLeftRight size={16} />
{busy
? "Working…"
: !peerId && linked
? "Remove transfer link"
: peerId === linked
? "Counterpart linked"
: linked
? "Relink counterpart"
: "Link as own-account transfer"}
</button>
</div>
</>
);
}
+309
View File
@@ -0,0 +1,309 @@
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<Wealth | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [retry, setRetry] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
request<Wealth>("/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 (
<>
<div className="section-heading">
<div>
<h2>Wealth</h2>
<p>
Cash and positions recomputed from your journal, with the checks
that decide whether the figures can be trusted.
</p>
</div>
<button
className="button secondary"
onClick={() => setRetry(retry + 1)}
disabled={loading}
>
Recheck figures
</button>
</div>
<ErrorMessage error={error} />
{loading ? (
<div className="loading-block" role="status">
<span className="spinner" />
Recomputing cash and positions
</div>
) : (
wealth && (
<>
{failures > 0 && (
<div className="alert error" role="alert">
<AlertTriangle size={19} />
<div>
<strong>
{failures} check{failures === 1 ? "" : "s"} failed across{" "}
{failingAccounts} account
{failingAccounts === 1 ? "" : "s"}.
</strong>
<p>
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.
</p>
</div>
</div>
)}
{wealth.totals.length > 0 && (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<PiggyBank size={17} />
Total cash
</h3>
<p>
Every recorded movement summed per currency, across all{" "}
{wealth.accounts.length} account
{wealth.accounts.length === 1 ? "" : "s"}.
</p>
</div>
</div>
<div className="registry">
<div className="preview-summary">
{wealth.totals.map((total) => (
<span key={total.currency}>
<strong className="money">
{money(total.cash, total.currency)}
</strong>{" "}
in cash
</span>
))}
</div>
</div>
</section>
)}
{wealth.accounts.length === 0 ? (
<section className="panel">
<Empty title="No accounts to report on yet">
Add an account and import a statement or broker export to see
its cash balance, positions and checks here.
</Empty>
</section>
) : (
wealth.accounts.map((account) => (
<AccountReport key={account.account_id} account={account} />
))
)}
<section className="panel">
<div className="panel-heading">
<div>
<h3>Reading these figures</h3>
<p>
The three rules that decide what a broker export does and
does not move.
</p>
</div>
</div>
<div className="registry">
<dl className="facts">
<div>
<dt>Tax on broker cash</dt>
<dd>
A broker cash amount is already net of tax. The tax is
recorded on the transaction and deliberately not
subtracted a second time.
</dd>
</div>
<div>
<dt>Position-only events</dt>
<dd>
Corporate actions and position transfers move a position
and settle zero cash, so they change a holding without
touching the balance.
</dd>
</div>
<div>
<dt>Investment transactions</dt>
<dd>
Transactions classified as investment are excluded from
every spending and income figure, exactly like transfers.
</dd>
</div>
<div>
<dt>Completeness</dt>
<dd>
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.
</dd>
</div>
</dl>
</div>
</section>
</>
)
)}
</>
);
}
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 (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
{investing ? (
<CandlestickChart size={17} />
) : (
<Landmark size={17} />
)}
{account.display_name}
</h3>
<p>
{account.institution} · {investing ? "Investment" : "Cash"} account
· {account.records} record{account.records === 1 ? "" : "s"}
{range ? ` · ${range}` : " · no bookings"}
{!account.active && " · archived"}
</p>
</div>
<div>
<span className="eyebrow">Cash balance</span>
<span className="large-money money">
{money(account.cash, account.currency)}
</span>
</div>
</div>
{account.holdings.length > 0 && (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Instrument</th>
<th>ISIN</th>
<th className="numeric">Quantity</th>
<th className="numeric">Invested</th>
<th className="numeric">Received</th>
<th className="numeric">Records</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={holding.instrument_id}>
<td>{holding.name}</td>
<td className="nowrap muted">{holding.isin}</td>
<td
className={`numeric money ${negative ? "text-danger" : empty ? "muted" : "positive"}`}
>
{holding.quantity}
{negative && (
<small className="text-danger">
more units left than entered
</small>
)}
</td>
<td className="numeric money">
{money(holding.invested, account.currency)}
</td>
<td className="numeric money">
{money(holding.received, account.currency)}
</td>
<td className="numeric">{holding.records}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{failed.length > 0 && (
<div className="registry">
{failed.map((check) => (
<div className="alert error" role="alert" key={check.name}>
<AlertTriangle size={19} />
<div>
<strong>{check.name}</strong>
<p>{check.detail}</p>
</div>
</div>
))}
</div>
)}
{notes.length > 0 && (
<div className="health-grid">
{notes.map((check) => (
<div className="health" key={check.name}>
<span className="positive">
<CheckCircle2 size={18} />
</span>
<div>
<strong>{check.name}</strong>
<p>{check.detail}</p>
</div>
</div>
))}
</div>
)}
</section>
);
}
+95
View File
@@ -3,10 +3,36 @@ export interface Account {
display_name: string;
institution: string;
currency: string;
// kind is "cash" or "investment"; an absent kind is a cash account.
kind?: string;
external_account_id?: string;
iban?: string;
// reference_iban is the counterpart an investment account settles cash
// against: a broker export carries no counterparty, so its deposits and
// withdrawals pair with the funding account through this IBAN.
reference_iban?: string;
active: boolean;
}
// Instrument is a security held in an investment account. The ISIN is the
// identity; the name is editable display text.
export interface Instrument {
id: string;
isin: string;
name: string;
currency: 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.
export interface Investment {
event: string;
instrument_id?: string;
quantity?: string;
price?: string;
gross?: string;
fee?: string;
tax?: string;
}
export interface Facts {
id: string;
source: string;
@@ -20,6 +46,7 @@ export interface Facts {
fingerprint: string;
counterparty?: string;
counterparty_iban?: string;
investment?: Investment;
}
export interface Provenance {
source: string;
@@ -62,6 +89,7 @@ export interface Dataset {
categories: Category[];
tags: Tag[];
merchants: Merchant[];
instruments: Instrument[];
transactions: Transaction[];
}
export interface Connection {
@@ -161,6 +189,27 @@ export interface CSVColumn {
field: string;
column: string;
}
// BrokerReview is what the broker parser decided about an export that has not
// been imported yet: which securities it would register, which rows it skipped
// and which figures it deliberately did not apply.
export interface BrokerReview {
instruments: Instrument[];
// cancelled counts rows the broker did not execute.
cancelled: number;
// rounded counts rows whose money carried more than four decimal places;
// rounding is the exact total adjustment, to eight places.
rounded: number;
rounding: string;
// unapplied lists cash rows carrying a fee or tax. A broker cash amount is
// already net of both, so subtracting them again would double-count.
unapplied: {
record: number;
date: string;
description: string;
fee?: string;
tax?: string;
}[];
}
// PreparedImport is a parsed statement that has not been imported yet: the
// mapping and sample must be confirmed before any transaction is written.
export interface PreparedImport {
@@ -176,6 +225,51 @@ export interface PreparedImport {
new: number;
duplicates: number;
samples: Facts[];
broker?: BrokerReview;
}
// WealthHolding is one instrument's position in one account. quantity is an
// exact signed decimal and never money; invested and received are money.
export interface WealthHolding {
instrument_id: string;
isin: string;
name: string;
quantity: string;
invested: string;
received: string;
records: number;
}
// WealthCheck is one named verification with its evidence. failed marks a
// disagreement inside the journal; the rest are notes that explain a figure.
export interface WealthCheck {
name: string;
detail: string;
failed: boolean;
}
export interface WealthAccount {
account_id: string;
display_name: string;
institution: string;
currency: string;
kind: string;
active: boolean;
records: number;
first_booking?: string;
last_booking?: string;
// cash is every recorded movement summed. It equals the real balance only
// when the journal holds that account's complete history.
cash: string;
holdings: WealthHolding[];
checks: WealthCheck[];
}
export interface WealthTotal {
currency: string;
cash: 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[];
totals: WealthTotal[];
}
export class APIError extends Error {
constructor(
@@ -244,6 +338,7 @@ export function normalizeState(state: State): State {
"categories",
"tags",
"merchants",
"instruments",
"transactions",
] as const) {
if (!(key in state.data))
+16 -2
View File
@@ -6,7 +6,9 @@ import {
FolderTree,
Tags,
Store,
CandlestickChart,
Wallet,
PiggyBank,
Sparkles,
Settings as SettingsIcon,
RefreshCw,
@@ -30,6 +32,7 @@ import { Registry } from "./Registry";
import { Accounts } from "./Accounts";
import { Classification } from "./Classification";
import { Settings } from "./Settings";
import Wealth from "./Wealth";
import { ErrorMessage } from "./ui";
// Montserrat carries the wordmark. The subsets are bundled rather than fetched
// from Google Fonts: the Content-Security-Policy serves fonts from 'self' only,
@@ -43,7 +46,9 @@ const navigation = [
{ id: "categories", label: "Categories", icon: FolderTree },
{ id: "tags", label: "Tags", icon: Tags },
{ id: "merchants", label: "Merchants", icon: Store },
{ id: "instruments", label: "Instruments", icon: CandlestickChart },
{ id: "accounts", label: "Accounts", icon: Wallet },
{ id: "wealth", label: "Wealth", icon: PiggyBank },
{ id: "classification", label: "AI classification", icon: Sparkles },
{ id: "settings", label: "Settings", icon: SettingsIcon },
];
@@ -178,10 +183,10 @@ function App() {
</a>
<span className="nav-label">WORKSPACE</span>
<nav aria-label="Main navigation">
{navigation.map(({ id, label, icon: Icon }, i) => (
{navigation.map(({ id, label, icon: Icon }) => (
<button
key={id}
className={`nav-item ${page === id ? "active" : ""} ${i === 7 ? "nav-settings" : ""}`}
className={`nav-item ${page === id ? "active" : ""} ${id === "settings" ? "nav-settings" : ""}`}
aria-current={page === id ? "page" : undefined}
onClick={() => navigate(id)}
>
@@ -378,6 +383,14 @@ function App() {
mutate={mutate}
/>
)}
{page === "instruments" && (
<Registry
key={`instruments-${state.revision}`}
entity="instrument"
data={state.data}
mutate={mutate}
/>
)}
{page === "accounts" && (
<Accounts
state={state}
@@ -385,6 +398,7 @@ function App() {
acceptState={acceptState}
/>
)}
{page === "wealth" && <Wealth revision={state.revision} />}
{page === "classification" && (
<Classification state={state} acceptState={acceptState} />
)}