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
+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