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