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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user