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:
@@ -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
@@ -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],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func NewDataset() Dataset {
|
||||
return Dataset{Accounts: []Account{}, Categories: []Category{
|
||||
{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"},
|
||||
}, 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
|
||||
@@ -181,7 +181,7 @@ func InstrumentID(isin string) string {
|
||||
return "ins_" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
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 {
|
||||
c.Merchants[i].Aliases = append([]string{}, d.Merchants[i].Aliases...)
|
||||
c.Merchants[i].DefaultTagIDs = append([]string{}, d.Merchants[i].DefaultTagIDs...)
|
||||
@@ -398,6 +398,22 @@ func Validate(d Dataset) error {
|
||||
isins[v.ISIN] = v.ID
|
||||
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 {
|
||||
f := t.Facts
|
||||
if err := register(f.ID, "transaction"); err != nil {
|
||||
|
||||
@@ -116,6 +116,20 @@ type Instrument struct {
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
@@ -178,6 +192,7 @@ type Dataset struct {
|
||||
Tags []Tag `json:"tags"`
|
||||
Merchants []Merchant `json:"merchants"`
|
||||
Instruments []Instrument `json:"instruments"`
|
||||
Assets []Asset `json:"assets"`
|
||||
Transactions []Transaction `json:"transactions"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/merchants", s.merchant)
|
||||
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}", s.transaction)
|
||||
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) })
|
||||
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
|
||||
// separate endpoint because both sides change together: the transaction editor
|
||||
|
||||
Reference in New Issue
Block a user