Configure initial bank history in the connection UI

This commit is contained in:
Lars Nolden
2026-09-10 16:37:34 +02:00
parent bec6d0b444
commit f4c7d54575
10 changed files with 181 additions and 36 deletions
+2 -2
View File
@@ -87,7 +87,7 @@ func bankingAuthorization(t *testing.T, a *App, key *rsa.PrivateKey, appID, redi
}
provider.BaseURL = mock.URL
provider.HTTPClient = mock.Client()
if _, err := a.Authorize(context.Background(), "N26", "DE"); err != nil {
if _, err := a.Authorize(context.Background(), "N26", "DE", 12); err != nil {
t.Fatal(err)
}
return pending
@@ -244,7 +244,7 @@ func TestBankingSavedCredentialsAndDisableOverrideEnvironment(t *testing.T) {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
if _, err := a.Authorize(ctx, "N26", "DE"); err == nil {
if _, err := a.Authorize(ctx, "N26", "DE", 12); err == nil {
t.Fatal("disabled saved configuration fell back to environment")
}
}
+23 -10
View File
@@ -8,30 +8,42 @@ import (
"finance-duck/internal/domain"
)
const defaultHistoryMonths = 12
type authorization struct {
Expires time.Time
Institution string
Country string
Expires time.Time
Institution string
Country string
HistoryMonths int
}
type Consent struct {
Institution string `json:"institution"`
Country string `json:"country"`
HistoryMonths int `json:"history_months"`
Error string `json:"error,omitempty"`
NeedsReconnect bool `json:"needs_reconnect"`
}
type Connection struct {
AccountID string `json:"account_id"`
Institution string `json:"institution"`
Country string `json:"country"`
Status string `json:"status"`
ValidUntil string `json:"valid_until"`
Error string `json:"error"`
AccountID string `json:"account_id"`
Institution string `json:"institution"`
Country string `json:"country"`
HistoryMonths int `json:"history_months"`
Status string `json:"status"`
ValidUntil string `json:"valid_until"`
Error string `json:"error"`
}
func (c Consent) historyMonths() int {
if c.HistoryMonths == 0 {
return defaultHistoryMonths
}
return c.HistoryMonths
}
func (a *App) connections(d domain.Dataset) []Connection {
out := make([]Connection, 0, len(d.Accounts))
for _, account := range d.Accounts {
c := Connection{AccountID: account.ID, Institution: account.Institution, Country: "DE", Status: "local"}
c := Connection{AccountID: account.ID, Institution: account.Institution, Country: "DE", HistoryMonths: defaultHistoryMonths, Status: "local"}
if account.ExternalAccountID != "" {
c.Status = "reconnect_required"
c.Error = "No saved bank consent; reconnect this account"
@@ -42,6 +54,7 @@ func (a *App) connections(d domain.Dataset) []Connection {
continue
}
meta := a.ops.Consents[session.ID]
c.HistoryMonths = meta.historyMonths()
if meta.Institution != "" {
c.Institution = meta.Institution
}
+84 -4
View File
@@ -11,12 +11,22 @@ import (
type historyBank struct {
bankScenario
fetched chan struct{}
fetched chan struct{}
authState string
authorizations int
fromDates []string
}
func (b *historyBank) Authorize(_ context.Context, _, _, state string) (string, error) {
b.authState = state
b.authorizations++
return "https://bank.example/authorize", nil
}
func (b *historyBank) Transactions(_ context.Context, account domain.Account, from, to string) ([]domain.Facts, error) {
b.fromDates = append(b.fromDates, from)
var rows []domain.Facts
for _, days := range []int{60, 1} {
for _, days := range []int{400, 300, 1} {
date := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02")
if date >= from && date <= to {
rows = append(rows, domain.Facts{Source: "enablebanking", AccountID: account.ID, BookingDate: date, Amount: "-10.00", Currency: "EUR", RawDescription: "Card payment", ExternalID: "entry_" + date})
@@ -40,10 +50,16 @@ func TestNewAccountImportsHistoryIndependentOfExistingSyncCursor(t *testing.T) {
t.Fatal(err)
}
session := banking.Session{ID: "consent", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: s.Data.Accounts}
a.bank = &historyBank{bankScenario: bankScenario{session: session}}
b := &historyBank{bankScenario: bankScenario{session: session}}
a.bank = b
a.ops.Sessions = []banking.Session{session}
a.ops.LastSync = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
a.ops.AccountSync[old.ID] = a.ops.LastSync
if err := a.saveOps(); err != nil {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
a.bank = b
after, err := a.Sync(context.Background())
if err != nil {
t.Fatal(err)
@@ -56,6 +72,67 @@ func TestNewAccountImportsHistoryIndependentOfExistingSyncCursor(t *testing.T) {
t.Fatalf("new account history skipped: %v", counts)
}
}
func TestAuthorizedHistorySurvivesReopenAndRespectsIncrementalCursor(t *testing.T) {
a, s := testApp(t)
ctx := context.Background()
account := s.Data.Accounts[0]
account.ExternalAccountID = "history_uid"
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "history_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}}
a.bank = b
for _, months := range []int{-1, 0, 121} {
if _, err := a.Authorize(ctx, "N26", "DE", months); err == nil {
t.Fatalf("accepted invalid history choice %d", months)
}
}
if b.authorizations != 0 {
t.Fatal("invalid history choice reached the bank")
}
if _, err := a.Authorize(ctx, "N26", "DE", 24); err != nil {
t.Fatal(err)
}
if err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
a.bank = b
s, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
if len(s.Connections) != 1 || s.Connections[0].HistoryMonths != 24 {
t.Fatalf("saved history choice unavailable after restart: %+v", s.Connections)
}
before := time.Now().UTC().AddDate(0, -24, 0).Format("2006-01-02")
first, err := a.Sync(ctx)
if err != nil {
t.Fatal(err)
}
after := time.Now().UTC().AddDate(0, -24, 0).Format("2006-01-02")
if len(first.Data.Transactions) != 3 {
t.Fatalf("selected history did not import older transactions: %+v", first.Data.Transactions)
}
if len(b.fromDates) != 1 || (b.fromDates[0] != before && b.fromDates[0] != after) {
t.Fatalf("initial import did not use 24 calendar months: %v", b.fromDates)
}
cursor, err := time.Parse(time.RFC3339, first.Status.LastSync)
if err != nil {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
a.bank = b
again, err := a.Sync(ctx)
if err != nil {
t.Fatal(err)
}
wantFrom := cursor.AddDate(0, 0, -14).Format("2006-01-02")
if len(b.fromDates) != 2 || b.fromDates[1] != wantFrom {
t.Fatalf("incremental import ignored saved cursor: %v, want %s", b.fromDates, wantFrom)
}
if len(again.Data.Transactions) != 3 || again.Connections[0].HistoryMonths != 24 {
t.Fatal("incremental import duplicated history or lost the selected window")
}
}
func TestExpiredConsentIsVisibleBeforeNextScheduledSync(t *testing.T) {
a, s := testApp(t)
account := s.Data.Accounts[0]
@@ -73,6 +150,9 @@ func TestExpiredConsentIsVisibleBeforeNextScheduledSync(t *testing.T) {
if len(after.Connections) != 1 || after.Connections[0].Status != "reconnect_required" || after.Connections[0].Institution != "ING" {
t.Fatalf("missing bank reconnect status: %+v", after.Connections)
}
if after.Connections[0].HistoryMonths != 12 {
t.Fatal("legacy consent did not retain the default reconnect history")
}
}
func TestRenewedConsentWakesSchedulerAndAutomaticallyImports(t *testing.T) {
a, s := testApp(t)
@@ -81,7 +161,7 @@ func TestRenewedConsentWakesSchedulerAndAutomaticallyImports(t *testing.T) {
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "renewed_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}, fetched: make(chan struct{}, 1)}
a.bank = b
a.ops.LastSync = time.Now().UTC().Format(time.RFC3339)
a.authStates["state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE"}
a.authStates["state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE", HistoryMonths: 12}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { defer close(done); a.RunScheduler(ctx) }()
+10 -4
View File
@@ -92,9 +92,12 @@ func (a *App) ImportCSV(ctx context.Context, rev, accountID string, r io.Reader)
}
return ImportResult{}, errors.New("unknown account")
}
func (a *App) Authorize(ctx context.Context, institution, country string) (string, error) {
func (a *App) Authorize(ctx context.Context, institution, country string, historyMonths int) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if historyMonths < 1 || historyMonths > 120 {
return "", errors.New("history_months must be an integer between 1 and 120")
}
if a.bank == nil {
return "", errors.New("Enable Banking is not configured")
}
@@ -114,7 +117,7 @@ func (a *App) Authorize(ctx context.Context, institution, country string) (strin
state := domain.NewID("auth")
url, err := a.bank.Authorize(ctx, institution, country, state)
if err == nil {
a.authStates[state] = authorization{time.Now().Add(15 * time.Minute), institution, country}
a.authStates[state] = authorization{Expires: time.Now().Add(15 * time.Minute), Institution: institution, Country: country, HistoryMonths: historyMonths}
}
return url, err
}
@@ -165,7 +168,7 @@ func (a *App) Callback(ctx context.Context, code, state string) error {
return err
}
a.ops.Sessions = append(a.ops.Sessions, session)
a.ops.Consents[session.ID] = Consent{Institution: auth.Institution, Country: auth.Country}
a.ops.Consents[session.ID] = Consent{Institution: auth.Institution, Country: auth.Country, HistoryMonths: auth.HistoryMonths}
if err = a.saveOps(); err != nil {
return err
}
@@ -301,9 +304,12 @@ func (a *App) Sync(ctx context.Context) (State, error) {
failures = append(failures, account.DisplayName+": bank connection unavailable")
continue
}
from := now.AddDate(0, 0, -90).Format("2006-01-02")
var from string
if last, e := time.Parse(time.RFC3339, a.ops.AccountSync[account.ID]); e == nil {
from = last.AddDate(0, 0, -14).Format("2006-01-02")
} else {
months := a.ops.Consents[accountSession[account.ID]].historyMonths()
from = now.AddDate(0, -months, 0).Format("2006-01-02")
}
facts, e := a.bank.Transactions(ctx, account, from, to)
if e != nil {
+7 -1
View File
@@ -81,12 +81,15 @@ func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T)
t.Fatal(err)
}
a.ops.Sessions = []banking.Session{{ID: "old_session", Accounts: []domain.Account{account}}}
a.ops.Consents["old_session"] = Consent{Institution: "N26", Country: "DE", HistoryMonths: 24}
cursor := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
a.ops.AccountSync[account.ID] = cursor
renewed := account
renewed.ID = "provider_local_id"
renewed.ExternalAccountID = "new_uid"
renewed.DisplayName = "Bank-generated name"
a.bank = &bankScenario{session: banking.Session{ID: "new_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{renewed}}}
a.authStates["one_time_state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE"}
a.authStates["one_time_state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE", HistoryMonths: 24}
if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err != nil {
t.Fatal(err)
}
@@ -100,6 +103,9 @@ func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T)
if len(after.Sessions) != 1 || after.Sessions[0].ID != "new_session" {
t.Fatal("expired session remains active after reconnect")
}
if a.ops.AccountSync[account.ID] != cursor || after.Connections[0].HistoryMonths != 24 {
t.Fatal("reconnect reset the account cursor or lost the history choice")
}
if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err == nil {
t.Fatal("authorization state replay was accepted")
}