Add account balance anchors
This commit is contained in:
@@ -291,6 +291,20 @@ func Validate(d Dataset) error {
|
||||
if a.Kind != "" && a.Kind != AccountCash && a.Kind != AccountInvestment {
|
||||
return fmt.Errorf("account %q: kind must be %q or %q", a.ID, AccountCash, AccountInvestment)
|
||||
}
|
||||
// An anchor is one figure and the day it was true: neither half means
|
||||
// anything alone, and anchoring an investment account would mask an
|
||||
// incomplete broker history instead of exposing it.
|
||||
if (a.AnchorBalance == "") != (a.AnchorDate == "") {
|
||||
return fmt.Errorf("account %q: an anchor needs both a balance and its date", a.ID)
|
||||
}
|
||||
if a.AnchorDate != "" {
|
||||
if a.Investing() {
|
||||
return fmt.Errorf("account %q: a balance anchor belongs to a cash account; a broker export carries its complete history", a.ID)
|
||||
}
|
||||
if _, err := a.AnchorBalance.Minor(); err != nil || !validDate(a.AnchorDate) {
|
||||
return fmt.Errorf("account %q: invalid anchor balance or date", a.ID)
|
||||
}
|
||||
}
|
||||
accounts[a.ID] = a
|
||||
}
|
||||
for _, c := range d.Categories {
|
||||
|
||||
@@ -77,6 +77,18 @@ func TestDomainRejectsBrokenReferencesAndTaxonomy(t *testing.T) {
|
||||
{"nonleaf merchant default", func(d *Dataset) { d.Merchants[0].DefaultCategoryID = "cat_food" }},
|
||||
{"oversized tag name", func(d *Dataset) { d.Tags[0].Name = strings.Repeat("x", 201) }},
|
||||
{"oversized category name", func(d *Dataset) { d.Categories[2].Name = strings.Repeat("x", 201) }},
|
||||
{"anchor balance without its date", func(d *Dataset) { d.Accounts[1].AnchorBalance = "100.00" }},
|
||||
{"anchor date without its balance", func(d *Dataset) { d.Accounts[1].AnchorDate = "2026-01-01" }},
|
||||
{"anchored investment account", func(d *Dataset) {
|
||||
d.Accounts[1].Kind = AccountInvestment
|
||||
d.Accounts[1].AnchorBalance, d.Accounts[1].AnchorDate = "100.00", "2026-01-01"
|
||||
}},
|
||||
{"invalid anchor date", func(d *Dataset) {
|
||||
d.Accounts[1].AnchorBalance, d.Accounts[1].AnchorDate = "100.00", "2026-02-30"
|
||||
}},
|
||||
{"invalid anchor balance", func(d *Dataset) {
|
||||
d.Accounts[1].AnchorBalance, d.Accounts[1].AnchorDate = "1e2", "2026-01-01"
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -31,6 +31,16 @@ type Account struct {
|
||||
// broker exports no counterparty column, so deposits and withdrawals carry
|
||||
// this IBAN instead and pair with the funding account like any transfer.
|
||||
ReferenceIBAN string `json:"reference_iban,omitempty"`
|
||||
// AnchorBalance is the bank's booked (CLBD) balance on AnchorDate, captured
|
||||
// once from open banking after a sync. It fixes the start balance of a
|
||||
// date-windowed history: the money that existed before the recorded rows is
|
||||
// AnchorBalance less every movement booked through AnchorDate, so the
|
||||
// account's real balance is computable without complete history. The bank's
|
||||
// figure is stored verbatim — the start balance is derived, never stored —
|
||||
// so importing older history later corrects the derivation by itself.
|
||||
// Cash accounts only: a broker export carries its complete history.
|
||||
AnchorBalance Money `json:"anchor_balance,omitempty"`
|
||||
AnchorDate string `json:"anchor_date,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user