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
+19 -9
View File
@@ -147,8 +147,14 @@ this is not local AI and cannot promise that a remote provider honors policy.
Each classification sends the transaction date, signed amount, currency, Each classification sends the transaction date, signed amount, currency,
merchant and counterparty text, account institution/currency, the complete merchant and counterparty text, account institution/currency, the complete
leaf-category registry for the transaction kind, all tags and all merchants leaf-category registry for the transaction kind, all tags and all merchants.
with their real local IDs. Identifier-only redaction removes IBANs (with a Names, paths, hints and aliases remain available, but registry IDs use short
request-local references (c1, m1, t1), including merchant usual categories and
history. History includes only categories offered for that transaction kind.
Responses are mapped back to canonical IDs and validated locally; canonical
IDs are not accepted as alternative response references. This keeps the full
registry without the long-ID schema overhead that providers can reject.
Identifier-only redaction removes IBANs (with a
directly attached BIC), labeled BIC/SWIFT references, UUIDs, URLs/emails, directly attached BIC), labeled BIC/SWIFT references, UUIDs, URLs/emails,
labeled payment or customer references, card fragments, long digit-bearing labeled payment or customer references, card fragments, long digit-bearing
tokens, the row's own IDs, account labels and configured private names. A tokens, the row's own IDs, account labels and configured private names. A
@@ -536,10 +542,13 @@ repeats daily. Nothing is committed when no quote changed.
A holding's value is its share count times its quote, rounded half away from A holding's value is its share count times its quote, rounded half away from
zero to money's four places. Positions is that value summed per account, wealth zero to money's four places. Positions is that value summed per account, wealth
is cash plus positions, and result is value plus everything the position is cash plus positions plus hand-valued assets, and result is value plus
returned less everything put into it - the outcome to date, realised and not. everything the position returned less everything put into it - the outcome to
None of these figures are read from the DuckDB index: the report is recomputed date, realised and not. A hand-valued asset (a house, a car, a private loan) is
from the journal so it can be checked against a broker's own screen. entered on the Wealth page with a stated value, a currency and the day the
estimate was made; a negative value records a liability. None of these figures
are read from the DuckDB index: the report is recomputed from the journal so it
can be checked against a broker's own screen.
A broker reuses one reference across every leg of an economic event: the cash A broker reuses one reference across every leg of an economic event: the cash
and position sides of a corporate action arrive with the same reference byte for and position sides of a corporate action arrive with the same reference byte for
@@ -652,6 +661,7 @@ finance/
tags.finance tags.finance
merchants.finance merchants.finance
instruments.finance instruments.finance
assets.finance
journal/YYYY/YYYY-MM.finance journal/YYYY/YYYY-MM.finance
state/sync-state.json sensitive local consent/session metadata state/sync-state.json sensitive local consent/session metadata
state/openrouter.json sensitive UI-managed OpenRouter key or explicit disable state/openrouter.json sensitive UI-managed OpenRouter key or explicit disable
@@ -738,9 +748,9 @@ recurring payees classify locally without any provider request.
The finished run is a read-only preview. Apply all/selected writes all The finished run is a read-only preview. Apply all/selected writes all
selected changes in one canonical commit; financial facts never change. A selected changes in one canonical commit; financial facts never change. A
manual edit, external journal change or taxonomy change invalidates old previews. manual edit, external journal change or taxonomy change invalidates old previews.
Previews are kept in memory for up to one hour and disappear on restart. Cancel Previews are kept in memory for up to 24 hours from the start of analysis and
writes nothing. Transfers and broker facts are skipped, and unselected fields disappear on restart. Cancel writes nothing. Transfers and broker facts are
are preserved. skipped, and unselected fields are preserved.
When a selected transaction is linked to a merchant, applying the preview and When a selected transaction is linked to a merchant, applying the preview and
manual transaction edits may add its normalized counterparty as an alias if manual transaction edits may add its normalized counterparty as an alias if
that alias is unambiguous and the merchant has fewer than 32 aliases. A new that alias is unambiguous and the merchant has fewer than 32 aliases. A new
+4
View File
@@ -220,6 +220,8 @@ One ISIN lists on several exchanges in different currencies, and the wrong listi
**Verify it yourself.** **Wealth** shows each account's cash, its positions as exact share counts, each holding's quote, value and result, and named checks — row arithmetic, cash never negative, holdings never negative, holdings priced. Compare the cash balance and the positions against your broker's own screen. The figures come from the journal, not from the DuckDB index, so they do not depend on the cache that the same journal derives. A negative holding means the imported history is partial: a position was closed that was never opened. **Verify it yourself.** **Wealth** shows each account's cash, its positions as exact share counts, each holding's quote, value and result, and named checks — row arithmetic, cash never negative, holdings never negative, holdings priced. Compare the cash balance and the positions against your broker's own screen. The figures come from the journal, not from the DuckDB index, so they do not depend on the cache that the same journal derives. A negative holding means the imported history is partial: a position was closed that was never opened.
**Other assets.** Possessions with no market feed — a house, a car, a private loan — are added by hand on the **Wealth** page with a stated value, a currency and the day the estimate was made, and they join the total immediately. A negative value records a liability such as a mortgage. Each asset is a plaintext block in `assets.finance` like every other registry entity, so a backup carries it and a text editor can correct it. The value is never guessed or aged: it stays what you stated, dated, until you re-edit it.
Deliberately **not** included: intraday prices, net worth over time, FIFO lot accounting, realised gains, `Vorabpauschale`, and currency conversion. A position's *invested* figure is cash in less cash out, not a cost basis, and *result* is value plus everything returned less everything put in — the outcome to date, not a taxable gain. Deliberately **not** included: intraday prices, net worth over time, FIFO lot accounting, realised gains, `Vorabpauschale`, and currency conversion. A position's *invested* figure is cash in less cash out, not a cost basis, and *result* is value plus everything returned less everything put in — the outcome to date, not a taxable gain.
## Deployment options ## Deployment options
@@ -423,6 +425,8 @@ Bank synchronization and recognized N26, ING, and Kontist CSV imports do **not**
AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers and labels, payment references, labeled or IBAN-attached BICs, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence. Imports never auto-apply a low-confidence category — the row stays on the kind-appropriate unclassified category with the merchant link and confidence recorded — while **Analyse** previews show the low-confidence suggestion unselected for review, and **Transactions → Needs review** lists both. AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers and labels, payment references, labeled or IBAN-attached BICs, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence. Imports never auto-apply a low-confidence category — the row stays on the kind-appropriate unclassified category with the merchant link and confidence recorded — while **Analyse** previews show the low-confidence suggestion unselected for review, and **Transactions → Needs review** lists both.
Classification choices retain their names, paths, hints, and aliases, but use short request-local references such as `c1`, `m1`, and `t1` instead of long database IDs. Merchant defaults and applicable classification history use the same references. Every eligible category, merchant, and tag remains available; responses are mapped back to canonical IDs and validated locally.
From **Categories**, **Propose taxonomy** samples up to 300 redacted transactions, grouped so recurring counterparties are represented without sending raw identifiers. The proposal can suggest categories, tags, and merchants with hints and evidence. Approve each item individually; applying it also creates any approved category parents required by the hierarchy. Existing registry entries and transaction facts are never overwritten. From **Categories**, **Propose taxonomy** samples up to 300 redacted transactions, grouped so recurring counterparties are represented without sending raw identifiers. The proposal can suggest categories, tags, and merchants with hints and evidence. Approve each item individually; applying it also creates any approved category parents required by the hierarchy. Existing registry entries and transaction facts are never overwritten.
Every AI classification requests `provider.data_collection = "deny"`, `provider.zdr = true`, and `provider.require_parameters = true`. Unsupported private routing fails rather than falling back to a less restrictive provider. Amount, date, and currency are always included; identifier-only redaction removes account and transaction identifiers, payment references, and configured private names but does not remove merchant or counterparty text. Keep OpenRouter account prompt logging disabled as well. Every AI classification requests `provider.data_collection = "deny"`, `provider.zdr = true`, and `provider.require_parameters = true`. Unsupported private routing fails rather than falling back to a less restrictive provider. Amount, date, and currency are always included; identifier-only redaction removes account and transaction identifiers, payment references, and configured private names but does not remove merchant or counterparty text. Keep OpenRouter account prompt logging disabled as well.
+28
View File
@@ -104,6 +104,25 @@ func SaveInstrument(d *domain.Dataset, v domain.Instrument) error {
d.Instruments = append(d.Instruments, v) d.Instruments = append(d.Instruments, v)
return nil return nil
} }
// SaveAsset registers or revalues a hand-valued possession. The value and the
// day it was stated travel together; full validation happens at commit.
func SaveAsset(d *domain.Dataset, v domain.Asset) error {
v.Name = strings.TrimSpace(v.Name)
v.Kind = strings.TrimSpace(v.Kind)
v.Currency = strings.ToUpper(strings.TrimSpace(v.Currency))
if v.ID == "" {
v.ID = domain.NewID("asset")
}
for i, x := range d.Assets {
if x.ID == v.ID {
d.Assets[i] = v
return nil
}
}
d.Assets = append(d.Assets, v)
return nil
}
func SaveCategory(d *domain.Dataset, v domain.Category) error { func SaveCategory(d *domain.Dataset, v domain.Category) error {
v.Name = strings.TrimSpace(v.Name) v.Name = strings.TrimSpace(v.Name)
if v.ID == "" { if v.ID == "" {
@@ -203,6 +222,15 @@ func Manage(d *domain.Dataset, entity, action, id, target string) error {
if n == len(d.Instruments) { if n == len(d.Instruments) {
return errors.New("unknown instrument") return errors.New("unknown instrument")
} }
case "asset":
if action != "delete" {
return errors.New("asset merging is not supported")
}
n := len(d.Assets)
d.Assets = slices.DeleteFunc(d.Assets, func(v domain.Asset) bool { return v.ID == id })
if n == len(d.Assets) {
return errors.New("unknown asset")
}
case "tag": case "tag":
if !slices.ContainsFunc(d.Tags, func(v domain.Tag) bool { return v.ID == id }) { if !slices.ContainsFunc(d.Tags, func(v domain.Tag) bool { return v.ID == id }) {
return errors.New("unknown tag") return errors.New("unknown tag")
+45 -7
View File
@@ -15,8 +15,11 @@ import (
// same journal derives. // same journal derives.
type Wealth struct { type Wealth struct {
Accounts []WealthAccount `json:"accounts"` Accounts []WealthAccount `json:"accounts"`
// Totals is cash, position value and their sum per currency, across every // Assets are the hand-valued possessions outside any account, echoed here
// account. // so the page that shows the total also shows what the total contains.
Assets []WealthAsset `json:"assets"`
// Totals is cash, position value, hand-valued assets and their sum per
// currency, across every account.
Totals []WealthTotal `json:"totals"` Totals []WealthTotal `json:"totals"`
} }
@@ -28,6 +31,9 @@ type WealthTotal struct {
// in Unpriced, because valuing them at cost would report a number the // in Unpriced, because valuing them at cost would report a number the
// journal cannot support. // journal cannot support.
Positions domain.Money `json:"positions"` Positions domain.Money `json:"positions"`
// Assets is the stated value of every hand-valued asset in this currency,
// and Wealth is cash, positions and assets together.
Assets domain.Money `json:"assets"`
Wealth domain.Money `json:"wealth"` Wealth domain.Money `json:"wealth"`
Unpriced int `json:"unpriced"` Unpriced int `json:"unpriced"`
} }
@@ -113,6 +119,17 @@ type WealthHolding struct {
Records int `json:"records"` Records int `json:"records"`
} }
// WealthAsset is one hand-valued asset as the journal records it. The value is
// stated, never quoted, and carries the day it was stated.
type WealthAsset struct {
AssetID string `json:"asset_id"`
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Currency string `json:"currency"`
Value domain.Money `json:"value"`
ValuedAt string `json:"valued_at"`
}
// WealthCheck is one named verification with its evidence. Failed marks a // WealthCheck is one named verification with its evidence. Failed marks a
// disagreement inside the journal; the rest are notes that explain a figure // disagreement inside the journal; the rest are notes that explain a figure
// before it is compared with a broker's screen. // before it is compared with a broker's screen.
@@ -282,11 +299,18 @@ func WealthOf(data domain.Dataset) Wealth {
} }
} }
report := Wealth{Accounts: []WealthAccount{}, Totals: []WealthTotal{}} report := Wealth{Accounts: []WealthAccount{}, Assets: []WealthAsset{}, Totals: []WealthTotal{}}
totals := map[string]int64{} totals := map[string]int64{}
positionTotals := map[string]int64{} positionTotals := map[string]int64{}
assetTotals := map[string]int64{}
unpricedTotals := map[string]int{} unpricedTotals := map[string]int{}
currencies := []string{} currencies := []string{}
seen := func(currency string) {
if _, ok := totals[currency]; !ok {
currencies = append(currencies, currency)
totals[currency] = 0
}
}
for _, account := range data.Accounts { for _, account := range data.Accounts {
st := state(account.ID) st := state(account.ID)
kind := account.Kind kind := account.Kind
@@ -308,9 +332,7 @@ func WealthOf(data domain.Dataset) Wealth {
}) })
} }
} }
if _, seen := totals[account.Currency]; !seen { seen(account.Currency)
currencies = append(currencies, account.Currency)
}
totals[account.Currency] += st.cash totals[account.Currency] += st.cash
positions, unpriced, stale := int64(0), 0, []string{} positions, unpriced, stale := int64(0), 0, []string{}
for _, id := range st.order { for _, id := range st.order {
@@ -391,11 +413,27 @@ func WealthOf(data domain.Dataset) Wealth {
} }
report.Accounts = append(report.Accounts, entry) report.Accounts = append(report.Accounts, entry)
} }
// Hand-valued assets join the totals after the accounts: they belong to no
// account, and a currency held only in an asset still earns its own line.
for _, asset := range data.Assets {
value, err := asset.Value.Minor()
if err != nil {
continue
}
seen(asset.Currency)
assetTotals[asset.Currency] += value
report.Assets = append(report.Assets, WealthAsset{
AssetID: asset.ID, Name: asset.Name, Kind: asset.Kind,
Currency: asset.Currency, Value: domain.FormatMoney(value), ValuedAt: asset.ValuedAt,
})
}
slices.SortStableFunc(report.Assets, func(x, y WealthAsset) int { return strings.Compare(x.Name, y.Name) })
for _, currency := range currencies { for _, currency := range currencies {
report.Totals = append(report.Totals, WealthTotal{ report.Totals = append(report.Totals, WealthTotal{
Currency: currency, Cash: domain.FormatMoney(totals[currency]), Currency: currency, Cash: domain.FormatMoney(totals[currency]),
Positions: domain.FormatMoney(positionTotals[currency]), Positions: domain.FormatMoney(positionTotals[currency]),
Wealth: domain.FormatMoney(totals[currency] + positionTotals[currency]), Assets: domain.FormatMoney(assetTotals[currency]),
Wealth: domain.FormatMoney(totals[currency] + positionTotals[currency] + assetTotals[currency]),
Unpriced: unpricedTotals[currency], Unpriced: unpricedTotals[currency],
}) })
} }
+35
View File
@@ -381,3 +381,38 @@ func TestWealthValuesHoldingsAtTheirQuote(t *testing.T) {
t.Error("no note about the holdings left out of the wealth figure") t.Error("no note about the holdings left out of the wealth figure")
} }
} }
// A wealth figure that ignores the house is not a wealth figure. A hand-valued
// asset joins its currency's total, a currency held only in an asset earns its
// own line, and a negative value records a liability that subtracts.
func TestWealthCountsHandValuedAssets(t *testing.T) {
data := domain.NewDataset()
data.Accounts = []domain.Account{{ID: "acc_main", DisplayName: "Main", Currency: "EUR", Active: true}}
f := domain.Facts{
ID: "tx_1", Source: "csv", AccountID: "acc_main", BookingDate: "2026-01-02",
Amount: "1000.00", Currency: "EUR", RawDescription: "salary", Fingerprint: "tx_1",
}
data.Transactions = []domain.Transaction{{Facts: f, Enrichment: domain.Fallback(f)}}
data.Assets = []domain.Asset{
{ID: "asset_house", Name: "House", Kind: "Real estate", Currency: "EUR", Value: "250000.00", ValuedAt: "2026-09-01"},
{ID: "asset_loan", Name: "Mortgage", Currency: "EUR", Value: "-150000.00", ValuedAt: "2026-09-01"},
{ID: "asset_cabin", Name: "Cabin", Currency: "USD", Value: "40000.00", ValuedAt: "2026-08-15"},
}
if err := domain.Validate(data); err != nil {
t.Fatal(err)
}
report := WealthOf(data)
byCurrency := map[string]WealthTotal{}
for _, total := range report.Totals {
byCurrency[total.Currency] = total
}
if eur := byCurrency["EUR"]; eur.Cash != "1000.00" || eur.Assets != "100000.00" || eur.Wealth != "101000.00" {
t.Errorf("EUR total %+v; want cash 1000.00, assets 100000.00, wealth 101000.00", eur)
}
if usd, ok := byCurrency["USD"]; !ok || usd.Cash != "0.00" || usd.Assets != "40000.00" || usd.Wealth != "40000.00" {
t.Errorf("a currency held only in an asset earned no line of its own: %+v", byCurrency["USD"])
}
if len(report.Assets) != 3 || report.Assets[0].Name != "Cabin" || report.Assets[1].ValuedAt != "2026-09-01" {
t.Errorf("assets not echoed sorted by name with their dates: %+v", report.Assets)
}
}
+18 -2
View File
@@ -171,7 +171,7 @@ func NewDataset() Dataset {
return Dataset{Accounts: []Account{}, Categories: []Category{ return Dataset{Accounts: []Account{}, Categories: []Category{
{ID: "cat_expenses", Name: "Expenses", Kind: "expense"}, {ID: ExpenseFallback, Name: "Unclassified", ParentID: "cat_expenses", Kind: "expense"}, {ID: "cat_expenses", Name: "Expenses", Kind: "expense"}, {ID: ExpenseFallback, Name: "Unclassified", ParentID: "cat_expenses", Kind: "expense"},
{ID: "cat_income", Name: "Income", Kind: "income"}, {ID: IncomeFallback, Name: "Unclassified", ParentID: "cat_income", Kind: "income"}, {ID: "cat_income", Name: "Income", Kind: "income"}, {ID: IncomeFallback, Name: "Unclassified", ParentID: "cat_income", Kind: "income"},
}, Tags: []Tag{}, Merchants: []Merchant{}, Instruments: []Instrument{}, Transactions: []Transaction{}} }, Tags: []Tag{}, Merchants: []Merchant{}, Instruments: []Instrument{}, Assets: []Asset{}, Transactions: []Transaction{}}
} }
// InstrumentID derives a stable registry ID from an ISIN so re-importing the // InstrumentID derives a stable registry ID from an ISIN so re-importing the
@@ -181,7 +181,7 @@ func InstrumentID(isin string) string {
return "ins_" + hex.EncodeToString(sum[:16]) return "ins_" + hex.EncodeToString(sum[:16])
} }
func Clone(d Dataset) Dataset { func Clone(d Dataset) Dataset {
c := Dataset{Accounts: append([]Account{}, d.Accounts...), Categories: append([]Category{}, d.Categories...), Tags: append([]Tag{}, d.Tags...), Merchants: append([]Merchant{}, d.Merchants...), Instruments: append([]Instrument{}, d.Instruments...), Transactions: append([]Transaction{}, d.Transactions...)} c := Dataset{Accounts: append([]Account{}, d.Accounts...), Categories: append([]Category{}, d.Categories...), Tags: append([]Tag{}, d.Tags...), Merchants: append([]Merchant{}, d.Merchants...), Instruments: append([]Instrument{}, d.Instruments...), Assets: append([]Asset{}, d.Assets...), Transactions: append([]Transaction{}, d.Transactions...)}
for i := range c.Merchants { for i := range c.Merchants {
c.Merchants[i].Aliases = append([]string{}, d.Merchants[i].Aliases...) c.Merchants[i].Aliases = append([]string{}, d.Merchants[i].Aliases...)
c.Merchants[i].DefaultTagIDs = append([]string{}, d.Merchants[i].DefaultTagIDs...) c.Merchants[i].DefaultTagIDs = append([]string{}, d.Merchants[i].DefaultTagIDs...)
@@ -398,6 +398,22 @@ func Validate(d Dataset) error {
isins[v.ISIN] = v.ID isins[v.ISIN] = v.ID
instruments[v.ID] = v instruments[v.ID] = v
} }
for _, v := range d.Assets {
if err := register(v.ID, "asset"); err != nil {
return err
}
if !nonblank(v.Name) || !currencyPattern.MatchString(v.Currency) || !validText(v.Kind) {
return fmt.Errorf("asset %q: valid UTF-8 name and three-letter uppercase currency required", v.ID)
}
// A hand-stated value without its day cannot be judged stale, so the
// two are recorded together, always.
if _, err := v.Value.Minor(); err != nil {
return fmt.Errorf("asset %q: %w", v.ID, err)
}
if !validDate(v.ValuedAt) {
return fmt.Errorf("asset %q: invalid valuation date %q", v.ID, v.ValuedAt)
}
}
for _, t := range d.Transactions { for _, t := range d.Transactions {
f := t.Facts f := t.Facts
if err := register(f.ID, "transaction"); err != nil { if err := register(f.ID, "transaction"); err != nil {
+15
View File
@@ -116,6 +116,20 @@ type Instrument struct {
QuotedAt string `json:"quoted_at,omitempty"` QuotedAt string `json:"quoted_at,omitempty"`
} }
// 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 ValuedAt the day
// that estimate was made, so a stale figure is visible rather than silently
// trusted. A negative value records a liability such as a mortgage.
type Asset struct {
ID string `json:"id"`
Name string `json:"name"`
// Kind is free display text grouping the asset: "Real estate", "Vehicle".
Kind string `json:"kind,omitempty"`
Currency string `json:"currency"`
Value Money `json:"value"`
ValuedAt string `json:"valued_at"`
}
type Facts struct { type Facts struct {
ID string `json:"id"` ID string `json:"id"`
Source string `json:"source"` Source string `json:"source"`
@@ -178,6 +192,7 @@ type Dataset struct {
Tags []Tag `json:"tags"` Tags []Tag `json:"tags"`
Merchants []Merchant `json:"merchants"` Merchants []Merchant `json:"merchants"`
Instruments []Instrument `json:"instruments"` Instruments []Instrument `json:"instruments"`
Assets []Asset `json:"assets"`
Transactions []Transaction `json:"transactions"` Transactions []Transaction `json:"transactions"`
} }
+10 -2
View File
@@ -19,7 +19,7 @@ import (
// registryFiles are the non-monthly journal files, in the order they are read // registryFiles are the non-monthly journal files, in the order they are read
// and written. A block's file is its kind pluralized, so this list and the // and written. A block's file is its kind pluralized, so this list and the
// kinds accepted by parseDocument must stay in step. // kinds accepted by parseDocument must stay in step.
var registryFiles = []string{"accounts.finance", "categories.finance", "tags.finance", "merchants.finance", "instruments.finance"} var registryFiles = []string{"accounts.finance", "categories.finance", "tags.finance", "merchants.finance", "instruments.finance", "assets.finance"}
type fieldSpan struct{ start, end int } type fieldSpan struct{ start, end int }
type block struct { type block struct {
@@ -140,7 +140,7 @@ func parseDocument(path string, raw []byte) (*document, error) {
} }
header := strings.Fields(trimmed) header := strings.Fields(trimmed)
if len(header) != 2 || header[1] != "{" { if len(header) != 2 || header[1] != "{" {
return fail(i+1, "expected 'account|category|tag|merchant|instrument|transaction {'") return fail(i+1, "expected 'account|category|tag|merchant|instrument|asset|transaction {'")
} }
kind := header[0] kind := header[0]
var value any var value any
@@ -155,6 +155,8 @@ func parseDocument(path string, raw []byte) (*document, error) {
value = &domain.Merchant{} value = &domain.Merchant{}
case "instrument": case "instrument":
value = &domain.Instrument{} value = &domain.Instrument{}
case "asset":
value = &domain.Asset{}
case "transaction": case "transaction":
value = &domain.Transaction{} value = &domain.Transaction{}
default: default:
@@ -237,6 +239,9 @@ func parseDocument(path string, raw []byte) (*document, error) {
case *domain.Instrument: case *domain.Instrument:
b.id = v.ID b.id = v.ID
b.value = *v b.value = *v
case *domain.Asset:
b.id = v.ID
b.value = *v
case *domain.Merchant: case *domain.Merchant:
if v.Aliases == nil { if v.Aliases == nil {
v.Aliases = []string{} v.Aliases = []string{}
@@ -342,6 +347,9 @@ func datasetFiles(d domain.Dataset) map[string]map[string]piece {
for _, v := range d.Instruments { for _, v := range d.Instruments {
add("instruments.finance", "instrument", v.ID, v) add("instruments.finance", "instrument", v.ID, v)
} }
for _, v := range d.Assets {
add("assets.finance", "asset", v.ID, v)
}
for _, v := range d.Transactions { for _, v := range d.Transactions {
month := v.Facts.BookingDate[:7] month := v.Facts.BookingDate[:7]
add("journal/"+month[:4]+"/"+month+".finance", "transaction", v.Facts.ID, v) add("journal/"+month[:4]+"/"+month+".finance", "transaction", v.Facts.ID, v)
+3 -1
View File
@@ -435,7 +435,7 @@ func (s *Store) snapshot() (*snapshot, error) {
return snap, nil return snap, nil
} }
func decodeSnapshot(raw map[string][]byte) (*snapshot, error) { func decodeSnapshot(raw map[string][]byte) (*snapshot, error) {
snap := &snapshot{raw: raw, docs: map[string]*document{}, revision: revision(raw), data: domain.Dataset{Accounts: []domain.Account{}, Categories: []domain.Category{}, Tags: []domain.Tag{}, Merchants: []domain.Merchant{}, Instruments: []domain.Instrument{}, Transactions: []domain.Transaction{}}} snap := &snapshot{raw: raw, docs: map[string]*document{}, revision: revision(raw), data: domain.Dataset{Accounts: []domain.Account{}, Categories: []domain.Category{}, Tags: []domain.Tag{}, Merchants: []domain.Merchant{}, Instruments: []domain.Instrument{}, Assets: []domain.Asset{}, Transactions: []domain.Transaction{}}}
if len(raw) == 0 { if len(raw) == 0 {
snap.data = domain.NewDataset() snap.data = domain.NewDataset()
return snap, nil return snap, nil
@@ -483,6 +483,8 @@ func decodeSnapshot(raw map[string][]byte) (*snapshot, error) {
snap.data.Merchants = append(snap.data.Merchants, v) snap.data.Merchants = append(snap.data.Merchants, v)
case domain.Instrument: case domain.Instrument:
snap.data.Instruments = append(snap.data.Instruments, v) snap.data.Instruments = append(snap.data.Instruments, v)
case domain.Asset:
snap.data.Assets = append(snap.data.Assets, v)
case domain.Transaction: case domain.Transaction:
snap.data.Transactions = append(snap.data.Transactions, v) snap.data.Transactions = append(snap.data.Transactions, v)
} }
+24
View File
@@ -514,6 +514,30 @@ func TestNullListsPreserveUntouchedExternalBlockBytes(t *testing.T) {
} }
} }
// An asset is a registry entity like any other: committed to its own file and
// identical after a fresh load, or the wealth it backs vanishes on restart.
func TestAssetsSurviveCommitAndReload(t *testing.T) {
s := openTestStore(t)
d, r := loadTestStore(t, s)
d.Assets = []domain.Asset{{ID: "asset_house", Name: "House", Kind: "Real estate", Currency: "EUR", Value: "250000.00", ValuedAt: "2026-09-01"}}
commitTestStore(t, s, r, d)
if err := s.Close(); err != nil {
t.Fatal(err)
}
fresh, err := Open(s.dir)
if err != nil {
t.Fatal(err)
}
defer fresh.Close()
loaded, _ := loadTestStore(t, fresh)
if !reflect.DeepEqual(loaded.Assets, d.Assets) {
t.Errorf("assets after reload %+v, want %+v", loaded.Assets, d.Assets)
}
if raw := readTestFile(t, filepath.Join(s.dir, "assets.finance")); !bytes.Contains(raw, []byte(`asset {`)) {
t.Errorf("assets.finance holds no asset block: %s", raw)
}
}
func TestOversizedCommitCannotPublishUnreadableRecoveryIntent(t *testing.T) { func TestOversizedCommitCannotPublishUnreadableRecoveryIntent(t *testing.T) {
s := openTestStore(t) s := openTestStore(t)
original, r := loadTestStore(t, s) original, r := loadTestStore(t, s)
+12
View File
@@ -45,6 +45,7 @@ func New(a *app.App, assets fs.FS, publicURL string) (http.Handler, error) {
s.mux.HandleFunc("POST /api/tags", s.tag) s.mux.HandleFunc("POST /api/tags", s.tag)
s.mux.HandleFunc("POST /api/merchants", s.merchant) s.mux.HandleFunc("POST /api/merchants", s.merchant)
s.mux.HandleFunc("POST /api/instruments", s.instrument) s.mux.HandleFunc("POST /api/instruments", s.instrument)
s.mux.HandleFunc("POST /api/assets", s.asset)
s.mux.HandleFunc("POST /api/transactions/{id}/transfer", s.transfer) s.mux.HandleFunc("POST /api/transactions/{id}/transfer", s.transfer)
s.mux.HandleFunc("POST /api/transactions/{id}", s.transaction) s.mux.HandleFunc("POST /api/transactions/{id}", s.transaction)
s.mux.HandleFunc("POST /api/manage", s.manage) s.mux.HandleFunc("POST /api/manage", s.manage)
@@ -294,6 +295,17 @@ func (s *Server) instrument(w http.ResponseWriter, r *http.Request) {
v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveInstrument(d, b.Instrument) }) v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveInstrument(d, b.Instrument) })
respond(w, v, e) respond(w, v, e)
} }
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
var b struct {
Revision string `json:"revision"`
Asset domain.Asset `json:"asset"`
}
if !decode(w, r, &b) {
return
}
v, e := s.app.Mutate(r.Context(), b.Revision, func(d *domain.Dataset) error { return app.SaveAsset(d, b.Asset) })
respond(w, v, e)
}
// transfer links or unlinks one transaction's own-account counterpart. It is a // transfer links or unlinks one transaction's own-account counterpart. It is a
// separate endpoint because both sides change together: the transaction editor // separate endpoint because both sides change together: the transaction editor
+2
View File
@@ -617,6 +617,8 @@ function WealthStrip({
{money(total.positions, total.currency)} in positions across{" "} {money(total.positions, total.currency)} in positions across{" "}
{positions} investment account {positions} investment account
{positions === 1 ? "" : "s"} {positions === 1 ? "" : "s"}
{total.assets !== "0.00" &&
` · ${money(total.assets, total.currency)} in other assets`}
{total.unpriced > 0 && {total.unpriced > 0 &&
` · ${total.unpriced} holding${total.unpriced === 1 ? "" : "s"} without a quote, excluded`} ` · ${total.unpriced} holding${total.unpriced === 1 ? "" : "s"} without a quote, excluded`}
{failing > 0 && {failing > 0 &&
+315 -5
View File
@@ -3,12 +3,30 @@ import {
AlertTriangle, AlertTriangle,
CandlestickChart, CandlestickChart,
CheckCircle2, CheckCircle2,
Home,
Landmark, Landmark,
Pencil,
PiggyBank, PiggyBank,
Plus,
Trash2,
} from "lucide-react"; } 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 { 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 // 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. // 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({ export default function WealthPage({
revision, revision,
acceptState, acceptState,
mutate,
}: { }: {
revision: string; revision: string;
acceptState: (state: State, message?: string) => void; acceptState: (state: State, message?: string) => void;
mutate: Mutate;
}) { }) {
const [wealth, setWealth] = useState<Wealth | null>(null); const [wealth, setWealth] = useState<Wealth | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -33,7 +53,7 @@ export default function WealthPage({
setError(""); setError("");
request<Wealth>("/api/wealth", undefined, controller.signal) request<Wealth>("/api/wealth", undefined, controller.signal)
.then((value) => { .then((value) => {
for (const key of ["accounts", "totals"] as const) { for (const key of ["accounts", "assets", "totals"] as const) {
if (!(key in value)) if (!(key in value))
throw new Error(`Wealth response is missing ${key}.`); throw new Error(`Wealth response is missing ${key}.`);
if (value[key] === null) Object.assign(value, { [key]: [] }); if (value[key] === null) Object.assign(value, { [key]: [] });
@@ -181,8 +201,9 @@ export default function WealthPage({
Total wealth Total wealth
</h3> </h3>
<p> <p>
Cash plus the market value of every priced holding, per Cash, the market value of every priced holding, and your
currency, across all {wealth.accounts.length} account other assets, per currency, across all{" "}
{wealth.accounts.length} account
{wealth.accounts.length === 1 ? "" : "s"}. {wealth.accounts.length === 1 ? "" : "s"}.
</p> </p>
</div> </div>
@@ -212,6 +233,14 @@ export default function WealthPage({
</strong>{" "} </strong>{" "}
in positions in positions
</span> </span>
{total.assets !== "0.00" && (
<span>
<strong className="money">
{money(total.assets, total.currency)}
</strong>{" "}
in other assets
</span>
)}
{total.unpriced > 0 && ( {total.unpriced > 0 && (
<span> <span>
<strong>{total.unpriced}</strong> holding <strong>{total.unpriced}</strong> holding
@@ -224,6 +253,11 @@ export default function WealthPage({
</div> </div>
</section> </section>
)} )}
<AssetsPanel
assets={wealth.assets}
currency={wealth.totals[0]?.currency ?? "EUR"}
mutate={mutate}
/>
{wealth.accounts.length === 0 ? ( {wealth.accounts.length === 0 ? (
<section className="panel"> <section className="panel">
<Empty title="No accounts to report on yet"> <Empty title="No accounts to report on yet">
@@ -476,3 +510,279 @@ function AccountReport({ account }: { account: WealthAccount }) {
</section> </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; quote?: string;
quoted_at?: 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 // 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 // Facts.amount, so a position-only event carries a zero amount. Quantity is an
// exact signed decimal, not money: negative removes from the holding. // exact signed decimal, not money: negative removes from the holding.
@@ -107,6 +118,7 @@ export interface Dataset {
tags: Tag[]; tags: Tag[];
merchants: Merchant[]; merchants: Merchant[];
instruments: Instrument[]; instruments: Instrument[];
assets: Asset[];
transactions: Transaction[]; transactions: Transaction[];
} }
export interface Connection { export interface Connection {
@@ -384,13 +396,27 @@ export interface WealthTotal {
currency: string; currency: string;
cash: string; cash: string;
positions: 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; wealth: string;
unpriced: number; 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 // 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. // analytics index, so it can be checked against a bank or broker's own screen.
export interface Wealth { export interface Wealth {
accounts: WealthAccount[]; accounts: WealthAccount[];
assets: WealthAsset[];
totals: WealthTotal[]; totals: WealthTotal[];
} }
export class APIError extends Error { export class APIError extends Error {
@@ -461,6 +487,7 @@ export function normalizeState(state: State): State {
"tags", "tags",
"merchants", "merchants",
"instruments", "instruments",
"assets",
"transactions", "transactions",
] as const) { ] as const) {
if (!(key in state.data)) if (!(key in state.data))
+5 -1
View File
@@ -402,7 +402,11 @@ function App() {
/> />
)} )}
{page === "wealth" && ( {page === "wealth" && (
<Wealth revision={state.revision} acceptState={acceptState} /> <Wealth
revision={state.revision}
acceptState={acceptState}
mutate={mutate}
/>
)} )}
{page === "classification" && ( {page === "classification" && (
<Classification state={state} acceptState={acceptState} /> <Classification state={state} acceptState={acceptState} />