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
+28
View File
@@ -104,6 +104,25 @@ func SaveInstrument(d *domain.Dataset, v domain.Instrument) error {
d.Instruments = append(d.Instruments, v)
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 {
v.Name = strings.TrimSpace(v.Name)
if v.ID == "" {
@@ -203,6 +222,15 @@ func Manage(d *domain.Dataset, entity, action, id, target string) error {
if n == len(d.Instruments) {
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":
if !slices.ContainsFunc(d.Tags, func(v domain.Tag) bool { return v.ID == id }) {
return errors.New("unknown tag")
+47 -9
View File
@@ -15,8 +15,11 @@ import (
// same journal derives.
type Wealth struct {
Accounts []WealthAccount `json:"accounts"`
// Totals is cash, position value and their sum per currency, across every
// account.
// Assets are the hand-valued possessions outside any account, echoed here
// 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"`
}
@@ -28,8 +31,11 @@ type WealthTotal struct {
// in Unpriced, because valuing them at cost would report a number the
// journal cannot support.
Positions domain.Money `json:"positions"`
Wealth domain.Money `json:"wealth"`
Unpriced int `json:"unpriced"`
// 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"`
Unpriced int `json:"unpriced"`
}
// WealthAccount is one account's position as the journal records it.
@@ -113,6 +119,17 @@ type WealthHolding struct {
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
// disagreement inside the journal; the rest are notes that explain a figure
// 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{}
positionTotals := map[string]int64{}
assetTotals := map[string]int64{}
unpricedTotals := map[string]int{}
currencies := []string{}
seen := func(currency string) {
if _, ok := totals[currency]; !ok {
currencies = append(currencies, currency)
totals[currency] = 0
}
}
for _, account := range data.Accounts {
st := state(account.ID)
kind := account.Kind
@@ -308,9 +332,7 @@ func WealthOf(data domain.Dataset) Wealth {
})
}
}
if _, seen := totals[account.Currency]; !seen {
currencies = append(currencies, account.Currency)
}
seen(account.Currency)
totals[account.Currency] += st.cash
positions, unpriced, stale := int64(0), 0, []string{}
for _, id := range st.order {
@@ -391,11 +413,27 @@ func WealthOf(data domain.Dataset) Wealth {
}
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 {
report.Totals = append(report.Totals, WealthTotal{
Currency: currency, Cash: domain.FormatMoney(totals[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],
})
}
+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")
}
}
// 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)
}
}