Configure initial bank history in the connection UI
This commit is contained in:
+12
-6
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 (1–120 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{" "}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user