Add account balance anchors

This commit is contained in:
Lars Nolden
2026-09-14 13:32:18 +02:00
parent 83bb86bc93
commit 71e95917da
15 changed files with 440 additions and 23 deletions
+58
View File
@@ -60,6 +60,64 @@ func seed(t *testing.T, a *App, s State) State {
return result.State
}
func TestSaveAccountClearsStaleBalanceAnchorOnIdentityChange(t *testing.T) {
cases := []struct {
name string
change func(*domain.Account)
clear bool
}{
{
name: "currency",
change: func(account *domain.Account) {
account.Currency = "USD"
},
clear: true,
},
{
name: "external account",
change: func(account *domain.Account) {
account.ExternalAccountID = "new_uid"
},
clear: true,
},
{
name: "display name",
change: func(account *domain.Account) {
account.DisplayName = "Renamed"
},
clear: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a, s := testApp(t)
anchored := s.Data.Accounts[0]
anchored.ExternalAccountID = "old_uid"
anchored.AnchorBalance = "100.00"
anchored.AnchorDate = "2026-09-10"
var err error
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
return SaveAccount(d, anchored)
})
if err != nil {
t.Fatal(err)
}
changed := anchored
tc.change(&changed)
s, err = a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error {
return SaveAccount(d, changed)
})
if err != nil {
t.Fatal(err)
}
got := s.Data.Accounts[0]
if tc.clear != (got.AnchorBalance == "" && got.AnchorDate == "") {
t.Fatalf("anchor after %s change: balance=%q date=%q", tc.name, got.AnchorBalance, got.AnchorDate)
}
})
}
}
// A released binary wrote include_amount into config.toml. Refusing it on
// startup made every upgraded deployment crash-loop against its own settings
// file, so a retired key must load and then disappear on the next save.
+61
View File
@@ -882,6 +882,7 @@ func (a *App) Sync(ctx context.Context) (State, error) {
}
s = result.State
a.ops.AccountSync[account.ID] = now.Format(time.RFC3339)
s = a.anchorAccount(ctx, s, account, to)
}
a.ops.SyncError = strings.Join(failures, "; ")
a.ops.SyncRetryAt = ""
@@ -897,6 +898,66 @@ func (a *App) Sync(ctx context.Context) (State, error) {
return a.snapshot(ctx)
}
// anchorAccount fixes a connected cash account's start balance after its first
// successful sync: the bank's booked (CLBD) balance is captured once, verbatim,
// with the day it was true, so a date-windowed history still yields the real
// balance — the money from before the window is derived as the anchor less
// every movement booked through the anchor date, and an older import later
// corrects that derivation by itself. The balance is fetched after the
// transactions to minimize the gap between the two reads. Banks supply booking
// dates rather than exact times, so the anchor day is deliberately treated as
// one completed booked state. Every failure leaves the anchor unset for the
// next sync to retry; a missing CLBD figure is such a failure, because an
// available or expected balance includes pending amounts that have no booked
// fact to subtract.
func (a *App) anchorAccount(ctx context.Context, s State, account domain.Account, today string) State {
if account.Investing() || account.AnchorDate != "" || account.ExternalAccountID == "" {
return s
}
balances, err := a.bank.Balances(ctx, account.ExternalAccountID)
if err != nil {
return s
}
var selected banking.Balance
anchorDate := ""
for _, balance := range balances {
if balance.Type != "CLBD" || balance.Currency != account.Currency {
continue
}
date := balance.ReferenceDate
if date == "" {
date = today
} else if _, e := time.Parse("2006-01-02", date); e != nil || date > today {
continue
}
if date < anchorDate {
continue
}
// Two different booked figures for the same account, currency and
// reference day are ambiguous. Do not let response order decide money.
if date == anchorDate && anchorDate != "" && balance.Amount != selected.Amount {
return s
}
selected, anchorDate = balance, date
}
if anchorDate == "" {
return s
}
data := domain.Clone(s.Data)
for i := range data.Accounts {
if data.Accounts[i].ID != account.ID {
continue
}
data.Accounts[i].AnchorBalance = selected.Amount
data.Accounts[i].AnchorDate = anchorDate
if next, e := a.commit(ctx, s.Revision, data); e == nil {
return next
}
return s
}
return s
}
// syncInterval is how often connected accounts synchronize on their own. Twice
// a day halves how long a booking can sit unseen while staying inside Enable
// Banking's documented background allowance of roughly four fetches per day per
+6
View File
@@ -61,6 +61,12 @@ func SaveAccount(d *domain.Dataset, v domain.Account) error {
}
for i, x := range d.Accounts {
if x.ID == v.ID {
// A balance belongs to the account identity and currency that the
// bank reported. Changing either makes the captured figure stale;
// clear it so the next connected sync can capture a matching one.
if x.Currency != v.Currency || x.ExternalAccountID != v.ExternalAccountID {
v.AnchorBalance, v.AnchorDate = "", ""
}
d.Accounts[i] = v
return nil
}
+73 -5
View File
@@ -17,8 +17,9 @@ import (
)
type bankScenario struct {
session banking.Session
fail bool
session banking.Session
fail bool
balances []banking.Balance
}
func (b *bankScenario) Authorize(context.Context, string, string, string, string) (string, error) {
@@ -41,6 +42,9 @@ func (b *bankScenario) Status(context.Context, string) (banking.SessionStatus, e
return status, nil
}
func (b *bankScenario) Balances(context.Context, string) ([]banking.Balance, error) {
if b.balances != nil {
return b.balances, nil
}
return []banking.Balance{{Amount: "100.00", Currency: "EUR", Type: "CLBD"}}, nil
}
func (b *bankScenario) Transactions(_ context.Context, a domain.Account, from, to string, _ bool) ([]domain.Facts, error) {
@@ -83,6 +87,53 @@ func TestSyncRestoresSavedConsentBindingsAndDoesNotDuplicateFacts(t *testing.T)
t.Fatal("provider failure was not isolated from canonical data")
}
}
// The first successful sync fixes the start balance from the bank's booked
// figure only: an available balance includes pending amounts with no booked
// fact to subtract, and a later balance change must never move an anchor that
// has been set — the anchor is the day a figure was true, not a mirror.
func TestSyncAnchorsBalanceOnceFromBookedFigureOnly(t *testing.T) {
a, s := testApp(t)
account := s.Data.Accounts[0]
account.ExternalAccountID = "provider_uid"
provider := &bankScenario{
session: banking.Session{ID: "session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}},
balances: []banking.Balance{{Amount: "999.99", Currency: "EUR", Type: "ITAV"}},
}
a.bank = provider
a.ops.Sessions = []banking.Session{provider.session}
if err := a.saveOps(); err != nil {
t.Fatal(err)
}
unbooked, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if got := unbooked.Data.Accounts[0]; got.AnchorBalance != "" || got.AnchorDate != "" {
t.Fatalf("available-only balance was anchored: %+v", got)
}
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
older := time.Now().UTC().AddDate(0, 0, -2).Format("2006-01-02")
provider.balances = append(provider.balances,
banking.Balance{Amount: "240.00", Currency: "EUR", Type: "CLBD", ReferenceDate: older},
banking.Balance{Amount: "250.00", Currency: "EUR", Type: "CLBD", ReferenceDate: yesterday},
)
anchored, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if got := anchored.Data.Accounts[0]; got.AnchorBalance != "250.00" || got.AnchorDate != yesterday {
t.Fatalf("booked balance was not anchored at its reference day: %+v", got)
}
provider.balances = []banking.Balance{{Amount: "300.00", Currency: "EUR", Type: "CLBD", ReferenceDate: yesterday}}
retained, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(anchored.Data, retained.Data) {
t.Fatal("a later balance moved an existing anchor")
}
}
func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T) {
a, s := testApp(t)
account := s.Data.Accounts[0]
@@ -196,9 +247,16 @@ func TestSyncSessionRateLimitPreservesBindingsAndRecovers(t *testing.T) {
failures: map[string]error{},
}
a.bank = b
first, err := a.Sync(ctx)
if err != nil || len(first.Data.Transactions) != 4 {
t.Fatalf("initial sync: transactions=%d, error=%v, sync error=%s", len(first.Data.Transactions), err, first.Status.SyncError)
}
// The first successful sync also anchors each account's balance; a second
// sync reaches the steady state where the session bindings have absorbed
// the anchored accounts and nothing changes any more.
before, err := a.Sync(ctx)
if err != nil || len(before.Data.Transactions) != 4 {
t.Fatalf("initial sync: transactions=%d, error=%v, sync error=%s", len(before.Data.Transactions), err, before.Status.SyncError)
if err != nil || !reflect.DeepEqual(first.Data, before.Data) {
t.Fatalf("steady-state sync changed canonical data: %v", err)
}
old := time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339)
a.ops.LastSync = old
@@ -267,9 +325,19 @@ func TestSyncMissingMembershipStillRejectsAccount(t *testing.T) {
if len(b.accounts) != 1 || b.accounts[0].ID != "other" || a.ops.AccountSync[s.Data.Accounts[0].ID] != last || a.ops.LastSync != last {
t.Fatal("missing member was fetched or advanced its cursor, or valid member was skipped")
}
if !reflect.DeepEqual(before.Accounts, after.Data.Accounts) || len(after.Data.Transactions) != 1 || after.Data.Transactions[0].Facts.AccountID != "other" {
if !reflect.DeepEqual(before.Accounts[0], after.Data.Accounts[0]) || len(after.Data.Transactions) != 1 || after.Data.Transactions[0].Facts.AccountID != "other" {
t.Fatal("missing membership changed bindings or imported unauthorized facts")
}
// The authorized member's first successful sync anchors its balance from
// the bank's booked figure; the rejected member must not gain one.
anchored := after.Data.Accounts[1]
if anchored.AnchorBalance != "100.00" || anchored.AnchorDate == "" {
t.Fatalf("authorized member was not anchored: %+v", anchored)
}
anchored.AnchorBalance, anchored.AnchorDate = "", ""
if !reflect.DeepEqual(before.Accounts[1], anchored) {
t.Fatal("anchoring changed more than the anchor on the authorized member")
}
}
func TestSyncTransactionFailuresPreserveProgressAndSafeErrors(t *testing.T) {
+60 -8
View File
@@ -49,9 +49,11 @@ type WealthAccount struct {
Records int `json:"records"`
FirstBooking string `json:"first_booking,omitempty"`
LastBooking string `json:"last_booking,omitempty"`
// Cash is every recorded movement summed. It equals the account's real
// balance only when the journal holds that account's complete history,
// which a broker export does and a date-windowed bank statement does not.
// Cash is every recorded movement summed — plus, when the account carries a
// balance anchor, the derived start balance. Without an anchor it equals
// the account's real balance only when the journal holds that account's
// complete history, which a broker export does and a date-windowed bank
// statement does not.
Cash domain.Money `json:"cash"`
// Positions is the market value of every priced holding, and Wealth the two
// together: the number this page exists to show. Unpriced counts the
@@ -192,6 +194,13 @@ func WealthOf(data domain.Dataset) Wealth {
unappliedFee, unappliedTax int64
unappliedRows int
unmatchedCash, unmatchedRows int64
// anchored accounts carry the bank's booked balance on anchorDate.
// residual is that figure less every movement booked through the
// anchor day: the money from before the recorded history, and the
// account's derived start balance.
anchored bool
anchorDate string
residual int64
}
states := map[string]*accountState{}
state := func(id string) *accountState {
@@ -200,15 +209,42 @@ func WealthOf(data domain.Dataset) Wealth {
}
return states[id]
}
// An anchored account's balance is the bank's own figure plus what moved
// after the anchor day. The residue is order-independent, so it is settled
// before the chronological pass that judges running balances.
for _, account := range data.Accounts {
if account.AnchorDate == "" {
continue
}
anchor, err := account.AnchorBalance.Minor()
if err != nil {
continue
}
st := state(account.ID)
st.anchored, st.anchorDate, st.residual = true, account.AnchorDate, anchor
for _, t := range data.Transactions {
if t.Facts.AccountID != account.ID || t.Facts.BookingDate > account.AnchorDate {
continue
}
if minor, e := t.Facts.Amount.Minor(); e == nil {
st.residual -= minor
}
}
}
// A day's rows are applied together before any low-water mark is taken.
// Order within a day is not knowable: a broker export states a booking date
// and a clock time, the time is local and crosses midnight, so only the
// date is imported. A purchase funded by a sale nine seconds earlier then
// arrives in an arbitrary order, and checking row by row reports a dip
// that never happened.
// Days on or before an anchor are not judged at all: the history before
// the anchor is incomplete by definition, so a running balance there is
// not observable.
closeDay := func(st *accountState) {
if st.cash < st.lowestCash {
st.lowestCash, st.lowestCashDate = st.cash, st.day
if !st.anchored || st.day > st.anchorDate {
if effective := st.cash + st.residual; effective < st.lowestCash {
st.lowestCash, st.lowestCashDate = effective, st.day
}
}
for _, held := range st.holdings {
if held.units < held.lowest {
@@ -317,13 +353,22 @@ func WealthOf(data domain.Dataset) Wealth {
if kind == "" {
kind = domain.AccountCash
}
cash := st.cash + st.residual
entry := WealthAccount{
AccountID: account.ID, DisplayName: account.DisplayName, Institution: account.Institution,
Currency: account.Currency, Kind: kind, Active: account.Active,
Records: st.records, FirstBooking: st.first, LastBooking: st.last,
Cash: domain.FormatMoney(st.cash), Flows: []WealthFlow{},
Cash: domain.FormatMoney(cash), Flows: []WealthFlow{},
Holdings: []WealthHolding{}, Checks: []WealthCheck{},
}
// The start balance reads first, like the carried-over line on a paper
// statement, and keeps the invariant that the flows sum to the balance.
if st.anchored {
entry.Flows = append(entry.Flows, WealthFlow{
Event: "anchor", Label: "Start balance (before the recorded rows)",
Cash: domain.FormatMoney(st.residual),
})
}
for _, flow := range flowLabels {
if moved := st.flows[flow.event]; moved != nil {
entry.Flows = append(entry.Flows, WealthFlow{
@@ -333,7 +378,7 @@ func WealthOf(data domain.Dataset) Wealth {
}
}
seen(account.Currency)
totals[account.Currency] += st.cash
totals[account.Currency] += cash
positions, unpriced, stale := int64(0), 0, []string{}
for _, id := range st.order {
held := st.holdings[id]
@@ -372,7 +417,7 @@ func WealthOf(data domain.Dataset) Wealth {
}
slices.SortFunc(entry.Holdings, func(x, y WealthHolding) int { return strings.Compare(x.Name, y.Name) })
entry.Positions, entry.Unpriced = domain.FormatMoney(positions), unpriced
entry.Wealth = domain.FormatMoney(st.cash + positions)
entry.Wealth = domain.FormatMoney(cash + positions)
positionTotals[account.Currency] += positions
unpricedTotals[account.Currency] += unpriced
@@ -384,8 +429,15 @@ func WealthOf(data domain.Dataset) Wealth {
} else {
check("Row arithmetic", "every record agrees with its own gross, fee, tax, quantity and price", false)
}
if st.anchored {
check("Balance anchored", fmt.Sprintf("cash is the bank's own booked balance %s on %s plus every movement after that day; the start balance line, %s, is that figure less the movements booked through it", account.AnchorBalance, st.anchorDate, domain.FormatMoney(st.residual)), false)
} else if !account.Investing() && account.ExternalAccountID != "" {
check("Balance not anchored", "cash is the recorded movements only; the next successful synchronization captures the bank's booked balance and fixes the start balance", false)
}
if st.lowestCash < 0 {
check("Cash never negative", fmt.Sprintf("balance reached %s on %s, so the history is incomplete or a movement is misread", domain.FormatMoney(st.lowestCash), st.lowestCashDate), true)
} else if st.anchored {
check("Cash never negative", "the running balance stays at or above zero from the anchor day onward; earlier days are not judged against an incomplete window", false)
} else {
check("Cash never negative", "the running balance stays at or above zero throughout", false)
}
+68
View File
@@ -416,3 +416,71 @@ func TestWealthCountsHandValuedAssets(t *testing.T) {
t.Errorf("assets not echoed sorted by name with their dates: %+v", report.Assets)
}
}
// A bank's date-windowed history starts mid-life, so an anchored account
// derives its start balance: the bank's booked figure on the anchor day less
// everything booked through it. The derived line keeps the flows summing to
// the balance, and the pre-anchor window is never judged as an overdraft —
// the history there is incomplete by definition.
func TestAnchoredAccountDerivesStartBalance(t *testing.T) {
data := domain.NewDataset()
data.Accounts = []domain.Account{
{ID: "acc_anchored", DisplayName: "Checking", Currency: "EUR", Active: true, ExternalAccountID: "uid_one", AnchorBalance: "2450.00", AnchorDate: "2026-09-10"},
{ID: "acc_plain", DisplayName: "Connected", Currency: "EUR", Active: true, ExternalAccountID: "uid_two"},
}
row := func(id, account, date string, amount domain.Money) domain.Transaction {
f := domain.Facts{ID: id, Source: "enablebanking", AccountID: account, BookingDate: date, Amount: amount, Currency: "EUR", RawDescription: id, Fingerprint: "fp_" + id}
return domain.Transaction{Facts: f, Enrichment: domain.Fallback(f)}
}
data.Transactions = []domain.Transaction{
// The recorded window alone would dip to 900 before the anchor day.
row("tx_pre", "acc_anchored", "2026-09-01", "-900.00"),
row("tx_on", "acc_anchored", "2026-09-10", "50.00"),
row("tx_post", "acc_anchored", "2026-09-12", "-100.00"),
row("tx_other", "acc_plain", "2026-09-12", "10.00"),
}
if err := domain.Validate(data); err != nil {
t.Fatal(err)
}
report := WealthOf(data)
anchored := report.Accounts[0]
// 2450.00 on 2026-09-10 less the 850.00 booked through that day puts
// 3300.00 before the window; the balance is 2450.00 100.00 booked after.
if anchored.Cash != "2350.00" || anchored.Wealth != "2350.00" {
t.Errorf("anchored cash %s wealth %s, want 2350.00", anchored.Cash, anchored.Wealth)
}
if len(anchored.Flows) == 0 || anchored.Flows[0].Event != "anchor" || anchored.Flows[0].Cash != "3300.00" {
t.Errorf("start balance line missing or wrong: %+v", anchored.Flows)
}
total := int64(0)
for _, flow := range anchored.Flows {
cash, err := flow.Cash.Minor()
if err != nil {
t.Fatal(err)
}
total += cash
}
if domain.FormatMoney(total) != anchored.Cash {
t.Errorf("flows sum to %s, balance is %s", domain.FormatMoney(total), anchored.Cash)
}
checks := map[string]WealthCheck{}
for _, check := range anchored.Checks {
checks[check.Name] = check
}
if _, ok := checks["Balance anchored"]; !ok {
t.Errorf("no anchor note: %+v", anchored.Checks)
}
if check := checks["Cash never negative"]; check.Failed {
t.Errorf("pre-anchor window judged as an overdraft: %s", check.Detail)
}
note := false
for _, check := range report.Accounts[1].Checks {
note = note || check.Name == "Balance not anchored"
}
if !note {
t.Errorf("connected account without an anchor carries no note: %+v", report.Accounts[1].Checks)
}
if report.Totals[0].Cash != "2360.00" {
t.Errorf("total cash %s, want 2360.00", report.Totals[0].Cash)
}
}