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. code for a session_id, and persists session details locally with mode 0600.
Accounts -> Connect: enter the exact Enable Banking institution name and Accounts -> Connect: enter the exact Enable Banking institution name and
country code (DE for Germany), then authorize through the bank. A successful country code (DE for Germany), then choose History to import (months).
callback immediately wakes the synchronization worker; Sync now is also available. 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 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 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 country already selected. The new consent replaces the old account bindings
without duplicating local accounts or their financial history. Transient provider without duplicating local accounts or their financial history. Transient provider
errors are displayed separately from expired consent. errors are displayed separately from expired consent.
Only booked transactions are persisted. Daily sync deliberately overlaps each Only booked transactions are persisted. Each new account initially requests the
account's last successful sync by 14 days; each new account first requests 90 days. selected number of calendar months (12 by default); the bank may provide less.
Per-account cursors prevent newly connected or reactivated accounts losing history Daily sync deliberately overlaps each account's last successful sync by 14 days.
because another account synced recently. Older records can be imported using CSV. 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; A failed provider call retains local data and is retried by the daily scheduler;
Sync now can retry sooner. Balances are fetched Sync now can retry sooner. Balances are fetched
on demand, with exact amount/currency/type values, rather than inferred from an 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. 1. Enter the bank's exact Enable Banking institution name.
2. Set the country to **DE** for a German bank. 2. Set the country to **DE** for a German bank.
3. Click **Authorize bank**. 3. Set **History to import (months)**: **12** by default, or another whole number from **1 to 120**.
4. Log in on your bank's page and approve account access. 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. 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 ### 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.BaseURL = mock.URL
provider.HTTPClient = mock.Client() 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) t.Fatal(err)
} }
return pending return pending
@@ -244,7 +244,7 @@ func TestBankingSavedCredentialsAndDisableOverrideEnvironment(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
a = reopenBankingApp(t, a) 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") t.Fatal("disabled saved configuration fell back to environment")
} }
} }
+23 -10
View File
@@ -8,30 +8,42 @@ import (
"finance-duck/internal/domain" "finance-duck/internal/domain"
) )
const defaultHistoryMonths = 12
type authorization struct { type authorization struct {
Expires time.Time Expires time.Time
Institution string Institution string
Country string Country string
HistoryMonths int
} }
type Consent struct { type Consent struct {
Institution string `json:"institution"` Institution string `json:"institution"`
Country string `json:"country"` Country string `json:"country"`
HistoryMonths int `json:"history_months"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
NeedsReconnect bool `json:"needs_reconnect"` NeedsReconnect bool `json:"needs_reconnect"`
} }
type Connection struct { type Connection struct {
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
Institution string `json:"institution"` Institution string `json:"institution"`
Country string `json:"country"` Country string `json:"country"`
Status string `json:"status"` HistoryMonths int `json:"history_months"`
ValidUntil string `json:"valid_until"` Status string `json:"status"`
Error string `json:"error"` 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 { func (a *App) connections(d domain.Dataset) []Connection {
out := make([]Connection, 0, len(d.Accounts)) out := make([]Connection, 0, len(d.Accounts))
for _, account := range 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 != "" { if account.ExternalAccountID != "" {
c.Status = "reconnect_required" c.Status = "reconnect_required"
c.Error = "No saved bank consent; reconnect this account" c.Error = "No saved bank consent; reconnect this account"
@@ -42,6 +54,7 @@ func (a *App) connections(d domain.Dataset) []Connection {
continue continue
} }
meta := a.ops.Consents[session.ID] meta := a.ops.Consents[session.ID]
c.HistoryMonths = meta.historyMonths()
if meta.Institution != "" { if meta.Institution != "" {
c.Institution = meta.Institution c.Institution = meta.Institution
} }
+84 -4
View File
@@ -11,12 +11,22 @@ import (
type historyBank struct { type historyBank struct {
bankScenario 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) { 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 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") date := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02")
if date >= from && date <= to { 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}) 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) t.Fatal(err)
} }
session := banking.Session{ID: "consent", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: s.Data.Accounts} 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.Sessions = []banking.Session{session}
a.ops.LastSync = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339) a.ops.LastSync = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
a.ops.AccountSync[old.ID] = a.ops.LastSync 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()) after, err := a.Sync(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -56,6 +72,67 @@ func TestNewAccountImportsHistoryIndependentOfExistingSyncCursor(t *testing.T) {
t.Fatalf("new account history skipped: %v", counts) 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) { func TestExpiredConsentIsVisibleBeforeNextScheduledSync(t *testing.T) {
a, s := testApp(t) a, s := testApp(t)
account := s.Data.Accounts[0] 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" { 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) 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) { func TestRenewedConsentWakesSchedulerAndAutomaticallyImports(t *testing.T) {
a, s := testApp(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)} 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.bank = b
a.ops.LastSync = time.Now().UTC().Format(time.RFC3339) 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()) ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{}) done := make(chan struct{})
go func() { defer close(done); a.RunScheduler(ctx) }() 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") 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() a.mu.Lock()
defer a.mu.Unlock() 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 { if a.bank == nil {
return "", errors.New("Enable Banking is not configured") 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") state := domain.NewID("auth")
url, err := a.bank.Authorize(ctx, institution, country, state) url, err := a.bank.Authorize(ctx, institution, country, state)
if err == nil { 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 return url, err
} }
@@ -165,7 +168,7 @@ func (a *App) Callback(ctx context.Context, code, state string) error {
return err return err
} }
a.ops.Sessions = append(a.ops.Sessions, session) 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 { if err = a.saveOps(); err != nil {
return err return err
} }
@@ -301,9 +304,12 @@ func (a *App) Sync(ctx context.Context) (State, error) {
failures = append(failures, account.DisplayName+": bank connection unavailable") failures = append(failures, account.DisplayName+": bank connection unavailable")
continue 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 { if last, e := time.Parse(time.RFC3339, a.ops.AccountSync[account.ID]); e == nil {
from = last.AddDate(0, 0, -14).Format("2006-01-02") 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) facts, e := a.bank.Transactions(ctx, account, from, to)
if e != nil { if e != nil {
+7 -1
View File
@@ -81,12 +81,15 @@ func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T)
t.Fatal(err) t.Fatal(err)
} }
a.ops.Sessions = []banking.Session{{ID: "old_session", Accounts: []domain.Account{account}}} 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 := account
renewed.ID = "provider_local_id" renewed.ID = "provider_local_id"
renewed.ExternalAccountID = "new_uid" renewed.ExternalAccountID = "new_uid"
renewed.DisplayName = "Bank-generated name" 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.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 { if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -100,6 +103,9 @@ func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T)
if len(after.Sessions) != 1 || after.Sessions[0].ID != "new_session" { if len(after.Sessions) != 1 || after.Sessions[0].ID != "new_session" {
t.Fatal("expired session remains active after reconnect") 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 { if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err == nil {
t.Fatal("authorization state replay was accepted") 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) { func (s *Server) authorize(w http.ResponseWriter, r *http.Request) {
var b struct { var b struct {
Institution string `json:"institution"` Institution string `json:"institution"`
Country string `json:"country"` Country string `json:"country"`
HistoryMonths int `json:"history_months"`
} }
if !decode(w, r, &b) { if !decode(w, r, &b) {
return 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) respond(w, map[string]string{"url": v}, e)
} }
func (s *Server) callback(w http.ResponseWriter, r *http.Request) { 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", { const response = await request<{ url: string }>("/api/banking/authorize", {
institution, institution,
country, country,
history_months: historyMonths,
}); });
const url = new URL(response.url); const url = new URL(response.url);
if (url.protocol !== "https:") if (url.protocol !== "https:")
@@ -218,6 +223,12 @@ function AccountCard({
{connection?.valid_until && ( {connection?.valid_until && (
<small>Authorization expires {connection.valid_until}</small> <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 && ( {connection?.error && (
<small className="text-danger">{connection.error}</small> <small className="text-danger">{connection.error}</small>
)} )}
@@ -229,7 +240,11 @@ function AccountCard({
setConnecting(true); setConnecting(true);
onError(""); onError("");
try { try {
await authorize(institution, connection.country || "DE"); await authorize(
institution,
connection.country || "DE",
connection.history_months,
);
} catch (err) { } catch (err) {
onError(err instanceof Error ? err.message : String(err)); onError(err instanceof Error ? err.message : String(err));
setConnecting(false); setConnecting(false);
@@ -406,6 +421,7 @@ function ConnectForm({
}) { }) {
const [institution, setInstitution] = useState(""); const [institution, setInstitution] = useState("");
const [country, setCountry] = useState("DE"); const [country, setCountry] = useState("DE");
const [historyMonths, setHistoryMonths] = useState("12");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const callback = const callback =
@@ -462,10 +478,11 @@ function ConnectForm({
className="form-body" className="form-body"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
if (!e.currentTarget.reportValidity()) return;
setBusy(true); setBusy(true);
onError(""); onError("");
try { try {
await authorize(institution.trim(), country); await authorize(institution.trim(), country, Number(historyMonths));
} catch (err) { } catch (err) {
onError(err instanceof Error ? err.message : String(err)); onError(err instanceof Error ? err.message : String(err));
setBusy(false); setBusy(false);
@@ -492,6 +509,20 @@ function ConnectForm({
onChange={(e) => setCountry(e.target.value.toUpperCase())} onChange={(e) => setCountry(e.target.value.toUpperCase())}
/> />
</Field> </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 && ( {!state.status.banking_configured && (
<p className="muted small"> <p className="muted small">
Add your Enable Banking application ID and private key in{" "} Add your Enable Banking application ID and private key in{" "}
+1
View File
@@ -68,6 +68,7 @@ export interface Connection {
account_id: string; account_id: string;
institution: string; institution: string;
country: string; country: string;
history_months: number;
status: "local" | "connected" | "reconnect_required" | "error"; status: "local" | "connected" | "reconnect_required" | "error";
valid_until: string; valid_until: string;
error: string; error: string;