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
+10 -2
View File
@@ -19,7 +19,7 @@ import (
// 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
// 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 block struct {
@@ -140,7 +140,7 @@ func parseDocument(path string, raw []byte) (*document, error) {
}
header := strings.Fields(trimmed)
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]
var value any
@@ -155,6 +155,8 @@ func parseDocument(path string, raw []byte) (*document, error) {
value = &domain.Merchant{}
case "instrument":
value = &domain.Instrument{}
case "asset":
value = &domain.Asset{}
case "transaction":
value = &domain.Transaction{}
default:
@@ -237,6 +239,9 @@ func parseDocument(path string, raw []byte) (*document, error) {
case *domain.Instrument:
b.id = v.ID
b.value = *v
case *domain.Asset:
b.id = v.ID
b.value = *v
case *domain.Merchant:
if v.Aliases == nil {
v.Aliases = []string{}
@@ -342,6 +347,9 @@ func datasetFiles(d domain.Dataset) map[string]map[string]piece {
for _, v := range d.Instruments {
add("instruments.finance", "instrument", v.ID, v)
}
for _, v := range d.Assets {
add("assets.finance", "asset", v.ID, v)
}
for _, v := range d.Transactions {
month := v.Facts.BookingDate[:7]
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
}
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 {
snap.data = domain.NewDataset()
return snap, nil
@@ -483,6 +483,8 @@ func decodeSnapshot(raw map[string][]byte) (*snapshot, error) {
snap.data.Merchants = append(snap.data.Merchants, v)
case domain.Instrument:
snap.data.Instruments = append(snap.data.Instruments, v)
case domain.Asset:
snap.data.Assets = append(snap.data.Assets, v)
case domain.Transaction:
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) {
s := openTestStore(t)
original, r := loadTestStore(t, s)