Add account balance anchors
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user