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
+12 -6
View File
@@ -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
+4 -3
View File
@@ -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
+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")
}
+4 -3
View File
@@ -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) {
+34 -3
View File
@@ -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 && (
<small>Authorization expires {connection.valid_until}</small>
)}
{connection && connection.status !== "local" && (
<small>
Initial history: {connection.history_months}{" "}
{connection.history_months === 1 ? "month" : "months"}
</small>
)}
{connection?.error && (
<small className="text-danger">{connection.error}</small>
)}
@@ -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())}
/>
</Field>
<Field
label="History to import (months)"
hint="Initial history for newly synced accounts (1120 calendar months). Your bank may provide less. Existing accounts keep their sync cursors; this does not backfill them."
>
<input
type="number"
required
min={1}
max={120}
step={1}
value={historyMonths}
onChange={(e) => setHistoryMonths(e.target.value)}
/>
</Field>
{!state.status.banking_configured && (
<p className="muted small">
Add your Enable Banking application ID and private key in{" "}
+1
View File
@@ -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;