diff --git a/OPERATIONS.txt b/OPERATIONS.txt
index f3f88cc..82afaab 100644
--- a/OPERATIONS.txt
+++ b/OPERATIONS.txt
@@ -196,18 +196,24 @@ one-time code and state, not a reusable API key. Go verifies state, exchanges th
code for a session_id, and persists session details locally with mode 0600.
Accounts -> Connect: enter the exact Enable Banking institution name and
-country code (DE for Germany), then authorize through the bank. A successful
-callback immediately wakes the synchronization worker; Sync now is also available.
+country code (DE for Germany), then choose History to import (months).
+The default is 12; whole numbers from 1 to 120 are accepted. Authorize through
+the bank to save this choice with the consent. A successful callback immediately
+wakes the synchronization worker; Sync now is also available.
The dashboard shows, for example, \"ING needs reconnection\" when consent expires
or is revoked. Reconnect ING starts the same approval flow with that bank and
country already selected. The new consent replaces the old account bindings
without duplicating local accounts or their financial history. Transient provider
errors are displayed separately from expired consent.
-Only booked transactions are persisted. Daily sync deliberately overlaps each
-account's last successful sync by 14 days; each new account first requests 90 days.
-Per-account cursors prevent newly connected or reactivated accounts losing history
-because another account synced recently. Older records can be imported using CSV.
+Only booked transactions are persisted. Each new account initially requests the
+selected number of calendar months (12 by default); the bank may provide less.
+Daily sync deliberately overlaps each account's last successful sync by 14 days.
+Per-account cursors prevent another account's recent sync from skipping a new
+account's history. Reconnection preserves the saved history choice and existing
+cursors. Changing the choice or reconnecting does not backfill already-synced
+accounts. Older records can be imported using CSV. Older saved consents without
+a history choice use 12 months for accounts that have no successful-sync cursor.
A failed provider call retains local data and is retried by the daily scheduler;
Sync now can retry sooner. Balances are fetched
on demand, with exact amount/currency/type values, rather than inferred from an
diff --git a/README.md b/README.md
index f8a4eae..c81c276 100644
--- a/README.md
+++ b/README.md
@@ -134,12 +134,13 @@ Open **Accounts → Connect your bank**:
1. Enter the bank's exact Enable Banking institution name.
2. Set the country to **DE** for a German bank.
-3. Click **Authorize bank**.
-4. Log in on your bank's page and approve account access.
+3. Set **History to import (months)**: **12** by default, or another whole number from **1 to 120**.
+4. Click **Authorize bank**.
+5. Log in on your bank's page and approve account access.
Finance Duck verifies the callback state, exchanges the returned code for a `session_id`, stores the session locally with restrictive permissions, and wakes the synchronization worker immediately. You do not need to copy the code or session ID manually.
-Initial synchronization requests **90 days of booked transactions per account**. Subsequent daily synchronization overlaps each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization; older history can be imported with CSV.
+Initial synchronization requests the selected number of **calendar months of booked transactions per account**, defaulting to **12 months**. The bank may provide less history. The choice is saved with the bank connection and reused on reconnection. Subsequent daily synchronization overlaps each account's last successful sync by **14 days**. **Sync now** starts a manual synchronization. Existing accounts keep their successful-sync cursors: changing the history choice or reconnecting does **not** backfill them. Older history can be imported with CSV.
### Reauthorize expired consent
diff --git a/internal/app/banking_settings_test.go b/internal/app/banking_settings_test.go
index 2df4f24..0c8db5e 100644
--- a/internal/app/banking_settings_test.go
+++ b/internal/app/banking_settings_test.go
@@ -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")
}
}
diff --git a/internal/app/consent.go b/internal/app/consent.go
index a503808..bf46ee2 100644
--- a/internal/app/consent.go
+++ b/internal/app/consent.go
@@ -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
}
diff --git a/internal/app/consent_test.go b/internal/app/consent_test.go
index 967cf1a..64f37c0 100644
--- a/internal/app/consent_test.go
+++ b/internal/app/consent_test.go
@@ -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) }()
diff --git a/internal/app/import.go b/internal/app/import.go
index 3cc5327..2ebe846 100644
--- a/internal/app/import.go
+++ b/internal/app/import.go
@@ -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 {
diff --git a/internal/app/sync_test.go b/internal/app/sync_test.go
index ec72e51..fa4edfb 100644
--- a/internal/app/sync_test.go
+++ b/internal/app/sync_test.go
@@ -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")
}
diff --git a/internal/server/server.go b/internal/server/server.go
index 7d8d94a..38913e8 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -346,13 +346,14 @@ func (s *Server) bankingSettings(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) authorize(w http.ResponseWriter, r *http.Request) {
var b struct {
- Institution string `json:"institution"`
- Country string `json:"country"`
+ Institution string `json:"institution"`
+ Country string `json:"country"`
+ HistoryMonths int `json:"history_months"`
}
if !decode(w, r, &b) {
return
}
- v, e := s.app.Authorize(r.Context(), b.Institution, b.Country)
+ v, e := s.app.Authorize(r.Context(), b.Institution, b.Country, b.HistoryMonths)
respond(w, map[string]string{"url": v}, e)
}
func (s *Server) callback(w http.ResponseWriter, r *http.Request) {
diff --git a/web/src/Accounts.tsx b/web/src/Accounts.tsx
index 8258a84..bfc0a61 100644
--- a/web/src/Accounts.tsx
+++ b/web/src/Accounts.tsx
@@ -157,10 +157,15 @@ export function Accounts({
>
);
}
-async function authorize(institution: string, country: string) {
+async function authorize(
+ institution: string,
+ country: string,
+ historyMonths: number,
+) {
const response = await request<{ url: string }>("/api/banking/authorize", {
institution,
country,
+ history_months: historyMonths,
});
const url = new URL(response.url);
if (url.protocol !== "https:")
@@ -218,6 +223,12 @@ function AccountCard({
{connection?.valid_until && (
Authorization expires {connection.valid_until}
)}
+ {connection && connection.status !== "local" && (
+
+ Initial history: {connection.history_months}{" "}
+ {connection.history_months === 1 ? "month" : "months"}
+
+ )}
{connection?.error && (
{connection.error}
)}
@@ -229,7 +240,11 @@ function AccountCard({
setConnecting(true);
onError("");
try {
- await authorize(institution, connection.country || "DE");
+ await authorize(
+ institution,
+ connection.country || "DE",
+ connection.history_months,
+ );
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setConnecting(false);
@@ -406,6 +421,7 @@ function ConnectForm({
}) {
const [institution, setInstitution] = useState("");
const [country, setCountry] = useState("DE");
+ const [historyMonths, setHistoryMonths] = useState("12");
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const callback =
@@ -462,10 +478,11 @@ function ConnectForm({
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
+ if (!e.currentTarget.reportValidity()) return;
setBusy(true);
onError("");
try {
- await authorize(institution.trim(), country);
+ await authorize(institution.trim(), country, Number(historyMonths));
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setBusy(false);
@@ -492,6 +509,20 @@ function ConnectForm({
onChange={(e) => setCountry(e.target.value.toUpperCase())}
/>
+
Add your Enable Banking application ID and private key in{" "} diff --git a/web/src/api.ts b/web/src/api.ts index fbdb649..be539e4 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -68,6 +68,7 @@ export interface Connection { account_id: string; institution: string; country: string; + history_months: number; status: "local" | "connected" | "reconnect_required" | "error"; valid_until: string; error: string;